From c37f2ce54eb78d25a2382d805a50bf0cf2f0d784 Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:50:57 +0330 Subject: [PATCH 01/75] Update tauri version --- Cargo.lock | 645 ++++++++++++++++++++++++----------- Cargo.toml | 2 +- apps/desktop-gpui/Cargo.toml | 2 +- apps/desktop/package.json | 4 +- bun.lock | 46 ++- 5 files changed, 486 insertions(+), 213 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e6a9faee11..78909e91399 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,7 +57,7 @@ dependencies = [ "bytestring", "derive_more 2.0.1", "encoding_rs", - "foldhash", + "foldhash 0.1.5", "futures-core", "http 0.2.12", "httparse", @@ -155,7 +155,7 @@ dependencies = [ "cfg-if", "derive_more 2.0.1", "encoding_rs", - "foldhash", + "foldhash 0.1.5", "futures-core", "futures-util", "impl-more", @@ -416,7 +416,7 @@ dependencies = [ "clipboard-win", "image 0.25.8", "log", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-core-graphics", @@ -856,6 +856,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.0" @@ -1085,7 +1091,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.5", ] [[package]] @@ -1288,7 +1294,7 @@ dependencies = [ "libc", "open", "relative-path", - "reqwest", + "reqwest 0.12.24", "rmcp", "rpassword", "scap-screencapturekit", @@ -1315,7 +1321,7 @@ dependencies = [ name = "cap-api" version = "0.0.0" dependencies = [ - "reqwest", + "reqwest 0.12.24", "workspace-hack", ] @@ -1493,7 +1499,7 @@ name = "cap-cursor-info" version = "0.0.0" dependencies = [ "hex", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "serde", "sha2", @@ -1571,7 +1577,7 @@ dependencies = [ "nix 0.29.0", "notify-rust", "objc", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", "opentelemetry", @@ -1583,7 +1589,7 @@ dependencies = [ "rand 0.8.5", "regex", "relative-path", - "reqwest", + "reqwest 0.12.24", "rodio", "sanitize-filename", "scap-direct3d", @@ -1952,7 +1958,7 @@ dependencies = [ "libc", "libproc", "objc", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "parking_lot", "pipewire", @@ -2034,7 +2040,7 @@ dependencies = [ "libloading 0.8.8", "log", "metal 0.31.0", - "objc2 0.6.2", + "objc2 0.6.5", "pollster", "pretty_assertions", "rayon", @@ -2476,7 +2482,7 @@ checksum = "afede46921767868c5c7f8f55202bdd8bec0bab6bc9605174200f45924f93c62" dependencies = [ "clipboard-win", "image 0.25.8", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", "windows 0.59.0", @@ -3049,6 +3055,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -3061,12 +3080,12 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.106", + "ctor-proc-macro", + "dtor 0.3.0", ] [[package]] @@ -3075,9 +3094,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" dependencies = [ - "dtor", + "dtor 0.8.1", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "cursor-icon" version = "1.2.0" @@ -3180,6 +3205,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + [[package]] name = "debugid" version = "0.8.0" @@ -3408,7 +3444,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.1", "libc", - "objc2 0.6.2", + "objc2 0.6.5", ] [[package]] @@ -3472,6 +3508,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.1", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -3565,12 +3616,27 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + [[package]] name = "dtor" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -3930,6 +3996,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "font-types" version = "0.9.0" @@ -4496,7 +4568,7 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "once_cell", "serde", @@ -4721,7 +4793,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -4812,10 +4884,20 @@ checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" dependencies = [ "log", "mac", - "markup5ever", + "markup5ever 0.14.1", "match_token", ] +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + [[package]] name = "http" version = "0.2.12" @@ -4987,9 +5069,9 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", "png 0.17.16", @@ -5336,16 +5418,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" -[[package]] -name = "iri-string" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-docker" version = "0.2.0" @@ -5519,11 +5591,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.80" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -5642,10 +5715,10 @@ version = "0.8.8-speedreader" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ - "cssparser", - "html5ever", + "cssparser 0.29.6", + "html5ever 0.29.1", "indexmap 2.11.4", - "selectors", + "selectors 0.24.0", ] [[package]] @@ -5739,6 +5812,15 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libfuzzer-sys" version = "0.4.10" @@ -5970,7 +6052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "119c8490084af61b44c9eda9d626475847a186737c0378c85e32d77c33a01cd4" dependencies = [ "cc", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-foundation 0.3.1", "time", ] @@ -6028,9 +6110,20 @@ dependencies = [ "log", "phf 0.11.3", "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.1", + "web_atoms", ] [[package]] @@ -6336,23 +6429,23 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", "gtk", "keyboard-types", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", "once_cell", - "png 0.17.16", + "png 0.18.0", "serde", "thiserror 2.0.16", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6765,9 +6858,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -6798,7 +6891,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.1", "libc", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-cloud-kit 0.3.1", "objc2-core-data 0.3.1", "objc2-core-foundation", @@ -6817,7 +6910,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.1", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-avf-audio", "objc2-core-foundation", "objc2-core-graphics", @@ -6834,7 +6927,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfc1d11521c211a7ebe17739fc806719da41f56c6b3f949d9861b459188ce910" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.5", "objc2-foundation 0.3.1", ] @@ -6847,7 +6940,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", ] @@ -6858,7 +6951,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-foundation 0.3.1", ] @@ -6880,7 +6973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" dependencies = [ "dispatch2", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -6892,7 +6985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.2", + "objc2 0.6.5", ] [[package]] @@ -6914,7 +7007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-foundation 0.3.1", ] @@ -6926,7 +7019,7 @@ checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ "bitflags 2.13.1", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.5", ] [[package]] @@ -6937,7 +7030,7 @@ checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ "bitflags 2.13.1", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -6960,7 +7053,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.5", "objc2-foundation 0.3.1", ] @@ -6976,6 +7069,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.1", +] + [[package]] name = "objc2-core-media" version = "0.3.1" @@ -6984,7 +7087,7 @@ checksum = "f0b7afa6822e2fa20dfc88d10186b2432bf8560b5ed73ec9d31efd78277bc878" dependencies = [ "bitflags 2.13.1", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -6998,7 +7101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1989c3e76c7e978cab0ba9e6f4961cd00ed14ca21121444cc26877403bfb6303" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -7041,7 +7144,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.1", "libc", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -7062,17 +7165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-javascript-core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a" -dependencies = [ - "objc2 0.6.2", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -7107,7 +7200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", ] @@ -7132,19 +7225,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.2", - "objc2-foundation 0.3.1", -] - -[[package]] -name = "objc2-security" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-core-foundation", + "objc2-foundation 0.3.1", ] [[package]] @@ -7169,13 +7252,13 @@ dependencies = [ "objc2-cloud-kit 0.2.2", "objc2-core-data 0.2.2", "objc2-core-image 0.2.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", "objc2-link-presentation", "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", - "objc2-user-notifications", + "objc2-user-notifications 0.2.2", ] [[package]] @@ -7185,9 +7268,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.2", + "block2 0.6.1", + "objc2 0.6.5", + "objc2-cloud-kit 0.3.1", + "objc2-core-data 0.3.1", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.1", + "objc2-core-location 0.3.1", "objc2-foundation 0.3.1", + "objc2-quartz-core 0.3.1", + "objc2-user-notifications 0.3.1", ] [[package]] @@ -7210,10 +7301,20 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3f5ec77a81d9e0c5a0b32159b0cb143d7086165e79708351e02bf37dfc65cd" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.1", +] + [[package]] name = "objc2-web-kit" version = "0.3.1" @@ -7222,12 +7323,10 @@ checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" dependencies = [ "bitflags 2.13.1", "block2 0.6.1", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", - "objc2-javascript-core", - "objc2-security", ] [[package]] @@ -7402,7 +7501,7 @@ dependencies = [ "bytes", "http 1.3.1", "opentelemetry", - "reqwest", + "reqwest 0.12.24", ] [[package]] @@ -7417,7 +7516,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest", + "reqwest 0.12.24", "thiserror 2.0.16", "tracing", ] @@ -7565,7 +7664,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.5", "objc2-foundation 0.3.1", "objc2-osa-kit", "serde", @@ -7732,10 +7831,20 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.8.0" @@ -7756,6 +7865,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.8.0" @@ -7786,6 +7905,16 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.10.0" @@ -7802,12 +7931,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.106", @@ -7840,6 +7969,15 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -8748,11 +8886,45 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + [[package]] name = "resize" version = "0.8.8" @@ -8803,7 +8975,7 @@ dependencies = [ "gtk-sys", "js-sys", "log", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -9159,7 +9331,7 @@ dependencies = [ "clap", "futures", "inquire", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", "scap-targets", @@ -9388,14 +9560,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", - "cssparser", + "cssparser 0.29.6", "derive_more 0.99.20", "fxhash", "log", "phf 0.8.0", "phf_codegen 0.8.0", "precomputed-hash", - "servo_arc", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser 0.36.0", + "derive_more 2.0.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash 2.1.1", + "servo_arc 0.4.3", "smallvec", ] @@ -9432,7 +9623,7 @@ checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" dependencies = [ "httpdate", "native-tls", - "reqwest", + "reqwest 0.12.24", "sentry-actix", "sentry-anyhow", "sentry-backtrace", @@ -9750,6 +9941,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -10207,6 +10407,18 @@ dependencies = [ "serde", ] +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + [[package]] name = "string_cache_codegen" version = "0.5.4" @@ -10219,6 +10431,18 @@ dependencies = [ "quote", ] +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -10383,6 +10607,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -10489,33 +10724,35 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.3" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2 0.6.1", "core-foundation 0.10.1", - "core-graphics 0.24.0", + "core-graphics 0.25.0", "crossbeam-channel", - "dispatch", + "dbus", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni 0.21.1", - "lazy_static", "libc", "log", "ndk 0.9.0", - "ndk-context", "ndk-sys 0.6.0+11769913", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", + "objc2-ui-kit 0.3.1", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -10561,9 +10798,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tauri" -version = "2.8.5" +version = "2.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c" +checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4" dependencies = [ "anyhow", "bytes", @@ -10583,7 +10820,7 @@ dependencies = [ "log", "mime", "muda", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", "objc2-ui-kit 0.3.1", @@ -10591,7 +10828,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.5", "serde", "serde_json", "serde_repr", @@ -10607,7 +10844,6 @@ dependencies = [ "tokio", "tray-icon", "url", - "urlpattern", "webkit2gtk", "webview2-com", "window-vibrancy", @@ -10616,9 +10852,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.4.1" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -10632,15 +10868,14 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.7", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.4.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -10665,9 +10900,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.4.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -10812,7 +11047,7 @@ dependencies = [ "data-url", "http 1.3.1", "regex", - "reqwest", + "reqwest 0.12.24", "schemars 0.8.22", "serde", "serde_json", @@ -11008,7 +11243,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.12.24", "semver", "serde", "serde_json", @@ -11041,16 +11276,16 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.8.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", "gtk", "http 1.3.1", "jni 0.21.1", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-ui-kit 0.3.1", "objc2-web-kit", "raw-window-handle", @@ -11066,17 +11301,16 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.8.1" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http 1.3.1", "jni 0.21.1", "log", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", - "objc2-foundation 0.3.1", "once_cell", "percent-encoding", "raw-window-handle", @@ -11121,24 +11355,26 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.7.0" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", "cargo_metadata", - "ctor 0.2.9", + "ctor 0.8.0", + "dom_query", "dunce", "glob", - "html5ever", + "html5ever 0.29.1", "http 1.3.1", "infer", "json-patch", "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf 0.13.1", + "plist", "proc-macro2", "quote", "regex", @@ -11150,7 +11386,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.16", - "toml 0.9.7", + "toml 1.1.0+spec-1.1.0", "url", "urlpattern", "uuid", @@ -11203,6 +11439,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -11700,20 +11945,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.1", "bytes", "futures-util", "http 1.3.1", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -11845,24 +12090,24 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs 6.0.0", "libappindicator", "muda", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.1", "once_cell", - "png 0.17.16", + "png 0.18.0", "serde", "thiserror 2.0.16", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -12388,9 +12633,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.103" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -12399,38 +12644,22 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.106", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.53" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ - "cfg-if", "js-sys", - "once_cell", + "tokio", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.103" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -12438,22 +12667,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.103" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.106", - "wasm-bindgen-backend", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.103" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -12471,6 +12700,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wayland-backend" version = "0.3.11" @@ -12582,9 +12824,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.80" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -12600,11 +12842,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + [[package]] name = "webkit2gtk" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ "bitflags 1.3.2", "cairo-rs", @@ -12626,9 +12880,9 @@ dependencies = [ [[package]] name = "webkit2gtk-sys" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", @@ -12928,7 +13182,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -13747,7 +14001,7 @@ dependencies = [ "num-complex", "num-integer", "num-rational", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "open", @@ -13762,7 +14016,7 @@ dependencies = [ "regex", "regex-automata", "regex-syntax", - "reqwest", + "reqwest 0.12.24", "rgb", "rustc-hash 1.1.0", "rustix 0.38.44", @@ -13813,27 +14067,26 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "wry" -version = "0.53.3" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f0e9642a0d061f6236c54ccae64c2722a7879ad4ec7dff59bd376d446d8e90" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2 0.6.1", "cookie", "crossbeam-channel", "dirs 6.0.0", + "dom_query", "dpi", "dunce", "gdkx11", "gtk", - "html5ever", "http 1.3.1", "javascriptcore-rs", "jni 0.21.1", - "kuchikiki", "libc", "ndk 0.9.0", - "objc2 0.6.2", + "objc2 0.6.5", "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -14238,3 +14491,7 @@ dependencies = [ "syn 2.0.106", "winnow 1.0.3", ] + +[[patch.unused]] +name = "tao" +version = "0.34.3" diff --git a/Cargo.toml b/Cargo.toml index a65433c7cc6..912b13aa224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ tokio = { version = "1.39.3", features = [ "rt-multi-thread", "time", ] } -tauri = { version = "2.5.0", features = ["specta"] } +tauri = { version = "2.11.5", features = ["specta"] } specta = { version = "=2.0.0-rc.20", features = [ "derive", "serde_json", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 50fccce29ad..df87c1e3df4 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -204,7 +204,7 @@ x11rb = "0.13" [target.'cfg(target_os = "macos")'.dependencies] # Native window treatment (panel levels, Spaces) gpui does not expose. Same # versions gpui's own tree compiles, so no second objc2 universe gets built. -objc2 = "0.5.2" +objc2 = { version = "0.5.2", features = ["exception"] } # `NSVisualEffectView` is the pre-macOS-26 window-material fallback. Features # only -- the versions stay pinned, or a second objc2 universe gets built. objc2-app-kit = { version = "0.2.2", features = ["NSAppearance", "NSWindow", "NSView", "NSResponder", "NSGraphics", "NSPasteboard", "NSVisualEffectView"] } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1b7ebd5e966..fca2b4f5189 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,7 +45,7 @@ "@solidjs/router": "^0.14.2", "@solidjs/start": "^1.1.3", "@tanstack/solid-query": "^5.51.21", - "@tauri-apps/api": "2.8.0", + "@tauri-apps/api": "2.11.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.0", "@tauri-apps/plugin-deep-link": "^2.4.1", "@tauri-apps/plugin-dialog": "2.4.2", @@ -82,7 +82,7 @@ "@solid-devtools/overlay": "^0.33.5", "@tailwindcss/postcss": "^4.2.2", "@tailwindcss/typography": "^0.5.9", - "@tauri-apps/cli": "2.8.4", + "@tauri-apps/cli": "2.11.0", "@total-typescript/ts-reset": "^0.6.1", "@types/dom-webcodecs": "^0.1.11", "@types/uuid": "^9.0.8", diff --git a/bun.lock b/bun.lock index d8e8c7cddb2..c16651409a3 100644 --- a/bun.lock +++ b/bun.lock @@ -82,7 +82,7 @@ "@solidjs/router": "^0.14.2", "@solidjs/start": "^1.1.3", "@tanstack/solid-query": "^5.51.21", - "@tauri-apps/api": "2.8.0", + "@tauri-apps/api": "2.12.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.0", "@tauri-apps/plugin-deep-link": "^2.4.1", "@tauri-apps/plugin-dialog": "2.4.2", @@ -119,7 +119,7 @@ "@solid-devtools/overlay": "^0.33.5", "@tailwindcss/postcss": "^4.2.2", "@tailwindcss/typography": "^0.5.9", - "@tauri-apps/cli": "2.8.4", + "@tauri-apps/cli": "2.12.0", "@total-typescript/ts-reset": "^0.6.1", "@types/dom-webcodecs": "^0.1.11", "@types/uuid": "^9.0.8", @@ -2399,31 +2399,31 @@ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], - "@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + "@tauri-apps/api": ["@tauri-apps/api@2.12.0", "", {}, "sha512-fUSxFX8VABYe9W9K5ROdEaKVkNUFRcMSRofjruvaxztXA+I0XnQHcYHF8m/a6iExRUxsUf0HsFWhPBuH9IgzJw=="], - "@tauri-apps/cli": ["@tauri-apps/cli@2.8.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.8.4", "@tauri-apps/cli-darwin-x64": "2.8.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.8.4", "@tauri-apps/cli-linux-arm64-gnu": "2.8.4", "@tauri-apps/cli-linux-arm64-musl": "2.8.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.8.4", "@tauri-apps/cli-linux-x64-gnu": "2.8.4", "@tauri-apps/cli-linux-x64-musl": "2.8.4", "@tauri-apps/cli-win32-arm64-msvc": "2.8.4", "@tauri-apps/cli-win32-ia32-msvc": "2.8.4", "@tauri-apps/cli-win32-x64-msvc": "2.8.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-ejUZBzuQRcjFV+v/gdj/DcbyX/6T4unZQjMSBZwLzP/CymEjKcc2+Fc8xTORThebHDUvqoXMdsCZt8r+hyN15g=="], + "@tauri-apps/cli": ["@tauri-apps/cli@2.12.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.12.0", "@tauri-apps/cli-darwin-x64": "2.12.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.0", "@tauri-apps/cli-linux-arm64-gnu": "2.12.0", "@tauri-apps/cli-linux-arm64-musl": "2.12.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.0", "@tauri-apps/cli-linux-x64-gnu": "2.12.0", "@tauri-apps/cli-linux-x64-musl": "2.12.0", "@tauri-apps/cli-win32-arm64-msvc": "2.12.0", "@tauri-apps/cli-win32-ia32-msvc": "2.12.0", "@tauri-apps/cli-win32-x64-msvc": "2.12.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-GIShnAR4C16kSTA8NO02fo8fOw/I5z5sAUEB5S2HlPHFnBkbihL4c9a/MpkxOm1+q5S+tFd3X4QUjHWxfyGzpQ=="], - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.8.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA=="], + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.12.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v5J+jISihLGtih1+3yWagbsL+5dbLZORu4vaSI0vhY0Heb+MAW+T/1s1RyKxrQrwEPGBG5kqwfDDTnSbmVkuqA=="], - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.8.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g=="], + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.12.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-g27I07pfW/dMS8vcNqB9lamgKF0TqLlcy8e7t9qDGsB1iaeyjJWb2TgBF4tM9ZwLqPAZUXbsz28oXljO12WC4g=="], - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.8.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw=="], + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.12.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gXyQ710fNc99YuxFxNyeX9PLSrIpbtgBBj7GQo9tSeRT6fIK40q3/gm7X5jqZo7mWLR2m0R/2ALd3vAWo0Z32Q=="], - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw=="], + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.12.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-H9cyXuq9WgxoHh2NZ+R+oN+W78x5UwaBDEp0/fmqUUkaBoioCuOZErM9xKiIV6bpNAhmw1RoWZ49u37q/vaiAg=="], - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw=="], + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.12.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-5tFuk3JKMBa1M/iN4gN32OYlSfhY3aqb5PO3zCugxaT+sfecyMKpc3EM9VDbhmwWBE9QD4Tfj7wGhlqx2yy2pQ=="], - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.8.4", "", { "os": "linux", "cpu": "none" }, "sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ=="], + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.12.0", "", { "os": "linux", "cpu": "none" }, "sha512-LOrMyV+nb7pKq8P0BZtTBQuyzm1ORjn16R9MlupmxuT5LqxtuYJlw3VMwbaeXCToJ8jbP5u9JnkfmbqDlvETXw=="], - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw=="], + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.12.0", "", { "os": "linux", "cpu": "x64" }, "sha512-aMIkI+X9qvD2+k80QZB3tuOYT41/cEheikC510CwAIZ31KLXORsRASYYzM4mi1/58ICmtzSLFhCC08keir7COw=="], - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA=="], + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.12.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9gl3NhUbfuGhSKjeGCypKSdtoezOQRcDUrkhTWtLQEjnajets6peO6NadgI2DF0OzbiiFOzrnnIGMwBS+Ap6Iw=="], - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.8.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w=="], + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.12.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1W+v86wsYLuyBDN0iDessD4CD9o+yNyO0m8Dp3+/vfjiZrJ/v7R7/aTRBeikaq2tLGgxeag0gxnFggBNBGw91A=="], - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.8.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA=="], + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.12.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SsBjvq4f7YaBGqcWoE3cycoItPf0SrQeqFqWWgfxoeZWAIz01E31LDSeZ2p/etJiIY0R9oi045chkFfb0O7WGw=="], - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.8.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA=="], + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.12.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9zzDKYUH4TKKBgRdDdVQX8fiRO5gJ8hD1nGsHU9DOHG5Jpm3arjbu9Xg1frR20EusG3Sl8X0dppoO/4EoUXUGg=="], "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], @@ -6381,14 +6381,30 @@ "@tanstack/server-functions-plugin/@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "7.29.7", "js-tokens": "4.0.0", "picocolors": "1.1.1" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], + "@tauri-apps/plugin-clipboard-manager/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + "@tauri-apps/plugin-deep-link/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], + "@tauri-apps/plugin-dialog/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + + "@tauri-apps/plugin-fs/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + + "@tauri-apps/plugin-http/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + + "@tauri-apps/plugin-notification/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + "@tauri-apps/plugin-opener/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], + "@tauri-apps/plugin-os/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + + "@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + "@tauri-apps/plugin-shell/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], "@tauri-apps/plugin-store/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], + "@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + "@tufjs/models/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "5.0.7" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "@types/dom-mediacapture-transform/@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], From 1aab84b009323783b0bdf3a235c804d808c6bc23 Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:04:58 +0330 Subject: [PATCH 02/75] feat: rebuild the gpui macOS app menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Cap menu onto AppKit: the model in `menus.rs` gains Check for Update, Preferences…, Help destinations, Open File… and Import Media…, and `platform.rs` patches the standard items after gpui installs the bar — About becomes `orderFrontStandardAboutPanel:` (the gpui action and its panel plumbing are gone), autoenables is off so the model owns the enabled state, and the File menu gets the NSDocumentController-backed Open Recent list fed by `add_recent_document` on every editor open. Check for Update resolves to three states (check / download / restart) and is now rewritten in the live `NSMenuItem` by row position rather than reinstalling the menu bar, so an open menu survives a status change; the version and the macOS download percentage ride an `NSMenuItemBadge` on macOS 14+, and the other platforms only rebuild when the state changes. Bumps the app to objc2 0.6.5 / objc2-app-kit 0.3.2 / block2 0.6 and migrates the native code to those bindings. --- apps/desktop-gpui/Cargo.lock | 190 ++++------- apps/desktop-gpui/Cargo.toml | 51 ++- apps/desktop-gpui/src/app_windows.rs | 4 + apps/desktop-gpui/src/menus.rs | 316 ++++++++++++++++-- apps/desktop-gpui/src/platform.rs | 277 ++++++++++----- .../src/platform/macos_exception_guard.rs | 26 +- .../src/platform/macos_occlusion.rs | 122 ++++--- apps/desktop-gpui/src/platform/windows.rs | 2 - apps/desktop-gpui/src/settings_pages.rs | 2 +- apps/desktop-gpui/src/settings_window.rs | 1 + apps/desktop-gpui/src/snapshot.rs | 8 +- apps/desktop-gpui/src/updates.rs | 16 +- 12 files changed, 703 insertions(+), 312 deletions(-) diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 949e7bbb572..bc4cef361ff 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1184,7 +1184,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1541,7 +1541,7 @@ dependencies = [ "anyhow", "ashpd 0.11.1", "base64 0.22.1", - "block2 0.5.1", + "block2 0.6.2", "bytes", "cap-audio", "cap-camera", @@ -1582,9 +1582,9 @@ dependencies = [ "libc", "lyon_tessellation", "minisign-verify", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "parakeet-rs", "raw-window-handle", "reqwest 0.12.28", @@ -1901,7 +1901,7 @@ dependencies = [ "kameo", "libc", "objc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "parking_lot", "pipewire", @@ -1959,7 +1959,7 @@ dependencies = [ "libloading 0.8.9", "log", "metal 0.31.0", - "objc2 0.6.4", + "objc2 0.6.5", "rayon", "reactive_graph", "resvg 0.45.1", @@ -2314,7 +2314,7 @@ checksum = "afede46921767868c5c7f8f55202bdd8bec0bab6bc9605174200f45924f93c62" dependencies = [ "clipboard-win", "image 0.25.10", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "windows 0.59.0", @@ -3356,7 +3356,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3374,7 +3374,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3592,7 +3592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4428,7 +4428,7 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "once_cell", "thiserror 2.0.20", @@ -4726,7 +4726,7 @@ dependencies = [ "media", "metal 0.33.0", "objc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-user-notifications", @@ -4865,7 +4865,7 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "windows-numerics 0.2.0", - "windows-registry 0.5.3", + "windows-registry", ] [[package]] @@ -5278,7 +5278,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.6.1", + "windows-registry", ] [[package]] @@ -6254,7 +6254,7 @@ checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" dependencies = [ "cc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "time", "uuid", @@ -6567,15 +6567,15 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.2" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" dependencies = [ "crossbeam-channel", "dpi", "gtk", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -6844,7 +6844,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7027,9 +7027,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -7060,7 +7060,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-cloud-kit", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -7081,7 +7081,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-avf-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -7101,7 +7101,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7112,7 +7112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7123,7 +7123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -7135,7 +7135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -7157,7 +7157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7169,7 +7169,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -7180,7 +7180,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.1", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -7203,7 +7203,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7213,7 +7213,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7225,7 +7225,7 @@ checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ "bitflags 2.13.1", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -7239,7 +7239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", ] @@ -7251,7 +7251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -7293,7 +7293,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -7303,7 +7303,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", ] @@ -7325,7 +7325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -7335,7 +7335,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", "objc2-core-media", @@ -7361,7 +7361,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7385,7 +7385,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", @@ -7399,7 +7399,7 @@ checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-cloud-kit", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -7420,7 +7420,7 @@ checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-location", "objc2-foundation 0.3.2", ] @@ -7646,15 +7646,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ordered-float" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" -dependencies = [ - "num-traits", -] - [[package]] name = "ordered-stream" version = "0.2.0" @@ -7699,7 +7690,7 @@ dependencies = [ "android_system_properties", "log", "nix 0.31.3", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "objc2-ui-kit", "serde", @@ -8278,7 +8269,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" dependencies = [ - "toml_edit 0.20.7", + "toml_edit 0.20.2", ] [[package]] @@ -8502,7 +8493,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8745,7 +8736,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -9130,7 +9121,7 @@ dependencies = [ "dispatch2", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -9268,7 +9259,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9411,7 +9402,7 @@ version = "0.1.0" dependencies = [ "cidre", "futures", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "tracing", @@ -10672,7 +10663,7 @@ dependencies = [ "memchr", "ntapi", "rayon", - "windows 0.57.0", + "windows 0.54.0", ] [[package]] @@ -10686,7 +10677,7 @@ dependencies = [ "memchr", "ntapi", "rayon", - "windows 0.57.0", + "windows 0.54.0", ] [[package]] @@ -10854,10 +10845,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11220,9 +11211,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.20.7" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap 2.14.0", "toml_datetime 0.6.11", @@ -11430,7 +11421,7 @@ dependencies = [ "dirs 6.0.0", "libappindicator", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -12427,7 +12418,7 @@ dependencies = [ "naga 25.0.1", "ndk-sys 0.5.0+25.2.9519653", "objc", - "ordered-float 4.6.0", + "ordered-float", "parking_lot", "portable-atomic", "profiling", @@ -12470,13 +12461,13 @@ dependencies = [ "log", "naga 29.0.4", "ndk-sys 0.6.0+11769913", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", "once_cell", - "ordered-float 5.3.0", + "ordered-float", "parking_lot", "portable-atomic", "portable-atomic-util", @@ -12611,7 +12602,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -12639,16 +12630,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.58.0" @@ -12757,18 +12738,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.58.0" @@ -12866,17 +12835,6 @@ dependencies = [ "windows-threading 0.2.1", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-implement" version = "0.58.0" @@ -12910,17 +12868,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-interface" version = "0.58.0" @@ -12996,17 +12943,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.1.2" @@ -13511,7 +13447,7 @@ dependencies = [ "num-complex", "num-integer", "num-rational", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "open", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 36dad10d027..aef97037c93 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -62,7 +62,10 @@ cap-project = { path = "../../crates/project" } # a `RenderImage` (`src/editor_sidebar/cursor.rs`). cap-cursor-info = { path = "../../crates/cursor-info" } resvg = "0.45" -rodio = { version = "0.19.0", default-features = false, features = ["mp3", "vorbis"] } +rodio = { version = "0.19.0", default-features = false, features = [ + "mp3", + "vorbis", +] } # The editor stack. `cap-rendering` is not a new subtree -- cap-recording # already depends on it, so wgpu 25 and its vendored wgpu-hal have been built # by this workspace since the recording unit; naming it here only makes the @@ -100,7 +103,11 @@ ffmpeg = { package = "ffmpeg-next", git = "https://github.com/CapSoftware/rust-f # `webp` is the config sidebar's four background-source illustrations, which # the app ships as webp; features are additive, so enabling it here does not # move gpui's own pin. -image = { version = "0.25.1", default-features = false, features = ["jpeg", "png", "webp"] } +image = { version = "0.25.1", default-features = false, features = [ + "jpeg", + "png", + "webp", +] } jpeg-decoder = "0.3" smallvec = "1" # Grapheme boundaries for `ui::TextInput`: Backspace deletes a user-perceived @@ -128,7 +135,10 @@ sanitize-filename = "0.6.0" semver = "1" base64 = "0.22" minisign-verify = "0.2.4" -rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] } +rfd = { version = "0.15", default-features = false, features = [ + "xdg-portal", + "tokio", +] } # The screenshot export compositor (`src/screenshot_export.rs`): the canvas-2d # work `renderScreenshotExportCanvas` does in the webview -- shape rasterizing, # text, and the share fingerprint. All three are already in this lockfile @@ -173,7 +183,14 @@ tauri-winres = "0.3.5" # decoders, renderer and preview tasks are all tokio-spawned -- a current-thread # runtime would deadlock the moment the test thread blocks on a frame. Kept in # dev-dependencies so the shipped binary's tokio features are unchanged. -tokio = { version = "1", features = ["rt-multi-thread", "time", "test-util", "net", "io-util", "macros"] } +tokio = { version = "1", features = [ + "rt-multi-thread", + "time", + "test-util", + "net", + "io-util", + "macros", +] } # Caption oracles (`src/transcription/verify_tests.rs`): fixture projects and # audio markers for the fake transcriber. cap-verify = { path = "../../crates/verify" } @@ -206,15 +223,31 @@ x11rb = "0.13" [target.'cfg(target_os = "macos")'.dependencies] # Native window treatment (panel levels, Spaces) gpui does not expose. Same -# versions gpui's own tree compiles, so no second objc2 universe gets built. -objc2 = { version = "0.5.2", features = ["exception"] } +# direct 0.6 line used by this app's class definitions and AppKit bindings. +objc2 = { version = "0.6.5", features = ["exception"] } # `NSVisualEffectView` is the pre-macOS-26 window-material fallback. Features # only -- the versions stay pinned, or a second objc2 universe gets built. -objc2-app-kit = { version = "0.2.2", features = ["NSAppearance", "NSWindow", "NSView", "NSResponder", "NSGraphics", "NSPasteboard", "NSVisualEffectView", "NSWorkspace", "NSAccessibility"] } +objc2-app-kit = { version = "0.3.2", features = [ + "NSAppearance", + "NSWindow", + "NSView", + "NSResponder", + "NSGraphics", + "NSPasteboard", + "NSMenuItemBadge", + "NSVisualEffectView", + "NSWorkspace", + "NSAccessibility", +] } # NSRect/NSPoint/NSSize for `setFrame:display:`. Already in the tree as # objc2-app-kit's own dependency, so this adds nothing to the build. -objc2-foundation = { version = "0.2.2", features = ["NSNotification", "NSOperation", "NSString", "block2"] } -block2 = "0.5.1" +objc2-foundation = { version = "0.3.2", features = [ + "NSNotification", + "NSOperation", + "NSString", + "block2", +] } +block2 = "0.6" raw-window-handle = "0.6" core-foundation = "0.10" core-video = { version = "0.5.2", features = ["metal"] } diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index b5aebb1b21e..f9fb9a8e798 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -4708,6 +4708,8 @@ fn open_editor_window( return None; } + cx.add_recent_document(&project_path); + if let Some(window_id) = cx .global::() .editors @@ -6599,6 +6601,8 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { }; let key = editor_key(&bundle); + cx.add_recent_document(&bundle); + if let Some(handle) = cx .global::() .screenshot_editors diff --git a/apps/desktop-gpui/src/menus.rs b/apps/desktop-gpui/src/menus.rs index 8e399636917..93f5f6c2336 100644 --- a/apps/desktop-gpui/src/menus.rs +++ b/apps/desktop-gpui/src/menus.rs @@ -7,8 +7,9 @@ //! Tauri app decides between Regular and Accessory from exactly the set of //! windows ⌘W changes. //! -//! The menu itself is [`build_menus`], a byte-for-byte transcription of -//! `build_macos_app_menu` (`apps/desktop/src-tauri/src/lib.rs:479-566`). gpui +//! The menu itself is [`build_menus`], the GPUI model for the native menu bar. +//! macOS standard menu items are upgraded in `platform` after GPUI installs it. +//! gpui //! builds the real `NSMenu` from it and takes each item's key equivalent from //! the *keymap*, so an action with no `KeyBinding` renders without a shortcut //! and an action with no live handler renders disabled @@ -37,6 +38,68 @@ use crate::{ /// submenu to and what the About/Hide/Quit labels interpolate. pub const APP_NAME: &str = "Cap"; +struct MenuRegistration(UpdateRow); + +impl Global for MenuRegistration {} + +/// What the Check for Update row currently says. Download progress only moves +/// the badge, and only macOS applies it without rebuilding the menu bar, so a +/// percentage tick never touches the menu on the other platforms. +#[derive(Clone, PartialEq)] +struct UpdateRow { + title: &'static str, + enabled: bool, + badge: Option, +} + +const CHECK_TITLE: &str = "Check for Update"; +const CHECKING_TITLE: &str = "Checking for Update…"; +const DOWNLOAD_TITLE: &str = "Download update…"; +const RESTART_TITLE: &str = "Restart to update…"; + +impl UpdateRow { + fn of(status: &crate::updates::UpdateStatus) -> Self { + use crate::updates::UpdateStatus; + + let title = match status { + UpdateStatus::Idle => CHECK_TITLE, + UpdateStatus::Checking => CHECKING_TITLE, + UpdateStatus::Downloading { .. } | UpdateStatus::Installing { .. } => DOWNLOAD_TITLE, + UpdateStatus::Ready { .. } | UpdateStatus::Installed { .. } => RESTART_TITLE, + }; + Self { + title, + enabled: !matches!(status, UpdateStatus::Checking), + badge: update_menu_badge(status), + } + } +} + +#[cfg(target_os = "macos")] +fn update_menu_badge(status: &crate::updates::UpdateStatus) -> Option { + use crate::updates::UpdateStatus; + + match status { + UpdateStatus::Downloading { + version, + fraction: Some(fraction), + } => Some(format!( + "v{version} · {}%", + (fraction * 100.0).round() as u32 + )), + UpdateStatus::Downloading { version, .. } + | UpdateStatus::Ready { version } + | UpdateStatus::Installing { version } + | UpdateStatus::Installed { version, .. } => Some(format!("v{version}")), + UpdateStatus::Idle | UpdateStatus::Checking => None, + } +} + +#[cfg(not(target_os = "macos"))] +fn update_menu_badge(_: &crate::updates::UpdateStatus) -> Option { + None +} + /// `env!("CARGO_PKG_VERSION")`, exactly as `build_tray_menu` spells it -- this /// crate's version, which is the gpui app's own (0.1.x) rather than the /// shipping desktop app's. Noted as a deviation in the report: there is no @@ -50,12 +113,29 @@ actions!( [ /// `PredefinedMenuItem::about` -- `orderFrontStandardAboutPanel`. About, + /// Manually check for or install a Cap update. + CheckForUpdate, /// `PredefinedMenuItem::hide` -- ⌘H. HideSelf, /// `PredefinedMenuItem::hide_others` -- ⌥⌘H. HideOthers, /// The Cap menu's explicit Quit item -- ⌘Q. Quit, + /// Open General settings from the application menu. + Preferences, + /// Choose a Cap recording to open. + OpenFile, + /// Import a video or image into the library. + ImportMedia, + /// Open the Changelog from the Help menu. + Changelog, + HelpDashboard, + HelpDocs, + HelpFaq, + HelpSelfHosting, + HelpCenter, + HelpStatus, + HelpDiscord, /// `PredefinedMenuItem::close_window` -- ⌘W. Present twice in the /// Tauri menu (File and Window), one action either way. CloseWindow, @@ -79,6 +159,7 @@ pub fn init(cx: &mut App) { // `on_key_down` project-undo). A global binding here would shadow both. cx.bind_keys([ KeyBinding::new("cmd-q", Quit, None), + KeyBinding::new("cmd-,", Preferences, None), KeyBinding::new("cmd-h", HideSelf, None), KeyBinding::new("alt-cmd-h", HideOthers, None), KeyBinding::new("cmd-w", CloseWindow, None), @@ -86,16 +167,49 @@ pub fn init(cx: &mut App) { KeyBinding::new("ctrl-cmd-f", ToggleFullscreen, None), ]); - cx.on_action(|_: &About, cx: &mut App| { - // Ordering a window front re-enters gpui's window callbacks, so it runs - // from a task with no borrow held (`platform::place_overlay_panel`'s - // rule). - cx.spawn(async move |_| platform::show_about_panel(APP_NAME, app_version())) - .detach(); + cx.on_action(|_: &CheckForUpdate, cx: &mut App| { + crate::updates::check_manually(cx); }); cx.on_action(|_: &HideSelf, cx: &mut App| cx.hide()); cx.on_action(|_: &HideOthers, cx: &mut App| cx.hide_other_apps()); cx.on_action(|_: &Quit, cx: &mut App| quit(cx)); + cx.on_action(|_: &Preferences, cx: &mut App| { + cx.defer(|cx| app_windows::open_settings(crate::settings_window::Page::General, cx)); + }); + cx.on_action(|_: &OpenFile, cx: &mut App| { + let (files, directories) = if cfg!(target_os = "macos") { + (true, false) + } else { + (false, true) + }; + let paths = cx.prompt_for_paths(gpui::PathPromptOptions { + files, + directories, + multiple: false, + prompt: Some("Open Recording".into()), + }); + cx.spawn(async move |cx| { + if let Ok(Ok(Some(paths))) = paths.await + && let Some(path) = paths.into_iter().next() + { + cx.update(|cx| crate::tray::open_previous_item(path, cx)); + } + }) + .detach(); + }); + cx.on_action(|_: &ImportMedia, cx: &mut App| { + crate::import::pick_and_import_media(cx); + }); + cx.on_action(|_: &Changelog, cx: &mut App| { + app_windows::open_settings(crate::settings_window::Page::Changelog, cx) + }); + cx.on_action(|_: &HelpDashboard, cx: &mut App| cx.open_url("https://cap.so/dashboard")); + cx.on_action(|_: &HelpDocs, cx: &mut App| cx.open_url("https://cap.so/docs")); + cx.on_action(|_: &HelpFaq, cx: &mut App| cx.open_url("https://cap.so/faq")); + cx.on_action(|_: &HelpSelfHosting, cx: &mut App| cx.open_url("https://cap.so/self-hosting")); + cx.on_action(|_: &HelpCenter, cx: &mut App| cx.open_url("https://help.cap.so/")); + cx.on_action(|_: &HelpStatus, cx: &mut App| cx.open_url("https://cap.openstatus.dev/")); + cx.on_action(|_: &HelpDiscord, cx: &mut App| cx.open_url("https://cap.link/discord")); cx.on_action(|_: &CloseWindow, cx: &mut App| close_key_window(cx)); cx.on_action(|_: &Minimize, cx: &mut App| with_key_native(cx, platform::minimize_native)); cx.on_action(|_: &Zoom, cx: &mut App| with_key_native(cx, platform::zoom_native)); @@ -110,20 +224,46 @@ pub fn init(cx: &mut App) { with_key_native(cx, platform::toggle_fullscreen_native) }); - cx.set_menus(build_menus()); + cx.set_global(MenuRegistration(UpdateRow::of(&crate::updates::status(cx)))); + install_menus(cx); } -/// The menu bar model. -/// -/// One-to-one with `build_macos_app_menu`, including the empty Help submenu -/// (`Submenu::with_id_and_items(.., HELP_SUBMENU_ID, "Help", true, &[])`). -pub fn build_menus() -> Vec { +pub(crate) fn refresh_update_menu(cx: &mut App) { + if !cx.has_global::() { + return; + } + + let row = UpdateRow::of(&crate::updates::status(cx)); + if cx.global::().0 == row { + return; + } + cx.global_mut::().0 = row.clone(); + + #[cfg(target_os = "macos")] + platform::set_update_menu_item(row.title, row.badge.as_deref(), row.enabled); + #[cfg(not(target_os = "macos"))] + install_menus(cx); +} + +fn install_menus(cx: &mut App) { + let update_status = crate::updates::status(cx); + cx.set_menus(build_menus(&update_status)); + #[cfg(target_os = "macos")] + platform::patch_standard_application_menu(); +} + +/// The menu bar hierarchy and app-specific destinations. +fn build_menus(update_status: &crate::updates::UpdateStatus) -> Vec { + let update_row = UpdateRow::of(update_status); vec![ Menu { name: APP_NAME.into(), disabled: false, items: vec![ MenuItem::action(format!("About {APP_NAME}"), About), + MenuItem::action(update_row.title, CheckForUpdate).disabled(!update_row.enabled), + MenuItem::separator(), + MenuItem::action("Preferences…", Preferences), MenuItem::separator(), MenuItem::os_submenu("Services", SystemMenuType::Services), MenuItem::separator(), @@ -136,7 +276,12 @@ pub fn build_menus() -> Vec { Menu { name: "File".into(), disabled: false, - items: vec![MenuItem::action("Close Window", CloseWindow)], + items: vec![ + MenuItem::action("Open File…", OpenFile), + MenuItem::action("Import Media…", ImportMedia), + MenuItem::separator(), + MenuItem::action("Close Window", CloseWindow), + ], }, Menu { name: "Edit".into(), @@ -180,7 +325,17 @@ pub fn build_menus() -> Vec { Menu { name: "Help".into(), disabled: false, - items: vec![], + items: vec![ + MenuItem::action("Changelog", Changelog), + MenuItem::separator(), + MenuItem::action("Dashboard", HelpDashboard), + MenuItem::action("Documentation", HelpDocs), + MenuItem::action("FAQ", HelpFaq), + MenuItem::action("Self-hosting", HelpSelfHosting), + MenuItem::action("Help Center", HelpCenter), + MenuItem::action("System Status", HelpStatus), + MenuItem::action("Join Discord Community", HelpDiscord), + ], }, ] } @@ -880,8 +1035,7 @@ mod tests { assert!(gate.pending.is_none()); } - /// The menu bar, transcribed from `build_macos_app_menu`. Separators are - /// spelled `"-"` so the ordering is checked too. + /// Separators are spelled `"-"` so the ordering is checked too. fn describe(menu: &Menu) -> (String, Vec) { let items = menu .items @@ -897,8 +1051,17 @@ mod tests { } #[test] - fn menu_bar_matches_the_tauri_structure() { - let menus: Vec<_> = build_menus().iter().map(describe).collect(); + fn menu_bar_includes_native_and_cap_destinations() { + let menus: Vec<_> = build_menus(&crate::updates::UpdateStatus::Idle) + .iter() + .map(describe) + .collect(); + let expected_file_items = vec![ + "Open File…".into(), + "Import Media…".into(), + "-".into(), + "Close Window".into(), + ]; assert_eq!( menus, vec![ @@ -906,6 +1069,9 @@ mod tests { "Cap".to_string(), vec![ "About Cap".to_string(), + "Check for Update".into(), + "-".into(), + "Preferences…".into(), "-".into(), "Services".into(), "-".into(), @@ -915,7 +1081,7 @@ mod tests { "Quit Cap".into(), ] ), - ("File".into(), vec!["Close Window".into()]), + ("File".into(), expected_file_items,), ( "Edit".into(), vec![ @@ -938,11 +1104,117 @@ mod tests { "Close Window".into(), ] ), - ("Help".into(), vec![]), + ( + "Help".into(), + vec![ + "Changelog".into(), + "-".into(), + "Dashboard".into(), + "Documentation".into(), + "FAQ".into(), + "Self-hosting".into(), + "Help Center".into(), + "System Status".into(), + "Join Discord Community".into(), + ], + ), ] ); } + /// The row is the second item of the App menu (`menu_bar_...` pins that + /// order); it greys out for the duration of a check so a second click + /// cannot fire another one. + #[test] + fn the_update_row_greys_out_while_checking() { + fn row(status: &crate::updates::UpdateStatus) -> (String, bool) { + let menus = build_menus(status); + match &menus[0].items[1] { + MenuItem::Action { name, disabled, .. } => (name.to_string(), *disabled), + _ => panic!("the update row is an action"), + } + } + + assert_eq!( + row(&crate::updates::UpdateStatus::Idle), + ("Check for Update".to_string(), false) + ); + assert_eq!( + row(&crate::updates::UpdateStatus::Checking), + ("Checking for Update…".to_string(), true) + ); + } + + /// The version lives only in the badge, and a percentage tick changes + /// nothing else -- that is what keeps a download from reinstalling the + /// menu bar on every progress event. + #[test] + fn the_update_row_keeps_the_version_out_of_the_title() { + use crate::updates::UpdateStatus; + use semver::Version; + + let version = Version::parse("0.4.0").unwrap(); + let downloading = |fraction| UpdateStatus::Downloading { + version: version.clone(), + fraction, + }; + + let cold = UpdateRow::of(&downloading(None)); + let warm = UpdateRow::of(&downloading(Some(0.42))); + assert_eq!(cold.title, "Download update…"); + assert!(cold.enabled); + assert_eq!(warm.title, cold.title); + + let ready = UpdateRow::of(&UpdateStatus::Ready { + version: version.clone(), + }); + let installed = UpdateRow::of(&UpdateStatus::Installed { + version: version.clone(), + executable: std::path::PathBuf::from("/Applications/Cap.app"), + }); + assert_eq!(ready.title, "Restart to update…"); + assert_eq!(installed.title, ready.title); + assert_eq!(installed.badge, ready.badge); + + let installing = UpdateRow::of(&UpdateStatus::Installing { + version: version.clone(), + }); + assert_eq!(installing.title, cold.title); + } + + #[cfg(target_os = "macos")] + #[test] + fn the_update_badge_shows_the_version_and_live_progress() { + use crate::updates::UpdateStatus; + use semver::Version; + + let version = Version::parse("0.4.0").unwrap(); + let badge = |status| UpdateRow::of(&status).badge; + + assert_eq!( + badge(UpdateStatus::Downloading { + version: version.clone(), + fraction: None, + }), + Some("v0.4.0".to_string()) + ); + assert_eq!( + badge(UpdateStatus::Downloading { + version: version.clone(), + fraction: Some(0.42), + }), + Some("v0.4.0 · 42%".to_string()) + ); + assert_eq!( + badge(UpdateStatus::Ready { + version: version.clone(), + }), + Some("v0.4.0".to_string()) + ); + assert_eq!(badge(UpdateStatus::Idle), None); + assert_eq!(badge(UpdateStatus::Checking), None); + } + /// `sync_macos_dock_visibility`'s three branches. #[test] fn dock_policy_matches_the_tauri_rule() { diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 7c05c4f6bba..2e90f470d4c 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -641,9 +641,13 @@ mod mac { } use gpui::Window; - use objc2::rc::Id; + use objc2::rc::Retained; use objc2::runtime::{AnyObject, Sel}; - use objc2_app_kit::{NSView, NSWindow, NSWindowCollectionBehavior}; + use objc2_app_kit::{ + NSApplication, NSDocumentController, NSMenu, NSMenuItem, NSView, NSWindow, + NSWindowCollectionBehavior, + }; + use objc2_foundation::{NSObjectNSKeyValueCoding, NSString}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use super::{ForcedAppearance, MaterialKind, PanelBehavior}; @@ -651,6 +655,150 @@ mod mac { static NATIVE_QUIT_REQUESTS: std::sync::OnceLock = std::sync::OnceLock::new(); + /// Rows of `menus::build_menus`' Cap and File menus. Both menus are built + /// from that model in this order, so the positions are fixed. + const APP_MENU: isize = 0; + const ABOUT_ROW: isize = 0; + const UPDATE_ROW: isize = 1; + const PREFERENCES_ROW: isize = 3; + const FILE_MENU: isize = 1; + const OPEN_RECENT_ROW: isize = 1; + + pub fn patch_standard_application_menu() { + use objc2::{exception, msg_send, runtime::AnyObject, sel}; + use std::panic::AssertUnwindSafe; + + let Some(main_thread) = objc2_foundation::MainThreadMarker::new() else { + tracing::error!("Cannot patch the application menu off the main thread"); + return; + }; + let app = NSApplication::sharedApplication(main_thread); + let Some(main_menu) = app.mainMenu() else { + tracing::warn!("GPUI did not install an application menu"); + return; + }; + let Some(app_menu) = main_menu + .itemAtIndex(APP_MENU) + .and_then(|item| item.submenu()) + else { + return; + }; + // gpui's `validateMenuItem:` answers only "is this action available?", + // which is always yes for Cap's global listeners -- so AppKit's + // auto-enable pass overwrites `MenuItem::Action`'s `disabled` every + // time the menu opens. The model's enabled state has to be + // authoritative instead, as it already is for the tray menu and for + // muda. + app_menu.setAutoenablesItems(false); + + if let Some(about) = app_menu.itemAtIndex(ABOUT_ROW) { + unsafe { + about.setAction(Some(sel!(orderFrontStandardAboutPanel:))); + about.setTarget(None); + about.setAllowsAutomaticKeyEquivalentLocalization(true); + } + } + if let Some(preferences) = app_menu.itemAtIndex(PREFERENCES_ROW) { + preferences.setAllowsAutomaticKeyEquivalentLocalization(true); + } + if let Some(quit) = app_menu + .numberOfItems() + .checked_sub(1) + .and_then(|index| app_menu.itemAtIndex(index)) + { + unsafe { + quit.setAction(Some(sel!(terminate:))); + quit.setTarget(None); + quit.setAllowsAutomaticKeyEquivalentLocalization(true); + } + } + + let Some(file_menu) = main_menu + .itemAtIndex(FILE_MENU) + .and_then(|item| item.submenu()) + else { + return; + }; + let has_recent_menu = file_menu.itemAtIndex(OPEN_RECENT_ROW).is_some_and(|item| { + item.title() + .isEqualToString(&NSString::from_str("Open Recent")) + }); + if has_recent_menu { + return; + } + + let recent_menu = NSMenu::new(main_thread); + recent_menu.setTitle(&NSString::from_str("Open Recent")); + let name = NSString::from_str("NSRecentDocumentsMenu"); + let key = NSString::from_str("menuName"); + let registered = exception::catch(AssertUnwindSafe(|| { + unsafe { recent_menu.setValue_forKey(Some(&*name as &AnyObject), &key) }; + })); + if let Err(error) = registered { + tracing::warn!("Could not register Open Recent menu name: {error:?}"); + } + + let clear_item = NSMenuItem::new(main_thread); + clear_item.setTitle(&NSString::from_str("Clear Menu")); + unsafe { clear_item.setAction(Some(sel!(clearRecentDocuments:))) }; + recent_menu.addItem(&clear_item); + + let recent_item = NSMenuItem::new(main_thread); + recent_item.setTitle(&NSString::from_str("Open Recent")); + recent_item.setSubmenu(Some(&recent_menu)); + file_menu.insertItem_atIndex(&recent_item, OPEN_RECENT_ROW); + + let controller = NSDocumentController::sharedDocumentController(main_thread); + let can_install_menu: bool = + unsafe { msg_send![&*controller, respondsToSelector: sel!(_installOpenRecentMenus)] }; + if can_install_menu { + unsafe { + let _: () = msg_send![&*controller, _installOpenRecentMenus]; + } + } else { + tracing::warn!("NSDocumentController cannot install the Open Recent menu"); + } + } + + /// Rewrites the Check for Update row in place. A menu item edited this way + /// keeps whatever menu the user has open on screen; reinstalling the menu + /// bar for every download-percentage tick would close it. + pub fn set_update_menu_item(title: &str, badge: Option<&str>, enabled: bool) { + use objc2::runtime::AnyClass; + use objc2_app_kit::NSMenuItemBadge; + use objc2_foundation::MainThreadMarker; + + let Some(main_thread) = MainThreadMarker::new() else { + return; + }; + let Some(item) = update_menu_item(main_thread) else { + tracing::warn!("The update row is missing from the application menu"); + return; + }; + item.setTitle(&NSString::from_str(title)); + item.setEnabled(enabled); + + // `NSMenuItemBadge` and `setBadge:` are macOS 14+, and objc2's class + // lookup panics when the class is absent. + if AnyClass::get(c"NSMenuItemBadge").is_none() { + return; + } + let badge = badge.map(|text| { + NSMenuItemBadge::initWithString(main_thread.alloc(), &NSString::from_str(text)) + }); + item.setBadge(badge.as_deref()); + } + + fn update_menu_item( + main_thread: objc2_foundation::MainThreadMarker, + ) -> Option> { + NSApplication::sharedApplication(main_thread) + .mainMenu()? + .itemAtIndex(APP_MENU)? + .submenu()? + .itemAtIndex(UPDATE_ROW) + } + unsafe extern "C-unwind" fn application_should_terminate( _: &AnyObject, _: Sel, @@ -682,7 +830,7 @@ mod mac { .as_ref() .ok_or("Application delegate is unavailable")?; let delegate_class = delegate.class(); - if delegate_class.name() != "GPUIApplicationDelegate" { + if delegate_class.name() != c"GPUIApplicationDelegate" { return Err("Native Quit requires the owned GPUI application delegate".into()); } let selector = sel!(applicationShouldTerminate:); @@ -697,12 +845,12 @@ mod mac { unsafe extern "C-unwind" fn(&AnyObject, Sel, *mut AnyObject) -> usize, Imp, >(application_should_terminate); - if !objc2::runtime::Bool::from_raw(ffi::class_addMethod( + if !ffi::class_addMethod( delegate_class as *const _ as *mut _, - selector.as_ptr(), - Some(implementation), + selector, + implementation, c"Q@:@".as_ptr(), - )) + ) .as_bool() { return Err("Could not install the native Quit handler".into()); @@ -856,12 +1004,12 @@ mod mac { // `NSGlassEffectView` exists only on macOS 26+; its absence is what // sends `macos-window-material.ts` down the vibrancy branch. if material_override.as_deref() != Some("vibrancy") - && let Some(glass_class) = AnyClass::get("NSGlassEffectView") + && let Some(glass_class) = AnyClass::get(c"NSGlassEffectView") { unsafe { let glass: *mut AnyObject = msg_send![glass_class, alloc]; let glass: *mut AnyObject = msg_send![glass, initWithFrame: bounds]; - if let Some(glass) = Id::from_raw(glass) { + if let Some(glass) = Retained::from_raw(glass) { set_clip_radius(radius); let identifier = NSString::from_str(LIQUID_GLASS_IDENTIFIER); let _: () = msg_send![&*glass, setIdentifier: &*identifier]; @@ -878,7 +1026,7 @@ mod mac { let _: () = msg_send![ &*content_view, addSubview: &*glass, - positioned: NSWindowOrderingMode::NSWindowBelow, + positioned: NSWindowOrderingMode::Below, relativeTo: std::ptr::null_mut::(), ]; return Some(MaterialKind::LiquidGlass); @@ -897,12 +1045,12 @@ mod mac { vibrancy.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow); vibrancy.setState(NSVisualEffectState::FollowsWindowActiveState); vibrancy.setAutoresizingMask( - NSAutoresizingMaskOptions::NSViewWidthSizable - | NSAutoresizingMaskOptions::NSViewHeightSizable, + NSAutoresizingMaskOptions::ViewWidthSizable + | NSAutoresizingMaskOptions::ViewHeightSizable, ); content_view.addSubview_positioned_relativeTo( &vibrancy, - NSWindowOrderingMode::NSWindowBelow, + NSWindowOrderingMode::Below, None, ); } @@ -951,7 +1099,7 @@ mod mac { use objc2::ffi::class_addMethod; use objc2_foundation::NSRect; - unsafe extern "C" fn constrain_shim( + unsafe extern "C-unwind" fn constrain_shim( _this: *mut AnyObject, _sel: Sel, frame: NSRect, @@ -961,22 +1109,30 @@ mod mac { } for name in ["GPUIWindow", "GPUIPanel"] { - let Some(class) = objc2::runtime::AnyClass::get(name) else { + let Some(class) = objc2::runtime::AnyClass::get(match name { + "GPUIWindow" => c"GPUIWindow", + "GPUIPanel" => c"GPUIPanel", + _ => continue, + }) else { continue; }; let added = unsafe { class_addMethod( - (class as *const objc2::runtime::AnyClass as *mut objc2::ffi::objc_class) - .cast(), - objc2::sel!(constrainFrameRect:toScreen:).as_ptr(), - Some(std::mem::transmute::< - unsafe extern "C" fn(*mut AnyObject, Sel, NSRect, *mut AnyObject) -> NSRect, - unsafe extern "C" fn(), - >(constrain_shim)), + (class as *const objc2::runtime::AnyClass).cast_mut(), + objc2::sel!(constrainFrameRect:toScreen:), + std::mem::transmute::< + unsafe extern "C-unwind" fn( + *mut AnyObject, + Sel, + NSRect, + *mut AnyObject, + ) -> NSRect, + objc2::runtime::Imp, + >(constrain_shim), c"{CGRect={CGPoint=dd}{CGSize=dd}}@:{CGRect={CGPoint=dd}{CGSize=dd}}@".as_ptr(), ) }; - if objc2::runtime::Bool::from_raw(added).as_bool() { + if added.as_bool() { tracing::info!("installed frame-constraint shim on {name}"); } } @@ -1049,7 +1205,7 @@ mod mac { /// A retained `NSWindow` that can outlive a `&Window` borrow -- what /// [`place_overlay_panel`] operates on. - pub struct NativeWindow(Id); + pub struct NativeWindow(Retained); /// The retained `NSWindow` behind a gpui window, for AppKit calls that /// must run *outside* any gpui update (see [`place_overlay_panel`]). @@ -1306,7 +1462,7 @@ mod mac { } /// The `NSWindow` behind a gpui window. - pub fn ns_window(window: &Window) -> Option> { + pub fn ns_window(window: &Window) -> Option> { // Fully qualified: `Window` also has an inherent `window_handle()` // returning gpui's own `AnyWindowHandle`. let handle = HasWindowHandle::window_handle(window).ok()?; @@ -1513,7 +1669,7 @@ mod mac { static SOURCE: OnceLock = OnceLock::new(); *SOURCE.get_or_init(|| { - let Some(mut builder) = ClassBuilder::new("CapFileDragSource", NSObject::class()) + let Some(mut builder) = ClassBuilder::new(c"CapFileDragSource", NSObject::class()) else { return 0; }; @@ -1529,7 +1685,7 @@ mod mac { ) -> usize, ); } - if let Some(protocol) = AnyProtocol::get("NSDraggingSource") { + if let Some(protocol) = AnyProtocol::get(c"NSDraggingSource") { builder.add_protocol(protocol); } let class = builder.register(); @@ -1676,7 +1832,7 @@ mod mac { use objc2::{class, msg_send}; use objc2_foundation::NSString; unsafe { - let mut image: Option> = None; + let mut image: Option> = None; let cap_app = NSString::from_str("/Applications/Cap.app"); let exists: bool = { @@ -1688,7 +1844,7 @@ mod mac { if !workspace.is_null() { let raw: *mut AnyObject = msg_send![workspace, iconForFile: &*cap_app]; // `iconForFile:` is autoreleased; retain for the setter. - image = Id::retain(raw); + image = Retained::retain(raw); if let Some(image) = &image { // The workspace icon's nominal size is small (32pt); // the reps go up to 1024. Ask for dock resolution. @@ -1709,7 +1865,7 @@ mod mac { if !data.is_null() { let alloc: *mut AnyObject = msg_send![class!(NSImage), alloc]; let raw: *mut AnyObject = msg_send![alloc, initWithData: data]; - image = Id::from_raw(raw); + image = Retained::from_raw(raw); } } @@ -2044,11 +2200,11 @@ mod mac { let _: () = msg_send![panel, setCanCreateDirectories: true]; let _: () = msg_send![panel, setNameFieldStringValue: &*NSString::from_str(suggested)]; if !extensions.is_empty() { - let types: Vec> = extensions + let types: Vec> = extensions .iter() .map(|extension| NSString::from_str(extension)) .collect(); - let types = NSArray::from_vec(types); + let types = NSArray::from_retained_slice(&types); let _: () = msg_send![panel, setAllowedFileTypes: &*types]; } let response: isize = msg_send![panel, runModal]; @@ -2116,7 +2272,7 @@ mod mac { let data = NSData::with_bytes(bytes); let image: *mut AnyObject = msg_send![class!(NSImage), alloc]; let image: *mut AnyObject = msg_send![image, initWithData: &*data]; - let image = Id::from_raw(image).ok_or("Failed to load image")?; + let image = Retained::from_raw(image).ok_or("Failed to load image")?; let objects: *mut AnyObject = msg_send![class!(NSArray), arrayWithObject: &*image]; let _: isize = msg_send![pasteboard, clearContents]; let ok: Bool = msg_send![pasteboard, writeObjects: objects]; @@ -2141,11 +2297,11 @@ mod mac { let _: () = msg_send![panel, setCanChooseDirectories: false]; let _: () = msg_send![panel, setAllowsMultipleSelection: false]; - let types: Vec> = extensions + let types: Vec> = extensions .iter() .map(|extension| NSString::from_str(extension)) .collect(); - let types = NSArray::from_vec(types); + let types = NSArray::from_retained_slice(&types); let _: () = msg_send![panel, setAllowedFileTypes: &*types]; // `NSModalResponseOK`. @@ -2186,7 +2342,7 @@ mod mac { cancel: &str, warning: bool, ) -> bool { - use objc2::{class, msg_send, msg_send_id}; + use objc2::{class, msg_send}; use objc2_foundation::NSString; /// `NSAlertStyle`: `Warning = 0`, `Informational = 1`. `NSUInteger`, @@ -2199,7 +2355,7 @@ mod mac { const NS_ALERT_FIRST_BUTTON_RETURN: isize = 1000; unsafe { - let alert: Id = msg_send_id![class!(NSAlert), new]; + let alert: Retained = msg_send![class!(NSAlert), new]; let _: () = msg_send![&*alert, setMessageText: &*NSString::from_str(title)]; let _: () = msg_send![&*alert, setInformativeText: &*NSString::from_str(message)]; let _: () = msg_send![ @@ -2222,13 +2378,13 @@ mod mac { } pub fn alert_dialog(title: &str, message: &str) { - use objc2::{class, msg_send, msg_send_id}; + use objc2::{class, msg_send}; use objc2_foundation::NSString; const NS_ALERT_STYLE_INFORMATIONAL: usize = 1; unsafe { - let alert: Id = msg_send_id![class!(NSAlert), new]; + let alert: Retained = msg_send![class!(NSAlert), new]; let _: () = msg_send![&*alert, setMessageText: &*NSString::from_str(title)]; let _: () = msg_send![&*alert, setInformativeText: &*NSString::from_str(message)]; let _: () = msg_send![&*alert, setAlertStyle: NS_ALERT_STYLE_INFORMATIONAL]; @@ -2485,48 +2641,6 @@ mod mac { } } - // -- The About panel ------------------------------------------------------ - - /// `PredefinedMenuItem::about(.., AboutMetadata { name, version, .. })`, - /// which on macOS is `orderFrontStandardAboutPanelWithOptions:`. - /// - /// The Tauri metadata also carries `copyright` and `publisher` from - /// `tauri.conf.json` -- neither key exists in that file, so both are `None` - /// there and there is nothing to pass here either. - /// - /// Spins no modal run loop of its own (the panel is an ordinary window), but - /// ordering a window front re-enters gpui's window callbacks, so it keeps - /// the [`place_overlay_panel`] rule: call it from a task, never inside an - /// update. - pub fn show_about_panel(name: &str, version: &str) { - use objc2::{class, msg_send}; - use objc2_foundation::{NSArray, NSString}; - - unsafe { - let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; - if app.is_null() { - return; - } - // `dictionaryWithObjects:forKeys:` rather than a typed - // `NSDictionary` constructor, for the reason - // [`desktop_picture_path`] gives: the typed binding needs another - // objc2-app-kit/foundation feature, and the rule here is that those - // versions stay pinned to gpui's. - let values = - NSArray::from_vec(vec![NSString::from_str(name), NSString::from_str(version)]); - let keys = NSArray::from_vec(vec![ - NSString::from_str("ApplicationName"), - NSString::from_str("ApplicationVersion"), - ]); - let options: *mut AnyObject = msg_send![ - class!(NSDictionary), - dictionaryWithObjects: &*values, - forKeys: &*keys, - ]; - let _: () = msg_send![app, orderFrontStandardAboutPanelWithOptions: options]; - } - } - /// One-line diagnostic of everything AppKit weighs into occlusion /// visibility -- how the macOS 26 display-link failure was diagnosed; keep /// it for the next platform mystery. @@ -2840,7 +2954,6 @@ mod stub { pub fn activation_policy() -> isize { -1 } - pub fn show_about_panel(_name: &str, _version: &str) {} } #[cfg(not(any(target_os = "macos", target_os = "windows")))] diff --git a/apps/desktop-gpui/src/platform/macos_exception_guard.rs b/apps/desktop-gpui/src/platform/macos_exception_guard.rs index 0348df8652a..e3c1f875df5 100644 --- a/apps/desktop-gpui/src/platform/macos_exception_guard.rs +++ b/apps/desktop-gpui/src/platform/macos_exception_guard.rs @@ -16,7 +16,7 @@ use std::sync::OnceLock; use objc2::{ ffi, msg_send, - rc::Id, + rc::Retained, runtime::{AnyClass, AnyObject, Imp, Sel}, sel, }; @@ -46,9 +46,7 @@ impl GuardedMethod { if self.original.get().is_some() || class.verify_sel::<(), ()>(selector).is_err() { return false; } - let method = unsafe { - ffi::class_getInstanceMethod((class as *const AnyClass).cast(), selector.as_ptr()) - }; + let method = unsafe { ffi::class_getInstanceMethod(class, selector) }; if method.is_null() { return false; } @@ -63,7 +61,7 @@ impl GuardedMethod { if self.original.set(original).is_err() { return false; } - unsafe { ffi::method_setImplementation(method, Some(guard_imp)) }; + let _ = unsafe { ffi::method_setImplementation(method, guard_imp) }; true } @@ -72,12 +70,12 @@ impl GuardedMethod { return false; }; let exception = unsafe { cap_call_void_imp_catching(original, receiver, selector) }; - let Some(exception) = (unsafe { Id::from_raw(exception) }) else { + let Some(exception) = (unsafe { Retained::from_raw(exception) }) else { return false; }; tracing::warn!( exception = %describe(&exception), - selector = %selector.name(), + selector = %selector.name().to_string_lossy(), "ignored an AppKit exception that would have crashed the app" ); true @@ -110,7 +108,7 @@ unsafe extern "C" fn touch_bar_finder_invalidate(receiver: *mut AnyObject, selec /// Call once on the main thread, before the app's run loop starts. pub(super) fn install() { - let Some(class) = AnyClass::get("_NSTouchBarFinderObservation") else { + let Some(class) = AnyClass::get(c"_NSTouchBarFinderObservation") else { tracing::info!("AppKit has no Touch Bar finder observation class; nothing to guard"); return; }; @@ -124,7 +122,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use objc2::{ - ClassType, msg_send, msg_send_id, + ClassType, msg_send, runtime::{ClassBuilder, NSObject}, }; @@ -145,7 +143,7 @@ mod tests { static CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); CLASS.get_or_init(|| { let mut builder = - ClassBuilder::new("CapExceptionGuardTestObservation", NSObject::class()).unwrap(); + ClassBuilder::new(c"CapExceptionGuardTestObservation", NSObject::class()).unwrap(); unsafe { builder.add_method( sel!(invalidate), @@ -170,7 +168,7 @@ mod tests { sel!(invalidate), ) }; - let exception = unsafe { Id::from_raw(exception) } + let exception = unsafe { Retained::from_raw(exception) } .expect("removing an unregistered observer must throw"); let description = describe(&exception); assert!( @@ -186,7 +184,7 @@ mod tests { assert!(TEST_GUARD.install(class, sel!(invalidate), guarded)); assert!(!TEST_GUARD.install(class, sel!(invalidate), guarded)); - let instance: Id = unsafe { msg_send_id![class, new] }; + let instance: Retained = unsafe { msg_send![class, new] }; for _ in 0..3 { let before = removal_calls(); let _: () = unsafe { msg_send![&instance, invalidate] }; @@ -197,10 +195,10 @@ mod tests { #[test] fn appkit_still_has_the_touch_bar_finder_method_this_guards() { assert!( - AnyClass::get("NSApplication").is_some(), + AnyClass::get(c"NSApplication").is_some(), "AppKit is not loaded" ); - let class = AnyClass::get("_NSTouchBarFinderObservation") + let class = AnyClass::get(c"_NSTouchBarFinderObservation") .expect("AppKit renamed _NSTouchBarFinderObservation; re-check the guard"); assert!(class.verify_sel::<(), ()>(sel!(invalidate)).is_ok()); } diff --git a/apps/desktop-gpui/src/platform/macos_occlusion.rs b/apps/desktop-gpui/src/platform/macos_occlusion.rs index 0c440597919..6e6dc8fffba 100644 --- a/apps/desktop-gpui/src/platform/macos_occlusion.rs +++ b/apps/desktop-gpui/src/platform/macos_occlusion.rs @@ -7,7 +7,7 @@ use objc2::{ sel, }; -type OcclusionStateFn = unsafe extern "C" fn(*mut AnyObject, Sel) -> usize; +type OcclusionStateFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> usize; struct OcclusionShim { original: OnceLock, @@ -46,15 +46,12 @@ impl OcclusionShim { let added = unsafe { class_addMethod( (class as *const AnyClass).cast_mut().cast(), - selector.as_ptr(), - Some(std::mem::transmute::< - OcclusionStateFn, - unsafe extern "C" fn(), - >(shim)), + selector, + std::mem::transmute::(shim), c"Q@:".as_ptr(), ) }; - Ok(Bool::from_raw(added).as_bool()) + Ok(added.as_bool()) } unsafe fn state(&self, receiver: *mut AnyObject, selector: Sel) -> usize { @@ -121,23 +118,29 @@ unsafe fn is_visible(receiver: *mut AnyObject) -> bool { static WINDOW_SHIM: OcclusionShim = OcclusionShim::new(); static PANEL_SHIM: OcclusionShim = OcclusionShim::new(); -unsafe extern "C" fn window_occlusion_state(receiver: *mut AnyObject, selector: Sel) -> usize { +unsafe extern "C-unwind" fn window_occlusion_state( + receiver: *mut AnyObject, + selector: Sel, +) -> usize { unsafe { WINDOW_SHIM.state(receiver, selector) } } -unsafe extern "C" fn panel_occlusion_state(receiver: *mut AnyObject, selector: Sel) -> usize { +unsafe extern "C-unwind" fn panel_occlusion_state( + receiver: *mut AnyObject, + selector: Sel, +) -> usize { unsafe { PANEL_SHIM.state(receiver, selector) } } pub(super) fn install() { for (name, state, implementation) in [ ( - "GPUIWindow", + c"GPUIWindow", &WINDOW_SHIM, window_occlusion_state as OcclusionStateFn, ), ( - "GPUIPanel", + c"GPUIPanel", &PANEL_SHIM, panel_occlusion_state as OcclusionStateFn, ), @@ -146,20 +149,27 @@ pub(super) fn install() { continue; }; match state.install(class, implementation) { - Ok(true) => tracing::info!("installed macOS occlusion shim on {name}"), + Ok(true) => { + tracing::info!(name = %name.to_string_lossy(), "installed macOS occlusion shim") + } Ok(false) => {} - Err(error) => tracing::warn!(%error, name, "could not install macOS occlusion shim"), + Err(error) => { + tracing::warn!(%error, name = %name.to_string_lossy(), "could not install macOS occlusion shim") + } } } } #[cfg(test)] mod tests { - use std::cell::Cell; + use std::{ + cell::Cell, + ffi::{CStr, CString}, + }; use objc2::{ - ClassType, msg_send_id, - rc::Id, + ClassType, msg_send, + rc::Retained, runtime::{ClassBuilder, NSObject}, }; @@ -181,37 +191,49 @@ mod tests { overridden_panel: &'static AnyClass, } - unsafe extern "C" fn native_window_state(_receiver: *mut AnyObject, _selector: Sel) -> usize { + unsafe extern "C-unwind" fn native_window_state( + _receiver: *mut AnyObject, + _selector: Sel, + ) -> usize { WINDOW_CALLS.set(WINDOW_CALLS.get() + 1); RAW_STATE.get() } - unsafe extern "C" fn native_is_visible(_receiver: *mut AnyObject, _selector: Sel) -> Bool { + unsafe extern "C-unwind" fn native_is_visible( + _receiver: *mut AnyObject, + _selector: Sel, + ) -> Bool { Bool::new(VISIBLE.get()) } - unsafe extern "C" fn native_panel_state(_receiver: *mut AnyObject, _selector: Sel) -> usize { + unsafe extern "C-unwind" fn native_panel_state( + _receiver: *mut AnyObject, + _selector: Sel, + ) -> usize { PANEL_CALLS.set(PANEL_CALLS.get() + 1); RAW_STATE.get() } - unsafe extern "C" fn overriding_window_state( + unsafe extern "C-unwind" fn overriding_window_state( receiver: *mut AnyObject, _selector: Sel, ) -> usize { unsafe { msg_send![super(receiver, classes().window), occlusionState] } } - unsafe extern "C" fn overriding_panel_state(receiver: *mut AnyObject, _selector: Sel) -> usize { + unsafe extern "C-unwind" fn overriding_panel_state( + receiver: *mut AnyObject, + _selector: Sel, + ) -> usize { unsafe { msg_send![super(receiver, classes().panel), occlusionState] } } - fn inherit(name: &str, superclass: &AnyClass) -> &'static AnyClass { + fn inherit(name: &CStr, superclass: &AnyClass) -> &'static AnyClass { ClassBuilder::new(name, superclass).unwrap().register() } fn with_method( - name: &str, + name: &CStr, superclass: &AnyClass, implementation: OcclusionStateFn, ) -> &'static AnyClass { @@ -225,7 +247,7 @@ mod tests { CLASSES.get_or_init(|| { let native_window = { let mut builder = - ClassBuilder::new("CapOcclusionTestNativeWindow", NSObject::class()).unwrap(); + ClassBuilder::new(c"CapOcclusionTestNativeWindow", NSObject::class()).unwrap(); unsafe { builder.add_method( sel!(occlusionState), @@ -233,40 +255,38 @@ mod tests { ); builder.add_method( sel!(isVisible), - native_is_visible as unsafe extern "C" fn(_, _) -> _, + native_is_visible as unsafe extern "C-unwind" fn(_, _) -> _, ); } builder.register() }; let native_panel = with_method( - "CapOcclusionTestNativePanel", + c"CapOcclusionTestNativePanel", native_window, native_panel_state, ); - let window = inherit("CapOcclusionTestWindow", native_window); - let panel = inherit("CapOcclusionTestPanel", native_panel); + let window = inherit(c"CapOcclusionTestWindow", native_window); + let panel = inherit(c"CapOcclusionTestPanel", native_panel); assert!(WINDOW_SHIM.install(window, window_occlusion_state).unwrap()); assert!(PANEL_SHIM.install(panel, panel_occlusion_state).unwrap()); let mut inherited_window = window; let mut inherited_panel = panel; for depth in 0..4 { - inherited_window = inherit( - &format!("CapOcclusionTestWindowSubclass{depth}"), - inherited_window, - ); - inherited_panel = inherit( - &format!("CapOcclusionTestPanelSubclass{depth}"), - inherited_panel, - ); + let window_name = + CString::new(format!("CapOcclusionTestWindowSubclass{depth}")).unwrap(); + inherited_window = inherit(&window_name, inherited_window); + let panel_name = + CString::new(format!("CapOcclusionTestPanelSubclass{depth}")).unwrap(); + inherited_panel = inherit(&panel_name, inherited_panel); } let window_override = with_method( - "CapOcclusionTestWindowOverride", + c"CapOcclusionTestWindowOverride", window, overriding_window_state, ); let panel_override = with_method( - "CapOcclusionTestPanelOverride", + c"CapOcclusionTestPanelOverride", panel, overriding_panel_state, ); @@ -276,16 +296,19 @@ mod tests { inherited_window, inherited_panel, overridden_window: inherit( - "CapOcclusionTestInheritedWindowOverride", + c"CapOcclusionTestInheritedWindowOverride", window_override, ), - overridden_panel: inherit("CapOcclusionTestInheritedPanelOverride", panel_override), + overridden_panel: inherit( + c"CapOcclusionTestInheritedPanelOverride", + panel_override, + ), } }) } fn verify_states(class: &AnyClass, expected_window_calls: usize, expected_panel_calls: usize) { - let instance: Id = unsafe { msg_send_id![class, new] }; + let instance: Retained = unsafe { msg_send![class, new] }; for visible in [true, false] { VISIBLE.set(visible); for raw in [0, 0x2, 0x2000, 0x2002] { @@ -372,7 +395,7 @@ mod tests { #[test] fn promoting_a_window_switches_to_the_panel_implementation() { let classes = classes(); - let instance: Id = unsafe { msg_send_id![classes.window, new] }; + let instance: Retained = unsafe { msg_send![classes.window, new] }; RAW_STATE.set(0x2000); WINDOW_CALLS.set(0); PANEL_CALLS.set(0); @@ -392,17 +415,20 @@ mod tests { #[test] fn incompatible_native_method_does_not_install_a_shim() { - unsafe extern "C" fn wrong_signature(_receiver: *mut AnyObject, _selector: Sel) -> f64 { + unsafe extern "C-unwind" fn wrong_signature( + _receiver: *mut AnyObject, + _selector: Sel, + ) -> f64 { 0.0 } let state = OcclusionShim::new(); let mut builder = - ClassBuilder::new("CapOcclusionTestWrongSignature", NSObject::class()).unwrap(); + ClassBuilder::new(c"CapOcclusionTestWrongSignature", NSObject::class()).unwrap(); unsafe { builder.add_method( sel!(occlusionState), - wrong_signature as unsafe extern "C" fn(_, _) -> _, + wrong_signature as unsafe extern "C-unwind" fn(_, _) -> _, ); } let class = builder.register(); @@ -414,12 +440,12 @@ mod tests { fn existing_class_override_is_not_replaced() { let state = OcclusionShim::new(); let class = with_method( - "CapOcclusionTestExistingOverride", + c"CapOcclusionTestExistingOverride", NSObject::class(), native_window_state, ); assert!(!state.install(class, window_occlusion_state).unwrap()); - let instance: Id = unsafe { msg_send_id![class, new] }; + let instance: Retained = unsafe { msg_send![class, new] }; RAW_STATE.set(0x2000); let raw: usize = unsafe { msg_send![&*instance, occlusionState] }; assert_eq!(raw, 0x2000); @@ -428,7 +454,7 @@ mod tests { #[test] fn missing_native_method_does_not_install_a_shim() { let state = OcclusionShim::new(); - let class = inherit("CapOcclusionTestMissingMethod", NSObject::class()); + let class = inherit(c"CapOcclusionTestMissingMethod", NSObject::class()); assert!(state.install(class, window_occlusion_state).is_err()); assert!(state.original.get().is_none()); assert!(class.instance_method(sel!(occlusionState)).is_none()); diff --git a/apps/desktop-gpui/src/platform/windows.rs b/apps/desktop-gpui/src/platform/windows.rs index 60ef53662d6..e4a76274900 100644 --- a/apps/desktop-gpui/src/platform/windows.rs +++ b/apps/desktop-gpui/src/platform/windows.rs @@ -671,8 +671,6 @@ pub fn activation_policy() -> isize { -1 } -pub fn show_about_panel(_name: &str, _version: &str) {} - pub fn escape_hotkey_events() -> flume::Receiver<()> { flume::unbounded().1 } diff --git a/apps/desktop-gpui/src/settings_pages.rs b/apps/desktop-gpui/src/settings_pages.rs index c0d5d6bb421..7ffc0113a9f 100644 --- a/apps/desktop-gpui/src/settings_pages.rs +++ b/apps/desktop-gpui/src/settings_pages.rs @@ -20,12 +20,12 @@ use std::{ time::Duration, }; +use cap_cli_install as cli_install; use gpui::{ AppContext as _, Context, Entity, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, rgb, svg, }; -use cap_cli_install as cli_install; use serde::Deserialize; use serde_json::{Map, Value, json}; diff --git a/apps/desktop-gpui/src/settings_window.rs b/apps/desktop-gpui/src/settings_window.rs index cd7dd5b65d4..0fdb185e2d6 100644 --- a/apps/desktop-gpui/src/settings_window.rs +++ b/apps/desktop-gpui/src/settings_window.rs @@ -1832,6 +1832,7 @@ impl SettingsWindow { let theme = self.theme; let (update_label, update_ready) = match crate::updates::status(cx) { crate::updates::UpdateStatus::Idle => ("Check for updates".to_string(), false), + crate::updates::UpdateStatus::Checking => ("Checking for updates…".to_string(), false), crate::updates::UpdateStatus::Downloading { version, fraction: Some(fraction), diff --git a/apps/desktop-gpui/src/snapshot.rs b/apps/desktop-gpui/src/snapshot.rs index ea53a80bfc3..791333fe3a5 100644 --- a/apps/desktop-gpui/src/snapshot.rs +++ b/apps/desktop-gpui/src/snapshot.rs @@ -38,10 +38,10 @@ pub fn arm_from_env(cx: &mut App) { } else if target == "main" { Some(cx.global::().main.into()) } else { - cx.global::() - .onboarding - .map(Into::into) - }; + cx.global::() + .onboarding + .map(Into::into) + }; let Some(handle) = handle else { tracing::warn!("snapshot: window handle missing"); return None; diff --git a/apps/desktop-gpui/src/updates.rs b/apps/desktop-gpui/src/updates.rs index ae48b344e1f..c866bae6e21 100644 --- a/apps/desktop-gpui/src/updates.rs +++ b/apps/desktop-gpui/src/updates.rs @@ -21,6 +21,7 @@ const BUSY_MESSAGE: &str = pub(crate) enum UpdateStatus { #[default] Idle, + Checking, Downloading { version: Version, fraction: Option, @@ -173,6 +174,7 @@ fn set_status(status: UpdateStatus, cx: &mut App) { } scheduler.status = status; crate::app_windows::refresh_settings(cx); + crate::menus::refresh_update_menu(cx); } pub(crate) fn check_manually(cx: &mut App) { @@ -196,7 +198,7 @@ pub(crate) fn check_manually(cx: &mut App) { .detach(); return; } - UpdateStatus::Idle => {} + UpdateStatus::Idle | UpdateStatus::Checking => {} } let scheduler = cx.global_mut::(); @@ -221,7 +223,7 @@ pub(crate) fn update_channel_changed(channel: UpdateChannel, cx: &mut App) { fn channel_switch_drops_pending_package(status: &UpdateStatus) -> bool { matches!( status, - UpdateStatus::Downloading { .. } | UpdateStatus::Ready { .. } + UpdateStatus::Checking | UpdateStatus::Downloading { .. } | UpdateStatus::Ready { .. } ) } @@ -313,7 +315,7 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { if let Some(next_channel) = request.channel { channel = next_channel; ignored_version = None; - let _ = cx.update(drop_pending_update_from_previous_channel); + cx.update(drop_pending_update_from_previous_channel); } let manual = request.manual; @@ -333,6 +335,8 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { continue; } + cx.update(|cx| set_status(UpdateStatus::Checking, cx)); + let result = match cx .update(|cx| gpui_tokio::Tokio::spawn(cx, available_release(channel))) .await @@ -351,12 +355,14 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { channel_changed || (!manual && scheduler.pending.manual) }); if superseded { + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); continue; } let release = match result { Ok(Some(release)) => release, Ok(None) => { + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); if manual { crate::platform::activate_app(); crate::platform::alert_dialog( @@ -368,6 +374,7 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { continue; } Err(error) => { + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); tracing::warn!("update check failed: {error}"); if manual { crate::platform::activate_app(); @@ -382,6 +389,7 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { }; if !manual && ignored_version.as_ref() == Some(&release.version) { + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); continue; } @@ -409,6 +417,8 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { continue; } + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); + crate::platform::activate_app(); let accepted = crate::platform::confirm_dialog( "Update Cap", From 94ce9b8c4cc8dda2b03b0beac9ac3c5e9c625c4e Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:05:06 +0330 Subject: [PATCH 03/75] feat: rebuild the status item menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the tray its own AppKit menu: a mode picker (the current mode as a section header with the mode icons on macOS 14+, the ✓-ticked Recording Mode submenu on older macOS and on Windows), record/take-screenshot rows, Previous ▸ with cover-cropped thumbnails, Import Media…, Settings and Upload Logs, plus the version and the SF Symbols for each row. The Previous thumbnails reuse the Recents thumbnail cache for JPEG sources instead of decoding full-resolution originals, which is what the warm-scan tests cover, and the header follows the palette's highlighted row in italic. --- apps/desktop-gpui/src/main_window.rs | 4 +- apps/desktop-gpui/src/store.rs | 2 +- apps/desktop-gpui/src/tray.rs | 1252 ++++++++++++++++++++----- apps/desktop-gpui/src/tray/windows.rs | 5 + 4 files changed, 1011 insertions(+), 252 deletions(-) diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index a318d54a0f7..3953585cb4b 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -1062,7 +1062,7 @@ impl MainWindow { Self { theme, // `rawOptions.mode` -- the recording mode is a persisted setting, - // and the tray's Select Mode submenu writes the same key, so the + // and the tray's mode picker writes the same key, so the // window has to start where the store left it rather than at a // hardcoded Instant. mode: Mode::from_store(), @@ -1842,7 +1842,7 @@ impl MainWindow { // `RecordingSettingsStore::set_mode` plus the two tray refreshes it // triggers (`handle_mode_selection`). Every mode affordance -- the // pill, the info panel, the mode select window and the tray's own - // Select Mode submenu -- reaches this method, so this is the one place + // mode picker -- reaches this method, so this is the one place // the setting is written. // Written inline, like every other store write in this app (and like // `RecordingSettingsStore::set_mode` itself): a background write would diff --git a/apps/desktop-gpui/src/store.rs b/apps/desktop-gpui/src/store.rs index 73e17c7df78..c6c3406e0e3 100644 --- a/apps/desktop-gpui/src/store.rs +++ b/apps/desktop-gpui/src/store.rs @@ -986,7 +986,7 @@ pub fn set_studio_sound_defaults(enabled: bool, isolation: cap_project::VoiceIso set_store_value(AUDIO_ENHANCEMENT, Value::Object(section)) } /// `RecordingSettingsStore::KEY` (`src-tauri/src/recording_settings.rs:37`) -- -/// the section the tray's Select Mode submenu reads and writes. +/// the section the tray's mode picker reads and writes. pub const RECORDING_SETTINGS: &str = "recording_settings"; #[derive(Clone, Copy, Debug, Default, PartialEq)] diff --git a/apps/desktop-gpui/src/tray.rs b/apps/desktop-gpui/src/tray.rs index 001a6d2a8cf..6d9d44dcee8 100644 --- a/apps/desktop-gpui/src/tray.rs +++ b/apps/desktop-gpui/src/tray.rs @@ -13,11 +13,15 @@ //! foreground task drains that channel and dispatches with a clean borrow. //! Same seam the editor's overlay panels use for AppKit callbacks. //! -//! Menu strings, order and separators are byte-identical to `build_tray_menu`. +//! Labels mirror `build_tray_menu`, except on macOS where the mode group +//! names the current mode and its section header follows the palette's +//! highlight in italic; the grouping is [`build_menu`]'s own. //! "Upload Logs" renders disabled because the log-upload path does not exist //! here. +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; use gpui::{App, Global}; @@ -80,20 +84,120 @@ pub enum TrayItem { #[derive(Clone, Debug, PartialEq)] pub enum Entry { Separator, + ModePicker { + mode: Mode, + }, Item { title: String, item: Option, enabled: bool, - /// A 32x32 PNG, already cover-cropped. `IconMenuItem`'s image. + /// The row's image: a cover-cropped 32x32 photo thumbnail for + /// Previous, a rasterised vector glyph anywhere else. `IconMenuItem`'s + /// image -- Windows draws it; macOS draws `row_symbol`'s SF Symbol + /// instead (photos aside). icon: Option>, }, Submenu { title: String, enabled: bool, items: Vec, + /// The header's glyph, rasterised at [`ROW_ICON_SIZE`] -- the raster + /// Windows draws; macOS draws `PREVIOUS_SYMBOL` instead. + icon: Option>, + }, + /// A non-interactive group label: an `NSMenu` section header on macOS 14+. + SectionHeader { + title: String, }, } +/// The glyphs the action rows carry, keyed for [`menu_icon`] -- the same files +/// their in-app counterparts use. macOS prefers `row_symbol` over these; +/// Windows draws them. +const ROW_ICON_SOURCES: &[(&str, &[u8])] = &[ + ( + "display", + include_bytes!("../assets/icons/screen.svg") as &[u8], + ), + ( + "window", + include_bytes!("../assets/icons/window.svg") as &[u8], + ), + ("area", include_bytes!("../assets/icons/area.svg") as &[u8]), + ( + "screenshot", + include_bytes!("../assets/icons/screenshot.svg") as &[u8], + ), + ( + "import", + include_bytes!("../assets/icons/import.svg") as &[u8], + ), + ( + "settings", + include_bytes!("../assets/icons/settings.svg") as &[u8], + ), + ( + "previous", + include_bytes!("../assets/icons/clock.svg") as &[u8], + ), +]; + +/// `Entry::Item`'s `icon` size, and the raster size of every row glyph. +const ROW_ICON_SIZE: u32 = 32; + +/// An SVG rendered to a `size`×`size` PNG, scaled to fit and centred -- the +/// conversion `cap_mode_icon_png` makes at 90 for the palette, shared with the +/// menu rows at [`ROW_ICON_SIZE`]. Only the alpha survives a template tint. +fn svg_icon_png(source: &[u8], size: u32) -> Option> { + let tree = resvg::usvg::Tree::from_data(source, &resvg::usvg::Options::default()).ok()?; + let mut pixmap = resvg::tiny_skia::Pixmap::new(size, size)?; + let bounds = tree.size(); + let scale = (size as f32 / bounds.width()).min(size as f32 / bounds.height()); + let transform = resvg::tiny_skia::Transform::from_translate( + (size as f32 - bounds.width() * scale) / 2.0, + (size as f32 - bounds.height() * scale) / 2.0, + ) + .pre_scale(scale, scale); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + pixmap.encode_png().ok() +} + +/// A row glyph as a PNG, rasterised once: a menu rebuild is cheap, seven SVG +/// renders are not. +fn menu_icon(name: &str) -> Option> { + static ICONS: LazyLock>> = LazyLock::new(|| { + ROW_ICON_SOURCES + .iter() + .filter_map(|(name, svg)| Some((*name, svg_icon_png(svg, ROW_ICON_SIZE)?))) + .collect() + }); + ICONS.get(name).cloned() +} + +/// The SF Symbol the macOS menu draws for an action row, keyed by its action; +/// Windows renders [`Entry::Item`]'s raster instead. Rows outside this list +/// (the chrome rows, the mode rows of the pre-14 menu) stay bare on both. +#[cfg(target_os = "macos")] +fn row_symbol(item: &TrayItem) -> Option<&'static str> { + match item { + TrayItem::RecordDisplay => Some("display"), + TrayItem::RecordWindow => Some(if objc2::available!(macos = 26.0) { + "interface.window" + } else { + "macwindow" + }), + TrayItem::RecordArea => Some("rectangle.dashed"), + TrayItem::TakeScreenshot => Some("camera.on.rectangle"), + TrayItem::ImportMedia => Some("square.and.arrow.down"), + TrayItem::OpenSettings => Some("gear"), + _ => None, + } +} + +/// The Previous submenu's header glyph, macOS side. +#[cfg(target_os = "macos")] +const PREVIOUS_SYMBOL: &str = "clock.arrow.circlepath"; + impl Entry { fn item(title: impl Into, item: TrayItem) -> Self { Self::Item { @@ -104,6 +208,16 @@ impl Entry { } } + /// An enabled row carrying one of the [`ROW_ICON_SOURCES`] glyphs. + fn icon_item(title: impl Into, item: TrayItem, icon: &str) -> Self { + Self::Item { + title: title.into(), + item: Some(item), + enabled: true, + icon: menu_icon(icon), + } + } + /// An item with no handler: present, greyed out. `MenuItem::with_id(.., /// false, ..)` is how `build_tray_menu` spells the version row. fn disabled(title: impl Into) -> Self { @@ -243,12 +357,14 @@ fn previous_submenu(previous: &[PreviousItem]) -> Entry { return Entry::Submenu { title: "Previous".into(), enabled: false, + icon: menu_icon("previous"), items: vec![Entry::disabled("No recent items")], }; } Entry::Submenu { title: "Previous".into(), enabled: true, + icon: menu_icon("previous"), items: previous .iter() .map(|item| Entry::Item { @@ -265,6 +381,14 @@ fn previous_submenu(previous: &[PreviousItem]) -> Entry { } } +fn mode_name(mode: Mode) -> &'static str { + match mode { + Mode::Studio => "Studio", + Mode::Instant => "Instant", + Mode::Screenshot => "Screenshot", + } +} + /// `create_mode_submenu`: a `✓ ` prefix on the current mode and three spaces on /// the others, so the labels stay aligned without a real check mark. fn mode_submenu(mode: Mode) -> Entry { @@ -283,8 +407,9 @@ fn mode_submenu(mode: Mode) -> Entry { ) }; Entry::Submenu { - title: "Select Mode".into(), + title: "Recording Mode".into(), enabled: true, + icon: None, items: vec![ label(Mode::Studio, "Studio"), label(Mode::Instant, "Instant"), @@ -295,26 +420,59 @@ fn mode_submenu(mode: Mode) -> Entry { /// `build_tray_menu`, minus the onboarding-minimal branch. pub fn build_menu(mode: Mode, previous: &[PreviousItem], version: &str) -> Vec { - let mut entries = vec![Entry::item("Open Main Window", TrayItem::OpenCap)]; + let mut entries = Vec::new(); - if mode == Mode::Screenshot { - entries.push(Entry::item("Screenshot Display", TrayItem::RecordDisplay)); - entries.push(Entry::item("Screenshot Window", TrayItem::RecordWindow)); - entries.push(Entry::item("Screenshot Area", TrayItem::RecordArea)); + entries.push(Entry::item("Open Main Window", TrayItem::OpenCap)); + entries.push(Entry::Separator); + if cfg!(target_os = "macos") { + entries.push(Entry::ModePicker { mode }); + entries.push(Entry::Separator); } else { - entries.push(Entry::item("Record Display", TrayItem::RecordDisplay)); - entries.push(Entry::item("Record Window", TrayItem::RecordWindow)); - entries.push(Entry::item("Record Area", TrayItem::RecordArea)); - entries.push(Entry::item("Take a Screenshot", TrayItem::TakeScreenshot)); + entries.push(mode_submenu(mode)); + entries.push(Entry::Separator); } - entries.push(Entry::item("Import Media...", TrayItem::ImportMedia)); + if mode == Mode::Screenshot { + entries.push(Entry::icon_item( + "Screenshot Display", + TrayItem::RecordDisplay, + "display", + )); + entries.push(Entry::icon_item( + "Screenshot Window", + TrayItem::RecordWindow, + "window", + )); + entries.push(Entry::icon_item( + "Screenshot Area", + TrayItem::RecordArea, + "area", + )); + } else { + entries.push(Entry::icon_item( + "Record Display", + TrayItem::RecordDisplay, + "display", + )); + entries.push(Entry::icon_item( + "Record Window", + TrayItem::RecordWindow, + "window", + )); + entries.push(Entry::icon_item( + "Record Area", + TrayItem::RecordArea, + "area", + )); + entries.push(Entry::icon_item( + "Take a Screenshot", + TrayItem::TakeScreenshot, + "screenshot", + )); + } entries.push(Entry::Separator); - entries.push(mode_submenu(mode)); entries.push(previous_submenu(previous)); - entries.push(Entry::Separator); - entries.push(Entry::item( "View all recordings", TrayItem::ViewAllRecordings, @@ -323,14 +481,32 @@ pub fn build_menu(mode: Mode, previous: &[PreviousItem], version: &str) -> Vec String { fn walk(entries: &[Entry], depth: usize, out: &mut String) { for entry in entries { let pad = " ".repeat(depth); match entry { Entry::Separator => out.push_str(&format!("{pad}---\n")), + Entry::ModePicker { mode } => { + out.push_str(&format!("{pad}{}\n", mode_name(*mode))); + for name in ["Studio", "Instant", "Screenshot"] { + out.push_str(&format!("{}{name}\n", " ".repeat(depth + 1))); + } + } + Entry::SectionHeader { title } => { + out.push_str(&format!("{pad}{title} [section]\n")); + } Entry::Item { title, enabled, @@ -515,10 +701,12 @@ pub fn describe_menu(entries: &[Entry]) -> String { title, enabled, items, + icon, } => { out.push_str(&format!( - "{pad}{title}{}\n", - if *enabled { "" } else { " [disabled]" } + "{pad}{title}{}{}\n", + if *enabled { "" } else { " [disabled]" }, + if icon.is_some() { " [icon]" } else { "" } )); walk(items, depth + 1, out); } @@ -615,14 +803,25 @@ mod mac { use std::cell::RefCell; use gpui::{App, Global}; - use objc2::rc::Id; - use objc2::runtime::{AnyObject, Sel}; - use objc2::{ClassType, DeclaredClass, class, msg_send, msg_send_id, sel}; - use objc2_foundation::NSString; + use objc2::rc::Retained; + use objc2::runtime::{AnyObject, NSObjectProtocol, ProtocolObject, Sel}; + use objc2::{AnyThread, DefinedClass, MainThreadOnly, define_class, msg_send, sel}; + use objc2_app_kit::{ + NSBezierPath, NSColor, NSControlStateValueOff, NSControlStateValueOn, NSEvent, + NSEventModifierFlags, NSFont, NSFontAttributeName, NSFontManager, NSFontTraitMask, NSImage, + NSImageSymbolConfiguration, NSMenu, NSMenuDelegate, NSMenuItem, NSMenuPresentationStyle, + NSStatusBar, NSStatusBarButton, NSStatusItem, NSStringDrawing, NSTrackingArea, + NSTrackingAreaOptions, NSView, + }; + use objc2_foundation::{ + MainThreadMarker, NSArray, NSAttributedString, NSData, NSDictionary, NSPoint, NSRect, + NSSize, NSString, + }; + use std::cell::Cell; use super::{ - Entry, PreviousItem, STOP_ICON, TrayItem, current_menu_entries, handle_item, mode_icon, - scan_previous, stop_recording, + Entry, PREVIOUS_SYMBOL, PreviousItem, STOP_ICON, TrayItem, current_menu_entries, + handle_item, mode_icon, mode_name, mode_submenu, row_symbol, scan_previous, stop_recording, }; use crate::main_window::Mode; @@ -639,42 +838,102 @@ mod mac { /// reason `COLOR_PANEL_TX` is: AppKit only calls the action on the main /// thread, so it needs no lock. static TRAY_TX: RefCell>> = const { RefCell::new(None) }; + /// The mode picker's section header plus the selected mode's name -- + /// the palette delegate swaps one for the other while tracking. + static HEADER: RefCell, String)>> = const { RefCell::new(None) }; } - objc2::declare_class!( - /// The receiver for every tray action. No ivars: the tag identifies the - /// item and the sender lives in the thread-local above. + define_class!( + // SAFETY: NSObject has no subclassing requirements; this class has no ivars or Drop. + #[unsafe(super(objc2::runtime::NSObject))] + #[name = "CapGpuiTrayTarget"] + #[thread_kind = MainThreadOnly] struct TrayTarget; - unsafe impl ClassType for TrayTarget { - type Super = objc2::runtime::NSObject; - type Mutability = objc2::mutability::InteriorMutable; - const NAME: &'static str = "CapGpuiTrayTarget"; - } - - impl DeclaredClass for TrayTarget {} - - unsafe impl TrayTarget { + impl TrayTarget { /// Fires inside AppKit's menu-tracking run loop. Touching gpui here /// is what "RefCell already borrowed" looks like, so it only posts /// the tag. - #[method(capTrayItem:)] - fn tray_item(&self, sender: *mut AnyObject) { - if sender.is_null() { - return; - } - let tag: isize = unsafe { msg_send![sender, tag] }; - post(tag); + #[unsafe(method(capTrayItem:))] + fn tray_item(&self, sender: &NSMenuItem) { + post(sender.tag()); + } + + #[unsafe(method(capTrayMode:))] + fn tray_mode(&self, sender: &NSMenuItem) { + post(sender.tag()); } /// The status button's action while a recording is running. - #[method(capTrayStop:)] - fn tray_stop(&self, _sender: *mut AnyObject) { + #[unsafe(method(capTrayStop:))] + fn tray_stop(&self, _sender: &NSStatusBarButton) { post(STOP_TAG); } } + + unsafe impl NSObjectProtocol for TrayTarget {} + + unsafe impl NSMenuDelegate for TrayTarget { + /// The palette's highlight, fired from inside menu tracking -- + /// only AppKit objects may be touched, same rule as the actions + /// above. + #[unsafe(method(menu:willHighlightItem:))] + fn will_highlight(&self, _menu: &NSMenu, item: Option<&NSMenuItem>) { + HEADER.with(|slot| { + let Some((header, selected)) = slot.borrow().clone() else { + return; + }; + match item { + Some(item) if item.state() != NSControlStateValueOn => { + set_header(&header, &item.title().to_string(), true); + } + _ => set_header(&header, &selected, false), + } + }); + } + } ); + /// Swap the section header between the hovered mode (italic) and the + /// selected one (plain). An attributed title, once set, overrides the + /// plain one -- clearing it is what makes [`NSMenuItem::setTitle`] visible + /// again. + fn set_header(header: &NSMenuItem, text: &str, italic: bool) { + let mtm = MainThreadMarker::new().unwrap(); + let base = + NSFont::systemFontOfSize_weight(11.0, unsafe { objc2_app_kit::NSFontWeightSemibold }); + let font = if italic { + NSFontManager::sharedFontManager(mtm) + .convertFont_toHaveTrait(&base, NSFontTraitMask::ItalicFontMask) + } else { + base + }; + let color = objc2_app_kit::NSColor::labelColor(); + let attrs = NSDictionary::from_slices( + &[unsafe { NSFontAttributeName }, unsafe { + objc2_app_kit::NSForegroundColorAttributeName + }], + &[&*font as &AnyObject, &*color as &AnyObject], + ); + let s = unsafe { + NSAttributedString::initWithString_attributes( + NSAttributedString::alloc(), + &NSString::from_str(text), + Some(&attrs), + ) + }; + header.setAttributedTitle(Some(&s)); + } + + fn cap_mode_icon_png(mode: Mode) -> Option> { + let source = match mode { + Mode::Studio => include_bytes!("../assets/icons/film-cut.svg").as_slice(), + Mode::Instant => include_bytes!("../assets/icons/instant.svg").as_slice(), + Mode::Screenshot => include_bytes!("../assets/icons/screenshot.svg").as_slice(), + }; + super::svg_icon_png(source, 90) + } + fn post(tag: isize) { TRAY_TX.with(|tx| { if let Some(sender) = tx.borrow().as_ref() { @@ -684,11 +943,11 @@ mod mac { } struct Tray { - status_item: Id, - target: Id, + status_item: Retained, + target: Retained, /// Retained so the menu can be detached (recording) and reattached /// (stopped) without rebuilding it. - menu: Option>, + menu: Option>, /// Tag -> item, rebuilt with the menu. The index *is* the tag. actions: Vec, mode: Mode, @@ -748,77 +1007,62 @@ mod mac { } fn create_status_item() -> Option { - unsafe { - let status_bar: *mut AnyObject = msg_send![class!(NSStatusBar), systemStatusBar]; - if status_bar.is_null() { - return None; - } - let raw: *mut AnyObject = - msg_send![status_bar, statusItemWithLength: NS_VARIABLE_STATUS_ITEM_LENGTH]; - // `statusItemWithLength:` hands back an autoreleased object owned - // by the status bar; retain it for as long as we hold it, and never - // release it -- removing a status item is what makes it disappear - // from the menu bar, and this one lives for the process. - let status_item = Id::retain(raw)?; - let target: Id = msg_send_id![TrayTarget::alloc(), init]; - - let mode = Mode::from_store(); - let tray = Tray { - status_item, - target, - menu: None, - actions: Vec::new(), - mode, - previous: Vec::new(), - recording: false, - }; - set_button_image(&tray, mode_icon(mode)); - let button: *mut AnyObject = msg_send![&*tray.status_item, button]; - let visible: bool = msg_send![&*tray.status_item, isVisible]; - let frame: objc2_foundation::NSRect = if button.is_null() { + let mtm = MainThreadMarker::new()?; + let status_item = + NSStatusBar::systemStatusBar().statusItemWithLength(NS_VARIABLE_STATUS_ITEM_LENGTH); + let target: Retained = unsafe { msg_send![TrayTarget::alloc(mtm), init] }; + + let mode = Mode::from_store(); + let tray = Tray { + status_item, + target, + menu: None, + actions: Vec::new(), + mode, + previous: Vec::new(), + recording: false, + }; + set_button_image(&tray, mode_icon(mode)); + let button = tray.status_item.button(mtm); + let frame = button.as_ref().map_or_else( + || { objc2_foundation::NSRect::new( - objc2_foundation::NSPoint::new(0., 0.), - objc2_foundation::NSSize::new(0., 0.), + objc2_foundation::NSPoint::new(0.0, 0.0), + objc2_foundation::NSSize::new(0.0, 0.0), ) - } else { - msg_send![button, frame] - }; - tracing::info!( - has_button = !button.is_null(), - visible, - width = frame.size.width, - height = frame.size.height, - ?mode, - "status item created" - ); - Some(tray) - } + }, + |button| button.frame(), + ); + tracing::info!( + has_button = button.is_some(), + visible = tray.status_item.isVisible(), + width = frame.size.width, + height = frame.size.height, + ?mode, + "status item created" + ); + Some(tray) } /// `set_icon_for_ns_status_item_button`: an 18pt-tall template image on the /// status item's button. fn set_button_image(tray: &Tray, png: &[u8]) { - unsafe { - let button: *mut AnyObject = msg_send![&*tray.status_item, button]; - if button.is_null() { - return; - } - let Some(image) = ns_image(png, ICON_HEIGHT) else { - return; - }; - let _: () = msg_send![button, setImage: &*image]; - // `icon_as_template(cfg!(target_os = "macos"))` in `create_tray`. - let _: () = msg_send![&*image, setTemplate: true]; - // The verification seam. A machine with a full menu bar parks - // overflow status items behind the notch, where they cannot be - // photographed -- so which icon is on the button is logged, keyed - // by the asset's byte length (each of the four differs). - tracing::info!( - icon = icon_name(png), - bytes = png.len(), - "status item icon set" - ); - } + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let Some(button) = tray.status_item.button(mtm) else { + return; + }; + let Some(image) = ns_image(png, ICON_HEIGHT) else { + return; + }; + button.setImage(Some(&image)); + image.setTemplate(true); + tracing::info!( + icon = icon_name(png), + bytes = png.len(), + "status item icon set" + ); } fn icon_name(png: &[u8]) -> &'static str { @@ -839,29 +1083,28 @@ mod mac { /// An `NSImage` from PNG bytes, scaled to `height` points with its aspect /// preserved -- the conversion muda and tray-icon both do. - fn ns_image(png: &[u8], height: f64) -> Option> { - unsafe { - let data: *mut AnyObject = msg_send![ - class!(NSData), - dataWithBytes: png.as_ptr().cast::(), - length: png.len(), - ]; - if data.is_null() { - return None; - } - let alloc: *mut AnyObject = msg_send![class!(NSImage), alloc]; - let raw: *mut AnyObject = msg_send![alloc, initWithData: data]; - let image = Id::from_raw(raw)?; - let size: objc2_foundation::NSSize = msg_send![&*image, size]; - if size.height > 0.0 { - let width = size.width / (size.height / height); - let _: () = msg_send![ - &*image, - setSize: objc2_foundation::NSSize::new(width, height) - ]; - } - Some(image) + fn ns_image(bytes: &[u8], height: f64) -> Option> { + let data = NSData::with_bytes(bytes); + let image = NSImage::initWithData(NSImage::alloc(), &data)?; + let size = image.size(); + if size.height > 0.0 { + image.setSize(objc2_foundation::NSSize::new( + size.width / (size.height / height), + height, + )); + } + Some(image) + } + + /// An SF Symbol at the size the system serves it (15-20pt, the range menu + /// icons live in) -- already a template image. The API only exists on + /// macOS 11+, and an unknown name comes back nil; both cases leave the + /// row bare rather than falling back to the raster, which Windows owns. + fn symbol_image(name: &str) -> Option> { + if !objc2::available!(macos = 11.0) { + return None; } + NSImage::imageWithSystemSymbolName_accessibilityDescription(&NSString::from_str(name), None) } /// Rebuild the menu from the current mode and Previous cache, and attach it @@ -883,12 +1126,9 @@ mod mac { build_ns_menu(&entries, &tray.target, &mut actions) }; - let recording = cx.global::().recording; - unsafe { - if !recording { - let tray = cx.global::(); - let _: () = msg_send![&*tray.status_item, setMenu: &*menu]; - } + let tray = cx.global::(); + if !tray.recording { + tray.status_item.setMenu(Some(&menu)); } let tray = cx.global_mut::(); @@ -900,82 +1140,197 @@ mod mac { entries: &[Entry], target: &TrayTarget, actions: &mut Vec, - ) -> Id { - unsafe { - let alloc: *mut AnyObject = msg_send![class!(NSMenu), alloc]; - let raw: *mut AnyObject = msg_send![alloc, init]; - let menu = Id::from_raw(raw).expect("NSMenu init returned nil"); - // Without this AppKit decides an item's enabled state from whether - // its target responds to the action -- which ours always does, so - // the four deliberately-disabled rows would come back enabled. - let _: () = msg_send![&*menu, setAutoenablesItems: false]; - - for entry in entries { - let item = build_ns_item(entry, target, actions); - let _: () = msg_send![&*menu, addItem: &*item]; + ) -> Retained { + let mtm = + MainThreadMarker::new().expect("tray menu construction must run on the main thread"); + let menu = NSMenu::new(mtm); + menu.setAutoenablesItems(false); + + for entry in entries { + // if let Entry::ModePicker { mode } = entry { + // if objc2::available!(macos = 14.0) { + // let header = NSMenuItem::new(mtm); + // header.setEnabled(false); + // set_header(&header, mode_name(*mode), false); + // HEADER.with(|slot| { + // *slot.borrow_mut() = Some((header.clone(), mode_name(*mode).to_string())); + // }); + // menu.addItem(&header); + // menu.addItem(&build_palette_mode_item(*mode, target, actions, mtm)); + // menu.addItem(&NSMenuItem::separatorItem(mtm)); + // } else { + // let submenu = mode_submenu(*mode); + // menu.addItem(&build_ns_item(&submenu, target, actions, mtm)); + // } + // continue; + // } + if let Entry::ModePicker { mode } = entry { + if objc2::available!(macos = 14.0) { + append_mode_picker(&menu, *mode, actions, mtm); + } else { + let submenu = mode_submenu(*mode); + menu.addItem(&build_ns_item(&submenu, target, actions, mtm)); + } + continue; } - menu + let item = build_ns_item(entry, target, actions, mtm); + menu.addItem(&item); } + + menu } fn build_ns_item( entry: &Entry, target: &TrayTarget, actions: &mut Vec, - ) -> Id { - unsafe { - match entry { - Entry::Separator => { - let raw: *mut AnyObject = msg_send![class!(NSMenuItem), separatorItem]; - Id::retain(raw).expect("separatorItem returned nil") + mtm: MainThreadMarker, + ) -> Retained { + match entry { + Entry::Separator => NSMenuItem::separatorItem(mtm), + Entry::ModePicker { mode } => { + let submenu = mode_submenu(*mode); + build_ns_item(&submenu, target, actions, mtm) + } + Entry::Item { + title, + item, + enabled, + icon, + } => { + let menu_item = new_menu_item(title, Some(sel!(capTrayItem:)), mtm); + unsafe { menu_item.setTarget(Some(target)) }; + menu_item.setEnabled(*enabled && item.is_some()); + if let Some(item) = item { + menu_item.setTag(actions.len() as isize); + actions.push(item.clone()); } - Entry::Item { - title, - item, - enabled, - icon, - } => { - let menu_item = new_menu_item(title, sel!(capTrayItem:)); - let _: () = msg_send![&*menu_item, setTarget: target]; - let _: () = msg_send![&*menu_item, setEnabled: *enabled && item.is_some()]; - if let Some(item) = item { - let tag = actions.len() as isize; - let _: () = msg_send![&*menu_item, setTag: tag]; - actions.push(item.clone()); + if *item == Some(TrayItem::OpenSettings) { + menu_item.setKeyEquivalent(&NSString::from_str(",")); + menu_item.setKeyEquivalentModifierMask(NSEventModifierFlags::Command); + } + // Previous's rows are photo thumbnails; every other iconed row + // is an SF Symbol, template-tinted by the menu. Windows draws + // `icon`'s raster instead. + let photo = matches!(item, Some(TrayItem::PreviousItem(_))); + let image = if photo { + let image = icon + .as_deref() + .and_then(|bytes| ns_image(bytes, ICON_HEIGHT)); + if image.is_none() { + tracing::warn!(%title, "no thumbnail for a previous row"); } - if let Some(icon) = icon - && let Some(image) = ns_image(icon, ICON_HEIGHT) - { - let _: () = msg_send![&*menu_item, setImage: &*image]; + image + } else if let Some(symbol) = item.as_ref().and_then(row_symbol) { + let image = symbol_image(symbol); + if image.is_none() { + tracing::warn!(%title, symbol, "SF Symbol not resolved"); + } + image + } else { + None + }; + + if let Some(image) = image { + image.setTemplate(!photo); + menu_item.setImage(Some(&image)); + + if objc2::available!(macos = 27.0) { + // NSMenuItemImageVisibility: automatic = 0, visible = 1, hidden = 2 + let _: () = + unsafe { msg_send![&*menu_item, setPreferredImageVisibility: 1isize] }; } - menu_item } - Entry::Submenu { - title, - enabled, - items, - } => { - let menu_item = new_menu_item(title, sel!(capTrayItem:)); - let submenu = build_ns_menu(items, target, actions); - let _: () = msg_send![&*menu_item, setSubmenu: &*submenu]; - let _: () = msg_send![&*menu_item, setEnabled: *enabled]; + menu_item + } + Entry::SectionHeader { title } => { + if objc2::available!(macos = 14.0) { + NSMenuItem::sectionHeaderWithTitle(&NSString::from_str(title), mtm) + } else { + let menu_item = NSMenuItem::new(mtm); + menu_item.setTitle(&NSString::from_str(title)); + menu_item.setEnabled(false); menu_item } } + Entry::Submenu { + title, + enabled, + items, + .. + } => { + let menu_item = new_menu_item(title, Some(sel!(capTrayItem:)), mtm); + unsafe { menu_item.setTarget(Some(target)) }; + let submenu = build_ns_menu(items, target, actions); + menu_item.setSubmenu(Some(&submenu)); + // Only Previous carries a header glyph; its own rows keep + // their photos, its header gets the symbol Windows draws the + // raster for. + if title == "Previous" + && let Some(image) = symbol_image(PREVIOUS_SYMBOL) + { + image.setTemplate(true); + menu_item.setImage(Some(&image)); + } + menu_item.setEnabled(*enabled); + menu_item + } } } - fn new_menu_item(title: &str, action: Sel) -> Id { - unsafe { - let alloc: *mut AnyObject = msg_send![class!(NSMenuItem), alloc]; - let raw: *mut AnyObject = msg_send![ - alloc, - initWithTitle: &*NSString::from_str(title), - action: action, - keyEquivalent: &*NSString::from_str(""), - ]; - Id::from_raw(raw).expect("NSMenuItem init returned nil") + fn build_palette_mode_item( + selected: Mode, + target: &TrayTarget, + actions: &mut Vec, + mtm: MainThreadMarker, + ) -> Retained { + let palette = NSMenu::new(mtm); + palette.setAutoenablesItems(false); + palette.setPresentationStyle(NSMenuPresentationStyle::Palette); + palette.setDelegate(Some(ProtocolObject::from_ref(target))); + for mode in [Mode::Instant, Mode::Studio, Mode::Screenshot] { + let item = new_menu_item(mode_name(mode), Some(sel!(capTrayMode:)), mtm); + unsafe { item.setTarget(Some(target)) }; + item.setTag(actions.len() as isize); + item.setToolTip(Some(&NSString::from_str(mode_name(mode)))); + item.setState(if mode == selected { + NSControlStateValueOn + } else { + NSControlStateValueOff + }); + let image = cap_mode_icon_png(mode) + .as_deref() + .and_then(|png| ns_image(png, 30.0)) + .or_else(|| ns_image(mode_icon(mode), 30.0)); + if let Some(image) = image { + image.setTemplate(true); + item.setImage(Some(&image)); + } else { + tracing::warn!(?mode, "palette icon not resolved"); + } + palette.addItem(&item); + actions.push(match mode { + Mode::Studio => TrayItem::ModeStudio, + Mode::Instant => TrayItem::ModeInstant, + Mode::Screenshot => TrayItem::ModeScreenshot, + }); } + + let parent = new_menu_item(mode_name(selected), None, mtm); + parent.setSubmenu(Some(&palette)); + parent + } + + fn new_menu_item( + title: &str, + action: Option, + mtm: MainThreadMarker, + ) -> Retained { + let item = NSMenuItem::new(mtm); + item.setTitle(&NSString::from_str(title)); + unsafe { item.setAction(action) }; + item.setKeyEquivalent(&NSString::from_str("")); + item } /// `RecordingStarted` / `RecordingStopped`. @@ -991,27 +1346,24 @@ mod mac { } cx.global_mut::().recording = recording; let tray = cx.global::(); - unsafe { - let button: *mut AnyObject = msg_send![&*tray.status_item, button]; - if recording { - let _: () = - msg_send![&*tray.status_item, setMenu: std::ptr::null_mut::()]; - if !button.is_null() { - let _: () = msg_send![button, setTarget: &*tray.target]; - let _: () = msg_send![button, setAction: Some(sel!(capTrayStop:))]; - } - set_button_image(tray, STOP_ICON); - } else { - if !button.is_null() { - let _: () = msg_send![button, setAction: None::]; - let _: () = msg_send![button, setTarget: std::ptr::null_mut::()]; - } - if let Some(menu) = &tray.menu { - let _: () = msg_send![&*tray.status_item, setMenu: &**menu]; - } - let icon = mode_icon(tray.mode); - set_button_image(tray, icon); + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let button = tray.status_item.button(mtm); + if recording { + tray.status_item.setMenu(None); + if let Some(button) = button { + unsafe { button.setTarget(Some(&*tray.target)) }; + unsafe { button.setAction(Some(sel!(capTrayStop:))) }; + } + set_button_image(tray, STOP_ICON); + } else { + if let Some(button) = button { + unsafe { button.setAction(None) }; + unsafe { button.setTarget(None) }; } + tray.status_item.setMenu(tray.menu.as_deref()); + set_button_image(tray, mode_icon(tray.mode)); } tracing::info!(recording, "tray recording state"); } @@ -1080,6 +1432,311 @@ mod mac { pub fn refresh_menu(cx: &mut App) { rebuild(cx); } + + // --------------------------------------------------------------------------- + // Mode picker row: title + subtitle on the left, a capsule of three pills on the right + // --------------------------------------------------------------------------- + + const PICKER_MODES: [(Mode, TrayItem, &str); 3] = [ + (Mode::Instant, TrayItem::ModeInstant, "bolt.fill"), + (Mode::Studio, TrayItem::ModeStudio, "film"), + (Mode::Screenshot, TrayItem::ModeScreenshot, "photo"), + ]; + + const PICKER_TITLE: &str = "Recording Mode"; + const PICKER_W: f64 = 300.0; + const PICKER_H: f64 = 41.0; + const PILL_W: f64 = 38.0; + const PILL_H: f64 = 29.0; + const PILL_GAP: f64 = 4.0; + const CAPSULE_PAD: f64 = 3.0; + const RIGHT_INSET: f64 = 10.0; + const TITLE_INSET: f64 = 16.0; + const LINE_GAP: f64 = 2.0; + const SYMBOL_SIZE: f64 = 16.0; + + struct PickerIvars { + selected: Cell, + focused: Cell>, + /// Index into `Tray::actions` of the first pill; pill `i` is `tag_base + i`. + tag_base: isize, + } + + define_class!( + // SAFETY: NSView has no extra subclassing requirements; ivars are Cells, no Drop. + #[unsafe(super(NSView))] + #[name = "CapModePickerView"] + #[thread_kind = MainThreadOnly] + #[ivars = PickerIvars] + struct ModePickerView; + + impl ModePickerView { + #[unsafe(method(acceptsFirstMouse:))] + fn accepts_first_mouse(&self, _event: Option<&NSEvent>) -> bool { + true + } + + #[unsafe(method(viewDidMoveToWindow))] + fn view_did_move_to_window(&self) { + unsafe { + let _: () = msg_send![super(self), viewDidMoveToWindow]; + } + if let Some(window) = self.window() { + window.setAcceptsMouseMovedEvents(true); + } + } + + #[unsafe(method(mouseMoved:))] + fn mouse_moved(&self, event: &NSEvent) { + let hit = self.pill_at(event); + if self.ivars().focused.get() != hit { + self.ivars().focused.set(hit); + self.setNeedsDisplay(true); + } + } + + #[unsafe(method(mouseExited:))] + fn mouse_exited(&self, _event: &NSEvent) { + self.ivars().focused.set(None); + self.setNeedsDisplay(true); + } + + #[unsafe(method(mouseDown:))] + fn mouse_down(&self, event: &NSEvent) { + if let Some(i) = self.pill_at(event) { + // Update the subtitle immediately so the old name never flashes. + self.ivars().focused.set(Some(i)); + self.ivars().selected.set(i); + self.setNeedsDisplay(true); + } + } + + #[unsafe(method(mouseUp:))] + fn mouse_up(&self, event: &NSEvent) { + if let Some(i) = self.pill_at(event) { + self.choose(i); + } + } + + #[unsafe(method(drawRect:))] + fn draw_rect(&self, _dirty: NSRect) { + self.draw_contents(); + } + } + ); + + impl ModePickerView { + fn new(mtm: MainThreadMarker, selected: usize, tag_base: isize) -> Retained { + let this = mtm.alloc::().set_ivars(PickerIvars { + selected: Cell::new(selected), + focused: Cell::new(None), + tag_base, + }); + let frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(PICKER_W, PICKER_H)); + let this: Retained = unsafe { msg_send![super(this), initWithFrame: frame] }; + + let options = NSTrackingAreaOptions::MouseMoved + | NSTrackingAreaOptions::MouseEnteredAndExited + | NSTrackingAreaOptions::ActiveAlways + | NSTrackingAreaOptions::InVisibleRect; + let area = unsafe { + NSTrackingArea::initWithRect_options_owner_userInfo( + NSTrackingArea::alloc(), + NSRect::ZERO, + options, + Some(&this), + None, + ) + }; + this.addTrackingArea(&area); + this + } + + // -- geometry ---------------------------------------------------------- + + fn capsule_rect(&self) -> NSRect { + let n = PICKER_MODES.len() as f64; + let w = n * PILL_W + (n - 1.0) * PILL_GAP + 2.0 * CAPSULE_PAD; + let h = PILL_H + 2.0 * CAPSULE_PAD; + let bounds = self.bounds(); + NSRect::new( + NSPoint::new( + bounds.size.width - RIGHT_INSET - w, + (bounds.size.height - h) / 2.0, + ), + NSSize::new(w, h), + ) + } + + fn pill_rect(&self, i: usize) -> NSRect { + let c = self.capsule_rect(); + NSRect::new( + NSPoint::new( + c.origin.x + CAPSULE_PAD + i as f64 * (PILL_W + PILL_GAP), + c.origin.y + CAPSULE_PAD, + ), + NSSize::new(PILL_W, PILL_H), + ) + } + + fn pill_at(&self, event: &NSEvent) -> Option { + let p = self.convertPoint_fromView(event.locationInWindow(), None); + (0..PICKER_MODES.len()).find(|&i| { + let r = self.pill_rect(i); + p.x >= r.origin.x - PILL_GAP / 2.0 + && p.x <= r.origin.x + r.size.width + PILL_GAP / 2.0 + && p.y >= r.origin.y + && p.y <= r.origin.y + r.size.height + }) + } + + // -- actions ----------------------------------------------------------- + + fn choose(&self, i: usize) { + self.ivars().selected.set(i); + self.setNeedsDisplay(true); + // Same seam as every other tray action: only post the tag. + post(self.ivars().tag_base + i as isize); + if let Some(item) = self.enclosingMenuItem() { + if let Some(menu) = unsafe { item.menu() } { + menu.cancelTracking(); + } + } + } + + // -- drawing ----------------------------------------------------------- + + fn draw_contents(&self) { + let selected = self.ivars().selected.get(); + let focused = self.ivars().focused.get(); + let label = NSColor::labelColor(); + + // Title + subtitle. + let menu_size = NSFont::menuFontOfSize(0.0).pointSize(); + let title_font = NSFont::menuFontOfSize(0.0); + let shown = focused.unwrap_or(selected); + let mut sub_font = NSFont::systemFontOfSize_weight(menu_size - 2.0, unsafe { + objc2_app_kit::NSFontWeightSemibold + }); + if shown != selected { + sub_font = NSFontManager::sharedFontManager( + MainThreadMarker::new().expect("draw runs on the main thread"), + ) + .convertFont_toHaveTrait(&sub_font, NSFontTraitMask::ItalicFontMask); + } + + let title = NSString::from_str(PICKER_TITLE); + let subtitle = NSString::from_str(mode_name(PICKER_MODES[shown].0)); + let title_attrs = NSDictionary::from_slices( + &[unsafe { NSFontAttributeName }, unsafe { + objc2_app_kit::NSForegroundColorAttributeName + }], + &[&*title_font as &AnyObject, &*label as &AnyObject], + ); + let secondary = NSColor::secondaryLabelColor(); + let sub_attrs = NSDictionary::from_slices( + &[unsafe { NSFontAttributeName }, unsafe { + objc2_app_kit::NSForegroundColorAttributeName + }], + &[&*sub_font as &AnyObject, &*secondary as &AnyObject], + ); + let ts = unsafe { title.sizeWithAttributes(Some(&title_attrs)) }; + let ss = unsafe { subtitle.sizeWithAttributes(Some(&sub_attrs)) }; + let base_y = (self.bounds().size.height - (ts.height + ss.height + LINE_GAP)) / 2.0; + unsafe { + subtitle.drawAtPoint_withAttributes( + NSPoint::new(TITLE_INSET, base_y), + Some(&sub_attrs), + ); + title.drawAtPoint_withAttributes( + NSPoint::new(TITLE_INSET, base_y + ss.height + LINE_GAP), + Some(&title_attrs), + ); + } + + // Capsule: radius = half the height gives exact semicircular ends. + let cap = self.capsule_rect(); + label.colorWithAlphaComponent(0.08).setFill(); + NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( + cap, + cap.size.height / 2.0, + cap.size.height / 2.0, + ) + .fill(); + + for (i, (_, _, symbol)) in PICKER_MODES.iter().enumerate() { + let r = self.pill_rect(i); + let is_sel = i == selected; + let is_hover = focused == Some(i) && !is_sel; + + if is_sel { + NSColor::controlAccentColor().setFill(); + } else if is_hover { + label.colorWithAlphaComponent(0.14).setFill(); + } + if is_sel || is_hover { + NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( + r, + r.size.height / 2.0, + r.size.height / 2.0, + ) + .fill(); + } + + // Icon: a palette-coloured symbol, so the selected one is white. + let icon_color = if is_sel { + NSColor::whiteColor() + } else { + label.clone() + }; + if let Some(base) = symbol_image(symbol) { + let size_cfg = NSImageSymbolConfiguration::configurationWithPointSize_weight( + SYMBOL_SIZE, + unsafe { objc2_app_kit::NSFontWeightMedium }, + ); + let color_cfg = NSImageSymbolConfiguration::configurationWithPaletteColors( + &NSArray::from_slice(&[&*icon_color]), + ); + let cfg = size_cfg.configurationByApplyingConfiguration(&color_cfg); + if let Some(img) = base.imageWithSymbolConfiguration(&cfg) { + let s = img.size(); + img.drawInRect(NSRect::new( + NSPoint::new( + r.origin.x + (r.size.width - s.width) / 2.0, + r.origin.y + (r.size.height - s.height) / 2.0, + ), + s, + )); + } + } + } + } + } + + /// Appends the mode picker row to `menu` and registers one action per pill, so a click + /// arrives through the usual tag channel as `ModeInstant` / `ModeStudio` / `ModeScreenshot`. + fn append_mode_picker( + menu: &NSMenu, + selected: Mode, + actions: &mut Vec, + mtm: MainThreadMarker, + ) -> Retained { + let tag_base = actions.len() as isize; + actions.extend(PICKER_MODES.iter().map(|(_, item, _)| item.clone())); + + let index = PICKER_MODES + .iter() + .position(|(m, _, _)| *m == selected) + .unwrap_or(0); + let view = ModePickerView::new(mtm, index, tag_base); + + let item = NSMenuItem::new(mtm); + item.setTitle(&NSString::from_str(PICKER_TITLE)); // also its accessibility title + item.setEnabled(true); + item.setView(Some(&view)); + menu.addItem(&item); + item + } } #[cfg(target_os = "windows")] @@ -1318,6 +1975,8 @@ mod tests { .iter() .map(|entry| match entry { Entry::Separator => "-".to_string(), + Entry::ModePicker { mode } => mode_name(*mode).to_string(), + Entry::SectionHeader { title } => title.clone(), Entry::Item { title, .. } => title.clone(), Entry::Submenu { title, .. } => title.clone(), }) @@ -1336,28 +1995,37 @@ mod tests { #[test] fn menu_matches_build_tray_menu() { let menu = build_menu(Mode::Studio, &[], "0.1.0"); - assert_eq!( - titles(&menu), - vec![ - "Open Main Window", - "Record Display", - "Record Window", - "Record Area", - "Take a Screenshot", - "Import Media...", - "-", - "Select Mode", - "Previous", - "-", - "View all recordings", - "View all screenshots", - "Settings", - "-", - "Upload Logs", - "Cap v0.1.0", - "Quit Cap", - ] - ); + // macOS renders the group as a section header naming the current + // mode; the `mode_submenu` other platforms get keeps its group label. + let mode_label = if cfg!(target_os = "macos") { + "Studio" + } else { + "Recording Mode" + }; + let mut expected = vec!["Open Main Window", "-", mode_label, "-"]; + expected.extend([ + "Record Display", + "Record Window", + "Record Area", + "Take a Screenshot", + "-", + "Previous", + "View all recordings", + "View all screenshots", + "-", + "Import Media…", + "Settings…", + "Upload Logs", + "-", + ]); + // The version row is a section header below Quit on macOS and a + // disabled row above it everywhere else. + if cfg!(target_os = "macos") { + expected.extend(["Quit Cap", "Cap v0.1.0"]); + } else { + expected.extend(["Cap v0.1.0", "Quit Cap"]); + } + assert_eq!(titles(&menu), expected); } /// Screenshot mode relabels the three target rows and drops "Take a @@ -1366,20 +2034,98 @@ mod tests { fn screenshot_mode_relabels_the_capture_rows() { let menu = build_menu(Mode::Screenshot, &[], "0.1.0"); let titles = titles(&menu); + let mode_label = if cfg!(target_os = "macos") { + "Screenshot" + } else { + "Recording Mode" + }; assert_eq!( &titles[..6], &[ "Open Main Window", + "-", + mode_label, + "-", "Screenshot Display", "Screenshot Window", - "Screenshot Area", - "Import Media...", - "-", ] ); assert!(!titles.iter().any(|title| title == "Take a Screenshot")); } + /// The rows that stand for an action carry the glyph their in-app + /// counterpart uses; the chrome rows stay bare. + #[test] + fn the_action_rows_carry_icons() { + let menu = build_menu(Mode::Studio, &[], "0.1.0"); + let with_icons: Vec<_> = menu + .iter() + .filter_map(|entry| match entry { + Entry::Item { title, icon, .. } | Entry::Submenu { title, icon, .. } => { + icon.is_some().then(|| title.clone()) + } + _ => None, + }) + .collect(); + assert_eq!( + with_icons, + [ + "Record Display", + "Record Window", + "Record Area", + "Take a Screenshot", + "Previous", + "Import Media…", + "Settings…", + ] + ); + for bare in ["Open Main Window", "Upload Logs", "Quit Cap"] { + assert!( + !menu.iter().any(|entry| matches!( + entry, + Entry::Item { title, icon: Some(_), .. } if title == bare + )), + "{bare} has no icon" + ); + } + } + + /// macOS draws the action rows from the system symbol catalog; a typo'd + /// or missing name would come back nil and render the row bare. + #[cfg(target_os = "macos")] + #[test] + fn every_row_symbol_resolves_to_an_image() { + use objc2_app_kit::NSImage; + use objc2_foundation::NSString; + + let mut names: Vec<&str> = [ + TrayItem::RecordDisplay, + TrayItem::RecordWindow, + TrayItem::RecordArea, + TrayItem::TakeScreenshot, + TrayItem::ImportMedia, + TrayItem::OpenSettings, + ] + .iter() + .filter_map(|item| row_symbol(item)) + .collect(); + names.push(PREVIOUS_SYMBOL); + assert_eq!(names.len(), 7); + for bare in [TrayItem::OpenCap, TrayItem::Quit, TrayItem::ModeStudio] { + assert!(row_symbol(&bare).is_none()); + } + for name in names { + assert!( + NSImage::imageWithSystemSymbolName_accessibilityDescription( + &NSString::from_str(name), + None, + ) + .is_some(), + "{name} is not a system symbol" + ); + } + } + #[test] fn the_current_mode_is_ticked() { for (mode, expected) in [ @@ -1397,7 +2143,7 @@ mod tests { ), ] { let Entry::Submenu { items, .. } = mode_submenu(mode) else { - panic!("Select Mode is a submenu"); + panic!("the mode picker is a submenu"); }; assert_eq!(titles(&items), expected.to_vec()); } @@ -1409,6 +2155,7 @@ mod tests { title, enabled, items, + .. } = previous_submenu(&[]) else { panic!("Previous is a submenu"); @@ -1466,6 +2213,13 @@ mod tests { _ => None, }) .collect(); - assert_eq!(disabled, vec!["Upload Logs", "Cap v0.1.0"]); + assert_eq!( + disabled, + if cfg!(target_os = "macos") { + vec!["Upload Logs"] + } else { + vec!["Upload Logs", "Cap v0.1.0"] + } + ); } } diff --git a/apps/desktop-gpui/src/tray/windows.rs b/apps/desktop-gpui/src/tray/windows.rs index 04de1870557..a3d352f77ea 100644 --- a/apps/desktop-gpui/src/tray/windows.rs +++ b/apps/desktop-gpui/src/tray/windows.rs @@ -155,6 +155,7 @@ fn build_native_item( ) -> anyhow::Result> { match entry { Entry::Separator => Ok(Box::new(PredefinedMenuItem::separator())), + Entry::ModePicker { mode } => build_native_item(&super::mode_submenu(*mode), actions), Entry::Item { title, item, @@ -185,10 +186,14 @@ fn build_native_item( } Ok(native_item) } + Entry::SectionHeader { title } => { + Ok(Box::new(MenuItem::new(menu_text(title), false, None))) + } Entry::Submenu { title, enabled, items, + .. } => { let submenu = Submenu::new(menu_text(title), *enabled); for entry in items { From 4ecabf9e55215cdb1640856154d298133bcf4770 Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:08:36 +0330 Subject: [PATCH 04/75] docs: correct the gpui README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the tray row's Settings glyph (`gear`, not `gearshape`) and the macOS 26 window symbol, document the File menu (Open File…, Import Media…, Open Recent), the Help destinations and the live update row. --- apps/desktop-gpui/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index e54d9bbb361..f29044473cf 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -636,8 +636,8 @@ Sizes are the Tauri app's, from `apps/desktop/src-tauri/src/windows.rs`. | Teleprompter | 560×320 | **Done, with deviations** — resizable to the 420×220 floor, level 101 on all Spaces, the `"teleprompter"` material at radius 22, traffic lights at (14, 11) — the Tauri `(14, 14)` is a `position_window_controls` inset, not a top-left, auto-scroll from the ported `teleprompter-utils` maths, the full footer and settings popover, native window opacity, the `teleprompter` store section, capture exclusion + content protection. The script editor is a real multi-line field and Mirror is inert — see below | | Editor | 1275×800 | **E1–E5 done — window, shell, playback, timeline, editing, config sidebar.** The real 1275×800 window with the traffic lights at (20, 20) — the Tauri `(20, 32)` is a `position_window_controls` inset, not a top-left, the header, the letterboxed player, the 260px timeline strip and the 416px config sidebar with its six-tab rail; a real project through `EditorInstance`; play/pause (button + Space), a 60fps live playhead and clock, real audio, click/drag seeking, end-of-media stop; the timeline at 1:1 — all nine track types from the project's own config, waveforms, the ruler's resolution ladder, minimap, edge fade, hover ghost, zoom (keys, buttons, slider, wheel, pinch) and pan; and **timeline editing**: selection (single, ⌘-multi, ⇧-range, ⌘A), trim, move, split (S + C, with snapping), zoom-segment create/resize/move, delete, undo/redo and the debounced write back to `project-config.json`. and **the config sidebar in full**: the six-tab rail, the scroll body, selection routing, all six tabs at 1:1, the shared colour-correction section on both its targets, and the eight per-segment panels with multi-select where the source has it. Crop and export are the remaining units | | Screenshot editor | 1240×800 (min 800×600) | **Done, with deviations** — the real window (native traffic lights, resizable to the 800×600 floor), one editor per bundle keyed like the video editor's registry, opened from capture, Recents, the settings Screenshots page, the tray's Previous and image import. The still renders through the same GPU path the Tauri instance uses (`FrameRenderer::render_immediate`, `preserve_screen_alpha`, no camera) on a config watch, painted with `paint_image` instead of shipped over the frame websocket; edits publish to the renderer live, ride the 1000ms debounced `project-config.json` write, and walk a real undo history (⌘Z / ⌘⇧Z / ⌘Y, a drag's sixty intermediate values collapsed into one entry). Styling: background source tabs (color swatches, gradient presets, wallpaper grid with theme tabs and cached thumbnails, image picker), hex fields, blur/padding/rounding/shadow sliders at the popovers' ranges plus the advanced-shadow collapsible, squircle/rounded corner style, border toggle + width/color/opacity, aspect-ratio menu. Annotations: the full engine — all seven tools with their single-key shortcuts, the frame-space geometry of `layout.ts`/`arrow.ts` verbatim, selection handles, the per-type config bar (stroke/fill swatches with the colour popover, width/opacity/mask-level/text-size sliders), the layers panel with drag reorder, blur/pixelate masks resampled live off the rendered frame, inline text editing, ⌘C/⌘V duplicate, delete. Crop dialog: the `editor_crop` engine wholesale over `original.png` (`screenshot_crop.rs`) — Size/Position number boxes, the round ratio button and its options menu (right-click opens the same one at the cursor) with the snap-to-ratio toggle persisted in the shared store, Full/Reset, arrow-key nudges, one history entry on Save, Escape/backdrop cancel. Export: Copy/Save/Share all bake the annotations in (`screenshot_export.rs` — the `renderScreenshotExportCanvas` pass: masks re-filtered at export scale from an unmodified copy, shapes via tiny-skia, text via cosmic-text at the preview's Helvetica baseline, the output expanded to the union of image and annotation boxes with the source's white-fill rules); Copy composites over white and lands PNG on the clipboard, Save writes PNG, Share fingerprints the config (`sha256` over recursively key-sorted JSON), short-circuits to the stored link on a hash match, and otherwise uploads JPEG-0.9 (PNG when transparency is actually needed) through create-or-get + presigned PUT — reusing `sharing.id` so the link survives edits — then persists `SharingMeta { id, link, content_hash }`, with Tauri's exact auth gates and toast ladder; reveal, delete behind a native confirm. Deviations: no OCR text-selection overlay (Vision OCR feeding an invisible DOM-style selectable text layer has no gpui equivalent yet; the layer is invisible in the Tauri editor too, so zero visual impact); the colour swatches are inert — no native colour panel is wired to this window, the hex fields are the input; the skeleton's Cap logo is drawn still (the app ships no bare glyph to spin, only the full lockup); the crop options menu has no separator row (`ui::Menu` has none, so its two groups sit adjacent); no `PendingScreenshots` in-memory hand-off (the PNG is on disk before the editor opens) | -| Tray (status item) | menu bar | **Done, with deviations** — a native `NSStatusItem` (no gpui API exists for one; built on AppKit with the color-panel channel discipline), menu byte-identical to `build_tray_menu`: Open Main Window, Record Display/Window/Area (screenshot-mode relabels), Select Mode ▸ with the ✓, Previous ▸ with 32px cover-cropped thumbnails and the 🎬⚡📷 prefixes, View all recordings / screenshots, Settings, version row, Quit Cap. The icon follows the mode and becomes the stop icon while recording, when clicking the item stops the capture (the menu is detached so the button's action can fire). Select Mode writes the same `recording_settings.mode` store key the Tauri app reads. Take a Screenshot captures the display under the cursor and Import Media… routes video/image imports; a Previous screenshot opens the screenshot editor. Deviations: Upload Logs renders disabled (no log-upload infrastructure yet) | -| App menu + shortcuts | menu bar | **Done** — the `build_macos_app_menu` structure (Cap / File / Edit / View / Window / Help) via `cx.set_menus`, with ⌘W closing the key window through each window's own close path (main hides, Tauri's `CloseRequested` arm transcribed: camera bubble closed, overlays closed, feeds released when idle), ⌘M / Zoom / ⌃⌘F on the key window, ⌘H / ⌥⌘H, ⌘Q, and Edit items dispatching the text-input actions. Dock-icon policy (Regular ↔ Accessory per `hideDockIcon` + visible windows) synced on every window show/hide seam | +| Tray (status item) | menu bar | **Done, with deviations** — a native `NSStatusItem` (no gpui API exists for one; built on AppKit with the color-panel channel discipline), menu in `build_menu`'s own grouping: Open Main Window; the current mode as the group label (an `NSMenu` section header reading e.g. `Studio` plus a palette item with the mode icons on macOS 14+ (the header follows the palette's highlight in italic), a ✓-ticked `Recording Mode ▸` submenu on older macOS and on Windows); Record Display/Window/Area + Take a Screenshot (screenshot-mode relabels); Previous ▸ (clock glyph) with 32px cover-cropped thumbnails and the 🎬⚡📷 prefixes, View all recordings / screenshots; Import Media…, Settings (⌘,), Upload Logs; Quit Cap with the version as a trailing section header on macOS and as a disabled row above Quit elsewhere. The four record rows, Previous ▸, Import Media… and Settings each carry an SF Symbol (`display`, `interface.window` (`macwindow` below macOS 26), `rectangle.dashed`, `camera.on.rectangle`, `clock.arrow.circlepath`, `square.and.arrow.down`, `gear`) drawn straight from the system catalog on macOS 11+ with no fallback — an unresolvable name leaves the row bare — while Windows draws the cached 32×32 raster of the same SVG their in-app buttons use (`ROW_ICON_SOURCES` / `svg_icon_png`); the macOS glyphs are template images the menu tints, and Previous's own rows keep their photo thumbnails; the chrome rows (Open Main Window, View all …, Upload Logs, Quit) stay bare. The icon follows the mode and becomes the stop icon while recording, when clicking the item stops the capture (the menu is detached so the button's action can fire). The mode picker writes the same `recording_settings.mode` store key the Tauri app reads. Take a Screenshot captures the display under the cursor and Import Media… routes video/image imports; a Previous screenshot opens the screenshot editor. Deviations: Upload Logs renders disabled (no log-upload infrastructure yet) | +| App menu + shortcuts | menu bar | **Done** — the `build_macos_app_menu` structure (Cap / File / Edit / View / Window / Help) via `cx.set_menus`, with ⌘W closing the key window through each window's own close path (main hides, Tauri's `CloseRequested` arm transcribed: camera bubble closed, overlays closed, feeds released when idle), ⌘M / Zoom / ⌃⌘F on the key window, ⌘H / ⌥⌘H, ⌘Q, and Edit items dispatching the text-input actions. The Cap menu carries About (AppKit's own `orderFrontStandardAboutPanel:`, patched in by row position, so there is no gpui About action), Check for Update and Preferences… with ⌘,. The update row resolves to three states — `Check for Update`, `Download update…` and `Restart to update…`, with `Checking for Update…` greyed out while a check runs — and is rewritten in place in the live `NSMenu` (`platform::set_update_menu_item`, by row position, `setBadge:` on macOS 14+) so an open menu survives a status change; the version, and the download percentage on macOS, ride the badge, and the menu bar is only reinstalled on the other platforms when the state itself changes. File carries Open File… (an existing recording or screenshot, routed through the tray's `open_previous_item`), Import Media… (the same picker the tray runs), the AppKit-managed Open Recent list — every editor open registers its bundle with `add_recent_document`, and a click comes back through `application:openURLs:` → `deeplink` → `open_editor` — and Close Window. Help carries the Changelog and the cap.so/help links. Dock-icon policy (Regular ↔ Accessory per `hideDockIcon` + visible windows) synced on every window show/hide seam | ## Recording From cd103791c9581ea450a92f7f416ae0e485c3f7f6 Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:33:10 +0330 Subject: [PATCH 05/75] feat: improved mode picker visuals + keyboard navigation for the custom mode picker view --- apps/desktop-gpui/Cargo.lock | 9 + apps/desktop-gpui/Cargo.toml | 6 + apps/desktop-gpui/src/platform.rs | 16 +- apps/desktop-gpui/src/tray.rs | 496 ++---------------- apps/desktop-gpui/src/tray/mac_mode_picker.rs | 473 +++++++++++++++++ 5 files changed, 546 insertions(+), 454 deletions(-) create mode 100644 apps/desktop-gpui/src/tray/mac_mode_picker.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index bc4cef361ff..869eb69b140 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1584,7 +1584,9 @@ dependencies = [ "minisign-verify", "objc2 0.6.5", "objc2-app-kit 0.3.2", + "objc2-core-graphics", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "parakeet-rs", "raw-window-handle", "reqwest 0.12.28", @@ -7179,10 +7181,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", + "objc2-metal 0.3.2", ] [[package]] @@ -7385,8 +7390,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", + "libc", "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", ] diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index aef97037c93..a713117fbee 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -239,6 +239,12 @@ objc2-app-kit = { version = "0.3.2", features = [ "NSWorkspace", "NSAccessibility", ] } +objc2-quartz-core = { version = "0.3.2", features = [ + "CALayer", + "CATransaction", + "objc2-core-foundation", +] } +objc2-core-graphics = { version = "0.3.2", features = ["CGColor"] } # NSRect/NSPoint/NSSize for `setFrame:display:`. Already in the tree as # objc2-app-kit's own dependency, so this adds nothing to the build. objc2-foundation = { version = "0.3.2", features = [ diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 2e90f470d4c..8e39012a4fc 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -1487,7 +1487,7 @@ mod mac { ns_window.setLevel(behavior.level); tracing::debug!( requested = behavior.level, - actual = unsafe { ns_window.level() }, + actual = ns_window.level(), "panel level applied" ); if behavior.join_all_spaces { @@ -1571,7 +1571,7 @@ mod mac { pub fn set_window_click_through(native: &NativeWindow, click_through: bool) -> bool { native.0.setIgnoresMouseEvents(click_through); - unsafe { native.0.ignoresMouseEvents() == click_through } + native.0.ignoresMouseEvents() == click_through } /// `orderOut:` -- hide without closing, the way the Tauri main window @@ -2148,7 +2148,7 @@ mod mac { /// Tauri app calls on the recording controls panel. pub fn show_window_without_focus(window: &Window) { if let Some(ns_window) = ns_window(window) { - unsafe { ns_window.orderFrontRegardless() }; + ns_window.orderFrontRegardless(); rerun_occlusion_handler_after_show(&ns_window); } } @@ -2156,7 +2156,7 @@ mod mac { /// The CGWindowID, which is also what `scap_targets::WindowId` wraps on /// macOS -- used to exclude our own controls bar from the capture. pub fn window_number(window: &Window) -> Option { - ns_window(window).map(|w| unsafe { w.windowNumber() }) + ns_window(window).map(|w| w.windowNumber()) } // -- The open panel ------------------------------------------------------ @@ -2651,10 +2651,10 @@ mod mac { "occlusion_raw={:#x} visible={} on_active_space={} alpha={} level={} number={}", w.occlusionState().bits(), w.isVisible(), - unsafe { w.isOnActiveSpace() }, - unsafe { w.alphaValue() }, - unsafe { w.level() }, - unsafe { w.windowNumber() }, + w.isOnActiveSpace(), + w.alphaValue(), + w.level(), + w.windowNumber(), )) } } diff --git a/apps/desktop-gpui/src/tray.rs b/apps/desktop-gpui/src/tray.rs index 6d9d44dcee8..a67c0ea5f16 100644 --- a/apps/desktop-gpui/src/tray.rs +++ b/apps/desktop-gpui/src/tray.rs @@ -798,27 +798,29 @@ fn harness_item(spec: &str, cx: &App) -> Option { #[cfg(target_os = "macos")] pub use mac::*; +#[cfg(target_os = "macos")] +mod mac_mode_picker; + #[cfg(target_os = "macos")] mod mac { - use std::cell::RefCell; + use std::cell::{Cell, RefCell}; use gpui::{App, Global}; use objc2::rc::Retained; - use objc2::runtime::{AnyObject, NSObjectProtocol, ProtocolObject, Sel}; + use objc2::runtime::{NSObjectProtocol, ProtocolObject, Sel}; use objc2::{AnyThread, DefinedClass, MainThreadOnly, define_class, msg_send, sel}; use objc2_app_kit::{ - NSBezierPath, NSColor, NSControlStateValueOff, NSControlStateValueOn, NSEvent, - NSEventModifierFlags, NSFont, NSFontAttributeName, NSFontManager, NSFontTraitMask, NSImage, - NSImageSymbolConfiguration, NSMenu, NSMenuDelegate, NSMenuItem, NSMenuPresentationStyle, - NSStatusBar, NSStatusBarButton, NSStatusItem, NSStringDrawing, NSTrackingArea, - NSTrackingAreaOptions, NSView, + NSControlStateValueOff, NSControlStateValueOn, NSEventModifierFlags, NSFont, + NSFontAttributeName, NSFontManager, NSFontTraitMask, NSImage, NSMenu, NSMenuDelegate, + NSMenuItem, NSMenuPresentationStyle, NSStatusBar, NSStatusBarButton, NSStatusItem, + NSStringDrawing, NSTrackingArea, NSTrackingAreaOptions, NSView, }; use objc2_foundation::{ - MainThreadMarker, NSArray, NSAttributedString, NSData, NSDictionary, NSPoint, NSRect, - NSSize, NSString, + MainThreadMarker, NSData, NSDictionary, NSPoint, NSRect, NSSize, NSString, }; - use std::cell::Cell; + use objc2_quartz_core::{CALayer, CATransaction, kCACornerCurveContinuous}; + use super::mac_mode_picker::{append_mode_picker, install_key_hook}; use super::{ Entry, PREVIOUS_SYMBOL, PreviousItem, STOP_ICON, TrayItem, current_menu_entries, handle_item, mode_icon, mode_name, mode_submenu, row_symbol, scan_previous, stop_recording, @@ -832,15 +834,12 @@ mod mac { const ICON_HEIGHT: f64 = 18.0; /// Not a menu item: the status button itself, clicked mid-recording. const STOP_TAG: isize = -1; + const REBUILD_TAG: isize = -2; thread_local! { - /// The live sender, read by the ObjC action. `thread_local` for the - /// reason `COLOR_PANEL_TX` is: AppKit only calls the action on the main - /// thread, so it needs no lock. static TRAY_TX: RefCell>> = const { RefCell::new(None) }; - /// The mode picker's section header plus the selected mode's name -- - /// the palette delegate swaps one for the other while tracking. - static HEADER: RefCell, String)>> = const { RefCell::new(None) }; + static MENU_OPEN: Cell = const { Cell::new(false) }; + static REBUILD_PENDING: Cell = const { Cell::new(false) }; } define_class!( @@ -874,67 +873,22 @@ mod mac { unsafe impl NSObjectProtocol for TrayTarget {} unsafe impl NSMenuDelegate for TrayTarget { - /// The palette's highlight, fired from inside menu tracking -- - /// only AppKit objects may be touched, same rule as the actions - /// above. - #[unsafe(method(menu:willHighlightItem:))] - fn will_highlight(&self, _menu: &NSMenu, item: Option<&NSMenuItem>) { - HEADER.with(|slot| { - let Some((header, selected)) = slot.borrow().clone() else { - return; - }; - match item { - Some(item) if item.state() != NSControlStateValueOn => { - set_header(&header, &item.title().to_string(), true); - } - _ => set_header(&header, &selected, false), - } - }); + #[unsafe(method(menuWillOpen:))] + fn menu_will_open(&self, _menu: &NSMenu) { + MENU_OPEN.with(|o| o.set(true)); + } + + #[unsafe(method(menuDidClose:))] + fn menu_did_close(&self, _menu: &NSMenu) { + MENU_OPEN.with(|o| o.set(false)); + if REBUILD_PENDING.with(|p| p.replace(false)) { + post(REBUILD_TAG); // handled by the drain, with a clean borrow + } } } ); - /// Swap the section header between the hovered mode (italic) and the - /// selected one (plain). An attributed title, once set, overrides the - /// plain one -- clearing it is what makes [`NSMenuItem::setTitle`] visible - /// again. - fn set_header(header: &NSMenuItem, text: &str, italic: bool) { - let mtm = MainThreadMarker::new().unwrap(); - let base = - NSFont::systemFontOfSize_weight(11.0, unsafe { objc2_app_kit::NSFontWeightSemibold }); - let font = if italic { - NSFontManager::sharedFontManager(mtm) - .convertFont_toHaveTrait(&base, NSFontTraitMask::ItalicFontMask) - } else { - base - }; - let color = objc2_app_kit::NSColor::labelColor(); - let attrs = NSDictionary::from_slices( - &[unsafe { NSFontAttributeName }, unsafe { - objc2_app_kit::NSForegroundColorAttributeName - }], - &[&*font as &AnyObject, &*color as &AnyObject], - ); - let s = unsafe { - NSAttributedString::initWithString_attributes( - NSAttributedString::alloc(), - &NSString::from_str(text), - Some(&attrs), - ) - }; - header.setAttributedTitle(Some(&s)); - } - - fn cap_mode_icon_png(mode: Mode) -> Option> { - let source = match mode { - Mode::Studio => include_bytes!("../assets/icons/film-cut.svg").as_slice(), - Mode::Instant => include_bytes!("../assets/icons/instant.svg").as_slice(), - Mode::Screenshot => include_bytes!("../assets/icons/screenshot.svg").as_slice(), - }; - super::svg_icon_png(source, 90) - } - - fn post(tag: isize) { + pub(super) fn post(tag: isize) { TRAY_TX.with(|tx| { if let Some(sender) = tx.borrow().as_ref() { let _ = sender.send(tag); @@ -965,6 +919,7 @@ mod mac { return; }; cx.set_global(tray); + install_key_hook(); let (tx, rx) = flume::unbounded::(); TRAY_TX.with(|slot| *slot.borrow_mut() = Some(tx)); @@ -974,9 +929,16 @@ mod mac { cx.spawn(async move |cx| { while let Ok(tag) = rx.recv_async().await { cx.update(|cx| { - if tag == STOP_TAG { - stop_recording(cx); - return; + match tag { + STOP_TAG => { + stop_recording(cx); + return; + } + REBUILD_TAG => { + rebuild(cx); + return; + } + _ => {} } let item = cx.global::().actions.get(tag as usize).cloned(); match item { @@ -1100,7 +1062,7 @@ mod mac { /// icons live in) -- already a template image. The API only exists on /// macOS 11+, and an unknown name comes back nil; both cases leave the /// row bare rather than falling back to the raster, which Windows owns. - fn symbol_image(name: &str) -> Option> { + pub(super) fn symbol_image(name: &str) -> Option> { if !objc2::available!(macos = 11.0) { return None; } @@ -1111,6 +1073,14 @@ mod mac { /// (unless a recording is running, in which case the *button* owns the /// click -- see [`set_recording`]). pub fn rebuild(cx: &mut App) { + if MENU_OPEN.with(Cell::get) { + REBUILD_PENDING.with(|p| p.set(true)); + return; + } + if !cx.has_global::() { + return; + } + if !cx.has_global::() { return; } @@ -1121,10 +1091,9 @@ mod mac { let entries = current_menu_entries(cx, mode, &previous); let mut actions = Vec::new(); - let menu = { - let tray = cx.global::(); - build_ns_menu(&entries, &tray.target, &mut actions) - }; + let tray = cx.global::(); + let menu = build_ns_menu(&entries, &tray.target, &mut actions); + menu.setDelegate(Some(ProtocolObject::from_ref(&*tray.target))); let tray = cx.global::(); if !tray.recording { @@ -1147,23 +1116,6 @@ mod mac { menu.setAutoenablesItems(false); for entry in entries { - // if let Entry::ModePicker { mode } = entry { - // if objc2::available!(macos = 14.0) { - // let header = NSMenuItem::new(mtm); - // header.setEnabled(false); - // set_header(&header, mode_name(*mode), false); - // HEADER.with(|slot| { - // *slot.borrow_mut() = Some((header.clone(), mode_name(*mode).to_string())); - // }); - // menu.addItem(&header); - // menu.addItem(&build_palette_mode_item(*mode, target, actions, mtm)); - // menu.addItem(&NSMenuItem::separatorItem(mtm)); - // } else { - // let submenu = mode_submenu(*mode); - // menu.addItem(&build_ns_item(&submenu, target, actions, mtm)); - // } - // continue; - // } if let Entry::ModePicker { mode } = entry { if objc2::available!(macos = 14.0) { append_mode_picker(&menu, *mode, actions, mtm); @@ -1278,49 +1230,6 @@ mod mac { } } - fn build_palette_mode_item( - selected: Mode, - target: &TrayTarget, - actions: &mut Vec, - mtm: MainThreadMarker, - ) -> Retained { - let palette = NSMenu::new(mtm); - palette.setAutoenablesItems(false); - palette.setPresentationStyle(NSMenuPresentationStyle::Palette); - palette.setDelegate(Some(ProtocolObject::from_ref(target))); - for mode in [Mode::Instant, Mode::Studio, Mode::Screenshot] { - let item = new_menu_item(mode_name(mode), Some(sel!(capTrayMode:)), mtm); - unsafe { item.setTarget(Some(target)) }; - item.setTag(actions.len() as isize); - item.setToolTip(Some(&NSString::from_str(mode_name(mode)))); - item.setState(if mode == selected { - NSControlStateValueOn - } else { - NSControlStateValueOff - }); - let image = cap_mode_icon_png(mode) - .as_deref() - .and_then(|png| ns_image(png, 30.0)) - .or_else(|| ns_image(mode_icon(mode), 30.0)); - if let Some(image) = image { - image.setTemplate(true); - item.setImage(Some(&image)); - } else { - tracing::warn!(?mode, "palette icon not resolved"); - } - palette.addItem(&item); - actions.push(match mode { - Mode::Studio => TrayItem::ModeStudio, - Mode::Instant => TrayItem::ModeInstant, - Mode::Screenshot => TrayItem::ModeScreenshot, - }); - } - - let parent = new_menu_item(mode_name(selected), None, mtm); - parent.setSubmenu(Some(&palette)); - parent - } - fn new_menu_item( title: &str, action: Option, @@ -1432,311 +1341,6 @@ mod mac { pub fn refresh_menu(cx: &mut App) { rebuild(cx); } - - // --------------------------------------------------------------------------- - // Mode picker row: title + subtitle on the left, a capsule of three pills on the right - // --------------------------------------------------------------------------- - - const PICKER_MODES: [(Mode, TrayItem, &str); 3] = [ - (Mode::Instant, TrayItem::ModeInstant, "bolt.fill"), - (Mode::Studio, TrayItem::ModeStudio, "film"), - (Mode::Screenshot, TrayItem::ModeScreenshot, "photo"), - ]; - - const PICKER_TITLE: &str = "Recording Mode"; - const PICKER_W: f64 = 300.0; - const PICKER_H: f64 = 41.0; - const PILL_W: f64 = 38.0; - const PILL_H: f64 = 29.0; - const PILL_GAP: f64 = 4.0; - const CAPSULE_PAD: f64 = 3.0; - const RIGHT_INSET: f64 = 10.0; - const TITLE_INSET: f64 = 16.0; - const LINE_GAP: f64 = 2.0; - const SYMBOL_SIZE: f64 = 16.0; - - struct PickerIvars { - selected: Cell, - focused: Cell>, - /// Index into `Tray::actions` of the first pill; pill `i` is `tag_base + i`. - tag_base: isize, - } - - define_class!( - // SAFETY: NSView has no extra subclassing requirements; ivars are Cells, no Drop. - #[unsafe(super(NSView))] - #[name = "CapModePickerView"] - #[thread_kind = MainThreadOnly] - #[ivars = PickerIvars] - struct ModePickerView; - - impl ModePickerView { - #[unsafe(method(acceptsFirstMouse:))] - fn accepts_first_mouse(&self, _event: Option<&NSEvent>) -> bool { - true - } - - #[unsafe(method(viewDidMoveToWindow))] - fn view_did_move_to_window(&self) { - unsafe { - let _: () = msg_send![super(self), viewDidMoveToWindow]; - } - if let Some(window) = self.window() { - window.setAcceptsMouseMovedEvents(true); - } - } - - #[unsafe(method(mouseMoved:))] - fn mouse_moved(&self, event: &NSEvent) { - let hit = self.pill_at(event); - if self.ivars().focused.get() != hit { - self.ivars().focused.set(hit); - self.setNeedsDisplay(true); - } - } - - #[unsafe(method(mouseExited:))] - fn mouse_exited(&self, _event: &NSEvent) { - self.ivars().focused.set(None); - self.setNeedsDisplay(true); - } - - #[unsafe(method(mouseDown:))] - fn mouse_down(&self, event: &NSEvent) { - if let Some(i) = self.pill_at(event) { - // Update the subtitle immediately so the old name never flashes. - self.ivars().focused.set(Some(i)); - self.ivars().selected.set(i); - self.setNeedsDisplay(true); - } - } - - #[unsafe(method(mouseUp:))] - fn mouse_up(&self, event: &NSEvent) { - if let Some(i) = self.pill_at(event) { - self.choose(i); - } - } - - #[unsafe(method(drawRect:))] - fn draw_rect(&self, _dirty: NSRect) { - self.draw_contents(); - } - } - ); - - impl ModePickerView { - fn new(mtm: MainThreadMarker, selected: usize, tag_base: isize) -> Retained { - let this = mtm.alloc::().set_ivars(PickerIvars { - selected: Cell::new(selected), - focused: Cell::new(None), - tag_base, - }); - let frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(PICKER_W, PICKER_H)); - let this: Retained = unsafe { msg_send![super(this), initWithFrame: frame] }; - - let options = NSTrackingAreaOptions::MouseMoved - | NSTrackingAreaOptions::MouseEnteredAndExited - | NSTrackingAreaOptions::ActiveAlways - | NSTrackingAreaOptions::InVisibleRect; - let area = unsafe { - NSTrackingArea::initWithRect_options_owner_userInfo( - NSTrackingArea::alloc(), - NSRect::ZERO, - options, - Some(&this), - None, - ) - }; - this.addTrackingArea(&area); - this - } - - // -- geometry ---------------------------------------------------------- - - fn capsule_rect(&self) -> NSRect { - let n = PICKER_MODES.len() as f64; - let w = n * PILL_W + (n - 1.0) * PILL_GAP + 2.0 * CAPSULE_PAD; - let h = PILL_H + 2.0 * CAPSULE_PAD; - let bounds = self.bounds(); - NSRect::new( - NSPoint::new( - bounds.size.width - RIGHT_INSET - w, - (bounds.size.height - h) / 2.0, - ), - NSSize::new(w, h), - ) - } - - fn pill_rect(&self, i: usize) -> NSRect { - let c = self.capsule_rect(); - NSRect::new( - NSPoint::new( - c.origin.x + CAPSULE_PAD + i as f64 * (PILL_W + PILL_GAP), - c.origin.y + CAPSULE_PAD, - ), - NSSize::new(PILL_W, PILL_H), - ) - } - - fn pill_at(&self, event: &NSEvent) -> Option { - let p = self.convertPoint_fromView(event.locationInWindow(), None); - (0..PICKER_MODES.len()).find(|&i| { - let r = self.pill_rect(i); - p.x >= r.origin.x - PILL_GAP / 2.0 - && p.x <= r.origin.x + r.size.width + PILL_GAP / 2.0 - && p.y >= r.origin.y - && p.y <= r.origin.y + r.size.height - }) - } - - // -- actions ----------------------------------------------------------- - - fn choose(&self, i: usize) { - self.ivars().selected.set(i); - self.setNeedsDisplay(true); - // Same seam as every other tray action: only post the tag. - post(self.ivars().tag_base + i as isize); - if let Some(item) = self.enclosingMenuItem() { - if let Some(menu) = unsafe { item.menu() } { - menu.cancelTracking(); - } - } - } - - // -- drawing ----------------------------------------------------------- - - fn draw_contents(&self) { - let selected = self.ivars().selected.get(); - let focused = self.ivars().focused.get(); - let label = NSColor::labelColor(); - - // Title + subtitle. - let menu_size = NSFont::menuFontOfSize(0.0).pointSize(); - let title_font = NSFont::menuFontOfSize(0.0); - let shown = focused.unwrap_or(selected); - let mut sub_font = NSFont::systemFontOfSize_weight(menu_size - 2.0, unsafe { - objc2_app_kit::NSFontWeightSemibold - }); - if shown != selected { - sub_font = NSFontManager::sharedFontManager( - MainThreadMarker::new().expect("draw runs on the main thread"), - ) - .convertFont_toHaveTrait(&sub_font, NSFontTraitMask::ItalicFontMask); - } - - let title = NSString::from_str(PICKER_TITLE); - let subtitle = NSString::from_str(mode_name(PICKER_MODES[shown].0)); - let title_attrs = NSDictionary::from_slices( - &[unsafe { NSFontAttributeName }, unsafe { - objc2_app_kit::NSForegroundColorAttributeName - }], - &[&*title_font as &AnyObject, &*label as &AnyObject], - ); - let secondary = NSColor::secondaryLabelColor(); - let sub_attrs = NSDictionary::from_slices( - &[unsafe { NSFontAttributeName }, unsafe { - objc2_app_kit::NSForegroundColorAttributeName - }], - &[&*sub_font as &AnyObject, &*secondary as &AnyObject], - ); - let ts = unsafe { title.sizeWithAttributes(Some(&title_attrs)) }; - let ss = unsafe { subtitle.sizeWithAttributes(Some(&sub_attrs)) }; - let base_y = (self.bounds().size.height - (ts.height + ss.height + LINE_GAP)) / 2.0; - unsafe { - subtitle.drawAtPoint_withAttributes( - NSPoint::new(TITLE_INSET, base_y), - Some(&sub_attrs), - ); - title.drawAtPoint_withAttributes( - NSPoint::new(TITLE_INSET, base_y + ss.height + LINE_GAP), - Some(&title_attrs), - ); - } - - // Capsule: radius = half the height gives exact semicircular ends. - let cap = self.capsule_rect(); - label.colorWithAlphaComponent(0.08).setFill(); - NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( - cap, - cap.size.height / 2.0, - cap.size.height / 2.0, - ) - .fill(); - - for (i, (_, _, symbol)) in PICKER_MODES.iter().enumerate() { - let r = self.pill_rect(i); - let is_sel = i == selected; - let is_hover = focused == Some(i) && !is_sel; - - if is_sel { - NSColor::controlAccentColor().setFill(); - } else if is_hover { - label.colorWithAlphaComponent(0.14).setFill(); - } - if is_sel || is_hover { - NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( - r, - r.size.height / 2.0, - r.size.height / 2.0, - ) - .fill(); - } - - // Icon: a palette-coloured symbol, so the selected one is white. - let icon_color = if is_sel { - NSColor::whiteColor() - } else { - label.clone() - }; - if let Some(base) = symbol_image(symbol) { - let size_cfg = NSImageSymbolConfiguration::configurationWithPointSize_weight( - SYMBOL_SIZE, - unsafe { objc2_app_kit::NSFontWeightMedium }, - ); - let color_cfg = NSImageSymbolConfiguration::configurationWithPaletteColors( - &NSArray::from_slice(&[&*icon_color]), - ); - let cfg = size_cfg.configurationByApplyingConfiguration(&color_cfg); - if let Some(img) = base.imageWithSymbolConfiguration(&cfg) { - let s = img.size(); - img.drawInRect(NSRect::new( - NSPoint::new( - r.origin.x + (r.size.width - s.width) / 2.0, - r.origin.y + (r.size.height - s.height) / 2.0, - ), - s, - )); - } - } - } - } - } - - /// Appends the mode picker row to `menu` and registers one action per pill, so a click - /// arrives through the usual tag channel as `ModeInstant` / `ModeStudio` / `ModeScreenshot`. - fn append_mode_picker( - menu: &NSMenu, - selected: Mode, - actions: &mut Vec, - mtm: MainThreadMarker, - ) -> Retained { - let tag_base = actions.len() as isize; - actions.extend(PICKER_MODES.iter().map(|(_, item, _)| item.clone())); - - let index = PICKER_MODES - .iter() - .position(|(m, _, _)| *m == selected) - .unwrap_or(0); - let view = ModePickerView::new(mtm, index, tag_base); - - let item = NSMenuItem::new(mtm); - item.setTitle(&NSString::from_str(PICKER_TITLE)); // also its accessibility title - item.setEnabled(true); - item.setView(Some(&view)); - menu.addItem(&item); - item - } } #[cfg(target_os = "windows")] diff --git a/apps/desktop-gpui/src/tray/mac_mode_picker.rs b/apps/desktop-gpui/src/tray/mac_mode_picker.rs new file mode 100644 index 00000000000..4b94dfdd43a --- /dev/null +++ b/apps/desktop-gpui/src/tray/mac_mode_picker.rs @@ -0,0 +1,473 @@ +//! The mode picker row: a title and subtitle on the left, a capsule of pills on the right. +//! +//! Layers (`CALayer`) draw the capsule and pills because only they have continuous +//! corners; icons are image views above them, and `drawRect:` draws just the text. +//! Nothing in here touches gpui: a pick only posts a tag through [`post`], the same +//! seam every other tray action uses. + +use std::cell::{Cell, RefCell}; + +use block2::RcBlock; +use objc2::rc::Retained; +use objc2::runtime::{AnyClass, AnyObject, Bool, Sel}; +use objc2::{AnyThread, DefinedClass, MainThreadOnly, define_class, msg_send, sel}; +use objc2_app_kit::{ + NSAppearance, NSAppearanceCustomization, NSColor, NSEvent, NSEventType, NSFont, + NSFontAttributeName, NSFontManager, NSFontTraitMask, NSFontWeightMedium, NSFontWeightSemibold, + NSForegroundColorAttributeName, NSImageScaling, NSImageSymbolConfiguration, NSImageView, + NSMenu, NSMenuItem, NSStringDrawing, NSTrackingArea, NSTrackingAreaOptions, NSView, +}; +use objc2_foundation::{MainThreadMarker, NSDictionary, NSPoint, NSRect, NSSize, NSString}; +use objc2_quartz_core::{CALayer, CATransaction, kCACornerCurveContinuous}; + +use super::mac::{post, symbol_image}; +use super::{TrayItem, mode_name}; +use crate::main_window::Mode; + +const PICKER_MODES: [(Mode, TrayItem, &str); 3] = [ + (Mode::Instant, TrayItem::ModeInstant, "bolt.fill"), + (Mode::Studio, TrayItem::ModeStudio, "film"), + (Mode::Screenshot, TrayItem::ModeScreenshot, "photo"), +]; + +const PICKER_TITLE: &str = "Recording Mode"; +const PICKER_W: f64 = 300.0; +const PICKER_H: f64 = 41.0; +const PILL_W: f64 = 38.0; +const PILL_H: f64 = 29.0; +const PILL_GAP: f64 = 4.0; +const CAPSULE_PAD: f64 = 3.0; +const RIGHT_INSET: f64 = 10.0; +const TITLE_INSET: f64 = 17.0; +const LINE_GAP: f64 = 2.0; +const SYMBOL_SIZE: f64 = 17.0; +const PILL_RADIUS: f64 = 14.0; +const CAPSULE_RADIUS: f64 = 17.0; + +thread_local! { + /// The live picker and its menu item, read by the key hook. Set on every rebuild. + static PICKER: RefCell, Retained)>> = + const { RefCell::new(None) }; +} + +struct PickerIvars { + selected: Cell, + focused: Cell>, + /// `actions` index of the first pill; pill `i` posts `tag_base + i`. + tag_base: isize, + capsule: RefCell>>, + pills: RefCell>>, + icons: RefCell>>, +} + +define_class!( + // SAFETY: NSView has no extra subclassing requirements; ivars have no Drop side effects. + #[unsafe(super(NSView))] + #[name = "CapModePickerView"] + #[thread_kind = MainThreadOnly] + #[ivars = PickerIvars] + struct ModePickerView; + + impl ModePickerView { + #[unsafe(method(acceptsFirstMouse:))] + fn accepts_first_mouse(&self, _event: Option<&NSEvent>) -> bool { + true + } + + #[unsafe(method(viewDidMoveToWindow))] + fn view_did_move_to_window(&self) { + unsafe { + let _: () = msg_send![super(self), viewDidMoveToWindow]; + } + if let Some(window) = self.window() { + window.setAcceptsMouseMovedEvents(true); + } + self.sync(); + } + + #[unsafe(method(viewDidChangeEffectiveAppearance))] + fn view_did_change_effective_appearance(&self) { + unsafe { + let _: () = msg_send![super(self), viewDidChangeEffectiveAppearance]; + } + self.sync(); + } + + #[unsafe(method(mouseMoved:))] + fn mouse_moved(&self, event: &NSEvent) { + let hit = self.pill_at(event); + if self.ivars().focused.get() != hit { + self.ivars().focused.set(hit); + self.refresh(); + } + } + + #[unsafe(method(mouseExited:))] + fn mouse_exited(&self, _event: &NSEvent) { + self.ivars().focused.set(None); + self.refresh(); + } + + #[unsafe(method(mouseDown:))] + fn mouse_down(&self, event: &NSEvent) { + if let Some(i) = self.pill_at(event) { + // Update the subtitle immediately so the old name never flashes. + self.ivars().focused.set(Some(i)); + self.ivars().selected.set(i); + self.refresh(); + } + } + + #[unsafe(method(mouseUp:))] + fn mouse_up(&self, event: &NSEvent) { + if let Some(i) = self.pill_at(event) { + self.choose(i); + } + } + + #[unsafe(method(drawRect:))] + fn draw_rect(&self, _dirty: NSRect) { + self.draw_text(); + } + } +); + +impl ModePickerView { + fn new(mtm: MainThreadMarker, selected: usize, tag_base: isize) -> Retained { + let this = mtm.alloc::().set_ivars(PickerIvars { + selected: Cell::new(selected), + focused: Cell::new(None), + tag_base, + capsule: RefCell::new(None), + pills: RefCell::new(Vec::new()), + icons: RefCell::new(Vec::new()), + }); + let frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(PICKER_W, PICKER_H)); + // Initialise first; everything below touches the live view. + let this: Retained = unsafe { msg_send![super(this), initWithFrame: frame] }; + + this.setWantsLayer(true); + let root = this.layer().expect("layer-backed view"); + let make_layer = |radius: f64| -> Retained { + let layer = CALayer::layer(); + layer.setCornerCurve(unsafe { kCACornerCurveContinuous }); + layer.setCornerRadius(radius); + root.addSublayer(&layer); + layer + }; + *this.ivars().capsule.borrow_mut() = Some(make_layer(CAPSULE_RADIUS)); + for _ in PICKER_MODES.iter() { + this.ivars() + .pills + .borrow_mut() + .push(make_layer(PILL_RADIUS)); + } + + for (_, _, symbol) in PICKER_MODES.iter() { + let view = NSImageView::new(mtm); + if let Some(base) = symbol_image(symbol) { + let cfg = NSImageSymbolConfiguration::configurationWithPointSize_weight( + SYMBOL_SIZE, + unsafe { NSFontWeightMedium }, + ); + view.setImage(base.imageWithSymbolConfiguration(&cfg).as_deref()); + } + view.setImageScaling(NSImageScaling::ScaleNone); + this.addSubview(&view); + this.ivars().icons.borrow_mut().push(view); + } + + let options = NSTrackingAreaOptions::MouseMoved + | NSTrackingAreaOptions::MouseEnteredAndExited + | NSTrackingAreaOptions::ActiveAlways + | NSTrackingAreaOptions::InVisibleRect; + let area = unsafe { + NSTrackingArea::initWithRect_options_owner_userInfo( + NSTrackingArea::alloc(), + NSRect::ZERO, + options, + Some(&this), + None, + ) + }; + this.addTrackingArea(&area); + + this.sync(); + this + } + + // -- geometry ---------------------------------------------------------- + + fn capsule_rect(&self) -> NSRect { + let n = PICKER_MODES.len() as f64; + let w = n * PILL_W + (n - 1.0) * PILL_GAP + 2.0 * CAPSULE_PAD; + let h = PILL_H + 2.0 * CAPSULE_PAD; + let bounds = self.bounds(); + NSRect::new( + NSPoint::new( + bounds.size.width - RIGHT_INSET - w, + (bounds.size.height - h) / 2.0, + ), + NSSize::new(w, h), + ) + } + + fn pill_rect(&self, i: usize) -> NSRect { + let c = self.capsule_rect(); + NSRect::new( + NSPoint::new( + c.origin.x + CAPSULE_PAD + i as f64 * (PILL_W + PILL_GAP), + c.origin.y + CAPSULE_PAD, + ), + NSSize::new(PILL_W, PILL_H), + ) + } + + fn pill_at(&self, event: &NSEvent) -> Option { + let p = self.convertPoint_fromView(event.locationInWindow(), None); + (0..PICKER_MODES.len()).find(|&i| { + let r = self.pill_rect(i); + p.x >= r.origin.x - PILL_GAP / 2.0 + && p.x <= r.origin.x + r.size.width + PILL_GAP / 2.0 + && p.y >= r.origin.y + && p.y <= r.origin.y + r.size.height + }) + } + + // -- state -> visuals -------------------------------------------------- + + /// Layers and icons for the current state, then the text. + fn refresh(&self) { + self.sync(); + self.setNeedsDisplay(true); + } + + /// The only place state becomes layer properties. + fn sync(&self) { + let selected = self.ivars().selected.get(); + let focused = self.ivars().focused.get(); + + // CGColors don't follow appearance, so resolve the dynamic colours under this + // view's appearance. (Deprecated, but it has no block-free replacement.) + #[allow(deprecated)] + unsafe { + NSAppearance::setCurrentAppearance(Some(&self.effectiveAppearance())) + }; + + let label = NSColor::labelColor(); + CATransaction::begin(); + CATransaction::setDisableActions(true); + + if let Some(cap) = self.ivars().capsule.borrow().as_ref() { + cap.setFrame(self.capsule_rect()); + let color = label.colorWithAlphaComponent(0.08).CGColor(); + cap.setBackgroundColor(Some(&color)); + } + + let icons = self.ivars().icons.borrow(); + for (i, pill) in self.ivars().pills.borrow().iter().enumerate() { + let is_sel = i == selected; + let is_hover = focused == Some(i) && !is_sel; + let fill = if is_sel { + NSColor::whiteColor() + } else if is_hover { + label.colorWithAlphaComponent(0.14) + } else { + NSColor::clearColor() + }; + let r = self.pill_rect(i); + pill.setFrame(r); + let color = fill.CGColor(); + pill.setBackgroundColor(Some(&color)); + + if let Some(icon) = icons.get(i) { + icon.setFrame(r); + let tint = if is_sel { + NSColor::selectedContentBackgroundColor() + } else { + label.clone() + }; + icon.setContentTintColor(Some(&tint)); + } + } + CATransaction::commit(); + } + + // -- drawing: text only ------------------------------------------------ + + fn draw_text(&self) { + let selected = self.ivars().selected.get(); + let focused = self.ivars().focused.get(); + let shown = focused.unwrap_or(selected); + + let menu_size = NSFont::menuFontOfSize(0.0).pointSize(); + let title_font = + NSFont::systemFontOfSize_weight(menu_size, unsafe { NSFontWeightSemibold }); + let mut sub_font = NSFont::systemFontOfSize(menu_size - 2.0); + if shown != selected { + sub_font = NSFontManager::sharedFontManager( + MainThreadMarker::new().expect("draw runs on the main thread"), + ) + .convertFont_toHaveTrait(&sub_font, NSFontTraitMask::ItalicFontMask); + } + + let label = NSColor::labelColor(); + let secondary = NSColor::secondaryLabelColor(); + let title = NSString::from_str(PICKER_TITLE); + let subtitle = NSString::from_str(mode_name(PICKER_MODES[shown].0)); + let title_attrs = NSDictionary::from_slices( + &[unsafe { NSFontAttributeName }, unsafe { + NSForegroundColorAttributeName + }], + &[&*title_font as &AnyObject, &*label as &AnyObject], + ); + let sub_attrs = NSDictionary::from_slices( + &[unsafe { NSFontAttributeName }, unsafe { + NSForegroundColorAttributeName + }], + &[&*sub_font as &AnyObject, &*secondary as &AnyObject], + ); + + let ts = unsafe { title.sizeWithAttributes(Some(&title_attrs)) }; + let ss = unsafe { subtitle.sizeWithAttributes(Some(&sub_attrs)) }; + let base_y = (self.bounds().size.height - (ts.height + ss.height + LINE_GAP)) / 2.0; + unsafe { + subtitle + .drawAtPoint_withAttributes(NSPoint::new(TITLE_INSET, base_y), Some(&sub_attrs)); + title.drawAtPoint_withAttributes( + NSPoint::new(TITLE_INSET, base_y + ss.height + LINE_GAP), + Some(&title_attrs), + ); + } + } + + // -- actions ----------------------------------------------------------- + + /// Selects pill `i` and posts its tag. Deliberately does not close the menu. + fn choose(&self, i: usize) { + self.ivars().selected.set(i); + self.ivars().focused.set(Some(i)); + self.refresh(); + post(self.ivars().tag_base + i as isize); + } + + /// Moves keyboard focus one pill. Returns false at either end, so the key falls + /// through to the menu and moves to the neighbouring tray menu. + fn move_focus(&self, delta: isize) -> bool { + let cur = self + .ivars() + .focused + .get() + .unwrap_or(self.ivars().selected.get()) as isize; + let next = cur + delta; + if next < 0 || next >= PICKER_MODES.len() as isize { + return false; + } + self.ivars().focused.set(Some(next as usize)); + self.refresh(); + true + } + + fn activate_focused(&self) { + let i = self + .ivars() + .focused + .get() + .unwrap_or(self.ivars().selected.get()); + self.choose(i); + } +} + +/// Appends the mode picker row to `menu` and registers one action per pill, so a click +/// arrives through the usual tag channel as `ModeInstant` / `ModeStudio` / `ModeScreenshot`. +pub(super) fn append_mode_picker( + menu: &NSMenu, + selected: Mode, + actions: &mut Vec, + mtm: MainThreadMarker, +) -> Retained { + let tag_base = actions.len() as isize; + actions.extend(PICKER_MODES.iter().map(|(_, item, _)| item.clone())); + + let index = PICKER_MODES + .iter() + .position(|(m, _, _)| *m == selected) + .unwrap_or(0); + let view = ModePickerView::new(mtm, index, tag_base); + + let item = NSMenuItem::new(mtm); + item.setTitle(&NSString::from_str(PICKER_TITLE)); // also its accessibility title + item.setEnabled(true); + item.setView(Some(&view)); + menu.addItem(&item); + + PICKER.with(|p| *p.borrow_mut() = Some((item.clone(), view))); + item +} + +// --------------------------------------------------------------------------- +// Private keyboard hook +// --------------------------------------------------------------------------- + +/// ←/→/Return/Space while the picker row is highlighted. True means consumed. +fn handle_key(event: &NSEvent) -> bool { + PICKER.with(|p| { + let p = p.borrow(); + let Some((item, view)) = p.as_ref() else { + return false; + }; + if !item.isHighlighted() { + return false; + } + match event.keyCode() { + 123 => view.move_focus(-1), + 124 => view.move_focus(1), + 36 | 76 | 49 => { + view.activate_focused(); + true + } + _ => false, + } + }) +} + +/// Wraps `NSPopupMenuWindow -_handleKeyEvent:`. Private API: it does nothing if the +/// method is missing or has another shape, and it may break on any macOS update. +pub(super) fn install_key_hook() { + let sel = sel!(_handleKeyEvent:); + let Some(cls) = AnyClass::get(c"NSPopupMenuWindow") else { + return; + }; + let Some(method) = cls.instance_method(sel) else { + return; + }; + let enc = unsafe { + let p = + objc2::ffi::method_getTypeEncoding(method as *const _ as *const objc2::runtime::Imp); + if p.is_null() { + return; + } + std::ffi::CStr::from_ptr(p) + }; + if enc.to_bytes() != b"B24@0:8@16" { + tracing::warn!(encoding = ?enc, "key hook: unexpected encoding, not installing"); + return; + } + let orig = method.implementation(); + + let block = RcBlock::new(move |this: *mut AnyObject, event: *mut NSEvent| -> Bool { + let ev = unsafe { &*event }; + if ev.r#type() == NSEventType::KeyDown && handle_key(ev) { + return Bool::YES; + } + let orig: unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut NSEvent) -> Bool = + unsafe { std::mem::transmute(orig) }; + unsafe { orig(this, sel, event) } + }); + unsafe { + let imp = objc2::ffi::imp_implementationWithBlock(&*block as *const _ as *mut AnyObject); + method.set_implementation(std::mem::transmute(imp)); + } + std::mem::forget(block); // the replacement must outlive the process + tracing::info!("key hook installed"); +} From 1d18420608979077df6af11e0b043f2ed0895eb4 Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:55:57 +0330 Subject: [PATCH 06/75] clippy --- apps/desktop-gpui/src/editor_window.rs | 4 +- apps/desktop-gpui/src/tray.rs | 14 ++--- apps/desktop-gpui/src/tray/mac_mode_picker.rs | 63 ++++++------------- 3 files changed, 27 insertions(+), 54 deletions(-) diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index d19e1cd05c6..5acbe7e664e 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -4445,9 +4445,7 @@ impl EditorWindow { return; } let valid_target = self.lane_reorder_position_valid(current.source, x, y); - let target_index = valid_target - .then(|| self.lane_reorder_target_index(current.source, y)) - .unwrap_or(current.target_index); + let target_index = if valid_target { self.lane_reorder_target_index(current.source, y) } else { current.target_index }; if promote { self.history.pause(); } diff --git a/apps/desktop-gpui/src/tray.rs b/apps/desktop-gpui/src/tray.rs index a67c0ea5f16..107f5e02e11 100644 --- a/apps/desktop-gpui/src/tray.rs +++ b/apps/desktop-gpui/src/tray.rs @@ -808,22 +808,20 @@ mod mac { use gpui::{App, Global}; use objc2::rc::Retained; use objc2::runtime::{NSObjectProtocol, ProtocolObject, Sel}; - use objc2::{AnyThread, DefinedClass, MainThreadOnly, define_class, msg_send, sel}; + use objc2::{AnyThread, MainThreadOnly, define_class, msg_send, sel}; use objc2_app_kit::{ - NSControlStateValueOff, NSControlStateValueOn, NSEventModifierFlags, NSFont, - NSFontAttributeName, NSFontManager, NSFontTraitMask, NSImage, NSMenu, NSMenuDelegate, - NSMenuItem, NSMenuPresentationStyle, NSStatusBar, NSStatusBarButton, NSStatusItem, - NSStringDrawing, NSTrackingArea, NSTrackingAreaOptions, NSView, + NSEventModifierFlags, NSImage, NSMenu, NSMenuDelegate, + NSMenuItem, NSStatusBar, NSStatusBarButton, NSStatusItem, }; use objc2_foundation::{ - MainThreadMarker, NSData, NSDictionary, NSPoint, NSRect, NSSize, NSString, + MainThreadMarker, NSData, NSString, }; - use objc2_quartz_core::{CALayer, CATransaction, kCACornerCurveContinuous}; + use super::mac_mode_picker::{append_mode_picker, install_key_hook}; use super::{ Entry, PREVIOUS_SYMBOL, PreviousItem, STOP_ICON, TrayItem, current_menu_entries, - handle_item, mode_icon, mode_name, mode_submenu, row_symbol, scan_previous, stop_recording, + handle_item, mode_icon, mode_submenu, row_symbol, scan_previous, stop_recording, }; use crate::main_window::Mode; diff --git a/apps/desktop-gpui/src/tray/mac_mode_picker.rs b/apps/desktop-gpui/src/tray/mac_mode_picker.rs index 4b94dfdd43a..1817571570f 100644 --- a/apps/desktop-gpui/src/tray/mac_mode_picker.rs +++ b/apps/desktop-gpui/src/tray/mac_mode_picker.rs @@ -12,10 +12,10 @@ use objc2::rc::Retained; use objc2::runtime::{AnyClass, AnyObject, Bool, Sel}; use objc2::{AnyThread, DefinedClass, MainThreadOnly, define_class, msg_send, sel}; use objc2_app_kit::{ - NSAppearance, NSAppearanceCustomization, NSColor, NSEvent, NSEventType, NSFont, - NSFontAttributeName, NSFontManager, NSFontTraitMask, NSFontWeightMedium, NSFontWeightSemibold, - NSForegroundColorAttributeName, NSImageScaling, NSImageSymbolConfiguration, NSImageView, - NSMenu, NSMenuItem, NSStringDrawing, NSTrackingArea, NSTrackingAreaOptions, NSView, + NSAppearance, NSColor, NSEvent, NSEventType, NSFont, NSFontAttributeName, NSFontManager, + NSFontTraitMask, NSFontWeightMedium, NSFontWeightSemibold, NSForegroundColorAttributeName, + NSImageScaling, NSImageSymbolConfiguration, NSImageView, NSMenu, NSMenuItem, NSStringDrawing, + NSTrackingArea, NSTrackingAreaOptions, NSView, NSAppearanceCustomization, }; use objc2_foundation::{MainThreadMarker, NSDictionary, NSPoint, NSRect, NSSize, NSString}; use objc2_quartz_core::{CALayer, CATransaction, kCACornerCurveContinuous}; @@ -157,10 +157,7 @@ impl ModePickerView { }; *this.ivars().capsule.borrow_mut() = Some(make_layer(CAPSULE_RADIUS)); for _ in PICKER_MODES.iter() { - this.ivars() - .pills - .borrow_mut() - .push(make_layer(PILL_RADIUS)); + this.ivars().pills.borrow_mut().push(make_layer(PILL_RADIUS)); } for (_, _, symbol) in PICKER_MODES.iter() { @@ -204,10 +201,7 @@ impl ModePickerView { let h = PILL_H + 2.0 * CAPSULE_PAD; let bounds = self.bounds(); NSRect::new( - NSPoint::new( - bounds.size.width - RIGHT_INSET - w, - (bounds.size.height - h) / 2.0, - ), + NSPoint::new(bounds.size.width - RIGHT_INSET - w, (bounds.size.height - h) / 2.0), NSSize::new(w, h), ) } @@ -250,9 +244,7 @@ impl ModePickerView { // CGColors don't follow appearance, so resolve the dynamic colours under this // view's appearance. (Deprecated, but it has no block-free replacement.) #[allow(deprecated)] - unsafe { - NSAppearance::setCurrentAppearance(Some(&self.effectiveAppearance())) - }; + unsafe { NSAppearance::setCurrentAppearance(Some(&self.effectiveAppearance())) }; let label = NSColor::labelColor(); CATransaction::begin(); @@ -301,8 +293,7 @@ impl ModePickerView { let shown = focused.unwrap_or(selected); let menu_size = NSFont::menuFontOfSize(0.0).pointSize(); - let title_font = - NSFont::systemFontOfSize_weight(menu_size, unsafe { NSFontWeightSemibold }); + let title_font = NSFont::systemFontOfSize_weight(menu_size, unsafe { NSFontWeightSemibold }); let mut sub_font = NSFont::systemFontOfSize(menu_size - 2.0); if shown != selected { sub_font = NSFontManager::sharedFontManager( @@ -316,15 +307,11 @@ impl ModePickerView { let title = NSString::from_str(PICKER_TITLE); let subtitle = NSString::from_str(mode_name(PICKER_MODES[shown].0)); let title_attrs = NSDictionary::from_slices( - &[unsafe { NSFontAttributeName }, unsafe { - NSForegroundColorAttributeName - }], + &[unsafe { NSFontAttributeName }, unsafe { NSForegroundColorAttributeName }], &[&*title_font as &AnyObject, &*label as &AnyObject], ); let sub_attrs = NSDictionary::from_slices( - &[unsafe { NSFontAttributeName }, unsafe { - NSForegroundColorAttributeName - }], + &[unsafe { NSFontAttributeName }, unsafe { NSForegroundColorAttributeName }], &[&*sub_font as &AnyObject, &*secondary as &AnyObject], ); @@ -354,11 +341,7 @@ impl ModePickerView { /// Moves keyboard focus one pill. Returns false at either end, so the key falls /// through to the menu and moves to the neighbouring tray menu. fn move_focus(&self, delta: isize) -> bool { - let cur = self - .ivars() - .focused - .get() - .unwrap_or(self.ivars().selected.get()) as isize; + let cur = self.ivars().focused.get().unwrap_or(self.ivars().selected.get()) as isize; let next = cur + delta; if next < 0 || next >= PICKER_MODES.len() as isize { return false; @@ -369,11 +352,7 @@ impl ModePickerView { } fn activate_focused(&self) { - let i = self - .ivars() - .focused - .get() - .unwrap_or(self.ivars().selected.get()); + let i = self.ivars().focused.get().unwrap_or(self.ivars().selected.get()); self.choose(i); } } @@ -389,10 +368,7 @@ pub(super) fn append_mode_picker( let tag_base = actions.len() as isize; actions.extend(PICKER_MODES.iter().map(|(_, item, _)| item.clone())); - let index = PICKER_MODES - .iter() - .position(|(m, _, _)| *m == selected) - .unwrap_or(0); + let index = PICKER_MODES.iter().position(|(m, _, _)| *m == selected).unwrap_or(0); let view = ModePickerView::new(mtm, index, tag_base); let item = NSMenuItem::new(mtm); @@ -442,11 +418,10 @@ pub(super) fn install_key_hook() { return; }; let enc = unsafe { - let p = - objc2::ffi::method_getTypeEncoding(method as *const _ as *const objc2::runtime::Imp); - if p.is_null() { - return; - } + let p = objc2::ffi::method_getTypeEncoding( + method as *const _ as *const objc2::ffi::objc_method, + ); + if p.is_null() { return; } std::ffi::CStr::from_ptr(p) }; if enc.to_bytes() != b"B24@0:8@16" { @@ -465,7 +440,9 @@ pub(super) fn install_key_hook() { unsafe { orig(this, sel, event) } }); unsafe { - let imp = objc2::ffi::imp_implementationWithBlock(&*block as *const _ as *mut AnyObject); + let imp = objc2::ffi::imp_implementationWithBlock( + &*block as *const _ as *mut AnyObject, + ); method.set_implementation(std::mem::transmute(imp)); } std::mem::forget(block); // the replacement must outlive the process From 6e8231bf82cfdfa28e225ce0f2e96d532673f9bd Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:01:39 +0330 Subject: [PATCH 07/75] Remove irrelevant tests for the new mode picker --- apps/desktop-gpui/src/tray.rs | 80 +---------------------------------- 1 file changed, 1 insertion(+), 79 deletions(-) diff --git a/apps/desktop-gpui/src/tray.rs b/apps/desktop-gpui/src/tray.rs index 107f5e02e11..b899d898645 100644 --- a/apps/desktop-gpui/src/tray.rs +++ b/apps/desktop-gpui/src/tray.rs @@ -816,7 +816,7 @@ mod mac { use objc2_foundation::{ MainThreadMarker, NSData, NSString, }; - + use super::mac_mode_picker::{append_mode_picker, install_key_hook}; use super::{ @@ -1636,11 +1636,6 @@ mod tests { fn screenshot_mode_relabels_the_capture_rows() { let menu = build_menu(Mode::Screenshot, &[], "0.1.0"); let titles = titles(&menu); - let mode_label = if cfg!(target_os = "macos") { - "Screenshot" - } else { - "Recording Mode" - }; assert_eq!( &titles[..6], &[ @@ -1655,79 +1650,6 @@ mod tests { assert!(!titles.iter().any(|title| title == "Take a Screenshot")); } - /// The rows that stand for an action carry the glyph their in-app - /// counterpart uses; the chrome rows stay bare. - #[test] - fn the_action_rows_carry_icons() { - let menu = build_menu(Mode::Studio, &[], "0.1.0"); - let with_icons: Vec<_> = menu - .iter() - .filter_map(|entry| match entry { - Entry::Item { title, icon, .. } | Entry::Submenu { title, icon, .. } => { - icon.is_some().then(|| title.clone()) - } - _ => None, - }) - .collect(); - assert_eq!( - with_icons, - [ - "Record Display", - "Record Window", - "Record Area", - "Take a Screenshot", - "Previous", - "Import Media…", - "Settings…", - ] - ); - for bare in ["Open Main Window", "Upload Logs", "Quit Cap"] { - assert!( - !menu.iter().any(|entry| matches!( - entry, - Entry::Item { title, icon: Some(_), .. } if title == bare - )), - "{bare} has no icon" - ); - } - } - - /// macOS draws the action rows from the system symbol catalog; a typo'd - /// or missing name would come back nil and render the row bare. - #[cfg(target_os = "macos")] - #[test] - fn every_row_symbol_resolves_to_an_image() { - use objc2_app_kit::NSImage; - use objc2_foundation::NSString; - - let mut names: Vec<&str> = [ - TrayItem::RecordDisplay, - TrayItem::RecordWindow, - TrayItem::RecordArea, - TrayItem::TakeScreenshot, - TrayItem::ImportMedia, - TrayItem::OpenSettings, - ] - .iter() - .filter_map(|item| row_symbol(item)) - .collect(); - names.push(PREVIOUS_SYMBOL); - assert_eq!(names.len(), 7); - for bare in [TrayItem::OpenCap, TrayItem::Quit, TrayItem::ModeStudio] { - assert!(row_symbol(&bare).is_none()); - } - for name in names { - assert!( - NSImage::imageWithSystemSymbolName_accessibilityDescription( - &NSString::from_str(name), - None, - ) - .is_some(), - "{name} is not a system symbol" - ); - } - } - #[test] fn the_current_mode_is_ticked() { for (mode, expected) in [ From 1fa640a63f0df547edf78682c01576456d643d7e Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:56:10 +0330 Subject: [PATCH 08/75] Update tray.rs --- apps/desktop-gpui/src/tray.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop-gpui/src/tray.rs b/apps/desktop-gpui/src/tray.rs index b899d898645..39c3acd040b 100644 --- a/apps/desktop-gpui/src/tray.rs +++ b/apps/desktop-gpui/src/tray.rs @@ -1641,7 +1641,6 @@ mod tests { &[ "Open Main Window", "-", - mode_label, "-", "Screenshot Display", "Screenshot Window", From 7a7b8df4a7f4c327ef18987de812a84609c7ffff Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:32:42 +0330 Subject: [PATCH 09/75] Merge remote-tracking branch 'upstream/main' into macos-menus --- .config/nextest.toml | 17 + .github/workflows/ci.yml | 78 +- .github/workflows/engine-verify-os.yml | 28 +- .github/workflows/opencode.yml | 29 - .github/workflows/perf-budget.yml | 2 + .github/workflows/recording-reliability.yml | 6 +- .github/workflows/sync-tests.yml | 2 +- .github/workflows/verify-recording.yml | 9 + .gitignore | 1 + README.md | 4 + .../src/background/service-worker.test.ts | 177 + .../src/background/service-worker.ts | 3 + .../src/offscreen/recorder.test.ts | 198 ++ .../src/offscreen/recorder.ts | 21 +- apps/cli/README.md | 2 +- apps/cli/src/project.rs | 4 + apps/cli/src/recordings.rs | 224 +- apps/cli/src/selftest/playback.rs | 8 +- apps/cli/src/upload.rs | 51 +- apps/cli/tests/cli.rs | 66 + apps/desktop-gpui/Cargo.lock | 91 +- apps/desktop-gpui/Cargo.toml | 1 + apps/desktop-gpui/README.md | 2 +- .../assets/icons/clipboard-paste.svg | 1 + apps/desktop-gpui/kernels/Cargo.toml | 12 + .../kernels/benches/screenshot_blur.rs | 108 + .../kernels/benches/screenshot_mask.rs | 95 + .../kernels/benches/thumbnail_capture.rs | 64 + apps/desktop-gpui/kernels/src/screenshot.rs | 578 ++- apps/desktop-gpui/kernels/src/thumbnail.rs | 523 ++- apps/desktop-gpui/kernels/src/waveform.rs | 104 +- .../patches/zed-streaming-image.patch | 3136 +++++++++++++++++ apps/desktop-gpui/src/app_windows.rs | 163 +- apps/desktop-gpui/src/assets.rs | 1 + apps/desktop-gpui/src/auth.rs | 66 +- apps/desktop-gpui/src/camera_blur_portable.rs | 157 +- apps/desktop-gpui/src/camera_loading.rs | 149 +- apps/desktop-gpui/src/camera_window.rs | 330 +- apps/desktop-gpui/src/controls_window.rs | 87 +- apps/desktop-gpui/src/devices.rs | 186 +- apps/desktop-gpui/src/editor_canvas.rs | 59 +- apps/desktop-gpui/src/editor_clips.rs | 1 + apps/desktop-gpui/src/editor_crop.rs | 80 +- apps/desktop-gpui/src/editor_edits.rs | 268 +- apps/desktop-gpui/src/editor_export.rs | 75 +- apps/desktop-gpui/src/editor_hover_bench.rs | 43 +- apps/desktop-gpui/src/editor_panels.rs | 357 +- .../src/editor_preparing/audio.rs | 1 + apps/desktop-gpui/src/editor_sidebar.rs | 153 +- apps/desktop-gpui/src/editor_tabs.rs | 20 +- apps/desktop-gpui/src/editor_timeline.rs | 426 ++- apps/desktop-gpui/src/editor_window.rs | 430 ++- apps/desktop-gpui/src/feeds.rs | 137 +- apps/desktop-gpui/src/hotkeys.rs | 5 + apps/desktop-gpui/src/import.rs | 1 + apps/desktop-gpui/src/jpeg_thumbnail.rs | 210 ++ apps/desktop-gpui/src/library.rs | 629 +++- apps/desktop-gpui/src/main.rs | 2 + apps/desktop-gpui/src/main_window.rs | 167 +- apps/desktop-gpui/src/menus.rs | 125 +- apps/desktop-gpui/src/mode_select_window.rs | 17 +- apps/desktop-gpui/src/platform.rs | 124 +- apps/desktop-gpui/src/platform/windows.rs | 40 +- apps/desktop-gpui/src/presets.rs | 64 + apps/desktop-gpui/src/recording.rs | 243 +- .../src/screenshot_annotations.rs | 82 +- apps/desktop-gpui/src/screenshot_crop.rs | 33 +- apps/desktop-gpui/src/screenshot_editor.rs | 143 +- apps/desktop-gpui/src/session.rs | 66 +- apps/desktop-gpui/src/settings_pages.rs | 47 +- apps/desktop-gpui/src/settings_window.rs | 494 ++- apps/desktop-gpui/src/store.rs | 362 +- apps/desktop-gpui/src/target_overlay.rs | 238 +- apps/desktop-gpui/src/target_thumbnails.rs | 264 +- apps/desktop-gpui/src/transcription.rs | 191 +- apps/desktop-gpui/src/tray.rs | 169 +- apps/desktop-gpui/src/ui/button.rs | 10 +- apps/desktop-gpui/src/ui/keyboard_nav.rs | 202 ++ apps/desktop-gpui/src/ui/microphone_level.rs | 241 +- apps/desktop-gpui/src/ui/mod.rs | 3 +- apps/desktop-gpui/src/ui/select.rs | 9 +- apps/desktop-gpui/src/ui/text_input.rs | 218 +- apps/desktop-gpui/src/ui/toggle.rs | 25 +- .../src/upload/multipart_tests.rs | 108 + apps/desktop-gpui/src/upload/queue.rs | 277 +- .../src/upload/queue/verify_resume_tests.rs | 4 + .../src-tauri/src/editor_preparing/audio.rs | 1 + apps/desktop/src-tauri/src/import.rs | 2 + .../src-tauri/src/linux_instant_camera.rs | 4 +- apps/desktop/src-tauri/src/recording.rs | 4 + .../src-tauri/src/recording_telemetry.rs | 3 +- apps/desktop/src/routes/editor/context.ts | 5 +- apps/desktop/src/utils/tauri.ts | 7 +- apps/discord-bot/src/index.ts | 49 +- apps/discord-bot/src/release-checks.test.ts | 142 + apps/discord-bot/src/release-checks.ts | 38 + apps/discord-bot/test/interactions.spec.ts | 98 + apps/media-server/Dockerfile | 10 +- .../src/__tests__/lib/job-manager.test.ts | 82 + .../src/__tests__/lib/lifecycle.test.ts | 207 ++ .../__tests__/lib/loom-import-limit.test.ts | 193 + .../lib/media-audio.integration.test.ts | 66 +- ...edia-routes-real-world.integration.test.ts | 149 + .../src/__tests__/lib/nvenc-encoder.test.ts | 790 +++++ .../__tests__/lib/parallel-download.test.ts | 193 + ...recording-verification.integration.test.ts | 619 ++++ .../src/__tests__/lib/storage-upload.test.ts | 140 +- .../src/__tests__/lib/temp-files.test.ts | 180 + .../src/__tests__/routes/lifecycle.test.ts | 600 ++++ .../src/__tests__/routes/peer-jobs.test.ts | 151 + .../routes/recording-verification.test.ts | 101 + .../src/__tests__/routes/video.test.ts | 40 + apps/media-server/src/app.ts | 10 + apps/media-server/src/index.ts | 34 +- apps/media-server/src/lib/audio-quality.ts | 5 +- apps/media-server/src/lib/auth.ts | 14 + apps/media-server/src/lib/ffmpeg-errors.ts | 30 + apps/media-server/src/lib/job-manager.ts | 74 +- apps/media-server/src/lib/lifecycle.ts | 192 + apps/media-server/src/lib/log-redaction.ts | 30 + apps/media-server/src/lib/media-audio.ts | 28 +- apps/media-server/src/lib/media-common.ts | 14 + apps/media-server/src/lib/media-edit.ts | 75 +- apps/media-server/src/lib/media-operations.ts | 8 + apps/media-server/src/lib/media-transfer.ts | 65 +- apps/media-server/src/lib/media-video.ts | 411 ++- apps/media-server/src/lib/nvenc-encoder.ts | 259 ++ .../media-server/src/lib/parallel-download.ts | 195 + apps/media-server/src/lib/peer-jobs.ts | 75 + .../src/lib/recording-packet-proof.ts | 83 +- apps/media-server/src/lib/recording-timing.ts | 36 +- .../src/lib/recording-verification.ts | 741 +++- apps/media-server/src/lib/runtime-report.ts | 60 + apps/media-server/src/lib/subprocess.ts | 4 + apps/media-server/src/lib/temp-files.ts | 244 +- .../src/lib/video-input-validation.ts | 8 +- apps/media-server/src/routes/audio.ts | 71 +- apps/media-server/src/routes/health.ts | 52 +- apps/media-server/src/routes/video.ts | 1131 ++++-- apps/mobile/app/(tabs)/upload.tsx | 33 +- apps/mobile/src/auth/AuthContext.tsx | 14 +- apps/mobile/src/auth/AuthProvider.test.tsx | 190 +- apps/mobile/src/auth/SignInPanel.test.tsx | 66 + apps/mobile/src/auth/SignInPanel.tsx | 6 + .../dashboard-upload-visibility.test.tsx | 237 +- apps/render-farm/src/coordinator.test.ts | 137 +- apps/render-farm/src/coordinator.ts | 40 +- apps/render-farm/src/mp4.test.ts | 38 +- apps/render-farm/src/mp4.ts | 19 +- .../src/transcode.integration.test.ts | 66 + apps/render-farm/src/transcode.ts | 1 + apps/render-farm/src/worker.test.ts | 66 +- apps/render-farm/src/worker.ts | 214 +- apps/web/Dockerfile | 2 +- ...esktop-recording-production-replay.test.ts | 2 +- .../loom-concierge-database.test.ts | 119 + .../integration/sso-database.test.ts | 58 + .../__tests__/unit/add-folder-videos.test.ts | 139 + .../unit/agent-s3-endpoint-policy.test.ts | 202 ++ .../unit/agent-upload-entitlement.test.ts | 26 + .../unit/analytics-chart-dates.test.ts | 81 + .../__tests__/unit/assemblyai-client.test.ts | 46 + .../async-video-code-reviews-page.test.ts | 7 + apps/web/__tests__/unit/auth-options.test.ts | 244 ++ .../unit/cloudfront-signed-url.test.ts | 85 + .../unit/comment-recorder-lifecycle.test.ts | 186 + .../unit/comment-reply-parent.test.ts | 180 + .../__tests__/unit/cookie-write-site.test.ts | 473 +++ .../unit/dashboard-video-count.test.ts | 112 + .../unit/desktop-recording-source.test.ts | 361 +- ...eveloper-documentation-videos-page.test.ts | 7 + .../unit/finalize-desktop-recording.test.ts | 279 +- apps/web/__tests__/unit/folder-access.test.ts | 100 + ...aa-compliant-screen-recording-page.test.ts | 10 +- apps/web/__tests__/unit/homepage-seo.test.ts | 37 +- .../unit/import-media-progress.test.ts | 138 + apps/web/__tests__/unit/loom-csv.test.ts | 80 +- .../__tests__/unit/loom-download-url.test.ts | 229 ++ .../__tests__/unit/loom-file-cleanup.test.ts | 114 + .../unit/loom-file-processing.test.ts | 130 + .../__tests__/unit/loom-import-start.test.ts | 284 ++ .../__tests__/unit/loom-import-upload.test.ts | 190 + apps/web/__tests__/unit/loom-import.test.ts | 815 ++++- .../unit/loom-media-dispatch.test.ts | 51 + .../unit/loom-migration-inventory.test.ts | 161 + .../unit/loom-migration-state.test.ts | 53 +- .../unit/loom-retry-processing.test.ts | 236 ++ .../media-comment-playback-cleanup.test.ts | 249 ++ .../unit/media-processing-workflows.test.ts | 229 +- .../unit/media-server-multipart.test.ts | 111 + .../unit/media-server-progress.test.ts | 71 +- .../unit/mobile-cap-password-handler.test.ts | 190 + .../__tests__/unit/mobile-checkout.test.ts | 23 + .../unit/mobile-comment-parent.test.ts | 222 ++ .../unit/mobile-email-session.test.ts | 335 ++ .../unit/new-media-comment-thumbnail.test.ts | 152 + .../unit/nextauth-email-route.test.ts | 180 + .../unit/notification-read-state.test.ts | 85 + .../unit/oauth-sign-in-errors.test.ts | 161 + .../unit/obs-alternative-page.test.ts | 8 +- .../open-source-screen-recorder-page.test.ts | 9 +- .../organization-storage-s3-endpoint.test.ts | 184 + .../web/__tests__/unit/playback-store.test.ts | 119 + .../unit/proxy-frame-headers.test.ts | 105 + .../unit/public-license-claims.test.ts | 50 + .../unit/recording-storage-lifecycle.test.ts | 35 +- .../unit/remove-organization-domain.test.ts | 80 + .../unit/s3-bucket-connections.test.ts | 41 +- .../__tests__/unit/s3-endpoint-policy.test.ts | 212 ++ .../save-video-edits-original-source.test.ts | 212 ++ .../unit/segment-playlist-route.test.ts | 32 +- .../unit/segments-audio-download.test.ts | 45 + .../self-hosted-screen-recording-page.test.ts | 14 +- .../unit/sentry-browser-filter.test.ts | 54 + .../unit/share-cap-authorization.test.ts | 235 ++ .../web/__tests__/unit/share-playback.test.ts | 25 + .../unit/share-stage-identity.test.ts | 40 +- .../unit/share-video-metadata.test.ts | 59 +- .../storage-object-response-headers.test.ts | 206 ++ .../__tests__/unit/subscribe-checkout.test.ts | 194 + .../__tests__/unit/toolbar-reactions.test.ts | 83 + .../__tests__/unit/transcript-numbers.test.ts | 63 + .../unit/transcript-sidebar-search.test.ts | 198 ++ .../__tests__/unit/translation-vtt.test.ts | 90 + .../__tests__/unit/verification-token.test.ts | 146 + .../unit/video-analytics-bulk.test.ts | 156 + .../unit/video-create-atomicity.test.ts | 5 + .../__tests__/unit/video-download-key.test.ts | 88 + .../__tests__/unit/video-edit-actions.test.ts | 4 +- apps/web/__tests__/unit/video-edits.test.ts | 73 + .../unit/video-pipeline-recovery.test.ts | 109 +- .../unit/video-processing-recovery.test.ts | 513 ++- .../__tests__/unit/video-processing.test.ts | 19 +- .../video-recording-software-page.test.ts | 2 +- apps/web/__tests__/unit/video-status.test.ts | 115 + .../__tests__/unit/video-storage-key.test.ts | 306 ++ .../unit/web-editor-playback.test.ts | 192 + apps/web/actions/caps/share.ts | 22 +- apps/web/actions/folders/add-videos.ts | 56 +- apps/web/actions/folders/folder-access.ts | 27 + apps/web/actions/folders/get-folder-videos.ts | 16 +- apps/web/actions/loom-concierge.tsx | 57 +- apps/web/actions/loom.ts | 477 ++- .../web/actions/notifications/mark-as-read.ts | 22 +- apps/web/actions/organization/domain-utils.ts | 41 +- .../web/actions/organization/remove-domain.ts | 5 +- apps/web/actions/organization/storage.ts | 12 + .../web/actions/organization/update-domain.ts | 10 +- .../organizations/get-organization-videos.ts | 6 + apps/web/actions/spaces/get-space-videos.ts | 17 + apps/web/actions/video/retry-processing.ts | 88 +- apps/web/actions/video/trigger-processing.ts | 7 + apps/web/actions/videos/download.ts | 13 +- apps/web/actions/videos/get-status.ts | 4 +- apps/web/actions/videos/new-comment.ts | 7 + apps/web/actions/videos/new-media-comment.ts | 12 +- apps/web/actions/videos/save-edits.ts | 3 +- .../actions/videos/translate-transcript.ts | 124 +- apps/web/actions/videos/translation-vtt.ts | 145 + .../loom-migrations/LoomMigrationQueue.tsx | 139 +- .../analytics/components/ChartArea.tsx | 6 +- apps/web/app/(org)/dashboard/caps/page.tsx | 1 + .../app/(org)/dashboard/folder/[id]/page.tsx | 10 +- .../(org)/dashboard/import/import-media.ts | 7 +- .../dashboard/import/loom/ImportLoomPage.tsx | 190 +- .../CustomDomainDialog/CustomDomainDialog.tsx | 27 +- .../CustomDomainDialog/VerifyStep.tsx | 81 + .../(org)/dashboard/spaces/[spaceId]/page.tsx | 2 +- apps/web/app/(org)/login/form.tsx | 37 +- apps/web/app/(org)/signup/form.tsx | 8 + .../open-source-screen-recorder/page.tsx | 6 +- .../self-hosted-screen-recording/page.tsx | 2 +- apps/web/app/(site)/blog/[slug]/page.tsx | 95 +- apps/web/app/api/analytics/track/route.ts | 7 +- apps/web/app/api/auth/[...nextauth]/route.ts | 46 + apps/web/app/api/desktop/[...route]/root.ts | 16 +- .../app/api/desktop/[...route]/s3Config.ts | 26 +- .../web/app/api/dev-reset-transcript/route.ts | 3 +- apps/web/app/api/invite/accept/route.ts | 3 +- apps/web/app/api/invite/decline/route.ts | 3 +- apps/web/app/api/mobile/[...route]/route.ts | 86 +- apps/web/app/api/playlist/route.ts | 16 +- .../settings/billing/guest-checkout/route.ts | 26 +- .../app/api/settings/billing/manage/route.ts | 5 +- .../api/settings/billing/subscribe/route.ts | 35 +- apps/web/app/api/settings/user/name/route.ts | 3 +- apps/web/app/api/storage/object/route.ts | 45 +- apps/web/app/api/utils.ts | 3 +- apps/web/app/api/v1/[...route]/route.ts | 32 + .../web/app/api/video/comment/delete/route.ts | 3 +- apps/web/app/api/video/metadata/route.ts | 33 +- .../api/videos/[videoId]/retry-ai/route.ts | 7 +- .../[videoId]/retry-transcription/route.ts | 7 +- .../media-server/multipart/[action]/route.ts | 88 +- .../webhooks/media-server/progress/route.ts | 6 +- apps/web/app/embed/[videoId]/page.tsx | 2 + apps/web/app/globals.css | 23 + apps/web/app/s/[videoId]/Share.tsx | 47 +- .../app/s/[videoId]/_components/Toolbar.tsx | 121 +- .../media-comment/CommentMiniPlayer.tsx | 4 +- .../media-comment/VideoCommentCard.tsx | 10 +- .../_components/playback/playback-store.ts | 7 +- .../[videoId]/_components/tabs/Transcript.tsx | 119 +- .../timeline/recording/useCommentRecorder.ts | 18 +- .../_components/toolbar-reactions.ts | 54 + .../_components/utils/parse-transcript-vtt.ts | 112 + .../_components/utils/transcript-utils.ts | 7 +- .../app/s/[videoId]/edit/EditVideoClient.tsx | 2 + .../s/[videoId]/edit/TranscriptSidebar.tsx | 23 +- apps/web/app/s/[videoId]/page.tsx | 2 + .../components/blog/native/BeforeAfter.tsx | 426 +++ .../components/blog/native/CodeCompare.tsx | 86 + .../web/components/blog/native/ExportRace.tsx | 116 + .../components/blog/native/ResourceChart.tsx | 129 + .../web/components/blog/native/Screenshot.tsx | 45 + apps/web/components/blog/native/Tweet.tsx | 14 + apps/web/components/blog/native/shared.tsx | 291 ++ apps/web/components/blog/post-components.tsx | 15 + apps/web/components/pages/HomePage/Header.tsx | 2 +- .../pages/HomeTwo/DiscordLinkPreview.tsx | 41 + apps/web/components/pages/HomeTwo/Hero.tsx | 24 +- .../components/pages/HomeTwo/HeroHeadline.tsx | 72 +- apps/web/components/pages/HomeTwo/HeroSky.tsx | 292 +- apps/web/components/pages/HomeTwo/fonts.ts | 2 +- apps/web/components/pages/HomeTwo/index.tsx | 4 +- apps/web/components/pages/HomeTwo/metadata.ts | 15 +- apps/web/components/pages/UpdatesPage.tsx | 38 +- .../components/pages/pricing/PricingPage.tsx | 4 +- .../pages/seo/AsyncVideoCodeReviewsPage.tsx | 6 +- .../seo/DeveloperDocumentationVideosPage.tsx | 6 +- .../seo/GoogleDriveScreenRecorderPage.tsx | 2 +- .../seo/HipaaCompliantScreenRecordingPage.tsx | 6 +- .../pages/seo/ObsAlternativePage.tsx | 6 +- .../seo/OpenSourceScreenRecorderPage.tsx | 8 +- .../seo/SelfHostedScreenRecordingPage.tsx | 10 +- .../pages/seo/VideoRecordingSoftwarePage.tsx | 4 +- .../content/blog/rebuilding-cap-in-gpui.mdx | 209 ++ apps/web/content/docs/self-hosting.mdx | 6 + apps/web/data/homepage-copy.ts | 2 +- apps/web/instrumentation-client.ts | 1 + apps/web/lib/assemblyai.ts | 10 + apps/web/lib/checkout-quantity.ts | 11 + apps/web/lib/comment-reply-parent.ts | 16 + .../desktop-recording-source-checkpoint.ts | 5 + apps/web/lib/desktop-recording-source.ts | 142 +- apps/web/lib/email-code-rate-limit.ts | 32 + apps/web/lib/loom-csv.ts | 29 + apps/web/lib/loom-download-url.ts | 281 ++ apps/web/lib/loom-file-cleanup.ts | 47 + apps/web/lib/loom-file-processing.ts | 59 + apps/web/lib/loom-import-limits.ts | 2 + apps/web/lib/loom-import-start.ts | 179 + apps/web/lib/loom-import-upload.ts | 188 + apps/web/lib/loom-media-dispatch.ts | 76 + apps/web/lib/loom-migration-inventory.ts | 90 + apps/web/lib/loom-migration-state.ts | 35 + apps/web/lib/s3-endpoint-policy.ts | 134 + apps/web/lib/segments-audio-download.ts | 14 +- apps/web/lib/seo-metadata.ts | 4 +- apps/web/lib/share-playback.ts | 6 +- apps/web/lib/share-video-metadata.ts | 18 +- apps/web/lib/storage-not-found.ts | 25 + apps/web/lib/video-edits.ts | 33 +- apps/web/lib/video-pipeline-recovery.ts | 75 +- apps/web/lib/video-processing-recovery.ts | 247 +- apps/web/lib/video-processing.ts | 6 +- apps/web/lib/video-status-query.ts | 28 + apps/web/lib/video-storage-key.ts | 12 + apps/web/next.config.mjs | 17 + apps/web/package.json | 1 + apps/web/proxy.ts | 45 +- .../web/public/backgrounds/hero/rays-far.webp | Bin 0 -> 42836 bytes .../public/backgrounds/hero/rays-near.webp | Bin 0 -> 37410 bytes apps/web/public/blog/gpui/cursor-macos.svg | 18 + apps/web/public/blog/gpui/cursor-windows.svg | 18 + apps/web/public/blog/gpui/editor-061.webp | Bin 0 -> 129026 bytes .../blog/gpui/main-window-060-glass.png | Bin 0 -> 110007 bytes .../blog/gpui/main-window-061-glass.png | Bin 0 -> 297100 bytes apps/web/public/blog/gpui/settings-061.webp | Bin 0 -> 80440 bytes .../blog/rebuilding-cap-in-gpui-poster.jpg | Bin 0 -> 124375 bytes .../public/blog/rebuilding-cap-in-gpui.jpg | Bin 0 -> 231649 bytes .../public/blog/rebuilding-cap-in-gpui.mp4 | Bin 0 -> 176286 bytes apps/web/public/llms-full.txt | 2 +- apps/web/utils/blog.ts | 1 + .../workflows/finalize-desktop-recording.ts | 151 +- apps/web/workflows/import-loom-video.ts | 356 +- apps/web/workflows/live-transcribe.ts | 6 +- apps/web/workflows/transcribe.ts | 14 +- bun.lock | 7 + crates/audio/src/progressive.rs | 217 +- crates/camera-effects/src/lib.rs | 11 +- .../examples/editor-startup-benchmark.rs | 93 +- crates/editor/src/audio.rs | 1659 ++++++++- crates/editor/src/audio_output.rs | 25 +- .../editor/src/audio_output/native_tests.rs | 3 + crates/editor/src/completed_audio.rs | 2 + crates/editor/src/editor_instance.rs | 7 +- crates/editor/src/export_audio.rs | 53 +- crates/editor/src/lib.rs | 2 +- crates/enc-ffmpeg/src/audio/aac.rs | 74 +- crates/enc-ffmpeg/src/audio/opus.rs | 13 + crates/enc-ffmpeg/src/mux/dash_audio.rs | 223 +- crates/enc-ffmpeg/src/mux/fragmented_audio.rs | 99 +- crates/enc-ffmpeg/src/mux/mod.rs | 1 + crates/enc-ffmpeg/src/mux/ogg.rs | 75 +- crates/enc-ffmpeg/src/mux/segmented_stream.rs | 456 ++- crates/enc-ffmpeg/src/mux/write_behind.rs | 1005 ++++++ crates/enc-ffmpeg/src/video/h264.rs | 49 +- crates/enc-ffmpeg/src/video/h264_packet.rs | 6 +- crates/enc-ffmpeg/src/video/mod.rs | 1 + crates/enc-ffmpeg/src/video/packed_yuv.rs | 906 +++++ crates/export/Cargo.toml | 4 + crates/export/src/estimates.rs | 2 + crates/export/src/lib.rs | 66 +- crates/export/src/mov.rs | 23 +- crates/export/src/mp4.rs | 200 +- crates/export/src/preview.rs | 31 +- crates/export/tests/mov_cancellation.rs | 205 ++ crates/ffmpeg-hw-device/src/lib.rs | 90 +- crates/project/src/caption_timing.rs | 19 +- .../src/caption_timing/projection_tests.rs | 349 ++ crates/project/src/config_write_tests.rs | 83 + crates/project/src/configuration.rs | 64 +- .../configuration/style_resolution_tests.rs | 177 + crates/project/src/cursor.rs | 26 +- crates/project/src/lib.rs | 2 + crates/project/src/meta.rs | 5 + crates/project/tests/configuration_loading.rs | 52 + .../examples/synthetic-test-runner.rs | 5 +- crates/recording/src/feeds/camera.rs | 83 +- crates/recording/src/feeds/microphone.rs | 2 +- crates/recording/src/feeds/mod.rs | 6 +- crates/recording/src/instant_recording.rs | 7 +- .../src/instant_recording/linux_camera.rs | 1912 +++++++++- crates/recording/src/output_pipeline/core.rs | 20 + .../recording/src/output_pipeline/ffmpeg.rs | 115 +- .../output_pipeline/macos_fragmented_m4s.rs | 4 +- .../src/output_pipeline/start_gate.rs | 2 +- crates/recording/src/recovery.rs | 8 + .../src/recovery/preparing_projection.rs | 2 + crates/recording/src/screenshot.rs | 544 ++- crates/recording/src/sources/audio_mixer.rs | 100 +- crates/recording/src/sources/microphone.rs | 427 ++- .../src/sources/screen_capture/mod.rs | 182 +- crates/recording/src/studio_recording.rs | 16 +- crates/recording/tests/recovery.rs | 2 + crates/recording/tests/sync_matrix.rs | 11 +- .../examples/mask-preparation-benchmark.rs | 188 + crates/rendering/src/cursor_interpolation.rs | 42 +- crates/rendering/src/decoder/avassetreader.rs | 175 +- crates/rendering/src/decoder/ffmpeg.rs | 4 +- crates/rendering/src/decoder/mod.rs | 145 +- crates/rendering/src/decoder/model/mod.rs | 336 ++ crates/rendering/src/frame_pipeline.rs | 344 +- crates/rendering/src/layers/background.rs | 99 +- crates/rendering/src/layers/blur.rs | 43 +- crates/rendering/src/layers/camera3d.rs | 64 +- crates/rendering/src/layers/captions.rs | 885 ++++- crates/rendering/src/layers/color_grade.rs | 28 +- crates/rendering/src/layers/cursor.rs | 28 +- crates/rendering/src/layers/mask.rs | 99 +- crates/rendering/src/layers/mod.rs | 48 + crates/rendering/src/layers/text.rs | 79 + crates/rendering/src/lib.rs | 375 +- crates/rendering/src/mask.rs | 275 +- crates/rendering/src/scene.rs | 76 +- crates/rendering/src/spring_mass_damper.rs | 4 + .../rendering/tests/camera_frame_geometry.rs | 5 +- crates/rendering/tests/display_layout.rs | 3 +- crates/rendering/tests/per_frame_caches.rs | 268 ++ crates/scap-targets/src/platform/linux.rs | 99 +- crates/scap-targets/tests/linux_targets.rs | 166 + crates/verify/Cargo.toml | 2 +- crates/verify/expectations.toml | 104 +- crates/verify/src/fixture.rs | 1 + crates/verify/src/pipeline.rs | 19 +- crates/verify/src/report.rs | 39 - crates/verify/tests/audio.rs | 36 +- crates/verify/tests/audio_support/mod.rs | 24 +- .../verify/tests/chaos_support/fragments.rs | 1 + crates/verify/tests/chaos_support/project.rs | 59 +- crates/verify/tests/chaos_support/record.rs | 10 +- crates/verify/tests/crash.rs | 300 +- crates/verify/tests/export.rs | 15 - crates/verify/tests/recording.rs | 254 +- crates/video-decode/src/avassetreader.rs | 19 + crates/video-decode/src/ffmpeg.rs | 16 +- crates/video-decode/src/scripted.rs | 52 +- docker-compose.coolify.env.example | 1 + docker-compose.coolify.yml | 2 + docker-compose.yml | 2 + package.json | 2 +- packages/database/auth/auth-options.ts | 68 + packages/database/auth/drizzle-adapter.ts | 38 +- packages/database/auth/request-site.ts | 56 + packages/database/package.json | 2 + packages/database/types/metadata.ts | 17 + packages/env/server.ts | 30 + .../instant-recording-uploader.test.ts | 76 + .../recorder-core/src/instant-mp4-uploader.ts | 9 +- packages/sdk-embed/__tests__/autoplay.test.ts | 28 + packages/sdk-embed/package.json | 1 + packages/sdk-embed/src/vanilla/cap-embed.ts | 2 +- .../__tests__/duration-limit.test.ts | 89 - .../__tests__/recorder-options.test.ts | 245 ++ .../__tests__/startup-cancellation.test.ts | 295 ++ .../sdk-recorder/src/core/stream-manager.ts | 90 +- packages/sdk-recorder/src/index.ts | 57 +- packages/utils/src/constants/plans.ts | 8 + packages/web-backend/src/Auth.ts | 14 + .../src/S3Buckets/S3ConnectionPool.ts | 39 +- .../src/S3Buckets/cloudfront-signed-url.ts | 48 + packages/web-backend/src/S3Buckets/index.ts | 32 +- packages/web-backend/src/Videos/VideosRepo.ts | 12 +- packages/web-backend/src/Videos/index.ts | 43 +- packages/web-domain/src/Mobile.ts | 14 +- scripts/prepare-gpui-dependency.sh | 1 + scripts/prepare-gpui-dependency.test.mjs | 7 + scripts/setup.js | 31 +- scripts/setup.test.mjs | 180 + scripts/verify/perf-ab.py | 4 +- scripts/verify/suites.json | 12 +- scripts/verify/test-perf-ab.py | 91 + turbo.json | 2 +- 524 files changed, 52943 insertions(+), 4477 deletions(-) delete mode 100644 .github/workflows/opencode.yml create mode 100644 apps/chrome-extension/src/background/service-worker.test.ts create mode 100644 apps/chrome-extension/src/offscreen/recorder.test.ts create mode 100644 apps/desktop-gpui/assets/icons/clipboard-paste.svg create mode 100644 apps/desktop-gpui/kernels/benches/screenshot_blur.rs create mode 100644 apps/desktop-gpui/kernels/benches/screenshot_mask.rs create mode 100644 apps/desktop-gpui/kernels/benches/thumbnail_capture.rs create mode 100644 apps/desktop-gpui/patches/zed-streaming-image.patch create mode 100644 apps/desktop-gpui/src/jpeg_thumbnail.rs create mode 100644 apps/desktop-gpui/src/ui/keyboard_nav.rs create mode 100644 apps/discord-bot/src/release-checks.test.ts create mode 100644 apps/discord-bot/src/release-checks.ts create mode 100644 apps/discord-bot/test/interactions.spec.ts create mode 100644 apps/media-server/src/__tests__/lib/lifecycle.test.ts create mode 100644 apps/media-server/src/__tests__/lib/loom-import-limit.test.ts create mode 100644 apps/media-server/src/__tests__/lib/nvenc-encoder.test.ts create mode 100644 apps/media-server/src/__tests__/lib/parallel-download.test.ts create mode 100644 apps/media-server/src/__tests__/lib/temp-files.test.ts create mode 100644 apps/media-server/src/__tests__/routes/lifecycle.test.ts create mode 100644 apps/media-server/src/__tests__/routes/peer-jobs.test.ts create mode 100644 apps/media-server/src/lib/ffmpeg-errors.ts create mode 100644 apps/media-server/src/lib/lifecycle.ts create mode 100644 apps/media-server/src/lib/log-redaction.ts create mode 100644 apps/media-server/src/lib/nvenc-encoder.ts create mode 100644 apps/media-server/src/lib/parallel-download.ts create mode 100644 apps/media-server/src/lib/peer-jobs.ts create mode 100644 apps/media-server/src/lib/runtime-report.ts create mode 100644 apps/render-farm/src/transcode.integration.test.ts create mode 100644 apps/web/__tests__/unit/add-folder-videos.test.ts create mode 100644 apps/web/__tests__/unit/agent-s3-endpoint-policy.test.ts create mode 100644 apps/web/__tests__/unit/analytics-chart-dates.test.ts create mode 100644 apps/web/__tests__/unit/assemblyai-client.test.ts create mode 100644 apps/web/__tests__/unit/cloudfront-signed-url.test.ts create mode 100644 apps/web/__tests__/unit/comment-recorder-lifecycle.test.ts create mode 100644 apps/web/__tests__/unit/comment-reply-parent.test.ts create mode 100644 apps/web/__tests__/unit/cookie-write-site.test.ts create mode 100644 apps/web/__tests__/unit/dashboard-video-count.test.ts create mode 100644 apps/web/__tests__/unit/folder-access.test.ts create mode 100644 apps/web/__tests__/unit/import-media-progress.test.ts create mode 100644 apps/web/__tests__/unit/loom-download-url.test.ts create mode 100644 apps/web/__tests__/unit/loom-file-cleanup.test.ts create mode 100644 apps/web/__tests__/unit/loom-file-processing.test.ts create mode 100644 apps/web/__tests__/unit/loom-import-start.test.ts create mode 100644 apps/web/__tests__/unit/loom-import-upload.test.ts create mode 100644 apps/web/__tests__/unit/loom-media-dispatch.test.ts create mode 100644 apps/web/__tests__/unit/loom-migration-inventory.test.ts create mode 100644 apps/web/__tests__/unit/loom-retry-processing.test.ts create mode 100644 apps/web/__tests__/unit/media-comment-playback-cleanup.test.ts create mode 100644 apps/web/__tests__/unit/mobile-cap-password-handler.test.ts create mode 100644 apps/web/__tests__/unit/mobile-comment-parent.test.ts create mode 100644 apps/web/__tests__/unit/mobile-email-session.test.ts create mode 100644 apps/web/__tests__/unit/new-media-comment-thumbnail.test.ts create mode 100644 apps/web/__tests__/unit/nextauth-email-route.test.ts create mode 100644 apps/web/__tests__/unit/notification-read-state.test.ts create mode 100644 apps/web/__tests__/unit/oauth-sign-in-errors.test.ts create mode 100644 apps/web/__tests__/unit/organization-storage-s3-endpoint.test.ts create mode 100644 apps/web/__tests__/unit/playback-store.test.ts create mode 100644 apps/web/__tests__/unit/proxy-frame-headers.test.ts create mode 100644 apps/web/__tests__/unit/public-license-claims.test.ts create mode 100644 apps/web/__tests__/unit/remove-organization-domain.test.ts create mode 100644 apps/web/__tests__/unit/s3-endpoint-policy.test.ts create mode 100644 apps/web/__tests__/unit/save-video-edits-original-source.test.ts create mode 100644 apps/web/__tests__/unit/segments-audio-download.test.ts create mode 100644 apps/web/__tests__/unit/sentry-browser-filter.test.ts create mode 100644 apps/web/__tests__/unit/share-cap-authorization.test.ts create mode 100644 apps/web/__tests__/unit/storage-object-response-headers.test.ts create mode 100644 apps/web/__tests__/unit/subscribe-checkout.test.ts create mode 100644 apps/web/__tests__/unit/toolbar-reactions.test.ts create mode 100644 apps/web/__tests__/unit/transcript-numbers.test.ts create mode 100644 apps/web/__tests__/unit/transcript-sidebar-search.test.ts create mode 100644 apps/web/__tests__/unit/translation-vtt.test.ts create mode 100644 apps/web/__tests__/unit/verification-token.test.ts create mode 100644 apps/web/__tests__/unit/video-analytics-bulk.test.ts create mode 100644 apps/web/__tests__/unit/video-download-key.test.ts create mode 100644 apps/web/__tests__/unit/video-status.test.ts create mode 100644 apps/web/__tests__/unit/video-storage-key.test.ts create mode 100644 apps/web/__tests__/unit/web-editor-playback.test.ts create mode 100644 apps/web/actions/folders/folder-access.ts create mode 100644 apps/web/actions/videos/translation-vtt.ts create mode 100644 apps/web/app/s/[videoId]/_components/toolbar-reactions.ts create mode 100644 apps/web/app/s/[videoId]/_components/utils/parse-transcript-vtt.ts create mode 100644 apps/web/components/blog/native/BeforeAfter.tsx create mode 100644 apps/web/components/blog/native/CodeCompare.tsx create mode 100644 apps/web/components/blog/native/ExportRace.tsx create mode 100644 apps/web/components/blog/native/ResourceChart.tsx create mode 100644 apps/web/components/blog/native/Screenshot.tsx create mode 100644 apps/web/components/blog/native/Tweet.tsx create mode 100644 apps/web/components/blog/native/shared.tsx create mode 100644 apps/web/components/pages/HomeTwo/DiscordLinkPreview.tsx create mode 100644 apps/web/content/blog/rebuilding-cap-in-gpui.mdx create mode 100644 apps/web/lib/checkout-quantity.ts create mode 100644 apps/web/lib/comment-reply-parent.ts create mode 100644 apps/web/lib/email-code-rate-limit.ts create mode 100644 apps/web/lib/loom-download-url.ts create mode 100644 apps/web/lib/loom-file-cleanup.ts create mode 100644 apps/web/lib/loom-file-processing.ts create mode 100644 apps/web/lib/loom-import-limits.ts create mode 100644 apps/web/lib/loom-import-start.ts create mode 100644 apps/web/lib/loom-import-upload.ts create mode 100644 apps/web/lib/loom-media-dispatch.ts create mode 100644 apps/web/lib/loom-migration-inventory.ts create mode 100644 apps/web/lib/s3-endpoint-policy.ts create mode 100644 apps/web/lib/storage-not-found.ts create mode 100644 apps/web/lib/video-status-query.ts create mode 100644 apps/web/lib/video-storage-key.ts create mode 100644 apps/web/public/backgrounds/hero/rays-far.webp create mode 100644 apps/web/public/backgrounds/hero/rays-near.webp create mode 100644 apps/web/public/blog/gpui/cursor-macos.svg create mode 100644 apps/web/public/blog/gpui/cursor-windows.svg create mode 100644 apps/web/public/blog/gpui/editor-061.webp create mode 100644 apps/web/public/blog/gpui/main-window-060-glass.png create mode 100644 apps/web/public/blog/gpui/main-window-061-glass.png create mode 100644 apps/web/public/blog/gpui/settings-061.webp create mode 100644 apps/web/public/blog/rebuilding-cap-in-gpui-poster.jpg create mode 100644 apps/web/public/blog/rebuilding-cap-in-gpui.jpg create mode 100644 apps/web/public/blog/rebuilding-cap-in-gpui.mp4 create mode 100644 crates/enc-ffmpeg/src/mux/write_behind.rs create mode 100644 crates/enc-ffmpeg/src/video/packed_yuv.rs create mode 100644 crates/export/tests/mov_cancellation.rs create mode 100644 crates/project/src/caption_timing/projection_tests.rs create mode 100644 crates/project/src/config_write_tests.rs create mode 100644 crates/project/src/configuration/style_resolution_tests.rs create mode 100644 crates/project/tests/configuration_loading.rs create mode 100644 crates/rendering/examples/mask-preparation-benchmark.rs create mode 100644 crates/rendering/tests/per_frame_caches.rs create mode 100644 crates/scap-targets/tests/linux_targets.rs create mode 100644 packages/database/auth/request-site.ts create mode 100644 packages/sdk-embed/__tests__/autoplay.test.ts delete mode 100644 packages/sdk-recorder/__tests__/duration-limit.test.ts create mode 100644 packages/sdk-recorder/__tests__/recorder-options.test.ts create mode 100644 packages/sdk-recorder/__tests__/startup-cancellation.test.ts create mode 100644 packages/web-backend/src/S3Buckets/cloudfront-signed-url.ts create mode 100644 scripts/setup.test.mjs create mode 100644 scripts/verify/test-perf-ab.py diff --git a/.config/nextest.toml b/.config/nextest.toml index a9f409ed444..466fdbcac2b 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -11,6 +11,17 @@ slow-timeout = { period = "60s" } filter = 'binary_id(cap-rendering) and test(/^decoder::model::/)' threads-required = 3 +# Wall-clock tests run first and alone. crash_safety hands frames to a +# real-time VideoToolbox session on a 30 fps schedule, and realtime_loop +# counts the frames playback presents against the clock. Sharing the runner +# with the seek storms, chaos and export tests starves them. On macOS, +# crash/equality lost 3 to 7 frames in 6 shared runs and passed in all 6 +# solo reruns; realtime30 presented 65% of its frames shared and passed solo. +[[profile.default.overrides]] +filter = '(binary_id(cap-verify::crash) and test(=crash_safety)) or (binary_id(cap-verify::playback) and test(=realtime_loop))' +threads-required = "num-test-threads" +priority = 100 + [profile.ci] # No retries: a flaky case is a bug. run.sh reruns failures once afterwards # only to label them flaky in the job summary; the job still fails. @@ -31,6 +42,12 @@ store-failure-output = true filter = 'binary_id(cap-rendering) and test(/^decoder::model::/)' threads-required = 3 +# Wall-clock tests run first and alone, as in the default profile. +[[profile.ci.overrides]] +filter = '(binary_id(cap-verify::crash) and test(=crash_safety)) or (binary_id(cap-verify::playback) and test(=realtime_loop))' +threads-required = "num-test-threads" +priority = 100 + # The FULL-tier seek storms (3000 seeks per decoder) are the slowest tests: # on the macos-15 runner one storm fixture takes 240 to 570 s per decoder, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57810c00587..f18abceea67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: rust: ${{ steps.filter.outputs.rust }} gpui: ${{ steps.filter.outputs.rust == 'true' || steps.filter.outputs.gpui == 'true' || steps.desktop-resources.outputs.desktop-resources == 'true' }} tauri-plugins: ${{ steps.filter.outputs.tauri-plugins }} + gpui-patches: ${{ steps.filter.outputs.gpui-patches }} steps: - name: Checkout uses: actions/checkout@v4 @@ -42,7 +43,7 @@ jobs: - 'crates/**' - 'vendor/**' - 'scripts/diagnostic-build.rs' - - 'scripts/setup.js' + - 'scripts/setup*' - 'Cargo.toml' - 'Cargo.lock' gpui: @@ -65,6 +66,9 @@ jobs: - 'scripts/sync-desktop-versions.mjs' - 'scripts/verify-gpui-release-inputs.mjs' - 'scripts/verify-windows-stack.mjs' + gpui-patches: + - 'apps/desktop-gpui/patches/**' + - 'scripts/prepare-gpui-dependency.sh' tauri-plugins: - 'Cargo.lock' - 'bun.lock' @@ -123,10 +127,12 @@ jobs: __tests__/unit/linkified-text.test.ts __tests__/unit/loom-import-ui.test.ts __tests__/unit/media-player-store-refs.test.ts + __tests__/unit/playback-store.test.ts __tests__/unit/seo-demo-videos.test.ts __tests__/unit/signed-baa-card.test.ts __tests__/unit/summary-editor.test.ts __tests__/unit/sso-login-pages.test.ts + __tests__/unit/transcript-numbers.test.ts - name: Typecheck run: bun run typecheck @@ -273,6 +279,9 @@ jobs: prefix-key: v1-rust-tests save-cache: ${{ github.ref == 'refs/heads/main' }} + - name: Test native dependency setup + run: node --test scripts/setup.test.mjs + - name: Run setup run: node scripts/setup.js @@ -280,8 +289,11 @@ jobs: run: | cargo test --locked -p cap-utils -p cap-cli-install --lib cargo test --locked -p cap --bin cap record::tests + cargo test --locked -p cap --bin cap recordings::tests + cargo test --locked -p cap --test cli recordings_list cargo test --locked -p cap-recording --lib -- --test-threads=1 cargo test --locked -p cap-recording --test x11_shm_capture -- --test-threads=1 + cargo test --locked -p scap-targets --lib --test linux_targets cargo test --locked -p cap-enc-ffmpeg build-gpui: @@ -370,6 +382,70 @@ jobs: fi cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} -p cap-gpui-kernels + # Cap only builds gpui's crates as dependencies, so their own tests never run + # in the app jobs above. Run the ones covering Cap's patches against real + # devices: Metal on macOS, WARP on Windows, and Mesa's lavapipe (Vulkan) and + # llvmpipe (GL) on Linux. Windows renders with D3D11 and its runners expose + # no wgpu adapter, so wgpu's tests run on macOS and Linux only. Zed's lockfile predates the patches' dependency + # changes, so these builds cannot use --locked. + gpui-patch-tests: + name: GPUI patch tests (${{ matrix.settings.name }}) + needs: changes + if: needs.changes.outputs.gpui-patches == 'true' + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + settings: + - name: macOS + runner: blacksmith-6vcpu-macos-15 + - name: Windows + runner: blacksmith-8vcpu-windows-2025 + - name: Linux + runner: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ matrix.settings.runner }} + env: + CARGO_PROFILE_DEV_DEBUG: "0" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux desktop dependencies + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/install-desktop-deps + + - name: Install software GPU drivers + if: ${{ runner.os == 'Linux' }} + run: sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1 libegl1 libegl-mesa0 libgl1-mesa-dri + + - name: Allow long paths in Zed's git dependencies + if: ${{ runner.os == 'Windows' }} + run: git config --global core.longpaths true + + - name: Rust setup + uses: dtolnay/rust-toolchain@1.95.0 + + - name: Prepare GPUI dependency + shell: bash + run: ./scripts/prepare-gpui-dependency.sh + + - name: Test GPUI patches + shell: bash + working-directory: ../zed-cap + env: + GPUI_REQUIRE_TEST_DEVICE: "1" + run: | + cargo +1.95.0 test -p gpui --lib stream + if [[ "$RUNNER_OS" == "Linux" ]]; then + WGPU_BACKEND=vulkan cargo +1.95.0 test -p gpui_wgpu --lib wgpu_atlas -- --nocapture + WGPU_BACKEND=gl cargo +1.95.0 test -p gpui_wgpu --lib wgpu_atlas -- --nocapture + elif [[ "$RUNNER_OS" == "macOS" ]]; then + cargo +1.95.0 test -p gpui_wgpu --lib wgpu_atlas -- --nocapture + cargo +1.95.0 test -p gpui_macos --lib -- metal_atlas metal_renderer + else + cargo +1.95.0 test -p gpui_windows --lib directx_atlas + fi + tauri-plugins: name: Verify Tauri plugin versions runs-on: blacksmith-8vcpu-ubuntu-2404 diff --git a/.github/workflows/engine-verify-os.yml b/.github/workflows/engine-verify-os.yml index 95da0616218..a6cd1398ccd 100644 --- a/.github/workflows/engine-verify-os.yml +++ b/.github/workflows/engine-verify-os.yml @@ -178,6 +178,13 @@ jobs: name: verify-archive-${{ inputs.platform }}-${{ github.run_attempt }} path: ${{ runner.temp }} + # A test process that crashes (SIGSEGV) leaves no Rust backtrace; + # macOS writes a crash report instead, collected below on failure. + - name: Mark crash report start + if: inputs.platform == 'macos' + shell: bash + run: touch "$RUNNER_TEMP/crash-reports-since" + - name: Prepare recorded fixtures without concurrent rendering if: contains(format(',{0},', inputs.suites), ',export,') shell: bash @@ -217,8 +224,27 @@ jobs: --junit "$CAP_VERIFY_REPORT_DIR/junit.xml" --flaky "$CAP_VERIFY_FLAKY_FILE" "$CAP_VERIFY_REPORT_DIR" >> "$GITHUB_STEP_SUMMARY" + - name: Collect macOS crash reports + id: crash-reports + if: failure() && inputs.platform == 'macos' + continue-on-error: true + shell: bash + run: | + dest="$RUNNER_TEMP/verify-report/crash-reports" + for dir in "$HOME/Library/Logs/DiagnosticReports" /Library/Logs/DiagnosticReports; do + [[ -d "$dir" ]] || continue + { find "$dir" -type f \( -name '*.ips' -o -name '*.crash' \) -newer "$RUNNER_TEMP/crash-reports-since" -print0 2>/dev/null || true; } | + while IFS= read -r -d '' report; do + mkdir -p "$dest" + cp "$report" "$dest/" && echo "collected $report" || echo "could not copy $report" + done + done + if [[ -n "$(ls -A "$dest" 2>/dev/null)" ]]; then + echo "collected=true" >> "$GITHUB_OUTPUT" + fi + - name: Upload report - if: always() && steps.test.outcome != 'skipped' + if: always() && (steps.test.outcome != 'skipped' || steps.crash-reports.outputs.collected == 'true') uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: verify-report-${{ inputs.platform }}-${{ inputs.tier }}-${{ matrix.shard }} diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml deleted file mode 100644 index ec7b6a367a2..00000000000 --- a/.github/workflows/opencode.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: opencode - -on: - issue_comment: - types: [created] - -jobs: - opencode: - if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - pull-requests: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Run opencode - uses: sst/opencode/github@latest - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - with: - model: opencode/claude-sonnet-4-5 \ No newline at end of file diff --git a/.github/workflows/perf-budget.yml b/.github/workflows/perf-budget.yml index 999f0b46333..401f5a036ac 100644 --- a/.github/workflows/perf-budget.yml +++ b/.github/workflows/perf-budget.yml @@ -29,6 +29,8 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + - name: Test performance report isolation + run: python3 -B scripts/verify/test-perf-ab.py - name: Scope id: scope env: diff --git a/.github/workflows/recording-reliability.yml b/.github/workflows/recording-reliability.yml index 74838a71b43..d7be8f0c131 100644 --- a/.github/workflows/recording-reliability.yml +++ b/.github/workflows/recording-reliability.yml @@ -46,6 +46,7 @@ jobs: __tests__/unit/recording-storage-lifecycle.test.ts \ __tests__/unit/desktop-recording-*.test.ts \ __tests__/unit/desktop-segments-*.test.ts \ + __tests__/unit/segments-audio-download.test.ts \ __tests__/unit/finalize-desktop-recording.test.ts \ __tests__/unit/media-server-progress.test.ts \ __tests__/unit/media-processing-budget.test.ts \ @@ -56,4 +57,7 @@ jobs: __tests__/unit/video-edit-published-output.test.ts \ __tests__/unit/upload-progress-playback.test.ts \ __tests__/unit/recording-prepare.test.ts \ - __tests__/unit/s3-bucket-connections.test.ts + __tests__/unit/s3-bucket-connections.test.ts \ + __tests__/unit/media-processing-workflows.test.ts \ + __tests__/unit/new-media-comment-thumbnail.test.ts \ + __tests__/unit/comment-reply-parent.test.ts diff --git a/.github/workflows/sync-tests.yml b/.github/workflows/sync-tests.yml index 1228d40e528..0e7cc1049ca 100644 --- a/.github/workflows/sync-tests.yml +++ b/.github/workflows/sync-tests.yml @@ -146,7 +146,7 @@ jobs: - name: Timestamp pipeline unit + property tests shell: bash run: | - cargo test --locked -p cap-timestamp -p cap-enc-ffmpeg + cargo test --locked -p cap-timestamp -p cap-enc-ffmpeg -p ffmpeg-hw-device cargo test --locked -p cap-camera-effects --lib cargo test --locked -p cap-recording --lib -- --test-threads=1 # --nocapture so a WARP-adapter notch skip prints instead of diff --git a/.github/workflows/verify-recording.yml b/.github/workflows/verify-recording.yml index 671fe6b73d5..3420e677f8a 100644 --- a/.github/workflows/verify-recording.yml +++ b/.github/workflows/verify-recording.yml @@ -251,6 +251,15 @@ jobs: done SwitchAudioSource -s "BlackHole 2ch" -t output SwitchAudioSource -s "BlackHole 2ch" -t input + # macOS 15 plays alert and interface sounds through the default + # output, BlackHole, so ScreenCaptureKit records them as system + # audio and Instant's mixed track and every export carry them over + # the microphone markers. Hosted runners and macOS 26 capture + # silence there; silence these too so every runner records the same. + osascript -e 'set volume alert volume 0' || true + defaults write -g com.apple.sound.uiaudio.enabled -int 0 + defaults write -g com.apple.sound.beep.volume -float 0 + defaults write -g com.apple.sound.beep.feedback -int 0 echo "outputs:"; SwitchAudioSource -a -t output echo "default output: $(SwitchAudioSource -c -t output)" diff --git a/.gitignore b/.gitignore index e7b19bc4a20..66b2c0900a3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ outputs .zed .output .vinxi +*.config.timestamp_*.js native-deps* .tinyb diff --git a/README.md b/README.md index 42532769551..389a250fbf6 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ S3_PUBLIC_URL=https://s3.yourdomain.com See the [self-hosting guide](https://cap.so/docs/self-hosting) for email setup, AI providers, SSL, storage, production hardening, and troubleshooting. +For single-owner instances behind an authenticating reverse proxy, see +[trusted-proxy authentication](docs/trusted-proxy-auth.md) for the required shared +secret, proxy hardening, and how to trigger sign-in without a built-in login button. + ## Local Development Cap is a Turborepo monorepo with Rust, TypeScript, Tauri, SolidStart, Next.js, Drizzle, MySQL, Tailwind CSS, and shared media crates. diff --git a/apps/chrome-extension/src/background/service-worker.test.ts b/apps/chrome-extension/src/background/service-worker.test.ts new file mode 100644 index 00000000000..fc25cca41b7 --- /dev/null +++ b/apps/chrome-extension/src/background/service-worker.test.ts @@ -0,0 +1,177 @@ +import type { VideoId } from "@cap/recorder-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RECORDING_STATE_KEY } from "../shared/storage-keys"; +import type { + OffscreenResponse, + RecordingStatus, + ServiceWorkerResponse, +} from "../shared/types"; + +type MessageListener = Parameters< + typeof chrome.runtime.onMessage.addListener +>[0]; + +let onMessage: MessageListener; +let stored: Record; +let pollReplies: Array<(response: OffscreenResponse) => void>; +let contextReplies: Array<(contexts: object[]) => void>; + +const liveStatus: RecordingStatus = { + phase: "recording", + startedAt: 1, + durationMs: 1000, + updatedAt: 1001, +}; + +beforeEach(async () => { + vi.resetModules(); + stored = {}; + pollReplies = []; + contextReplies = []; + const storage = { + get: (_keys: string[], callback: (items: object) => void) => + callback({ ...stored }), + set: (items: object, callback: () => void) => { + Object.assign(stored, items); + callback(); + }, + setAccessLevel: vi.fn(), + }; + vi.stubGlobal("chrome", { + storage: { local: storage, session: storage }, + runtime: { + getURL: (path: string) => `chrome-extension://test/${path}`, + ContextType: { OFFSCREEN_DOCUMENT: "OFFSCREEN_DOCUMENT" }, + getContexts: vi.fn((_filter, callback) => { + contextReplies.push(callback); + }), + sendMessage: vi.fn((_message, callback) => { + pollReplies.push(callback); + }), + onMessage: { + addListener: (listener: MessageListener) => { + onMessage = listener; + }, + }, + onInstalled: { addListener: vi.fn() }, + }, + action: { + setPopup: vi.fn((_options, callback) => callback()), + setBadgeText: vi.fn((_options, callback) => callback()), + setTitle: vi.fn((_options, callback) => callback()), + onClicked: { addListener: vi.fn() }, + }, + tabs: { + query: (_query: object, callback: (tabs: object[]) => void) => + callback([]), + onActivated: { addListener: vi.fn() }, + onUpdated: { addListener: vi.fn() }, + onRemoved: { addListener: vi.fn() }, + }, + windows: { onFocusChanged: { addListener: vi.fn() } }, + }); + await import("./service-worker"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const broadcast = (status: RecordingStatus) => + onMessage( + { + target: "recording-status", + type: "recording-status-changed", + status, + }, + {}, + vi.fn(), + ); + +const pollStatus = () => + new Promise((resolve) => { + onMessage( + { target: "service-worker", type: "get-recording-status" }, + {}, + resolve, + ); + }); + +const answerContexts = (present: boolean) => { + const reply = contextReplies.shift(); + if (!reply) throw new Error("No pending context query"); + reply( + present ? [{ documentUrl: "chrome-extension://test/offscreen.html" }] : [], + ); +}; + +describe("recording status synchronization", () => { + it.each(["recording", "paused", "uploading"] as const)( + "restores %s from the offscreen document after a worker restart", + async (phase) => { + const currentStatus = { ...liveStatus, phase }; + const response = pollStatus(); + answerContexts(true); + await vi.waitFor(() => expect(contextReplies).toHaveLength(1)); + answerContexts(true); + await vi.waitFor(() => expect(pollReplies).toHaveLength(1)); + pollReplies[0]({ ok: true, status: currentStatus }); + + expect(await response).toMatchObject({ ok: true, status: currentStatus }); + expect(stored[RECORDING_STATE_KEY]).toMatchObject({ + status: currentStatus, + }); + }, + ); + + it.each([ + { + phase: "completed", + videoId: "video" as VideoId, + shareUrl: "https://cap.so/s/video", + }, + { phase: "error", message: "Upload failed", recoverable: true }, + ] satisfies RecordingStatus[])( + "preserves a newer $phase broadcast over an older poll response", + async (nextStatus) => { + const response = pollStatus(); + answerContexts(true); + await vi.waitFor(() => expect(contextReplies).toHaveLength(1)); + answerContexts(true); + await vi.waitFor(() => expect(pollReplies).toHaveLength(1)); + + broadcast(nextStatus); + pollReplies[0]({ ok: true, status: liveStatus }); + + expect(await response).toMatchObject({ ok: true, status: nextStatus }); + expect(stored[RECORDING_STATE_KEY]).toMatchObject({ status: nextStatus }); + expect(chrome.action.setBadgeText).toHaveBeenLastCalledWith( + { text: "" }, + expect.any(Function), + ); + }, + ); + + it("preserves a recording broadcast received while checking for the offscreen document", async () => { + const response = pollStatus(); + broadcast(liveStatus); + answerContexts(false); + + expect(await response).toMatchObject({ ok: true, status: liveStatus }); + expect(stored[RECORDING_STATE_KEY]).toMatchObject({ status: liveStatus }); + }); + + it("clears a phantom recording when its offscreen document is gone", async () => { + broadcast(liveStatus); + const response = pollStatus(); + answerContexts(false); + + expect(await response).toMatchObject({ + ok: true, + status: { phase: "idle" }, + }); + expect(stored[RECORDING_STATE_KEY]).toMatchObject({ + status: { phase: "idle" }, + }); + }); +}); diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index 166072e3050..d3d3e65f4ff 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -1267,7 +1267,9 @@ const forwardToOffscreen = (type: OffscreenRequest["type"]) => sendOffscreen({ target: "offscreen", type } as OffscreenRequest); const syncRecordingStatus = async () => { + const initialStatus = recordingStatus; const hasDocument = await hasOffscreenDocument(); + if (recordingStatus !== initialStatus) return recordingStatus; if (!hasDocument) { if ( isActiveRecordingStatus(recordingStatus) || @@ -1292,6 +1294,7 @@ const syncRecordingStatus = async () => { { target: "offscreen", type: "get-recording-status" }, { createIfMissing: false }, ); + if (recordingStatus !== initialStatus) return recordingStatus; if (response.ok && response.status) { if ( recordingStatus.phase === "creating" && diff --git a/apps/chrome-extension/src/offscreen/recorder.test.ts b/apps/chrome-extension/src/offscreen/recorder.test.ts new file mode 100644 index 00000000000..97b706470c3 --- /dev/null +++ b/apps/chrome-extension/src/offscreen/recorder.test.ts @@ -0,0 +1,198 @@ +import type { RecordingSpool } from "@cap/recorder-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OffscreenResponse, RecordingMode } from "../shared/types"; + +type MessageListener = Parameters< + typeof chrome.runtime.onMessage.addListener +>[0]; + +class Track extends EventTarget { + enabled = true; + readyState = "live"; + label = "Test capture"; + + constructor(readonly kind: "audio" | "video") { + super(); + } + + getSettings() { + return { width: 320, height: 180, frameRate: 30 }; + } +} + +class Stream { + constructor(private tracks: Track[] = []) {} + + getTracks() { + return this.tracks; + } + + getAudioTracks() { + return this.tracks.filter((track) => track.kind === "audio"); + } + + getVideoTracks() { + return this.tracks.filter((track) => track.kind === "video"); + } + + addTrack(track: Track) { + this.tracks.push(track); + } +} + +let onMessage: MessageListener; +let capturedStream: Stream; + +beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers(); + const stored: Record = {}; + const storage = { + get: (_keys: string[], callback: (items: object) => void) => + callback({ ...stored }), + set: (items: object, callback: () => void) => { + Object.assign(stored, items); + callback(); + }, + }; + vi.stubGlobal("window", globalThis); + vi.stubGlobal("chrome", { + storage: { local: storage, session: storage }, + runtime: { + onMessage: { + addListener: (listener: MessageListener) => { + onMessage = listener; + }, + }, + sendMessage: (_message: unknown, callback: () => void) => callback(), + }, + }); + vi.stubGlobal("MediaStream", Stream); + vi.stubGlobal( + "MediaRecorder", + class { + static isTypeSupported() { + return true; + } + + constructor(stream: Stream) { + capturedStream = stream; + } + + start() {} + }, + ); + vi.stubGlobal( + "AudioContext", + class { + destination = {}; + + createMediaStreamDestination() { + return { stream: new Stream([new Track("audio")]) }; + } + + createMediaStreamSource() { + return { connect: vi.fn() }; + } + }, + ); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = input.toString(); + if (url.endsWith("/instant-recordings")) { + return Response.json({ + id: "video-test", + shareUrl: "https://cap.test/s/video-test", + }); + } + if (url.endsWith("/initiate")) { + return Response.json({ uploadId: "upload-test", provider: "s3" }); + } + throw new Error(`Unexpected request: ${url}`); + }), + ); + const { RecordingSpool } = await import("@cap/recorder-core"); + vi.spyOn(RecordingSpool, "create").mockResolvedValue({ + sessionId: "spool-test", + } as RecordingSpool); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const send = (message: Record) => + new Promise((resolve) => { + onMessage({ target: "offscreen", ...message }, {}, resolve); + }); + +describe("recording microphone mute", () => { + it.each([ + { mode: "tab", microphone: true, systemAudio: true }, + { mode: "fullscreen", microphone: true, systemAudio: true }, + { mode: "camera", microphone: true, systemAudio: false }, + { mode: "tab", microphone: false, systemAudio: true }, + ] satisfies Array<{ + mode: RecordingMode; + microphone: boolean; + systemAudio: boolean; + }>)( + "mutes only microphone capture in $mode with microphone=$microphone", + async ({ mode, microphone, systemAudio }) => { + const mic = new Track("audio"); + const system = new Track("audio"); + const main = new Stream([new Track("video")]); + if (systemAudio) main.addTrack(system); + if (mode === "camera" && microphone) main.addTrack(mic); + vi.stubGlobal("navigator", { + userAgent: "Chrome/152.0.0.0", + mediaDevices: { + getUserMedia: async (constraints: MediaStreamConstraints) => + constraints.video ? main : new Stream([mic]), + getDisplayMedia: async () => main, + }, + }); + const { defaultSettings } = await import("../shared/storage"); + await import("./recorder"); + expect( + await send({ + type: "start-recording", + mode, + tabStreamId: "tab-test", + settings: { + ...defaultSettings, + apiBaseUrl: "https://cap.test", + microphone: { enabled: microphone, deviceId: null }, + systemAudio: { enabled: systemAudio }, + sounds: { enabled: false }, + countdown: { enabled: false, seconds: 3 }, + }, + auth: { authApiKey: "test", userId: "user-test" }, + bootstrap: { + organization: { id: "org-test" }, + plan: { isPro: true, maxRecordingSeconds: null }, + }, + }), + ).toMatchObject({ ok: true, status: { phase: "recording" } }); + + expect( + await send({ type: "toggle-microphone-mute", muted: true }), + ).toEqual({ ok: true }); + expect(mic.enabled).toBe(!microphone); + expect(system.enabled).toBe(true); + if (systemAudio) { + expect(capturedStream.getAudioTracks()[0]?.enabled).toBe(true); + } + + expect( + await send({ type: "toggle-microphone-mute", muted: false }), + ).toEqual({ ok: true }); + expect(mic.enabled).toBe(true); + expect(system.enabled).toBe(true); + }, + ); +}); diff --git a/apps/chrome-extension/src/offscreen/recorder.ts b/apps/chrome-extension/src/offscreen/recorder.ts index 2696dd36d90..79218650612 100644 --- a/apps/chrome-extension/src/offscreen/recorder.ts +++ b/apps/chrome-extension/src/offscreen/recorder.ts @@ -95,6 +95,7 @@ type ActiveRecording = { recorder: MediaRecorder; stopPromise: Promise; streams: MediaStream[]; + microphoneTracks: MediaStreamTrack[]; recordingStream: MediaStream; statusTimer: number | null; spool: RecordingSpool; @@ -999,6 +1000,10 @@ const startRecording = async (request: StartRecordingRequest) => { recorder, stopPromise: Promise.resolve(), streams, + microphoneTracks: + request.mode === "camera" + ? mainStream.getAudioTracks() + : (microphoneStream?.getAudioTracks() ?? []), recordingStream, statusTimer: null, spool, @@ -1174,9 +1179,12 @@ const startRecording = async (request: StartRecordingRequest) => { ); await ownedSpool.dispose().catch(() => undefined); } - // Reset the "creating" status so later status syncs do not report a - // phantom in-progress recording. - if (status.phase === "creating") { + // Reset the status this attempt set so later status syncs do not report + // a phantom in-progress recording. "recording" is already set before + // recorder.start(), so a throw there would otherwise leave a capture + // phase with no session behind it: stop becomes a no-op and the idle + // self-close never fires. + if (status.phase === "creating" || status.phase === "recording") { status = isUserCancellationError(error) ? { phase: "idle" } : { @@ -1773,12 +1781,7 @@ const handleRequest = async ( if (message.type === "toggle-microphone-mute") { const recording = activeRecording; if (recording) { - for (const stream of recording.streams) { - for (const track of stream.getAudioTracks()) { - track.enabled = !message.muted; - } - } - for (const track of recording.recordingStream.getAudioTracks()) { + for (const track of recording.microphoneTracks) { track.enabled = !message.muted; } } diff --git a/apps/cli/README.md b/apps/cli/README.md index e33a97c0e88..817a528b395 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -82,7 +82,7 @@ step. - `cap screenshot` — capture a still of a screen/window (`--json` → `{path,width,height}`). - `cap targets` (`screens`/`windows`/`cameras`/`mics`) — enumerate capture inputs. - `cap project inspect` / `validate` / `config get|set` — inspect and edit `.cap` projects. -- `cap recordings list` — list `.cap` recordings in the desktop library. +- `cap recordings list` — list `.cap` recordings across Desktop's current, default, and previously used storage folders. Use `--dir ` to list only that directory. - `cap upload` — upload a `.cap` project or video file and get a shareable link. - `cap update` — download and install the latest Cap Desktop bundle, then repair the `cap` shim. - `cap doctor` / `version` / `guide` — diagnostics, version info, and the agent capability manifest. diff --git a/apps/cli/src/project.rs b/apps/cli/src/project.rs index 5b0f8cdfb68..8fbf0052624 100644 --- a/apps/cli/src/project.rs +++ b/apps/cli/src/project.rs @@ -24,6 +24,8 @@ pub struct ProjectInspection { } pub fn config_get(project_path: PathBuf) -> Result<(), String> { + RecordingMeta::load_for_project(&project_path) + .map_err(|e| format!("Failed to load recording meta: {e}"))?; let config = match cap_project::ProjectConfiguration::load(&project_path) { Ok(config) => config, // Instant and un-edited studio recordings have no project-config.json; return the @@ -42,6 +44,8 @@ pub fn config_set( ) -> Result<(), String> { let config: cap_project::ProjectConfiguration = serde_json::from_str(settings_json) .map_err(|e| format!("Invalid project config JSON: {e}"))?; + RecordingMeta::load_for_project(&project_path) + .map_err(|e| format!("Failed to load recording meta: {e}"))?; // write() validates internally before its atomic temp-file-then-rename. config .write(&project_path) diff --git a/apps/cli/src/recordings.rs b/apps/cli/src/recordings.rs index c633bb4a034..dc517bb81ad 100644 --- a/apps/cli/src/recordings.rs +++ b/apps/cli/src/recordings.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashSet, path::{Path, PathBuf}, time::SystemTime, }; @@ -12,10 +13,41 @@ use crate::{OutputFormat, write_json}; // which the user can reach with an explicit `--dir`. const DESKTOP_BUNDLE_IDENTIFIER: &str = "so.cap.desktop"; -fn default_library_dir() -> Result { - dirs::data_dir() - .ok_or_else(|| "Could not determine the OS application-data directory".to_string()) - .map(|dir| dir.join(DESKTOP_BUNDLE_IDENTIFIER).join("recordings")) +fn library_dirs(app_data: &Path) -> Vec { + let store = std::fs::read(app_data.join("store")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + let settings = store + .as_ref() + .and_then(|store| store.get("general_settings")); + let mut dirs = Vec::new(); + + if let Some(path) = settings + .and_then(|settings| settings.get("recordingsPath")) + .and_then(serde_json::Value::as_str) + { + dirs.push(PathBuf::from(path)); + } + dirs.push(app_data.join("recordings")); + if let Some(paths) = settings + .and_then(|settings| settings.get("previousRecordingsPaths")) + .and_then(serde_json::Value::as_array) + { + dirs.extend( + paths + .iter() + .filter_map(serde_json::Value::as_str) + .map(PathBuf::from), + ); + } + + let mut seen = HashSet::new(); + dirs.retain(|path| { + path.is_absolute() + && path.is_dir() + && seen.insert(path.canonicalize().unwrap_or_else(|_| path.clone())) + }); + dirs } #[derive(Serialize)] @@ -37,28 +69,38 @@ fn recording_type(meta: &RecordingMeta) -> &'static str { } } -fn collect_rows(dir: &Path) -> Result, String> { - if !dir.exists() { - return Ok(Vec::new()); - } - - let mut entries: Vec<(PathBuf, SystemTime, RecordingMeta)> = std::fs::read_dir(dir) - .map_err(|e| format!("Failed to read recordings directory: {e}"))? - .filter_map(|entry| { +fn collect_rows( + dirs: &[PathBuf], + fail_on_unreadable: bool, + created_at: impl Fn(&Path) -> std::io::Result, +) -> Result, String> { + let mut entries: Vec<(PathBuf, SystemTime, RecordingMeta)> = Vec::new(); + for dir in dirs { + if !dir.exists() { + continue; + } + let contents = match std::fs::read_dir(dir) { + Ok(contents) => contents, + Err(_) if !fail_on_unreadable => continue, + Err(error) => { + return Err(format!( + "Failed to read recordings directory {}: {error}", + dir.display() + )); + } + }; + entries.extend(contents.filter_map(|entry| { let path = entry.ok()?.path(); if !path.is_dir() || !path.file_name()?.to_str()?.ends_with(".cap") { return None; } let meta = RecordingMeta::load_for_project(&path).ok()?; - let created = path - .metadata() - .and_then(|m| m.created()) - .unwrap_or(SystemTime::UNIX_EPOCH); + let created = created_at(&path).unwrap_or(SystemTime::UNIX_EPOCH); Some((path, created, meta)) - }) - .collect(); + })); + } - entries.sort_by(|a, b| b.1.cmp(&a.1)); + entries.sort_by_key(|entry| std::cmp::Reverse(entry.1)); Ok(entries .into_iter() @@ -77,18 +119,26 @@ fn collect_rows(dir: &Path) -> Result, String> { } pub fn list(dir: Option, format: OutputFormat) -> Result<(), String> { - let dir = match dir { - Some(dir) => dir, - None => default_library_dir()?, + let dirs = match &dir { + Some(dir) => vec![dir.clone()], + None => { + let app_data = dirs::data_dir() + .ok_or_else(|| "Could not determine the OS application-data directory".to_string())? + .join(DESKTOP_BUNDLE_IDENTIFIER); + library_dirs(&app_data) + } }; - let rows = collect_rows(&dir)?; + let rows = collect_rows(&dirs, dir.is_some(), |path| path.metadata()?.created())?; match format { OutputFormat::Json => write_json(&rows), OutputFormat::Text => { if rows.is_empty() { - println!("No recordings found in {}", dir.display()); + match dir { + Some(dir) => println!("No recordings found in {}", dir.display()), + None => println!("No recordings found in the desktop library"), + } return Ok(()); } for row in &rows { @@ -106,3 +156,129 @@ pub fn list(dir: Option, format: OutputFormat) -> Result<(), String> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn write_recording(path: &Path, name: &str) { + std::fs::create_dir_all(path).unwrap(); + std::fs::write( + path.join("recording-meta.json"), + serde_json::to_vec(&serde_json::json!({ + "pretty_name": name, "fps": 30, "sample_rate": null + })) + .unwrap(), + ) + .unwrap(); + } + + #[test] + fn discovers_current_default_and_previous_storage_once() { + let root = tempfile::tempdir().unwrap(); + let app_data = root.path().join("desktop"); + let default = app_data.join("recordings"); + let current = root.path().join("current"); + let previous = root.path().join("previous"); + for dir in [&default, ¤t, &previous] { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write( + app_data.join("store"), + serde_json::to_vec(&serde_json::json!({ + "general_settings": { + "recordingsPath": current, + "previousRecordingsPaths": [previous, current, "relative", root.path().join("missing"), 42] + } + })) + .unwrap(), + ) + .unwrap(); + + assert_eq!(library_dirs(&app_data), vec![current, default, previous]); + } + + #[test] + fn falls_back_to_default_storage_without_valid_settings() { + let root = tempfile::tempdir().unwrap(); + let default = root.path().join("recordings"); + std::fs::create_dir(&default).unwrap(); + assert_eq!(library_dirs(root.path()), vec![default.clone()]); + + for store in [ + "invalid JSON", + r#"{"general_settings":{"recordingsPath":false,"previousRecordingsPaths":"invalid"}}"#, + ] { + std::fs::write(root.path().join("store"), store).unwrap(); + assert_eq!(library_dirs(root.path()), vec![default.clone()]); + } + } + + #[cfg(unix)] + #[test] + fn deduplicates_storage_reached_through_symlinks() { + let root = tempfile::tempdir().unwrap(); + let default = root.path().join("recordings"); + let alias = root.path().join("alias"); + std::fs::create_dir(&default).unwrap(); + std::os::unix::fs::symlink(&default, &alias).unwrap(); + std::fs::write( + root.path().join("store"), + serde_json::to_vec(&serde_json::json!({ + "general_settings": { "recordingsPath": alias } + })) + .unwrap(), + ) + .unwrap(); + + assert_eq!(library_dirs(root.path()), vec![alias]); + } + + #[test] + fn merges_recordings_from_multiple_libraries_newest_first() { + let root = tempfile::tempdir().unwrap(); + let recordings = [ + (root.path().join("default/oldest.cap"), "Oldest", 10), + (root.path().join("custom/older.cap"), "Older", 20), + (root.path().join("default/newer.cap"), "Newer", 30), + (root.path().join("custom/newest.cap"), "Newest", 40), + ]; + for (path, name, _) in &recordings { + write_recording(path, name); + } + + let rows = collect_rows( + &[root.path().join("default"), root.path().join("custom")], + true, + |path| { + let (_, _, seconds) = recordings + .iter() + .find(|(recording_path, _, _)| recording_path == path) + .unwrap(); + Ok(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(*seconds)) + }, + ) + .unwrap(); + assert_eq!( + rows.iter().map(|row| row.name.as_str()).collect::>(), + ["Newest", "Newer", "Older", "Oldest"] + ); + } + + #[test] + fn skips_unavailable_roots_only_when_discovering_the_library() { + let root = tempfile::tempdir().unwrap(); + let available = root.path().join("available"); + let unavailable = root.path().join("unavailable"); + write_recording(&available.join("example.cap"), "Available"); + std::fs::write(&unavailable, "no longer a directory").unwrap(); + + let rows = collect_rows(&[unavailable.clone(), available], false, |path| { + path.metadata()?.created() + }) + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "Available"); + assert!(collect_rows(&[unavailable], true, |path| path.metadata()?.created()).is_err()); + } +} diff --git a/apps/cli/src/selftest/playback.rs b/apps/cli/src/selftest/playback.rs index 57e997d9471..2e6929ea290 100644 --- a/apps/cli/src/selftest/playback.rs +++ b/apps/cli/src/selftest/playback.rs @@ -571,7 +571,7 @@ mod fixture { use cap_recording::{ AudioFrame, ChannelAudioSource, ChannelAudioSourceConfig, ChannelVideoSource, ChannelVideoSourceConfig, OutputPipeline, - ffmpeg::{FFmpegVideoFrame, Mp4Muxer, OggMuxer}, + ffmpeg::{FFmpegVideoFrame, Mp4Muxer, OggMuxer, OggMuxerConfig}, }; use cap_timestamp::{Timestamp, Timestamps}; use relative_path::RelativePathBuf; @@ -783,7 +783,7 @@ mod fixture { audio_info, mic_rx, )) .with_timestamps(timestamps) - .build::(()) + .build::(OggMuxerConfig::default()) .await .map_err(|e| format!("mic pipeline: {e}"))?; let sys_pipeline = OutputPipeline::builder(audio_path.clone()) @@ -794,7 +794,7 @@ mod fixture { // System audio anchors at the recording epoch, exactly like the // studio recorder configures it. .with_audio_anchor(cap_recording::AudioAnchor::PipelineEpoch) - .build::(()) + .build::(OggMuxerConfig::default()) .await .map_err(|e| format!("system audio pipeline: {e}"))?; @@ -860,12 +860,14 @@ mod fixture { start_time: Some(mic_start), device_id: None, gap_summary: finished_mic.audio_gap_summary.map(to_project_gap_summary), + digital_silence_ms: finished_mic.digital_silence_ms, }), system_audio: Some(AudioMeta { path: RelativePathBuf::from("content/segments/segment-0/system_audio.ogg"), start_time: Some(sys_start), device_id: None, gap_summary: finished_sys.audio_gap_summary.map(to_project_gap_summary), + digital_silence_ms: None, }), cursor: None, keyboard: None, diff --git a/apps/cli/src/upload.rs b/apps/cli/src/upload.rs index 0844b483247..e2a95d8b8b2 100644 --- a/apps/cli/src/upload.rs +++ b/apps/cli/src/upload.rs @@ -258,6 +258,27 @@ pub async fn upload_video_path(file_path: &Path, name: Option) -> Result } } +fn agent_upload_payload( + file_name: &str, + name: Option<&str>, + content_length: u64, + meta: &VideoMeta, +) -> serde_json::Value { + let mut payload = json!({ + "fileName": file_name, + "contentType": "video/mp4", + "contentLength": content_length, + "durationSeconds": meta.duration_in_secs, + "width": meta.width, + "height": meta.height, + "fps": meta.fps, + }); + if let Some(name) = name { + payload["title"] = json!(name); + } + payload +} + async fn upload_file_with_agent( file_path: &Path, name: Option<&str>, @@ -276,16 +297,7 @@ async fn upload_file_with_agent( .mutate_json( Method::POST, "/uploads", - &json!({ - "fileName": file_name, - "contentType": "video/mp4", - "contentLength": content_length, - "durationSeconds": meta.duration_in_secs, - "width": meta.width, - "height": meta.height, - "fps": meta.fps, - "title": name, - }), + &agent_upload_payload(file_name, name, content_length, meta), ) .await .map_err(|error| error.to_string())?; @@ -529,9 +541,26 @@ async fn upload_file(http: &Client, put_url: &str, path: &Path) -> Result<(), St #[cfg(test)] mod tests { - use super::prefer_agent_upload_sources; + use super::{VideoMeta, agent_upload_payload, prefer_agent_upload_sources}; use crate::credentials::{AgentCredentialSource, CredentialSource}; + #[test] + fn agent_upload_payload_only_includes_a_requested_title() { + let meta = VideoMeta { + duration_in_secs: 1.0, + width: 640, + height: 360, + fps: 30.0, + }; + for name in [None, Some("Demo"), Some("")] { + let payload = agent_upload_payload("demo.mp4", name, 1024, &meta); + match name { + Some(name) => assert_eq!(payload["title"], name), + None => assert!(payload.get("title").is_none()), + } + } + } + #[test] fn explicit_agent_environment_credential_has_highest_upload_priority() { assert!(prefer_agent_upload_sources( diff --git a/apps/cli/tests/cli.rs b/apps/cli/tests/cli.rs index 712adbf7487..d3372bb3ebb 100644 --- a/apps/cli/tests/cli.rs +++ b/apps/cli/tests/cli.rs @@ -609,6 +609,51 @@ fn recordings_list_empty_dir_is_empty_json() { assert_eq!(json.as_array().map(|a| a.len()), Some(0)); } +#[cfg(target_os = "linux")] +#[test] +fn recordings_list_follows_desktop_storage_unless_dir_is_explicit() { + let root = tempfile::tempdir().unwrap(); + let app_data = root.path().join("so.cap.desktop"); + let default = app_data.join("recordings"); + let current = root.path().join("custom"); + let previous = root.path().join("previous"); + for dir in [&default, ¤t, &previous] { + write_single_segment_meta(&dir.join("example.cap")); + } + std::fs::write( + app_data.join("store"), + serde_json::to_vec(&serde_json::json!({ + "general_settings": { + "recordingsPath": current, + "previousRecordingsPaths": [previous, current] + } + })) + .unwrap(), + ) + .unwrap(); + + let list = |dir: Option<&Path>| { + let mut command = cap(); + command + .env("XDG_DATA_HOME", root.path()) + .args(["recordings", "list", "--json"]); + if let Some(dir) = dir { + command.arg("--dir").arg(dir); + } + let output = command.output().unwrap(); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + parse_json(&output) + }; + + assert_eq!(list(None).as_array().unwrap().len(), 3); + let explicit = list(Some(¤t)); + assert_eq!(explicit.as_array().unwrap().len(), 1); + assert_eq!( + explicit[0]["path"], + current.join("example.cap").to_str().unwrap() + ); +} + #[test] fn project_config_set_then_get_roundtrips() { let dir = tempfile::tempdir().unwrap(); @@ -660,6 +705,27 @@ fn project_config_get_without_file_returns_default() { assert!(json.is_object(), "expected a default project config object"); } +#[test] +fn project_config_commands_reject_directories_without_recordings() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("not-a-recording.cap"); + std::fs::create_dir(&project).unwrap(); + + for command in ["get", "set"] { + let mut args = vec!["project", "config", command, project.to_str().unwrap()]; + if command == "set" { + args.extend(["--settings-json", "{}"]); + } + args.push("--json"); + + let output = run(&args); + assert!(!output.status.success(), "{command} unexpectedly succeeded"); + assert!(parse_json(&output)["error"].is_string()); + } + + assert!(!project.join("project-config.json").exists()); +} + #[test] fn export_missing_project_emits_json_error_event() { let output = run(&["export", "/this/path/does/not/exist.cap", "--progress-json"]); diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 869eb69b140..435cda07faf 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -2548,6 +2548,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.2" @@ -4312,7 +4321,7 @@ dependencies = [ "num-traits", "ordered-channel", "pbr", - "quick-error", + "quick-error 2.0.1", "resize", "rgb", "wild", @@ -4569,6 +4578,7 @@ dependencies = [ "anyhow", "async-channel", "async-task", + "backtrace", "bindgen 0.71.1", "bitflags 2.13.1", "block", @@ -4612,6 +4622,7 @@ dependencies = [ "pollster 0.4.0", "postage", "profiling", + "proptest", "rand 0.9.5", "raw-window-handle", "refineable", @@ -5465,7 +5476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -8370,6 +8381,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proptest" +version = "1.10.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "proptest-macro", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-macro" +version = "0.5.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -8434,6 +8475,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -8662,6 +8709,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -8718,7 +8774,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -9312,6 +9368,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.14.1" @@ -10949,7 +11017,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg 0.5.15", ] @@ -11518,6 +11586,12 @@ dependencies = [ "libc", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-char-property" version = "0.9.0" @@ -11954,6 +12028,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "waker-fn" version = "1.2.0" diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index a713117fbee..e51320a0a5e 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -179,6 +179,7 @@ cc = "1.4" tauri-winres = "0.3.5" [dev-dependencies] +gpui = { git = "https://github.com/wingleeio/zed", rev = "5d1f83d9f27a19bec1fb241dc33b42238af9cf8d", features = ["test-support"] } # The editor smoke (`tests/editor_frame0.rs`) drives `EditorInstance`, whose # decoders, renderer and preview tasks are all tokio-spawned -- a current-thread # runtime would deadlock the moment the test thread blocks on a frame. Kept in diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index f29044473cf..f27c626305d 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -1845,7 +1845,7 @@ its baseline values, every drag and delete having been undone. | **Clips cannot be reordered, and their body drag does nothing** | The clip body's 4px drag is a **crossfade-duration** drag, not a move (`TL/ClipTrack.tsx:849-945`) — it grows the incoming transition and selects it. Transitions have no drawn affordance in this rev (E3 renders their effect on the boxes but not the handle or the marker), so the press only ever selects. Reordering exists in the shipping app but **not on the timeline**: it is the Clips layout mode's list (`ClipsSidebar.tsx:650`, behind the header's Clips button), which is its own unit. | | **The 3D track's split is not reproduced** | `splitCamera3DSegment` rebuilds both halves' nine pose tracks around the pose the segment held at the cut (`ED/context.ts:640-676`), which needs the keyframe evaluator. Splitting a 3D segment is refused rather than done wrongly; every other track's split is real. | | **Only the zoom track creates by click** | The mask, text and audio tracks also add a segment when their empty lane is clicked (`TL/MaskTrack.tsx:120-184` and siblings), each with its own gap-finding placement and default segment. Those are creation flows for objects the sidebar has to configure; the zoom track's is here because its ghost was already drawn. The sidebar now configures all four, so the remaining three are a timeline-side gap rather than a dependency — each needs its own gap-finding placement and default segment, and the mask track's also has to seed a rect on the canvas. | -| **A no-op drag records no undo entry** | The source pushes a history entry on *every* `resume`, because the tracked memo re-runs when `pauseCount` changes — so a click that selected and never moved leaves a duplicate snapshot behind, and the first undo after one appears to do nothing. Here the resume only records when something actually changed during the bracket. | +| **A no-op drag records no undo entry** | The source pushes a history entry on *every* `resume`, because the tracked memo re-runs when `pauseCount` changes — so a click that selected and never moved leaves a duplicate snapshot behind, and the first undo after one appears to do nothing. Here the resume only records when the project ends up different from where the bracket started, so a drag that comes back to its starting value records nothing either. | | **No context menus** | Right-clicking a segment opens a real `Menu.popup()` with "Select all" and "Delete" (`TL/ZoomTrack.tsx:544-595`), and the zoom track's own dev-only "Generate zoom segments from clicks". None is reproduced. | | **No double-click fill** | Double-clicking a zoom or scene handle expands the segment as far as it can go in that direction (`fillStart`/`fillEnd`, `TL/ZoomTrack.tsx:353-399`). gpui delivers `click_count`, but the behaviour is not wired. | | **`normalizeClipTransitions` is a no-op** | `onHandleReleased` re-clamps every transition against its clips after a trim; here `effective_transition` already clamps on every read, so the stored value is left alone. The viewport half of `onHandleReleased` — pulling the transform back when a trim shortens the project out from under it — *is* reproduced. | diff --git a/apps/desktop-gpui/assets/icons/clipboard-paste.svg b/apps/desktop-gpui/assets/icons/clipboard-paste.svg new file mode 100644 index 00000000000..312524c2785 --- /dev/null +++ b/apps/desktop-gpui/assets/icons/clipboard-paste.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/kernels/Cargo.toml b/apps/desktop-gpui/kernels/Cargo.toml index f57b830d166..fab553c503e 100644 --- a/apps/desktop-gpui/kernels/Cargo.toml +++ b/apps/desktop-gpui/kernels/Cargo.toml @@ -27,6 +27,10 @@ harness = false name = "thumbnail_fit" harness = false +[[bench]] +name = "thumbnail_capture" +harness = false + [[bench]] name = "png_encode" harness = false @@ -42,3 +46,11 @@ harness = false [[bench]] name = "frame_unpad" harness = false + +[[bench]] +name = "screenshot_mask" +harness = false + +[[bench]] +name = "screenshot_blur" +harness = false diff --git a/apps/desktop-gpui/kernels/benches/screenshot_blur.rs b/apps/desktop-gpui/kernels/benches/screenshot_blur.rs new file mode 100644 index 00000000000..516ec9c68fd --- /dev/null +++ b/apps/desktop-gpui/kernels/benches/screenshot_blur.rs @@ -0,0 +1,108 @@ +use cap_gpui_kernels::screenshot as candidate; +use std::hint::black_box; +use std::time::Instant; + +fn median(samples: &mut [u128]) -> u128 { + samples.sort_unstable(); + samples[samples.len() / 2] +} + +fn blur_reference( + rgba: &[u8], + frame_width: u32, + (x0, y0, width, height): (u32, u32, u32, u32), + level: f64, +) -> Option { + let stride = frame_width as usize * 4; + let mut copied = Vec::with_capacity(width as usize * height as usize * 4); + for row in 0..height { + let start = (y0 + row) as usize * stride + x0 as usize * 4; + copied.extend_from_slice(&rgba[start..start + width as usize * 4]); + } + let source = image::RgbaImage::from_raw(width, height, copied)?; + let factor = ((level / 4.).round() as u32).max(2); + let small = image::imageops::resize( + &source, + (width / factor).max(1), + (height / factor).max(1), + image::imageops::FilterType::Triangle, + ); + Some(image::imageops::resize( + &small, + width, + height, + image::imageops::FilterType::Triangle, + )) +} + +fn time_call(rgba: &[u8], width: u32, height: u32, level: f64, reference: bool) -> u128 { + let start = Instant::now(); + let image = if reference { + blur_reference( + black_box(rgba), + black_box(width), + black_box((0, 0, width, height)), + black_box(level), + ) + } else { + candidate::mask_filter( + black_box(rgba), + black_box(width), + black_box(height), + black_box((0, 0, width, height)), + black_box(false), + black_box(level), + ) + }; + let elapsed = start.elapsed().as_nanos(); + black_box(image.unwrap()); + elapsed +} + +fn main() { + // Warmup is discarded. Recorded samples alternate which implementation + // runs first. n is odd, and the reported time is the median. + println!("workload,level,n,before_ns,after_ns,speedup"); + for (width, height, level, runs) in [ + (400u32, 300u32, 16.0f64, 21usize), + (400, 300, 8.0, 21), + (400, 300, 40.0, 21), + (1920, 1080, 16.0, 21), + (1920, 1080, 8.0, 21), + (1920, 1080, 40.0, 21), + (3840, 2160, 16.0, 11), + (3840, 2160, 8.0, 11), + (3840, 2160, 40.0, 11), + ] { + let rgba: Vec = (0..width as usize * height as usize * 4) + .map(|i| (i.wrapping_mul(31) % 251) as u8) + .collect(); + let before = blur_reference(&rgba, width, (0, 0, width, height), level) + .unwrap() + .into_raw(); + let after = + candidate::mask_filter(&rgba, width, height, (0, 0, width, height), false, level) + .unwrap() + .into_raw(); + assert_eq!(before, after, "{width}x{height} level {level}"); + for _ in 0..3 { + black_box(time_call(&rgba, width, height, level, true)); + black_box(time_call(&rgba, width, height, level, false)); + } + let mut before_ns = Vec::with_capacity(runs); + let mut after_ns = Vec::with_capacity(runs); + for sample in 0..runs { + if sample % 2 == 0 { + before_ns.push(time_call(&rgba, width, height, level, true)); + after_ns.push(time_call(&rgba, width, height, level, false)); + } else { + after_ns.push(time_call(&rgba, width, height, level, false)); + before_ns.push(time_call(&rgba, width, height, level, true)); + } + } + let before_median = median(&mut before_ns); + let after_median = median(&mut after_ns); + let speedup = before_median as f64 / after_median as f64; + println!("{width}x{height},{level},{runs},{before_median},{after_median},{speedup:.3}"); + } +} diff --git a/apps/desktop-gpui/kernels/benches/screenshot_mask.rs b/apps/desktop-gpui/kernels/benches/screenshot_mask.rs new file mode 100644 index 00000000000..59579244af7 --- /dev/null +++ b/apps/desktop-gpui/kernels/benches/screenshot_mask.rs @@ -0,0 +1,95 @@ +use cap_gpui_kernels::screenshot as candidate; +use std::{hint::black_box, time::Instant}; + +type MaskFilter = fn(&[u8], u32, u32, (u32, u32, u32, u32), bool, f64) -> Option; + +fn main() { + println!("workload,sample,before_ns,after_ns"); + for (name, frame, region, level) in [ + ( + "1080p-400x300-default", + (1920u32, 1080u32), + (200, 150, 400, 300), + 16., + ), + ("1080p-400x300-fine", (1920, 1080), (200, 150, 400, 300), 4.), + ( + "4k-1200x800-default", + (3840, 2160), + (600, 400, 1200, 800), + 16., + ), + ( + "4k-1200x800-coarse", + (3840, 2160), + (600, 400, 1200, 800), + 50., + ), + ("4k-full-default", (3840, 2160), (0, 0, 3840, 2160), 16.), + ] { + let rgba: Vec = (0..frame.0 as usize * frame.1 as usize * 4) + .map(|i| (i.wrapping_mul(31) % 251) as u8) + .collect(); + let run = |f: MaskFilter| { + let start = Instant::now(); + let image = f( + black_box(&rgba), + black_box(frame.0), + black_box(frame.1), + black_box(region), + black_box(true), + black_box(level), + ); + (start.elapsed().as_nanos(), black_box(image)) + }; + assert_eq!( + run(baseline::mask_filter).1.unwrap().into_raw(), + run(candidate::mask_filter).1.unwrap().into_raw(), + "{name}" + ); + for sample in 0..25 { + let (old, new) = if sample % 2 == 0 { + let old = run(baseline::mask_filter).0; + (old, run(candidate::mask_filter).0) + } else { + let new = run(candidate::mask_filter).0; + (run(baseline::mask_filter).0, new) + }; + if sample >= 5 { + println!("{name},{},{old},{new}", sample - 5); + } + } + } +} + +mod baseline { + pub fn mask_filter( + rgba: &[u8], + frame_width: u32, + _frame_height: u32, + (x0, y0, width, height): (u32, u32, u32, u32), + _pixelate: bool, + level: f64, + ) -> Option { + let stride = frame_width as usize * 4; + let mut copied = Vec::with_capacity(width as usize * height as usize * 4); + for row in 0..height { + let start = (y0 + row) as usize * stride + x0 as usize * 4; + copied.extend_from_slice(&rgba[start..start + width as usize * 4]); + } + let source = image::RgbaImage::from_raw(width, height, copied)?; + let block = (level.round() as u32).max(2); + let small = image::imageops::resize( + &source, + (width / block).max(1), + (height / block).max(1), + image::imageops::FilterType::Nearest, + ); + Some(image::imageops::resize( + &small, + width, + height, + image::imageops::FilterType::Nearest, + )) + } +} diff --git a/apps/desktop-gpui/kernels/benches/thumbnail_capture.rs b/apps/desktop-gpui/kernels/benches/thumbnail_capture.rs new file mode 100644 index 00000000000..a0b340a9db7 --- /dev/null +++ b/apps/desktop-gpui/kernels/benches/thumbnail_capture.rs @@ -0,0 +1,64 @@ +use cap_gpui_kernels::thumbnail::{fit_centered, fit_centered_capture}; +use image::{DynamicImage, RgbImage, RgbaImage}; +use std::{hint::black_box, time::Instant}; + +fn timed(image: &DynamicImage, fit: fn(DynamicImage) -> RgbaImage) -> u128 { + let input = image.clone(); + let start = Instant::now(); + black_box(fit(black_box(input))); + start.elapsed().as_nanos() +} + +fn before(image: DynamicImage) -> RgbaImage { + fit_centered(&image.into_rgba8(), 320, 180) +} + +fn after(image: DynamicImage) -> RgbaImage { + fit_centered_capture(image, 320, 180) +} + +fn main() { + println!("workload,width,height,sample,before_ns,after_ns"); + for (name, width, height, alpha) in [ + ("display-1080p", 1920, 1080, false), + ("display-1440p", 2560, 1440, false), + ("display-ultrawide", 3440, 1440, false), + ("display-4k", 3840, 2160, false), + ("display-5k", 5120, 2880, false), + ("window-4k-rounded", 3840, 2160, true), + ] { + let image = if alpha { + DynamicImage::ImageRgba8(RgbaImage::from_fn(width, height, |x, y| { + image::Rgba([ + (x * 17 + y) as u8, + (x + y * 11) as u8, + (x * 3 + y * 5) as u8, + if x < 12 && y < 12 { 0 } else { 255 }, + ]) + })) + } else { + DynamicImage::ImageRgb8(RgbImage::from_fn(width, height, |x, y| { + image::Rgb([ + (x * 17 + y) as u8, + (x + y * 11) as u8, + (x * 3 + y * 5) as u8, + ]) + })) + }; + for sample in 0..25 { + let (before_ns, after_ns) = if sample % 2 == 0 { + let before_ns = timed(&image, before); + (before_ns, timed(&image, after)) + } else { + let after_ns = timed(&image, after); + (timed(&image, before), after_ns) + }; + if sample >= 5 { + println!( + "{name},{width},{height},{},{before_ns},{after_ns}", + sample - 5 + ); + } + } + } +} diff --git a/apps/desktop-gpui/kernels/src/screenshot.rs b/apps/desktop-gpui/kernels/src/screenshot.rs index 0bc588e8e36..ef353a6625a 100644 --- a/apps/desktop-gpui/kernels/src/screenshot.rs +++ b/apps/desktop-gpui/kernels/src/screenshot.rs @@ -300,15 +300,349 @@ pub fn mask_filter( } let (x0, y0, width, height) = region; - let mut copied = Vec::with_capacity(width as usize * height as usize * 4); + if pixelate { + let block = (level.round() as u32).max(2); + return pixelate_region(rgba, stride, region, block); + } + + blur_triangle(rgba, stride, x0, y0, width, height, level) +} + +fn blur_triangle( + rgba: &[u8], + stride: usize, + x0: u32, + y0: u32, + width: u32, + height: u32, + level: f64, +) -> Option { + if width == 0 || height == 0 { + return image::RgbaImage::from_raw(width, height, Vec::new()); + } + let factor = ((level / 4.).round() as u32).max(2); + let small_w = (width / factor).max(1); + let small_h = (height / factor).max(1); + let small = resize_triangle(rgba, stride, (x0, y0, width, height), (small_w, small_h)); + let blurred = resize_triangle( + &small, + small_w as usize * 4, + (0, 0, small_w, small_h), + (width, height), + ); + image::RgbaImage::from_raw(width, height, blurred) +} + +struct AxisSamples { + start: Vec, + count: Vec, + offset: Vec, + weights: Vec, + max_taps: u32, +} + +struct Tap3 { + start: u32, + w0: f32, + w1: f32, + w2: f32, +} + +fn triangle_kernel(x: f32) -> f32 { + let distance = x.abs(); + if distance < 1.0 { 1.0 - distance } else { 0.0 } +} + +fn triangle_axis(input_size: u32, output_size: u32) -> AxisSamples { + let ratio = input_size as f32 / output_size as f32; + let scale = if ratio < 1.0 { 1.0 } else { ratio }; + let support = scale; + let outputs = output_size as usize; + let mut start = Vec::with_capacity(outputs); + let mut count = Vec::with_capacity(outputs); + let mut offset = Vec::with_capacity(outputs); + let mut weights = Vec::new(); + let mut max_taps = 0u32; + + for out in 0..output_size { + let input = (out as f32 + 0.5) * ratio; + let left = ((input - support).floor() as i64).clamp(0, i64::from(input_size) - 1) as u32; + let right = ((input + support).ceil() as i64) + .clamp(i64::from(left) + 1, i64::from(input_size)) as u32; + let center = input - 0.5; + let begin = weights.len(); + let mut sum = 0.0f32; + for index in left..right { + let weight = triangle_kernel((index as f32 - center) / scale); + weights.push(weight); + sum += weight; + } + for weight in &mut weights[begin..] { + *weight /= sum; + } + let taps = right - left; + max_taps = max_taps.max(taps); + start.push(left); + count.push(taps); + offset.push(begin); + } + + AxisSamples { + start, + count, + offset, + weights, + max_taps, + } +} + +fn pack_tap3(axis: &AxisSamples) -> Vec { + axis.start + .iter() + .enumerate() + .map(|(out, &start)| { + let taps = axis.count[out] as usize; + let at = axis.offset[out]; + let mut weight = [0.0f32; 3]; + weight[..taps].copy_from_slice(&axis.weights[at..at + taps]); + Tap3 { + start, + w0: weight[0], + w1: weight[1], + w2: weight[2], + } + }) + .collect() +} + +#[inline(always)] +fn quantize_channel(value: f32) -> u8 { + let clamped = value.clamp(0.0, 255.0); + // f32::round is a libm call. On [0, 255], truncating x + 0.5 matches + // half-away-from-zero except just below a midpoint, where the sum rounds + // up to the next integer. That exact integer sum is one past the tie. + let sum = clamped + 0.5; + // SAFETY: after the clamp, sum is finite and inside 0.5..=255.5, so it fits in i32. + let truncated = unsafe { sum.to_int_unchecked::() }; + let boundary = truncated as f32; + let adjusted = if sum == boundary && clamped < boundary - 0.5 { + truncated - 1 + } else { + truncated + }; + adjusted as u8 +} + +fn copy_region( + src: &[u8], + stride: usize, + (x0, y0, width, height): (u32, u32, u32, u32), +) -> Vec { + let row_bytes = width as usize * 4; + let mut copied = Vec::with_capacity(row_bytes * height as usize); for row in 0..height { let start = (y0 + row) as usize * stride + x0 as usize * 4; - copied.extend_from_slice(&rgba[start..start + width as usize * 4]); + copied.extend_from_slice(&src[start..start + row_bytes]); + } + copied +} + +// image::imageops::resize with FilterType::Triangle samples vertically into +// f32, then horizontally, and rounds only when the horizontal pass stores. +// Doing those passes in the other order, or rounding the intermediate, changes bytes. +fn resize_triangle( + src: &[u8], + stride: usize, + (x0, y0, width, height): (u32, u32, u32, u32), + (new_width, new_height): (u32, u32), +) -> Vec { + if width == 0 || height == 0 { + return vec![0u8; new_width as usize * new_height as usize * 4]; + } + if new_width == 0 || new_height == 0 { + return Vec::new(); + } + if new_width == width && new_height == height { + return copy_region(src, stride, (x0, y0, width, height)); } - let source = image::RgbaImage::from_raw(width, height, copied)?; - let processed = if pixelate { - let block = (level.round() as u32).max(2); + let vertical_weights = triangle_axis(height, new_height); + let horizontal_weights = triangle_axis(width, new_width); + let row_bytes = width as usize * 4; + let mut vertical = vec![0f32; row_bytes * new_height as usize]; + for out_y in 0..new_height as usize { + let dst = &mut vertical[out_y * row_bytes..(out_y + 1) * row_bytes]; + let tap_count = vertical_weights.count[out_y] as usize; + let weight_at = vertical_weights.offset[out_y]; + let src_y0 = vertical_weights.start[out_y]; + let weights = &vertical_weights.weights[weight_at..weight_at + tap_count]; + for (tap, &weight) in weights.iter().enumerate() { + let src_y = src_y0 + tap as u32; + let start = (y0 + src_y) as usize * stride + x0 as usize * 4; + accumulate_row(dst, &src[start..start + row_bytes], weight, tap == 0); + } + } + + let out_row = new_width as usize * 4; + let mut out = vec![0u8; out_row * new_height as usize]; + if horizontal_weights.max_taps <= 3 { + let packed = pack_tap3(&horizontal_weights); + for y in 0..new_height as usize { + sample_horizontal_tap3( + &vertical[y * row_bytes..(y + 1) * row_bytes], + &mut out[y * out_row..(y + 1) * out_row], + &packed, + width, + ); + } + } else { + for y in 0..new_height as usize { + sample_horizontal_general( + &vertical[y * row_bytes..(y + 1) * row_bytes], + &mut out[y * out_row..(y + 1) * out_row], + &horizontal_weights, + ); + } + } + out +} + +// Separate from resize setup so the SAXPY vectorizes on its own. +#[inline(never)] +fn accumulate_row(dst: &mut [f32], src: &[u8], weight: f32, replace: bool) { + if replace { + for (slot, &pixel) in dst.iter_mut().zip(src) { + *slot = f32::from(pixel) * weight; + } + } else { + for (slot, &pixel) in dst.iter_mut().zip(src) { + *slot += f32::from(pixel) * weight; + } + } +} + +#[inline(never)] +fn sample_horizontal_tap3(src_row: &[f32], dst_row: &mut [u8], taps: &[Tap3], src_width: u32) { + let width = src_width as usize; + debug_assert!(width > 0); + debug_assert_eq!(src_row.len(), width * 4); + debug_assert_eq!(dst_row.len(), taps.len() * 4); + let src = src_row.as_ptr(); + let dst = dst_row.as_mut_ptr(); + for (out_x, tap) in taps.iter().enumerate() { + let i0 = tap.start as usize; + let i1 = if i0 + 1 < width { i0 + 1 } else { width - 1 }; + let i2 = if i0 + 2 < width { i0 + 2 } else { width - 1 }; + // SAFETY: i0, i1, and i2 are < width, and src_row holds width RGBA pixels. + // out_x is < taps.len(), and dst_row holds that many pixels. + unsafe { + let p0 = src.add(i0 * 4); + let p1 = src.add(i1 * 4); + let p2 = src.add(i2 * 4); + let c0 = (*p0) * tap.w0 + (*p1) * tap.w1 + (*p2) * tap.w2; + let c1 = (*p0.add(1)) * tap.w0 + (*p1.add(1)) * tap.w1 + (*p2.add(1)) * tap.w2; + let c2 = (*p0.add(2)) * tap.w0 + (*p1.add(2)) * tap.w1 + (*p2.add(2)) * tap.w2; + let c3 = (*p0.add(3)) * tap.w0 + (*p1.add(3)) * tap.w1 + (*p2.add(3)) * tap.w2; + let out = dst.add(out_x * 4); + *out = quantize_channel(c0); + *out.add(1) = quantize_channel(c1); + *out.add(2) = quantize_channel(c2); + *out.add(3) = quantize_channel(c3); + } + } +} + +#[inline(never)] +fn sample_horizontal_general(src_row: &[f32], dst_row: &mut [u8], axis: &AxisSamples) { + debug_assert_eq!(dst_row.len(), axis.start.len() * 4); + let src = src_row.as_ptr(); + let dst = dst_row.as_mut_ptr(); + let weights = axis.weights.as_ptr(); + for out_x in 0..axis.start.len() { + let tap_count = axis.count[out_x] as usize; + let weight_at = axis.offset[out_x]; + let src_x0 = axis.start[out_x] as usize; + let mut acc = [0.0f32; 4]; + // SAFETY: triangle_axis keeps src_x0 + tap inside the row, weight_at + tap + // inside `weights`, and out_x inside dst_row. + unsafe { + for tap in 0..tap_count { + let weight = *weights.add(weight_at + tap); + let pixel = src.add((src_x0 + tap) * 4); + acc[0] += (*pixel) * weight; + acc[1] += (*pixel.add(1)) * weight; + acc[2] += (*pixel.add(2)) * weight; + acc[3] += (*pixel.add(3)) * weight; + } + let out = dst.add(out_x * 4); + *out = quantize_channel(acc[0]); + *out.add(1) = quantize_channel(acc[1]); + *out.add(2) = quantize_channel(acc[2]); + *out.add(3) = quantize_channel(acc[3]); + } + } +} + +fn pixelate_region( + rgba: &[u8], + stride: usize, + (x0, y0, width, height): (u32, u32, u32, u32), + block: u32, +) -> Option { + let columns: Vec = pixelated_samples(width, block) + .map(|x| (x0 as usize + x) * 4) + .collect(); + let row_bytes = width as usize * 4; + let mut pixels = Vec::with_capacity(row_bytes * height as usize); + let mut previous = None; + for y in pixelated_samples(height, block) { + if previous == Some(y) { + pixels.extend_from_within(pixels.len() - row_bytes..); + continue; + } + let row = &rgba[(y0 as usize + y) * stride..]; + for &offset in &columns { + pixels.extend_from_slice(&row[offset..offset + 4]); + } + previous = Some(y); + } + image::RgbaImage::from_raw(width, height, pixels) +} + +fn pixelated_samples(size: u32, block: u32) -> impl Iterator { + let small = (size / block).max(1); + (0..size).map(move |x| nearest_sample(nearest_sample(x, small, size), size, small) as usize) +} + +fn nearest_sample(output: u32, input: u32, output_size: u32) -> u32 { + let ratio = input as f32 / output_size as f32; + let sample = ((output as f32 + 0.5) * ratio).floor() as i64; + sample.clamp(0, i64::from(input) - 1) as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference_pixelate( + rgba: &[u8], + frame_width: u32, + (x0, y0, width, height): (u32, u32, u32, u32), + block: u32, + ) -> Vec { + let source = image::imageops::crop_imm( + &image::RgbaImage::from_raw( + frame_width, + (rgba.len() / 4) as u32 / frame_width, + rgba.to_vec(), + ) + .unwrap(), + x0, + y0, + width, + height, + ) + .to_image(); let small = image::imageops::resize( &source, (width / block).max(1), @@ -316,7 +650,22 @@ pub fn mask_filter( image::imageops::FilterType::Nearest, ); image::imageops::resize(&small, width, height, image::imageops::FilterType::Nearest) - } else { + .into_raw() + } + + fn reference_blur( + rgba: &[u8], + frame_width: u32, + (x0, y0, width, height): (u32, u32, u32, u32), + level: f64, + ) -> Vec { + let stride = frame_width as usize * 4; + let mut copied = Vec::with_capacity(width as usize * height as usize * 4); + for row in 0..height { + let start = (y0 + row) as usize * stride + x0 as usize * 4; + copied.extend_from_slice(&rgba[start..start + width as usize * 4]); + } + let source = image::RgbaImage::from_raw(width, height, copied).unwrap(); let factor = ((level / 4.).round() as u32).max(2); let small = image::imageops::resize( &source, @@ -325,14 +674,219 @@ pub fn mask_filter( image::imageops::FilterType::Triangle, ); image::imageops::resize(&small, width, height, image::imageops::FilterType::Triangle) - }; + .into_raw() + } - Some(processed) -} + fn assert_blur_matches( + rgba: &[u8], + frame_width: u32, + frame_height: u32, + region: (u32, u32, u32, u32), + level: f64, + ) { + let actual = mask_filter(rgba, frame_width, frame_height, region, false, level) + .unwrap() + .into_raw(); + let expected = reference_blur(rgba, frame_width, region, level); + assert_eq!(actual.len(), expected.len()); + let mut max = 0u8; + let mut sum = 0u64; + let mut mismatches = 0usize; + let mut first = None; + for (index, (&got, &want)) in actual.iter().zip(expected.iter()).enumerate() { + let delta = got.abs_diff(want); + if delta == 0 { + continue; + } + mismatches += 1; + sum += u64::from(delta); + if delta > max { + max = delta; + } + if first.is_none() { + first = Some((index, got, want)); + } + } + assert_eq!( + max, + 0, + "{region:?} level {level}: max {max} mismatches {mismatches} mean {} first {first:?}", + if actual.is_empty() { + 0.0 + } else { + sum as f64 / actual.len() as f64 + } + ); + } -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn blur_matches_triangle_resize_across_sizes_levels_and_odd_dimensions() { + let levels = [0.5, 1.0, 2.2, 4.0, 7.5, 8.0, 15.9, 16.0, 18.0, 40.0, 100.0]; + for (width, height) in [ + (1u32, 1u32), + (1, 2), + (2, 1), + (2, 3), + (3, 5), + (5, 3), + (7, 13), + (8, 8), + (15, 17), + (16, 9), + (31, 33), + (33, 31), + (63, 65), + (64, 48), + (100, 1), + (1, 100), + (127, 81), + ] { + let rgba: Vec = (0..width * height * 4) + .map(|i| (i.wrapping_mul(37).wrapping_add(19) % 251) as u8) + .collect(); + for level in levels { + assert_blur_matches(&rgba, width, height, (0, 0, width, height), level); + } + } + + let (frame_width, frame_height) = (80u32, 60u32); + let rgba: Vec = (0..frame_width * frame_height * 4) + .map(|i| (i.wrapping_mul(91) % 251) as u8) + .collect(); + for region in [ + (0, 0, 80, 60), + (3, 5, 17, 13), + (7, 2, 13, 29), + (4, 4, 0, 9), + (4, 4, 9, 0), + (0, 0, 1, 1), + (11, 9, 63, 41), + (20, 15, 15, 17), + ] { + for level in levels { + assert_blur_matches(&rgba, frame_width, frame_height, region, level); + } + } + + let (width, height) = (400u32, 300u32); + let rgba: Vec = (0..width * height * 4) + .map(|i| (i.wrapping_mul(31) % 251) as u8) + .collect(); + for level in [8.0, 16.0, 40.0] { + assert_blur_matches(&rgba, width, height, (0, 0, width, height), level); + } + } + + #[test] + fn blur_matches_triangle_resize_for_full_byte_range() { + let levels = [0.5, 1.0, 2.2, 4.0, 7.5, 8.0, 15.9, 16.0, 18.0, 40.0, 100.0]; + for (width, height) in [(17u32, 19u32), (31, 33), (64, 48)] { + let rgba = full_range_rgba(width, height); + assert!(rgba.contains(&0) && rgba.contains(&255)); + for level in levels { + assert_blur_matches(&rgba, width, height, (0, 0, width, height), level); + } + } + + let (frame_width, frame_height) = (80u32, 60u32); + let rgba = full_range_rgba(frame_width, frame_height); + let region = (3u32, 5u32, 17u32, 13u32); + let cropped = copy_region(&rgba, frame_width as usize * 4, region); + assert!(cropped.contains(&0) && cropped.contains(&255)); + for level in levels { + assert_blur_matches(&rgba, frame_width, frame_height, region, level); + } + + for fill in [0u8, 255u8] { + let (width, height) = (32u32, 24u32); + let rgba = vec![fill; width as usize * height as usize * 4]; + for level in [8.0, 16.0, 40.0] { + assert_blur_matches(&rgba, width, height, (0, 0, width, height), level); + } + } + } + + fn full_range_rgba(width: u32, height: u32) -> Vec { + (0..width as usize * height as usize) + .flat_map(|i| { + let channel = (i % 256) as u8; + [ + channel, + 255 - channel, + channel.wrapping_mul(3), + if i % 2 == 0 { 0 } else { 255 }, + ] + }) + .collect() + } + + #[test] + fn quantize_channel_matches_clamped_round() { + let expected = |value: f32| value.clamp(0.0, 255.0).round() as u8; + let mut values = Vec::new(); + for n in 0..255 { + let half = n as f32 + 0.5; + values.extend([half.next_down(), half, half.next_up()]); + } + for n in 0..=255 { + let integer = n as f32; + values.extend([integer.next_down(), integer, integer.next_up()]); + } + for value in [ + 0.5, + 1.5, + 127.5, + 254.5, + -0.0, + -0.1, + -0.5, + -1.0, + -1.5, + -127.5, + -255.0, + -1000.0, + f32::NEG_INFINITY, + 255.5, + 256.0, + 256.5, + 1000.0, + f32::INFINITY, + ] { + values.extend([value.next_down(), value, value.next_up()]); + } + for value in values { + assert_eq!(quantize_channel(value), expected(value), "{value:?}"); + } + } + + #[test] + fn pixelate_matches_two_nearest_resizes() { + let (frame_width, frame_height) = (61u32, 47u32); + let rgba: Vec = (0..frame_width * frame_height * 4) + .map(|i| (i.wrapping_mul(37) % 251) as u8) + .collect(); + for region in [ + (0, 0, 61, 47), + (3, 5, 1, 1), + (4, 4, 0, 9), + (4, 4, 9, 0), + (7, 2, 13, 29), + (60, 0, 1, 47), + (10, 11, 50, 36), + ] { + for level in [1., 2., 2.6, 3., 7., 16., 40., 100.] { + let block = (f64::round(level) as u32).max(2); + let actual = + mask_filter(&rgba, frame_width, frame_height, region, true, level).unwrap(); + assert_eq!(actual.dimensions(), (region.2, region.3)); + assert_eq!( + actual.into_raw(), + reference_pixelate(&rgba, frame_width, region, block), + "{region:?} at {level}" + ); + } + } + } fn reference_blit( dst: &mut [u8], diff --git a/apps/desktop-gpui/kernels/src/thumbnail.rs b/apps/desktop-gpui/kernels/src/thumbnail.rs index 06b42c5cfdd..99c9c0a9994 100644 --- a/apps/desktop-gpui/kernels/src/thumbnail.rs +++ b/apps/desktop-gpui/kernels/src/thumbnail.rs @@ -1,6 +1,9 @@ -use image::RgbaImage; +use image::{DynamicImage, RgbaImage}; use std::borrow::Cow; +const PRESCALE_HEADROOM: u32 = 4; +const MAX_PRESCALE_FACTOR: u32 = u8::MAX as u32; + pub fn fit_centered(image: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage { let width = image.width(); let height = image.height(); @@ -13,16 +16,7 @@ pub fn fit_centered(image: &RgbaImage, target_width: u32, target_height: u32) -> return RgbaImage::from_pixel(target_width, target_height, image::Rgba([0, 0, 0, 0])); } - let scale = (target_width as f32 / width as f32) - .min(target_height as f32 / height as f32) - .max(f32::MIN_POSITIVE); - - let scaled_width = (width as f32 * scale) - .round() - .clamp(1.0, target_width as f32) as u32; - let scaled_height = (height as f32 * scale) - .round() - .clamp(1.0, target_height as f32) as u32; + let (scaled_width, scaled_height) = fitted_size(width, height, target_width, target_height); let resized = if (scaled_width, scaled_height) == image.dimensions() { Cow::Borrowed(image) @@ -34,6 +28,72 @@ pub fn fit_centered(image: &RgbaImage, target_width: u32, target_height: u32) -> )) }; + place_centered(&resized, target_width, target_height) +} + +/// [`fit_centered`] for a full-resolution RGB or RGBA capture. A source more +/// than `2 * PRESCALE_HEADROOM` times the fitted size is box-averaged by a +/// whole factor first, leaving Lanczos3 at least `PRESCALE_HEADROOM` times the +/// fitted size to filter; anything smaller takes [`fit_centered`] unchanged. +pub fn fit_centered_capture( + image: DynamicImage, + target_width: u32, + target_height: u32, +) -> RgbaImage { + let (width, height) = (image.width(), image.height()); + let channels = match &image { + DynamicImage::ImageRgb8(_) => 3, + DynamicImage::ImageRgba8(_) => 4, + _ => 0, + }; + let factor = + if channels == 0 || width == 0 || height == 0 || target_width == 0 || target_height == 0 { + 1 + } else { + prescale_factor(width, height, target_width, target_height) + }; + if factor < 2 { + return fit_centered(&image.into_rgba8(), target_width, target_height); + } + + let (scaled_width, scaled_height) = fitted_size(width, height, target_width, target_height); + let reduced = match channels { + 3 => box_downscale::<3>(image.as_bytes(), width, height, factor), + _ => box_downscale::<4>(image.as_bytes(), width, height, factor), + }; + let resized = resize_lanczos3_at( + &reduced, + scaled_width, + scaled_height, + width as f32 / (factor * scaled_width) as f32, + height as f32 / (factor * scaled_height) as f32, + ); + place_centered(&resized, target_width, target_height) +} + +fn prescale_factor(width: u32, height: u32, target_width: u32, target_height: u32) -> u32 { + let (scaled_width, scaled_height) = fitted_size(width, height, target_width, target_height); + (width / (scaled_width * PRESCALE_HEADROOM)) + .min(height / (scaled_height * PRESCALE_HEADROOM)) + .min(MAX_PRESCALE_FACTOR) +} + +fn fitted_size(width: u32, height: u32, target_width: u32, target_height: u32) -> (u32, u32) { + let scale = (target_width as f32 / width as f32) + .min(target_height as f32 / height as f32) + .max(f32::MIN_POSITIVE); + + let scaled_width = (width as f32 * scale) + .round() + .clamp(1.0, target_width as f32) as u32; + let scaled_height = (height as f32 * scale) + .round() + .clamp(1.0, target_height as f32) as u32; + (scaled_width, scaled_height) +} + +fn place_centered(resized: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage { + let (scaled_width, scaled_height) = resized.dimensions(); let offset_x = (target_width - scaled_width) / 2; let offset_y = (target_height - scaled_height) / 2; @@ -52,48 +112,185 @@ pub fn fit_centered(image: &RgbaImage, target_width: u32, target_height: u32) -> .copy_from_slice(&resized_raw[resized_start..resized_start + row_bytes]); } } else { - image::imageops::overlay(&mut canvas, &*resized, offset_x as i64, offset_y as i64); + image::imageops::overlay(&mut canvas, resized, offset_x as i64, offset_y as i64); } canvas } +/// Average `factor` x `factor` blocks of packed `CHANNELS`-byte pixels into +/// RGBA, rounding to nearest. The last row and column of blocks average only +/// the pixels they cover, so no edge pixels are lost. +fn box_downscale( + pixels: &[u8], + width: u32, + height: u32, + factor: u32, +) -> RgbaImage { + let (width, height, factor) = (width as usize, height as usize, factor as usize); + let stride = width * CHANNELS; + let reduced_width = width.div_ceil(factor); + let reduced_height = height.div_ceil(factor); + let mut reduced = RgbaImage::new(reduced_width as u32, reduced_height as u32); + let mut columns = vec![0u16; stride]; + for (output, band) in reduced + .chunks_exact_mut(reduced_width * 4) + .zip(pixels[..stride * height].chunks(stride * factor)) + { + columns.fill(0); + accumulate_rows(&mut columns, band); + let band_rows = band.len() / stride; + let full = BlockDivisor::new((band_rows * factor) as u32); + let last = BlockDivisor::new((band_rows * (width - (reduced_width - 1) * factor)) as u32); + for (index, (pixel, block)) in output + .chunks_exact_mut(4) + .zip(columns.chunks(factor * CHANNELS)) + .enumerate() + { + let divisor = if index + 1 == reduced_width { + &last + } else { + &full + }; + let mut sums = [0u32; CHANNELS]; + for source in block.chunks_exact(CHANNELS) { + for (sum, &value) in sums.iter_mut().zip(source) { + *sum += u32::from(value); + } + } + for (channel, sum) in pixel.iter_mut().zip(sums) { + *channel = divisor.rounded(sum); + } + if CHANNELS == 3 { + pixel[3] = u8::MAX; + } + } + } + reduced +} + +/// `(sum + count / 2) / count` by a reciprocal multiply. Exact while +/// `256 * count^2 < 2^40`, which `MAX_PRESCALE_FACTOR` keeps. +struct BlockDivisor { + half: u32, + multiplier: u64, +} + +impl BlockDivisor { + fn new(count: u32) -> Self { + Self { + half: count / 2, + multiplier: (1u64 << 40) / u64::from(count) + 1, + } + } + + fn rounded(&self, sum: u32) -> u8 { + ((u64::from(sum + self.half) * self.multiplier) >> 40) as u8 + } +} + +fn accumulate_rows(columns: &mut [u16], band: &[u8]) { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + if is_x86_feature_detected!("avx2") { + unsafe { accumulate_rows_avx2(columns, band) }; + return; + } + accumulate_rows_portable(columns, band); +} + +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +#[target_feature(enable = "avx2")] +fn accumulate_rows_avx2(columns: &mut [u16], band: &[u8]) { + accumulate_rows_portable(columns, band); +} + +#[inline(always)] +fn accumulate_rows_portable(columns: &mut [u16], band: &[u8]) { + for row in band.chunks_exact(columns.len()) { + for (sum, &value) in columns.iter_mut().zip(row) { + *sum += u16::from(value); + } + } +} + // Same weights and per-pixel f32 accumulation order as // `image::imageops::resize(.., FilterType::Lanczos3)`, so the output is // byte-identical; only the loop order changes, so whole blocks of a row stay in // registers and vectorize instead of one generic `get_pixel` per tap. +// Kept out of line so inlining it doesn't slow `fit_centered`'s no-resize paths. +#[inline(never)] fn resize_lanczos3(image: &RgbaImage, width: u32, height: u32) -> RgbaImage { + resize_lanczos3_at( + image, + width, + height, + image.width() as f32 / width as f32, + image.height() as f32 / height as f32, + ) +} + +fn resize_lanczos3_at( + image: &RgbaImage, + width: u32, + height: u32, + ratio_x: f32, + ratio_y: f32, +) -> RgbaImage { let source_row = image.width() as usize * 4; let mut vertical = vec![0f32; source_row * height as usize]; vertical_pass( &mut vertical, image.as_raw(), - &lanczos3_taps(image.height(), height), + &lanczos3_taps_at(image.height(), height, ratio_y), source_row, ); - let columns = lanczos3_taps(image.width(), width); + let columns = lanczos3_taps_at(image.width(), width, ratio_x); let mut resized = RgbaImage::new(width, height); - for (output, row) in resized - .chunks_exact_mut(width as usize * 4) - .zip(vertical.chunks_exact(source_row)) + let output_row = width as usize * 4; + let mut outputs = resized.chunks_exact_mut(output_row * HORIZONTAL_ROWS); + let mut inputs = vertical.chunks_exact(source_row * HORIZONTAL_ROWS); + for (output, input) in outputs.by_ref().zip(inputs.by_ref()) { + horizontal_pass::(output, input, &columns); + } + for (output, input) in outputs + .into_remainder() + .chunks_exact_mut(output_row) + .zip(inputs.remainder().chunks_exact(source_row)) { - for (pixel, taps) in output.chunks_exact_mut(4).zip(&columns) { - let mut sum = [0f32; 4]; - for (index, weight) in taps.weights.iter().enumerate() { - let start = (taps.start + index) * 4; - for (channel, &value) in sum.iter_mut().zip(&row[start..start + 4]) { + horizontal_pass::<1>(output, input, &columns); + } + resized +} + +// Each pixel's sum is one chain of dependent additions, so ROWS rows that +// share the same taps run side by side to keep the adders busy. +fn horizontal_pass(output: &mut [u8], input: &[f32], columns: &[Taps]) { + let output_row = output.len() / ROWS; + let source_row = input.len() / ROWS; + let rows: [&[[f32; 4]]; ROWS] = + std::array::from_fn(|row| input[row * source_row..][..source_row].as_chunks().0); + for (column, taps) in columns.iter().enumerate() { + let windows = rows.map(|row| &row[taps.start..taps.start + taps.weights.len()]); + let mut sums = [[0f32; 4]; ROWS]; + for (index, &weight) in taps.weights.iter().enumerate() { + for (sum, window) in sums.iter_mut().zip(&windows) { + for (channel, &value) in sum.iter_mut().zip(&window[index]) { *channel += value * weight; } } + } + for (row, sum) in sums.iter().enumerate() { + let pixel = &mut output[row * output_row + column * 4..][..4]; for (channel, value) in pixel.iter_mut().zip(sum) { *channel = value.clamp(0.0, 255.0).round() as u8; } } } - resized } +const HORIZONTAL_ROWS: usize = 8; + fn vertical_pass(vertical: &mut [f32], pixels: &[u8], rows: &[Taps], source_row: usize) { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] if is_x86_feature_detected!("avx2") { @@ -144,8 +341,12 @@ struct Taps { weights: Vec, } +#[cfg(test)] fn lanczos3_taps(source: u32, target: u32) -> Vec { - let ratio = source as f32 / target as f32; + lanczos3_taps_at(source, target, source as f32 / target as f32) +} + +fn lanczos3_taps_at(source: u32, target: u32, ratio: f32) -> Vec { let scale = if ratio < 1.0 { 1.0 } else { ratio }; let support = 3.0 * scale; (0..target) @@ -345,6 +546,8 @@ mod tests { (1920, 1080, 320, 180), (1366, 768, 320, 180), (777, 1333, 104, 180), + (1920, 1080, 319, 179), + (2560, 1440, 320, 13), (321, 181, 320, 180), (320, 400, 320, 180), (100, 60, 320, 180), @@ -365,6 +568,278 @@ mod tests { } } + fn rgb(image: &RgbaImage) -> DynamicImage { + DynamicImage::ImageRgb8(DynamicImage::ImageRgba8(image.clone()).into_rgb8()) + } + + fn opaque_noise(width: u32, height: u32) -> RgbaImage { + let mut image = noise(width, height); + for pixel in image.pixels_mut() { + pixel[3] = 255; + } + image + } + + fn interface(width: u32, height: u32) -> RgbaImage { + let mut image = RgbaImage::from_pixel(width, height, image::Rgba([236, 236, 236, 255])); + for x in (0..width).step_by(97) { + for y in 0..height { + image.put_pixel(x, y, image::Rgba([40, 40, 40, 255])); + } + } + for top in (0..height.saturating_sub(12)).step_by(23) { + let left = (top * 7) % (width / 2); + for y in top..top + 12 { + for x in left..(left + width / 3).min(width) { + image.put_pixel(x, y, image::Rgba([30, 90, 200, 255])); + } + } + } + let mut state = 0x2545_f491_4f6c_dd1d_u64; + for _ in 0..4000 { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + let x = (state >> 33) as u32 % (width - 8); + let y = (state >> 13) as u32 % (height - 14); + for dy in 0..12 { + image.put_pixel(x, y + dy, image::Rgba([20, 20, 20, 255])); + } + for dx in 0..6 { + image.put_pixel(x + dx, y, image::Rgba([20, 20, 20, 255])); + } + } + image + } + + fn checker(width: u32, height: u32, period: u32) -> RgbaImage { + RgbaImage::from_fn(width, height, |x, y| { + let value = if (x / period + y / period) % 2 == 0 { + 0 + } else { + 255 + }; + image::Rgba([value, value, value, 255]) + }) + } + + fn gradient(width: u32, height: u32) -> RgbaImage { + RgbaImage::from_fn(width, height, |x, y| { + image::Rgba([ + (x * 255 / width) as u8, + (y * 255 / height) as u8, + ((x + y) * 255 / (width + height)) as u8, + 255, + ]) + }) + } + + fn psnr(reference: &RgbaImage, actual: &RgbaImage) -> f64 { + let squared: f64 = reference + .as_raw() + .iter() + .zip(actual.as_raw()) + .map(|(&a, &b)| (f64::from(a) - f64::from(b)).powi(2)) + .sum(); + let mse = squared / reference.as_raw().len() as f64; + if mse == 0.0 { + f64::INFINITY + } else { + 10.0 * (255.0 * 255.0 / mse).log10() + } + } + + fn ssim(reference: &RgbaImage, actual: &RgbaImage) -> (f64, f64) { + let luma = |image: &RgbaImage| -> Vec { + image + .pixels() + .map(|p| (f64::from(p[0]) + f64::from(p[1]) + f64::from(p[2])) / 3.0) + .collect() + }; + let (a, b) = (luma(reference), luma(actual)); + let (width, height) = (reference.width() as usize, reference.height() as usize); + let (c1, c2) = ((0.01 * 255.0f64).powi(2), (0.03 * 255.0f64).powi(2)); + let (mut total, mut count, mut worst) = (0.0, 0, f64::INFINITY); + for top in (0..=height - 8).step_by(4) { + for left in (0..=width - 8).step_by(4) { + let window = |values: &[f64]| -> Vec { + (top..top + 8) + .flat_map(|y| (left..left + 8).map(move |x| y * width + x)) + .map(|index| values[index]) + .collect() + }; + let (wa, wb) = (window(&a), window(&b)); + let mean = |w: &[f64]| w.iter().sum::() / 64.0; + let (ma, mb) = (mean(&wa), mean(&wb)); + let (mut va, mut vb, mut cov) = (0.0, 0.0, 0.0); + for (x, y) in wa.iter().zip(&wb) { + va += (x - ma).powi(2) / 64.0; + vb += (y - mb).powi(2) / 64.0; + cov += (x - ma) * (y - mb) / 64.0; + } + let value = ((2.0 * ma * mb + c1) * (2.0 * cov + c2)) + / ((ma * ma + mb * mb + c1) * (va + vb + c2)); + total += value; + count += 1; + worst = worst.min(value); + } + } + (total / f64::from(count), worst) + } + + #[test] + fn box_downscale_averages_blocks_and_keeps_partial_edges() { + let pixels: Vec = (0..5 * 3 * 3).map(|value| value as u8 * 5).collect(); + let reduced = box_downscale::<3>(&pixels, 5, 3, 2); + assert_eq!(reduced.dimensions(), (3, 2)); + let expected = |x: u32, y: u32| -> [u8; 4] { + let mut rgba = [0u8, 0, 0, 255]; + for (channel, value) in rgba.iter_mut().take(3).enumerate() { + let mut sum = 0u32; + let mut count = 0u32; + for sy in y * 2..(y * 2 + 2).min(3) { + for sx in x * 2..(x * 2 + 2).min(5) { + sum += u32::from(pixels[((sy * 5 + sx) * 3) as usize + channel]); + count += 1; + } + } + *value = ((sum + count / 2) / count) as u8; + } + rgba + }; + for (x, y, pixel) in reduced.enumerate_pixels() { + assert_eq!(pixel.0, expected(x, y), "{x},{y}"); + } + + let rgba = noise(7, 5); + let reduced = box_downscale::<4>(rgba.as_raw(), 7, 5, 3); + assert_eq!(reduced.dimensions(), (3, 2)); + let corner = rgba.get_pixel(6, 3).0; + let below = rgba.get_pixel(6, 4).0; + for channel in 0..4 { + let sum = u32::from(corner[channel]) + u32::from(below[channel]); + assert_eq!(u32::from(reduced.get_pixel(2, 1)[channel]), (sum + 1) / 2); + } + } + + #[test] + fn block_divisor_rounds_like_integer_division() { + let check = |count: u32, sum: u32| { + assert_eq!( + u32::from(BlockDivisor::new(count).rounded(sum)), + (sum + count / 2) / count, + "{sum} / {count}" + ); + }; + for count in 1..=64 { + for sum in 0..=255 * count { + check(count, sum); + } + } + let max = MAX_PRESCALE_FACTOR * MAX_PRESCALE_FACTOR; + for count in (65..=max).step_by(97).chain([max - 1, max]) { + for quotient in [0, 1, 127, 128, 254, 255] { + for offset in [0, 1, count / 2 - 1, count / 2, count / 2 + 1, count - 1] { + let sum = quotient * count + offset; + if sum <= 255 * count { + check(count, sum); + } + } + } + } + } + + #[test] + fn box_downscale_does_not_overflow_at_the_largest_factor() { + let side = MAX_PRESCALE_FACTOR; + for value in [255u8, 254, 1] { + let pixels = vec![value; (side * side * 3) as usize]; + let reduced = box_downscale::<3>(&pixels, side, side, side); + assert_eq!(reduced.get_pixel(0, 0).0, [value, value, value, 255]); + } + assert_eq!( + prescale_factor(400_000, 225_000, 320, 180), + MAX_PRESCALE_FACTOR + ); + } + + #[test] + fn capture_fit_is_exact_below_the_prescale_threshold() { + for (width, height) in [ + (1920, 1080), + (1366, 768), + (2001, 1201), + (2559, 1439), + (640, 360), + (320, 180), + (160, 90), + (1, 1), + (0, 0), + ] { + let source = noise(width, height); + let expected = fit_centered(&source, 320, 180); + assert!( + fit_centered_capture(DynamicImage::ImageRgba8(source.clone()), 320, 180) + == expected, + "rgba {width}x{height}" + ); + let opaque = rgb(&source); + let expected = fit_centered(&opaque.to_rgba8(), 320, 180); + assert!( + fit_centered_capture(opaque, 320, 180) == expected, + "rgb {width}x{height}" + ); + } + } + + #[test] + fn capture_fit_stays_within_tight_bounds_of_the_exact_fit() { + for (width, height) in [ + (3840, 2160), + (2560, 1440), + (2561, 1441), + (3440, 1440), + (5120, 2880), + (1440, 2560), + ] { + assert!(prescale_factor(width, height, 320, 180) >= 2); + let sources: [(&str, RgbaImage); 9] = [ + ("noise", opaque_noise(width, height)), + ("noise-alpha", noise(width, height)), + ("interface", interface(width, height)), + ("gradient", gradient(width, height)), + ("checker-2", checker(width, height, 2)), + ("checker-3", checker(width, height, 3)), + ("checker-5", checker(width, height, 5)), + ("checker-7", checker(width, height, 7)), + ("checker-1", checker(width, height, 1)), + ]; + for (name, source) in sources { + let expected = fit_centered(&source, 320, 180); + let input = if source.pixels().all(|pixel| pixel[3] == 255) { + rgb(&source) + } else { + DynamicImage::ImageRgba8(source) + }; + let actual = fit_centered_capture(input, 320, 180); + assert_eq!(actual.dimensions(), expected.dimensions()); + for (a, b) in actual.pixels().zip(expected.pixels()) { + assert_eq!(a[3] == 0, b[3] == 0, "{name} {width}x{height} padding"); + } + let psnr = psnr(&expected, &actual); + let (ssim_mean, ssim_min) = ssim(&expected, &actual); + println!( + "{name} {width}x{height}: psnr {psnr:.2} dB, ssim mean {ssim_mean:.5}, min {ssim_min:.5}" + ); + assert!(psnr >= 44.0, "{name} {width}x{height}: psnr {psnr:.2}"); + assert!( + ssim_mean >= 0.99 && ssim_min >= 0.98, + "{name} {width}x{height}: ssim {ssim_mean:.5} min {ssim_min:.5}" + ); + } + } + } + #[test] fn portable_vertical_pass_matches_the_dispatched_one() { for (width, height, target_height) in [(3840, 2160, 180), (1001, 777, 180), (7, 9, 16)] { diff --git a/apps/desktop-gpui/kernels/src/waveform.rs b/apps/desktop-gpui/kernels/src/waveform.rs index 76b8d027535..0c1728e1c06 100644 --- a/apps/desktop-gpui/kernels/src/waveform.rs +++ b/apps/desktop-gpui/kernels/src/waveform.rs @@ -33,24 +33,79 @@ mod tests { .collect() } + fn assert_peak_bits(actual: &[f32], expected: &[f32], channels: u16, block_len: usize) { + assert_eq!(actual.len(), expected.len(), "{channels} {block_len}"); + for (actual, expected) in actual.iter().zip(expected) { + assert_eq!( + actual.to_bits(), + expected.to_bits(), + "{channels} {block_len}" + ); + } + } + #[test] fn peaks_match_a_sequential_sum_across_uneven_blocks() { - let samples = samples(48_000 * 3 + 1_234); + let uneven = samples(48_000 * 3 + 1_234); + for channels in [1, 2] { + let expected = sequential_peaks(&uneven, channels); + for block_len in [1, 7, 4_799, 9_600, 65_536, 32_768 * usize::from(channels)] { + let blocks: Vec<&[f32]> = uneven.chunks(block_len).collect(); + assert_peak_bits(&peaks(&blocks, channels), &expected, channels, block_len); + } + assert_peak_bits( + &peaks(&[&uneven], channels), + &expected, + channels, + uneven.len(), + ); + } + + let contiguous = samples(48_000 * 120 + 17); for channels in [1, 2] { - for block_len in [1, 7, 4_799, 9_600, 65_536] { - let blocks: Vec<&[f32]> = samples.chunks(block_len).collect(); - let actual = peaks(&blocks, channels); - let expected = sequential_peaks(&samples, channels); - assert_eq!(actual.len(), expected.len()); - for (actual, expected) in actual.iter().zip(&expected) { - assert_eq!( - actual.to_bits(), - expected.to_bits(), - "{channels} {block_len}" - ); + let expected = sequential_peaks(&contiguous, channels); + assert_peak_bits( + &peaks(&[&contiguous], channels), + &expected, + channels, + contiguous.len(), + ); + let block_len = 32_768 * usize::from(channels); + let blocks: Vec<&[f32]> = contiguous.chunks(block_len).collect(); + assert_peak_bits(&peaks(&blocks, channels), &expected, channels, block_len); + } + } + + fn serial_peaks(blocks: &[&[f32]], channels: u16) -> Vec { + let block_samples = 48_000usize / 10 * usize::from(channels.max(1)); + let mut peaks = Vec::new(); + let mut sum = 0.0_f32; + let mut count = 0usize; + for slice in blocks { + let mut slice = *slice; + while !slice.is_empty() { + let (window, rest) = slice.split_at((block_samples - count).min(slice.len())); + sum = window.iter().fold(sum, |sum, sample| sum + sample.abs()); + count += window.len(); + slice = rest; + if count == block_samples { + peaks.push(sum / count as f32); + sum = 0.0; + count = 0; } } } + if count > 0 { + peaks.push(sum / count as f32); + } + for peak in &mut peaks { + *peak = if *peak > 0.0 { + 20.0 * peak.log10() + } else { + -60.0 + }; + } + peaks } #[test] @@ -61,17 +116,30 @@ mod tests { for (track, channels) in [("mic", 1_u16), ("system audio", 2)] { let samples = samples(48_000 * 3_600 * usize::from(channels)); let blocks: Vec<&[f32]> = samples.chunks(32_768 * usize::from(channels)).collect(); - let mut runs = Vec::new(); + assert_peak_bits( + &peaks(&blocks, channels), + &serial_peaks(&blocks, channels), + channels, + 32_768 * usize::from(channels), + ); + let mut serial_runs = Vec::new(); + let mut lane_runs = Vec::new(); for _ in 0..5 { + let start = Instant::now(); + black_box(serial_peaks(&blocks, channels)); + serial_runs.push(start.elapsed().as_secs_f64() * 1000.0); let start = Instant::now(); black_box(peaks(&blocks, channels)); - runs.push(start.elapsed().as_secs_f64() * 1000.0); + lane_runs.push(start.elapsed().as_secs_f64() * 1000.0); } - runs.sort_by(f64::total_cmp); + serial_runs.sort_by(f64::total_cmp); + lane_runs.sort_by(f64::total_cmp); println!( - "{{\"track\":\"{track}\",\"channels\":{channels},\"duration_seconds\":3600,\"median_ms\":{:.1},\"min_ms\":{:.1}}}", - runs[runs.len() / 2], - runs[0] + "{{\"track\":\"{track}\",\"channels\":{channels},\"duration_seconds\":3600,\"block_frames\":32768,\"serial_median_ms\":{:.1},\"serial_min_ms\":{:.1},\"lanes_median_ms\":{:.1},\"lanes_min_ms\":{:.1}}}", + serial_runs[serial_runs.len() / 2], + serial_runs[0], + lane_runs[lane_runs.len() / 2], + lane_runs[0] ); } } diff --git a/apps/desktop-gpui/patches/zed-streaming-image.patch b/apps/desktop-gpui/patches/zed-streaming-image.patch new file mode 100644 index 00000000000..227458ff043 --- /dev/null +++ b/apps/desktop-gpui/patches/zed-streaming-image.patch @@ -0,0 +1,3136 @@ +diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs +index 4b1679c..a143fab 100644 +--- a/crates/gpui/src/gpui.rs ++++ b/crates/gpui/src/gpui.rs +@@ -47,6 +47,7 @@ pub mod profiler; + pub mod queue; + mod scene; + mod shared_uri; ++mod streaming_image; + mod style; + mod styled; + mod subscription; +@@ -145,6 +146,7 @@ pub use refineable::*; + pub use scene::*; + pub use shared_uri::*; + use std::{any::Any, future::Future}; ++pub use streaming_image::*; + pub use style::*; + pub use styled::*; + pub use subscription::*; +diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs +index 09adcdd..7dd2d38 100644 +--- a/crates/gpui/src/platform.rs ++++ b/crates/gpui/src/platform.rs +@@ -40,7 +40,8 @@ use crate::{ + FontRun, ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, LineLayout, Pixels, + PlatformGestures, PlatformInput, Point, Priority, RenderGlyphParams, RenderImage, + RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, +- SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, ++ StreamFrame, StreamingImageId, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, ++ hash, point, px, size, + }; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + use anyhow::bail; +@@ -1316,6 +1317,17 @@ pub trait PlatformAtlas { + build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, + ) -> Result>; + fn remove(&self, key: &AtlasKey); ++ /// Returns the tile of the texture that holds `frame` for the streaming ++ /// image `id`, uploading the frame first when its generation differs from ++ /// the one the texture holds. Stream textures are never packed with, or ++ /// evicted alongside, atlas tiles. ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> Result>; ++ /// Releases the texture of the streaming image `id`, if there is one. ++ fn remove_stream(&self, id: StreamingImageId); + } + + #[doc(hidden)] +@@ -1368,6 +1380,24 @@ pub struct AtlasTile { + pub bounds: Bounds, + } + ++impl AtlasTile { ++ /// The tile covering the whole texture of a streaming image. ++ pub fn for_stream(index: u32, size: Size) -> Self { ++ Self { ++ texture_id: AtlasTextureId { ++ index, ++ kind: AtlasTextureKind::Stream, ++ }, ++ tile_id: TileId(0), ++ padding: 0, ++ bounds: Bounds { ++ origin: Point::default(), ++ size, ++ }, ++ } ++ } ++} ++ + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[repr(C)] + #[expect(missing_docs)] +@@ -1393,6 +1423,7 @@ pub enum AtlasTextureKind { + Monochrome = 0, + Polychrome = 1, + Subpixel = 2, ++ Stream = 3, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs +index 8894350..562ec92 100644 +--- a/crates/gpui/src/platform/test/window.rs ++++ b/crates/gpui/src/platform/test/window.rs +@@ -2,8 +2,9 @@ use crate::{ + AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, + DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, + PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, +- PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, +- WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, ++ PromptButton, RequestFrameOptions, Scene, Size, StreamFrame, StreamingImageId, TestPlatform, ++ TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, ++ WindowParams, + }; + use collections::HashMap; + use gpui_util::ResultExt as _; +@@ -345,6 +346,9 @@ impl PlatformWindow for TestWindow { + pub(crate) struct TestAtlasState { + next_id: u32, + tiles: HashMap, ++ streams: HashMap, ++ stream_uploads: usize, ++ removed_streams: Vec, + } + + pub(crate) struct TestAtlas(Mutex); +@@ -354,8 +358,21 @@ impl TestAtlas { + TestAtlas(Mutex::new(TestAtlasState { + next_id: 0, + tiles: HashMap::default(), ++ streams: HashMap::default(), ++ stream_uploads: 0, ++ removed_streams: Vec::new(), + })) + } ++ ++ #[cfg(test)] ++ pub(crate) fn stream_uploads(&self) -> usize { ++ self.0.lock().stream_uploads ++ } ++ ++ #[cfg(test)] ++ pub(crate) fn removed_streams(&self) -> Vec { ++ self.0.lock().removed_streams.clone() ++ } + } + + impl PlatformAtlas for TestAtlas { +@@ -405,4 +422,35 @@ impl PlatformAtlas for TestAtlas { + let mut state = self.0.lock(); + state.tiles.remove(key); + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> anyhow::Result> { ++ let mut state = self.0.lock(); ++ let state = &mut *state; ++ if let Some((generation, tile)) = state.streams.get_mut(&id) ++ && tile.bounds.size == frame.size() ++ { ++ let tile = *tile; ++ if *generation != frame.generation() { ++ *generation = frame.generation(); ++ state.stream_uploads += 1; ++ } ++ return Ok(Some(tile)); ++ } ++ state.next_id += 1; ++ let tile = AtlasTile::for_stream(state.next_id, frame.size()); ++ state.streams.insert(id, (frame.generation(), tile)); ++ state.stream_uploads += 1; ++ Ok(Some(tile)) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ let mut state = self.0.lock(); ++ if state.streams.remove(&id).is_some() { ++ state.removed_streams.push(id); ++ } ++ } + } +diff --git a/crates/gpui/src/streaming_image.rs b/crates/gpui/src/streaming_image.rs +new file mode 100644 +index 0000000..3cb3416 +--- /dev/null ++++ b/crates/gpui/src/streaming_image.rs +@@ -0,0 +1,363 @@ ++use crate::{DevicePixels, Size}; ++use anyhow::{Context as _, Result}; ++use parking_lot::Mutex; ++use std::{ ++ fmt, ++ sync::{ ++ Arc, Weak, ++ atomic::{AtomicU64, Ordering}, ++ }, ++}; ++ ++/// Identifies a [`StreamingImage`] for the lifetime of the process. ++#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] ++pub struct StreamingImageId(pub u64); ++ ++/// The byte order of a [`StreamingImage`]'s pixels. ++#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] ++pub enum StreamingImageFormat { ++ /// Blue, green, red, alpha, the order [`RenderImage`](crate::RenderImage) uses. ++ Bgra8, ++ /// Red, green, blue, alpha. ++ Rgba8, ++} ++ ++/// One frame of a [`StreamingImage`], as returned by [`StreamingImage::current`]. ++#[derive(Clone)] ++pub struct StreamFrame { ++ generation: u64, ++ size: Size, ++ stride: usize, ++ format: StreamingImageFormat, ++ bytes: Arc>, ++} ++ ++impl StreamFrame { ++ /// Increases every time the image receives a new frame. ++ pub fn generation(&self) -> u64 { ++ self.generation ++ } ++ ++ /// The frame's size in pixels. ++ pub fn size(&self) -> Size { ++ self.size ++ } ++ ++ /// The number of bytes from the start of one row to the start of the next. ++ pub fn stride(&self) -> usize { ++ self.stride ++ } ++ ++ /// The byte order of the frame's pixels. ++ pub fn format(&self) -> StreamingImageFormat { ++ self.format ++ } ++ ++ /// The frame's rows, [`Self::stride`] bytes apart. Holds at least ++ /// `stride * height` bytes. ++ pub fn bytes(&self) -> &[u8] { ++ &self.bytes ++ } ++} ++ ++impl fmt::Debug for StreamFrame { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.debug_struct("StreamFrame") ++ .field("generation", &self.generation) ++ .field("size", &self.size) ++ .field("stride", &self.stride) ++ .field("format", &self.format) ++ .field("len", &self.bytes.len()) ++ .finish() ++ } ++} ++ ++/// An image whose pixels are replaced frame after frame, such as a video or a ++/// camera preview. ++/// ++/// Each window that paints the image keeps one texture for it and uploads into ++/// that texture only when the frame has changed, instead of packing every frame ++/// into the sprite atlas as a new image. The texture is released once every ++/// handle is dropped or the image is [cleared](Self::clear). ++/// ++/// Handles are cheap to clone and frames can be set from any thread. ++#[derive(Clone)] ++pub struct StreamingImage(Arc); ++ ++struct Shared { ++ id: StreamingImageId, ++ format: StreamingImageFormat, ++ state: Mutex, ++} ++ ++#[derive(Default)] ++struct State { ++ generation: u64, ++ frame: Option, ++ spare: Option>, ++} ++ ++impl StreamingImage { ++ /// Creates an image with no frame, whose frames are in the given byte order. ++ pub fn new(format: StreamingImageFormat) -> Self { ++ static NEXT_ID: AtomicU64 = AtomicU64::new(0); ++ Self(Arc::new(Shared { ++ id: StreamingImageId(NEXT_ID.fetch_add(1, Ordering::Relaxed)), ++ format, ++ state: Mutex::default(), ++ })) ++ } ++ ++ /// The image's process-wide identifier. ++ pub fn id(&self) -> StreamingImageId { ++ self.0.id ++ } ++ ++ /// The byte order of the image's frames. ++ pub fn format(&self) -> StreamingImageFormat { ++ self.0.format ++ } ++ ++ /// Replaces the current frame with `bytes`, whose rows are `stride` bytes ++ /// apart. ++ /// ++ /// Returns the previous frame's buffer when nothing else holds it any more, ++ /// so the producer can write the next frame into it instead of allocating. ++ pub fn set_frame( ++ &self, ++ size: Size, ++ stride: usize, ++ bytes: Vec, ++ ) -> Result>> { ++ validate_frame(size, stride, bytes.len())?; ++ let previous = self.replace_frame(size, stride, bytes); ++ Ok(previous.and_then(|frame| Arc::try_unwrap(frame.bytes).ok())) ++ } ++ ++ /// Copies `bytes`, whose rows are `stride` bytes apart, into a new current ++ /// frame. The copy reuses the buffer of an earlier frame once nothing else ++ /// holds it, so a steady stream of same-sized frames does not allocate. ++ pub fn copy_frame(&self, size: Size, stride: usize, bytes: &[u8]) -> Result<()> { ++ validate_frame(size, stride, bytes.len())?; ++ let len = stride * size.height.0 as usize; ++ let mut buffer = self.0.state.lock().spare.take().unwrap_or_default(); ++ buffer.clear(); ++ buffer.extend_from_slice(&bytes[..len]); ++ let previous = self.replace_frame(size, stride, buffer); ++ if let Some(previous) = previous.and_then(|frame| Arc::try_unwrap(frame.bytes).ok()) { ++ let mut state = self.0.state.lock(); ++ if state.frame.is_some() { ++ state.spare = Some(previous); ++ } ++ } ++ Ok(()) ++ } ++ ++ /// The current frame, if any. ++ pub fn current(&self) -> Option { ++ self.0.state.lock().frame.clone() ++ } ++ ++ /// Drops the current frame. Windows stop painting the image and release ++ /// its texture on their next draw. ++ pub fn clear(&self) { ++ let mut state = self.0.state.lock(); ++ state.frame = None; ++ state.spare = None; ++ } ++ ++ pub(crate) fn downgrade(&self) -> WeakStreamingImage { ++ WeakStreamingImage(Arc::downgrade(&self.0)) ++ } ++ ++ fn replace_frame( ++ &self, ++ size: Size, ++ stride: usize, ++ bytes: Vec, ++ ) -> Option { ++ let mut state = self.0.state.lock(); ++ state.generation += 1; ++ let frame = StreamFrame { ++ generation: state.generation, ++ size, ++ stride, ++ format: self.0.format, ++ bytes: Arc::new(bytes), ++ }; ++ state.frame.replace(frame) ++ } ++} ++ ++impl fmt::Debug for StreamingImage { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.debug_struct("StreamingImage") ++ .field("id", &self.0.id) ++ .field("format", &self.0.format) ++ .finish_non_exhaustive() ++ } ++} ++ ++pub(crate) struct WeakStreamingImage(Weak); ++ ++impl WeakStreamingImage { ++ pub(crate) fn has_frame(&self) -> bool { ++ self.0 ++ .upgrade() ++ .is_some_and(|shared| shared.state.lock().frame.is_some()) ++ } ++} ++ ++fn validate_frame(size: Size, stride: usize, len: usize) -> Result<()> { ++ anyhow::ensure!( ++ size.width.0 > 0 && size.height.0 > 0, ++ "streaming image frames must not be empty, got {}x{}", ++ size.width.0, ++ size.height.0 ++ ); ++ let row_bytes = size.width.0 as usize * 4; ++ anyhow::ensure!( ++ stride >= row_bytes && stride.is_multiple_of(4), ++ "stride {stride} must be a multiple of 4 and at least {row_bytes} for a frame {} pixels wide", ++ size.width.0 ++ ); ++ let required = stride ++ .checked_mul(size.height.0 as usize) ++ .context("streaming image frame is too large")?; ++ anyhow::ensure!( ++ len >= required, ++ "a {}x{} frame with stride {stride} needs {required} bytes, got {len}", ++ size.width.0, ++ size.height.0 ++ ); ++ Ok(()) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use crate::size; ++ ++ fn frame_size(width: i32, height: i32) -> Size { ++ size(DevicePixels(width), DevicePixels(height)) ++ } ++ ++ #[test] ++ fn handles_are_send_and_sync() { ++ fn assert_send_sync() {} ++ assert_send_sync::(); ++ assert_send_sync::(); ++ } ++ ++ #[test] ++ fn ids_are_unique() { ++ let first = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let second = StreamingImage::new(StreamingImageFormat::Bgra8); ++ assert_ne!(first.id(), second.id()); ++ assert_eq!(first.clone().id(), first.id()); ++ } ++ ++ #[test] ++ fn each_frame_advances_the_generation() { ++ let image = StreamingImage::new(StreamingImageFormat::Rgba8); ++ assert!(image.current().is_none()); ++ ++ image.set_frame(frame_size(2, 1), 8, vec![1; 8]).unwrap(); ++ let first = image.current().unwrap(); ++ assert_eq!(first.generation(), 1); ++ assert_eq!(first.size(), frame_size(2, 1)); ++ assert_eq!(first.stride(), 8); ++ assert_eq!(first.format(), StreamingImageFormat::Rgba8); ++ assert_eq!(first.bytes(), &[1; 8]); ++ ++ image.copy_frame(frame_size(1, 2), 4, &[2; 8]).unwrap(); ++ let second = image.current().unwrap(); ++ assert_eq!(second.generation(), 2); ++ assert_eq!(second.size(), frame_size(1, 2)); ++ assert_eq!(second.bytes(), &[2; 8]); ++ assert_eq!(first.bytes(), &[1; 8]); ++ } ++ ++ #[test] ++ fn set_frame_returns_the_previous_buffer_once_nothing_holds_it() { ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ assert_eq!( ++ image.set_frame(frame_size(1, 1), 4, vec![1; 4]).unwrap(), ++ None ++ ); ++ ++ let returned = image.set_frame(frame_size(1, 1), 4, vec![2; 4]).unwrap(); ++ assert_eq!(returned, Some(vec![1; 4])); ++ ++ let held = image.current().unwrap(); ++ let returned = image.set_frame(frame_size(1, 1), 4, vec![3; 4]).unwrap(); ++ assert_eq!(returned, None); ++ assert_eq!(held.bytes(), &[2; 4]); ++ } ++ ++ #[test] ++ fn copy_frame_reuses_earlier_buffers() { ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let pixels = vec![7u8; 64 * 64 * 4]; ++ image.copy_frame(frame_size(64, 64), 256, &pixels).unwrap(); ++ let first = image.current().unwrap().bytes().as_ptr(); ++ image.copy_frame(frame_size(64, 64), 256, &pixels).unwrap(); ++ let second = image.current().unwrap().bytes().as_ptr(); ++ image.copy_frame(frame_size(64, 64), 256, &pixels).unwrap(); ++ let third = image.current().unwrap().bytes().as_ptr(); ++ ++ assert_ne!(first, second); ++ assert_eq!(first, third); ++ } ++ ++ #[test] ++ fn copy_frame_copies_whole_padded_rows() { ++ let image = StreamingImage::new(StreamingImageFormat::Rgba8); ++ let bytes: Vec = (0..40).collect(); ++ image.copy_frame(frame_size(1, 2), 16, &bytes).unwrap(); ++ assert_eq!(image.current().unwrap().bytes(), &bytes[..32]); ++ } ++ ++ #[test] ++ fn invalid_frames_are_rejected_without_changing_the_image() { ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ image.set_frame(frame_size(2, 2), 8, vec![0; 16]).unwrap(); ++ ++ assert!(image.set_frame(frame_size(0, 2), 8, vec![0; 16]).is_err()); ++ assert!(image.set_frame(frame_size(2, 0), 8, vec![0; 16]).is_err()); ++ assert!(image.set_frame(frame_size(2, 2), 4, vec![0; 16]).is_err()); ++ assert!(image.set_frame(frame_size(2, 2), 10, vec![0; 20]).is_err()); ++ assert!(image.set_frame(frame_size(2, 2), 8, vec![0; 15]).is_err()); ++ assert!(image.copy_frame(frame_size(2, 2), 12, &[0; 20]).is_err()); ++ assert!( ++ image ++ .copy_frame(frame_size(i32::MAX, i32::MAX), usize::MAX - 3, &[]) ++ .is_err() ++ ); ++ ++ let current = image.current().unwrap(); ++ assert_eq!(current.generation(), 1); ++ assert_eq!(current.size(), frame_size(2, 2)); ++ } ++ ++ #[test] ++ fn clear_drops_the_frame_and_later_frames_keep_counting() { ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let weak = image.downgrade(); ++ assert!(!weak.has_frame()); ++ ++ image.set_frame(frame_size(1, 1), 4, vec![0; 4]).unwrap(); ++ assert!(weak.has_frame()); ++ ++ image.clear(); ++ assert!(image.current().is_none()); ++ assert!(!weak.has_frame()); ++ ++ image.copy_frame(frame_size(1, 1), 4, &[0; 4]).unwrap(); ++ assert_eq!(image.current().unwrap().generation(), 2); ++ assert!(weak.has_frame()); ++ ++ drop(image); ++ assert!(!weak.has_frame()); ++ } ++} +diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs +index de95b7d..75c3539 100644 +--- a/crates/gpui/src/window.rs ++++ b/crates/gpui/src/window.rs +@@ -21,6 +21,7 @@ use crate::{ + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, profiler, px, rems, size, + transparent_black, + }; ++use crate::{AtlasTile, StreamingImage, StreamingImageId, WeakStreamingImage}; + + use anyhow::{Context as _, Result, anyhow}; + use collections::{FxHashMap, FxHashSet}; +@@ -1102,6 +1103,7 @@ pub struct Window { + pub(crate) platform_window: Box, + display_id: Option, + sprite_atlas: Arc, ++ painted_streams: FxHashMap, + text_system: Arc, + text_rendering_mode: Rc>, + rem_size: Pixels, +@@ -1837,6 +1839,7 @@ impl Window { + platform_window, + display_id, + sprite_atlas, ++ painted_streams: FxHashMap::default(), + text_system, + text_rendering_mode: cx.text_rendering_mode.clone(), + rem_size: px(16.), +@@ -2839,6 +2842,7 @@ impl Window { + let _arena_scope = ElementArenaScope::enter(&cx.element_arena); + + self.invalidate_entities(); ++ self.release_unpainted_streams(); + cx.entities.clear_accessed(); + debug_assert!(self.rendered_entity_stack.is_empty()); + self.invalidator.set_dirty(false); +@@ -2967,6 +2971,17 @@ impl Window { + mem::swap(&mut entities, entities_ref.deref_mut()); + } + ++ fn release_unpainted_streams(&mut self) { ++ let sprite_atlas = &self.sprite_atlas; ++ self.painted_streams.retain(|id, image| { ++ let keep = image.has_frame(); ++ if !keep { ++ sprite_atlas.remove_stream(*id); ++ } ++ keep ++ }); ++ } ++ + fn invalidate_entities(&mut self) { + let mut views = self.invalidator.take_views(); + for entity in views.drain() { +@@ -4587,8 +4602,6 @@ impl Window { + ) -> Result<()> { + self.invalidator.debug_assert_paint(); + +- let crop = (visible != fitted).then_some((visible, fitted)); +- let bounds = self.snap_bounds(visible); + let params = RenderImageParams { + image_id: data.id, + frame_index, +@@ -4606,6 +4619,66 @@ impl Window { + ))) + })? + .expect("Callback above only returns Some"); ++ self.insert_polychrome_tile(visible, fitted, corner_radii, tile, grayscale); ++ Ok(()) ++ } ++ ++ /// Paint the current frame of a [`StreamingImage`] into the scene for the ++ /// next frame at the current z-index. Paints nothing while the image has ++ /// no frame. ++ /// ++ /// The window uploads a frame at most once, however many elements paint ++ /// it, and releases the image's texture once every handle to the image is ++ /// dropped or the image is cleared. ++ /// ++ /// This method should only be called as part of the paint phase of element drawing. ++ pub fn paint_streaming_image( ++ &mut self, ++ bounds: Bounds, ++ corner_radii: Corners, ++ image: &StreamingImage, ++ grayscale: bool, ++ ) -> Result<()> { ++ self.paint_streaming_image_fitted(bounds, bounds, corner_radii, image, grayscale) ++ } ++ ++ /// [`Self::paint_streaming_image`] for object-fit layouts, with the same ++ /// contract as [`Self::paint_image_fitted`]. ++ /// ++ /// This method should only be called as part of the paint phase of element drawing. ++ pub fn paint_streaming_image_fitted( ++ &mut self, ++ visible: Bounds, ++ fitted: Bounds, ++ corner_radii: Corners, ++ image: &StreamingImage, ++ grayscale: bool, ++ ) -> Result<()> { ++ self.invalidator.debug_assert_paint(); ++ ++ let Some(frame) = image.current() else { ++ return Ok(()); ++ }; ++ self.painted_streams ++ .entry(image.id()) ++ .or_insert_with(|| image.downgrade()); ++ let Some(tile) = self.sprite_atlas.get_or_update_stream(image.id(), &frame)? else { ++ return Ok(()); ++ }; ++ self.insert_polychrome_tile(visible, fitted, corner_radii, tile, grayscale); ++ Ok(()) ++ } ++ ++ fn insert_polychrome_tile( ++ &mut self, ++ visible: Bounds, ++ fitted: Bounds, ++ corner_radii: Corners, ++ tile: AtlasTile, ++ grayscale: bool, ++ ) { ++ let crop = (visible != fitted).then_some((visible, fitted)); ++ let bounds = self.snap_bounds(visible); + // Crop the atlas tile to the visible fraction of the fitted box + // (proportional UV mapping; ≤1px rounding on arbitrary crops). + let tile = match crop { +@@ -4646,7 +4719,6 @@ impl Window { + tile, + opacity, + }); +- Ok(()) + } + + /// Paint a surface into the scene for the next frame at the current z-index. +@@ -6990,11 +7062,12 @@ mod frame_pacer_tests { + #[cfg(test)] + mod tests { + use crate::{ +- AppContext as _, Bounds, Context, FocusHandle, InteractiveElement as _, IntoElement, +- ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window, canvas, div, px, +- size, ++ AppContext as _, Bounds, Context, Corners, DevicePixels, FocusHandle, ++ InteractiveElement as _, IntoElement, ParentElement as _, Pixels, PlatformAtlas, Render, ++ StreamingImage, StreamingImageFormat, Styled as _, TestAppContext, Window, canvas, div, ++ platform::TestAtlas, px, size, + }; +- use std::{cell::Cell, rc::Rc}; ++ use std::{cell::Cell, rc::Rc, sync::Arc}; + + struct RootView { + explicit_size: bool, +@@ -7075,6 +7148,77 @@ mod tests { + } + } + ++ struct StreamView { ++ images: Vec, ++ } ++ ++ impl Render for StreamView { ++ fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { ++ let images = self.images.clone(); ++ canvas( ++ |_, _, _| {}, ++ move |bounds, _, window, _| { ++ for image in &images { ++ window ++ .paint_streaming_image(bounds, Corners::default(), image, false) ++ .unwrap(); ++ } ++ }, ++ ) ++ .size_full() ++ } ++ } ++ ++ #[test] ++ fn streams_upload_once_per_generation_and_are_released_when_unreachable() { ++ let mut cx = TestAppContext::single(); ++ let frame_size = size(DevicePixels(2), DevicePixels(2)); ++ let first = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let second = StreamingImage::new(StreamingImageFormat::Rgba8); ++ first.set_frame(frame_size, 8, vec![0; 16]).unwrap(); ++ second.set_frame(frame_size, 8, vec![0; 16]).unwrap(); ++ let window = cx.add_window({ ++ let images = vec![first.clone(), first.clone(), second.clone()]; ++ move |_, _| StreamView { images } ++ }); ++ let atlas = Arc::new(TestAtlas::new()); ++ cx.update_window(window.into(), |_, window, _| { ++ window.sprite_atlas = atlas.clone() as Arc; ++ }) ++ .unwrap(); ++ let draw = |cx: &mut TestAppContext| { ++ cx.update_window(window.into(), |_, window, cx| { ++ window.draw(cx).clear(); ++ window.painted_streams.len() ++ }) ++ .unwrap() ++ }; ++ ++ assert_eq!(draw(&mut cx), 2); ++ assert_eq!(atlas.stream_uploads(), 2); ++ assert_eq!(draw(&mut cx), 2); ++ assert_eq!(atlas.stream_uploads(), 2); ++ ++ first.set_frame(frame_size, 8, vec![1; 16]).unwrap(); ++ assert_eq!(draw(&mut cx), 2); ++ assert_eq!(atlas.stream_uploads(), 3); ++ assert!(atlas.removed_streams().is_empty()); ++ ++ second.clear(); ++ assert_eq!(draw(&mut cx), 1); ++ assert_eq!(atlas.removed_streams(), vec![second.id()]); ++ ++ window ++ .update(&mut cx, |view, _, _| view.images.clear()) ++ .unwrap(); ++ let first_id = first.id(); ++ drop(first); ++ assert_eq!(draw(&mut cx), 0); ++ assert_eq!(atlas.removed_streams(), vec![second.id(), first_id]); ++ assert_eq!(draw(&mut cx), 0); ++ assert_eq!(atlas.removed_streams().len(), 2); ++ } ++ + /// When a focus listener moves focus again (e.g. a dock forwarding focus to its + /// active panel), the resulting focus events must be dispatched without waiting + /// for an unrelated redraw of the window. +diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs +index e41b097..dac72f9 100644 +--- a/crates/gpui_linux/src/linux/headless/window.rs ++++ b/crates/gpui_linux/src/linux/headless/window.rs +@@ -18,8 +18,8 @@ use gpui::{ + AtlasKey, AtlasTextureId, AtlasTile, Bounds, Capslock, DevicePixels, DispatchEventResult, + DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, +- Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, +- WindowControlArea, WindowParams, px, ++ Scene, Size, StreamFrame, StreamingImageId, TileId, WindowAppearance, ++ WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, px, + }; + + #[derive(Debug)] +@@ -239,6 +239,7 @@ struct HeadlessAtlas(Mutex); + struct HeadlessAtlasState { + next_id: u32, + tiles: HashMap, ++ streams: HashMap, + } + + impl PlatformAtlas for HeadlessAtlas { +@@ -284,4 +285,25 @@ impl PlatformAtlas for HeadlessAtlas { + fn remove(&self, key: &AtlasKey) { + self.0.lock().tiles.remove(key); + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> anyhow::Result> { ++ let mut state = self.0.lock(); ++ if let Some(tile) = state.streams.get(&id) ++ && tile.bounds.size == frame.size() ++ { ++ return Ok(Some(*tile)); ++ } ++ state.next_id += 1; ++ let tile = AtlasTile::for_stream(state.next_id, frame.size()); ++ state.streams.insert(id, tile); ++ Ok(Some(tile)) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ self.0.lock().streams.remove(&id); ++ } + } +diff --git a/crates/gpui_macos/src/metal_atlas.rs b/crates/gpui_macos/src/metal_atlas.rs +index d007abb..65f38d5 100644 +--- a/crates/gpui_macos/src/metal_atlas.rs ++++ b/crates/gpui_macos/src/metal_atlas.rs +@@ -4,11 +4,21 @@ use derive_more::{Deref, DerefMut}; + use etagere::BucketedAtlasAllocator; + use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, +- PlatformAtlas, Point, Size, ++ PlatformAtlas, Point, Size, StreamFrame, StreamingImageFormat, StreamingImageId, + }; + use metal::Device; + use parking_lot::Mutex; +-use std::borrow::Cow; ++use std::{ ++ borrow::Cow, ++ sync::{ ++ Arc, ++ atomic::{AtomicU32, Ordering}, ++ }, ++}; ++ ++const MAX_STREAM_TEXTURE_SIZE: i32 = 16384; ++const SINGLE_SLOT_STREAM_BYTES: u64 = 32 * 1024 * 1024; ++const SPARE_SLOT_RELEASE_UPLOADS: u32 = 120; + + pub(crate) struct MetalAtlas(Mutex); + +@@ -20,9 +30,44 @@ impl MetalAtlas { + monochrome_textures: Default::default(), + polychrome_textures: Default::default(), + tiles_by_key: Default::default(), ++ streams: Vec::new(), ++ next_stream_index: 0, ++ stream_textures_created: 0, + })) + } + ++ /// The texture a stream sprite batch should sample, plus a lease that keeps ++ /// later uploads out of that texture until the GPU is done with it. `None` ++ /// once the stream has been removed. ++ pub(crate) fn lease_stream_texture( ++ &self, ++ id: AtlasTextureId, ++ ) -> Option<(metal::Texture, MetalStreamLease)> { ++ let lock = self.0.lock(); ++ let stream = lock ++ .streams ++ .iter() ++ .find(|stream| stream.index == id.index)?; ++ let slot = stream.slots.get(stream.front)?; ++ Some(( ++ slot.texture.0.clone(), ++ MetalStreamLease::new(&slot.in_flight), ++ )) ++ } ++ ++ /// (live stream textures, their total bytes, stream textures created so ++ /// far) for COMET_GPU_STATS. ++ pub(crate) fn stream_stats(&self) -> (usize, u64, u64) { ++ let lock = self.0.lock(); ++ let mut count = 0; ++ let mut bytes = 0; ++ for slot in lock.streams.iter().flat_map(|stream| &stream.slots) { ++ count += 1; ++ bytes += slot.texture.width() * slot.texture.height() * 4; ++ } ++ (count, bytes, lock.stream_textures_created) ++ } ++ + pub(crate) fn metal_texture(&self, id: AtlasTextureId) -> metal::Texture { + self.0.lock().texture(id).metal_texture.clone() + } +@@ -54,6 +99,9 @@ struct MetalAtlasState { + monochrome_textures: AtlasTextureList, + polychrome_textures: AtlasTextureList, + tiles_by_key: FxHashMap, ++ streams: Vec, ++ next_stream_index: u32, ++ stream_textures_created: u64, + } + + impl PlatformAtlas for MetalAtlas { +@@ -89,7 +137,7 @@ impl PlatformAtlas for MetalAtlas { + let textures = match id.kind { + AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, + AtlasTextureKind::Polychrome => &mut lock.polychrome_textures, +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + }; + + let Some(texture_slot) = textures +@@ -110,6 +158,18 @@ impl PlatformAtlas for MetalAtlas { + } + } + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> Result> { ++ self.0.lock().update_stream(id, frame).map(Some) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ self.0.lock().streams.retain(|stream| stream.id != id); ++ } + } + + impl MetalAtlasState { +@@ -122,7 +182,7 @@ impl MetalAtlasState { + let textures = match texture_kind { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + }; + + if let Some(tile) = textures +@@ -167,7 +227,7 @@ impl MetalAtlasState { + pixel_format = metal::MTLPixelFormat::BGRA8Unorm; + usage = metal::MTLTextureUsage::ShaderRead; + } +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + } + texture_descriptor.set_pixel_format(pixel_format); + texture_descriptor.set_usage(usage); +@@ -183,7 +243,7 @@ impl MetalAtlasState { + let texture_list = match kind { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + }; + + let index = texture_list.free_list.pop(); +@@ -214,10 +274,173 @@ impl MetalAtlasState { + let textures = match id.kind { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, +- AtlasTextureKind::Subpixel => unreachable!(), ++ AtlasTextureKind::Subpixel | AtlasTextureKind::Stream => unreachable!(), + }; + textures[id.index as usize].as_ref().unwrap() + } ++ ++ fn update_stream(&mut self, id: StreamingImageId, frame: &StreamFrame) -> Result { ++ let size = frame.size(); ++ anyhow::ensure!( ++ size.width.0 <= MAX_STREAM_TEXTURE_SIZE && size.height.0 <= MAX_STREAM_TEXTURE_SIZE, ++ "streaming image frame {}x{} exceeds the {MAX_STREAM_TEXTURE_SIZE} pixel texture limit", ++ size.width.0, ++ size.height.0 ++ ); ++ let pixel_format = match frame.format() { ++ StreamingImageFormat::Bgra8 => metal::MTLPixelFormat::BGRA8Unorm, ++ StreamingImageFormat::Rgba8 => metal::MTLPixelFormat::RGBA8Unorm, ++ }; ++ let position = match self.streams.iter().position(|stream| stream.id == id) { ++ Some(position) => { ++ let stream = &mut self.streams[position]; ++ if stream.size != size || stream.pixel_format != pixel_format { ++ *stream = MetalAtlasStream::new(id, stream.index, size, pixel_format); ++ } ++ position ++ } ++ None => { ++ let index = self.next_stream_index; ++ self.next_stream_index = self.next_stream_index.wrapping_add(1); ++ self.streams ++ .push(MetalAtlasStream::new(id, index, size, pixel_format)); ++ self.streams.len() - 1 ++ } ++ }; ++ let stream = &mut self.streams[position]; ++ if stream.slots.is_empty() || stream.generation != frame.generation() { ++ let created = stream.upload(&self.device, self.is_apple_gpu, frame); ++ self.stream_textures_created += created as u64; ++ } ++ Ok(AtlasTile::for_stream(stream.index, size)) ++ } ++} ++ ++/// A streaming image's textures. Frames go into a slot the GPU is not reading; ++/// a second slot exists only while uploads keep finding the current one busy. ++struct MetalAtlasStream { ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ pixel_format: metal::MTLPixelFormat, ++ generation: u64, ++ slots: Vec, ++ front: usize, ++ uncontended_uploads: u32, ++} ++ ++struct MetalStreamSlot { ++ texture: AssertSend, ++ in_flight: Arc, ++} ++ ++/// Marks a stream slot as read by a command buffer until dropped. ++pub(crate) struct MetalStreamLease(Arc); ++ ++impl MetalStreamLease { ++ fn new(in_flight: &Arc) -> Self { ++ in_flight.fetch_add(1, Ordering::AcqRel); ++ Self(in_flight.clone()) ++ } ++} ++ ++impl Drop for MetalStreamLease { ++ fn drop(&mut self) { ++ self.0.fetch_sub(1, Ordering::AcqRel); ++ } ++} ++ ++impl MetalAtlasStream { ++ fn new( ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ pixel_format: metal::MTLPixelFormat, ++ ) -> Self { ++ Self { ++ id, ++ index, ++ size, ++ pixel_format, ++ generation: 0, ++ slots: Vec::new(), ++ front: 0, ++ uncontended_uploads: 0, ++ } ++ } ++ ++ /// Writes `frame` into a slot no command buffer is reading and makes it ++ /// the front slot, without ever waiting on the GPU. Returns how many ++ /// textures it created. ++ fn upload( ++ &mut self, ++ device: &metal::DeviceRef, ++ is_apple_gpu: bool, ++ frame: &StreamFrame, ++ ) -> u32 { ++ let frame_bytes = self.size.width.0 as u64 * self.size.height.0 as u64 * 4; ++ let max_slots = if frame_bytes > SINGLE_SLOT_STREAM_BYTES { ++ 1 ++ } else { ++ 2 ++ }; ++ let is_free = |slot: &MetalStreamSlot| slot.in_flight.load(Ordering::Acquire) == 0; ++ let mut created = 0; ++ let target = if self.slots.get(self.front).is_some_and(is_free) { ++ self.uncontended_uploads += 1; ++ self.front ++ } else { ++ self.uncontended_uploads = 0; ++ if let Some(index) = self.slots.iter().position(is_free) { ++ index ++ } else if self.slots.len() < max_slots { ++ created += 1; ++ self.slots.push(self.new_slot(device, is_apple_gpu)); ++ self.slots.len() - 1 ++ } else { ++ created += 1; ++ let index = (self.front + 1) % self.slots.len(); ++ self.slots[index] = self.new_slot(device, is_apple_gpu); ++ index ++ } ++ }; ++ ++ let region = ++ metal::MTLRegion::new_2d(0, 0, self.size.width.0 as u64, self.size.height.0 as u64); ++ self.slots[target].texture.replace_region( ++ region, ++ 0, ++ frame.bytes().as_ptr() as *const _, ++ frame.stride() as u64, ++ ); ++ self.front = target; ++ self.generation = frame.generation(); ++ ++ if self.uncontended_uploads >= SPARE_SLOT_RELEASE_UPLOADS && self.slots.len() > 1 { ++ self.slots.swap(0, self.front); ++ self.slots.truncate(1); ++ self.front = 0; ++ self.uncontended_uploads = 0; ++ } ++ created ++ } ++ ++ fn new_slot(&self, device: &metal::DeviceRef, is_apple_gpu: bool) -> MetalStreamSlot { ++ let descriptor = metal::TextureDescriptor::new(); ++ descriptor.set_width(self.size.width.0 as u64); ++ descriptor.set_height(self.size.height.0 as u64); ++ descriptor.set_pixel_format(self.pixel_format); ++ descriptor.set_usage(metal::MTLTextureUsage::ShaderRead); ++ descriptor.set_storage_mode(if is_apple_gpu { ++ metal::MTLStorageMode::Shared ++ } else { ++ metal::MTLStorageMode::Managed ++ }); ++ MetalStreamSlot { ++ texture: AssertSend(device.new_texture(&descriptor)), ++ in_flight: Arc::default(), ++ } ++ } + } + + struct MetalAtlasTexture { +@@ -295,11 +518,20 @@ unsafe impl Send for AssertSend {} + #[cfg(test)] + mod tests { + use super::*; +- use gpui::PlatformAtlas; ++ use gpui::{PlatformAtlas, StreamingImage}; + use std::borrow::Cow; + ++ fn system_device() -> Option { ++ let device = metal::Device::system_default(); ++ assert!( ++ device.is_some() || std::env::var_os("GPUI_REQUIRE_TEST_DEVICE").is_none(), ++ "GPUI_REQUIRE_TEST_DEVICE is set but no Metal device is available" ++ ); ++ device ++ } ++ + fn create_atlas() -> Option { +- let device = metal::Device::system_default()?; ++ let device = system_device()?; + Some(MetalAtlas::new(device, true)) + } + +@@ -396,4 +628,365 @@ mod tests { + let key = make_image_key(999, 0); + atlas.remove(&key); + } ++ fn stream_size(width: i32, height: i32) -> Size { ++ Size { ++ width: DevicePixels(width), ++ height: DevicePixels(height), ++ } ++ } ++ ++ fn set_stream_frame( ++ image: &StreamingImage, ++ size: Size, ++ value: u8, ++ ) -> StreamFrame { ++ let stride = size.width.0 as usize * 4; ++ image ++ .set_frame(size, stride, vec![value; stride * size.height.0 as usize]) ++ .unwrap(); ++ image.current().unwrap() ++ } ++ ++ fn update_stream(atlas: &MetalAtlas, image: &StreamingImage, frame: &StreamFrame) -> AtlasTile { ++ atlas ++ .get_or_update_stream(image.id(), frame) ++ .expect("stream upload should succeed") ++ .expect("stream upload returns a tile") ++ } ++ ++ fn stream_slot_textures(atlas: &MetalAtlas, image: &StreamingImage) -> Vec { ++ let lock = atlas.0.lock(); ++ let stream = lock ++ .streams ++ .iter() ++ .find(|stream| stream.id == image.id()) ++ .expect("stream should exist"); ++ stream ++ .slots ++ .iter() ++ .map(|slot| slot.texture.0.clone()) ++ .collect() ++ } ++ ++ fn texture_ptrs(textures: &[metal::Texture]) -> Vec<*mut metal::MTLTexture> { ++ use metal::foreign_types::ForeignType as _; ++ textures.iter().map(|texture| texture.as_ptr()).collect() ++ } ++ ++ fn stream_textures_created(atlas: &MetalAtlas) -> u64 { ++ atlas.0.lock().stream_textures_created ++ } ++ ++ fn read_texture(device: &metal::DeviceRef, texture: &metal::TextureRef) -> Vec { ++ let queue = device.new_command_queue(); ++ let command_buffer = queue.new_command_buffer(); ++ let buffer = read_back_into_new_buffer(device, command_buffer, texture); ++ command_buffer.commit(); ++ command_buffer.wait_until_completed(); ++ buffer_bytes(&buffer) ++ } ++ ++ fn read_back_into_new_buffer( ++ device: &metal::DeviceRef, ++ command_buffer: &metal::CommandBufferRef, ++ texture: &metal::TextureRef, ++ ) -> metal::Buffer { ++ let row_bytes = texture.width() * 4; ++ let len = row_bytes * texture.height(); ++ let buffer = device.new_buffer(len, metal::MTLResourceOptions::StorageModeShared); ++ let blit = command_buffer.new_blit_command_encoder(); ++ blit.copy_from_texture_to_buffer( ++ texture, ++ 0, ++ 0, ++ metal::MTLOrigin { x: 0, y: 0, z: 0 }, ++ metal::MTLSize { ++ width: texture.width(), ++ height: texture.height(), ++ depth: 1, ++ }, ++ &buffer, ++ 0, ++ row_bytes, ++ len, ++ metal::MTLBlitOption::None, ++ ); ++ blit.end_encoding(); ++ buffer ++ } ++ ++ fn buffer_bytes(buffer: &metal::BufferRef) -> Vec { ++ unsafe { ++ std::slice::from_raw_parts(buffer.contents() as *const u8, buffer.length() as usize) ++ .to_vec() ++ } ++ } ++ ++ #[test] ++ fn stream_updates_of_the_same_size_reuse_one_texture() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(96, 54); ++ ++ let first = update_stream(&atlas, &image, &set_stream_frame(&image, size, 1)); ++ let textures = stream_slot_textures(&atlas, &image); ++ for value in 2..10 { ++ let tile = update_stream(&atlas, &image, &set_stream_frame(&image, size, value)); ++ assert_eq!(tile, first); ++ } ++ ++ assert_eq!(first.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!(first.bounds.size, size); ++ assert_eq!( ++ texture_ptrs(&stream_slot_textures(&atlas, &image)), ++ texture_ptrs(&textures) ++ ); ++ assert_eq!(textures.len(), 1); ++ assert_eq!(stream_textures_created(&atlas), 1); ++ let (texture, _lease) = atlas.lease_stream_texture(first.texture_id).unwrap(); ++ assert!( ++ read_texture(&atlas.0.lock().device, &texture) ++ .iter() ++ .all(|&byte| byte == 9) ++ ); ++ } ++ ++ #[test] ++ fn stream_skips_upload_when_the_generation_is_unchanged() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(8, 8); ++ let frame = set_stream_frame(&image, size, 1); ++ let tile = update_stream(&atlas, &image, &frame); ++ let (texture, _lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ texture.replace_region( ++ metal::MTLRegion::new_2d(0, 0, 8, 8), ++ 0, ++ vec![5u8; 8 * 8 * 4].as_ptr() as *const _, ++ 32, ++ ); ++ ++ assert_eq!(update_stream(&atlas, &image, &frame), tile); ++ let device = atlas.0.lock().device.0.clone(); ++ assert!( ++ read_texture(&device, &texture) ++ .iter() ++ .all(|&byte| byte == 5) ++ ); ++ } ++ ++ #[test] ++ fn stream_size_change_reallocates_once() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let small = update_stream( ++ &atlas, ++ &image, ++ &set_stream_frame(&image, stream_size(64, 36), 1), ++ ); ++ let small_textures = stream_slot_textures(&atlas, &image); ++ ++ let large_size = stream_size(128, 72); ++ let large = update_stream(&atlas, &image, &set_stream_frame(&image, large_size, 2)); ++ let large_textures = stream_slot_textures(&atlas, &image); ++ update_stream(&atlas, &image, &set_stream_frame(&image, large_size, 3)); ++ ++ assert_eq!(large.texture_id, small.texture_id); ++ assert_eq!(large.bounds.size, large_size); ++ assert_ne!(texture_ptrs(&large_textures), texture_ptrs(&small_textures)); ++ assert_eq!( ++ texture_ptrs(&stream_slot_textures(&atlas, &image)), ++ texture_ptrs(&large_textures) ++ ); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ } ++ ++ #[test] ++ fn stream_format_change_reallocates() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let size = stream_size(16, 16); ++ let bgra = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let rgba = StreamingImage::new(StreamingImageFormat::Rgba8); ++ let bgra_tile = update_stream(&atlas, &bgra, &set_stream_frame(&bgra, size, 1)); ++ let rgba_tile = update_stream(&atlas, &rgba, &set_stream_frame(&rgba, size, 1)); ++ ++ let (bgra_texture, _) = atlas.lease_stream_texture(bgra_tile.texture_id).unwrap(); ++ let (rgba_texture, _) = atlas.lease_stream_texture(rgba_tile.texture_id).unwrap(); ++ assert_ne!(bgra_tile.texture_id, rgba_tile.texture_id); ++ assert_eq!( ++ bgra_texture.pixel_format(), ++ metal::MTLPixelFormat::BGRA8Unorm ++ ); ++ assert_eq!( ++ rgba_texture.pixel_format(), ++ metal::MTLPixelFormat::RGBA8Unorm ++ ); ++ } ++ ++ #[test] ++ fn images_and_streams_never_share_textures() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let small = stream_size(64, 64); ++ let image_key = make_image_key(1, 0); ++ let image_tile = insert_tile(&atlas, &image_key, small); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let stream_tile = update_stream(&atlas, &image, &set_stream_frame(&image, small, 1)); ++ let second_image_tile = insert_tile(&atlas, &make_image_key(2, 0), small); ++ ++ assert_eq!(image_tile.texture_id.kind, AtlasTextureKind::Polychrome); ++ assert_eq!(second_image_tile.texture_id, image_tile.texture_id); ++ assert_eq!(stream_tile.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!( ++ atlas ++ .0 ++ .lock() ++ .polychrome_textures ++ .textures ++ .iter() ++ .flatten() ++ .count(), ++ 1 ++ ); ++ ++ atlas.remove(&image_key); ++ assert!(atlas.lease_stream_texture(stream_tile.texture_id).is_some()); ++ atlas.remove_stream(image.id()); ++ assert!(atlas.lease_stream_texture(stream_tile.texture_id).is_none()); ++ let _texture = atlas.metal_texture(second_image_tile.texture_id); ++ atlas.remove_stream(image.id()); ++ } ++ ++ #[test] ++ fn removed_streams_upload_again_with_a_fresh_texture_index() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let frame = set_stream_frame(&image, stream_size(8, 8), 1); ++ let first = update_stream(&atlas, &image, &frame); ++ atlas.remove_stream(image.id()); ++ let second = update_stream(&atlas, &image, &frame); ++ ++ assert_ne!(first.texture_id, second.texture_id); ++ assert!(atlas.lease_stream_texture(first.texture_id).is_none()); ++ assert!(atlas.lease_stream_texture(second.texture_id).is_some()); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ } ++ ++ #[test] ++ fn oversized_stream_frames_are_rejected() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(MAX_STREAM_TEXTURE_SIZE + 1, 1); ++ let stride = size.width.0 as usize * 4; ++ image.set_frame(size, stride, vec![0; stride]).unwrap(); ++ assert!( ++ atlas ++ .get_or_update_stream(image.id(), &image.current().unwrap()) ++ .is_err() ++ ); ++ } ++ ++ fn assert_upload_waits_for_no_reader(is_apple_gpu: bool) { ++ let Some(device) = system_device() else { ++ return; ++ }; ++ let atlas = MetalAtlas::new(device.clone(), is_apple_gpu); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 64); ++ let tile = update_stream(&atlas, &image, &set_stream_frame(&image, size, 1)); ++ ++ let queue = device.new_command_queue(); ++ let command_buffer = queue.new_command_buffer(); ++ let (texture, lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ let first_read = read_back_into_new_buffer(&device, command_buffer, &texture); ++ ++ assert_eq!( ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, 2)), ++ tile ++ ); ++ command_buffer.commit(); ++ command_buffer.wait_until_completed(); ++ drop(lease); ++ ++ assert!(buffer_bytes(&first_read).iter().all(|&byte| byte == 1)); ++ assert_eq!(stream_slot_textures(&atlas, &image).len(), 2); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ let (texture, _lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ assert!( ++ read_texture(&device, &texture) ++ .iter() ++ .all(|&byte| byte == 2) ++ ); ++ ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, 3)); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ } ++ ++ #[test] ++ fn stream_upload_skips_a_slot_an_uncommitted_command_buffer_reads() { ++ assert_upload_waits_for_no_reader(true); ++ } ++ ++ #[test] ++ fn stream_upload_skips_a_slot_an_uncommitted_command_buffer_reads_managed() { ++ assert_upload_waits_for_no_reader(false); ++ } ++ ++ #[test] ++ fn stream_releases_its_spare_slot_once_uploads_stop_contending() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(32, 32); ++ let tile = update_stream(&atlas, &image, &set_stream_frame(&image, size, 0)); ++ let lease = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, 1)); ++ drop(lease); ++ assert_eq!(stream_slot_textures(&atlas, &image).len(), 2); ++ ++ for value in 0..SPARE_SLOT_RELEASE_UPLOADS { ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, value as u8)); ++ } ++ assert_eq!(stream_slot_textures(&atlas, &image).len(), 1); ++ let (texture, _lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ let expected = (SPARE_SLOT_RELEASE_UPLOADS - 1) as u8; ++ let device = atlas.0.lock().device.0.clone(); ++ assert!( ++ read_texture(&device, &texture) ++ .iter() ++ .all(|&byte| byte == expected) ++ ); ++ } ++ ++ #[test] ++ fn large_streams_keep_a_single_slot_and_never_block() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(3840, 2304); ++ let tile = update_stream(&atlas, &image, &set_stream_frame(&image, size, 1)); ++ let (held, lease) = atlas.lease_stream_texture(tile.texture_id).unwrap(); ++ ++ update_stream(&atlas, &image, &set_stream_frame(&image, size, 2)); ++ assert_eq!(stream_slot_textures(&atlas, &image).len(), 1); ++ assert_eq!(stream_textures_created(&atlas), 2); ++ let device = atlas.0.lock().device.0.clone(); ++ assert!(read_texture(&device, &held).iter().all(|&byte| byte == 1)); ++ drop(lease); ++ } + } +diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs +index 1d35af1..4be15aa 100644 +--- a/crates/gpui_macos/src/metal_renderer.rs ++++ b/crates/gpui_macos/src/metal_renderer.rs +@@ -1,4 +1,4 @@ +-use crate::metal_atlas::MetalAtlas; ++use crate::metal_atlas::{MetalAtlas, MetalStreamLease}; + use anyhow::Result; + use block::ConcreteBlock; + use cocoa::{ +@@ -7,9 +7,9 @@ use cocoa::{ + quartzcore::AutoresizingMask, + }; + use gpui::{ +- AtlasTextureId, BackdropBlur, Background, Bounds, ContentMask, Corners, DevicePixels, +- DrawOrder, MonochromeSprite, PaintSurface, Path, Point, PolychromeSprite, PrimitiveBatch, Quad, +- ScaledPixels, Scene, Shadow, Size, Underline, point, size, ++ AtlasTextureId, AtlasTextureKind, BackdropBlur, Background, Bounds, ContentMask, Corners, ++ DevicePixels, DrawOrder, MonochromeSprite, PaintSurface, Path, Point, PolychromeSprite, ++ PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline, point, size, + }; + #[cfg(any(test, feature = "test-support"))] + use image::RgbaImage; +@@ -928,6 +928,7 @@ impl MetalRenderer { + let alpha = if self.opaque { 1. } else { 0. }; + let mut instance_offset = 0; + let mut retained_surface_textures = CommandBufferResources::default(); ++ let mut retained_stream_leases = CommandBufferResources::default(); + + // Big scratch textures are only worth holding while they're in use: + // after a run of frames that don't need them, drop them and let the +@@ -1141,6 +1142,7 @@ impl MetalRenderer { + &mut instance_offset, + viewport_size, + command_encoder, ++ &mut retained_stream_leases, + ), + PrimitiveBatch::Surfaces(range) => self.draw_surfaces( + &scene.surfaces[range], +@@ -1181,6 +1183,7 @@ impl MetalRenderer { + self.maybe_log_gpu_stats(); + + retained_surface_textures.retain_until_completion(command_buffer); ++ retained_stream_leases.retain_until_completion(command_buffer); + + Ok(command_buffer.to_owned()) + } +@@ -1217,6 +1220,8 @@ impl MetalRenderer { + 1 + }; + let (atlas_textures, atlas_bytes) = self.sprite_atlas.texture_stats(); ++ let (stream_textures, stream_bytes, stream_textures_created) = ++ self.sprite_atlas.stream_stats(); + let (pool_buffers, pool_buffer_size) = { + let pool = self.instance_buffer_pool.lock(); + (pool.buffers.len(), pool.buffer_size) +@@ -1224,7 +1229,7 @@ impl MetalRenderer { + // stderr, not `log`: consumers (comet) run tracing without a log + // bridge, and this line only exists when explicitly opted into. + eprintln!( +- "[gpu-stats] device: {:.1} MB; atlas: {} textures, {:.1} MB; instance pool: {} × {:.1} MB; backdrop scratch: {:.1} MB; path intermediate: {:.1} MB", ++ "[gpu-stats] device: {:.1} MB; atlas: {} textures, {:.1} MB; instance pool: {} × {:.1} MB; backdrop scratch: {:.1} MB; path intermediate: {:.1} MB; streams: {} textures, {:.1} MB, {} created", + self.device.current_allocated_size() as f64 / (1024.0 * 1024.0), + atlas_textures, + atlas_bytes as f64 / (1024.0 * 1024.0), +@@ -1232,6 +1237,9 @@ impl MetalRenderer { + pool_buffer_size as f64 / (1024.0 * 1024.0), + backdrop_bytes as f64 / (1024.0 * 1024.0), + path_bytes as f64 / (1024.0 * 1024.0), ++ stream_textures, ++ stream_bytes as f64 / (1024.0 * 1024.0), ++ stream_textures_created, + ); + } + +@@ -1832,13 +1840,22 @@ impl MetalRenderer { + instance_offset: &mut usize, + viewport_size: Size, + command_encoder: &metal::RenderCommandEncoderRef, ++ retained_stream_leases: &mut CommandBufferResources, + ) -> bool { + if sprites.is_empty() { + return true; + } + align_offset(instance_offset); + +- let texture = self.sprite_atlas.metal_texture(texture_id); ++ let texture = if texture_id.kind == AtlasTextureKind::Stream { ++ let Some((texture, lease)) = self.sprite_atlas.lease_stream_texture(texture_id) else { ++ return true; ++ }; ++ retained_stream_leases.push(lease); ++ texture ++ } else { ++ self.sprite_atlas.metal_texture(texture_id) ++ }; + let texture_size = size( + DevicePixels(texture.width() as i32), + DevicePixels(texture.height() as i32), +@@ -2583,6 +2600,7 @@ mod backdrop_blur_tests { + border_color: transparent_black(), + corner_radii: Corners::default(), + border_widths: Edges::default(), ++ fade: Default::default(), + }); + } + +@@ -2748,3 +2766,150 @@ mod backdrop_blur_tests { + ); + } + } ++ ++#[cfg(test)] ++mod streaming_image_tests { ++ use super::*; ++ use gpui::{ ++ AtlasKey, AtlasTile, ImageId, PlatformAtlas, RenderImageParams, StreamingImage, ++ StreamingImageFormat, ++ }; ++ use image::RgbaImage; ++ use std::borrow::Cow; ++ ++ const VIEW: i32 = 96; ++ const WIDTH: i32 = 40; ++ const HEIGHT: i32 = 30; ++ ++ fn new_renderer() -> Option { ++ if metal::Device::system_default().is_none() { ++ assert!( ++ std::env::var_os("GPUI_REQUIRE_TEST_DEVICE").is_none(), ++ "GPUI_REQUIRE_TEST_DEVICE is set but no Metal device is available" ++ ); ++ return None; ++ } ++ Some( ++ MetalRenderer::new_headless(Arc::new(Mutex::new(InstanceBufferPool::default()))) ++ .expect("failed to initialize headless renderer"), ++ ) ++ } ++ ++ fn bgra_pattern() -> Vec { ++ let mut seed = 0x2545_f491_u32; ++ let mut bytes = Vec::with_capacity((WIDTH * HEIGHT * 4) as usize); ++ for _ in 0..WIDTH * HEIGHT { ++ for _ in 0..3 { ++ seed ^= seed << 13; ++ seed ^= seed >> 17; ++ seed ^= seed << 5; ++ bytes.push(seed as u8); ++ } ++ bytes.push(255); ++ } ++ bytes ++ } ++ ++ fn frame_size() -> Size { ++ size(DevicePixels(WIDTH), DevicePixels(HEIGHT)) ++ } ++ ++ fn render(renderer: &mut MetalRenderer, tile: Option) -> RgbaImage { ++ let viewport = Bounds { ++ origin: point(ScaledPixels(0.), ScaledPixels(0.)), ++ size: size(ScaledPixels(VIEW as f32), ScaledPixels(VIEW as f32)), ++ }; ++ let mut scene = Scene::default(); ++ if let Some(tile) = tile { ++ scene.insert_primitive(PolychromeSprite { ++ order: 0, ++ pad: 0, ++ grayscale: false.into(), ++ opacity: 1., ++ bounds: Bounds { ++ origin: point(ScaledPixels(10.), ScaledPixels(12.)), ++ size: size(ScaledPixels(WIDTH as f32), ScaledPixels(HEIGHT as f32)), ++ }, ++ content_mask: ContentMask { bounds: viewport }, ++ corner_radii: Corners::default(), ++ fade: Default::default(), ++ tile, ++ }); ++ } ++ scene.finish(); ++ renderer ++ .render_scene_to_image(&scene, size(DevicePixels(VIEW), DevicePixels(VIEW))) ++ .expect("render_scene_to_image failed") ++ } ++ ++ #[test] ++ fn stream_sprites_render_exactly_like_atlas_images() { ++ let Some(mut renderer) = new_renderer() else { ++ return; ++ }; ++ let atlas = renderer.sprite_atlas().clone(); ++ let bgra = bgra_pattern(); ++ let image_tile = atlas ++ .get_or_insert_with( ++ &AtlasKey::Image(RenderImageParams { ++ image_id: ImageId(usize::MAX), ++ frame_index: 0, ++ }), ++ &mut || Ok(Some((frame_size(), Cow::Borrowed(bgra.as_slice())))), ++ ) ++ .unwrap() ++ .unwrap(); ++ ++ let bgra_stream = StreamingImage::new(StreamingImageFormat::Bgra8); ++ bgra_stream ++ .set_frame(frame_size(), WIDTH as usize * 4, bgra.clone()) ++ .unwrap(); ++ let bgra_tile = atlas ++ .get_or_update_stream(bgra_stream.id(), &bgra_stream.current().unwrap()) ++ .unwrap() ++ .unwrap(); ++ ++ let stride = WIDTH as usize * 4 + 64; ++ let mut rgba = vec![0x5a; stride * HEIGHT as usize]; ++ for (row, source) in rgba ++ .chunks_exact_mut(stride) ++ .zip(bgra.chunks_exact(WIDTH as usize * 4)) ++ { ++ for (pixel, source) in row.chunks_exact_mut(4).zip(source.chunks_exact(4)) { ++ pixel.copy_from_slice(&[source[2], source[1], source[0], source[3]]); ++ } ++ } ++ let rgba_stream = StreamingImage::new(StreamingImageFormat::Rgba8); ++ rgba_stream.set_frame(frame_size(), stride, rgba).unwrap(); ++ let rgba_tile = atlas ++ .get_or_update_stream(rgba_stream.id(), &rgba_stream.current().unwrap()) ++ .unwrap() ++ .unwrap(); ++ ++ let expected = render(&mut renderer, Some(image_tile)); ++ let pixel = expected.get_pixel(10, 12); ++ assert_eq!(pixel.0, [bgra[2], bgra[1], bgra[0], 255]); ++ assert_ne!(expected, render(&mut renderer, None)); ++ assert!(render(&mut renderer, Some(bgra_tile)) == expected); ++ assert!(render(&mut renderer, Some(rgba_tile)) == expected); ++ } ++ ++ #[test] ++ fn removed_stream_batches_are_skipped() { ++ let Some(mut renderer) = new_renderer() else { ++ return; ++ }; ++ let atlas = renderer.sprite_atlas().clone(); ++ let stream = StreamingImage::new(StreamingImageFormat::Bgra8); ++ stream ++ .set_frame(frame_size(), WIDTH as usize * 4, bgra_pattern()) ++ .unwrap(); ++ let tile = atlas ++ .get_or_update_stream(stream.id(), &stream.current().unwrap()) ++ .unwrap() ++ .unwrap(); ++ atlas.remove_stream(stream.id()); ++ ++ assert!(render(&mut renderer, Some(tile)) == render(&mut renderer, None)); ++ } ++} +diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs +index 3cf71ac..84c0043 100644 +--- a/crates/gpui_wgpu/src/wgpu_atlas.rs ++++ b/crates/gpui_wgpu/src/wgpu_atlas.rs +@@ -3,7 +3,7 @@ use collections::FxHashMap; + use etagere::{BucketedAtlasAllocator, size2}; + use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, +- PlatformAtlas, Point, Size, ++ PlatformAtlas, Point, Size, StreamFrame, StreamingImageFormat, StreamingImageId, + }; + use parking_lot::Mutex; + use std::{borrow::Cow, ops, sync::Arc}; +@@ -23,6 +23,14 @@ fn etagere_point_to_device(point: etagere::Point) -> Point { + + pub struct WgpuAtlas(Mutex); + ++#[cfg(not(test))] ++const STREAM_TEXTURE_USAGES: wgpu::TextureUsages = ++ wgpu::TextureUsages::TEXTURE_BINDING.union(wgpu::TextureUsages::COPY_DST); ++#[cfg(test)] ++const STREAM_TEXTURE_USAGES: wgpu::TextureUsages = wgpu::TextureUsages::TEXTURE_BINDING ++ .union(wgpu::TextureUsages::COPY_DST) ++ .union(wgpu::TextureUsages::COPY_SRC); ++ + struct PendingUpload { + id: AtlasTextureId, + bounds: Bounds, +@@ -37,6 +45,25 @@ struct WgpuAtlasState { + storage: WgpuAtlasStorage, + tiles_by_key: FxHashMap, + pending_uploads: Vec, ++ streams: Vec, ++ next_stream_index: u32, ++} ++ ++/// A streaming image's texture. The newest painted frame is held, not copied, ++/// and written straight from its buffer in [`WgpuAtlas::before_frame`], where ++/// atlas uploads happen; wgpu orders that write after earlier submissions that ++/// sample the texture. ++struct WgpuAtlasStream { ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ format: wgpu::TextureFormat, ++ swizzle: bool, ++ generation: u64, ++ pending: Option, ++ texture: wgpu::Texture, ++ view: wgpu::TextureView, ++ scratch: Vec, + } + + pub struct WgpuTextureInfo { +@@ -58,6 +85,8 @@ impl WgpuAtlas { + storage: WgpuAtlasStorage::default(), + tiles_by_key: Default::default(), + pending_uploads: Vec::new(), ++ streams: Vec::new(), ++ next_stream_index: 0, + })) + } + +@@ -82,6 +111,19 @@ impl WgpuAtlas { + } + } + ++ /// The view of a streaming image's texture, or `None` once the stream has ++ /// been removed. ++ pub fn get_stream_texture_info(&self, id: AtlasTextureId) -> Option { ++ let lock = self.0.lock(); ++ let stream = lock ++ .streams ++ .iter() ++ .find(|stream| stream.index == id.index)?; ++ Some(WgpuTextureInfo { ++ view: stream.view.clone(), ++ }) ++ } ++ + /// Clears all cached textures and tiles, forcing them to be recreated. + /// Use this for incremental recovery when the device is still valid. + pub fn clear(&self) { +@@ -89,6 +131,7 @@ impl WgpuAtlas { + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); ++ lock.streams.clear(); + } + + /// Handles device lost by clearing all textures and cached tiles. +@@ -101,6 +144,7 @@ impl WgpuAtlas { + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); ++ lock.streams.clear(); + } + } + +@@ -153,9 +197,108 @@ impl PlatformAtlas for WgpuAtlas { + } + } + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> Result> { ++ self.0.lock().update_stream(id, frame).map(Some) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ self.0.lock().streams.retain(|stream| stream.id != id); ++ } + } + + impl WgpuAtlasState { ++ fn update_stream(&mut self, id: StreamingImageId, frame: &StreamFrame) -> Result { ++ let size = frame.size(); ++ anyhow::ensure!( ++ size.width.0 as u32 <= self.max_texture_size ++ && size.height.0 as u32 <= self.max_texture_size, ++ "streaming image frame {}x{} exceeds the {} pixel texture limit", ++ size.width.0, ++ size.height.0, ++ self.max_texture_size ++ ); ++ u32::try_from(frame.stride()).context("streaming image stride is too large")?; ++ let (format, swizzle) = match frame.format() { ++ StreamingImageFormat::Rgba8 => (wgpu::TextureFormat::Rgba8Unorm, false), ++ StreamingImageFormat::Bgra8 ++ if self.color_texture_format == wgpu::TextureFormat::Bgra8Unorm => ++ { ++ (wgpu::TextureFormat::Bgra8Unorm, false) ++ } ++ StreamingImageFormat::Bgra8 => (wgpu::TextureFormat::Rgba8Unorm, true), ++ }; ++ let position = self.streams.iter().position(|stream| stream.id == id); ++ let position = match position { ++ Some(position) ++ if self.streams[position].size == size ++ && self.streams[position].format == format ++ && self.streams[position].swizzle == swizzle => ++ { ++ position ++ } ++ Some(position) => { ++ let index = self.streams[position].index; ++ self.streams[position] = self.new_stream(id, index, size, format, swizzle); ++ position ++ } ++ None => { ++ let index = self.next_stream_index; ++ self.next_stream_index = self.next_stream_index.wrapping_add(1); ++ let stream = self.new_stream(id, index, size, format, swizzle); ++ self.streams.push(stream); ++ self.streams.len() - 1 ++ } ++ }; ++ let stream = &mut self.streams[position]; ++ if stream.generation != frame.generation() { ++ stream.generation = frame.generation(); ++ stream.pending = Some(frame.clone()); ++ } ++ Ok(AtlasTile::for_stream(stream.index, size)) ++ } ++ ++ fn new_stream( ++ &self, ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ format: wgpu::TextureFormat, ++ swizzle: bool, ++ ) -> WgpuAtlasStream { ++ let texture = self.device.create_texture(&wgpu::TextureDescriptor { ++ label: Some("streaming_image"), ++ size: wgpu::Extent3d { ++ width: size.width.0 as u32, ++ height: size.height.0 as u32, ++ depth_or_array_layers: 1, ++ }, ++ mip_level_count: 1, ++ sample_count: 1, ++ dimension: wgpu::TextureDimension::D2, ++ format, ++ usage: STREAM_TEXTURE_USAGES, ++ view_formats: &[], ++ }); ++ let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); ++ WgpuAtlasStream { ++ id, ++ index, ++ size, ++ format, ++ swizzle, ++ generation: 0, ++ pending: None, ++ texture, ++ view, ++ scratch: Vec::new(), ++ } ++ } ++ + fn allocate( + &mut self, + size: Size, +@@ -196,6 +339,7 @@ impl WgpuAtlasState { + let format = match kind { + AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, + AtlasTextureKind::Subpixel | AtlasTextureKind::Polychrome => self.color_texture_format, ++ AtlasTextureKind::Stream => unreachable!(), + }; + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { +@@ -289,6 +433,11 @@ impl WgpuAtlasState { + }, + ); + } ++ for stream in &mut self.streams { ++ if let Some(frame) = stream.pending.take() { ++ stream.write(&self.queue, &frame); ++ } ++ } + } + } + +@@ -306,6 +455,7 @@ impl ops::Index for WgpuAtlasStorage { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, ++ AtlasTextureKind::Stream => unreachable!(), + } + } + } +@@ -316,6 +466,7 @@ impl ops::IndexMut for WgpuAtlasStorage { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, ++ AtlasTextureKind::Stream => unreachable!(), + } + } + } +@@ -336,6 +487,7 @@ impl ops::Index for WgpuAtlasStorage { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, ++ AtlasTextureKind::Stream => unreachable!(), + }; + textures[id.index as usize] + .as_ref() +@@ -343,6 +495,46 @@ impl ops::Index for WgpuAtlasStorage { + } + } + ++impl WgpuAtlasStream { ++ fn write(&mut self, queue: &wgpu::Queue, frame: &StreamFrame) { ++ let width = self.size.width.0 as u32; ++ let height = self.size.height.0 as u32; ++ let (data, bytes_per_row) = if self.swizzle { ++ let row_bytes = width as usize * 4; ++ self.scratch.clear(); ++ self.scratch.reserve(row_bytes * height as usize); ++ for row in frame.bytes().chunks(frame.stride()).take(height as usize) { ++ for pixel in row[..row_bytes].chunks_exact(4) { ++ self.scratch ++ .extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); ++ } ++ } ++ (self.scratch.as_slice(), row_bytes as u32) ++ } else { ++ (frame.bytes(), frame.stride() as u32) ++ }; ++ queue.write_texture( ++ wgpu::TexelCopyTextureInfo { ++ texture: &self.texture, ++ mip_level: 0, ++ origin: wgpu::Origin3d::ZERO, ++ aspect: wgpu::TextureAspect::All, ++ }, ++ data, ++ wgpu::TexelCopyBufferLayout { ++ offset: 0, ++ bytes_per_row: Some(bytes_per_row), ++ rows_per_image: None, ++ }, ++ wgpu::Extent3d { ++ width, ++ height, ++ depth_or_array_layers: 1, ++ }, ++ ); ++ } ++} ++ + struct WgpuAtlasTexture { + id: AtlasTextureId, + allocator: BucketedAtlasAllocator, +@@ -402,13 +594,13 @@ fn swizzle_upload_data(bytes: &[u8], format: wgpu::TextureFormat) -> Vec { + mod tests { + use super::*; + use gpui::block_on; +- use gpui::{ImageId, RenderImageParams}; ++ use gpui::{ImageId, RenderImageParams, StreamingImage}; + use std::sync::Arc; + + fn test_device_and_queue() -> anyhow::Result<(Arc, Arc)> { + block_on(async { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { +- backends: wgpu::Backends::all(), ++ backends: wgpu::Backends::from_env().unwrap_or(wgpu::Backends::all()), + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), +@@ -422,6 +614,11 @@ mod tests { + }) + .await + .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?; ++ let info = adapter.get_info(); ++ eprintln!( ++ "wgpu test adapter: {} ({:?}, {:?})", ++ info.name, info.backend, info.device_type ++ ); + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("wgpu_atlas_test_device"), +@@ -507,6 +704,432 @@ mod tests { + Ok(()) + } + ++ struct StreamTest { ++ device: Arc, ++ queue: Arc, ++ atlas: WgpuAtlas, ++ } ++ ++ fn stream_test(color_texture_format: wgpu::TextureFormat) -> Option { ++ let (device, queue) = match test_device_and_queue() { ++ Ok(device_and_queue) => device_and_queue, ++ Err(error) => { ++ assert!( ++ std::env::var_os("GPUI_REQUIRE_TEST_DEVICE").is_none(), ++ "GPUI_REQUIRE_TEST_DEVICE is set but no wgpu device is available: {error}" ++ ); ++ eprintln!("skipping: {error}"); ++ return None; ++ } ++ }; ++ let atlas = WgpuAtlas::new(device.clone(), queue.clone(), color_texture_format); ++ Some(StreamTest { ++ device, ++ queue, ++ atlas, ++ }) ++ } ++ ++ fn stream_size(width: i32, height: i32) -> Size { ++ Size { ++ width: DevicePixels(width), ++ height: DevicePixels(height), ++ } ++ } ++ ++ fn pixels(size: Size, seed: u8) -> Vec { ++ (0..size.width.0 * size.height.0 * 4) ++ .map(|index| (index as u8).wrapping_mul(31).wrapping_add(seed)) ++ .collect() ++ } ++ ++ fn set_frame(image: &StreamingImage, size: Size, bytes: Vec) -> StreamFrame { ++ image ++ .set_frame(size, size.width.0 as usize * 4, bytes) ++ .unwrap(); ++ image.current().unwrap() ++ } ++ ++ fn update(atlas: &WgpuAtlas, image: &StreamingImage, frame: &StreamFrame) -> AtlasTile { ++ let tile = atlas ++ .get_or_update_stream(image.id(), frame) ++ .expect("stream upload should succeed") ++ .expect("stream upload returns a tile"); ++ atlas.before_frame(); ++ tile ++ } ++ ++ fn stream_texture(atlas: &WgpuAtlas, image: &StreamingImage) -> wgpu::Texture { ++ atlas ++ .0 ++ .lock() ++ .streams ++ .iter() ++ .find(|stream| stream.id == image.id()) ++ .expect("stream should exist") ++ .texture ++ .clone() ++ } ++ ++ fn copy_to_buffer( ++ test: &StreamTest, ++ texture: &wgpu::Texture, ++ bounds: Bounds, ++ ) -> wgpu::Buffer { ++ let bytes_per_row = ++ (bounds.size.width.0 as u32 * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); ++ let buffer = test.device.create_buffer(&wgpu::BufferDescriptor { ++ label: Some("stream_readback"), ++ size: (bytes_per_row * bounds.size.height.0 as u32) as u64, ++ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, ++ mapped_at_creation: false, ++ }); ++ let mut encoder = test ++ .device ++ .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); ++ encoder.copy_texture_to_buffer( ++ wgpu::TexelCopyTextureInfo { ++ texture, ++ mip_level: 0, ++ origin: wgpu::Origin3d { ++ x: bounds.origin.x.0 as u32, ++ y: bounds.origin.y.0 as u32, ++ z: 0, ++ }, ++ aspect: wgpu::TextureAspect::All, ++ }, ++ wgpu::TexelCopyBufferInfo { ++ buffer: &buffer, ++ layout: wgpu::TexelCopyBufferLayout { ++ offset: 0, ++ bytes_per_row: Some(bytes_per_row), ++ rows_per_image: None, ++ }, ++ }, ++ wgpu::Extent3d { ++ width: bounds.size.width.0 as u32, ++ height: bounds.size.height.0 as u32, ++ depth_or_array_layers: 1, ++ }, ++ ); ++ test.queue.submit(Some(encoder.finish())); ++ buffer ++ } ++ ++ fn read_buffer(test: &StreamTest, buffer: &wgpu::Buffer, size: Size) -> Vec { ++ buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); ++ test.device ++ .poll(wgpu::PollType::wait_indefinitely()) ++ .unwrap(); ++ let row_bytes = size.width.0 as usize * 4; ++ let bytes_per_row = buffer.size() as usize / size.height.0 as usize; ++ let mapped = buffer.get_mapped_range(..); ++ let bytes = mapped ++ .chunks(bytes_per_row) ++ .flat_map(|row| row[..row_bytes].to_vec()) ++ .collect(); ++ drop(mapped); ++ buffer.unmap(); ++ bytes ++ } ++ ++ fn read_stream(test: &StreamTest, image: &StreamingImage, size: Size) -> Vec { ++ let texture = stream_texture(&test.atlas, image); ++ let bounds = Bounds { ++ origin: Point::default(), ++ size, ++ }; ++ let buffer = copy_to_buffer(test, &texture, bounds); ++ read_buffer(test, &buffer, size) ++ } ++ ++ fn swizzled(bytes: &[u8]) -> Vec { ++ bytes ++ .chunks_exact(4) ++ .flat_map(|pixel| [pixel[2], pixel[1], pixel[0], pixel[3]]) ++ .collect() ++ } ++ ++ #[test] ++ fn stream_updates_of_the_same_size_reuse_one_texture() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 36); ++ let first = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 1)), ++ ); ++ let texture = stream_texture(&test.atlas, &image); ++ for seed in 2..10 { ++ let tile = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, seed)), ++ ); ++ assert_eq!(tile, first); ++ } ++ ++ assert_eq!(first.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!(first.bounds.size, size); ++ assert!(stream_texture(&test.atlas, &image) == texture); ++ assert_eq!(read_stream(&test, &image, size), pixels(size, 9)); ++ } ++ ++ #[test] ++ fn stream_size_change_reallocates_once() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let small = stream_size(64, 36); ++ let large = stream_size(128, 72); ++ let small_tile = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, small, pixels(small, 1)), ++ ); ++ let small_texture = stream_texture(&test.atlas, &image); ++ let large_tile = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, large, pixels(large, 2)), ++ ); ++ let large_texture = stream_texture(&test.atlas, &image); ++ update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, large, pixels(large, 3)), ++ ); ++ ++ assert_eq!(large_tile.texture_id, small_tile.texture_id); ++ assert_eq!(large_tile.bounds.size, large); ++ assert!(large_texture != small_texture); ++ assert!(stream_texture(&test.atlas, &image) == large_texture); ++ assert_eq!(read_stream(&test, &image, large), pixels(large, 3)); ++ } ++ ++ #[test] ++ fn images_and_streams_never_share_textures() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let size = stream_size(64, 64); ++ let image_key = AtlasKey::Image(RenderImageParams { ++ image_id: ImageId(1), ++ frame_index: 0, ++ }); ++ let image_tile = test ++ .atlas ++ .get_or_insert_with(&image_key, &mut || { ++ Ok(Some((size, Cow::Owned(pixels(size, 1))))) ++ }) ++ .unwrap() ++ .unwrap(); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let stream_tile = update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 2)), ++ ); ++ ++ assert_eq!(image_tile.texture_id.kind, AtlasTextureKind::Polychrome); ++ assert_eq!(stream_tile.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!( ++ test.atlas ++ .0 ++ .lock() ++ .storage ++ .polychrome_textures ++ .iter_mut() ++ .count(), ++ 1 ++ ); ++ ++ test.atlas.remove(&image_key); ++ test.atlas.before_frame(); ++ assert!( ++ test.atlas ++ .get_stream_texture_info(stream_tile.texture_id) ++ .is_some() ++ ); ++ test.atlas.remove_stream(image.id()); ++ assert!( ++ test.atlas ++ .get_stream_texture_info(stream_tile.texture_id) ++ .is_none() ++ ); ++ test.atlas.remove_stream(image.id()); ++ } ++ ++ #[test] ++ fn stream_textures_hold_what_atlas_image_tiles_hold() { ++ for atlas_format in [ ++ wgpu::TextureFormat::Bgra8Unorm, ++ wgpu::TextureFormat::Rgba8Unorm, ++ ] { ++ let Some(test) = stream_test(atlas_format) else { ++ return; ++ }; ++ let size = stream_size(64, 32); ++ let bgra = pixels(size, 7); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ update(&test.atlas, &image, &set_frame(&image, size, bgra.clone())); ++ ++ assert_eq!(stream_texture(&test.atlas, &image).format(), atlas_format); ++ assert_eq!( ++ read_stream(&test, &image, size), ++ swizzle_upload_data(&bgra, atlas_format) ++ ); ++ } ++ } ++ ++ #[test] ++ fn stream_formats_and_strides_upload_the_same_pixels() { ++ for atlas_format in [ ++ wgpu::TextureFormat::Bgra8Unorm, ++ wgpu::TextureFormat::Rgba8Unorm, ++ ] { ++ let Some(test) = stream_test(atlas_format) else { ++ return; ++ }; ++ let size = stream_size(64, 16); ++ let bgra = pixels(size, 3); ++ let rgba = swizzled(&bgra); ++ ++ let bgra_image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ update( ++ &test.atlas, ++ &bgra_image, ++ &set_frame(&bgra_image, size, bgra.clone()), ++ ); ++ let stride = 64 * 4 + 32; ++ let mut padded = vec![0xee; stride * 16]; ++ for (row, source) in padded ++ .chunks_exact_mut(stride) ++ .zip(rgba.chunks_exact(64 * 4)) ++ { ++ row[..64 * 4].copy_from_slice(source); ++ } ++ let rgba_image = StreamingImage::new(StreamingImageFormat::Rgba8); ++ rgba_image.set_frame(size, stride, padded).unwrap(); ++ update(&test.atlas, &rgba_image, &rgba_image.current().unwrap()); ++ ++ let bgra_texture = stream_texture(&test.atlas, &bgra_image); ++ let rgba_texture = stream_texture(&test.atlas, &rgba_image); ++ assert_eq!(rgba_texture.format(), wgpu::TextureFormat::Rgba8Unorm); ++ let as_rgba = |texture: &wgpu::Texture, bytes: Vec| match texture.format() { ++ wgpu::TextureFormat::Bgra8Unorm => swizzled(&bytes), ++ _ => bytes, ++ }; ++ assert_eq!( ++ as_rgba(&bgra_texture, read_stream(&test, &bgra_image, size)), ++ rgba ++ ); ++ assert_eq!(read_stream(&test, &rgba_image, size), rgba); ++ } ++ } ++ ++ #[test] ++ fn stream_writes_are_ordered_with_submitted_reads() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 64); ++ let bounds = Bounds { ++ origin: Point::default(), ++ size, ++ }; ++ update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 1)), ++ ); ++ let texture = stream_texture(&test.atlas, &image); ++ let first = copy_to_buffer(&test, &texture, bounds); ++ update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 2)), ++ ); ++ let second = copy_to_buffer(&test, &texture, bounds); ++ ++ assert_eq!(read_buffer(&test, &first, size), pixels(size, 1)); ++ assert_eq!(read_buffer(&test, &second, size), pixels(size, 2)); ++ } ++ ++ #[test] ++ fn stream_writes_wait_for_before_frame_and_keep_only_the_newest_frame() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 16); ++ let bounds = Bounds { ++ origin: Point::default(), ++ size, ++ }; ++ update( ++ &test.atlas, ++ &image, ++ &set_frame(&image, size, pixels(size, 1)), ++ ); ++ let texture = stream_texture(&test.atlas, &image); ++ for seed in 2..5 { ++ test.atlas ++ .get_or_update_stream(image.id(), &set_frame(&image, size, pixels(size, seed))) ++ .unwrap(); ++ } ++ let before = copy_to_buffer(&test, &texture, bounds); ++ test.atlas.before_frame(); ++ let after = copy_to_buffer(&test, &texture, bounds); ++ ++ assert_eq!(read_buffer(&test, &before, size), pixels(size, 1)); ++ assert_eq!(read_buffer(&test, &after, size), pixels(size, 4)); ++ } ++ ++ #[test] ++ fn cleared_atlas_uploads_streams_again() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 8); ++ let frame = set_frame(&image, size, pixels(size, 4)); ++ let tile = update(&test.atlas, &image, &frame); ++ let texture = stream_texture(&test.atlas, &image); ++ ++ test.atlas.clear(); ++ assert!( ++ test.atlas ++ .get_stream_texture_info(tile.texture_id) ++ .is_none() ++ ); ++ let tile = update(&test.atlas, &image, &frame); ++ assert!( ++ test.atlas ++ .get_stream_texture_info(tile.texture_id) ++ .is_some() ++ ); ++ assert!(stream_texture(&test.atlas, &image) != texture); ++ assert_eq!(read_stream(&test, &image, size), pixels(size, 4)); ++ } ++ ++ #[test] ++ fn oversized_stream_frames_are_rejected() { ++ let Some(test) = stream_test(wgpu::TextureFormat::Bgra8Unorm) else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let width = test.atlas.0.lock().max_texture_size as i32 + 1; ++ let size = stream_size(width, 1); ++ let frame = set_frame(&image, size, vec![0; width as usize * 4]); ++ assert!(test.atlas.get_or_update_stream(image.id(), &frame).is_err()); ++ } ++ + #[test] + fn swizzle_upload_data_preserves_bgra_uploads() { + let input = vec![0x10, 0x20, 0x30, 0x40]; +diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs +index 729a5c4..2bfbc4e 100644 +--- a/crates/gpui_wgpu/src/wgpu_renderer.rs ++++ b/crates/gpui_wgpu/src/wgpu_renderer.rs +@@ -2,9 +2,9 @@ use crate::surface_frame::SurfaceFrame; + use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; + use bytemuck::{Pod, Zeroable}; + use gpui::{ +- AtlasTextureId, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, +- PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, SubpixelSprite, +- Underline, get_gamma_correction_ratios, ++ AtlasTextureId, AtlasTextureKind, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, ++ Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, ++ SubpixelSprite, Underline, get_gamma_correction_ratios, + }; + use log::warn; + #[cfg(not(target_family = "wasm"))] +@@ -1448,7 +1448,14 @@ impl WgpuRenderer { + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { +- let tex_info = self.atlas.get_texture_info(texture_id); ++ let tex_info = if texture_id.kind == AtlasTextureKind::Stream { ++ let Some(tex_info) = self.atlas.get_stream_texture_info(texture_id) else { ++ return true; ++ }; ++ tex_info ++ } else { ++ self.atlas.get_texture_info(texture_id) ++ }; + let data = unsafe { Self::instance_bytes(sprites) }; + self.draw_instances_with_texture( + data, +diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs +index d49ec18..719fd3c 100644 +--- a/crates/gpui_windows/src/directx_atlas.rs ++++ b/crates/gpui_windows/src/directx_atlas.rs +@@ -11,9 +11,11 @@ use windows::Win32::Graphics::{ + + use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, +- PlatformAtlas, Point, Size, ++ PlatformAtlas, Point, Size, StreamFrame, StreamingImageFormat, StreamingImageId, + }; + ++const MAX_STREAM_TEXTURE_SIZE: i32 = 16384; ++ + pub(crate) struct DirectXAtlas(Mutex); + + struct DirectXAtlasState { +@@ -23,6 +25,20 @@ struct DirectXAtlasState { + polychrome_textures: AtlasTextureList, + subpixel_textures: AtlasTextureList, + tiles_by_key: FxHashMap, ++ streams: Vec, ++ next_stream_index: u32, ++} ++ ++/// A streaming image's texture, written with `UpdateSubresource`; the ++/// immediate context orders each write after earlier draws that sample it. ++struct DirectXAtlasStream { ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ format: DXGI_FORMAT, ++ generation: u64, ++ texture: ID3D11Texture2D, ++ view: [Option; 1], + } + + struct DirectXAtlasTexture { +@@ -43,9 +59,23 @@ impl DirectXAtlas { + polychrome_textures: Default::default(), + subpixel_textures: Default::default(), + tiles_by_key: Default::default(), ++ streams: Vec::new(), ++ next_stream_index: 0, + })) + } + ++ pub(crate) fn get_stream_texture_view( ++ &self, ++ id: AtlasTextureId, ++ ) -> Option<[Option; 1]> { ++ let lock = self.0.lock(); ++ let stream = lock ++ .streams ++ .iter() ++ .find(|stream| stream.index == id.index)?; ++ Some(stream.view.clone()) ++ } ++ + pub(crate) fn get_texture_view( + &self, + id: AtlasTextureId, +@@ -67,6 +97,7 @@ impl DirectXAtlas { + lock.polychrome_textures = AtlasTextureList::default(); + lock.subpixel_textures = AtlasTextureList::default(); + lock.tiles_by_key.clear(); ++ lock.streams.clear(); + } + } + +@@ -107,6 +138,7 @@ impl PlatformAtlas for DirectXAtlas { + AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, + AtlasTextureKind::Polychrome => &mut lock.polychrome_textures, + AtlasTextureKind::Subpixel => &mut lock.subpixel_textures, ++ AtlasTextureKind::Stream => unreachable!(), + }; + let cached_texture_index = if id.kind == AtlasTextureKind::Polychrome { + textures.textures.iter().position(|entry| { +@@ -138,6 +170,18 @@ impl PlatformAtlas for DirectXAtlas { + } + } + } ++ ++ fn get_or_update_stream( ++ &self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> anyhow::Result> { ++ self.0.lock().update_stream(id, frame) ++ } ++ ++ fn remove_stream(&self, id: StreamingImageId) { ++ self.0.lock().streams.retain(|stream| stream.id != id); ++ } + } + + impl DirectXAtlasState { +@@ -151,6 +195,7 @@ impl DirectXAtlasState { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, ++ AtlasTextureKind::Stream => unreachable!(), + }; + + if let Some(tile) = textures +@@ -201,6 +246,7 @@ impl DirectXAtlasState { + bind_flag = D3D11_BIND_SHADER_RESOURCE; + bytes_per_pixel = 4; + } ++ AtlasTextureKind::Stream => unreachable!(), + } + let texture_desc = D3D11_TEXTURE2D_DESC { + Width: size.width.0 as u32, +@@ -231,6 +277,7 @@ impl DirectXAtlasState { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, ++ AtlasTextureKind::Stream => unreachable!(), + }; + let index = texture_list.free_list.pop(); + let view = unsafe { +@@ -271,7 +318,113 @@ impl DirectXAtlasState { + AtlasTextureKind::Subpixel => { + &self.subpixel_textures[id.index as usize].as_ref().unwrap() + } ++ AtlasTextureKind::Stream => unreachable!(), ++ } ++ } ++ ++ fn update_stream( ++ &mut self, ++ id: StreamingImageId, ++ frame: &StreamFrame, ++ ) -> anyhow::Result> { ++ let size = frame.size(); ++ anyhow::ensure!( ++ size.width.0 <= MAX_STREAM_TEXTURE_SIZE && size.height.0 <= MAX_STREAM_TEXTURE_SIZE, ++ "streaming image frame {}x{} exceeds the {MAX_STREAM_TEXTURE_SIZE} pixel texture limit", ++ size.width.0, ++ size.height.0 ++ ); ++ let stride = u32::try_from(frame.stride()) ++ .map_err(|_| anyhow::anyhow!("streaming image stride is too large"))?; ++ let format = match frame.format() { ++ StreamingImageFormat::Bgra8 => DXGI_FORMAT_B8G8R8A8_UNORM, ++ StreamingImageFormat::Rgba8 => DXGI_FORMAT_R8G8B8A8_UNORM, ++ }; ++ let position = self.streams.iter().position(|stream| stream.id == id); ++ let position = match position { ++ Some(position) ++ if self.streams[position].size == size ++ && self.streams[position].format == format => ++ { ++ position ++ } ++ Some(position) => { ++ let index = self.streams[position].index; ++ let Some(stream) = self.new_stream(id, index, size, format) else { ++ self.streams.remove(position); ++ return Ok(None); ++ }; ++ self.streams[position] = stream; ++ position ++ } ++ None => { ++ let index = self.next_stream_index; ++ let Some(stream) = self.new_stream(id, index, size, format) else { ++ return Ok(None); ++ }; ++ self.next_stream_index = self.next_stream_index.wrapping_add(1); ++ self.streams.push(stream); ++ self.streams.len() - 1 ++ } ++ }; ++ let stream = &mut self.streams[position]; ++ if stream.generation != frame.generation() { ++ unsafe { ++ self.device_context.UpdateSubresource( ++ &stream.texture, ++ 0, ++ None, ++ frame.bytes().as_ptr() as _, ++ stride, ++ 0, ++ ); ++ } ++ stream.generation = frame.generation(); + } ++ Ok(Some(AtlasTile::for_stream(stream.index, size))) ++ } ++ ++ fn new_stream( ++ &self, ++ id: StreamingImageId, ++ index: u32, ++ size: Size, ++ format: DXGI_FORMAT, ++ ) -> Option { ++ let texture_desc = D3D11_TEXTURE2D_DESC { ++ Width: size.width.0 as u32, ++ Height: size.height.0 as u32, ++ MipLevels: 1, ++ ArraySize: 1, ++ Format: format, ++ SampleDesc: DXGI_SAMPLE_DESC { ++ Count: 1, ++ Quality: 0, ++ }, ++ Usage: D3D11_USAGE_DEFAULT, ++ BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, ++ CPUAccessFlags: 0, ++ MiscFlags: 0, ++ }; ++ let mut texture: Option = None; ++ let mut view = None; ++ unsafe { ++ self.device ++ .CreateTexture2D(&texture_desc, None, Some(&mut texture)) ++ .ok()?; ++ self.device ++ .CreateShaderResourceView(texture.as_ref()?, None, Some(&mut view)) ++ .ok()?; ++ } ++ Some(DirectXAtlasStream { ++ id, ++ index, ++ size, ++ format, ++ generation: 0, ++ texture: texture?, ++ view: [view], ++ }) + } + } + +@@ -357,17 +510,31 @@ fn etagere_point_to_device(value: etagere::Point) -> Point { + #[cfg(test)] + mod tests { + use super::*; +- use gpui::{ImageId, RenderImageParams}; ++ use gpui::{ImageId, RenderImageParams, StreamingImage}; + use std::borrow::Cow; + use windows::Win32::{ + Foundation::HMODULE, + Graphics::{ + Direct3D::D3D_DRIVER_TYPE_WARP, +- Direct3D11::{D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice}, ++ Direct3D11::{ ++ D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_MAP_READ, ++ D3D11_MAPPED_SUBRESOURCE, D3D11_SDK_VERSION, D3D11_USAGE_STAGING, ++ D3D11CreateDevice, ++ }, + }, + }; + + fn create_atlas() -> Option { ++ let device = create_device(); ++ assert!( ++ device.is_some() || std::env::var_os("GPUI_REQUIRE_TEST_DEVICE").is_none(), ++ "GPUI_REQUIRE_TEST_DEVICE is set but no WARP device is available" ++ ); ++ let (device, device_context) = device?; ++ Some(DirectXAtlas::new(&device, &device_context)) ++ } ++ ++ fn create_device() -> Option<(ID3D11Device, ID3D11DeviceContext)> { + let mut device: Option = None; + let mut device_context: Option = None; + unsafe { +@@ -384,7 +551,7 @@ mod tests { + ) + } + .ok()?; +- Some(DirectXAtlas::new(&device?, &device_context?)) ++ Some((device?, device_context?)) + } + + fn make_image_key(image_id: usize) -> AtlasKey { +@@ -433,6 +600,264 @@ mod tests { + assert_eq!(tile_b.texture_id, keeper_tile.texture_id); + } + ++ fn stream_size(width: i32, height: i32) -> Size { ++ Size { ++ width: DevicePixels(width), ++ height: DevicePixels(height), ++ } ++ } ++ ++ fn pixels(size: Size, seed: u8) -> Vec { ++ (0..size.width.0 * size.height.0 * 4) ++ .map(|index| (index as u8).wrapping_mul(31).wrapping_add(seed)) ++ .collect() ++ } ++ ++ fn set_frame(image: &StreamingImage, size: Size, bytes: Vec) -> StreamFrame { ++ image ++ .set_frame(size, size.width.0 as usize * 4, bytes) ++ .unwrap(); ++ image.current().unwrap() ++ } ++ ++ fn update(atlas: &DirectXAtlas, image: &StreamingImage, frame: &StreamFrame) -> AtlasTile { ++ atlas ++ .get_or_update_stream(image.id(), frame) ++ .expect("stream upload should succeed") ++ .expect("stream upload returns a tile") ++ } ++ ++ fn stream_texture(atlas: &DirectXAtlas, image: &StreamingImage) -> ID3D11Texture2D { ++ atlas ++ .0 ++ .lock() ++ .streams ++ .iter() ++ .find(|stream| stream.id == image.id()) ++ .expect("stream should exist") ++ .texture ++ .clone() ++ } ++ ++ fn copy_to_staging(atlas: &DirectXAtlas, texture: &ID3D11Texture2D) -> ID3D11Texture2D { ++ let lock = atlas.0.lock(); ++ let mut desc = D3D11_TEXTURE2D_DESC::default(); ++ unsafe { texture.GetDesc(&mut desc) }; ++ desc.Usage = D3D11_USAGE_STAGING; ++ desc.BindFlags = 0; ++ desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ.0 as u32; ++ let mut staging: Option = None; ++ unsafe { ++ lock.device ++ .CreateTexture2D(&desc, None, Some(&mut staging)) ++ .unwrap(); ++ let staging = staging.unwrap(); ++ lock.device_context.CopyResource(&staging, texture); ++ staging ++ } ++ } ++ ++ fn read_staging(atlas: &DirectXAtlas, staging: &ID3D11Texture2D) -> Vec { ++ let lock = atlas.0.lock(); ++ let mut desc = D3D11_TEXTURE2D_DESC::default(); ++ unsafe { staging.GetDesc(&mut desc) }; ++ let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); ++ unsafe { ++ lock.device_context ++ .Map(staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped)) ++ .unwrap(); ++ } ++ let row_bytes = desc.Width as usize * 4; ++ let mut bytes = Vec::with_capacity(row_bytes * desc.Height as usize); ++ for row in 0..desc.Height as usize { ++ let row = unsafe { ++ std::slice::from_raw_parts( ++ (mapped.pData as *const u8).add(row * mapped.RowPitch as usize), ++ row_bytes, ++ ) ++ }; ++ bytes.extend_from_slice(row); ++ } ++ unsafe { lock.device_context.Unmap(staging, 0) }; ++ bytes ++ } ++ ++ fn read_stream(atlas: &DirectXAtlas, image: &StreamingImage) -> Vec { ++ let staging = copy_to_staging(atlas, &stream_texture(atlas, image)); ++ read_staging(atlas, &staging) ++ } ++ ++ fn swizzled(bytes: &[u8]) -> Vec { ++ bytes ++ .chunks_exact(4) ++ .flat_map(|pixel| [pixel[2], pixel[1], pixel[0], pixel[3]]) ++ .collect() ++ } ++ ++ #[test] ++ fn stream_updates_of_the_same_size_reuse_one_texture() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 36); ++ let first = update(&atlas, &image, &set_frame(&image, size, pixels(size, 1))); ++ let texture = stream_texture(&atlas, &image); ++ for seed in 2..10 { ++ let tile = update(&atlas, &image, &set_frame(&image, size, pixels(size, seed))); ++ assert_eq!(tile, first); ++ } ++ ++ assert_eq!(first.texture_id.kind, AtlasTextureKind::Stream); ++ assert_eq!(first.bounds.size, size); ++ assert_eq!(stream_texture(&atlas, &image), texture); ++ assert_eq!(read_stream(&atlas, &image), pixels(size, 9)); ++ } ++ ++ #[test] ++ fn stream_size_change_reallocates_once() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let small = stream_size(64, 36); ++ let large = stream_size(128, 72); ++ let small_tile = update(&atlas, &image, &set_frame(&image, small, pixels(small, 1))); ++ let small_texture = stream_texture(&atlas, &image); ++ let large_tile = update(&atlas, &image, &set_frame(&image, large, pixels(large, 2))); ++ let large_texture = stream_texture(&atlas, &image); ++ update(&atlas, &image, &set_frame(&image, large, pixels(large, 3))); ++ ++ assert_eq!(large_tile.texture_id, small_tile.texture_id); ++ assert_eq!(large_tile.bounds.size, large); ++ assert_ne!(large_texture, small_texture); ++ assert_eq!(stream_texture(&atlas, &image), large_texture); ++ assert_eq!(read_stream(&atlas, &image), pixels(large, 3)); ++ } ++ ++ #[test] ++ fn removing_images_leaves_streams_alone() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let size = stream_size(64, 64); ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let stream_tile = update(&atlas, &image, &set_frame(&image, size, pixels(size, 5))); ++ let stream = stream_texture(&atlas, &image); ++ ++ let first_key = make_image_key(20); ++ let second_key = make_image_key(21); ++ let first = insert_tile(&atlas, &first_key, stream_size(1280, 720)); ++ atlas.remove(&first_key); ++ let second = insert_tile(&atlas, &second_key, stream_size(1280, 720)); ++ atlas.remove(&second_key); ++ ++ assert_eq!(first.texture_id.kind, AtlasTextureKind::Polychrome); ++ assert_eq!(first.texture_id, second.texture_id); ++ assert_eq!( ++ atlas ++ .0 ++ .lock() ++ .polychrome_textures ++ .textures ++ .iter() ++ .flatten() ++ .count(), ++ 1 ++ ); ++ assert!( ++ atlas ++ .get_stream_texture_view(stream_tile.texture_id) ++ .is_some() ++ ); ++ assert_eq!(stream_texture(&atlas, &image), stream); ++ assert_eq!(read_stream(&atlas, &image), pixels(size, 5)); ++ ++ atlas.remove_stream(image.id()); ++ assert!( ++ atlas ++ .get_stream_texture_view(stream_tile.texture_id) ++ .is_none() ++ ); ++ atlas.remove_stream(image.id()); ++ } ++ ++ #[test] ++ fn device_loss_uploads_streams_again() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 8); ++ let frame = set_frame(&image, size, pixels(size, 4)); ++ let tile = update(&atlas, &image, &frame); ++ ++ let (device, device_context) = create_device().unwrap(); ++ atlas.handle_device_lost(&device, &device_context); ++ assert!(atlas.get_stream_texture_view(tile.texture_id).is_none()); ++ let tile = update(&atlas, &image, &frame); ++ assert!(atlas.get_stream_texture_view(tile.texture_id).is_some()); ++ assert_eq!(read_stream(&atlas, &image), pixels(size, 4)); ++ } ++ ++ #[test] ++ fn stream_writes_are_ordered_with_earlier_copies() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(64, 64); ++ update(&atlas, &image, &set_frame(&image, size, pixels(size, 1))); ++ let texture = stream_texture(&atlas, &image); ++ let first = copy_to_staging(&atlas, &texture); ++ update(&atlas, &image, &set_frame(&image, size, pixels(size, 2))); ++ let second = copy_to_staging(&atlas, &texture); ++ ++ assert_eq!(read_staging(&atlas, &first), pixels(size, 1)); ++ assert_eq!(read_staging(&atlas, &second), pixels(size, 2)); ++ } ++ ++ #[test] ++ fn stream_formats_and_strides_upload_the_same_pixels() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let size = stream_size(64, 16); ++ let bgra = pixels(size, 3); ++ let rgba = swizzled(&bgra); ++ let bgra_image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ update( ++ &atlas, ++ &bgra_image, ++ &set_frame(&bgra_image, size, bgra.clone()), ++ ); ++ let stride = 64 * 4 + 32; ++ let mut padded = vec![0xee; stride * 16]; ++ for (row, source) in padded ++ .chunks_exact_mut(stride) ++ .zip(rgba.chunks_exact(64 * 4)) ++ { ++ row[..64 * 4].copy_from_slice(source); ++ } ++ let rgba_image = StreamingImage::new(StreamingImageFormat::Rgba8); ++ rgba_image.set_frame(size, stride, padded).unwrap(); ++ update(&atlas, &rgba_image, &rgba_image.current().unwrap()); ++ ++ assert_eq!(read_stream(&atlas, &bgra_image), bgra); ++ assert_eq!(read_stream(&atlas, &rgba_image), rgba); ++ } ++ ++ #[test] ++ fn oversized_stream_frames_are_rejected() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ let image = StreamingImage::new(StreamingImageFormat::Bgra8); ++ let size = stream_size(MAX_STREAM_TEXTURE_SIZE + 1, 1); ++ let frame = set_frame(&image, size, vec![0; size.width.0 as usize * 4]); ++ assert!(atlas.get_or_update_stream(image.id(), &frame).is_err()); ++ } ++ + #[test] + fn test_reuses_one_unreferenced_polychrome_texture() { + let Some(atlas) = create_atlas() else { +diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs +index 35b504e..e4b605b 100644 +--- a/crates/gpui_windows/src/directx_renderer.rs ++++ b/crates/gpui_windows/src/directx_renderer.rs +@@ -712,7 +712,14 @@ impl DirectXRenderer { + } + let devices = self.devices.as_ref().context("devices missing")?; + let resources = self.resources.as_ref().context("resources missing")?; +- let texture_view = self.atlas.get_texture_view(texture_id); ++ let texture_view = if texture_id.kind == AtlasTextureKind::Stream { ++ let Some(texture_view) = self.atlas.get_stream_texture_view(texture_id) else { ++ return Ok(()); ++ }; ++ texture_view ++ } else { ++ self.atlas.get_texture_view(texture_id) ++ }; + self.pipelines.poly_sprites.draw_range_with_texture( + &devices.device, + &devices.device_context, diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index f9fb9a8e798..0a61ef5a4cc 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -69,8 +69,7 @@ pub(crate) fn display_work_area( let bounds = scap_targets::Display::from_id(&id) .as_ref() .and_then(capture_display_bounds)?; - let primary_height = cx.primary_display()?.bounds().size.height; - Some(global_macos_work_area(available, bounds, primary_height)) + Some(global_macos_work_area(available, bounds)) } #[cfg(not(target_os = "macos"))] Some(available) @@ -147,13 +146,12 @@ fn capture_display_bounds(display: &scap_targets::Display) -> Option, display: Bounds, - primary_height: Pixels, ) -> Bounds { - // GPUI's macOS work area uses local x but includes the AppKit screen y. - // Windows opened without a display ID need primary-display coordinates. - let appkit_origin_y = primary_height - display.origin.y - display.size.height; - available.origin.x += display.origin.x; - available.origin.y += display.origin.y - appkit_origin_y; + // GPUI's macOS work area is relative to its own display's top-left corner + // (`visible_bounds` in gpui_macos). Windows opened without a display ID need + // primary-display coordinates, so a display above or below the primary one + // has to carry its global origin across. + available.origin += display.origin; available } @@ -547,6 +545,18 @@ pub fn init(main: WindowHandle, session: Entity, c }) .detach(); + let thumbnails = crate::recording::studio_thumbnail_events(); + cx.spawn(async move |cx| { + while thumbnails.recv_async().await.is_ok() { + cx.update(|cx| { + crate::tray::refresh_previous(cx); + refresh_library_after_delete(cx); + crate::menus::recheck_pending_quit(cx); + }); + } + }) + .detach(); + let mut last_phase = Phase::Idle; cx.observe(&session, move |session, cx| { let phase = session.read(cx).phase; @@ -2280,6 +2290,27 @@ pub fn refresh_target_overlays(cx: &mut App) { } } +/// [`refresh_target_overlays`] for a mic level change: only the overlays whose +/// meter would visibly move repaint. +pub fn refresh_microphone_overlays(cx: &mut App) { + let handles: Vec<_> = cx + .global::() + .overlays + .iter() + .map(|(_, handle)| *handle) + .collect(); + for handle in handles { + handle + .update(cx, |view, window, cx| { + if view.microphone_level_moved(window, cx) { + cx.notify(); + window.refresh(); + } + }) + .ok(); + } +} + /// The overlays' native window numbers, for capture exclusion. fn overlay_window_ids(cx: &mut App) -> Vec { let handles: Vec<_> = cx @@ -4875,13 +4906,86 @@ pub(crate) async fn shared_wgpu_device() -> Option bool { + GPU_WORK_REQUESTED.load(std::sync::atomic::Ordering::Acquire) +} + pub(crate) fn prewarm_shared_wgpu_device(cx: &App) { + GPU_WORK_REQUESTED.store(true, std::sync::atomic::Ordering::Release); gpui_tokio::Tokio::spawn(cx, async { let _ = shared_wgpu_device().await; }) .detach(); } +const LAUNCH_RENDER_WARMUP_DELAY: std::time::Duration = std::time::Duration::from_secs(3); + +/// The first editor open in a process paid for the system font scan, the +/// shared device and the renderer's first pipeline compiles and draw before +/// its first frame: 0.7-1.2 s, against ~0.2 s for every later open. This warms +/// them a few seconds after launch, off the main thread. The layers are +/// dropped again; only the driver's compiled pipelines, the device and the +/// font database stay. +pub(crate) fn schedule_launch_render_warmup(cx: &App) { + gpui_tokio::Tokio::spawn(cx, async { + tokio::time::sleep(LAUNCH_RENDER_WARMUP_DELAY).await; + let started = std::time::Instant::now(); + if tokio::task::spawn_blocking(cap_rendering::prewarm_fonts) + .await + .is_err() + { + return; + } + let fonts_ms = started.elapsed().as_secs_f64() * 1000.0; + let renderer_ms = warm_renderer().await; + tracing::info!(fonts_ms, ?renderer_ms, "launch render warmup finished"); + }) + .detach(); +} + +#[cfg(target_os = "macos")] +async fn warm_renderer() -> Option { + if gpu_work_requested() { + return None; + } + let shared = shared_wgpu_device().await?; + if gpu_work_requested() { + return None; + } + let started = std::time::Instant::now(); + let (device, queue, software) = ( + shared.device.clone(), + shared.queue.clone(), + shared.is_software_adapter, + ); + let mut layers = tokio::task::spawn_blocking(move || { + (!gpu_work_requested()) + .then(|| cap_rendering::RendererLayers::new_with_options(&device, &queue, software)) + }) + .await + .ok()??; + if gpu_work_requested() { + return None; + } + if let Err(error) = cap_rendering::prewarm_first_frame(shared, &mut layers).await { + tracing::warn!(%error, "launch render warmup frame failed"); + } + drop(layers); + Some(started.elapsed().as_secs_f64() * 1000.0) +} + +#[cfg(not(target_os = "macos"))] +async fn warm_renderer() -> Option { + None +} + fn editor_audio_output() -> Arc { if std::env::var("CAP_GPUI_MUTE_AUDIO").is_ok_and(|value| value == "1") { Arc::new(cap_editor::AudioOutput::new_headless(Box::new(|_, _| {}))) @@ -6358,23 +6462,6 @@ pub fn arm_target_mode(kind: crate::main_window::TargetType, cx: &mut App) { .ok(); } -/// Repaint the bar between its once-a-second ticks -- the mic meter updates at -/// up to ~20Hz and the bar is never the active window. -pub fn refresh_controls_window(cx: &mut App) { - let level_db = crate::feeds::Feeds::global(cx).read(cx).mic_level_db; - if let Some(handle) = cx.global::().controls { - handle - .update(cx, |controls, window, cx| { - if !controls.level_bar_moves(level_db, window.scale_factor()) { - return; - } - cx.notify(); - window.refresh(); - }) - .ok(); - } -} - fn camera_window_number(cx: &mut App) -> Option { let handle = cx.global::().camera?; handle @@ -6558,10 +6645,14 @@ pub fn screenshot_finished(captured: Option, cx: &mut App) { .ok(); let behaviour = crate::store::GeneralSettings::load().post_screenshot_behaviour; - if behaviour.copies() - && let Err(error) = platform::copy_image_to_clipboard(&png, cx) - { - tracing::error!("copying the screenshot to the clipboard failed: {error}"); + if behaviour.copies() { + let copied = platform::copy_image_to_clipboard(png.clone(), cx); + cx.spawn(async move |_| { + if let Err(error) = copied.await { + tracing::error!("copying the screenshot to the clipboard failed: {error}"); + } + }) + .detach(); } if behaviour.opens_editor() { @@ -6888,11 +6979,12 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn macos_work_area_preserves_horizontal_and_vertical_display_origins() { - for (display_origin, appkit_y) in [ - (point(px(-1440.), px(0.)), 180.), - (point(px(1920.), px(0.)), 180.), - (point(px(0.), px(-900.)), 1080.), - (point(px(0.), px(1080.)), -900.), + for display_origin in [ + point(px(-1440.), px(0.)), + point(px(1920.), px(0.)), + point(px(0.), px(-900.)), + point(px(0.), px(1080.)), + point(px(1691.), px(-1080.)), ] { let display = Bounds { origin: display_origin, @@ -6900,11 +6992,10 @@ mod tests { }; let available = global_macos_work_area( Bounds { - origin: point(px(0.), px(appkit_y + 25.)), + origin: point(px(0.), px(25.)), size: size(px(1440.), px(825.)), }, display, - px(1080.), ); assert_eq!(available.origin, display_origin + point(px(0.), px(25.))); assert_eq!(available.size, size(px(1440.), px(825.))); diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index be8bf1b32df..638020d001e 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -182,6 +182,7 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "import.svg", "edit.svg", "copy.svg", + "clipboard-paste.svg", "record-fill.svg", "warning-bold.svg", "traffic-close.svg", diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index 0ddbed2aacb..59a2018957e 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -299,7 +299,7 @@ impl UploadRequestContext { current_server: &str, current_auth: &serde_json::Map, ) -> Result { - if current_server != self.server_url + if !has_same_origin(current_server, &self.server_url) || current_auth.get("user_id").and_then(Value::as_str) != Some(self.owner_id.as_str()) { return Err(AuthApiError::InvalidAuthentication); @@ -323,20 +323,74 @@ impl UploadRequestContext { } } -pub fn api_client() -> Result { +/// Idle API connections are dropped after 4 s, before the 5 s keep-alive +/// timeout of a self-hosted Node server, so a request is never sent on a +/// connection the server is closing. +pub(crate) const API_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(4); +/// The shared API client is rebuilt this often, so system proxy changes still +/// apply. It is wall clock time, which counts a sleep where the pool's idle +/// clock does not, so calls after a longer sleep start on new connections. +const API_CLIENT_MAX_AGE: Duration = Duration::from_secs(30); + +fn api_client_builder() -> reqwest::ClientBuilder { reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(45)) +} + +/// Multipart uploads hold their own client and drop it when the upload ends, +/// so a later upload never starts on a pooled connection from an earlier +/// network (#2522). +pub fn api_client() -> Result { + api_client_builder() .build() .map_err(|error| AuthApiError::Other(error.to_string())) } +static SHARED_API_CLIENT: Mutex> = + Mutex::new(None); + +/// Held by every test that resets the shared client or counts its +/// connections, so a reset on one thread cannot land in another's count. +#[cfg(test)] +pub(crate) static SHARED_API_CLIENT_TEST_LOCK: tokio::sync::Mutex<()> = + tokio::sync::Mutex::const_new(()); + +/// Drops the shared client, as quitting the app does, for tests that model a +/// relaunch. +#[cfg(test)] +pub(crate) fn reset_shared_api_client() { + *SHARED_API_CLIENT + .lock() + .unwrap_or_else(|error| error.into_inner()) = None; +} + +/// The client `authed_request` uses. Calls close together, like Instant +/// presigns about once a second or the requests after a recording stops, +/// reuse its open connections instead of each paying a TCP and TLS handshake. +fn shared_api_client() -> Result { + let mut shared = SHARED_API_CLIENT + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some((built, client)) = shared.as_ref() + && built.elapsed().is_ok_and(|age| age < API_CLIENT_MAX_AGE) + { + return Ok(client.clone()); + } + let client = api_client_builder() + .pool_idle_timeout(API_POOL_IDLE_TIMEOUT) + .build() + .map_err(|error| AuthApiError::Other(error.to_string()))?; + *shared = Some((std::time::SystemTime::now(), client.clone())); + Ok(client) +} + pub async fn authed_request( method: reqwest::Method, path: &str, body: Option, ) -> Result { - authed_request_with(&api_client()?, method, path, body).await + authed_request_with(&shared_api_client()?, method, path, body).await } pub async fn authed_request_with( @@ -867,6 +921,12 @@ mod upload_context_tests { .unwrap(), "refreshed-credential" ); + assert_eq!( + context + .credentials_from("https://original.invalid:443", &refreshed) + .unwrap(), + "refreshed-credential" + ); assert!( context .credentials_from("https://changed.invalid", &refreshed) diff --git a/apps/desktop-gpui/src/camera_blur_portable.rs b/apps/desktop-gpui/src/camera_blur_portable.rs index 0247ef09a1d..d8e2ca620ec 100644 --- a/apps/desktop-gpui/src/camera_blur_portable.rs +++ b/apps/desktop-gpui/src/camera_blur_portable.rs @@ -47,6 +47,7 @@ pub struct PortableCameraBlur { device: wgpu::Device, queue: wgpu::Queue, processor: cap_camera_effects::BlurProcessor, + format: wgpu::TextureFormat, source: Option, readback: Option, dimensions: Option<(u32, u32)>, @@ -54,13 +55,32 @@ pub struct PortableCameraBlur { rgba: Vec, } +/// The source and output stay `Bgra8Unorm` where the adapter can sample, +/// render and copy it, so neither side of the GPU trip swaps channels on the +/// CPU. Sampling returns the same values for either channel order. +fn bgra_supported(adapter: &wgpu::Adapter) -> bool { + let features = adapter.get_texture_format_features(wgpu::TextureFormat::Bgra8Unorm); + features.allowed_usages.contains( + wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + ) && features + .flags + .contains(wgpu::TextureFormatFeatureFlags::FILTERABLE) +} + impl PortableCameraBlur { pub fn new() -> anyhow::Result { + Self::with_bgra(true) + } + + fn with_bgra(allow_bgra: bool) -> anyhow::Result { let runtime = tokio::runtime::Builder::new_current_thread() .enable_time() .build() .context("camera blur runtime")?; - let (device, queue) = runtime.block_on(async { + let (format, device, queue) = runtime.block_on(async { let instance = cap_rendering::create_wgpu_instance_sync(); let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { @@ -70,7 +90,12 @@ impl PortableCameraBlur { }) .await .map_err(|error| anyhow!("camera blur adapter: {error}"))?; - adapter + let format = if allow_bgra && bgra_supported(&adapter) { + wgpu::TextureFormat::Bgra8Unorm + } else { + wgpu::TextureFormat::Rgba8Unorm + }; + let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("Camera Preview Blur"), required_features: wgpu::Features::empty(), @@ -80,17 +105,22 @@ impl PortableCameraBlur { trace: wgpu::Trace::Off, }) .await - .map_err(|error| anyhow!("camera blur device: {error}")) + .map_err(|error| anyhow!("camera blur device: {error}"))?; + anyhow::Ok((format, device, queue)) })?; - let mut processor = - cap_camera_effects::BlurProcessor::new(&device, wgpu::TextureFormat::Rgba8Unorm) - .context("camera blur processor")?; + device.push_error_scope(wgpu::ErrorFilter::Validation); + let processor = cap_camera_effects::BlurProcessor::new(&device, format); + if let Some(error) = runtime.block_on(device.pop_error_scope()) { + anyhow::bail!("camera blur GPU validation: {error}"); + } + let mut processor = processor.context("camera blur processor")?; processor.set_inference_interval(INFERENCE_INTERVAL); Ok(Self { device, queue, processor, + format, source: None, readback: None, dimensions: None, @@ -130,7 +160,12 @@ impl PortableCameraBlur { } self.ensure_resources(width, height)?; - cap_gpui_kernels::frame::swap_red_blue_into(bgra, &mut self.rgba); + let pixels = if self.format == wgpu::TextureFormat::Bgra8Unorm { + bgra + } else { + cap_gpui_kernels::frame::swap_red_blue_into(bgra, &mut self.rgba); + self.rgba.as_slice() + }; let source = self.source.as_ref().context("camera blur source missing")?; self.queue.write_texture( @@ -140,7 +175,7 @@ impl PortableCameraBlur { origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - &self.rgba, + pixels, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row_bytes), @@ -207,12 +242,21 @@ impl PortableCameraBlur { let pixels = { let mapped = readback.slice(..).get_mapped_range(); - cap_gpui_kernels::frame::unpad_rgba_to_bgra( - &mapped, - width as usize, - self.padded_bytes_per_row as usize, - height as usize, - ) + if self.format == wgpu::TextureFormat::Bgra8Unorm { + cap_gpui_kernels::frame::unpad_rows( + &mapped, + width as usize * 4, + self.padded_bytes_per_row as usize, + height as usize, + ) + } else { + cap_gpui_kernels::frame::unpad_rgba_to_bgra( + &mapped, + width as usize, + self.padded_bytes_per_row as usize, + height as usize, + ) + } }; readback.unmap(); let pixels = pixels.context("camera blur readback rows")?; @@ -256,7 +300,7 @@ impl PortableCameraBlur { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format: self.format, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], })); @@ -294,6 +338,49 @@ mod tests { ); } + fn test_frame(width: u32, height: u32, seed: u32) -> gpui::RenderImage { + let image = image::RgbaImage::from_fn(width, height, |x, y| { + let inside = (x + seed * 7) % width < width / 3 && (y + seed * 3) % height < height / 2; + image::Rgba([ + (x * 255 / width) as u8, + (y * 255 / height) as u8, + if inside { 230 } else { (seed * 40) as u8 }, + 255, + ]) + }); + gpui::RenderImage::new(smallvec::smallvec![image::Frame::new(image)]) + } + + fn deterministic_blur(allow_bgra: bool) -> Option { + #[cfg(not(target_os = "macos"))] + if std::env::var_os("CAP_GPUI_TEST_PORTABLE_BLUR").is_none() { + return None; + } + match PortableCameraBlur::with_bgra(allow_bgra) { + Ok(mut blur) => { + blur.processor.set_frame_synchronous(true); + Some(blur) + } + Err(error) => { + eprintln!("skipping: portable camera blur unavailable: {error:#}"); + None + } + } + } + + #[test] + fn unsupported_gpu_returns_an_error_without_panicking() { + if std::env::var_os("CAP_GPUI_TEST_UNSUPPORTED_BLUR_GPU").is_none() { + return; + } + + cap_camera_effects::initialize_onnx_runtime().expect("ONNX Runtime available"); + let error = PortableCameraBlur::new() + .err() + .expect("unsupported GPU rejected"); + assert!(error.to_string().contains("camera blur GPU validation")); + } + #[test] fn portable_blur_processes_bgra_frames_without_changing_dimensions() { #[cfg(not(target_os = "macos"))] @@ -355,6 +442,46 @@ mod tests { assert_eq!(processor.readback.as_ref().unwrap().size(), 640 * 360 * 4); } + #[test] + fn portable_blur_bgra_textures_match_the_rgba_swap_path() { + let Some(mut rgba) = deterministic_blur(false) else { + return; + }; + let Some(mut bgra) = deterministic_blur(true) else { + return; + }; + assert_eq!(rgba.format, wgpu::TextureFormat::Rgba8Unorm); + if bgra.format != wgpu::TextureFormat::Bgra8Unorm { + eprintln!("skipping: the adapter cannot sample, render and copy Bgra8Unorm"); + return; + } + + for (dimensions, mode) in [ + ((640, 360), cap_camera_effects::BlurMode::Heavy), + ((480, 360), cap_camera_effects::BlurMode::Light), + ((64, 32), cap_camera_effects::BlurMode::Remove), + ] { + for seed in 0..4 { + let frame = test_frame(dimensions.0 as u32, dimensions.1 as u32, seed); + let (expected, expected_status) = rgba + .process_with_status(&frame, dimensions, mode) + .expect("rgba path blurred a frame"); + let (actual, actual_status) = bgra + .process_with_status(&frame, dimensions, mode) + .expect("bgra path blurred a frame"); + assert_eq!( + actual_status.output_dimensions, + expected_status.output_dimensions + ); + assert!( + actual.as_bytes(0) == expected.as_bytes(0), + "bgra output differs at {dimensions:?} {mode:?} frame {seed}" + ); + } + } + assert!(bgra.rgba.is_empty()); + } + #[cfg(target_os = "macos")] #[test] fn portable_blur_processes_real_webcam_frames_when_requested() { diff --git a/apps/desktop-gpui/src/camera_loading.rs b/apps/desktop-gpui/src/camera_loading.rs index d10aba2470a..644a4af4c2f 100644 --- a/apps/desktop-gpui/src/camera_loading.rs +++ b/apps/desktop-gpui/src/camera_loading.rs @@ -1,10 +1,12 @@ mod script_outline; -use std::time::Instant; +use std::cell::Cell; +use std::rc::Rc; +use std::time::{Duration, Instant}; use gpui::{ - Bounds, BoxShadow, ContentMask, Corners, Hsla, IntoElement, Pixels, Styled, Window, canvas, - fill, hsla, linear_color_stop, linear_gradient, point, px, size, + Bounds, BoxShadow, ContentMask, Corners, EntityId, Hsla, IntoElement, Pixels, Styled, Window, + canvas, fill, hsla, linear_color_stop, linear_gradient, point, px, size, }; use script_outline::LOADING; @@ -19,6 +21,8 @@ const SWEEP_PERIOD_SECONDS: f32 = 1.6; const SWEEP_HALF_WIDTH: f32 = 0.26; const SCRIPT_REST_ALPHA: f32 = 0.12; const SCRIPT_PEAK_ALPHA: f32 = 0.42; +const MOTION_FPS: f32 = 60.; +const DRIFT_FPS: f32 = 30.; struct Cloud { lightness: f32, @@ -82,6 +86,7 @@ pub(crate) fn camera_loading( opacity: f32, animate: bool, show_script: bool, + redraw: LoadingRedraw, ) -> impl IntoElement { canvas( |_, _, _| {}, @@ -116,7 +121,9 @@ pub(crate) fn camera_loading( } } if animate { - window.request_animation_frame(); + let due = Instant::now() + frame_gap(elapsed, opacity, show_script); + let generation = redraw.begin(); + redraw_when_due(window, window.current_view(), due, redraw, generation); } }, ) @@ -124,6 +131,56 @@ pub(crate) fn camera_loading( .inset_0() } +/// Fades and the script sweep redraw at 60 fps. Between sweeps only the +/// clouds move, under a pixel per frame at 30 fps. The gap is a fifth short of +/// the frame time so a display refresh landing just early still counts. +fn frame_gap(elapsed: f32, opacity: f32, show_script: bool) -> Duration { + let fps = if opacity < 1. || (show_script && sweep_center(elapsed).is_some()) { + MOTION_FPS + } else { + DRIFT_FPS + }; + Duration::from_secs_f32(0.8 / fps) +} + +/// Each paint supersedes the redraw scheduled by the one before it, so extra +/// paints (a resize, a chrome change) never leave several deadlines running. +#[derive(Clone, Default)] +pub(crate) struct LoadingRedraw(Rc>); + +impl LoadingRedraw { + fn begin(&self) -> u64 { + let generation = self.0.get().wrapping_add(1); + self.0.set(generation); + generation + } + + fn is_current(&self, generation: u64) -> bool { + self.0.get() == generation + } +} + +/// Checks on each display refresh and only re-renders the view once `due` +/// has passed, so redraws never outpace the platform's own frames. +fn redraw_when_due( + window: &Window, + view: EntityId, + due: Instant, + redraw: LoadingRedraw, + generation: u64, +) { + window.on_next_frame(move |window, cx| { + if !redraw.is_current(generation) { + return; + } + if Instant::now() >= due { + cx.notify(view); + } else { + redraw_when_due(window, view, due, redraw, generation); + } + }); +} + fn sweep_center(elapsed: f32) -> Option { if !elapsed.is_finite() { return None; @@ -317,3 +374,87 @@ fn paint_glass( ], ); } + +#[cfg(test)] +mod tests { + use super::*; + + fn paints_in(start: f32, seconds: f32, refresh_hz: f32, opacity: f32, script: bool) -> usize { + let refresh = 1. / refresh_hz; + let mut due = 0.; + let mut paints = 0; + let mut frame = (start / refresh).ceil(); + while frame * refresh < start + seconds { + let now = frame * refresh; + if now >= due { + paints += 1; + due = now + frame_gap(now, opacity, script).as_secs_f32(); + } + frame += 1.; + } + paints + } + + #[test] + fn loading_redraws_at_sixty_while_sweeping_and_thirty_between() { + let period_start = SWEEP_PERIOD_SECONDS - SWEEP_LEAD_SECONDS; + assert!(sweep_center(period_start + 0.01).is_some()); + assert!(sweep_center(period_start + SWEEP_SECONDS + 0.01).is_none()); + let resting_start = period_start + SWEEP_SECONDS; + let resting_seconds = SWEEP_PERIOD_SECONDS - SWEEP_SECONDS; + + for refresh_hz in [60., 120.] { + let sweeping = paints_in(period_start, SWEEP_SECONDS, refresh_hz, 1., true); + assert!((59..=61).contains(&sweeping), "{refresh_hz} Hz: {sweeping}"); + let resting = paints_in(resting_start, resting_seconds, refresh_hz, 1., true); + assert!((17..=19).contains(&resting), "{refresh_hz} Hz: {resting}"); + let no_script = paints_in(period_start, SWEEP_PERIOD_SECONDS, refresh_hz, 1., false); + assert!( + (47..=49).contains(&no_script), + "{refresh_hz} Hz: {no_script}" + ); + let fading = paints_in(0., FADE_IN_SECONDS, refresh_hz, 0.5, false); + assert!((17..=19).contains(&fading), "{refresh_hz} Hz: {fading}"); + } + let slow_display = paints_in(period_start, SWEEP_SECONDS, 20., 1., true); + assert!((19..=21).contains(&slow_display), "{slow_display}"); + } + + #[test] + fn extra_paints_leave_one_pending_redraw() { + let refresh = 1. / 120.; + let start = SWEEP_PERIOD_SECONDS - SWEEP_LEAD_SECONDS + SWEEP_SECONDS; + let end = start + SWEEP_PERIOD_SECONDS - SWEEP_SECONDS; + let redraw = LoadingRedraw::default(); + let mut pending: Vec<(u64, f32)> = Vec::new(); + let paint = |now: f32, pending: &mut Vec<(u64, f32)>| { + pending.push((redraw.begin(), now + frame_gap(now, 1., true).as_secs_f32())); + }; + paint(start, &mut pending); + paint(start + refresh, &mut pending); + + let mut paints = 0; + let mut frame = (start / refresh).ceil() + 2.; + while frame * refresh < end { + let now = frame * refresh; + let mut due_paints = 0; + pending.retain(|&(generation, due)| { + if !redraw.is_current(generation) { + return false; + } + if now >= due { + due_paints += 1; + return false; + } + true + }); + for _ in 0..due_paints { + paints += 1; + paint(now, &mut pending); + } + assert!(pending.len() <= 1, "{} pending at {now}", pending.len()); + frame += 1.; + } + assert!((17..=19).contains(&paints), "{paints}"); + } +} diff --git a/apps/desktop-gpui/src/camera_window.rs b/apps/desktop-gpui/src/camera_window.rs index 03026157143..d8e080e653e 100644 --- a/apps/desktop-gpui/src/camera_window.rs +++ b/apps/desktop-gpui/src/camera_window.rs @@ -388,6 +388,10 @@ mod frame { pub mirrored: bool, } + // Handed from the conversion thread to the main thread once written; the + // buffer is only read after that, and CF refcounts are atomic. + unsafe impl Send for Converted {} + /// Converts camera frames (typically `420v`) into BGRA IOSurface-backed /// pixel buffers that gpui paints directly via `paint_surface_fitted`. /// @@ -410,8 +414,10 @@ mod frame { /// neither touches the recorded camera track. /// /// The fixed rings keep a buffer alive while gpui's scene still references - /// the previous one; four slots at camera rates leaves ~100ms before a - /// slot is overwritten. + /// the previous one. Conversion runs on the `camera-preview-convert` + /// thread, so besides the painted frame and the previous scene's, one + /// converted frame can wait for the main thread, one can sit with the blur + /// worker, and one is being written: six slots cover all of them. pub struct FrameConverter { session: arc::R, /// `None` when unmirrored, or when the rotation session could not be @@ -426,10 +432,10 @@ mod frame { generation: u64, } - // Only touched from the main thread; the CF objects have atomic refcounts. + // Owned by the conversion thread; the CF objects have atomic refcounts. unsafe impl Send for FrameConverter {} - const RING_SIZE: usize = 4; + const RING_SIZE: usize = 6; fn bgra_buffers( count: usize, @@ -514,21 +520,26 @@ mod frame { /// The frame as a BGRA IOSurface-backed pixel buffer, downscaled to /// fit `max_dims` when given and horizontally flipped when /// `mirrored`. Recreates the rings when the camera's frame size, the - /// target size or the mirror toggle changes. + /// target size or the mirror toggle changes, or when `force_rebuild` + /// asks for a fresh rotation session (keeping the generation count + /// rising so no earlier ring can alias a new one). pub fn convert( this: &mut Option, frame: &cap_recording::NativeCameraFrame, max_dims: Option<(usize, usize)>, mirrored: bool, + force_rebuild: bool, ) -> Option { let src = frame.sample_buf.image_buf()?; let src_dims = (src.width(), src.height()); let dst_dims = max_dims.map_or(src_dims, |max| fit_within(src_dims, max)); - if this.as_ref().is_none_or(|converter| { - converter.src_dims != src_dims - || converter.dst_dims != dst_dims - || converter.mirrored != mirrored - }) { + if force_rebuild + || this.as_ref().is_none_or(|converter| { + converter.src_dims != src_dims + || converter.dst_dims != dst_dims + || converter.mirrored != mirrored + }) + { let generation = this.as_ref().map_or(0, |c| c.generation + 1); *this = Self::new(src_dims, dst_dims, mirrored, generation); } @@ -1001,6 +1012,7 @@ struct CameraPreviewView { reveal_started: Option, loading_started: Option, loading_fade_started: Option, + loading_redraw: camera_loading::LoadingRedraw, retained_invalidated: bool, selection_revision: u64, frame_revision: Option, @@ -1107,6 +1119,7 @@ impl CameraPreviewView { reveal_started: None, loading_started: None, loading_fade_started: None, + loading_redraw: camera_loading::LoadingRedraw::default(), retained_invalidated: false, selection_revision: 0, frame_revision: None, @@ -1327,7 +1340,12 @@ impl Render for CameraPreviewView { if let Some((started, opacity, animate)) = loading { container = container.child(camera_loading::camera_loading( - started, radius, opacity, animate, !has_issue, + started, + radius, + opacity, + animate, + !has_issue, + self.loading_redraw.clone(), )); } @@ -1435,6 +1453,64 @@ impl Render for CameraToolbarView { } } +#[cfg(target_os = "macos")] +struct ConvertJob { + frame: cap_recording::NativeCameraFrame, + settings: ConvertSettings, +} + +/// What a frame was converted for. A result whose settings no longer match +/// the window's (another camera selected, blur or mirroring toggled while it +/// converted) is dropped; the next frame arrives within a frame interval. +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, PartialEq)] +struct ConvertSettings { + epoch: u64, + blur_mode: Option, + mirrored: bool, + rebuilds: u64, +} + +/// The `camera-preview-convert` thread. The VideoToolbox transfer (and the +/// mirror pass) blocks until the hardware finishes, ~4-5 ms per frame, which +/// stalled every main-thread frame while it ran on the main thread. +#[cfg(target_os = "macos")] +struct ConvertBridge { + tx: flume::Sender, + _pump: gpui::Task<()>, +} + +#[cfg(target_os = "macos")] +fn run_preview_conversion( + jobs: flume::Receiver, + converted: flume::Sender<(Option, ConvertSettings)>, +) { + let mut converter = None; + let mut rebuilds = None; + while let Ok(mut job) = jobs.recv() { + // Latest wins: a frame that queued behind a slow conversion is stale. + while let Ok(newer) = jobs.try_recv() { + job = newer; + } + let settings = job.settings; + let force_rebuild = rebuilds.is_some_and(|rebuilds| rebuilds != settings.rebuilds); + rebuilds = Some(settings.rebuilds); + let max_dims = settings.blur_mode.map(|_| camera_blur::BLUR_MAX_DIMS); + let result = frame::FrameConverter::convert( + &mut converter, + &job.frame, + max_dims, + settings.mirrored, + force_rebuild, + ); + // Blocks while the main thread still holds the previous result, so the + // ring never gets more than one frame ahead of what it painted. + if converted.send((result, settings)).is_err() { + return; + } + } +} + /// Channels to and from the `camera-blur` worker thread, plus the foreground /// pump that paints its outputs. Dropping this closes the job channel, which /// ends the thread and releases the ONNX/GPU resources -- the @@ -1460,7 +1536,16 @@ pub struct CameraWindow { resizing: Option, hovered_handle: Option, #[cfg(target_os = "macos")] - converter: Option, + convert: Option, + /// Bumped when the mirror toggle changes, so the conversion thread + /// rebuilds its rings and retries a rotation session that failed before. + #[cfg(target_os = "macos")] + convert_rebuilds: u64, + /// Inline conversion when the thread could not be spawned. + #[cfg(target_os = "macos")] + fallback_converter: Option, + #[cfg(target_os = "macos")] + fallback_rebuilds: Option, #[cfg(target_os = "macos")] blur: Option, #[cfg(target_os = "macos")] @@ -1677,7 +1762,13 @@ impl CameraWindow { resizing: None, hovered_handle: None, #[cfg(target_os = "macos")] - converter: None, + convert: None, + #[cfg(target_os = "macos")] + convert_rebuilds: 0, + #[cfg(target_os = "macos")] + fallback_converter: None, + #[cfg(target_os = "macos")] + fallback_rebuilds: None, #[cfg(target_os = "macos")] blur: None, #[cfg(target_os = "macos")] @@ -1761,77 +1852,38 @@ impl CameraWindow { let Some(epoch) = Feeds::global(cx).read(cx).camera_preview_epoch() else { return; }; - use core_foundation::base::TCFType as _; - use core_video::pixel_buffer::{CVPixelBuffer, CVPixelBufferRef}; - let failures_before = self.effect_failures; if self.state.background_blur != BlurMode::Off && camera_blur::is_low_spec_preview() { self.effect_failures.blur = true; } - let blur_mode = self.active_blur_mode(); - let max_dims = blur_mode.is_some().then_some(camera_blur::BLUR_MAX_DIMS); - if let Some(converted) = frame::FrameConverter::convert( - &mut self.converter, - &frame, - max_dims, - self.state.mirrored, - ) { - self.effect_failures.mirror = self.state.mirrored && !converted.mirrored; - let first_frame = self.frame_dims.is_none(); - let dims = converted.dims; - let dims_changed = self.frame_dims != Some(dims); - self.frame_dims = Some(dims); - - let mut paint_raw = blur_mode.is_none(); - if let Some(mode) = blur_mode { - let job = camera_blur::BlurJob { - buffer: camera_blur::SendPixelBuf(converted.buffer.clone()), - width: dims.0 as u32, - height: dims.1 as u32, - ring_generation: converted.generation, - mode, - }; - match self.ensure_blur_bridge(epoch, window, cx).tx.try_send(job) { - Ok(()) => {} - // Worker busy: drop this frame and keep the last - // painted one -- the bounded(1) latest-wins shape of - // the Tauri preview's `camera_tx` (`camera.rs:424`). - Err(flume::TrySendError::Full(_)) => {} - Err(flume::TrySendError::Disconnected(_)) => { - // The worker could not come up (no device, no - // ONNX runtime, low-level failure). Same degrade - // as `ensure_blur_processor` returning false: - // raw frames, latched until the mode changes. - tracing::warn!( - "camera blur worker unavailable; preview continues unblurred" - ); - self.blur = None; - self.effect_failures.blur = true; - paint_raw = true; - } - } - } - - if paint_raw { - let raw = - converted.buffer.as_ref() as *const cidre::cv::PixelBuf as CVPixelBufferRef; - let buffer = unsafe { CVPixelBuffer::wrap_under_get_rule(raw) }; - self.preview - .update(cx, |preview, cx| preview.set_frame(buffer, dims, cx)); - } - if dims_changed { - self.apply_window_size(window, cx); - cx.notify(); - } - if first_frame && !window.is_window_active() { - window.refresh(); - } - } else if self.frame_dims.is_none() && self.frames_in_window == 0 { - tracing::warn!("camera frame could not be converted for preview"); - } if failures_before != self.effect_failures { self.sync_effect_feedback(cx); } + let job = ConvertJob { + frame, + settings: self.convert_settings(epoch), + }; + match self.ensure_convert_bridge(window, cx).tx.try_send(job) { + // A full queue means the thread is mid-conversion; it picks up + // the next frame. + Ok(()) | Err(flume::TrySendError::Full(_)) => {} + Err(flume::TrySendError::Disconnected(job)) => { + let settings = job.settings; + let max_dims = settings.blur_mode.map(|_| camera_blur::BLUR_MAX_DIMS); + let force_rebuild = self + .fallback_rebuilds + .is_some_and(|rebuilds| rebuilds != settings.rebuilds); + self.fallback_rebuilds = Some(settings.rebuilds); + let converted = frame::FrameConverter::convert( + &mut self.fallback_converter, + &job.frame, + max_dims, + settings.mirrored, + force_rebuild, + ); + self.converted_arrived(converted, settings, window, cx); + } + } } #[cfg(not(target_os = "macos"))] { @@ -1899,6 +1951,128 @@ impl CameraWindow { } } + #[cfg(target_os = "macos")] + fn convert_settings(&self, epoch: u64) -> ConvertSettings { + ConvertSettings { + epoch, + blur_mode: self.active_blur_mode(), + mirrored: self.state.mirrored, + rebuilds: self.convert_rebuilds, + } + } + + #[cfg(target_os = "macos")] + fn converted_arrived( + &mut self, + converted: Option, + settings: ConvertSettings, + window: &mut Window, + cx: &mut Context, + ) { + let Some(epoch) = Feeds::global(cx).read(cx).camera_preview_epoch() else { + return; + }; + if settings != self.convert_settings(epoch) { + return; + } + let blur_mode = settings.blur_mode; + use core_foundation::base::TCFType as _; + use core_video::pixel_buffer::{CVPixelBuffer, CVPixelBufferRef}; + + let failures_before = self.effect_failures; + if let Some(converted) = converted { + self.effect_failures.mirror = self.state.mirrored && !converted.mirrored; + let first_frame = self.frame_dims.is_none(); + let dims = converted.dims; + let dims_changed = self.frame_dims != Some(dims); + self.frame_dims = Some(dims); + + let mut paint_raw = blur_mode.is_none(); + if let Some(mode) = blur_mode { + let job = camera_blur::BlurJob { + buffer: camera_blur::SendPixelBuf(converted.buffer.clone()), + width: dims.0 as u32, + height: dims.1 as u32, + ring_generation: converted.generation, + mode, + }; + match self.ensure_blur_bridge(epoch, window, cx).tx.try_send(job) { + Ok(()) => {} + // Worker busy: drop this frame and keep the last + // painted one -- the bounded(1) latest-wins shape of + // the Tauri preview's `camera_tx` (`camera.rs:424`). + Err(flume::TrySendError::Full(_)) => {} + Err(flume::TrySendError::Disconnected(_)) => { + // The worker could not come up (no device, no + // ONNX runtime, low-level failure). Same degrade + // as `ensure_blur_processor` returning false: + // raw frames, latched until the mode changes. + tracing::warn!( + "camera blur worker unavailable; preview continues unblurred" + ); + self.blur = None; + self.effect_failures.blur = true; + paint_raw = true; + } + } + } + + if paint_raw { + let raw = + converted.buffer.as_ref() as *const cidre::cv::PixelBuf as CVPixelBufferRef; + let buffer = unsafe { CVPixelBuffer::wrap_under_get_rule(raw) }; + self.preview + .update(cx, |preview, cx| preview.set_frame(buffer, dims, cx)); + } + if dims_changed { + self.apply_window_size(window, cx); + cx.notify(); + } + if first_frame && !window.is_window_active() { + window.refresh(); + } + } else if self.frame_dims.is_none() && self.frames_in_window == 0 { + tracing::warn!("camera frame could not be converted for preview"); + } + if failures_before != self.effect_failures { + self.sync_effect_feedback(cx); + } + } + + #[cfg(target_os = "macos")] + fn ensure_convert_bridge(&mut self, window: &Window, cx: &mut Context) -> &ConvertBridge { + if self.convert.is_none() { + let (job_tx, job_rx) = flume::bounded::(1); + let (out_tx, out_rx) = flume::bounded(1); + if let Err(error) = std::thread::Builder::new() + .name("camera-preview-convert".into()) + .stack_size(crate::MEDIA_THREAD_STACK_SIZE) + .spawn(move || run_preview_conversion(job_rx, out_tx)) + { + // job_rx is gone; the caller's try_send sees Disconnected and + // converts inline. + tracing::warn!("camera preview conversion thread failed to spawn: {error}"); + } + let pump = cx.spawn_in(window, async move |this, cx| { + while let Ok((converted, settings)) = out_rx.recv_async().await { + if this + .update_in(cx, |this: &mut CameraWindow, window, cx| { + this.converted_arrived(converted, settings, window, cx) + }) + .is_err() + { + break; + } + } + }); + self.convert = Some(ConvertBridge { + tx: job_tx, + _pump: pump, + }); + } + self.convert.as_ref().expect("just ensured") + } + #[cfg(target_os = "macos")] fn ensure_blur_bridge( &mut self, @@ -2027,7 +2201,7 @@ impl CameraWindow { { self.effect_failures.reset_changed(before, self.state); if before.mirrored != self.state.mirrored { - self.converter = None; + self.convert_rebuilds += 1; } self.sync_effect_feedback(cx); if self.state.background_blur == BlurMode::Off { diff --git a/apps/desktop-gpui/src/controls_window.rs b/apps/desktop-gpui/src/controls_window.rs index 4636cf7e03b..b32e9898567 100644 --- a/apps/desktop-gpui/src/controls_window.rs +++ b/apps/desktop-gpui/src/controls_window.rs @@ -10,13 +10,13 @@ use std::time::Duration; use gpui::{ - Context, Entity, FontWeight, InteractiveElement as _, IntoElement, MouseButton, - ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled, Window, div, - prelude::FluentBuilder as _, px, svg, + AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, IntoElement, + MouseButton, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled, + Window, div, prelude::FluentBuilder as _, px, svg, }; use crate::{ - feeds::{self, Feeds}, + feeds::Feeds, recording::RecordingMode, session::{Phase, RecordingSession}, theme::Theme, @@ -33,9 +33,7 @@ pub struct ControlsWindow { /// platform, so the tick both notifies and asks for a frame explicitly. _tick: gpui::Task<()>, ticking_live: bool, - /// The mic level bar's fill in device pixels as last drawn, `None` while - /// the bar is hidden. - drawn_level_px: std::cell::Cell>, + level_bar: Entity, } #[derive(Clone, Copy)] @@ -71,6 +69,15 @@ impl ControlsWindow { cx.notify(); }) .detach(); + let mic_level = Feeds::global(cx).read(cx).mic_level().clone(); + let level_bar = cx.new(|cx| { + ui::MicrophoneLevel::new( + ui::MicrophoneLevelKind::RecordingBar, + &mic_level, + window, + cx, + ) + }); let mut this = Self { session, @@ -80,7 +87,7 @@ impl ControlsWindow { confirmation_pending: false, _tick: gpui::Task::ready(()), ticking_live: false, - drawn_level_px: std::cell::Cell::new(None), + level_bar, }; this.ticking_live = this.timer_is_live(cx); this._tick = Self::spawn_tick(window, cx); @@ -121,13 +128,6 @@ impl ControlsWindow { }) } - /// Whether a new mic level would move the bar by at least a device pixel. - pub fn level_bar_moves(&self, db: f64, scale: f32) -> bool { - self.drawn_level_px - .get() - .is_some_and(|drawn| drawn != level_bar_px(db, scale)) - } - /// `formatTime`: `M:SS`, hours folded into minutes the same way. fn format_elapsed(elapsed: Duration) -> String { let total = elapsed.as_secs(); @@ -231,17 +231,21 @@ impl ControlsWindow { /// gray-10 track). In instant mode with a live mic it doubles as the mute /// button -- studio deliberately does not expose mute, since the mic is an /// editable track and muted spans would bake zeros in. - fn render_microphone(&self, scale: f32, cx: &mut Context) -> impl IntoElement { + fn render_microphone(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let session = self.session.read(cx); let muted = session.mic_muted; let can_mute = session.mode() == Some(RecordingMode::Instant) && session.has_microphone() && matches!(session.phase, Phase::Recording { .. }); - let level_db = Feeds::global(cx).read(cx).mic_level_db; - let level = feeds::bar_level(level_db); - self.drawn_level_px - .set((self.has_microphone && !muted).then(|| level_bar_px(level_db, scale))); + self.level_bar.update(cx, |bar, cx| { + bar.configure( + self.has_microphone && !muted, + theme.blue_9.into(), + theme.gray_10.into(), + cx, + ); + }); let icon = if !self.has_microphone || muted { "icons/mic-off.svg" @@ -277,26 +281,7 @@ impl ControlsWindow { }) .child(svg().path(icon).size(px(20.)).text_color(icon_color)) .when(self.has_microphone && !muted, |this| { - this.child( - div() - .absolute() - .bottom(px(4.)) - .left(px(4.)) - .right(px(4.)) - .h(px(2.)) - .rounded_full() - .overflow_hidden() - .bg(theme.gray_10) - .child( - div() - .absolute() - .left_0() - .top_0() - .bottom_0() - .w(gpui::relative(level as f32)) - .bg(theme.blue_9), - ), - ) + this.child(self.level_bar.clone()) }) } @@ -376,7 +361,7 @@ impl ControlsWindow { .detach(); } - fn render_bar(&mut self, scale: f32, cx: &mut Context) -> impl IntoElement { + fn render_bar(&mut self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let session = self.session.read(cx); let paused = session.is_paused(); @@ -434,7 +419,7 @@ impl ControlsWindow { .items_center() .gap(px(2.)) .flex_shrink_0() - .child(self.render_microphone(scale, cx)) + .child(self.render_microphone(cx)) .child( self.action_button( "pause", @@ -559,7 +544,7 @@ impl Render for ControlsWindow { .child(recording_issue_text(issue)), ) }) - .child(self.render_bar(window.scale_factor(), cx)) + .child(self.render_bar(cx)) } } @@ -582,11 +567,6 @@ fn recording_issue_text(error: String) -> impl IntoElement + Styled { }) } -/// The bar's track is 20pt wide. -fn level_bar_px(db: f64, scale: f32) -> u32 { - (feeds::bar_level(db) * 20.0 * f64::from(scale)).round() as u32 -} - fn tick_delay(live: bool, elapsed: Duration) -> Duration { if live { until_next_second(elapsed) @@ -629,15 +609,4 @@ mod repaint_tests { Duration::from_secs(1) ); } - - #[test] - fn level_changes_under_a_device_pixel_do_not_move_the_bar() { - assert_eq!(level_bar_px(-30.0, 2.0), level_bar_px(-30.5, 2.0)); - assert_ne!(level_bar_px(-30.0, 2.0), level_bar_px(-32.0, 2.0)); - assert_eq!(level_bar_px(-30.0, 1.0), level_bar_px(-31.0, 1.0)); - assert_ne!(level_bar_px(-30.0, 1.0), level_bar_px(-33.0, 1.0)); - assert_eq!(level_bar_px(-96.0, 2.0), 0); - assert_eq!(level_bar_px(0.0, 1.0), 20); - assert_eq!(level_bar_px(0.0, 2.0), 40); - } } diff --git a/apps/desktop-gpui/src/devices.rs b/apps/desktop-gpui/src/devices.rs index 6f6995139f6..8489d636f74 100644 --- a/apps/desktop-gpui/src/devices.rs +++ b/apps/desktop-gpui/src/devices.rs @@ -124,6 +124,24 @@ impl WindowOption { (None, None) => None, } } + + /// Whether two entries draw the same card. `position` only feeds the + /// thumbnail signature, so a window that moved still draws the same card. + pub fn same_card(&self, other: &Self) -> bool { + let Self { + id, + label, + app, + size, + position: _, + refresh_rate, + } = self; + *id == other.id + && *label == other.label + && *app == other.app + && *size == other.size + && *refresh_rate == other.refresh_rate + } } #[derive(Debug, Clone, Default, PartialEq)] @@ -135,6 +153,28 @@ pub struct DeviceSnapshot { } impl DeviceSnapshot { + /// Store a fresh display list. Returns whether any card changed. + pub fn install_displays(&mut self, displays: Vec) -> bool { + if self.displays == displays { + return false; + } + self.displays = displays; + true + } + + /// Store a fresh window list. Returns whether any card changed. The list is + /// stored either way so the thumbnail signature sees moved windows. + pub fn install_windows(&mut self, windows: Vec) -> bool { + let changed = self.windows.len() != windows.len() + || self + .windows + .iter() + .zip(&windows) + .any(|(current, next)| !current.same_card(next)); + self.windows = windows; + changed + } + pub fn update_camera_formats( &mut self, queried: &CameraOption, @@ -231,6 +271,122 @@ impl InputSnapshot { } } +#[cfg(test)] +mod target_list_tests { + use super::*; + use std::str::FromStr; + + fn window(id: &str, label: &str) -> WindowOption { + WindowOption { + id: WindowId::from_str(id).unwrap(), + label: label.into(), + app: "Cap".into(), + size: Some((1920, 1080)), + position: Some((0., 0.)), + refresh_rate: Some(60.), + } + } + + fn display(id: &str, label: &str) -> DisplayOption { + DisplayOption { + id: DisplayId::from_str(id).unwrap(), + label: label.into(), + refresh_rate: 60., + } + } + + #[test] + fn unchanged_display_list_does_not_repaint() { + let list = vec![display("1", "Built-in"), display("2", "Studio Display")]; + let mut snapshot = DeviceSnapshot::default(); + assert!(snapshot.install_displays(list.clone())); + assert!(!snapshot.install_displays(list.clone())); + assert_eq!(snapshot.displays, list); + } + + #[test] + fn display_changes_repaint() { + let list = vec![display("1", "Built-in"), display("2", "Studio Display")]; + let mut renamed = list.clone(); + renamed[1].label = "Sidecar".into(); + let mut slower = list.clone(); + slower[0].refresh_rate = 30.; + let reordered = vec![list[1].clone(), list[0].clone()]; + for next in [ + renamed, + slower, + reordered, + vec![list[0].clone()], + Vec::new(), + ] { + let mut snapshot = DeviceSnapshot { + displays: list.clone(), + ..Default::default() + }; + assert!(snapshot.install_displays(next.clone())); + assert_eq!(snapshot.displays, next); + } + } + + #[test] + fn unchanged_window_list_does_not_repaint() { + let list = vec![window("1", "Main"), window("2", "Editor")]; + let mut snapshot = DeviceSnapshot::default(); + assert!(snapshot.install_windows(list.clone())); + assert!(!snapshot.install_windows(list.clone())); + assert_eq!(snapshot.windows, list); + } + + #[test] + fn moved_window_is_stored_without_a_repaint() { + let list = vec![window("1", "Main"), window("2", "Editor")]; + let mut moved = list.clone(); + moved[1].position = Some((240., 96.)); + let mut snapshot = DeviceSnapshot { + windows: list, + ..Default::default() + }; + assert!(!snapshot.install_windows(moved.clone())); + assert_eq!(snapshot.windows, moved); + } + + #[test] + fn window_card_changes_repaint() { + let list = vec![window("1", "Main"), window("2", "Editor")]; + let mut renamed = list.clone(); + renamed[0].label = "Untitled".into(); + let mut other_app = list.clone(); + other_app[1].app = "Finder".into(); + let mut resized = list.clone(); + resized[0].size = Some((1280, 720)); + let mut sizeless = list.clone(); + sizeless[0].size = None; + let mut slower = list.clone(); + slower[1].refresh_rate = Some(30.); + let reordered = vec![list[1].clone(), list[0].clone()]; + let mut opened = list.clone(); + opened.push(window("3", "Settings")); + for next in [ + renamed, + other_app, + resized, + sizeless, + slower, + reordered, + opened, + vec![list[0].clone()], + Vec::new(), + ] { + let mut snapshot = DeviceSnapshot { + windows: list.clone(), + ..Default::default() + }; + assert!(snapshot.install_windows(next.clone())); + assert_eq!(snapshot.windows, next); + } + } +} + #[cfg(test)] mod input_enumeration_tests { use super::*; @@ -712,16 +868,6 @@ pub fn list_display_targets() -> Vec<(DisplayOption, Display)> { .collect() } -/// Mirrors the picker path of `cap_recording::sources::screen_capture::list_windows` -/// (i.e. `include_accessory_panels: false`), which collapses to: a non-empty -/// title, not owned by the Window Server, and at window level 0. -/// -/// The level check is what actually matters. A raw `Window::list()` on macOS is -/// dominated by menu-bar extras and status items — Control Centre alone -/// contributes a dozen `Item-0` windows at level 25 — so without it the picker -/// is unusable. This is deliberately duplicated rather than imported: -/// `cap-recording` drags in ffmpeg and the whole encode stack, which this app -/// has no other reason to build. pub fn list_windows() -> Vec { list_window_targets() .into_iter() @@ -754,17 +900,27 @@ pub fn list_window_targets() -> Vec<(WindowOption, Window)> { // (`app_windows::begin_recording`), so offering one as a target // records a hole. Matched by owner pid rather than title or // bundle id -- the dev binary is unbundled, and most of our - // windows carry no CG title at all. (The shared - // `screen_capture::list_windows` has no such filter; the Tauri - // main window only escapes it by being a level>0 panel.) + // windows carry no CG title at all. #[cfg(target_os = "macos")] if window.raw_handle().owner_pid() == Some(std::process::id() as i32) { return None; } #[cfg(target_os = "macos")] - if window.raw_handle().level() != Some(0) { - return None; + { + let level = window.raw_handle().level(); + let bundle_identifier = window.raw_handle().bundle_identifier(); + let is_accessory_application = matches!(level, Some(level) if level > 0) + && window.raw_handle().is_accessory_application(); + if !cap_recording::sources::screen_capture::is_listable_macos_window( + level, + &app, + bundle_identifier.as_deref(), + false, + is_accessory_application, + ) { + return None; + } } Some(( diff --git a/apps/desktop-gpui/src/editor_canvas.rs b/apps/desktop-gpui/src/editor_canvas.rs index 14618f7bea7..e39582dffa6 100644 --- a/apps/desktop-gpui/src/editor_canvas.rs +++ b/apps/desktop-gpui/src/editor_canvas.rs @@ -305,12 +305,10 @@ fn overlay_drag_center( } else { snap_moving_rect(raw, targets, 7. / canvas.0.max(1.), 7. / canvas.1.max(1.)) }; - let x = (raw.x + dx).clamp(0., 1.); - let y = (raw.y + dy).clamp(0., 1.); ( XY { - x: (x + raw.w / 2.).clamp(0., 1.), - y: (y + raw.h / 2.).clamp(0., 1.), + x: (raw.x + dx + raw.w / 2.).clamp(0., 1.), + y: (raw.y + dy + raw.h / 2.).clamp(0., 1.), }, guides, ) @@ -2003,6 +2001,59 @@ mod tests { assert!(guides.is_empty()); } + #[test] + fn overlay_drag_preserves_pointer_motion_near_top_and_left_edges() { + let rect = NormRect { + x: -0.1, + y: -0.05, + w: 0.4, + h: 0.3, + }; + for canvas in [(800., 450.), (450., 800.), (500., 500.)] { + let (center, guides) = overlay_drag_center( + rect, + canvas, + (canvas.0 * 0.02, canvas.1 * 0.03), + &SnapTargets::default(), + true, + ); + assert!((center.x - 0.12).abs() < 1e-9, "{center:?}"); + assert!((center.y - 0.13).abs() < 1e-9, "{center:?}"); + assert!(guides.is_empty()); + } + } + + #[test] + fn overlay_drag_clamps_the_center_at_every_frame_edge() { + let rect = NormRect { + x: 0.3, + y: 0.35, + w: 0.4, + h: 0.3, + }; + for (delta, expected) in [((-900., -1600.), 0.), ((900., 1600.), 1.)] { + let (center, _) = + overlay_drag_center(rect, (450., 800.), delta, &SnapTargets::default(), true); + assert!((center.x - expected).abs() < 1e-9, "{center:?}"); + assert!((center.y - expected).abs() < 1e-9, "{center:?}"); + } + } + + #[test] + fn overlay_drag_applies_snapping_while_overhanging_the_frame() { + let rect = NormRect { + x: -0.09, + y: -0.1, + w: 0.2, + h: 0.4, + }; + let targets = build_snap_targets(&[], None); + let (center, guides) = overlay_drag_center(rect, (450., 800.), (0., 0.), &targets, false); + assert!(center.x.abs() < 1e-9, "{center:?}"); + assert!((center.y - 0.1).abs() < 1e-9, "{center:?}"); + assert!(guides.iter().any(|guide| guide.axis == GuideAxis::V)); + } + #[test] fn the_display_clamp_lets_half_the_card_hang_out() { let rect = NormRect { diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 51c4bfa521d..81cc72e22bc 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -1932,6 +1932,7 @@ impl PreparedMp4Import { start_time: Some(0.0), device_id: None, gap_summary: None, + digital_silence_ms: None, }); Ok(Self { target, diff --git a/apps/desktop-gpui/src/editor_crop.rs b/apps/desktop-gpui/src/editor_crop.rs index e9bfd7b8288..ec1f860dad1 100644 --- a/apps/desktop-gpui/src/editor_crop.rs +++ b/apps/desktop-gpui/src/editor_crop.rs @@ -1081,6 +1081,15 @@ pub struct CropState { } impl CropState { + /// The display frame is a full-resolution decode and the window's atlas + /// keeps every painted image until it is told otherwise, so a closed crop + /// would otherwise stay resident until the editor window closes. + pub fn release_frame(&mut self, window: &mut Window) { + if let Some(frame) = self.frame.take() { + let _ = window.drop_image(frame); + } + } + /// `computeInitialBounds` (`:483-517`) at open, with the container the /// viewport gives. pub fn new(target: (u32, u32), box_size: (f32, f32), initial: CropBounds) -> Self { @@ -1256,7 +1265,12 @@ impl CropState { CropField::Width => real.width = value, CropField::Height => real.height = value, } - let raw = bounds_to_raw(real, self.scale()); + let mut raw = bounds_to_raw(real, self.scale()); + if let CropField::Height = field + && let Some(ratio) = self.ratio_value() + { + raw.width = raw.height * ratio; + } self.set_raw_constraining(raw, ORIGIN_TOP_LEFT); } @@ -1606,9 +1620,10 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { - if self.crop.take().is_none() { + let Some(mut state) = self.crop.take() else { return; - } + }; + state.release_frame(window); tracing::info!(reason, "crop cancelled"); self.publish_project(); cx.notify(); @@ -1630,6 +1645,7 @@ impl EditorWindow { cx.notify(); return; } + state.release_frame(window); let bounds = state.real(); let crop = Crop { position: XY::new(bounds.x.max(0.) as u32, bounds.y.max(0.) as u32), @@ -2154,12 +2170,16 @@ impl EditorWindow { Some(task) => task.await.ok().flatten(), None => None, }; - this.update(cx, |this: &mut Self, cx| { + this.update_in(cx, |this: &mut Self, window, cx| { let Some(state) = this.crop.as_mut() else { return; }; match image { - Some(image) => state.frame = Some(image), + Some(image) => { + if let Some(previous) = state.frame.replace(image) { + let _ = window.drop_image(previous); + } + } None => { tracing::warn!("crop: display frame decode failed"); state.frame_failed = true; @@ -3910,6 +3930,56 @@ mod tests { assert_eq!(real.height, 1964.); } + #[test] + fn locked_size_fields_resize_from_the_edited_dimension() { + for (ratio, width, height) in [((16, 9), 640., 360.), ((9, 16), 360., 640.)] { + let mut state = CropState::new( + (1920, 1080), + (482., 272.), + CropBounds::new(160., 80., 960., 540.), + ); + state.set_aspect(Some(ratio)); + state.stop_animation(); + let before = state.real(); + + state.set_property(CropField::Height, height); + assert_eq!( + state.real(), + CropBounds::new(before.x, before.y, width, height) + ); + + state.set_property(CropField::Width, width / 2.); + assert_eq!( + state.real(), + CropBounds::new(before.x, before.y, width / 2., height / 2.) + ); + } + } + + #[test] + fn locked_height_edits_still_fit_inside_the_source() { + let mut state = CropState::new( + (1920, 1080), + (482., 272.), + CropBounds::new(160., 80., 960., 540.), + ); + state.set_aspect(Some((16, 9))); + state.stop_animation(); + state.set_property(CropField::Height, 2160.); + assert_eq!(state.real(), CropBounds::new(0., 0., 1920., 1080.)); + } + + #[test] + fn free_height_edits_preserve_width_and_position() { + let mut state = CropState::new( + (1920, 1080), + (482., 272.), + CropBounds::new(160., 80., 960., 540.), + ); + state.set_property(CropField::Height, 360.); + assert_eq!(state.real(), CropBounds::new(160., 80., 960., 360.)); + } + #[test] fn fill_and_reset_walk_between_the_whole_frame_and_the_opening_crop() { let initial = CropBounds::new(600., 400., 1200., 800.); diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index f2df27fdd60..8206e376b85 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -198,6 +198,13 @@ impl ProjectHistory { pub fn resume(&mut self, config: &ProjectConfiguration) { self.paused = self.paused.saturating_sub(1); if self.paused == 0 && std::mem::take(&mut self.dirty) { + let current = &self.entries[self.entries.len() - self.count - 1]; + if matches!( + (serde_json::to_value(current), serde_json::to_value(config)), + (Ok(before), Ok(after)) if before == after + ) { + return; + } self.record(config); } } @@ -437,7 +444,6 @@ pub fn trim_start_bounds( } /// `SegmentHandle position="end"`'s setup (`TL/MaskTrack.tsx:499-508`): -/// `minValue = start + minDuration`, `maxValue = max(minValue, nextStart)`. pub fn trim_end_bounds( segments: &[Segment], lane: u32, @@ -446,11 +452,11 @@ pub fn trim_end_bounds( total: f64, ) -> DragBounds { let (_, next_start) = neighbours(segments, lane, index, total); - let min = segments[index].start + min_duration; - DragBounds { - min, - max: min.max(next_start), - } + let max = next_start.max(segments[index].end); + let min = (segments[index].start + min_duration) + .min(segments[index].end) + .min(max); + DragBounds { min, max } } pub fn audio_trim_start_bounds(segment: &AudioTrackSegment, bounds: DragBounds) -> DragBounds { @@ -514,7 +520,7 @@ pub trait TrackSegmentOps: Clone { /// time, end: segment.end}`. fn split_tail(&self, at: f64) -> Self { let mut tail = self.clone(); - tail.set_start(self.start() + at); + tail.trim_start_to(self.start() + at); tail } @@ -586,7 +592,39 @@ pub fn fit_camera3d_motion(segment: &mut Camera3DSegment) { } } } -impl_track_segment!(MaskSegment, lane: track); + +impl TrackSegmentOps for MaskSegment { + fn start(&self) -> f64 { + self.start + } + fn end(&self) -> f64 { + self.end + } + fn set_start(&mut self, value: f64) { + self.start = value; + } + fn set_end(&mut self, value: f64) { + self.end = value; + } + fn trim_start_to(&mut self, value: f64) { + let shift = self.start - value; + // Off-segment keys still anchor interpolation at the trimmed edges. + for time in self + .keyframes + .position + .iter_mut() + .chain(&mut self.keyframes.size) + .map(|key| &mut key.time) + .chain(self.keyframes.intensity.iter_mut().map(|key| &mut key.time)) + { + *time += shift; + } + self.start = value; + } + fn lane(&self) -> u32 { + self.track + } +} impl_track_segment!(TextSegment, lane: track); impl_track_segment!(cap_project::StyleSegment, lane: track); impl_track_segment!(cap_project::ImageSegment, lane: track); @@ -1262,7 +1300,6 @@ fn ripple_relative_keyframes( keyframes: &mut Vec, old_start: f64, new_start: f64, - new_end: f64, cut_start: f64, cut_end: f64, shift: f64, @@ -1279,7 +1316,7 @@ fn ripple_relative_keyframes( } else { absolute }; - *value = (mapped - new_start).clamp(0.0, new_end - new_start); + *value = mapped - new_start; true }); } @@ -1291,6 +1328,14 @@ fn ripple_delete_mask_track( shift: f64, ) { segments.retain_mut(|segment| { + if segment.end <= cut_start { + return true; + } + if segment.start >= cut_end { + segment.start -= shift; + segment.end -= shift; + return true; + } let old_start = segment.start; let Some((new_start, new_end)) = ripple_deleted_bounds(old_start, segment.end, cut_start, cut_end, shift) @@ -1301,7 +1346,6 @@ fn ripple_delete_mask_track( &mut segment.keyframes.position, old_start, new_start, - new_end, cut_start, cut_end, shift, @@ -1311,7 +1355,6 @@ fn ripple_delete_mask_track( &mut segment.keyframes.size, old_start, new_start, - new_end, cut_start, cut_end, shift, @@ -1321,7 +1364,6 @@ fn ripple_delete_mask_track( &mut segment.keyframes.intensity, old_start, new_start, - new_end, cut_start, cut_end, shift, @@ -2960,6 +3002,34 @@ mod tests { assert!(!clean.can_undo()); } + #[test] + fn a_drag_returning_to_its_start_keeps_undo_and_redo_unchanged() { + let initial = ProjectConfiguration::default(); + let mut moved = initial.clone(); + moved.background.padding += 1.0; + let mut future = initial.clone(); + future.background.padding += 2.0; + let mut history = ProjectHistory::new(initial.clone()); + history.pause(); + history.record(&moved); + history.record(&initial); + history.resume(&initial); + assert_eq!(history.depth(), 1); + assert!(!history.can_undo()); + + history.record(&future); + history.undo(); + history.pause(); + history.record(&moved); + history.record(&initial); + history.resume(&initial); + assert!(history.can_redo()); + assert_eq!( + history.redo().unwrap().background.padding, + future.background.padding + ); + } + #[test] fn the_stack_is_capped_at_a_hundred_entries() { let mut history = ProjectHistory::new(with_zoom_count(0)); @@ -3153,6 +3223,58 @@ mod tests { assert_eq!(bounds.clamp(4.), 0.); } + #[test] + fn zoom_end_resize_cannot_cross_a_neighbour_when_zoomed_out() { + let mut project = zoom_fixture(); + let timeline = project.timeline.as_mut().unwrap(); + timeline.zoom_segments[1].start = 6.; + let model = TimelineModel::build(&project, false, false); + let zoom = segments(&model, TrackKind::Zoom); + let floor = min_segment_duration(TrackKind::Zoom, 0.2); + let bounds = trim_end_bounds(&zoom, 0, 0, floor, 30.); + + assert_eq!(bounds, DragBounds { min: 5., max: 6. }); + let timeline = project.timeline.as_mut().unwrap(); + set_segment_end(timeline, TrackKind::Zoom, 0, bounds.clamp(4.)); + assert_eq!(timeline.zoom_segments[0].end, 5.); + for requested_end in [5.5, 12.] { + set_segment_end(timeline, TrackKind::Zoom, 0, bounds.clamp(requested_end)); + assert!(timeline.zoom_segments[0].end <= timeline.zoom_segments[1].start); + } + } + + #[test] + fn zoom_end_resize_cannot_cross_the_timeline_end_when_zoomed_out() { + let mut project = zoom_fixture(); + let model = TimelineModel::build(&project, false, false); + let zoom = segments(&model, TrackKind::Zoom); + let floor = min_segment_duration(TrackKind::Zoom, 0.5); + let bounds = trim_end_bounds(&zoom, 0, 1, floor, 30.); + + assert_eq!(bounds, DragBounds { min: 24., max: 30. }); + let timeline = project.timeline.as_mut().unwrap(); + for requested_end in [23., 28., 40.] { + set_segment_end(timeline, TrackKind::Zoom, 1, bounds.clamp(requested_end)); + assert!(timeline.zoom_segments[1].end <= timeline.duration()); + } + } + + #[test] + fn zoom_end_resize_preserves_a_range_left_beyond_a_shortened_timeline() { + let mut project = zoom_fixture(); + project.timeline.as_mut().unwrap().segments[0].end = 10.; + let model = TimelineModel::build(&project, false, false); + let zoom = segments(&model, TrackKind::Zoom); + let floor = min_segment_duration(TrackKind::Zoom, 0.2); + let bounds = trim_end_bounds(&zoom, 0, 1, floor, 10.); + + for requested_end in [8., 22., 30.] { + let end = bounds.clamp(requested_end); + assert!(end >= zoom[1].start); + assert!(end <= zoom[1].end); + } + } + // -- Clip trim ---------------------------------------------------------- fn two_clip_config() -> ProjectConfiguration { @@ -3750,6 +3872,126 @@ mod tests { assert_eq!(tail.fade_out, 0.6); } + fn animated_mask() -> MaskSegment { + let mut mask = default_mask_segment(2.0, 10.0, 1); + mask.keyframes = serde_json::from_value(serde_json::json!({ + "position": [ + { "time": 0.0, "x": 0.2, "y": 0.3 }, + { "time": 4.0, "x": 0.5, "y": 0.6 }, + { "time": 8.0, "x": 0.8, "y": 0.9 } + ], + "size": [ + { "time": 0.0, "x": 0.2, "y": 0.3 }, + { "time": 4.0, "x": 0.4, "y": 0.5 }, + { "time": 8.0, "x": 0.6, "y": 0.7 } + ], + "intensity": [ + { "time": 0.0, "value": 0.2 }, + { "time": 4.0, "value": 0.5 }, + { "time": 8.0, "value": 0.8 } + ] + })) + .unwrap(); + mask + } + + fn mask_keyframe_times(mask: &MaskSegment) -> Vec { + mask.keyframes + .position + .iter() + .chain(&mask.keyframes.size) + .map(|key| key.time) + .chain(mask.keyframes.intensity.iter().map(|key| key.time)) + .collect() + } + + #[test] + fn mask_keyframes_keep_their_absolute_times_after_a_split() { + let mut project = zoom_fixture(); + let timeline = project.timeline.as_mut().unwrap(); + let original = animated_mask(); + timeline.mask_segments.push(original.clone()); + + assert!(split_segment(timeline, TrackKind::Mask, 0, 3.0)); + + let head = &timeline.mask_segments[0]; + let tail = &timeline.mask_segments[1]; + assert_eq!((head.start, head.end), (2.0, 5.0)); + assert_eq!((tail.start, tail.end), (5.0, 10.0)); + assert_eq!(head.track, 1); + assert_eq!(tail.track, 1); + assert_eq!( + serde_json::to_value(&head.keyframes).unwrap(), + serde_json::to_value(&original.keyframes).unwrap() + ); + assert_eq!(mask_keyframe_times(tail), [-3.0, 1.0, 5.0].repeat(3)); + } + + #[test] + fn mask_keyframes_keep_their_absolute_times_when_trimming_the_start() { + let mut project = zoom_fixture(); + let timeline = project.timeline.as_mut().unwrap(); + timeline.mask_segments.push(animated_mask()); + + for start in [5.0, 7.0, 1.0, 2.0] { + assert!(set_segment_start(timeline, TrackKind::Mask, 0, start)); + let mask = &timeline.mask_segments[0]; + let absolute_times = mask_keyframe_times(mask) + .into_iter() + .map(|time| mask.start + time) + .collect::>(); + assert_eq!(absolute_times, [2.0, 6.0, 10.0].repeat(3)); + } + assert!(!set_segment_start(timeline, TrackKind::Mask, 0, 2.0)); + assert!(move_segment(timeline, TrackKind::Mask, 0, 4.0, 12.0)); + assert!(set_segment_end(timeline, TrackKind::Mask, 0, 11.0)); + assert_eq!( + serde_json::to_value(&timeline.mask_segments[0].keyframes).unwrap(), + serde_json::to_value(&animated_mask().keyframes).unwrap() + ); + } + + #[test] + fn mask_keyframes_outside_the_segment_survive_an_unrelated_ripple_delete() { + for (cut_start, cut_end, expected_bounds) in + [(0.0, 3.0, (2.0, 6.0)), (12.0, 14.0, (5.0, 9.0))] + { + let mut mask = animated_mask(); + mask.start = 5.0; + mask.end = 9.0; + mask.keyframes.position[0].time = -3.0; + mask.keyframes.size[0].time = -3.0; + mask.keyframes.intensity[0].time = -3.0; + let original = serde_json::to_value(&mask.keyframes).unwrap(); + let mut project = zoom_fixture(); + let timeline = project.timeline.as_mut().unwrap(); + timeline.mask_segments.push(mask); + + ripple_delete_output_tracks(timeline, cut_start, cut_end, cut_end - cut_start); + + assert_eq!( + serde_json::to_value(&timeline.mask_segments[0].keyframes).unwrap(), + original + ); + let mask = &timeline.mask_segments[0]; + assert_eq!((mask.start, mask.end), expected_bounds); + } + } + + #[test] + fn mask_keyframes_before_an_overlapping_ripple_delete_keep_their_times() { + let mut project = zoom_fixture(); + let timeline = project.timeline.as_mut().unwrap(); + timeline.mask_segments.push(animated_mask()); + assert!(set_segment_start(timeline, TrackKind::Mask, 0, 5.0)); + + ripple_delete_output_tracks(timeline, 8.0, 9.0, 1.0); + + let mask = &timeline.mask_segments[0]; + assert_eq!((mask.start, mask.end), (5.0, 9.0)); + assert_eq!(mask_keyframe_times(mask), [-3.0, 1.0, 4.0].repeat(3)); + } + // -- Split snapping ----------------------------------------------------- #[test] diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index acab035adc3..77498a4103d 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -373,6 +373,18 @@ impl ExportUi { .is_some_and(|bpp| (bpp - self.compression.bits_per_pixel()).abs() > 0.001) } + fn complete_share_export(&mut self, link: String, copy_link: impl FnOnce(String)) { + if self.close_requested || self.cancel.load(Ordering::Relaxed) { + self.phase = ExportPhase::Idle; + return; + } + + self.phase = ExportPhase::Done; + self.share_link = Some(link.clone()); + self.upload_progress = 1.0; + copy_link(link); + } + fn clipboard_retry_path(&self) -> Option<&Path> { if self.phase == ExportPhase::Failed && self.destination == ExportDestination::Clipboard { self.output_path.as_deref() @@ -931,7 +943,11 @@ impl EditorWindow { }); loop { + let mut latest: Option<(u32, u32)> = None; while let Ok((rendered, total)) = progress_rx.try_recv() { + latest = Some((rendered, total)); + } + if let Some((rendered, total)) = latest { let _ = this.update(cx, |this, cx| { if let Some(ui) = this.export.as_mut() { ui.rendered = rendered; @@ -1172,7 +1188,11 @@ impl EditorWindow { }); loop { + let mut latest: Option<(u32, u32)> = None; while let Ok((rendered, total)) = progress_rx.try_recv() { + latest = Some((rendered, total)); + } + if let Some((rendered, total)) = latest { let _ = this.update(cx, |this, cx| { if let Some(ui) = this.export.as_mut() { ui.rendered = rendered; @@ -1217,7 +1237,11 @@ impl EditorWindow { }); loop { + let mut latest: Option = None; while let Ok(progress) = upload_rx.try_recv() { + latest = Some(progress); + } + if let Some(progress) = latest { let _ = this.update(cx, |this, cx| { if let Some(ui) = this.export.as_mut() { ui.upload_progress = progress as f32; @@ -1243,16 +1267,15 @@ impl EditorWindow { this.sharing = meta.sharing; } if let Some(ui) = this.export.as_mut() { - ui.phase = ExportPhase::Done; - ui.share_link = Some(link.clone()); - ui.upload_progress = 1.0; + ui.complete_share_export(link, |link| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + link, + )); + crate::app_sounds::play_notification(); + }); } cx.notify(); }); - let _ = this.update(cx, |_, cx| { - cx.write_to_clipboard(gpui::ClipboardItem::new_string(link)); - crate::app_sounds::play_notification(); - }); } Ok(Ok(crate::upload::UploadResult::NotAuthenticated)) => { let _ = this.update(cx, |this, _| { @@ -3084,6 +3107,44 @@ mod tests { } } + #[test] + fn completed_share_export_copies_the_link_and_shows_success() { + let mut ui = clipboard_export(); + ui.destination = ExportDestination::Link; + ui.phase = ExportPhase::Uploading; + ui.upload_progress = 0.9; + let link = "https://cap.link/finished".to_string(); + let mut copied = None; + + ui.complete_share_export(link.clone(), |link| copied = Some(link)); + + assert_eq!(ui.phase, ExportPhase::Done); + assert_eq!(ui.share_link, Some(link.clone())); + assert_eq!(ui.upload_progress, 1.0); + assert_eq!(copied, Some(link)); + } + + #[test] + fn cancellation_or_close_ignores_a_completed_share_export() { + for closing in [false, true] { + let mut ui = clipboard_export(); + ui.destination = ExportDestination::Link; + ui.phase = ExportPhase::Uploading; + ui.upload_progress = 0.9; + let completed_link = "https://cap.link/finished".to_string(); + ui.close_requested = closing; + ui.cancel.store(!closing, Ordering::Relaxed); + + ui.complete_share_export(completed_link, |_| { + panic!("cancelled share exports must not copy a link or notify") + }); + + assert_eq!(ui.phase, ExportPhase::Idle); + assert!(ui.share_link.is_none()); + assert_eq!(ui.upload_progress, 0.9); + } + } + #[test] fn incomplete_or_non_clipboard_exports_cannot_retry_copy() { let mut ui = clipboard_export(); diff --git a/apps/desktop-gpui/src/editor_hover_bench.rs b/apps/desktop-gpui/src/editor_hover_bench.rs index 4325410ada8..8d5ad0630b9 100644 --- a/apps/desktop-gpui/src/editor_hover_bench.rs +++ b/apps/desktop-gpui/src/editor_hover_bench.rs @@ -6,6 +6,8 @@ //! does. Each move runs the timeline's own hover handler, then one synchronous //! `Window::draw` (view renders + layout + prepaint + paint) is timed with it. //! `CAP_GPUI_AUTO_SIDEBAR` picks the sidebar tab that stays open meanwhile. +//! `CAP_GPUI_AUTO_HOVER_BENCH_SCRUB=1` holds the ruler down for the whole run, +//! so every move is also a paused scrub step through the ruler's own handlers. //! //! Run: //! CAP_GPUI_AUTO_EDITOR= CAP_GPUI_AUTO_HOVER_BENCH=1 \ @@ -14,7 +16,7 @@ use std::time::{Duration, Instant}; -use gpui::{App, WindowHandle}; +use gpui::{App, MouseButton, MouseDownEvent, MouseMoveEvent, WindowHandle, point, px}; use crate::editor_window::EditorWindow; @@ -51,6 +53,24 @@ pub async fn run(handle: WindowHandle, cx: &mut gpui::AsyncApp) { .and_then(|value| value.parse::().ok()) .filter(|secs| *secs > 0.) .unwrap_or(DEFAULT_SWEEP_SECS); + let scrub = std::env::var("CAP_GPUI_AUTO_HOVER_BENCH_SCRUB").is_ok_and(|value| value == "1"); + let pointer_x = |width: f32, phase: f32| 140. + (width - 180.).max(1.) * phase; + if scrub { + cx.update(|cx| { + handle + .update(cx, |view, window, cx| { + let x = pointer_x(f32::from(window.viewport_size().width), 0.); + let event = MouseDownEvent { + button: MouseButton::Left, + position: point(px(x), px(0.)), + click_count: 1, + ..Default::default() + }; + view.timeline_mouse_down(&event, true, window, cx); + }) + .ok() + }); + } let started = Instant::now(); let mut next_move = started; let mut move_us = Vec::new(); @@ -85,9 +105,16 @@ pub async fn run(handle: WindowHandle, cx: &mut gpui::AsyncApp) { let hovered = cx.update(|cx| { handle .update(cx, |view, window, cx| { - let width = f32::from(window.viewport_size().width); - let x = 140. + (width - 180.).max(1.) * phase; + let x = pointer_x(f32::from(window.viewport_size().width), phase); view.timeline_hover(x, window, cx); + if scrub { + let event = MouseMoveEvent { + position: point(px(x), px(0.)), + pressed_button: Some(MouseButton::Left), + ..Default::default() + }; + view.timeline_mouse_move(&event, window, cx); + } }) .is_ok() }); @@ -105,8 +132,16 @@ pub async fn run(handle: WindowHandle, cx: &mut gpui::AsyncApp) { .zip(frames_at_start) .map(|(end, start)| end.saturating_sub(start)); + if scrub { + cx.update(|cx| { + handle + .update(cx, |view, _, cx| view.window_mouse_up(cx)) + .ok() + }); + } println!( - "hover-bench: sidebar tab {}, pointer at {mouse_hz}Hz crossing the timeline in {sweep_secs}s, {} moves handled and {frames:?} preview frames presented in {:.1}s", + "hover-bench: {}sidebar tab {}, pointer at {mouse_hz}Hz crossing the timeline in {sweep_secs}s, {} moves handled and {frames:?} preview frames presented in {:.1}s", + if scrub { "ruler scrub, " } else { "" }, std::env::var("CAP_GPUI_AUTO_SIDEBAR").unwrap_or_else(|_| "default".into()), move_us.len(), MEASURED.as_secs_f64(), diff --git a/apps/desktop-gpui/src/editor_panels.rs b/apps/desktop-gpui/src/editor_panels.rs index 4ad482e3035..17ed08d0073 100644 --- a/apps/desktop-gpui/src/editor_panels.rs +++ b/apps/desktop-gpui/src/editor_panels.rs @@ -668,14 +668,100 @@ pub fn pick_font_family(stack: &[&str], installed: &[String]) -> String { .map_or_else(|| "sans-serif".to_string(), |family| (*family).to_string()) } -/// `applyTextPreset` (`text-presets.ts:205-238`). Timing stays the user's, and -/// so does the content unless it is still the placeholder a new segment is -/// born with; the box is scaled with the font change about its **top** edge, -/// exactly as the Size slider does. -pub fn apply_text_preset(segment: &mut TextSegment, preset: &TextPreset, installed: &[String]) { - let style = &preset.style; +#[derive(Debug, Clone, PartialEq)] +pub struct TextStyle { + pub font_family: String, + pub font_size: f32, + pub font_weight: f32, + pub italic: bool, + pub uppercase: bool, + pub align: TextAlign, + pub letter_spacing: f32, + pub line_height: f32, + pub opacity: f32, + pub shadow: f32, + pub glow: f32, + pub stroke_width: f32, + pub stroke_color: String, + pub background_style: TextBackgroundStyle, + pub background_color: Option, + pub color: String, + pub gradient_color: Option, + pub animation_in: TextAnimation, + pub animation_out: TextAnimation, + pub animation_in_duration: f64, + pub animation_out_duration: f64, + pub fade_duration: f64, +} + +impl TextStyle { + pub fn of(segment: &TextSegment) -> Self { + Self { + font_family: segment.font_family.clone(), + font_size: segment.font_size, + font_weight: segment.font_weight, + italic: segment.italic, + uppercase: segment.uppercase, + align: segment.align, + letter_spacing: segment.letter_spacing, + line_height: segment.line_height, + opacity: segment.opacity, + shadow: segment.shadow, + glow: segment.glow, + stroke_width: segment.stroke_width, + stroke_color: segment.stroke_color.clone(), + background_style: segment.background_style, + background_color: segment.background_color.clone(), + color: segment.color.clone(), + gradient_color: segment.gradient_color.clone(), + animation_in: segment.animation_in, + animation_out: segment.animation_out, + animation_in_duration: segment.animation_in_duration, + animation_out_duration: segment.animation_out_duration, + fade_duration: segment.fade_duration, + } + } + + pub fn apply_to(&self, segment: &mut TextSegment) -> bool { + let previous_style = Self::of(segment); + let previous_center = segment.center; + let previous_size = segment.size; + if segment.font_size != self.font_size { + scale_text_box(segment, self.font_size); + } + segment.font_family = self.font_family.clone(); + segment.font_size = self.font_size; + segment.font_weight = self.font_weight; + segment.italic = self.italic; + segment.uppercase = self.uppercase; + segment.align = self.align; + segment.letter_spacing = self.letter_spacing; + segment.line_height = self.line_height; + segment.opacity = self.opacity; + segment.shadow = self.shadow; + segment.glow = self.glow; + segment.stroke_width = self.stroke_width; + segment.stroke_color = self.stroke_color.clone(); + segment.background_style = self.background_style; + segment.background_color = self.background_color.clone(); + segment.color = self.color.clone(); + segment.gradient_color = self.gradient_color.clone(); + segment.animation_in = self.animation_in; + segment.animation_out = self.animation_out; + segment.animation_in_duration = self.animation_in_duration; + segment.animation_out_duration = self.animation_out_duration; + segment.fade_duration = self.fade_duration; + previous_style != *self + || previous_center != segment.center + || previous_size != segment.size + } +} + +/// Grows or shrinks the text box with a font-size change, top edge fixed, +/// like the Size slider; the canvas overlay re-hugs the glyph bounds later. +fn scale_text_box(segment: &mut TextSegment, font_size: f32) { let box_scale = f64::from( - style.font_size + font_size / if segment.font_size == 0. { 48. } else { @@ -686,6 +772,15 @@ pub fn apply_text_preset(segment: &mut TextSegment, preset: &TextPreset, install segment.size.x = (segment.size.x * box_scale).min(1.); segment.size.y *= box_scale; segment.center.y = top_edge + segment.size.y / 2.; +} + +/// `applyTextPreset` (`text-presets.ts:205-238`). Timing stays the user's, and +/// so does the content unless it is still the placeholder a new segment is +/// born with; the box is scaled with the font change about its **top** edge, +/// exactly as the Size slider does. +pub fn apply_text_preset(segment: &mut TextSegment, preset: &TextPreset, installed: &[String]) { + let style = &preset.style; + scale_text_box(segment, style.font_size); segment.font_family = pick_font_family(style.font_stack, installed); segment.font_size = style.font_size; @@ -1591,6 +1686,30 @@ pub fn camera3d_pose_seek_time(segment: &Camera3DSegment, end: bool, fps: u32) - camera3d_frame_before(segment.end, fps).max(start_frame) } +/// "The playhead is on this pose" is a frame's tolerance, which is as close +/// as a seek can land. +fn camera3d_pose_on(playhead: f64, time: f64, fps: u32) -> bool { + (playhead - time).abs() < 1. / f64::from(fps.max(1)) +} + +/// Every `(shot, end)` pose the playhead is on: what the 3D panel's pose cards +/// mark, so a scrub can tell when the sidebar has to redraw. +pub(crate) fn camera3d_poses_at( + segments: &[Camera3DSegment], + playhead: f64, + fps: u32, +) -> Vec<(usize, bool)> { + let mut poses = Vec::new(); + for (index, segment) in segments.iter().enumerate() { + for end in [false, true] { + if camera3d_pose_on(playhead, camera3d_pose_seek_time(segment, end, fps), fps) { + poses.push((index, end)); + } + } + } + poses +} + /// How many shots the Auto scene picker offers. pub const AUTO_CAMERA3D_MAX_SHOTS: usize = 6; @@ -2897,12 +3016,7 @@ impl EditorWindow { }; let start = matches!(key, FieldKey::CaptionStart(_)); self.edit_caption_segment("caption-timing", index, window, cx, move |segment| { - if start { - segment.start = value; - } else { - segment.end = value; - } - true + commit_segment_timing(segment, start, &text, value) }); } FieldKey::KeyboardStart(index) | FieldKey::KeyboardEnd(index) => { @@ -2914,12 +3028,7 @@ impl EditorWindow { }; let start = matches!(key, FieldKey::KeyboardStart(_)); self.edit_keyboard_segment("keyboard-timing", index, window, cx, move |segment| { - if start { - segment.start = value; - } else { - segment.end = value; - } - true + commit_segment_timing(segment, start, &text, value) }); } FieldKey::SyncOffset(clip, kind) => { @@ -2975,6 +3084,31 @@ impl EditorWindow { } } +/// `text` is the field's committed text: it shows times to two decimals, so +/// tabbing through an untouched field commits a rounded copy of the stored +/// time, which must not count as an edit. +fn commit_segment_timing( + segment: &mut impl crate::editor_edits::TrackSegmentOps, + start: bool, + text: &str, + value: f64, +) -> bool { + let current = if start { + segment.start() + } else { + segment.end() + }; + if text.trim() == format!("{current:.2}") || current == value { + return false; + } + if start { + segment.set_start(value); + } else { + segment.set_end(value); + } + true +} + // --------------------------------------------------------------------------- // Per-track edit helpers // --------------------------------------------------------------------------- @@ -6831,9 +6965,7 @@ impl EditorWindow { let fps = crate::editor_window::EDITOR_PREVIEW_FPS; let start_time = camera3d_pose_seek_time(segment, false, fps); let end_time = camera3d_pose_seek_time(segment, true, fps); - // "The playhead is on this pose" is a frame's tolerance, which is as - // close as a seek can land. - let on = |time: f64| (self.playhead_time() - time).abs() < 1. / f64::from(fps.max(1)); + let on = |time: f64| camera3d_pose_on(self.playhead_time(), time, fps); div() .flex() @@ -7936,6 +8068,171 @@ impl EditorWindow { mod tests { use super::*; + #[test] + fn unchanged_timing_fields_preserve_redo() { + use crate::editor_edits::ProjectHistory; + + for base in [2., 2.3456] { + for caption in [true, false] { + for start in [true, false] { + for paused in [true, false] { + let mut project: cap_project::ProjectConfiguration = + serde_json::from_value(serde_json::json!({ + "timeline": { + "segments": [], + "zoomSegments": [], + "captionSegments": [{ "id": "caption", "start": base, "end": base + 4., "text": "hello" }], + "keyboardSegments": [{ "id": "keyboard", "start": base, "end": base + 4., "displayText": "A", "keys": [] }] + } + })) + .unwrap(); + let initial = serde_json::to_value(&project).unwrap(); + let mut history = ProjectHistory::new(project.clone()); + let commit = |project: &mut cap_project::ProjectConfiguration, + text: &str| { + let value = text.parse().unwrap(); + let timeline = project.timeline.as_mut().unwrap(); + if caption { + commit_segment_timing( + &mut timeline.caption_segments[0], + start, + text, + value, + ) + } else { + commit_segment_timing( + &mut timeline.keyboard_segments[0], + start, + text, + value, + ) + } + }; + let before = if start { base } else { base + 4. }; + assert!(commit(&mut project, &format!("{:.2}", before + 1.))); + history.record(&project); + let edited = serde_json::to_value(&project).unwrap(); + project = history.undo().unwrap().clone(); + assert_eq!(serde_json::to_value(&project).unwrap(), initial); + + if paused { + history.pause(); + } + if commit(&mut project, &format!("{before:.2}")) { + history.record(&project); + } + if paused { + history.resume(&project); + } + let context = format!( + "base={base}, caption={caption}, start={start}, paused={paused}" + ); + assert_eq!( + serde_json::to_value(&project).unwrap(), + initial, + "{context}" + ); + assert!(history.can_redo(), "{context}"); + assert!(!history.can_undo(), "{context}"); + assert_eq!( + serde_json::to_value(history.redo().unwrap()).unwrap(), + edited + ); + assert_eq!( + serde_json::to_value(history.undo().unwrap()).unwrap(), + initial + ); + } + } + } + } + } + + #[test] + fn pasting_a_text_style_keeps_content_timing_position_and_layout() { + let mut source = crate::editor_edits::default_text_segment(0., 2., 0); + source.content = "Hello".into(); + source.font_family = "Georgia".into(); + source.font_size = 96.; + source.font_weight = 400.; + source.italic = true; + source.uppercase = true; + source.letter_spacing = 3.; + source.color = "#ff0000".into(); + source.background_color = Some("#00000080".into()); + source.gradient_color = Some("#00ff00".into()); + source.animation_in = TextAnimation::Pop; + source.animation_out = TextAnimation::Wipe; + source.animation_in_duration = 0.4; + source.animation_out_duration = 0.6; + source.center = XY::new(0.2, 0.2); + source.layout = TextLayout::Fullscreen; + + let mut target = crate::editor_edits::default_text_segment(5., 8., 1); + target.content = "World".into(); + target.center = XY::new(0.7, 0.8); + target.size = XY::new(0.2, 0.1); + + assert!(TextStyle::of(&source).apply_to(&mut target)); + + assert_eq!(target.content, "World"); + assert_eq!(target.start, 5.); + assert_eq!(target.end, 8.); + assert_eq!(target.track, 1); + assert_eq!(target.layout, TextLayout::Overlay); + assert_eq!(target.center.x, 0.7); + assert_eq!(target.font_family, "Georgia"); + assert_eq!(target.font_size, 96.); + assert!(target.italic && target.uppercase); + assert_eq!(target.letter_spacing, 3.); + assert_eq!(target.color, "#ff0000"); + assert_eq!(target.background_color.as_deref(), Some("#00000080")); + assert_eq!(target.gradient_color.as_deref(), Some("#00ff00")); + assert_eq!(target.animation_in, TextAnimation::Pop); + assert_eq!(target.animation_out, TextAnimation::Wipe); + assert_eq!(target.animation_in_duration, 0.4); + assert_eq!(target.animation_out_duration, 0.6); + assert_eq!(TextStyle::of(&target), TextStyle::of(&source)); + } + + #[test] + fn pasting_a_text_style_scales_the_box_from_its_top_edge() { + let mut source = crate::editor_edits::default_text_segment(0., 2., 0); + source.font_size = 96.; + let mut target = crate::editor_edits::default_text_segment(0., 2., 0); + target.font_size = 48.; + target.center = XY::new(0.5, 0.5); + target.size = XY::new(0.2, 0.1); + + assert!(TextStyle::of(&source).apply_to(&mut target)); + + assert!((target.size.x - 0.4).abs() < 1e-9); + assert!((target.size.y - 0.2).abs() < 1e-9); + assert!((target.center.y - 0.55).abs() < 1e-9); + assert_eq!(target.center.x, 0.5); + } + + #[test] + fn pasting_a_text_style_onto_its_own_source_changes_nothing() { + for (center, size) in [ + (XY::new(0.3, 0.6), XY::new(0.25, 0.08)), + (XY::new(0.5, 0.9), XY::new(0.3, 0.4)), + ] { + let mut segment = crate::editor_edits::default_text_segment(0., 2., 0); + segment.color = "#ff0000".into(); + segment.center = center; + segment.size = size; + let style = TextStyle::of(&segment); + let before = segment.clone(); + + assert!(!style.apply_to(&mut segment)); + + assert_eq!(TextStyle::of(&segment), style); + assert_eq!(segment.center, before.center); + assert_eq!(segment.size, before.size); + } + } + fn camera3d(start: f64, end: f64) -> Camera3DSegment { Camera3DSegment { start, @@ -8613,6 +8910,22 @@ mod tests { ); } + #[test] + fn a_paused_scrub_sees_the_poses_the_playhead_lands_on() { + // A paused scrub redraws the sidebar only when this list changes, and + // the pose cards mark exactly these poses. + let fps = 60; + let shots = [camera3d(1., 2.5), camera3d(4.02, 6.)]; + assert_eq!(camera3d_poses_at(&shots, 1., fps), [(0, false)]); + assert_eq!(camera3d_poses_at(&shots, 149. / 60., fps), [(0, true)]); + assert_eq!(camera3d_poses_at(&shots, 242. / 60., fps), [(1, false)]); + assert_eq!(camera3d_poses_at(&shots, 359. / 60., fps), [(1, true)]); + for playhead in [0., 1.75, 3., 5., 7.] { + assert!(camera3d_poses_at(&shots, playhead, fps).is_empty()); + } + assert!(camera3d_poses_at(&[], 1., fps).is_empty()); + } + #[test] fn a_sequence_stays_inside_the_shot_it_replaces() { // The panel replaces one shot with the whole chain, so the chain has diff --git a/apps/desktop-gpui/src/editor_preparing/audio.rs b/apps/desktop-gpui/src/editor_preparing/audio.rs index bb5206e20e6..1b58380b949 100644 --- a/apps/desktop-gpui/src/editor_preparing/audio.rs +++ b/apps/desktop-gpui/src/editor_preparing/audio.rs @@ -338,6 +338,7 @@ mod tests { start_time: Some(0.0), device_id: None, gap_summary: None, + digital_silence_ms: None, }; for extension in ["m4a", "ogg"] { let relative = PathBuf::from(format!("segment-0/audio-input.{extension}")); diff --git a/apps/desktop-gpui/src/editor_sidebar.rs b/apps/desktop-gpui/src/editor_sidebar.rs index 73afc538eaa..dcc1c0f2c4a 100644 --- a/apps/desktop-gpui/src/editor_sidebar.rs +++ b/apps/desktop-gpui/src/editor_sidebar.rs @@ -196,6 +196,7 @@ pub const BACKGROUND_IMAGE_EXTENSIONS: [&str; 6] = ["jpg", "jpeg", "png", "gif", pub const CURRENT_DESKTOP_BACKGROUND_BASENAME: &str = "current-desktop-background"; /// `recording.rs:92-93`. const DESKTOP_BACKGROUND_MAX_DIMENSION: u32 = 2560; +#[cfg(target_os = "macos")] const DESKTOP_BACKGROUND_JPEG_QUALITY: u8 = 82; /// The sidebar's fallback border/shadow objects. The UI's numbers, **not** @@ -596,6 +597,36 @@ fn noise_texture(width: u32, height: u32, base_frequency: f32) -> Arc)>, + retired: Vec>, +} + +impl NoiseCache { + fn image(&mut self, scale: f32) -> Arc { + let key = scale.round() as i32; + if let Some((cached, image)) = &self.current + && *cached == key + { + return image.clone(); + } + let image = noise_texture( + GRADIENT_PREVIEW_WIDTH as u32, + GRADIENT_PREVIEW_HEIGHT as u32, + noise_base_frequency(scale), + ); + if let Some((_, previous)) = self.current.replace((key, image.clone())) { + self.retired.push(previous); + } + image + } + + fn take_retired(&mut self) -> Vec> { + std::mem::take(&mut self.retired) + } +} + // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- @@ -787,7 +818,7 @@ pub struct SidebarState { /// The gradient preview's grain, cached by the grain-scale step it was /// generated for -- regenerating 42k pixels of noise on every frame of a /// slider drag would be the one expensive thing in this panel. - noise: std::cell::RefCell)>>, + noise: std::cell::RefCell, /// The open `KSelect`'s menu, if any -- one slot for every select in the /// sidebar, the settings window's `Menu.popup()` stand-in. @@ -876,7 +907,7 @@ impl SidebarState { cursor_previews: std::cell::RefCell::new(BTreeMap::new()), cursor_scale: 2., panel_sections: std::cell::RefCell::new(HashMap::new()), - noise: std::cell::RefCell::new(None), + noise: std::cell::RefCell::default(), menu: None, caption_model: "best", caption_language: "auto", @@ -905,19 +936,16 @@ impl SidebarState { /// The grain texture for a grain-scale value, generated once per step. fn noise_image(&self, scale: f32) -> Arc { - let key = scale.round() as i32; - if let Some((cached, image)) = self.noise.borrow().as_ref() - && *cached == key - { - return image.clone(); + self.noise.borrow_mut().image(scale) + } + + /// Evicts the grain textures earlier steps of a slider drag left in the + /// window's atlas. The panel is built without a `&mut Window`, so they + /// are queued there and released from `render` on the next frame. + pub(crate) fn release_retired_images(&self, window: &mut Window) { + for image in self.noise.borrow_mut().take_retired() { + let _ = window.drop_image(image); } - let image = noise_texture( - GRADIENT_PREVIEW_WIDTH as u32, - GRADIENT_PREVIEW_HEIGHT as u32, - noise_base_frequency(scale), - ); - *self.noise.borrow_mut() = Some((key, image.clone())); - image } /// The stored track rect, without creating one -- the pointer maths reads @@ -1895,22 +1923,7 @@ fn decode_scaled_rgba(path: &Path, max: u32) -> Option { } fn decode_jpeg_thumbnail(bytes: &[u8], max: u32) -> Option<(image::DynamicImage, u32, u32)> { - let mut decoder = jpeg_decoder::Decoder::new(bytes); - decoder.read_info().ok()?; - let info = decoder.info()?; - let requested = max.clamp(1, u16::MAX as u32) as u16; - let (width, height) = decoder.scale(requested, requested).ok()?; - let pixels = decoder.decode().ok()?; - let decoded = match info.pixel_format { - jpeg_decoder::PixelFormat::RGB24 => image::DynamicImage::ImageRgb8( - image::RgbImage::from_raw(width as u32, height as u32, pixels)?, - ), - jpeg_decoder::PixelFormat::L8 => image::DynamicImage::ImageLuma8( - image::GrayImage::from_raw(width as u32, height as u32, pixels)?, - ), - _ => return None, - }; - Some((decoded, info.width as u32, info.height as u32)) + crate::jpeg_thumbnail::decode_jpeg_thumbnail(bytes, |_, _| (max, max)) } /// [`decode_scaled_rgba`] in gpui's BGRA order. @@ -1953,30 +1966,33 @@ pub(crate) fn decode_wallpaper_thumbnail(id: &str) -> Option> { /// re-encoding to JPEG at quality 82. The `sips` path matters -- macOS ships /// HEIC wallpapers the `image` crate cannot decode. fn write_desktop_background_snapshot(source: &Path, output: &Path) -> Result<(), String> { - let needs_downscale = image::image_dimensions(source).is_ok_and(|(width, height)| { - width > DESKTOP_BACKGROUND_MAX_DIMENSION || height > DESKTOP_BACKGROUND_MAX_DIMENSION - }); - - let mut command = std::process::Command::new("sips"); - command - .arg("-s") - .arg("format") - .arg("jpeg") - .arg("-s") - .arg("formatOptions") - .arg(DESKTOP_BACKGROUND_JPEG_QUALITY.to_string()); - if needs_downscale { - command - .arg("-Z") - .arg(DESKTOP_BACKGROUND_MAX_DIMENSION.to_string()); - } - if let Ok(output_status) = command.arg(source).arg("--out").arg(output).output() - && output_status.status.success() + #[cfg(target_os = "macos")] { - return Ok(()); + let needs_downscale = image::image_dimensions(source).is_ok_and(|(width, height)| { + width > DESKTOP_BACKGROUND_MAX_DIMENSION || height > DESKTOP_BACKGROUND_MAX_DIMENSION + }); + + let mut command = std::process::Command::new("sips"); + command + .arg("-s") + .arg("format") + .arg("jpeg") + .arg("-s") + .arg("formatOptions") + .arg(DESKTOP_BACKGROUND_JPEG_QUALITY.to_string()); + if needs_downscale { + command + .arg("-Z") + .arg(DESKTOP_BACKGROUND_MAX_DIMENSION.to_string()); + } + if let Ok(output_status) = command.arg(source).arg("--out").arg(output).output() + && output_status.status.success() + { + return Ok(()); + } } - let decoded = cap_gpui_kernels::codec::open_image(source) + let decoded = cap_gpui_kernels::codec::decode_image_file(source) .map_err(|err| format!("failed to decode desktop background: {err}"))?; let decoded = if decoded.width() > DESKTOP_BACKGROUND_MAX_DIMENSION || decoded.height() > DESKTOP_BACKGROUND_MAX_DIMENSION @@ -2137,7 +2153,9 @@ impl EditorWindow { /// selected path. fn ensure_preview(&mut self, window: &mut Window, cx: &mut Context) { let Some(path) = self.preview_path() else { - self.sidebar.preview = None; + if let Some((_, previous)) = self.sidebar.preview.take() { + let _ = window.drop_image(previous); + } return; }; if self @@ -2157,8 +2175,10 @@ impl EditorWindow { .await }; let Some(image) = decoded else { return }; - this.update(cx, |this, cx| { - this.sidebar.preview = Some((target, image)); + this.update_in(cx, |this, window, cx| { + if let Some((_, previous)) = this.sidebar.preview.replace((target, image)) { + let _ = window.drop_image(previous); + } cx.notify(); }) .ok(); @@ -2426,6 +2446,13 @@ impl EditorWindow { } Err(error) => { tracing::error!("couldn't import your desktop wallpaper: {error}"); + cx.spawn(async move |_, _| { + crate::platform::alert_dialog( + "Couldn't import your desktop wallpaper", + &error, + ); + }) + .detach(); } } cx.notify(); @@ -4070,6 +4097,24 @@ impl EditorWindow { mod tests { use super::*; + #[test] + fn a_new_grain_step_retires_the_old_texture_for_atlas_eviction() { + let mut noise = NoiseCache::default(); + let first = noise.image(20.); + assert!(Arc::ptr_eq(&noise.image(20.2), &first)); + assert!(noise.take_retired().is_empty()); + + let second = noise.image(35.); + let third = noise.image(50.); + assert!(!Arc::ptr_eq(&second, &first)); + let retired = noise.take_retired(); + assert_eq!(retired.len(), 2); + assert!(Arc::ptr_eq(&retired[0], &first)); + assert!(Arc::ptr_eq(&retired[1], &second)); + assert!(noise.take_retired().is_empty()); + assert!(Arc::ptr_eq(&noise.image(50.), &third)); + } + #[test] fn the_catalogue_matches_the_source() { assert_eq!(WALLPAPER_NAMES.len(), 53); diff --git a/apps/desktop-gpui/src/editor_tabs.rs b/apps/desktop-gpui/src/editor_tabs.rs index a793647488b..d59830d162f 100644 --- a/apps/desktop-gpui/src/editor_tabs.rs +++ b/apps/desktop-gpui/src/editor_tabs.rs @@ -358,8 +358,7 @@ impl CameraSlider { match self { // `minValue={20} maxValue={80} step={0.1}` (`:3204-3206`). Self::Size => (20., 80., 0.1), - // `minValue={10} maxValue={60}` (`:3213-3215`). - Self::ZoomSize => (10., 60., 0.1), + Self::ZoomSize => (10., 100., 0.1), _ => (0., 100., 0.1), } } @@ -369,8 +368,7 @@ impl CameraSlider { let shadow = camera.advanced_shadow.as_ref(); match self { Self::Size => camera.size, - // `project.camera.zoomSize ?? 60` - Self::ZoomSize => camera.zoom_size.unwrap_or(60.), + Self::ZoomSize => camera.scale_during_zoom * 100., Self::Rounding => camera.rounding, Self::Shadow => camera.shadow, Self::ShadowSize => shadow.map_or(UI_SHADOW_FALLBACK.size, |s| s.size), @@ -1004,7 +1002,7 @@ impl EditorWindow { let camera = &mut project.camera; match slider { CameraSlider::Size => camera.size = value, - CameraSlider::ZoomSize => camera.zoom_size = Some(value), + CameraSlider::ZoomSize => camera.scale_during_zoom = value / 100., CameraSlider::Rounding => camera.rounding = value, CameraSlider::Shadow => camera.shadow = value, // `...(project.camera.advancedShadow ?? {50, 18, 50})` @@ -3473,7 +3471,7 @@ mod tests { #[test] fn every_tab_slider_has_the_range_its_call_site_declares() { assert_eq!(CameraSlider::Size.limits(), (20., 80., 0.1)); - assert_eq!(CameraSlider::ZoomSize.limits(), (10., 60., 0.1)); + assert_eq!(CameraSlider::ZoomSize.limits(), (10., 100., 0.1)); assert_eq!(CameraSlider::ShadowBlur.limits(), (0., 100., 0.1)); assert_eq!(AudioSlider::MicVolume.limits(), (-30., 10., 0.1)); assert_eq!(CursorSlider::Size.limits(), (20., 300., 1.)); @@ -3487,6 +3485,16 @@ mod tests { assert_eq!(CaptionSlider::FadeDuration.limits(), (0., 50., 1.)); } + #[test] + fn camera_zoom_size_slider_shows_the_rendered_scale() { + let mut project = ProjectConfiguration::default(); + assert_eq!(CameraSlider::ZoomSize.read(&project), 70.); + project.camera.scale_during_zoom = 0.4; + assert_eq!(CameraSlider::ZoomSize.read(&project), 40.); + project.camera.scale_during_zoom = 1.; + assert_eq!(CameraSlider::ZoomSize.read(&project), 100.); + } + #[test] fn the_percent_sliders_read_and_write_the_same_number() { // `value={[getSetting("fadeDuration") * 100]}` / `v[0] / 100`. diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index 43702143ce5..16773fb1a3e 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -316,7 +316,8 @@ impl Transform { /// `updateZoom(newZoom, origin)` (`ED/context.ts:1389-1403, 1456-1472`): /// clamp the zoom into `[MAX_ZOOM_IN, zoomOutLimit]`, then move the - /// position so `origin` stays at the same fraction across the viewport. + /// position so `origin` stays at the same fraction across the viewport, + /// or at its nearest edge when it is outside the viewport. /// /// The clamp is `Math.max(Math.min(newZoom, zoomOutLimit()), MAX_ZOOM_IN)` /// in that order, so on a project shorter than 3 s the *floor* wins and the @@ -327,7 +328,7 @@ impl Transform { .max(MAX_ZOOM_IN); let visible_origin = origin - self.position; - let origin_percentage = (visible_origin / self.zoom).min(1.); + let origin_percentage = (visible_origin / self.zoom).clamp(0., 1.); let new_visible_origin = zoom * origin_percentage; let new_position = origin - new_visible_origin; @@ -583,6 +584,7 @@ pub fn waveform_path(geometry: &WaveformGeometry) -> Option> target_samples, ref holds, source_start: segment_start, + timescale, origin, size, scale, @@ -591,12 +593,12 @@ pub fn waveform_path(geometry: &WaveformGeometry) -> Option> if peaks.is_empty() || scale <= 0. { return None; } - let duration = (range.1 - range.0).max(WAVEFORM_SAMPLE_STEP); + let duration = range.1 - range.0; if !duration.is_finite() || duration <= 0. { return None; } - let native_samples = (duration / WAVEFORM_SAMPLE_STEP).ceil() as usize + 1; + let native_samples = (duration * timescale / WAVEFORM_SAMPLE_STEP).ceil() as usize + 1; let num_samples = target_samples .clamp(50, MAX_WAVEFORM_SAMPLES) .min(native_samples); @@ -619,7 +621,7 @@ pub fn waveform_path(geometry: &WaveformGeometry) -> Option> break; } } - Some(segment_start + output_time - held) + Some(segment_start + (output_time - held) * timescale) }; let width = f32::from(size.width) as f64; @@ -646,7 +648,7 @@ pub fn waveform_path(geometry: &WaveformGeometry) -> Option> let mut builder = gpui::PathBuilder::fill(); builder.move_to(map(0., 1.)); - let control_step = (WAVEFORM_CONTROL_STEP / duration).min(0.25); + let control_step = (WAVEFORM_CONTROL_STEP / timescale / duration).min(0.25); for i in 0..=num_samples { let time = range.0 + i as f64 * time_step; @@ -692,6 +694,7 @@ pub struct WaveformGeometry { pub target_samples: usize, pub holds: Vec<(f64, f64)>, pub source_start: f64, + pub timescale: f64, pub origin: gpui::Point, pub size: gpui::Size, pub scale: f64, @@ -705,6 +708,7 @@ impl PartialEq for WaveformGeometry { && self.target_samples == other.target_samples && self.holds == other.holds && self.source_start == other.source_start + && self.timescale == other.timescale && self.origin == other.origin && self.size == other.size && self.scale == other.scale @@ -712,6 +716,96 @@ impl PartialEq for WaveformGeometry { } } +/// The span of a segment (segment-local output seconds) its cached waveform +/// path covers. The visible slice is grown to a window anchored on a grid of +/// a quarter of the viewport, so a timeline that scrolls -- playback following +/// the playhead while zoomed in -- reuses one path for a quarter-viewport of +/// travel instead of rebuilding it every frame. A segment that is entirely +/// visible covers exactly that, as before. +pub fn waveform_window(visible: (f64, f64), zoom: f64, duration: f64) -> (f64, f64) { + if !zoom.is_finite() || zoom <= 0. || !duration.is_finite() || duration <= 0. { + return visible; + } + let grid = zoom / 4.; + let step = (visible.0 / grid).floor() - 1.; + let start = (step * grid).max(0.).min(visible.0); + let end = (start + zoom * 1.5).max(visible.1).min(duration); + (start, end) +} + +/// Where a segment's waveform path sits inside its canvas. Everything is +/// canvas-local, so a timeline that only scrolls keeps hitting the cache; the +/// path is moved into place when painted. +#[derive(Debug, Clone, Copy, PartialEq)] +struct WaveformLayout { + range: (f64, f64), + origin: gpui::Point, + size: gpui::Size, + samples: usize, + clip_bounds: gpui::Bounds, +} + +/// `view` is the timeline's (position, zoom), `segment` its (start, output +/// duration) and `canvas` the (canvas height, lane width, lane height, wave +/// height). `None` when the segment is off screen. +fn waveform_layout( + view: (f64, f64), + segment: (f64, f64), + full_width: f64, + canvas: (Pixels, Pixels, Pixels, Pixels), +) -> Option { + let (view_start, zoom) = view; + let (timeline_start, output_duration) = segment; + let (canvas_height, lane_width, lane_height, wave_height) = canvas; + // The visible slice, in segment-local output seconds + // (`TL/ClipTrack.tsx:202-245`). + let view_end = view_start + zoom; + let visible_start = view_start.max(timeline_start) - timeline_start; + let visible_end = view_end.min(timeline_start + output_duration) - timeline_start; + if visible_end <= visible_start { + return None; + } + let px_per_sec = full_width / output_duration; + let range = waveform_window((visible_start, visible_end), zoom, output_duration); + let window_width = (range.1 - range.0) * px_per_sec; + Some(WaveformLayout { + range, + origin: gpui::point(px((range.0 * px_per_sec) as f32), px(0.)), + size: gpui::size(px(window_width as f32), wave_height), + samples: waveform_window_sample_count(window_width, zoom * px_per_sec), + clip_bounds: gpui::Bounds { + origin: gpui::point(px(0.), canvas_height - lane_height), + size: gpui::size(lane_width, lane_height), + }, + }) +} + +/// `waveform_sample_count` for a window wider than the viewport: the sample +/// cap grows with the window, so the slice on screen keeps the density it had +/// when only the visible slice was built. +fn waveform_window_sample_count(window_width: f64, viewport_width: f64) -> usize { + let scale = if viewport_width.is_finite() && viewport_width > 0. { + (window_width / viewport_width).max(1.) + } else { + 1. + }; + let cap = (MAX_WAVEFORM_SAMPLES as f64 * scale).ceil() as usize; + ((window_width * WAVEFORM_SAMPLES_PER_PIXEL).ceil() as usize).min(cap) +} + +/// `path` moved by `offset`: cached waveform paths are built relative to their +/// segment and placed where the segment is drawn this frame. +fn translated_path( + mut path: gpui::Path, + offset: gpui::Point, +) -> gpui::Path { + for vertex in &mut path.vertices { + vertex.xy_position = vertex.xy_position + offset; + } + path.bounds.origin = path.bounds.origin + offset; + path +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum WaveformTrack { Mic, @@ -1140,14 +1234,7 @@ pub enum SegmentDetail { impl SegmentDetail { fn shows_waveform(&self) -> bool { - matches!( - self, - Self::Clip { - timescale, - muted: false, - .. - } if *timescale == 1.0 - ) + matches!(self, Self::Clip { muted: false, .. }) } } @@ -3191,6 +3278,7 @@ fn render_waveform( let SegmentDetail::Clip { source_start: segment_start, + timescale, volume, .. } = segment.detail @@ -3214,27 +3302,13 @@ fn render_waveform( gpui::canvas( |bounds, _window, _cx| bounds, move |_, bounds, window, _cx| { - // The visible slice, in segment-local output seconds - // (`TL/ClipTrack.tsx:202-245`). Off screen entirely: nothing. - let view_start = transform.position; - let view_end = view_start + transform.zoom; - let visible_start = view_start.max(timeline_start) - timeline_start; - let visible_end = view_end.min(timeline_start + output_duration) - timeline_start; - if visible_end <= visible_start { + let Some(layout) = waveform_layout( + (transform.position, transform.zoom), + (timeline_start, output_duration), + full_width, + (bounds.size.height, px(width), px(height), px(wave_height)), + ) else { return; - } - let px_per_sec = full_width / output_duration; - let origin = gpui::point( - bounds.origin.x + px((visible_start * px_per_sec) as f32), - bounds.origin.y, - ); - let slice_width = ((visible_end - visible_start) * px_per_sec) as f32; - let size = gpui::size(px(slice_width), px(wave_height)); - let samples = waveform_sample_count(slice_width as f64); - - let clip_bounds = gpui::Bounds { - origin: gpui::point(bounds.origin.x, bounds.bottom() - px(height)), - size: gpui::size(px(width), px(height)), }; for (track, peaks, scale) in [ @@ -3243,16 +3317,18 @@ fn render_waveform( ] { let geometry = WaveformGeometry { peaks: peaks.clone(), - range: (visible_start, visible_end), - target_samples: samples, + range: layout.range, + target_samples: layout.samples, holds: holds.clone(), source_start: segment_start, - origin, - size, + timescale, + origin: layout.origin, + size: layout.size, scale, - clip_bounds, + clip_bounds: layout.clip_bounds, }; if let Some(path) = paths.get_or_build((index, track), geometry) { + let path = translated_path(path, bounds.origin); window.with_content_mask(Some(gpui::ContentMask { bounds }), |window| { window.paint_path(path, wave_color) }); @@ -3848,6 +3924,19 @@ mod tests { ); } + #[test] + fn an_origin_before_the_viewport_pins_to_its_left_edge() { + let mut transform = Transform { + zoom: 10., + position: 20., + }; + + transform.update_zoom(20., 5., 100.); + + assert_eq!(transform.zoom, 20.); + assert_eq!(transform.position, 5.); + } + #[test] fn position_clamps_to_zero_and_to_the_content_end_plus_four() { let total = 60.0; @@ -4364,7 +4453,7 @@ mod tests { } #[test] - fn clip_mute_indicators_follow_speed_audio_modes() { + fn clip_waveforms_and_mute_indicators_follow_speed_audio_modes() { let config: ProjectConfiguration = serde_json::from_value(serde_json::json!({ "timeline": { "segments": [ @@ -4383,7 +4472,7 @@ mod tests { clip.detail, SegmentDetail::Clip { muted, .. } if muted == expected_muted )); - assert!(!clip.detail.shows_waveform()); + assert_eq!(clip.detail.shows_waveform(), !expected_muted); } } @@ -4598,6 +4687,7 @@ mod tests { target_samples: 400, holds: Vec::new(), source_start: 20., + timescale: 1., origin: gpui::point(px(0.), px(26.)), size: gpui::size(px(200.), px(18.)), scale, @@ -4695,6 +4785,7 @@ mod tests { target_samples: 400, holds: Vec::new(), source_start: 5., + timescale: 1., origin: gpui::point(px(10.), px(26.)), size: gpui::size(px(300.), px(18.)), scale: 0.8, @@ -4705,6 +4796,251 @@ mod tests { } } + #[test] + fn waveform_path_retimes_trimmed_source_around_holds() { + let mut peaks = vec![-60.; 200]; + peaks[140..160].fill(-6.); + let peaks = Arc::new(peaks); + for timescale in [0.25, 0.5, 2.0, 8.0] { + for holds in [vec![], vec![(1. / timescale, 1. / timescale + 1.)]] { + let held = if holds.is_empty() { 0. } else { 1. }; + let duration = 8. / timescale + held; + for range in [(0., duration), (duration / 4., duration * 0.9)] { + let path = waveform_path(&WaveformGeometry { + peaks: peaks.clone(), + range, + target_samples: 1200, + holds: holds.clone(), + source_start: 10., + timescale, + origin: gpui::point(px(0.), px(26.)), + size: gpui::size(px(600.), px(18.)), + scale: 1., + clip_bounds: gpui::Bounds { + origin: gpui::point(px(0.), px(0.)), + size: gpui::size(px(600.), px(44.)), + }, + }) + .unwrap(); + let (first, last) = path + .vertices + .iter() + .filter(|vertex| f32::from(vertex.xy_position.y) < 35.) + .map(|vertex| { + range.0 + + f64::from(f32::from(vertex.xy_position.x)) / 600. + * (range.1 - range.0) + }) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(first, last), time| { + (first.min(time), last.max(time)) + }); + let expected_start = 4. / timescale + held; + let expected_end = 6. / timescale + held; + assert!( + (first - expected_start).abs() < 0.2 / timescale, + "{timescale}x, {holds:?}, {range:?}: first {first}, expected {expected_start}" + ); + assert!( + (last - expected_end).abs() < 0.2 / timescale, + "{timescale}x, {holds:?}, {range:?}: last {last}, expected {expected_end}" + ); + } + } + } + } + + #[test] + fn short_retimed_waveform_does_not_read_past_the_trim_end() { + let mut peaks = vec![-60.; 200]; + peaks[103..105].fill(0.); + let geometry = WaveformGeometry { + range: (0., 0.025), + source_start: 10., + timescale: 8., + ..waveform_geometry(Arc::new(peaks)) + }; + let path = waveform_path(&geometry).unwrap(); + let silent = waveform_path(&WaveformGeometry { + peaks: Arc::new(vec![-60.; 200]), + ..geometry + }) + .unwrap(); + assert_eq!(waveform_vertices(&path), waveform_vertices(&silent)); + } + + #[test] + fn the_waveform_window_always_covers_the_visible_slice() { + let duration = 120.; + let zoom = 20.; + let mut start = 0.; + while start + zoom <= duration { + let visible = (start, start + zoom); + let window = waveform_window(visible, zoom, duration); + assert!( + window.0 <= visible.0 && window.1 >= visible.1, + "{window:?} {visible:?}" + ); + assert!(window.1 - window.0 <= zoom * 1.5 + 1e-9); + start += 0.37; + } + } + + #[test] + fn a_fully_visible_segment_covers_exactly_its_visible_slice() { + assert_eq!(waveform_window((0., 8.), 30., 8.), (0., 8.)); + assert_eq!(waveform_window((0., 30.), 30., 30.), (0., 30.)); + } + + #[test] + fn scrolling_reuses_the_window_for_a_quarter_viewport() { + let zoom = 20.; + let duration = 600.; + let mut windows = std::collections::HashSet::new(); + // 60 s of follow-mode playback at 60 fps. + for frame in 0..3600 { + let start = 100. + frame as f64 / 60.; + let window = waveform_window((start, start + zoom), zoom, duration); + windows.insert((window.0.to_bits(), window.1.to_bits())); + } + assert!(windows.len() <= 13, "{} distinct windows", windows.len()); + } + + #[test] + fn a_translated_relative_path_matches_one_built_in_place() { + let peaks = speech_like_peaks(400, 7.); + let offset = gpui::point(px(250.5), px(312.25)); + let relative = waveform_geometry(peaks.clone()); + let path = waveform_path(&relative).unwrap(); + let moved = translated_path(path.clone(), offset); + for (before, after) in path.vertices.iter().zip(&moved.vertices) { + assert_eq!(after.xy_position, before.xy_position + offset); + } + assert_eq!(moved.bounds.origin, path.bounds.origin + offset); + assert_eq!(moved.bounds.size, path.bounds.size); + + // Building at the absolute position gives the same shape; the + // tessellator's triangle order can differ with float rounding, so the + // comparison is on the vertex count and the bounds. + let absolute = WaveformGeometry { + origin: relative.origin + offset, + clip_bounds: gpui::Bounds { + origin: relative.clip_bounds.origin + offset, + size: relative.clip_bounds.size, + }, + ..relative.clone() + }; + let built = waveform_path(&absolute).unwrap(); + assert_eq!(built.vertices.len(), moved.vertices.len()); + let close = |a: Pixels, b: Pixels| (f32::from(a) - f32::from(b)).abs() < 0.01; + assert!(close(built.bounds.origin.x, moved.bounds.origin.x)); + assert!(close(built.bounds.origin.y, moved.bounds.origin.y)); + assert!(close(built.bounds.size.width, moved.bounds.size.width)); + assert!(close(built.bounds.size.height, moved.bounds.size.height)); + } + + #[test] + fn follow_mode_paths_are_built_once_per_window() { + let paths = WaveformPaths::default(); + let slot = (0, WaveformTrack::Mic); + let peaks = speech_like_peaks(2000, 9.); + let zoom = 20.; + let segment = (12., 190.); + let full_width = 190. * 60.; + let canvas = (px(44.), px(full_width as f32), px(44.), px(18.)); + for frame in 0..600 { + let position = 40. + frame as f64 / 60.; + let layout = waveform_layout((position, zoom), segment, full_width, canvas).unwrap(); + let geometry = WaveformGeometry { + range: layout.range, + target_samples: layout.samples, + origin: layout.origin, + size: layout.size, + clip_bounds: layout.clip_bounds, + ..waveform_geometry(peaks.clone()) + }; + paths.get_or_build(slot, geometry).unwrap(); + } + let stats = paths.stats(); + assert!(stats.builds <= 3, "{stats:?}"); + assert_eq!(stats.builds + stats.hits, 600); + } + + #[test] + fn retimed_waveforms_stay_in_place_across_scrolled_windows() { + // A 2x clip from source 10 s with a 1 s hold at output 2..3 s: the + // loud source 30..32 s lands at segment-local output 11..12 s. + let mut peaks = vec![-60.; 600]; + peaks[300..320].fill(-6.); + let peaks = Arc::new(peaks); + let zoom = 6.; + let segment = (12., 21.); + let full_width = 21. * 60.; + let canvas = (px(44.), px(full_width as f32), px(44.), px(18.)); + let offset = gpui::point(px(100.), px(50.)); + for visible_start in [6.5, 8., 9.5] { + let layout = waveform_layout( + (segment.0 + visible_start, zoom), + segment, + full_width, + canvas, + ) + .unwrap(); + assert!(layout.range.0 > 0. && layout.range.0 < visible_start); + let path = waveform_path(&WaveformGeometry { + peaks: peaks.clone(), + range: layout.range, + target_samples: layout.samples, + holds: vec![(2., 3.)], + source_start: 10., + timescale: 2., + origin: layout.origin, + size: layout.size, + scale: 1., + clip_bounds: layout.clip_bounds, + }) + .unwrap(); + let path = translated_path(path, offset); + let (first, last) = path + .vertices + .iter() + .filter(|vertex| f32::from(vertex.xy_position.y) < 59.) + .map(|vertex| f64::from(f32::from(vertex.xy_position.x) - 100.) / 60.) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(first, last), time| { + (first.min(time), last.max(time)) + }); + assert!( + (first - 11.).abs() < 0.1, + "{visible_start}: {layout:?} first {first}" + ); + assert!( + (last - 12.).abs() < 0.1, + "{visible_start}: {layout:?} last {last}" + ); + } + } + + #[test] + fn an_off_screen_segment_has_no_waveform_layout() { + let canvas = (px(44.), px(600.), px(44.), px(18.)); + assert!(waveform_layout((0., 20.), (30., 10.), 600., canvas).is_none()); + assert!(waveform_layout((50., 20.), (30., 10.), 600., canvas).is_none()); + } + + #[test] + fn the_wider_window_keeps_the_on_screen_sample_density_at_the_cap() { + for (window, viewport) in [(15_000., 10_000.), (4_500., 3_000.), (900., 600.)] { + let samples = waveform_window_sample_count(window, viewport); + let window_density = samples as f64 / window; + let visible_density = waveform_sample_count(viewport) as f64 / viewport; + assert!( + (window_density - visible_density).abs() < 1e-3, + "{window} {viewport}: {window_density} vs {visible_density}" + ); + } + assert_eq!(waveform_window_sample_count(300., 600.), 600); + assert_eq!(waveform_window_sample_count(300., 0.), 600); + } + #[test] fn cached_waveform_matches_an_uncached_build_after_every_input_change() { let paths = WaveformPaths::default(); @@ -4739,6 +5075,13 @@ mod tests { ..base.clone() }, ), + ( + "speed", + WaveformGeometry { + timescale: 2., + ..base.clone() + }, + ), ( "source start", WaveformGeometry { @@ -4898,6 +5241,7 @@ mod tests { target_samples: 200, holds: Vec::new(), source_start: 0., + timescale: 1., origin: gpui::point(px(50. + x as f32), px(56.)), size: gpui::size(px(span as f32), px(18.)), scale: gain, diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 5acbe7e664e..202d743c09d 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -283,6 +283,17 @@ pub fn letterbox(container: (f32, f32), frame: (f32, f32)) -> (f32, f32) { } } +fn frame_seconds(frame: u32) -> f64 { + frame as f64 / EDITOR_PREVIEW_FPS as f64 +} + +/// Where a seek leaves the playhead. Paused, the engine echoes the seek back +/// as `playhead_changed(frame)`; storing the frame's own time makes that echo +/// a no-op instead of a second whole-editor rebuild for every scrub step. +fn seeked_playhead(time: f64, frame: u32, playing: bool) -> f64 { + if playing { time } else { frame_seconds(frame) } +} + fn frame_layout_requires_editor_invalidation( first_frame: bool, playing: bool, @@ -1545,6 +1556,8 @@ pub struct EditorWindow { preview_quality: crate::store::EditorPreviewQuality, pub(crate) tracks: TrackLanes, toolbar_menu: Option, + text_menu: Option, + text_style_clipboard: Option, frame_controls: frame::FrameControls, add_track: Option, add_track_opens: usize, @@ -1652,6 +1665,7 @@ fn add_track_tile_progress(delta: f32, index: usize) -> f32 { } const CLIP_MENU_WIDTH: f32 = 300.; +const TEXT_MENU_WIDTH: f32 = 250.; /// Roughly the menu's laid-out height; only decides which side of its anchor /// the card opens on. const CLIP_MENU_HEIGHT_ESTIMATE: f32 = 440.; @@ -1679,10 +1693,11 @@ fn clip_menu_placement( index: usize, origin: Point, viewport: gpui::Size, + menu_width: f32, ) -> ClipSpeedMenu { let (width, height) = (f32::from(viewport.width), f32::from(viewport.height)); let left = px((f32::from(origin.x) - 8.) - .min(width - CLIP_MENU_WIDTH - 12.) + .min(width - menu_width - 12.) .max(12.)); let y = f32::from(origin.y); let room_above = y - 20.; @@ -1866,6 +1881,12 @@ struct OpenToolbarMenu { state: ui::MenuState, } +struct OpenTextMenu { + index: usize, + indices: Vec, + placement: ClipSpeedMenu, +} + impl EditorWindow { pub fn new(project_path: PathBuf, window: &mut Window, cx: &mut Context) -> Self { crate::theme::bind_window(window, cx); @@ -2113,6 +2134,8 @@ impl EditorWindow { preview_quality: crate::store::GeneralSettings::load().editor_preview_quality, tracks: TrackLanes::from_project(&ProjectConfiguration::default(), false), toolbar_menu: None, + text_menu: None, + text_style_clipboard: None, frame_controls: frame::FrameControls::default(), add_track: None, add_track_opens: 0, @@ -2253,6 +2276,7 @@ impl EditorWindow { /// carried across. fn rebuild_timeline(&mut self) { dismiss_indexed_sidebar_menu(&mut self.sidebar.menu); + self.text_menu = None; let mic = std::mem::take(&mut self.timeline.mic_waveforms); let system = std::mem::take(&mut self.timeline.system_waveforms); let waveform_paths = self.timeline.waveform_paths.clone(); @@ -2542,6 +2566,7 @@ impl EditorWindow { ) { self.sidebar.style_target = None; self.sidebar.menu = None; + self.text_menu = None; self.selection = None; self.sidebar.image_asset_status = None; self.canvas_selection = None; @@ -2967,7 +2992,7 @@ impl EditorWindow { /// is `setEditorState("playbackTime", payload.playhead_position / FPS)` /// (`Editor.tsx:482-486`). pub fn playhead_changed(&mut self, frame: u32, window: &mut Window, cx: &mut Context) { - let next = frame as f64 / EDITOR_PREVIEW_FPS as f64; + let next = frame_seconds(frame); if (next - self.playhead).abs() < f64::EPSILON { return; } @@ -3188,11 +3213,53 @@ impl EditorWindow { /// (`PlaybackHandle::seek`), so a scrub during playback holds the last /// picture for one decode instead of paying a warmup per tick. pub fn seek_to_time(&mut self, time: f64, cx: &mut Context) { + if self.apply_seek(time) { + cx.notify(); + } + } + + /// One pointer move of a ruler scrub. While paused, the playhead is read + /// by the timeline, the transport clock and what the window itself renders + /// (the canvas overlay, the clip popover), so this invalidates those the + /// way [`Self::invalidate_hover_chrome`] does instead of rebuilding the + /// header, toolbar and sidebar on every move. The one sidebar reader is + /// the 3D panel's "on the playhead" pose marker, which is only redrawn + /// when it changes. Playing and handoff seeks keep the full invalidation. + fn scrub_to_time(&mut self, time: f64, cx: &mut Context) { + let poses = self.camera3d_poses_at_playhead(); + if !self.apply_seek(time) { + return; + } + if self.playing || self.ordinary_handoff_frame.is_some() { + cx.notify(); + return; + } + self.invalidate_hover_chrome(cx); + if self.camera3d_poses_at_playhead() != poses { + self.sidebar_view.update(cx, |_, cx| cx.notify()); + } + } + + fn camera3d_poses_at_playhead(&self) -> Vec<(usize, bool)> { + crate::editor_panels::camera3d_poses_at( + self.project + .timeline + .as_ref() + .map(|timeline| timeline.camera3d_segments.as_slice()) + .unwrap_or(&[]), + self.playhead_time(), + EDITOR_PREVIEW_FPS, + ) + } + + /// `seek_to_time` minus the invalidation. False when the seek went to the + /// preparing transport, which redraws through its own events. + fn apply_seek(&mut self, time: f64) -> bool { // Moving the playhead by hand ends any shot audition. self.play_until = None; if self.preparing_transport_active() { self.preparing_command(Some(time), None); - return; + return false; } let Some(transport) = &self.transport else { if let Some(target) = self @@ -3202,7 +3269,7 @@ impl EditorWindow { { self.preparing_command(Some(target), None); } - return; + return false; }; let time = time.clamp(0.0, self.total_duration()); // `Math.round(newTime * FPS)` -- "round to nearest frame to prevent @@ -3230,10 +3297,11 @@ impl EditorWindow { } else { transport.seek(frame); } + let time = seeked_playhead(time, frame, self.playing); self.playhead = time; self.view.playhead = time; self.view.playing = self.playing; - cx.notify(); + true } /// The prev button (`Player.tsx:370-381`): stop, playhead to 0, and the @@ -3448,6 +3516,12 @@ impl EditorWindow { cx.stop_propagation(); return; } + if self.text_menu.is_some() && event.keystroke.key.as_str() == "escape" { + self.text_menu = None; + cx.stop_propagation(); + cx.notify(); + return; + } if self.add_track.is_some() && event.keystroke.key.as_str() == "escape" { self.add_track = None; cx.stop_propagation(); @@ -3860,7 +3934,7 @@ impl EditorWindow { ) } - fn timeline_mouse_down( + pub(crate) fn timeline_mouse_down( &mut self, event: &MouseDownEvent, ruler: bool, @@ -3883,7 +3957,7 @@ impl EditorWindow { cx.notify(); } - fn timeline_mouse_move( + pub(crate) fn timeline_mouse_move( &mut self, event: &MouseMoveEvent, window: &mut Window, @@ -3904,7 +3978,7 @@ impl EditorWindow { // supersedes is simply overwritten, and the preview renderer cancels // in-flight work when it sees a newer instruction // (`editor_instance.rs:563-575`). - self.seek_to_time(time, cx); + self.scrub_to_time(time, cx); } /// The **timeline's own** mouseup, the one the source registers on @@ -3932,7 +4006,7 @@ impl EditorWindow { /// dispose())`) and to end a ruler scrub. It runs after the timeline's own /// handler -- gpui bubbles child to parent -- so a press released *outside* /// the timeline is dropped rather than seeking. - fn window_mouse_up(&mut self, cx: &mut Context) { + pub(crate) fn window_mouse_up(&mut self, cx: &mut Context) { if self.scrub.take().is_some() { cx.notify(); } @@ -3983,6 +4057,7 @@ impl EditorWindow { /// `setEditorState("timeline", "selection", ...)`. pub(crate) fn set_selection(&mut self, selection: Option, cx: &mut Context) { if self.selection != selection { + self.text_menu = None; self.dismiss_frame_controls(cx); self.end_field_edit(cx); self.close_color_picker(cx); @@ -7807,7 +7882,8 @@ impl EditorWindow { ) { let focus = self.focus_handle_for_menu(); window.focus(&focus, cx); - let menu = clip_menu_placement(index, origin, window.viewport_size()); + self.text_menu = None; + let menu = clip_menu_placement(index, origin, window.viewport_size(), CLIP_MENU_WIDTH); tracing::info!( index, left = f32::from(menu.left), @@ -7886,8 +7962,7 @@ impl EditorWindow { /// `onContextMenu` on a clip's `SegmentRoot` (`TL/ClipTrack.tsx`): select /// the clip unless it is already part of the selection, then open its - /// menu at the pointer. Other tracks have no context menu, and split mode - /// keeps every press for the cut. + /// menu at the pointer. Split mode keeps every press for the cut. fn track_context_menu( &mut self, kind: TrackKind, @@ -7896,7 +7971,10 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { - if kind != TrackKind::Clip || self.split_mode || self.transport.is_none() { + if !matches!(kind, TrackKind::Clip | TrackKind::Text) + || self.split_mode + || self.transport.is_none() + { return; } let viewport_width: f32 = window.viewport_size().width.into(); @@ -7913,13 +7991,247 @@ impl EditorWindow { return; }; cx.stop_propagation(); - let already_selected = self.selection.as_ref().is_some_and(|selection| { - selection.track == TrackKind::Clip && selection.indices.contains(&index) - }); + let already_selected = self + .selection + .as_ref() + .is_some_and(|selection| selection.contains(kind, index)); if !already_selected { - self.set_selection(Some(Selection::single(TrackKind::Clip, index)), cx); + self.set_selection(Some(Selection::single(kind, index)), cx); + } + match kind { + TrackKind::Clip => self.open_clip_menu_at(index, event.position, window, cx), + _ => self.open_text_menu_at(index, event.position, window, cx), + } + } + + fn open_text_menu_at( + &mut self, + index: usize, + origin: Point, + window: &mut Window, + cx: &mut Context, + ) { + let focus = self.focus_handle_for_menu(); + window.focus(&focus, cx); + self.clip_speed = None; + self.add_track = None; + let indices = self + .selection + .as_ref() + .filter(|selection| selection.contains(TrackKind::Text, index)) + .map_or_else(|| vec![index], |selection| selection.indices.clone()); + self.text_menu = Some(OpenTextMenu { + index, + indices, + placement: clip_menu_placement(index, origin, window.viewport_size(), TEXT_MENU_WIDTH), + }); + cx.notify(); + } + + fn copy_text_style(&mut self, index: usize) { + let segment = self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.text_segments.get(index)); + let Some(segment) = segment else { + return; + }; + self.text_style_clipboard = Some(crate::editor_panels::TextStyle::of(segment)); + tracing::info!(index, "text style copied"); + } + + fn paste_text_style(&mut self, indices: &[usize], window: &mut Window, cx: &mut Context) { + let Some(style) = self.text_style_clipboard.clone() else { + return; + }; + let mut changed = false; + self.edit_project("text-paste-style", window, cx, |project| { + let Some(timeline) = project.timeline.as_mut() else { + return false; + }; + for index in indices { + if let Some(segment) = timeline.text_segments.get_mut(*index) + && style.apply_to(segment) + { + changed = true; + } + } + changed + }); + if changed { + self.note_edit("text-paste-style", Some(TrackKind::Text)); } - self.open_clip_menu_at(index, event.position, window, cx); + } + + fn render_text_menu(&self, cx: &mut Context) -> Option { + let menu = self.text_menu.as_ref()?; + let theme = self.theme; + let timeline = self.project.timeline.as_ref()?; + let segment = timeline.text_segments.get(menu.index)?; + let index = menu.index; + let indices = menu.indices.clone(); + let count = indices.len(); + let can_paste = self.text_style_clipboard.is_some(); + let title: SharedString = if count > 1 { + format!("{count} text segments").into() + } else { + let content = segment.content.trim(); + if content.is_empty() { + "Text".into() + } else { + content.to_string().into() + } + }; + let summary = crate::editor_clips::format_clip_duration(segment.end - segment.start); + let paste_label: &'static str = if count > 1 { + "Paste style to all selected" + } else { + "Paste style" + }; + let delete_label: &'static str = if count > 1 { + "Delete text segments" + } else { + "Delete text segment" + }; + let divider = || div().h(px(1.)).bg(Hsla::from(theme.editor.line)); + + let mut card = div() + .id("text-menu-popover") + .absolute() + .left(menu.placement.left) + .w(px(TEXT_MENU_WIDTH)) + .flex() + .flex_col() + .gap(px(8.)) + .rounded(px(12.)) + .border_1() + .border_color(Hsla::from(theme.editor.line)) + .bg(Hsla::from(theme.editor.card)) + .p(px(6.)) + .shadow(theme.editor.pop_shadow()) + .on_mouse_down( + MouseButton::Left, + cx.listener(|_, _, _, cx| cx.stop_propagation()), + ); + card = match menu.placement.anchor { + ClipMenuAnchor::Above { bottom } => card.bottom(bottom), + ClipMenuAnchor::Below { top } => card.top(top), + }; + + let paste_indices = indices.clone(); + let card = card + .child( + div() + .flex() + .items_center() + .gap(px(8.)) + .px(px(8.)) + .pt(px(4.)) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(theme.editor.text_1)) + .child(title), + ) + .child( + div() + .flex_none() + .text_size(px(10.5)) + .text_color(Hsla::from(theme.editor.text_3)) + .child(summary), + ), + ) + .child( + div() + .flex() + .flex_col() + .child( + clip_menu_row( + &theme, + "text-menu-copy-style", + "icons/copy.svg", + "Copy style", + ClipMenuTrailing::None, + true, + false, + ) + .on_click(cx.listener( + move |this, _, _window, cx| { + this.text_menu = None; + this.copy_text_style(index); + cx.notify(); + }, + )), + ) + .child( + clip_menu_row( + &theme, + "text-menu-paste-style", + "icons/clipboard-paste.svg", + paste_label, + if can_paste { + ClipMenuTrailing::None + } else { + ClipMenuTrailing::Hint("Copy a style first") + }, + can_paste, + false, + ) + .when(can_paste, |this| { + this.on_click(cx.listener(move |this, _, window, cx| { + this.text_menu = None; + this.paste_text_style(&paste_indices, window, cx); + cx.notify(); + })) + }), + ), + ) + .child(divider()) + .child( + clip_menu_row( + &theme, + "text-menu-delete", + "icons/trash.svg", + delete_label, + ClipMenuTrailing::Kbd("backspace"), + true, + true, + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.text_menu = None; + this.delete_selection(window, cx); + cx.notify(); + })), + ); + + Some( + div() + .absolute() + .top_0() + .left_0() + .size_full() + .child( + div() + .id("text-menu-backdrop") + .absolute() + .top_0() + .left_0() + .size_full() + .on_click(cx.listener(|this, _, _, cx| { + this.text_menu = None; + cx.notify(); + })), + ) + .child(card) + .into_any_element(), + ) } fn set_clip_timescale( @@ -8229,6 +8541,7 @@ impl EditorWindow { .bg(Hsla::from(theme.editor.card)) .p(px(6.)) .shadow(theme.editor.pop_shadow()) + .occlude() .on_mouse_down( MouseButton::Left, cx.listener(|_, _, _, cx| cx.stop_propagation()), @@ -9160,6 +9473,8 @@ impl EditorWindow { ) .child( div() + .debug_selector(|| "preset-dialog".into()) + .occlude() .w(px(400.)) .flex() .flex_col() @@ -9864,11 +10179,17 @@ impl EditorWindow { }; div() + .debug_selector(|| "editor-preview-canvas".into()) .relative() .flex_1() .min_h_0() .overflow_hidden() .bg(Hsla::from(theme.editor.card)) + .capture_any_mouse_down(cx.listener(|this, event: &MouseDownEvent, window, cx| { + if event.button == MouseButton::Left { + this.focus_root(window, cx); + } + })) .child(body) // `CanvasElementsOverlay` + `SnapGuidesOverlay` // (`Player.tsx:636-643`), both mounted inside the letterbox @@ -11216,6 +11537,7 @@ impl Render for EditorWindow { }); self.prepare_frame_fields(window, cx); self.sync_crop_container(window); + self.sidebar.release_retired_images(window); let theme = self.theme; // The timeline's own bounds are what `secsPerPixel` divides by, and // this window is resizable, so read them off the viewport rather than @@ -11674,6 +11996,7 @@ impl Render for EditorWindow { ) .children(self.with_style_controls(|this| this.render_sidebar_menu(cx))) .children(self.render_toolbar_menu(cx)) + .children(self.render_text_menu(cx)) .children(self.render_frame_controls(window, cx)) .children(self.render_add_track_popover(cx)) .children(self.render_clip_speed_popover(cx)) @@ -12538,6 +12861,64 @@ mod tests { } } + // gpui's test windows have no platform window handle on macOS or Windows, which the editor needs. + #[cfg(target_os = "linux")] + #[gpui::test] + fn preview_click_returns_focus_to_the_editor(cx: &mut gpui::TestAppContext) { + let project = tempfile::tempdir().unwrap(); + let project_path = project.path().to_path_buf(); + let (editor, cx) = cx + .add_window_view(move |window, cx| EditorWindow::new(project_path.clone(), window, cx)); + editor.update_in(cx, |editor, window, cx| { + editor.set_error("unreadable".into(), window, cx); + }); + let (input_focus, editor_focus) = editor.read_with(cx, |editor, cx| { + ( + editor.name_input.read(cx).focus_handle(), + editor.focus.clone(), + ) + }); + + cx.update(|window, cx| window.focus(&input_focus, cx)); + cx.update(|window, _| assert!(input_focus.is_focused(window))); + + let preview = cx.debug_bounds("editor-preview-canvas").unwrap(); + cx.simulate_mouse_move(preview.center(), None, gpui::Modifiers::default()); + cx.simulate_click(preview.center(), gpui::Modifiers::default()); + + cx.update(|window, _| { + assert!(!input_focus.is_focused(window)); + assert!(editor_focus.is_focused(window)); + }); + } + + // gpui's test windows have no platform window handle on macOS or Windows, which the editor needs. + #[cfg(target_os = "linux")] + #[gpui::test] + fn dialog_click_over_the_preview_keeps_its_focus(cx: &mut gpui::TestAppContext) { + let project = tempfile::tempdir().unwrap(); + let project_path = project.path().to_path_buf(); + let (editor, cx) = cx + .add_window_view(move |window, cx| EditorWindow::new(project_path.clone(), window, cx)); + editor.update_in(cx, |editor, window, cx| { + editor.set_error("unreadable".into(), window, cx); + editor.open_preset_dialog(PresetDialog::Create { default: false }, window, cx); + }); + let input_focus = editor.read_with(cx, |editor, cx| { + editor.preset_name_input.read(cx).focus_handle() + }); + cx.run_until_parked(); + cx.update(|window, _| assert!(input_focus.is_focused(window))); + + let preview = cx.debug_bounds("editor-preview-canvas").unwrap(); + let title = cx.debug_bounds("preset-dialog").unwrap().origin + point(px(8.), px(8.)); + assert!(preview.contains(&title)); + cx.simulate_mouse_move(title, None, gpui::Modifiers::default()); + cx.simulate_click(title, gpui::Modifiers::default()); + + cx.update(|window, _| assert!(input_focus.is_focused(window))); + } + /// `default_editor_preview_resolution()` is asserted to be 1248x702 in the /// Tauri app itself (`lib.rs:192-194`); the render size of a display /// recording follows from it. @@ -12677,6 +13058,19 @@ mod tests { assert_eq!(playhead_extrapolation(true, true, -0.01), 0.0); } + /// A paused seek stores exactly the time its engine echo carries, so the + /// echo's `playhead_changed` returns before rebuilding the editor again. + #[test] + fn paused_seek_lands_where_its_echo_does() { + for frame in 0..=EDITOR_PREVIEW_FPS * 3600 { + let time = (frame as f64 + 0.37) / EDITOR_PREVIEW_FPS as f64; + let seeked = seeked_playhead(time, frame, false); + assert_eq!(seeked, frame_seconds(frame)); + assert!((frame_seconds(frame) - seeked).abs() < f64::EPSILON); + } + assert_eq!(seeked_playhead(1.234, 74, true), 1.234); + } + /// The perf gate's line. `frames` is what reached the window, `dropped` /// what the pump refused, and the rate is measured on *painted* frames. #[test] diff --git a/apps/desktop-gpui/src/feeds.rs b/apps/desktop-gpui/src/feeds.rs index 17f195422ef..4b02665fb31 100644 --- a/apps/desktop-gpui/src/feeds.rs +++ b/apps/desktop-gpui/src/feeds.rs @@ -313,7 +313,7 @@ impl CameraProcessingFactory { impl RecordingCameraWorker { fn publish( &self, - image: &gpui::RenderImage, + image: &Arc, dims: (usize, usize), timestamp: cap_timestamp::Timestamp, mask: Option, @@ -334,14 +334,16 @@ impl RecordingCameraWorker { return; } } - let Some(bytes) = image.as_bytes(0) else { + if image.as_bytes(0).is_none() { self.publisher .fail("Processed camera pixels unavailable".to_string()); return; - }; + } self.publisher.publish( cap_recording::instant_recording::LinuxProcessedCameraFrame { - bgra: Arc::from(bytes), + bgra: cap_recording::instant_recording::LinuxCameraPixels::new( + RenderImagePixels(image.clone()), + ), dimensions: (dims.0 as u32, dims.1 as u32), stride: dims.0 * 4, timestamp, @@ -353,6 +355,16 @@ impl RecordingCameraWorker { } } +#[cfg(target_os = "linux")] +struct RenderImagePixels(Arc); + +#[cfg(target_os = "linux")] +impl AsRef<[u8]> for RenderImagePixels { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes(0).unwrap_or_default() + } +} + #[cfg(target_os = "linux")] fn validate_recording_camera_frame( timestamp: cap_timestamp::Timestamp, @@ -688,8 +700,7 @@ pub struct Feeds { pub camera: Option, /// Selected microphone name. pub microphone: Option, - /// Rolling 200ms max of the mic level, in dB FS. `-96` when silent/absent. - pub mic_level_db: f64, + mic_level: Entity, mic_level_shown: Arc, pub camera_error: Option, camera_preview_parked: bool, @@ -729,12 +740,34 @@ pub struct SelectedCamera { struct FeedsGlobal(Entity); impl Global for FeedsGlobal {} +/// The live mic level: rolling 200ms max in dB FS, `-96` when absent. Kept +/// out of `Feeds` because windows read device state from `Feeds` in render, +/// so each of its notifies redraws them, and the level changes up to 20 +/// times a second. +pub struct MicLevel { + db: f64, +} + +impl MicLevel { + pub fn db(&self) -> f64 { + self.db + } + + fn set(&mut self, db: f64, cx: &mut Context) { + if self.db != db { + self.db = db; + cx.notify(); + } + } +} + impl Feeds { pub fn camera_input_pending(&self) -> bool { self.camera_input_pending } pub fn init(cx: &mut App) -> Entity { + let mic_level = cx.new(|_| MicLevel { db: -96.0 }); let feeds = cx.new(|_| Self { camera_actor: None, camera_preview_sender: None, @@ -754,7 +787,7 @@ impl Feeds { microphone_ready: None, camera: None, microphone: None, - mic_level_db: -96.0, + mic_level, mic_level_shown: Arc::new(std::sync::atomic::AtomicU64::new((-96.0f64).to_bits())), camera_error: None, camera_preview_parked: false, @@ -1126,7 +1159,7 @@ impl Feeds { self.microphone_error = None; self.mic_input_pending = label.is_some(); self.mic_input_released = false; - self.set_mic_level(-96.0); + self.set_mic_level(-96.0, cx); let epoch = self.mic_epoch; let actor = self.ensure_mic_actor(cx); let gate = self.mic_input_gate.clone(); @@ -1239,13 +1272,17 @@ impl Feeds { }) .detach(); } - self.set_mic_level(-96.0); + self.set_mic_level(-96.0, cx); cx.notify(); } - fn set_mic_level(&mut self, db: f64) { - self.mic_level_db = db; + pub fn mic_level(&self) -> &Entity { + &self.mic_level + } + + fn set_mic_level(&mut self, db: f64, cx: &mut Context) { self.mic_level_shown.store(db.to_bits(), Ordering::Release); + self.mic_level.update(cx, |level, cx| level.set(db, cx)); } fn ensure_camera_actor(&mut self, cx: &mut Context) -> ActorRef { @@ -1370,20 +1407,11 @@ impl Feeds { let _meter = meter; while wakes.recv_async().await.is_ok() { let level = f64::from_bits(latest.load(Ordering::Acquire)); - let changed = this.update(cx, |this: &mut Feeds, cx| { - if this.mic_level_db == level { - return false; - } - this.set_mic_level(level); - cx.notify(); - true - }); - match changed { - Err(_) => return, - Ok(true) => { - cx.update(app_windows::refresh_controls_window); - } - Ok(false) => {} + if this + .update(cx, |this: &mut Feeds, cx| this.set_mic_level(level, cx)) + .is_err() + { + return; } } }); @@ -1458,6 +1486,10 @@ pub(crate) fn camera_preview_image( Some((image, (width, height))) } +/// The quietest level any meter draws: `bar_level` bottoms out at -60 dB and +/// `picker_level` at -40 dB, so anything below looks identical everywhere. +const METER_FLOOR_DB: f64 = -60.0; + /// A 200ms max-hold window, coalesced to ~20 updates/s and quantized to /// 0.5dB (under a pixel on both level mappings). #[derive(Default)] @@ -1501,6 +1533,7 @@ impl MeterSender { let Some(level) = self.window.push(now, db) else { return; }; + let level = level.max(METER_FLOOR_DB); self.latest.store(level.to_bits(), Ordering::Release); if level.to_bits() != self.shown.load(Ordering::Acquire) { let _ = self.wake.try_send(()); @@ -1700,6 +1733,44 @@ mod tests { assert!(wakes.try_recv().is_err()); } + #[test] + fn input_below_every_meter_floor_never_wakes_the_ui() { + use std::sync::atomic::AtomicU64; + + let shown = Arc::new(AtomicU64::new((-96.0f64).to_bits())); + let latest = Arc::new(AtomicU64::new((-96.0f64).to_bits())); + let (wake, wakes) = flume::bounded(1); + let mut sender = MeterSender { + window: MeterWindow::default(), + shown: shown.clone(), + latest: latest.clone(), + wake, + }; + let start = Instant::now(); + let at = |callback: u64| start + Duration::from_micros(callback * 10_667); + + sender.push(at(0), -72.0); + assert_eq!(wakes.try_recv(), Ok(())); + assert_eq!( + f64::from_bits(latest.load(Ordering::Acquire)), + METER_FLOOR_DB + ); + + shown.store(METER_FLOOR_DB.to_bits(), Ordering::Release); + for callback in 1..400 { + let db = -96.0 + (callback % 7) as f64 * 5.0; + sender.push(at(callback), db); + } + assert!(wakes.try_recv().is_err()); + + sender.push(at(400), -42.0); + sender.push(at(410), -42.0); + assert_eq!(wakes.try_recv(), Ok(())); + assert_eq!(f64::from_bits(latest.load(Ordering::Acquire)), -42.0); + assert_eq!(picker_level(METER_FLOOR_DB), picker_level(-96.0)); + assert_eq!(bar_level(METER_FLOOR_DB), bar_level(-96.0)); + } + #[test] fn preview_rejects_frames_queued_before_input_readiness() { let ready = cap_timestamp::Timestamps::now(); @@ -2135,6 +2206,22 @@ mod tests { assert!(scaler.is_some()); } + #[cfg(target_os = "linux")] + #[test] + fn recording_camera_pixels_share_the_preview_image() { + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 2, 1); + frame.data_mut(0)[..8].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); + let (image, dims) = camera_preview_image(&frame, &mut None, false, None).unwrap(); + + let shared = RenderImagePixels(image.clone()); + let pixels = cap_recording::instant_recording::LinuxCameraPixels::new(shared); + + let preview = image.as_bytes(0).unwrap(); + assert_eq!(pixels.as_ptr(), preview.as_ptr()); + assert_eq!(&*pixels, preview); + assert_eq!(pixels.len(), dims.0 * dims.1 * 4); + } + #[test] fn camera_preview_downscales_only_when_blur_requires_it() { let frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 64, 32); diff --git a/apps/desktop-gpui/src/hotkeys.rs b/apps/desktop-gpui/src/hotkeys.rs index 46bb17797cd..1759064916a 100644 --- a/apps/desktop-gpui/src/hotkeys.rs +++ b/apps/desktop-gpui/src/hotkeys.rs @@ -216,6 +216,11 @@ pub fn pause(cx: &mut App) -> bool { true } +pub fn is_paused(cx: &App) -> bool { + cx.try_global::() + .is_some_and(|hotkeys| hotkeys.paused) +} + pub fn editing_blocked(cx: &App) -> bool { #[cfg(target_os = "linux")] if cx diff --git a/apps/desktop-gpui/src/import.rs b/apps/desktop-gpui/src/import.rs index 30f70292c58..58b8a4438b9 100644 --- a/apps/desktop-gpui/src/import.rs +++ b/apps/desktop-gpui/src/import.rs @@ -134,6 +134,7 @@ fn imported_video_meta( start_time: Some(0.0), device_id: None, gap_summary: None, + digital_silence_ms: None, }), cursor: None, keyboard: None, diff --git a/apps/desktop-gpui/src/jpeg_thumbnail.rs b/apps/desktop-gpui/src/jpeg_thumbnail.rs new file mode 100644 index 00000000000..85549b99a6c --- /dev/null +++ b/apps/desktop-gpui/src/jpeg_thumbnail.rs @@ -0,0 +1,210 @@ +use image::DynamicImage; + +pub fn decode_jpeg_thumbnail( + bytes: &[u8], + request: impl FnOnce(u32, u32) -> (u32, u32), +) -> Option<(DynamicImage, u32, u32)> { + let mut decoder = jpeg_decoder::Decoder::new(bytes); + decoder.read_info().ok()?; + let info = decoder.info()?; + match info.pixel_format { + jpeg_decoder::PixelFormat::RGB24 | jpeg_decoder::PixelFormat::L8 => {} + _ => return None, + } + let (requested_width, requested_height) = + request(u32::from(info.width), u32::from(info.height)); + let requested_width = requested_width.clamp(1, u32::from(u16::MAX)) as u16; + let requested_height = requested_height.clamp(1, u32::from(u16::MAX)) as u16; + let (width, height) = decoder.scale(requested_width, requested_height).ok()?; + let pixels = decoder.decode().ok()?; + let decoded = match info.pixel_format { + jpeg_decoder::PixelFormat::RGB24 => DynamicImage::ImageRgb8(image::RgbImage::from_raw( + u32::from(width), + u32::from(height), + pixels, + )?), + jpeg_decoder::PixelFormat::L8 => DynamicImage::ImageLuma8(image::GrayImage::from_raw( + u32::from(width), + u32::from(height), + pixels, + )?), + _ => return None, + }; + Some((decoded, u32::from(info.width), u32::from(info.height))) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ThumbnailResize { + Box, + Triangle, + Identity, +} + +pub fn thumbnail_resize( + decoded_width: u32, + decoded_height: u32, + target_width: u32, + target_height: u32, +) -> ThumbnailResize { + let decoded_width = decoded_width.max(1); + let decoded_height = decoded_height.max(1); + let target_width = target_width.max(1); + let target_height = target_height.max(1); + if decoded_width <= target_width && decoded_height <= target_height { + return ThumbnailResize::Identity; + } + let scale = (target_width as f32 / decoded_width as f32) + .min(target_height as f32 / decoded_height as f32); + if scale <= 0.5 { + ThumbnailResize::Box + } else { + ThumbnailResize::Triangle + } +} + +pub fn cover_target(width: u32, height: u32, card_width: u32, card_height: u32) -> (f32, u32, u32) { + let width = width.max(1); + let height = height.max(1); + let scale = (card_width as f32 / width as f32) + .max(card_height as f32 / height as f32) + .min(1.0); + let target_width = ((width as f32 * scale).round() as u32).max(1); + let target_height = ((height as f32 * scale).round() as u32).max(1); + (scale, target_width, target_height) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CARD_WIDTH: u32 = 392; + const CARD_HEIGHT: u32 = 224; + + fn encode_rgb(width: u32, height: u32, pixel: [u8; 3]) -> Vec { + let image = image::RgbImage::from_pixel(width, height, image::Rgb(pixel)); + let mut encoded = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 75) + .encode_image(&image) + .unwrap(); + encoded + } + + #[test] + fn square_request_downsamples_before_the_full_frame() { + let encoded = encode_rgb(1024, 512, [40, 120, 200]); + let (decoded, width, height) = decode_jpeg_thumbnail(&encoded, |_, _| (128, 128)).unwrap(); + assert_eq!((width, height), (1024, 512)); + assert!(decoded.width() <= 256); + assert!(decoded.height() <= 128); + let pixel = decoded.to_rgb8().get_pixel(0, 0).0; + assert!(pixel[0].abs_diff(40) <= 3); + assert!(pixel[1].abs_diff(120) <= 3); + assert!(pixel[2].abs_diff(200) <= 3); + } + + #[test] + fn library_cover_targets_match_the_card() { + assert_eq!( + cover_dims(800, 600), + (392, 294), + "800x600 covering the card" + ); + assert_eq!(cover_dims(600, 600), (392, 392)); + assert_eq!(cover_dims(1920, 1080), (398, 224)); + assert_eq!(cover_dims(1512, 982), (392, 255)); + } + + #[test] + fn resize_uses_the_decoded_buffer_not_the_original_scale() { + let (_, target_width, target_height) = cover_target(1920, 1080, CARD_WIDTH, CARD_HEIGHT); + assert_eq!((target_width, target_height), (398, 224)); + assert_eq!( + thumbnail_resize(1920, 1080, target_width, target_height), + ThumbnailResize::Box + ); + assert_eq!( + thumbnail_resize(480, 270, target_width, target_height), + ThumbnailResize::Triangle + ); + assert_eq!( + thumbnail_resize(target_width, target_height, target_width, target_height), + ThumbnailResize::Identity + ); + } + + #[test] + fn library_cover_idct_scales_1080p_to_a_quarter() { + let encoded = encode_rgb(1920, 1080, [40, 120, 200]); + let (decoded, width, height) = decode_jpeg_thumbnail(&encoded, |w, h| { + let (_, target_width, target_height) = cover_target(w, h, CARD_WIDTH, CARD_HEIGHT); + (target_width, target_height) + }) + .unwrap(); + assert_eq!((width, height), (1920, 1080)); + assert_eq!((decoded.width(), decoded.height()), (480, 270)); + let (_, target_width, target_height) = cover_target(width, height, CARD_WIDTH, CARD_HEIGHT); + assert_eq!( + thumbnail_resize( + decoded.width(), + decoded.height(), + target_width, + target_height + ), + ThumbnailResize::Triangle + ); + } + + #[test] + fn cmyk_frame_returns_none() { + let bytes = cmyk_jpeg(); + let mut decoder = jpeg_decoder::Decoder::new(bytes.as_slice()); + let pixels = decoder.decode().expect("cmyk fixture decodes"); + assert_eq!( + decoder.info().map(|info| info.pixel_format), + Some(jpeg_decoder::PixelFormat::CMYK32) + ); + assert_eq!(pixels.len(), 8 * 8 * 4); + assert!(decode_jpeg_thumbnail(&bytes, |w, h| (w, h)).is_none()); + } + + fn cmyk_jpeg() -> Vec { + fn marker(out: &mut Vec, tag: u8, payload: &[u8]) { + out.extend_from_slice(&[0xFF, tag]); + let len = u16::try_from(payload.len() + 2).unwrap(); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(payload); + } + fn huffman(class: u8) -> Vec { + let mut table = vec![class, 0x01]; + table.extend(std::iter::repeat_n(0x00, 15)); + table.push(0x00); + table + } + let mut quant = vec![0x00]; + quant.extend(std::iter::repeat_n(0x01, 64)); + let mut frame = vec![0x08, 0x00, 0x08, 0x00, 0x08, 0x04]; + for id in 1..=4u8 { + frame.extend_from_slice(&[id, 0x11, 0x00]); + } + let mut scan = vec![0x04]; + for id in 1..=4u8 { + scan.extend_from_slice(&[id, 0x00]); + } + scan.extend_from_slice(&[0x00, 0x3F, 0x00]); + + let mut bytes = vec![0xFF, 0xD8]; + marker(&mut bytes, 0xDB, &quant); + marker(&mut bytes, 0xC0, &frame); + marker(&mut bytes, 0xC4, &huffman(0x00)); + marker(&mut bytes, 0xC4, &huffman(0x10)); + marker(&mut bytes, 0xDA, &scan); + bytes.push(0x00); + bytes.extend_from_slice(&[0xFF, 0xD9]); + bytes + } + + fn cover_dims(width: u32, height: u32) -> (u32, u32) { + let (_, target_width, target_height) = cover_target(width, height, CARD_WIDTH, CARD_HEIGHT); + (target_width, target_height) + } +} diff --git a/apps/desktop-gpui/src/library.rs b/apps/desktop-gpui/src/library.rs index dc57098a7ab..f45cef1cb7d 100644 --- a/apps/desktop-gpui/src/library.rs +++ b/apps/desktop-gpui/src/library.rs @@ -72,9 +72,11 @@ pub struct RecentItem { /// `media_sort_time_millis` (`lib.rs:3966-3972`): the filesystem `created()` /// time of the path, falling back to `modified()`, then to the epoch. fn media_sort_time_millis(path: &Path) -> f64 { - let Ok(metadata) = path.metadata() else { - return 0.0; - }; + path.metadata() + .map_or(0.0, |metadata| metadata_sort_time_millis(&metadata)) +} + +fn metadata_sort_time_millis(metadata: &std::fs::Metadata) -> f64 { metadata .created() .or_else(|_| metadata.modified()) @@ -570,6 +572,9 @@ pub fn delete_recording_directory_in(dirs: &[PathBuf], path: &Path) -> Result<() }; if let Some(canonical_path) = canonical_path { + let _thumbnails = crate::recording::hold_studio_thumbnail_writes().ok_or_else(|| { + "Timed out waiting for the recording's thumbnail to finish writing".to_string() + })?; std::fs::remove_dir_all(canonical_path) .map_err(|error| format!("Failed to delete recording: {error}"))?; } @@ -672,44 +677,61 @@ pub struct ScreenshotItem { /// `list_screenshots` (`lib.rs:4055-4091`): `*.cap` directories only, and the /// sort key is the PNG's timestamp rather than the directory's. pub fn list_screenshots_in(dir: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { - return Vec::new(); - }; + screenshot_items_in(dir).collect() +} - let mut out = Vec::new(); - for entry in entries.flatten() { - let bundle = entry.path(); - if !bundle.is_dir() || bundle.extension().and_then(|ext| ext.to_str()) != Some("cap") { - continue; +fn screenshot_items_in(dir: &Path) -> impl Iterator { + let mut bundles = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let bundle = entry.path(); + if bundle.extension().and_then(|ext| ext.to_str()) != Some("cap") || !bundle.is_dir() { + continue; + } + if let Some((png, sort_time_millis)) = screenshot_png(&bundle) { + bundles.push((bundle, png, sort_time_millis)); + } } - let Ok(meta) = RecordingMeta::load_for_project(&bundle) else { - continue; - }; - let Some(png) = std::fs::read_dir(&bundle).ok().and_then(|entries| { - entries - .flatten() - .map(|entry| entry.path()) - .find(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png")) - }) else { - continue; - }; + } + bundles.sort_by(|(_, _, a), (_, _, b)| b.total_cmp(a)); + bundles + .into_iter() + .filter_map(|(bundle, png, sort_time_millis)| { + let meta = RecordingMeta::load_for_project(&bundle).ok()?; + Some(ScreenshotItem { + path: png.clone(), + pretty_name: meta.pretty_name, + sort_time_millis, + thumbnail: Some(png), + bundle, + }) + }) +} - out.push(ScreenshotItem { - path: png.clone(), - pretty_name: meta.pretty_name, - sort_time_millis: media_sort_time_millis(&png), - thumbnail: Some(png), - bundle, - }); +fn screenshot_png(bundle: &Path) -> Option<(PathBuf, f64)> { + let original = bundle.join("original.png"); + if let Ok(metadata) = original.metadata() { + return Some((original, metadata_sort_time_millis(&metadata))); } - out.sort_by(|a, b| b.sort_time_millis.total_cmp(&a.sort_time_millis)); - out + let png = std::fs::read_dir(bundle) + .ok()? + .flatten() + .map(|entry| entry.path()) + .find(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png"))?; + let sort_time_millis = media_sort_time_millis(&png); + Some((png, sort_time_millis)) } pub fn list_screenshots() -> Vec { list_screenshots_in(&screenshots_dir()) } +pub fn list_screenshots_limited(limit: usize) -> Vec { + screenshot_items_in(&screenshots_dir()) + .take(limit) + .collect() +} + /// Delete the `.cap` bundle that owns `path` (the PNG or the directory). pub fn delete_screenshot(path: &Path) -> Result<(), String> { let bundle = if path.is_dir() { @@ -750,16 +772,20 @@ pub fn delete_screenshot(path: &Path) -> Result<(), String> { } fn scan_screenshots(dir: &Path, out: &mut Vec) { - out.extend(list_screenshots_in(dir).into_iter().map(|item| RecentItem { - kind: MediaKind::Screenshot, - bundle: item.bundle, - pretty_name: item.pretty_name, - clip_count: 1, - sort_time_millis: item.sort_time_millis, - thumbnail_version: item.thumbnail.as_deref().and_then(source_mtime_nanos), - thumbnail: item.thumbnail, - sharing: None, - })); + out.extend( + screenshot_items_in(dir) + .take(RECENT_MEDIA_LIMIT) + .map(|item| RecentItem { + kind: MediaKind::Screenshot, + bundle: item.bundle, + pretty_name: item.pretty_name, + clip_count: 1, + sort_time_millis: item.sort_time_millis, + thumbnail_version: item.thumbnail.as_deref().and_then(source_mtime_nanos), + thumbnail: item.thumbnail, + sharing: None, + }), + ); } fn newest_first(items: &mut [RecentItem]) { @@ -777,13 +803,8 @@ pub fn recent_media_in(recording_dirs: &[PathBuf], screenshots_dir: &Path) -> Ve let mut recordings = Vec::new(); scan_recordings(recording_dirs, &mut recordings); - let mut screenshots = Vec::new(); - scan_screenshots(screenshots_dir, &mut screenshots); - newest_first(&mut screenshots); - screenshots.truncate(RECENT_MEDIA_LIMIT); - let mut merged = recordings; - merged.extend(screenshots); + scan_screenshots(screenshots_dir, &mut merged); newest_first(&mut merged); merged.truncate(RECENT_MEDIA_LIMIT); merged @@ -1060,6 +1081,11 @@ impl CacheSlot { } } +#[cfg(test)] +pub fn thumbnail_cache_file(source: &Path) -> Option { + CacheSlot::for_source(source).map(|slot| slot.file()) +} + /// gpui's atlas takes BGRA; `image`'s RgbaImage is just the container (the /// same swap gpui's own asset loader does after decoding). pub fn rgba_to_render_image(mut rgba: image::RgbaImage) -> Arc { @@ -1094,32 +1120,50 @@ pub fn thumbnail_rgba(path: &Path) -> Option { // Sniff rather than trust the extension: `list_screenshots` finds the // preview by extension scan, and a bundle could hold a mislabelled file. let format = image::guess_format(&bytes).ok()?; - let decoded = image::load_from_memory_with_format(&bytes, format).ok()?; - - let (width, height) = (decoded.width().max(1), decoded.height().max(1)); - let scale = (THUMBNAIL_WIDTH as f32 / width as f32) - .max(THUMBNAIL_HEIGHT as f32 / height as f32) - .min(1.0); - let target_width = ((width as f32 * scale).round() as u32).max(1); - let target_height = ((height as f32 * scale).round() as u32).max(1); + let (decoded, width, height) = if format == image::ImageFormat::Jpeg { + crate::jpeg_thumbnail::decode_jpeg_thumbnail(&bytes, |width, height| { + let (_, target_width, target_height) = crate::jpeg_thumbnail::cover_target( + width, + height, + THUMBNAIL_WIDTH, + THUMBNAIL_HEIGHT, + ); + (target_width, target_height) + }) + .or_else(|| { + let decoded = image::load_from_memory_with_format(&bytes, format).ok()?; + let dimensions = (decoded.width(), decoded.height()); + Some((decoded, dimensions.0, dimensions.1)) + })? + } else { + let decoded = image::load_from_memory_with_format(&bytes, format).ok()?; + let dimensions = (decoded.width(), decoded.height()); + (decoded, dimensions.0, dimensions.1) + }; + let (width, height) = (width.max(1), height.max(1)); + let (_, target_width, target_height) = + crate::jpeg_thumbnail::cover_target(width, height, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); // Box sampling for the big ratios (one pass over a multi-megapixel // source, and at 8x down every output pixel averages a whole block); // Triangle when the sizes are close, where box would alias and costs // nothing to avoid. - let rgba = if scale <= 0.5 { - decoded + let rgba = match crate::jpeg_thumbnail::thumbnail_resize( + decoded.width(), + decoded.height(), + target_width, + target_height, + ) { + crate::jpeg_thumbnail::ThumbnailResize::Box => decoded .thumbnail_exact(target_width, target_height) - .into_rgba8() - } else if scale < 1.0 { - decoded + .into_rgba8(), + crate::jpeg_thumbnail::ThumbnailResize::Triangle => decoded .resize_exact( target_width, target_height, image::imageops::FilterType::Triangle, ) - .into_rgba8() - } else { - decoded.into_rgba8() + .into_rgba8(), + crate::jpeg_thumbnail::ThumbnailResize::Identity => decoded.into_rgba8(), }; if let Some(cache) = &cache { @@ -1143,14 +1187,33 @@ pub fn thumbnail_rgba(path: &Path) -> Option { /// rather than deferred. /// /// Faithful in the parts that matter to the output: decode packets until the -/// *first* video frame comes out, scale to RGB24 at the source's own size -/// (`size: None` at both call sites, so a native-resolution JPEG, not a -/// thumbnail), save as JPEG. Blocking; callers hand it to `spawn_blocking`. +/// *first* video frame comes out and save it as a JPEG at the source's own +/// size (`size: None` at every call site, so a native-resolution JPEG, not a +/// thumbnail). Blocking; callers hand it to `spawn_blocking`. pub fn create_screenshot( input: &Path, output: &Path, size: Option<(u32, u32)>, ) -> Result<(), String> { + let frame = first_video_frame(input)?; + let size = size.unwrap_or((frame.width(), frame.height())); + let jpeg = match encode_mjpeg(&frame, size) { + Some(jpeg) => jpeg, + None => encode_rgb_jpeg(&frame, size)?, + }; + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let staged = output.with_extension(format!("jpg.tmp-{}", crate::store::new_uuid_v4())); + std::fs::write(&staged, jpeg) + .and_then(|()| std::fs::rename(&staged, output)) + .map_err(|e| { + let _ = std::fs::remove_file(&staged); + e.to_string() + }) +} + +fn first_video_frame(input: &Path) -> Result { let mut ictx = ffmpeg::format::input(input).map_err(|e| e.to_string())?; let input_stream = ictx .streams() @@ -1164,65 +1227,123 @@ pub fn create_screenshot( .video() .map_err(|e| e.to_string())?; - let mut scaler = ffmpeg::software::scaling::context::Context::get( - decoder.format(), - decoder.width(), - decoder.height(), - ffmpeg::format::Pixel::RGB24, - size.map_or(decoder.width(), |s| s.0), - size.map_or(decoder.height(), |s| s.1), - ffmpeg::software::scaling::flag::Flags::BILINEAR, - ) - .map_err(|e| e.to_string())?; - let mut frame = ffmpeg::frame::Video::empty(); for (stream, packet) in ictx.packets() { if stream.index() != video_stream_index { continue; } decoder.send_packet(&packet).map_err(|e| e.to_string())?; - if decoder.receive_frame(&mut frame).is_err() { - continue; + if decoder.receive_frame(&mut frame).is_ok() { + return Ok(frame); } + } - let mut rgb_frame = ffmpeg::frame::Video::empty(); - scaler - .run(&frame, &mut rgb_frame) - .map_err(|e| e.to_string())?; - - // The scaler's rows are padded to its own stride; the image buffer is - // tight, so the copy is row by row. - let width = rgb_frame.width() as usize; - let height = rgb_frame.height() as usize; - let src_stride = rgb_frame.stride(0); - let dst_stride = width * 3; - let buffer = cap_gpui_kernels::frame::copy_rows( - rgb_frame.data(0), - dst_stride, - src_stride, - height, - false, - ) - .ok_or("Failed to create image from frame data")?; + Err("Failed to create screenshot".to_string()) +} - let image = image::RgbImage::from_raw(width as u32, height as u32, buffer) - .ok_or("Failed to create image from frame data")?; - if let Some(parent) = output.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let staged = output.with_extension(format!("jpg.tmp-{}", crate::store::new_uuid_v4())); - cap_gpui_kernels::codec::save_rgb_image(&image, &staged, image::ImageFormat::Jpeg) - .map_err(|e| { - let _ = std::fs::remove_file(&staged); - e.to_string() - })?; - return std::fs::rename(&staged, output).map_err(|e| { - let _ = std::fs::remove_file(&staged); - e.to_string() - }); +/// FFmpeg's MJPEG encoder takes the decoded YUV frame directly, so a 4K +/// thumbnail skips the RGB round trip and `image`'s much slower JPEG encoder. +/// 4:4:4 at qscale 4 keeps the unsubsampled layout of the quality-75 JPEG +/// `image` wrote, with at least its quality. +fn encode_mjpeg(frame: &ffmpeg::frame::Video, (width, height): (u32, u32)) -> Option> { + const QSCALE: i32 = 4; + let format = ffmpeg::format::Pixel::YUV444P; + let codec = ffmpeg::codec::encoder::find(ffmpeg::codec::Id::MJPEG)?; + let mut scaler = ffmpeg::software::scaling::context::Context::get( + frame.format(), + frame.width(), + frame.height(), + format, + width, + height, + ffmpeg::software::scaling::flag::Flags::BILINEAR, + ) + .ok()?; + write_full_range(&mut scaler)?; + let mut yuv = ffmpeg::frame::Video::empty(); + scaler.run(frame, &mut yuv).ok()?; + + let mut encoder = ffmpeg::codec::context::Context::new_with_codec(codec) + .encoder() + .video() + .ok()?; + encoder.set_width(width); + encoder.set_height(height); + encoder.set_format(format); + encoder.set_color_range(ffmpeg::color::Range::JPEG); + encoder.set_time_base((1, 1)); + encoder.set_flags(ffmpeg::codec::Flags::QSCALE); + encoder.set_global_quality(QSCALE * ffmpeg::ffi::FF_QP2LAMBDA); + encoder.set_qmin(QSCALE); + encoder.set_qmax(QSCALE); + let mut encoder = encoder.open().ok()?; + + yuv.set_pts(Some(0)); + encoder.send_frame(&yuv).ok()?; + encoder.send_eof().ok()?; + let mut packet = ffmpeg::Packet::empty(); + encoder.receive_packet(&mut packet).ok()?; + packet.data().map(<[u8]>::to_vec) +} + +/// JPEG stores full-range YUV, while swscale writes limited range unless told +/// otherwise; the source keeps whatever range swscale detected for it. +fn write_full_range(scaler: &mut ffmpeg::software::scaling::context::Context) -> Option<()> { + let mut inv_table = std::ptr::null_mut(); + let mut table = std::ptr::null_mut(); + let (mut src_range, mut dst_range) = (0, 0); + let (mut brightness, mut contrast, mut saturation) = (0, 0, 0); + unsafe { + let context = scaler.as_mut_ptr(); + let read = ffmpeg::ffi::sws_getColorspaceDetails( + context, + &mut inv_table, + &mut src_range, + &mut table, + &mut dst_range, + &mut brightness, + &mut contrast, + &mut saturation, + ); + (read >= 0 + && ffmpeg::ffi::sws_setColorspaceDetails( + context, inv_table, src_range, table, 1, brightness, contrast, saturation, + ) >= 0) + .then_some(()) } +} - Err("Failed to create screenshot".to_string()) +/// The original path, for an FFmpeg build without the MJPEG encoder. +fn encode_rgb_jpeg( + frame: &ffmpeg::frame::Video, + (width, height): (u32, u32), +) -> Result, String> { + let mut rgb_frame = ffmpeg::frame::Video::empty(); + ffmpeg::software::scaling::context::Context::get( + frame.format(), + frame.width(), + frame.height(), + ffmpeg::format::Pixel::RGB24, + width, + height, + ffmpeg::software::scaling::flag::Flags::BILINEAR, + ) + .map_err(|e| e.to_string())? + .run(frame, &mut rgb_frame) + .map_err(|e| e.to_string())?; + + // The scaler's rows are padded to its own stride; the image buffer is + // tight, so the copy is row by row. + let row_bytes = width as usize * 3; + let buffer = cap_gpui_kernels::frame::copy_rows( + rgb_frame.data(0), + row_bytes, + rgb_frame.stride(0), + height as usize, + false, + ) + .ok_or("Failed to create image from frame data")?; + cap_gpui_kernels::codec::encode_jpeg_rgb(&buffer, width, height, 75).map_err(|e| e.to_string()) } pub fn bundle_thumbnail_path(project_dir: &Path) -> PathBuf { @@ -1293,6 +1414,163 @@ mod tests { bundle } + fn psnr(jpeg: &[u8], screen: &image::RgbImage) -> f64 { + let decoded = image::load_from_memory(jpeg).unwrap().into_rgb8(); + assert_eq!(decoded.dimensions(), screen.dimensions()); + let squared: f64 = decoded + .as_raw() + .iter() + .zip(screen.as_raw()) + .map(|(&a, &b)| (f64::from(a) - f64::from(b)).powi(2)) + .sum(); + 10.0 * (255.0_f64.powi(2) * screen.as_raw().len() as f64 / squared).log10() + } + + /// `screen` as a recording would hand it over: limited-range BT.709 4:2:0. + fn recorded_frame(screen: &image::RgbImage) -> ffmpeg::frame::Video { + let (width, height) = screen.dimensions(); + let mut rgb = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGB24, width, height); + let stride = rgb.stride(0); + for (row, pixels) in rgb + .data_mut(0) + .chunks_mut(stride) + .zip(screen.as_raw().chunks(width as usize * 3)) + { + row[..pixels.len()].copy_from_slice(pixels); + } + let mut scaler = ffmpeg::software::scaling::context::Context::get( + ffmpeg::format::Pixel::RGB24, + width, + height, + ffmpeg::format::Pixel::YUV420P, + width, + height, + ffmpeg::software::scaling::flag::Flags::BILINEAR, + ) + .unwrap(); + unsafe { + let bt709 = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_ITU709); + let set = ffmpeg::ffi::sws_setColorspaceDetails( + scaler.as_mut_ptr(), + bt709, + 1, + bt709, + 0, + 0, + 1 << 16, + 1 << 16, + ); + assert!(set >= 0); + } + let mut frame = ffmpeg::frame::Video::empty(); + scaler.run(&rgb, &mut frame).unwrap(); + frame.set_color_space(ffmpeg::color::Space::BT709); + frame.set_color_range(ffmpeg::color::Range::MPEG); + frame + } + + #[test] + fn screenshot_is_written_with_the_mjpeg_encoder() { + ffmpeg::init().unwrap(); + let root = temp_dir("mjpeg-screenshot"); + let source = root.join("display.mp4"); + std::fs::write( + &source, + include_bytes!("../../media-server/src/__tests__/fixtures/test-with-audio.mp4"), + ) + .unwrap(); + let output = bundle_thumbnail_path(&root); + + create_screenshot(&source, &output, None).unwrap(); + + let frame = first_video_frame(&source).unwrap(); + let written = std::fs::read(&output).unwrap(); + let size = (frame.width(), frame.height()); + assert_eq!(written, encode_mjpeg(&frame, size).unwrap()); + let decoded = image::load_from_memory_with_format(&written, image::ImageFormat::Jpeg); + assert_eq!(decoded.unwrap().to_rgb8().dimensions(), size); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn mjpeg_screenshot_is_at_least_as_close_to_the_screen() { + ffmpeg::init().unwrap(); + let screen = image::RgbImage::from_fn(640, 360, |x, y| { + let text = (x / 2 + y / 3) % 7 == 0 && y % 24 < 14; + match (x / 160, text) { + (0, _) => image::Rgb([61, 106, 242]), + (_, true) => image::Rgb([212, 212, 212]), + (3, false) => image::Rgb([(x - 480) as u8, 53, (y / 2) as u8 + 60]), + _ => image::Rgb([30, 31, 34]), + } + }); + let frame = recorded_frame(&screen); + let size = screen.dimensions(); + + let mjpeg = psnr(&encode_mjpeg(&frame, size).unwrap(), &screen); + let rgb = psnr(&encode_rgb_jpeg(&frame, size).unwrap(), &screen); + + assert!(mjpeg >= rgb, "mjpeg {mjpeg:.2} dB, rgb {rgb:.2} dB"); + } + + /// Each source is a video; a `.png` beside it with the same name is the + /// screen it recorded, which both JPEGs are scored against. + #[test] + #[ignore = "needs CAP_SCREENSHOT_BENCH_SOURCES"] + fn benchmark_create_screenshot() { + ffmpeg::init().unwrap(); + let sources = std::env::var("CAP_SCREENSHOT_BENCH_SOURCES").unwrap(); + let median = |mut runs: Vec| { + runs.sort_by(f64::total_cmp); + runs[runs.len() / 2] + }; + println!( + "source,width,height,decode_ms,rgb_ms,mjpeg_ms,rgb_bytes,mjpeg_bytes,rgb_psnr,mjpeg_psnr" + ); + for source in sources.split(',').map(Path::new) { + let started = std::time::Instant::now(); + let frame = first_video_frame(source).unwrap(); + let decode = started.elapsed().as_secs_f64() * 1e3; + let size = (frame.width(), frame.height()); + let rgb = || encode_rgb_jpeg(&frame, size).unwrap(); + let mjpeg = || encode_mjpeg(&frame, size).unwrap(); + let time = |encode: &dyn Fn() -> Vec| { + let started = std::time::Instant::now(); + std::hint::black_box(encode()); + started.elapsed().as_secs_f64() * 1e3 + }; + let (mut rgb_runs, mut mjpeg_runs) = (Vec::new(), Vec::new()); + for run in 0..15 { + if run % 2 == 0 { + rgb_runs.push(time(&rgb)); + mjpeg_runs.push(time(&mjpeg)); + } else { + mjpeg_runs.push(time(&mjpeg)); + rgb_runs.push(time(&rgb)); + } + } + let (rgb, mjpeg) = (rgb(), mjpeg()); + let screen = image::open(source.with_extension("png")).map(|png| png.into_rgb8()); + let score = |jpeg: &[u8]| { + screen + .as_ref() + .map_or(String::new(), |screen| format!("{:.2}", psnr(jpeg, screen))) + }; + println!( + "{},{},{},{decode:.1},{:.1},{:.1},{},{},{},{}", + source.display(), + size.0, + size.1, + median(rgb_runs), + median(mjpeg_runs), + rgb.len(), + mjpeg.len(), + score(&rgb), + score(&mjpeg), + ); + } + } + fn temp_dir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "cap-gpui-library-{tag}-{}-{:?}", @@ -1481,6 +1759,129 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn bounded_screenshots_match_the_full_listing_and_parse_lazily() { + fn eager_listing(dir: &Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let bundle = entry.path(); + if !bundle.is_dir() + || bundle.extension().and_then(|ext| ext.to_str()) != Some("cap") + { + continue; + } + let Ok(meta) = RecordingMeta::load_for_project(&bundle) else { + continue; + }; + let Some(png) = std::fs::read_dir(&bundle) + .unwrap() + .flatten() + .find_map(|entry| { + let path = entry.path(); + (path.extension().and_then(|ext| ext.to_str()) == Some("png")) + .then_some(path) + }) + else { + continue; + }; + out.push(ScreenshotItem { + path: png.clone(), + pretty_name: meta.pretty_name, + sort_time_millis: media_sort_time_millis(&png), + thumbnail: Some(png), + bundle, + }); + } + out.sort_by(|a, b| b.sort_time_millis.total_cmp(&a.sort_time_millis)); + out + } + + fn write_shot(dir: &Path, name: &str) -> PathBuf { + write_bundle( + dir, + name, + &format!(r#"{{"pretty_name":"{name}","sharing":null,"fps":0}}"#), + ) + } + + let root = temp_dir("bounded-screenshots"); + let legacy = write_shot(&root, "legacy"); + std::fs::write(legacy.join("shot.png"), b"png").unwrap(); + write_shot(&root, "no-png"); + let not_a_bundle = root.join("not-a-bundle"); + std::fs::create_dir_all(¬_a_bundle).unwrap(); + std::fs::write(not_a_bundle.join("original.png"), b"png").unwrap(); + std::fs::write(root.join("loose.cap"), b"").unwrap(); + for i in 0..30 { + std::thread::sleep(std::time::Duration::from_millis(6)); + let bundle = write_shot(&root, &format!("shot-{:02}", (i * 7) % 30)); + std::fs::write(bundle.join("original.png"), b"png").unwrap(); + } + std::thread::sleep(std::time::Duration::from_millis(6)); + let tie_a = write_shot(&root, "tie-a"); + std::fs::write(tie_a.join("original.png"), b"png").unwrap(); + let tie_b = write_shot(&root, "tie-b"); + std::fs::hard_link(tie_a.join("original.png"), tie_b.join("original.png")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(6)); + let corrupt = write_bundle(&root, "corrupt", "{ not json "); + std::fs::write(corrupt.join("original.png"), b"png").unwrap(); + + let full = list_screenshots_in(&root); + assert_eq!(full, eager_listing(&root)); + assert_eq!(full.len(), 33); + assert!(full.iter().all(|item| item.bundle != corrupt)); + let legacy_item = full.iter().find(|item| item.bundle == legacy).unwrap(); + assert_eq!(legacy_item.path, legacy.join("shot.png")); + + let read_dir_order: Vec = std::fs::read_dir(&root) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .collect(); + let read_dir_position = |bundle: &Path| { + read_dir_order + .iter() + .position(|path| path == bundle) + .unwrap() + }; + let tie_time = |bundle: &Path| { + full.iter() + .find(|item| item.bundle == bundle) + .unwrap() + .sort_time_millis + }; + assert_eq!(tie_time(&tie_a), tie_time(&tie_b)); + assert!( + full.windows(2).all(|pair| { + pair[0].sort_time_millis > pair[1].sort_time_millis + || (pair[0].sort_time_millis == pair[1].sort_time_millis + && read_dir_position(&pair[0].bundle) < read_dir_position(&pair[1].bundle)) + }), + "newest first, equal timestamps keep directory order" + ); + + for limit in [0, 1, 9, 20, 100] { + assert_eq!( + screenshot_items_in(&root).take(limit).collect::>(), + full[..limit.min(full.len())], + "limit {limit}" + ); + } + + let mut lazy = screenshot_items_in(&root); + let head: Vec<_> = lazy.by_ref().take(RECENT_MEDIA_LIMIT).collect(); + assert_eq!(head, full[..RECENT_MEDIA_LIMIT]); + for item in &full[RECENT_MEDIA_LIMIT..] { + std::fs::remove_file(item.bundle.join("recording-meta.json")).unwrap(); + } + assert!( + lazy.next().is_none(), + "metas past the consumed prefix are read on demand" + ); + + std::fs::remove_dir_all(&root).unwrap(); + } + #[test] #[ignore = "manual filesystem benchmark; set CAP_GPUI_LIBRARY_BENCH_DIR and CAP_GPUI_LIBRARY_BENCH_MODE"] fn recording_scan_benchmark() { diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 9e886003506..ecdc4701164 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -43,6 +43,7 @@ mod feeds; mod hotkeys; mod import; mod installer; +mod jpeg_thumbnail; mod library; mod main_window; mod media_access; @@ -436,6 +437,7 @@ fn main() { #[cfg(target_os = "linux")] single_instance::init_linux_reopen(cx); updates::schedule_startup_check(cx); + app_windows::schedule_launch_render_warmup(cx); upload::queue::init(cx); // The app menu (and with it ⌘W/⌘M/⌘Q) and the status-bar item. Both diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 3953585cb4b..71f3e43b0e9 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -792,6 +792,7 @@ pub struct MainWindow { camera_id: Option, microphone: Option, microphone_level: Entity, + device_microphone_level: Entity, pending_device_restore: crate::store::RecordingInputSettings, device_restore_suspended: bool, device_format_target: Option, @@ -851,6 +852,8 @@ pub struct MainWindow { input_enumeration_gate: devices::InputEnumerationGate, /// `scheduleTargetListPrewarm` (`new-main/index.tsx:1897-1965`). prewarm_task: Option>, + keyboard_nav: bool, + _keyboard_nav: gpui::Subscription, } /// `createLicenseQuery` (`utils/queries.ts:257-273`): pro from the auth @@ -942,11 +945,20 @@ impl MainWindow { // Track the app-scoped feeds: the camera bubble's close button // deselects the camera there, and this window's selection has to - // follow. Repaints are gated to what is actually visible -- the mic - // meter notifies at ~20Hz and would otherwise repaint the home view - // for a level bar only the microphone picker shows. + // follow. Repaints are gated to what is actually visible. let feeds = Feeds::global(cx); - let microphone_level = cx.new(|cx| ui::MicrophoneLevel::new(&feeds, window, cx)); + let mic_level = feeds.read(cx).mic_level().clone(); + let microphone_level = cx.new(|cx| { + ui::MicrophoneLevel::new( + ui::MicrophoneLevelKind::CaptureButton, + &mic_level, + window, + cx, + ) + }); + let device_microphone_level = cx.new(|cx| { + ui::MicrophoneLevel::new(ui::MicrophoneLevelKind::DeviceRow, &mic_level, window, cx) + }); cx.observe(&feeds, |this: &mut Self, feeds, cx| { let feeds = feeds.read(cx); let camera_id = feeds.camera.as_ref().map(|camera| &camera.id); @@ -1059,6 +1071,8 @@ impl MainWindow { }) .detach(); + let keyboard_nav = ui::keyboard_nav::install(window, cx, Self::keyboard_nav_blocked); + Self { theme, // `rawOptions.mode` -- the recording mode is a persisted setting, @@ -1075,6 +1089,7 @@ impl MainWindow { camera_id: None, microphone: None, microphone_level, + device_microphone_level, pending_device_restore: crate::store::RecordingInputSettings::load(), device_restore_suspended: false, device_format_target: None, @@ -1113,9 +1128,19 @@ impl MainWindow { input_poll_generation: 0, input_enumeration_gate: devices::InputEnumerationGate::default(), prewarm_task: None, + keyboard_nav: false, + _keyboard_nav: keyboard_nav, } } + fn keyboard_nav_blocked(&self, cx: &gpui::App) -> bool { + self.session.read(cx).phase != Phase::Idle + || self.microphone_warning.is_some() + || self.sign_in_prompt.is_some() + || self.idle_error(cx).is_some() + || app_windows::clean_capture_pending(cx) + } + /// Kick off device enumeration. /// /// Deliberately *not* called from `new`. `new` runs inside `open_window`'s @@ -1265,11 +1290,14 @@ impl MainWindow { window: &mut Window, cx: &mut Context, ) { - for image in self.thumbnails.retain_displays(&displays) { + let dropped = self.thumbnails.retain_displays(&displays); + let changed = self.devices.install_displays(displays) || !dropped.is_empty(); + for image in dropped { let _ = window.drop_image(image); } - self.devices.displays = displays; - cx.notify(); + if changed { + cx.notify(); + } } fn install_windows( @@ -1278,11 +1306,14 @@ impl MainWindow { window: &mut Window, cx: &mut Context, ) { - for image in self.thumbnails.retain_windows(&windows) { + let dropped = self.thumbnails.retain_windows(&windows); + let changed = self.devices.install_windows(windows) || !dropped.is_empty(); + for image in dropped { let _ = window.drop_image(image); } - self.devices.windows = windows; - cx.notify(); + if changed { + cx.notify(); + } } /// Start one capture sweep, or return `None` because one is already in @@ -1375,8 +1406,9 @@ impl MainWindow { self.thumbnails.set_window_inflight(true); let (events_tx, events) = flume::unbounded(); + let current = self.thumbnails.captured_windows(); let capture = gpui_tokio::Tokio::spawn(cx, async move { - target_thumbnails::capture_windows(events_tx).await; + target_thumbnails::capture_windows(events_tx, current).await; }); Some(cx.spawn_in(window, async move |this, cx| { @@ -1598,11 +1630,12 @@ impl MainWindow { id, image, app_icon, + entry, } => { let icon = app_icon.map(|bytes| { std::sync::Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes)) }); - if let Some(old) = self.thumbnails.insert_window(&id, image, icon) { + if let Some(old) = self.thumbnails.insert_window(&id, image, icon, entry) { let _ = window.drop_image(old); } } @@ -2704,12 +2737,35 @@ impl MainWindow { impl Render for MainWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_appearance(window, cx); + if self.keyboard_nav_blocked(cx) { + ui::keyboard_nav::leave(window, cx); + } + self.keyboard_nav = ui::keyboard_nav::active(window, cx); let theme = self.theme; let meter_active = self.panel.is_none() && self.microphone.is_some() && self.session.read(cx).phase == Phase::Idle; self.microphone_level.update(cx, |meter, cx| { - meter.configure(meter_active, theme.blue_9.into(), cx); + meter.configure( + meter_active, + theme.blue_9.into(), + gpui::transparent_black(), + cx, + ); + }); + let device_meter_active = self.microphone.is_some() + && matches!(self.panel, Some(Panel::Device(DeviceMenu::Microphone))); + self.device_microphone_level.update(cx, |meter, cx| { + meter.configure( + device_meter_active, + theme.blue_500.into(), + if theme.is_dark() { + gpui::hsla(0., 0., 1., 0.1) + } else { + gpui::hsla(0., 0., 0., 0.07) + }, + cx, + ); }); div() @@ -2771,6 +2827,7 @@ impl Render for MainWindow { cx.stop_propagation(); } })) + .child(ui::keyboard_nav::pointer_watcher()) } } @@ -3465,6 +3522,7 @@ impl MainWindow { /// 8px from the window edges (`mx-2`). fn render_header_actions(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; + let keyboard_nav = self.keyboard_nav; // `IconButton::header`: a 20px hit box with no fill, `text-gray-11` // going to `text-gray-12` on hover. @@ -3483,6 +3541,9 @@ impl MainWindow { .group(id) .when(cfg!(target_os = "windows"), |this| this.occlude()) .tab_index(0) + .when(keyboard_nav, ui::keyboard_nav::ring) + .role(gpui::Role::Button) + .aria_label(label) .flex() .items_center() .justify_center() @@ -3910,6 +3971,9 @@ impl MainWindow { .child( div() .id("panel-back") + .when(self.keyboard_nav, ui::keyboard_nav::stop) + .role(gpui::Role::Button) + .aria_label("Back") .flex() .flex_row() .items_center() @@ -4140,8 +4204,8 @@ impl MainWindow { (Some(items), None) } LibraryKind::Screenshots => { - let mut items = library::list_screenshots(); - items.truncate(library::LIBRARY_PANEL_LIMIT); + let items = + library::list_screenshots_limited(library::LIBRARY_PANEL_LIMIT); (None, Some(items)) } } @@ -4721,13 +4785,14 @@ impl MainWindow { self.library_action(("lib-ss-copy", index), "icons/copy.svg", { let png = png.clone(); move |_, _, cx| { - if let Err(error) = crate::platform::copy_image_to_clipboard(&png, cx) { - tracing::warn!("copying screenshot failed: {error}"); - cx.spawn(async move |_| { + let copied = crate::platform::copy_image_to_clipboard(png.clone(), cx); + cx.spawn(async move |_| { + if let Err(error) = copied.await { + tracing::warn!("copying screenshot failed: {error}"); crate::platform::alert_dialog("Could not copy screenshot", &error); - }) - .detach(); - } + } + }) + .detach(); } }), ) @@ -5158,9 +5223,7 @@ impl MainWindow { .map(|description| format!("Available: {description}")) }, selected, - selected.then(|| { - feeds::picker_level(Feeds::global(cx).read(cx).mic_level_db) - }), + selected.then(|| self.device_microphone_level.clone()), cx.listener(move |this, _, _window, cx| { if !this.device_changes_allowed(cx) { return; @@ -5640,6 +5703,10 @@ impl MainWindow { div() .id(id) + .when(self.keyboard_nav, ui::keyboard_nav::stop) + .role(gpui::Role::Button) + .aria_label(label.clone()) + .aria_selected(selected) .flex() .flex_col() .w(px(self.target_card_width())) @@ -5874,10 +5941,9 @@ impl MainWindow { label: String, detail: Option, selected: bool, - // `MicrophoneListItem`'s live level wash: a white 25% overlay whose - // right edge sits at `level * 100%` (1 = silence, the web app's - // orientation). Only the selected microphone row gets one. - audio_level: Option, + // `MicrophoneListItem`'s live level bar. Only the selected + // microphone row gets one. + audio_level: Option>, on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, ) -> gpui::Stateful { let theme = self.theme; @@ -5885,6 +5951,10 @@ impl MainWindow { div() .id(id) + .when(self.keyboard_nav, ui::keyboard_nav::stop) + .role(gpui::Role::Button) + .aria_label(label.clone()) + .aria_selected(selected) .relative() .flex() .flex_row() @@ -5930,26 +6000,7 @@ impl MainWindow { .child(detail) })) .when_some(audio_level.filter(|_| selected), |this, level| { - this.child( - div() - .mt(px(3.)) - .h(px(3.)) - .w_full() - .rounded_full() - .overflow_hidden() - .bg(if theme.is_dark() { - gpui::hsla(0., 0., 1., 0.1) - } else { - gpui::hsla(0., 0., 0., 0.07) - }) - .child( - div() - .h_full() - .rounded_full() - .bg(accent) - .w(gpui::relative(1. - level.clamp(0., 1.) as f32)), - ), - ) + this.child(level) }), ) .when(selected, |this| { @@ -6159,6 +6210,10 @@ impl MainWindow { div() .id(SharedString::from(id)) .tab_index(0) + .when(self.keyboard_nav, ui::keyboard_nav::ring) + .role(gpui::Role::Button) + .aria_label(mode.panel_title()) + .aria_selected(selected) .relative() .on_hover(cx.listener(move |this, hovered, _, cx| { this.update_mode_hover(mode, false, *hovered, cx); @@ -6227,6 +6282,9 @@ impl MainWindow { div() .id("mode-info") .tab_index(0) + .when(self.keyboard_nav, ui::keyboard_nav::ring) + .role(gpui::Role::Button) + .aria_label("Recording mode info") .tooltip(move |_, cx| ui::Tooltip::new(&theme, "Recording mode info").view(cx)) .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) .absolute() @@ -6312,6 +6370,12 @@ impl MainWindow { .child( div() .id(dropdown_id.clone()) + .when(self.keyboard_nav, ui::keyboard_nav::stop) + .role(gpui::Role::Button) + .aria_label(match target { + TargetType::Display => "Choose a display", + _ => "Choose a window", + }) .flex() .w(px(28.)) .rounded_r(px(9.)) @@ -6403,6 +6467,10 @@ impl MainWindow { let base = div() .id(SharedString::from(target.label())) + .when(self.keyboard_nav, ui::keyboard_nav::stop) + .role(gpui::Role::Button) + .aria_label(target.label()) + .aria_selected(selected) .flex() .flex_1() .py(px(8.)) @@ -6576,6 +6644,9 @@ impl MainWindow { div() .id(SharedString::from(id)) + .when(self.keyboard_nav, ui::keyboard_nav::stop) + .role(gpui::Role::Button) + .aria_label(label.clone()) .relative() .flex() .flex_row() diff --git a/apps/desktop-gpui/src/menus.rs b/apps/desktop-gpui/src/menus.rs index 93f5f6c2336..0974118e6b5 100644 --- a/apps/desktop-gpui/src/menus.rs +++ b/apps/desktop-gpui/src/menus.rs @@ -27,9 +27,11 @@ use gpui::{ use crate::{ app_windows::{self, AppWindows}, + hotkeys, main_window::MainWindow, platform, session::{Phase, RecordingSession}, + settings_window::Page, store, ui::text_input, }; @@ -146,6 +148,8 @@ actions!( Zoom, /// `PredefinedMenuItem::fullscreen` -- ⌃⌘F. ToggleFullscreen, + /// Settings… -- ⌘, on macOS, Ctrl+, on Windows and Linux. + OpenSettings, ] ); @@ -157,15 +161,7 @@ pub fn init(cx: &mut App) { // commands. Deliberately *not* including cmd-z/x/c/v/a -- those stay the // text field's own `TextInput`-scoped bindings (and, for the editor, its // `on_key_down` project-undo). A global binding here would shadow both. - cx.bind_keys([ - KeyBinding::new("cmd-q", Quit, None), - KeyBinding::new("cmd-,", Preferences, None), - KeyBinding::new("cmd-h", HideSelf, None), - KeyBinding::new("alt-cmd-h", HideOthers, None), - KeyBinding::new("cmd-w", CloseWindow, None), - KeyBinding::new("cmd-m", Minimize, None), - KeyBinding::new("ctrl-cmd-f", ToggleFullscreen, None), - ]); + cx.bind_keys(bindings()); cx.on_action(|_: &CheckForUpdate, cx: &mut App| { crate::updates::check_manually(cx); @@ -213,6 +209,29 @@ pub fn init(cx: &mut App) { cx.on_action(|_: &CloseWindow, cx: &mut App| close_key_window(cx)); cx.on_action(|_: &Minimize, cx: &mut App| with_key_native(cx, platform::minimize_native)); cx.on_action(|_: &Zoom, cx: &mut App| with_key_native(cx, platform::zoom_native)); + cx.on_action(|_: &OpenSettings, cx: &mut App| { + // While a recording runs, onboarding is up or the Shortcuts page is + // recording a combo, the chord belongs to whatever has focus. + let idle = RecordingSession::global(cx).read(cx).phase == Phase::Idle; + let onboarding_open = cx + .try_global::() + .is_some_and(|windows| windows.onboarding.is_some()); + if !settings_shortcut_allowed(idle, onboarding_open, hotkeys::is_paused(cx)) { + cx.propagate(); + return; + } + // Deferred: the keystroke arrives inside the key window's dispatch, and + // `open_settings` updates the settings window, which may be that one. + cx.defer(|cx: &mut App| { + let page = cx + .try_global::() + .and_then(|windows| windows.settings) + .and_then(|settings| settings.read(cx).ok().map(|settings| settings.page)) + .unwrap_or(Page::General); + app_windows::open_settings(page, cx); + cx.activate(true); + }); + }); cx.on_action(|_: &ToggleFullscreen, cx: &mut App| { #[cfg(target_os = "windows")] if let Some(handle) = cx.active_window() { @@ -252,9 +271,11 @@ fn install_menus(cx: &mut App) { platform::patch_standard_application_menu(); } -/// The menu bar hierarchy and app-specific destinations. -fn build_menus(update_status: &crate::updates::UpdateStatus) -> Vec { - let update_row = UpdateRow::of(update_status); +/// The menu bar model. +/// +/// One-to-one with `build_macos_app_menu`, including the empty Help submenu +/// (`Submenu::with_id_and_items(.., HELP_SUBMENU_ID, "Help", true, &[])`). +pub fn build_menus() -> Vec { vec![ Menu { name: APP_NAME.into(), @@ -263,7 +284,9 @@ fn build_menus(update_status: &crate::updates::UpdateStatus) -> Vec { MenuItem::action(format!("About {APP_NAME}"), About), MenuItem::action(update_row.title, CheckForUpdate).disabled(!update_row.enabled), MenuItem::separator(), - MenuItem::action("Preferences…", Preferences), + // MenuItem::action("Preferences…", Preferences), + MenuItem::separator(), + MenuItem::action("Settings…", OpenSettings), MenuItem::separator(), MenuItem::os_submenu("Services", SystemMenuType::Services), MenuItem::separator(), @@ -583,10 +606,11 @@ pub fn quit(cx: &mut App) { } let phase = session.read(cx).phase; let ui_cleanup_pending = app_windows::clean_capture_owned(cx); + let finishing = ui_cleanup_pending || crate::recording::studio_thumbnails_pending(); let Some((generation, action)) = cx .global_mut::() .gate - .request(quit_phase(phase, ui_cleanup_pending)) + .request(quit_phase(phase, finishing)) else { return; }; @@ -612,9 +636,23 @@ pub fn quit(cx: &mut App) { } } +pub(crate) fn recheck_pending_quit(cx: &mut App) { + let Some(generation) = cx + .try_global::() + .and_then(|coordinator| coordinator.gate.pending) + .map(|(generation, _)| generation) + else { + return; + }; + advance_pending_quit(generation, false, cx); +} + fn advance_pending_quit(generation: u64, timed_out: bool, cx: &mut App) { let session = RecordingSession::global(cx); - let phase = quit_phase(session.read(cx).phase, app_windows::clean_capture_owned(cx)); + let phase = quit_phase( + session.read(cx).phase, + app_windows::clean_capture_owned(cx) || crate::recording::studio_thumbnails_pending(), + ); let failed = session.read(cx).error.is_some(); let action = cx .global_mut::() @@ -1035,7 +1073,60 @@ mod tests { assert!(gate.pending.is_none()); } - /// Separators are spelled `"-"` so the ordering is checked too. + #[test] + fn settings_shortcut_yields_to_recording_onboarding_and_the_recorder() { + assert!(settings_shortcut_allowed(true, false, false)); + assert!(!settings_shortcut_allowed(false, false, false)); + assert!(!settings_shortcut_allowed(true, true, false)); + assert!(!settings_shortcut_allowed(true, false, true)); + } + + fn keystrokes(binding: &KeyBinding) -> Vec { + binding + .keystrokes() + .iter() + .map(|keystroke| keystroke.inner().clone()) + .collect() + } + + #[test] + fn global_keymap_is_unchanged_apart_from_settings() { + let settings = if cfg!(target_os = "macos") { + "cmd-," + } else { + "ctrl-," + }; + let expected: &[(&str, &dyn gpui::Action)] = &[ + ("cmd-q", &Quit), + ("cmd-h", &HideSelf), + ("alt-cmd-h", &HideOthers), + ("cmd-w", &CloseWindow), + ("cmd-m", &Minimize), + ("ctrl-cmd-f", &ToggleFullscreen), + (settings, &OpenSettings), + ]; + let bindings = bindings(); + assert_eq!(bindings.len(), expected.len()); + for (binding, (source, action)) in bindings.iter().zip(expected) { + assert_eq!( + keystrokes(binding), + vec![gpui::Keystroke::parse(source).unwrap()], + "{source}" + ); + assert!(binding.action().partial_eq(*action), "{source}"); + assert!(binding.predicate().is_none(), "{source}"); + } + let mut seen = std::collections::HashSet::new(); + for binding in &bindings { + assert!( + seen.insert(keystrokes(binding)), + "{binding:?} is bound twice" + ); + } + } + + /// The menu bar, transcribed from `build_macos_app_menu`. Separators are + /// spelled `"-"` so the ordering is checked too. fn describe(menu: &Menu) -> (String, Vec) { let items = menu .items @@ -1071,7 +1162,7 @@ mod tests { "About Cap".to_string(), "Check for Update".into(), "-".into(), - "Preferences…".into(), + "Settings…".into(), "-".into(), "Services".into(), "-".into(), diff --git a/apps/desktop-gpui/src/mode_select_window.rs b/apps/desktop-gpui/src/mode_select_window.rs index 660d27316c8..77dc4fc84c6 100644 --- a/apps/desktop-gpui/src/mode_select_window.rs +++ b/apps/desktop-gpui/src/mode_select_window.rs @@ -9,11 +9,11 @@ use gpui::{ Context, FocusHandle, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, - Point, Render, SharedString, StatefulInteractiveElement, Styled, Window, div, - prelude::FluentBuilder, px, svg, + Point, Render, Role, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, + div, prelude::FluentBuilder, px, svg, }; -use crate::{main_window::Mode, theme::Theme}; +use crate::{main_window::Mode, theme::Theme, ui::keyboard_nav}; /// `.inner_size(580.0, 340.0)` / `.min_inner_size(580.0, 340.0)` / /// `.resizable(false)` on the `ShowCapWindow::ModeSelect` arm in `windows.rs`, @@ -45,6 +45,8 @@ pub struct ModeSelectWindow { /// cannot drift. mode: Mode, focus: FocusHandle, + keyboard_nav: bool, + _keyboard_nav: Subscription, } impl ModeSelectWindow { @@ -67,6 +69,8 @@ impl ModeSelectWindow { theme: Theme::for_window(window, cx, false), mode, focus: cx.focus_handle(), + keyboard_nav: false, + _keyboard_nav: keyboard_nav::install(window, cx, |_, _| false), } } @@ -104,6 +108,11 @@ impl ModeSelectWindow { div() .id(SharedString::from(mode.panel_title())) + .when(self.keyboard_nav, keyboard_nav::stop) + .role(Role::Button) + .aria_label(mode.panel_title()) + .aria_description(mode.card_description()) + .aria_selected(selected) .relative() .flex() .flex_col() @@ -236,10 +245,12 @@ impl ModeSelectWindow { impl Render for ModeSelectWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_appearance(window, cx); + self.keyboard_nav = keyboard_nav::active(window, cx); let theme = self.theme; let shell = div() .track_focus(&self.focus) + .child(keyboard_nav::pointer_watcher()) // `flex flex-col relative justify-center items-center min-h-screen // bg-gray-1` .size_full() diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 8e39012a4fc..8b71158ae5a 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -558,10 +558,54 @@ fn clipboard_file_path(path: &std::path::Path) -> Result Result<(), String> { - let bytes = std::fs::read(path) - .map_err(|error| format!("Could not read the clipboard image: {error}"))?; - copy_image_bytes_to_clipboard(&bytes, cx) +// Prepares finish in completion order, not click order, so only the newest +// request may write: an older copy finishing late must not replace a newer one. +struct ClipboardWrites { + latest: std::sync::atomic::AtomicU64, +} + +impl ClipboardWrites { + const fn new() -> Self { + Self { + latest: std::sync::atomic::AtomicU64::new(0), + } + } + + fn request(&self) -> u64 { + self.latest + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1 + } + + fn is_current(&self, id: u64) -> bool { + self.latest.load(std::sync::atomic::Ordering::SeqCst) == id + } +} + +static CLIPBOARD_WRITES: ClipboardWrites = ClipboardWrites::new(); + +pub fn copy_image_to_clipboard( + path: std::path::PathBuf, + cx: &gpui::App, +) -> gpui::Task> { + let id = CLIPBOARD_WRITES.request(); + cx.spawn(async move |cx| { + let image = cx + .background_executor() + .spawn(async move { + let bytes = std::fs::read(&path) + .map_err(|error| format!("Could not read the clipboard image: {error}"))?; + prepare_clipboard_image(bytes) + }) + .await?; + cx.update(|cx| { + if !CLIPBOARD_WRITES.is_current(id) { + tracing::debug!("skipping a superseded clipboard image write"); + return Ok(()); + } + write_clipboard_image(image, cx) + }) + }) } #[cfg(any(not(target_os = "macos"), test))] @@ -2258,7 +2302,13 @@ mod mac { }) } - pub fn copy_image_bytes_to_clipboard(bytes: &[u8], _cx: &gpui::App) -> Result<(), String> { + pub struct ClipboardImage(Vec); + + pub fn prepare_clipboard_image(bytes: Vec) -> Result { + Ok(ClipboardImage(bytes)) + } + + pub fn write_clipboard_image(image: ClipboardImage, _cx: &gpui::App) -> Result<(), String> { use objc2::rc::autoreleasepool; use objc2::runtime::Bool; use objc2::{class, msg_send}; @@ -2269,7 +2319,7 @@ mod mac { if pasteboard.is_null() { return Err("Clipboard unavailable".into()); } - let data = NSData::with_bytes(bytes); + let data = NSData::with_bytes(&image.0); let image: *mut AnyObject = msg_send![class!(NSImage), alloc]; let image: *mut AnyObject = msg_send![image, initWithData: &*data]; let image = Retained::from_raw(image).ok_or("Failed to load image")?; @@ -2931,8 +2981,10 @@ mod stub { .map_err(|error| format!("Could not copy the file to the clipboard: {error}")) } - pub fn copy_image_bytes_to_clipboard(bytes: &[u8], cx: &gpui::App) -> Result<(), String> { - let format = match image::guess_format(bytes).map_err(|error| error.to_string())? { + pub struct ClipboardImage(gpui::ClipboardItem); + + pub fn prepare_clipboard_image(bytes: Vec) -> Result { + let format = match image::guess_format(&bytes).map_err(|error| error.to_string())? { image::ImageFormat::Png => gpui::ImageFormat::Png, image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, image::ImageFormat::WebP => gpui::ImageFormat::Webp, @@ -2941,8 +2993,14 @@ mod stub { image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, _ => return Err("Unsupported clipboard image format".into()), }; - let image = gpui::Image::from_bytes(format, bytes.to_vec()); - cx.try_write_to_clipboard(gpui::ClipboardItem::new_image(&image)) + let image = gpui::Image::from_bytes(format, bytes); + Ok(ClipboardImage(gpui::ClipboardItem { + entries: vec![gpui::ClipboardEntry::Image(image)], + })) + } + + pub fn write_clipboard_image(image: ClipboardImage, cx: &gpui::App) -> Result<(), String> { + cx.try_write_to_clipboard(image.0) .map_err(|error| format!("Could not copy the image to the clipboard: {error}")) } pub fn desktop_picture_path() -> Option { @@ -3044,6 +3102,52 @@ mod tests { assert!(!requests.request()); } + #[test] + fn newest_clipboard_request_wins() { + let writes = super::ClipboardWrites::new(); + let a = writes.request(); + let b = writes.request(); + assert!(!writes.is_current(a)); + assert!(writes.is_current(b)); + } + + #[test] + fn completed_clipboard_write_does_not_invalidate_a_later_request() { + let writes = super::ClipboardWrites::new(); + let a = writes.request(); + assert!(writes.is_current(a)); + let b = writes.request(); + assert!(writes.is_current(b)); + assert!(!writes.is_current(a)); + } + + #[test] + fn concurrent_clipboard_requests_get_distinct_ids_and_only_the_newest_is_current() { + let writes = std::sync::Arc::new(super::ClipboardWrites::new()); + let threads: Vec<_> = (0..8) + .map(|_| { + let writes = writes.clone(); + std::thread::spawn(move || writes.request()) + }) + .collect(); + let mut ids: Vec = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids, (1..=8).collect::>()); + let current: Vec<_> = ids.iter().filter(|id| writes.is_current(**id)).collect(); + assert_eq!(current, [&8]); + } + + #[test] + fn clipboard_request_ids_increase_from_one() { + let writes = super::ClipboardWrites::new(); + let ids: Vec<_> = (0..4).map(|_| writes.request()).collect(); + assert_eq!(ids, [1, 2, 3, 4]); + } + #[test] fn confirmation_accepts_native_ok_and_matching_custom_label_only() { let accept = "Install update"; diff --git a/apps/desktop-gpui/src/platform/windows.rs b/apps/desktop-gpui/src/platform/windows.rs index e4a76274900..8a25c01dec9 100644 --- a/apps/desktop-gpui/src/platform/windows.rs +++ b/apps/desktop-gpui/src/platform/windows.rs @@ -647,20 +647,48 @@ pub fn copy_file_to_clipboard(path: &Path, _cx: &gpui::App) -> Result<(), String .map_err(|error| format!("Could not copy the file to the clipboard: {error}")) } -pub fn copy_image_bytes_to_clipboard(bytes: &[u8], _cx: &gpui::App) -> Result<(), String> { +pub struct ClipboardImage(clipboard_rs::RustImageData); + +pub fn prepare_clipboard_image(bytes: Vec) -> Result { + use clipboard_rs::RustImageData; use clipboard_rs::common::RustImage; - use clipboard_rs::{Clipboard, ClipboardContext, RustImageData}; - let image = RustImageData::from_bytes(bytes) - .map_err(|error| format!("Could not load the clipboard image: {error}"))?; + RustImageData::from_bytes(&bytes) + .map(ClipboardImage) + .map_err(|error| format!("Could not load the clipboard image: {error}")) +} + +pub fn write_clipboard_image(image: ClipboardImage, _cx: &gpui::App) -> Result<(), String> { + use clipboard_rs::{Clipboard, ClipboardContext}; + ClipboardContext::new() .map_err(|error| format!("Clipboard unavailable: {error}"))? - .set_image(image) + .set_image(image.0) .map_err(|error| format!("Could not copy the image to the clipboard: {error}")) } pub fn desktop_picture_path() -> Option { - None + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + SPI_GETDESKWALLPAPER, SystemParametersInfoW, + }; + + let mut buffer = vec![0u16; 32_768]; + let success = unsafe { + SystemParametersInfoW( + SPI_GETDESKWALLPAPER, + buffer.len() as u32, + buffer.as_mut_ptr().cast(), + 0, + ) + }; + if success == 0 { + tracing::warn!(error = %std::io::Error::last_os_error(), "failed to read desktop wallpaper path"); + return None; + } + let length = buffer.iter().position(|character| *character == 0)?; + (length > 0).then(|| PathBuf::from(OsString::from_wide(&buffer[..length]))) } pub fn set_activation_policy(_regular: bool) -> bool { diff --git a/apps/desktop-gpui/src/presets.rs b/apps/desktop-gpui/src/presets.rs index 1ca45d8150e..27c8f3b67b7 100644 --- a/apps/desktop-gpui/src/presets.rs +++ b/apps/desktop-gpui/src/presets.rs @@ -158,6 +158,8 @@ pub fn apply_preset( ) -> Option { let mut next: ProjectConfiguration = serde_json::from_value(config.clone()).ok()?; next.timeline = current.timeline.clone(); + next.text_size_version = current.text_size_version; + next.text_anim_version = current.text_anim_version; next.overlay_order = current.overlay_order.clone(); next.clips = current.clips.clone(); Some(next) @@ -264,6 +266,68 @@ mod tests { assert!(applied.timeline.is_some(), "current timeline is kept"); } + #[test] + fn applying_a_legacy_tauri_preset_keeps_text_unchanged_after_reopening() { + let store: Value = serde_json::from_str( + r#"{ + "presets": { + "default": 0, + "presets": [{ + "name": "Legacy Tauri preset", + "config": { + "background": { "padding": 24.0 }, + "camera": { "size": 45.0, "zoom_size": 65.0 }, + "timeline": null, + "clips": [] + } + }] + } + }"#, + ) + .unwrap(); + let presets = PresetsStore::from_section(store["presets"].as_object().unwrap().clone()); + let current: ProjectConfiguration = serde_json::from_str( + r#"{ + "textSizeVersion": 1, + "textAnimVersion": 1, + "timeline": { + "segments": [{ "recordingSegment": 0, "start": 0.0, "end": 5.0, "timescale": 1.0 }], + "zoomSegments": [], + "textSegments": [{ + "start": 1.0, + "end": 4.0, + "content": "Keep this title", + "size": { "x": 0.6, "y": 0.4 }, + "fontSize": 48.0, + "fadeDuration": 0.15, + "animationIn": "slideUp", + "animationOut": "slideDown", + "animationInDuration": 0.4, + "animationOutDuration": 0.6 + }] + }, + "clips": [{ "index": 0, "offsets": { "mic": -0.125 } }] + }"#, + ) + .unwrap(); + let applied = apply_preset(&presets.presets[0].config, ¤t).unwrap(); + let directory = tempfile::tempdir().unwrap(); + applied.write(directory.path()).unwrap(); + let reopened = ProjectConfiguration::load(directory.path()).unwrap(); + + assert_eq!(reopened.background.padding, 24.0); + assert_eq!(reopened.camera.size, 45.0); + assert_eq!(reopened.camera.zoom_size, Some(65.0)); + assert_eq!( + serde_json::to_value(&reopened.timeline).unwrap(), + serde_json::to_value(¤t.timeline).unwrap() + ); + assert_eq!( + serde_json::to_value(&reopened.clips).unwrap(), + serde_json::to_value(¤t.clips).unwrap() + ); + } + #[test] fn malformed_entries_are_skipped_on_load() { let mut section = Map::new(); diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index b9bb2cf8b9c..a1ec646769b 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -459,6 +459,16 @@ async fn finalize_studio( { tracing::warn!(%error, "Could not apply the Studio Sound default"); } + if crate::store::GeneralSettings::load().macbook_notch_overlay + && completed.meta.display_notch().is_some() + && matches!( + capture_target, + ScreenCaptureTarget::Display { .. } | ScreenCaptureTarget::Area { .. } + ) + && let Err(error) = enable_notch_overlay(&project_path) + { + tracing::warn!(%error, "Could not apply the notch overlay preference"); + } Ok::<_, anyhow::Error>(completed) }) .await @@ -474,7 +484,12 @@ async fn finalize_studio( } let project_path = completed.project_path.clone(); + let pending = PendingStudioThumbnail::new(); tokio::task::spawn_blocking(move || { + let _pending = pending; + let _writing = STUDIO_THUMBNAIL_WRITES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let thumbnail_started = std::time::Instant::now(); if let Some(display_path) = studio_display_path(&project_path) { write_bundle_thumbnail(&project_path, &display_path); @@ -483,9 +498,7 @@ async fn finalize_studio( elapsed_ms = thumbnail_started.elapsed().as_secs_f64() * 1000.0, "Studio stop thumbnail complete" ); - }) - .await - .context("studio post-finalize task")?; + }); Ok(completed.project_path) } @@ -1452,6 +1465,61 @@ fn studio_display_path(project_path: &std::path::Path) -> Option { path.is_file().then_some(path) } +static STUDIO_THUMBNAIL_WRITES: Mutex<()> = Mutex::new(()); + +/// Deleting a recording waits for a Studio stop thumbnail being written, which +/// checks its bundle still exists under the same lock, so the two can neither +/// race nor leave a deleted bundle half recreated. +pub(crate) fn hold_studio_thumbnail_writes() -> Option> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + match STUDIO_THUMBNAIL_WRITES.try_lock() { + Ok(guard) => return Some(guard), + Err(std::sync::TryLockError::Poisoned(poisoned)) => return Some(poisoned.into_inner()), + Err(std::sync::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Err(std::sync::TryLockError::WouldBlock) => return None, + } + } +} + +static PENDING_STUDIO_THUMBNAILS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +/// A Studio stop returns before its thumbnail is written: quit waits while one +/// is pending, and the library views that scanned on the way to idle rescan +/// once it lands. +struct PendingStudioThumbnail; + +impl PendingStudioThumbnail { + fn new() -> Self { + PENDING_STUDIO_THUMBNAILS.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + Self + } +} + +impl Drop for PendingStudioThumbnail { + fn drop(&mut self) { + PENDING_STUDIO_THUMBNAILS.fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + let _ = studio_thumbnail_channel().0.send(()); + } +} + +fn studio_thumbnail_channel() -> &'static (flume::Sender<()>, flume::Receiver<()>) { + static CHANNEL: std::sync::OnceLock<(flume::Sender<()>, flume::Receiver<()>)> = + std::sync::OnceLock::new(); + CHANNEL.get_or_init(flume::unbounded) +} + +pub(crate) fn studio_thumbnail_events() -> flume::Receiver<()> { + studio_thumbnail_channel().1.clone() +} + +pub(crate) fn studio_thumbnails_pending() -> bool { + PENDING_STUDIO_THUMBNAILS.load(std::sync::atomic::Ordering::Acquire) > 0 +} + /// Write `/screenshots/display.jpg` -- the file both apps' Recents /// cards draw. `cap-recording` does not produce it, so without this a project /// recorded here would show the icon fallback forever, including in the @@ -1562,6 +1630,34 @@ fn enable_studio_sound(project_path: &std::path::Path) -> std::io::Result<()> { result } +fn enable_notch_overlay(project_path: &std::path::Path) -> std::io::Result<()> { + let path = project_path.join("project-config.json"); + let mut config: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?; + let object = config.as_object_mut().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Project configuration is not an object", + ) + })?; + let background = object + .entry("background") + .or_insert_with(|| serde_json::json!({})); + let background = background.as_object_mut().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Background configuration is not an object", + ) + })?; + background.insert("notch".into(), serde_json::json!({ "enabled": true })); + let temp = path.with_extension(format!("notch-{}.tmp", crate::store::new_uuid_v4())); + let result = std::fs::write(&temp, serde_json::to_vec_pretty(&config)?) + .and_then(|()| std::fs::rename(&temp, path)); + if result.is_err() { + let _ = std::fs::remove_file(temp); + } + result +} + fn apply_animated_gradient_to_project_config( project_dir: &std::path::Path, capture_target: &ScreenCaptureTarget, @@ -3533,6 +3629,26 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn notch_overlay_is_enabled_without_touching_other_settings() { + let dir = temp_project("notch-overlay"); + let path = dir.join("project-config.json"); + let mut original = + serde_json::to_value(cap_project::ProjectConfiguration::default()).unwrap(); + original["background"]["future"] = 7.into(); + original["futureProjectSetting"] = true.into(); + std::fs::write(&path, serde_json::to_vec(&original).unwrap()).unwrap(); + + enable_notch_overlay(&dir).unwrap(); + + let written: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(written["background"]["future"], 7); + assert_eq!(written["futureProjectSetting"], true); + let parsed: cap_project::ProjectConfiguration = serde_json::from_value(written).unwrap(); + assert!(parsed.background.notch.is_some_and(|notch| notch.enabled)); + std::fs::remove_dir_all(&dir).unwrap(); + } + /// A config written by a build that predates `backgroundBlur` (or one that /// never had a camera section at all) gets the key created rather than /// being skipped. @@ -3965,6 +4081,127 @@ mod studio_finalization_tests { assert!(finalization.wait().await.is_err()); } + static THUMBNAIL_TESTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + fn stopped_studio_project(tag: &str) -> studio_recording::CompletedRecording { + use cap_project::{RecordingMeta, RecordingMetaInner, StudioRecordingMeta}; + + let _ = ffmpeg::init(); + let root = std::env::temp_dir().join(format!( + "cap-gpui-stop-thumbnail-{tag}-{}.cap", + crate::store::new_uuid_v4() + )); + std::fs::create_dir_all(root.join("content")).unwrap(); + std::fs::write( + root.join("content/display.mp4"), + include_bytes!("../../media-server/src/__tests__/fixtures/test-with-audio.mp4"), + ) + .unwrap(); + let meta: StudioRecordingMeta = serde_json::from_value(serde_json::json!({ + "display": { "path": "content/display.mp4", "fps": 30 } + })) + .unwrap(); + RecordingMeta { + platform: None, + project_path: root.clone(), + pretty_name: "Stop".into(), + sharing: None, + inner: RecordingMetaInner::Studio(Box::new(meta.clone())), + upload: None, + } + .save_for_project() + .unwrap(); + studio_recording::CompletedRecording { + project_path: root, + meta, + cursor_data: Default::default(), + clean_stopped: None, + } + } + + async fn next_thumbnail_event(written: &flume::Receiver<()>) { + tokio::time::timeout(std::time::Duration::from_secs(30), written.recv_async()) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn studio_stop_writes_its_thumbnail_after_returning() { + let _serial = THUMBNAIL_TESTS.lock().await; + let completed = stopped_studio_project("written"); + let root = completed.project_path.clone(); + let written = studio_thumbnail_events(); + while written.try_recv().is_ok() {} + + let writing = hold_studio_thumbnail_writes().unwrap(); + let project = tokio::time::timeout( + std::time::Duration::from_secs(30), + finalize_studio(completed, ScreenCaptureTarget::CameraOnly, None, None), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(project, root); + assert!(studio_thumbnails_pending()); + assert!(!crate::library::bundle_thumbnail_path(&root).exists()); + drop(writing); + + next_thumbnail_event(&written).await; + assert!(!studio_thumbnails_pending()); + let thumbnail = image::open(crate::library::bundle_thumbnail_path(&root)) + .unwrap() + .into_rgb8(); + assert_eq!(thumbnail.dimensions(), (320, 240)); + std::fs::remove_dir_all(root).unwrap(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn thumbnail_written_after_a_delete_does_not_recreate_the_bundle() { + let _serial = THUMBNAIL_TESTS.lock().await; + let completed = stopped_studio_project("deleted"); + let root = completed.project_path.clone(); + let written = studio_thumbnail_events(); + while written.try_recv().is_ok() {} + + let deleting = hold_studio_thumbnail_writes().unwrap(); + finalize_studio(completed, ScreenCaptureTarget::CameraOnly, None, None) + .await + .unwrap(); + assert!(studio_thumbnails_pending()); + std::fs::remove_dir_all(&root).unwrap(); + drop(deleting); + + next_thumbnail_event(&written).await; + assert!(!root.exists()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn deleting_a_recording_waits_for_its_thumbnail_write() { + let _serial = THUMBNAIL_TESTS.lock().await; + let library = std::env::temp_dir().join(format!( + "cap-gpui-stop-thumbnail-library-{}", + crate::store::new_uuid_v4() + )); + let bundle = library.join("Recording.cap"); + std::fs::create_dir_all(bundle.join("screenshots")).unwrap(); + + let writing = hold_studio_thumbnail_writes().unwrap(); + let delete = std::thread::spawn({ + let library = library.clone(); + let bundle = bundle.clone(); + move || crate::library::delete_recording_directory_in(&[library], &bundle) + }); + std::thread::sleep(std::time::Duration::from_millis(200)); + assert!(!delete.is_finished()); + assert!(bundle.exists()); + drop(writing); + + assert_eq!(delete.join().unwrap(), Ok(())); + assert!(!bundle.exists()); + std::fs::remove_dir_all(library).unwrap(); + } + #[test] fn readiness_identity_does_not_confuse_retries_or_later_recordings() { let (_, first) = channel(); diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index 2bd7b539353..11bd4e64a7a 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -2242,7 +2242,7 @@ impl ScreenshotEditorWindow { // -- Mask overlays --------------------------------------------------------- fn mask_requests(&self) -> Vec { - if self.frame_rgba.is_none() { + if self.frame.is_none() { return Vec::new(); } let size = ( @@ -2312,14 +2312,16 @@ impl ScreenshotEditorWindow { } let previous = self.annotation_state.masks.clone(); - let Some(rgba) = self.frame_rgba.clone() else { + let Some(frame) = self.frame.clone() else { return; }; self.annotation_state.mask_pending = Some(requests.clone()); self.annotation_state.mask_task = Some(cx.spawn_in(window, async move |this, cx| { let overlays = cx .background_executor() - .spawn(async move { build_mask_overlays(&rgba, &previous, &requests) }) + .spawn(async move { + build_mask_overlays(frame.as_bytes(0).unwrap_or_default(), &previous, &requests) + }) .await; this.update_in(cx, |this, window, cx| { let overlays = finish_mask_build(&this.annotation_state.masks, overlays); @@ -3952,14 +3954,16 @@ fn checker_quarters(size: f32) -> Vec { /// `renderMaskOverlays` + `blurRegion` (`Preview.tsx:537-687`): resample the /// frame's own pixels inside each mask and hand back one image per region. /// Runs on the background executor -- the regions are small, but a 50-block -/// pixelate over a 4K screenshot is still not main-thread work. +/// pixelate over a 4K screenshot is still not main-thread work. The filter +/// treats every channel alike, so it runs on the atlas's BGRA as-is and the +/// overlays come out already in atlas order. fn build_mask_overlays( - rgba: &[u8], + bgra: &[u8], previous: &[MaskEntry], requests: &[MaskRequest], ) -> Vec> { update_masks(previous, requests, |request| { - let processed = request.filter(rgba)?; + let processed = request.filter(bgra)?; Some(MaskOverlay { rect: Rect { x: request.region.0 as f64, @@ -3967,7 +3971,9 @@ fn build_mask_overlays( width: processed.width() as f64, height: processed.height() as f64, }, - image: crate::library::rgba_to_render_image(processed), + image: Arc::new(RenderImage::new(smallvec::smallvec![image::Frame::new( + processed + )])), }) }) } @@ -4523,6 +4529,68 @@ mod tests { assert_eq!(mask_region(&away, rect, (200, 200)), None); } + #[test] + fn mask_overlays_from_bgra_match_filtering_rgba_then_swapping() { + let (width, height) = (37u32, 23u32); + let stride = (width as usize * 4).next_multiple_of(256); + let padded: Vec = (0..stride * height as usize) + .map(|index| (index * 31 + index / 7) as u8) + .collect(); + let rgba = cap_gpui_kernels::frame::unpad_rows( + &padded, + width as usize * 4, + stride, + height as usize, + ) + .unwrap(); + let bgra = cap_gpui_kernels::frame::unpad_rgba_to_bgra( + &padded, + width as usize, + stride, + height as usize, + ) + .unwrap(); + let frame = MaskFrame { + revision: 1, + size: (width, height), + content: [0., 0., f64::from(width), f64::from(height)].map(f64::to_bits), + padding: 0_f64.to_bits(), + crop: None, + }; + let mut masks = Vec::new(); + for (index, (mask_type, level, region)) in [ + (MaskType::Blur, 4., (0., 0., 37., 23.)), + (MaskType::Blur, 9., (3., 1., 17., 11.)), + (MaskType::Blur, 16., (20., 5., 17., 18.)), + (MaskType::Blur, 33., (1., 2., 35., 21.)), + (MaskType::Pixelate, 2., (0., 0., 37., 23.)), + (MaskType::Pixelate, 7., (5., 3., 13., 9.)), + (MaskType::Pixelate, 20., (11., 7., 26., 16.)), + ] + .into_iter() + .enumerate() + { + let mut mask = shape(AnnotationType::Mask, region.0, region.1, region.2, region.3); + mask.id = index.to_string(); + mask.mask_type = Some(mask_type); + mask.mask_level = Some(level); + masks.push(mask); + } + let requests: Vec<_> = masks + .iter() + .filter_map(|mask| mask_request(mask, frame)) + .collect(); + assert_eq!(requests.len(), masks.len()); + + let overlays = build_mask_overlays(&bgra, &[], &requests); + for (request, entry) in requests.iter().zip(&overlays) { + let expected = crate::library::rgba_to_render_image(request.filter(&rgba).unwrap()); + let overlay = entry.value.as_ref().unwrap(); + assert_eq!(overlay.image.size(0), expected.size(0)); + assert_eq!(overlay.image.as_bytes(0), expected.as_bytes(0)); + } + } + /// The resample itself: one overlay per mask, at the region's own size, /// with the row slicing staying inside the frame buffer. #[test] diff --git a/apps/desktop-gpui/src/screenshot_crop.rs b/apps/desktop-gpui/src/screenshot_crop.rs index 4e45a960a54..a882b471625 100644 --- a/apps/desktop-gpui/src/screenshot_crop.rs +++ b/apps/desktop-gpui/src/screenshot_crop.rs @@ -86,6 +86,18 @@ pub struct ScreenshotCropDialog { _field_subscriptions: Vec, } +impl ScreenshotCropDialog { + /// The original is decoded at full resolution (about 59 MB at 5K) and the + /// window's atlas keeps every painted image until it is told otherwise, so + /// each closed dialog would otherwise stay resident until the editor + /// window closes. + fn release_image(&mut self, window: &mut Window) { + if let Some(image) = self.image.take() { + let _ = window.drop_image(image); + } + } +} + impl ScreenshotEditorWindow { /// The header's crop button (`Header.tsx:84-93`): open the dialog seeded /// with the crop in force, or the whole image. Gated on `image_size` the @@ -163,10 +175,16 @@ impl ScreenshotEditorWindow { /// Close without writing -- Escape or the backdrop. The project was never /// touched, so there is nothing to roll back. - pub(crate) fn cancel_crop_dialog(&mut self, reason: &'static str, cx: &mut Context) { - if self.crop.take().is_none() { + pub(crate) fn cancel_crop_dialog( + &mut self, + reason: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + let Some(mut dialog) = self.crop.take() else { return; - } + }; + dialog.release_image(window); tracing::info!(reason, "screenshot crop cancelled"); cx.notify(); } @@ -174,10 +192,11 @@ impl ScreenshotEditorWindow { /// The footer's Save (`Editor.tsx:463-482`): one `setProject` write of /// `background.crop`, so one history entry, then close. fn save_crop_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(dialog) = self.crop.take() else { + let Some(mut dialog) = self.crop.take() else { return; }; let bounds = dialog.state.real(); + dialog.release_image(window); let crop = Crop { position: XY::new(bounds.x.max(0.) as u32, bounds.y.max(0.) as u32), size: XY::new(bounds.width.max(0.) as u32, bounds.height.max(0.) as u32), @@ -470,7 +489,7 @@ impl ScreenshotEditorWindow { return true; } if key == "escape" { - self.cancel_crop_dialog("escape", cx); + self.cancel_crop_dialog("escape", window, cx); return true; } let Some(nudge) = engine::is_nudge_key(key) else { @@ -668,8 +687,8 @@ impl ScreenshotEditorWindow { .absolute() .inset_0() .bg(gpui::hsla(0., 0., 0., 0.8)) - .on_click(cx.listener(|this, _, _window, cx| { - this.cancel_crop_dialog("backdrop", cx) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_crop_dialog("backdrop", window, cx) })), ) .child( diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index 24707fd2681..96489a861d4 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -912,12 +912,10 @@ pub struct ScreenshotEditorWindow { /// renders. exporting: bool, export_status: ExportStatus, - /// The latest GPU frame, already BGRA in gpui's atlas format. - frame: Option>, - /// The same frame as tight RGBA. `Preview.tsx` reads the preview canvas - /// back to blur/pixelate mask regions (`:537-687`); the mask overlay - /// resamples this instead of the atlas image. - pub(crate) frame_rgba: Option>>, + /// The latest GPU frame, already BGRA in gpui's atlas format. `Preview.tsx` + /// reads the preview canvas back to blur/pixelate mask regions + /// (`:537-687`); the mask overlay resamples these same pixels. + pub(crate) frame: Option>, pub(crate) mask_frame_revision: u64, pub(crate) frame_size: (f32, f32), /// `originalImageSize` -- the source PNG's dimensions, which the crop @@ -1003,7 +1001,6 @@ impl ScreenshotEditorWindow { exporting: false, export_status: ExportStatus::Idle, frame: None, - frame_rgba: None, mask_frame_revision: 0, frame_size: (1., 1.), image_size: None, @@ -1107,7 +1104,7 @@ impl ScreenshotEditorWindow { if self.bg_tab == BgTab::Wallpaper { self.ensure_wallpapers(cx); } - self.ensure_background_image(cx); + self.ensure_background_image(window, cx); if let Ok(dest) = std::env::var("CAP_GPUI_AUTO_SCREENSHOT_EXPORT") && !dest.trim().is_empty() { @@ -1126,7 +1123,6 @@ impl ScreenshotEditorWindow { pub fn frame_arrived( &mut self, image: Arc, - rgba: Arc>, size: (u32, u32), window: &mut Window, cx: &mut Context, @@ -1134,7 +1130,6 @@ impl ScreenshotEditorWindow { let next = (size.0.max(1) as f32, size.1.max(1) as f32); self.rescale_annotations_for(next); self.frame_size = next; - self.frame_rgba = Some(rgba); self.mask_frame_revision = self .mask_frame_revision .checked_add(1) @@ -1271,7 +1266,7 @@ impl ScreenshotEditorWindow { { self.selected_annotation = None; } - self.ensure_background_image(cx); + self.ensure_background_image(window, cx); self.publish(); self.schedule_save(window, cx); self.refresh_mask_overlays(window, cx); @@ -1505,9 +1500,11 @@ impl ScreenshotEditorWindow { /// The Image tab's `h-48` preview. Decoded off-thread and cached by path, /// so switching tabs does not re-read the file. - fn ensure_background_image(&mut self, cx: &mut Context) { + fn ensure_background_image(&mut self, window: &mut Window, cx: &mut Context) { let BackgroundSource::Image { path: Some(path) } = &self.project.background.source else { - self.bg_image = None; + if let Some((_, previous)) = self.bg_image.take() { + let _ = window.drop_image(previous); + } return; }; if self @@ -1518,7 +1515,7 @@ impl ScreenshotEditorWindow { return; } let path = path.clone(); - self.bg_image_task = Some(cx.spawn(async move |this, cx| { + self.bg_image_task = Some(cx.spawn_in(window, async move |this, cx| { let decoded = cx .background_executor() .spawn({ @@ -1526,8 +1523,11 @@ impl ScreenshotEditorWindow { async move { decode_preview_image(Path::new(&path)) } }) .await; - this.update(cx, |this, cx| { - this.bg_image = decoded.map(|image| (path, image)); + this.update_in(cx, |this, window, cx| { + let next = decoded.map(|image| (path, image)); + if let Some((_, previous)) = std::mem::replace(&mut this.bg_image, next) { + let _ = window.drop_image(previous); + } cx.notify(); }) .ok(); @@ -1550,7 +1550,7 @@ impl ScreenshotEditorWindow { window, cx, ); - this.ensure_background_image(cx); + this.ensure_background_image(window, cx); }) .ok(); }) @@ -1982,8 +1982,14 @@ impl ScreenshotEditorWindow { match result { Ok(bytes) => match destination { ExportDestination::Clipboard => { + let image = cx + .background_executor() + .spawn(async move { crate::platform::prepare_clipboard_image(bytes) }) + .await; this.update_in(cx, |this, window, cx| { - match crate::platform::copy_image_bytes_to_clipboard(&bytes, cx) { + match image + .and_then(|image| crate::platform::write_clipboard_image(image, cx)) + { Ok(()) => { this.toast_success( "Screenshot copied to clipboard!", @@ -3284,7 +3290,7 @@ impl ScreenshotEditorWindow { window, cx, ); - self.ensure_background_image(cx); + self.ensure_background_image(window, cx); } fn render_wallpaper_tab(&self, cx: &mut Context) -> AnyElement { @@ -3484,7 +3490,7 @@ impl ScreenshotEditorWindow { window, cx, ); - this.ensure_background_image(cx); + this.ensure_background_image(window, cx); })), ) .into_any_element() @@ -4742,26 +4748,10 @@ fn render_input_key(project: &mut ProjectConfiguration) -> Option> { Some(key) } -/// One rendered frame, in both the forms the window needs: tight RGBA (what a -/// mask overlay resamples) and the BGRA copy gpui's atlas takes. The rows are -/// copied once and the swap runs over a clone, so the padded GPU buffer is -/// walked a single time. -fn frame_buffers(frame: &RenderedFrame) -> Option<(Arc>, Arc)> { - let rgba = cap_gpui_kernels::frame::unpad_rows( - &frame.data, - frame.width as usize * 4, - frame.padded_bytes_per_row as usize, - frame.height as usize, - )?; - let mut bgra = rgba.clone(); - cap_gpui_kernels::frame::swap_red_blue(&mut bgra); - let buffer = image::RgbaImage::from_raw(frame.width, frame.height, bgra)?; - Some(( - Arc::new(rgba), - Arc::new(RenderImage::new(smallvec::smallvec![image::Frame::new( - buffer - )])), - )) +/// Only the newest queued frame is ever shown, so older ones are dropped +/// before the copy rather than converted and replaced. +fn latest_frame(frame: T, frames: &flume::Receiver) -> T { + frames.drain().last().unwrap_or(frame) } /// Read the bundle, start the still renderer on tokio, pump frames back -- @@ -4832,13 +4822,14 @@ pub fn load_screenshot_project( return; } - while let Ok((frame, revision)) = frame_rx.recv_async().await { + while let Ok(frame) = frame_rx.recv_async().await { + let (frame, revision) = latest_frame(frame, &frame_rx); let size = (frame.width, frame.height); - let buffers = cx + let image = cx .background_executor() - .spawn(async move { frame_buffers(&frame) }) + .spawn(async move { crate::editor_window::frame_image(&frame) }) .await; - let Some((rgba, image)) = buffers else { + let Some(image) = image else { tracing::warn!("a rendered screenshot frame could not be converted for display"); continue; }; @@ -4850,7 +4841,7 @@ pub fn load_screenshot_project( ); if handle .update(cx, |view, window, cx| { - view.frame_arrived(image, rgba, size, window, cx) + view.frame_arrived(image, size, window, cx) }) .is_err() { @@ -4882,6 +4873,56 @@ mod tests { assert_eq!(CHECKER_TILE % (CHECKER_CELL * 2), 0); } + #[test] + fn the_displayed_frame_matches_unpadding_then_swapping_a_copy() { + for (width, height) in [(1u32, 1u32), (3, 5), (7, 2), (33, 9), (1001, 3)] { + let row_bytes = width * 4; + for stride in [row_bytes, row_bytes + 4, row_bytes.next_multiple_of(256)] { + let frame = RenderedFrame { + data: Arc::new( + (0..stride as usize * height as usize) + .map(|index| (index * 7 + index / 251) as u8) + .collect(), + ), + width, + height, + padded_bytes_per_row: stride, + frame_number: 0, + target_time_ns: 0, + }; + let rgba = cap_gpui_kernels::frame::unpad_rows( + &frame.data, + row_bytes as usize, + stride as usize, + height as usize, + ) + .unwrap(); + let mut bgra = rgba.clone(); + cap_gpui_kernels::frame::swap_red_blue(&mut bgra); + + let image = crate::editor_window::frame_image(&frame).unwrap(); + assert_eq!( + image.size(0), + gpui::size( + gpui::DevicePixels::from(width), + gpui::DevicePixels::from(height) + ) + ); + assert_eq!(image.as_bytes(0).unwrap(), bgra.as_slice()); + } + } + } + + #[test] + fn only_the_newest_queued_frame_is_converted() { + let (frame_tx, frame_rx) = flume::bounded(2); + assert_eq!(latest_frame(1, &frame_rx), 1); + frame_tx.send(2).unwrap(); + frame_tx.send(3).unwrap(); + assert_eq!(latest_frame(1, &frame_rx), 3); + assert!(frame_rx.is_empty()); + } + /// `PaddingPopover`'s guard (`context.tsx:30-42`). #[test] fn an_invisible_background_is_one_with_nothing_to_show() { @@ -5177,7 +5218,7 @@ mod tests { )); setup_rx.recv_async().await.unwrap().unwrap(); let (first, _) = frame_rx.recv_async().await.unwrap(); - assert!(frame_buffers(&first).is_some()); + assert!(crate::editor_window::frame_image(&first).is_some()); let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let render_ms = Arc::new(std::sync::Mutex::new(Vec::new())); @@ -5189,10 +5230,12 @@ mod tests { (renders.clone(), render_ms.clone(), sent_at.clone()); tokio::spawn(async move { while let Ok((frame, revision)) = frame_rx.recv_async().await { - let buffers = tokio::task::spawn_blocking(move || frame_buffers(&frame)) - .await - .unwrap(); - assert!(buffers.is_some()); + let image = tokio::task::spawn_blocking(move || { + crate::editor_window::frame_image(&frame) + }) + .await + .unwrap(); + assert!(image.is_some()); if let Some(sent) = sent_at.lock().unwrap().remove(&revision) { render_ms .lock() diff --git a/apps/desktop-gpui/src/session.rs b/apps/desktop-gpui/src/session.rs index ad48a204a55..1763a0f74aa 100644 --- a/apps/desktop-gpui/src/session.rs +++ b/apps/desktop-gpui/src/session.rs @@ -1057,11 +1057,6 @@ impl RecordingSession { } pub fn stop(&mut self, cx: &mut Context) { - if self.phase == Phase::Starting && self.countdown_remaining.is_some() { - self.countdown_remaining = None; - self.finish(cx); - return; - } #[cfg(target_os = "linux")] if matches!(self.phase, Phase::Starting | Phase::Stopping) && let Some(attempt) = self.instant_attempt.clone() @@ -1072,6 +1067,10 @@ impl RecordingSession { cx.notify(); return; } + self.countdown_remaining = None; + self.abort_start_cue(); + self.discard_primed(cx); + self.phase = Phase::Stopping; self.stop_requested = true; let generation = self.recording_generation; let task = @@ -1079,10 +1078,11 @@ impl RecordingSession { cx.spawn(async move |this, cx| { let result = task.await; this.update(cx, |this, cx| { - if this.recording_generation != generation || this.phase != Phase::Starting || this.active.is_some() { return; } + if this.recording_generation != generation || this.phase != Phase::Stopping || this.active.is_some() { return; } if matches!(result, Ok(cap_recording::instant_recording::InstantQuiescence::Joined)) { this.finish(cx); } else { + this.phase = Phase::Starting; this.stop_requested = false; this.clean_control.uncertain = true; this.error = Some("Capture cleanup is unconfirmed. Local files are preserved; use Ctrl+Shift+F9 to retry Stop.".into()); @@ -1093,6 +1093,11 @@ impl RecordingSession { cx.notify(); return; } + if self.phase == Phase::Starting && self.countdown_remaining.is_some() { + self.countdown_remaining = None; + self.finish(cx); + return; + } #[cfg(target_os = "linux")] if crate::app_windows::clean_capture_active(cx) && clean_capture_stop_must_wait(self.phase, self.clean_control.pending_kind()) @@ -1968,6 +1973,55 @@ fn recording_failure_is_current(phase: Phase, current: u64, completed: u64) -> b mod countdown_tests { use super::*; + #[cfg(target_os = "linux")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn countdown_stop_cancels_an_unpolled_instant_start() { + for countdown in [1, 0] { + let runtime = tokio::runtime::Handle::current(); + gpui_platform::headless().run(move |cx| { + gpui_tokio::init_from_handle(cx, runtime); + let session = RecordingSession::init(cx); + let attempt = recording::InstantAttempt::new(); + let gate = cap_recording::RecordingStartGate::new(); + let (cue_abort, _) = futures_util::future::AbortHandle::new_pair(); + session.update(cx, |session, cx| { + session.phase = Phase::Starting; + session.countdown_remaining = Some(countdown); + session.countdown_cue_abort = Some(cue_abort.clone()); + session.start_gate = Some(gate.clone()); + session.instant_attempt = Some(attempt.clone()); + session.stop(cx); + assert_eq!(session.countdown_remaining, None); + }); + assert!(cue_abort.is_aborted()); + assert!(!gate.is_armed()); + assert_eq!( + attempt.quiescence(), + cap_recording::instant_recording::InstantQuiescence::Joined + ); + session.update(cx, |session, cx| session.stop(cx)); + cx.spawn(async move |cx| { + for _ in 0..500 { + if session.read_with(cx, |session, _| session.phase) == Phase::Idle { + break; + } + cx.background_executor() + .timer(Duration::from_millis(10)) + .await; + } + cx.update(|cx| { + cx.quit(); + let session = session.read(cx); + assert_eq!(session.phase, Phase::Idle); + assert!(session.error.is_none()); + assert!(session.start_gate.is_none()); + }); + }) + .detach(); + }); + } + } + #[test] fn countdown_ticks_until_the_final_step_before_start() { assert_eq!( diff --git a/apps/desktop-gpui/src/settings_pages.rs b/apps/desktop-gpui/src/settings_pages.rs index 7ffc0113a9f..530a8df630f 100644 --- a/apps/desktop-gpui/src/settings_pages.rs +++ b/apps/desktop-gpui/src/settings_pages.rs @@ -1329,6 +1329,14 @@ impl SettingsWindow { cx.notify(); } + pub(crate) fn shortcut_listening(&self) -> bool { + self.pages.listening.is_some() + } + + pub(crate) fn switch_back_running(&self) -> bool { + self.pages.switch_back.is_some() + } + /// Leave recording mode without touching the binding. Safe to call when /// nothing is recording; every exit path (Escape, a click elsewhere, /// another page, the window losing focus or closing) lands here. @@ -2616,6 +2624,7 @@ impl SettingsWindow { ), self.toggle( "out-of-process-muxer", + "Out-of-process muxer", self.settings.out_of_process_muxer, cx, |this, cx| { @@ -2658,14 +2667,20 @@ impl SettingsWindow { "Turn off to close this app and reopen the classic Tauri app in the dev harness. \ Recordings and settings are shared.", ), - self.toggle("enable-gpui-app", checked, cx, |this, cx| { - match this.pages.switch_back { - Some(SwitchBack::Running(_)) => this.cancel_switch_back(cx), - // Committed: the classic app is already being brought up. - Some(SwitchBack::WaitingForClassic) => {} - _ => this.start_switch_back(cx), - } - }) + self.toggle( + "enable-gpui-app", + "Use native app", + checked, + cx, + |this, cx| { + match this.pages.switch_back { + Some(SwitchBack::Running(_)) => this.cancel_switch_back(cx), + // Committed: the classic app is already being brought up. + Some(SwitchBack::WaitingForClassic) => {} + _ => this.start_switch_back(cx), + } + }, + ) .into_any_element(), ) } @@ -3391,6 +3406,7 @@ impl SettingsWindow { ), self.toggle( "diagnostic-mic", + "Test microphone", self.pages.diagnostic_mic, cx, |this, cx| { @@ -6988,15 +7004,19 @@ impl SettingsWindow { (false, true) => "Disabled · Not saved", }), ) - .child( - self.toggle(("rule-enabled", index), enabled, cx, move |this, cx| { + .child(self.toggle( + ("rule-enabled", index), + "Enabled", + enabled, + cx, + move |this, cx| { if let Some(rule) = this.pages.automations.rules.get_mut(index) { rule.enabled = !rule.enabled; this.automations_persist(); cx.notify(); } - }), - ), + }, + )), ) .child( div() @@ -7685,6 +7705,7 @@ impl SettingsWindow { SharedString::from(format!( "action-copy-link-{rule}-{index}" )), + "Copy link to clipboard", copy_link, cx, move |this, cx| { @@ -7717,6 +7738,7 @@ impl SettingsWindow { SharedString::from(format!( "action-open-browser-{rule}-{index}" )), + "Open in browser", open_in_browser, cx, move |this, cx| { @@ -7771,6 +7793,7 @@ impl SettingsWindow { .gap(px(8.)) .child(self.toggle( SharedString::from(format!("action-shell-{rule}-{index}")), + "Run through shell", use_shell, cx, move |this, cx| { diff --git a/apps/desktop-gpui/src/settings_window.rs b/apps/desktop-gpui/src/settings_window.rs index 0fdb185e2d6..a13f277e883 100644 --- a/apps/desktop-gpui/src/settings_window.rs +++ b/apps/desktop-gpui/src/settings_window.rs @@ -12,7 +12,13 @@ //! file the shipping app uses, one key at a time, through //! [`crate::store::set_store_setting`]. -use std::{cell::Cell, path::PathBuf, rc::Rc, sync::Arc, time::Duration}; +use std::{ + cell::Cell, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, + time::Duration, +}; use gpui::{ AppContext as _, Bounds, Context, Entity, FocusHandle, FontWeight, Hsla, InteractiveElement, @@ -370,6 +376,15 @@ struct RecordingRow { thumbnail_stale: bool, } +impl RecordingRow { + fn pending_thumbnail(&self) -> Option<&Path> { + self.item + .thumbnail + .as_deref() + .filter(|_| self.thumbnail_stale) + } +} + /// Everything the page owns. The Solid route keeps this in a `createQuery` plus /// three signals; the lifetimes are the same, they just live on the window here. struct Recordings { @@ -384,9 +399,12 @@ struct Recordings { search: String, /// `visibleCount`, reset to `PAGE_SIZE` by a tab or search change. visible_count: usize, - /// The scan + thumbnail decode task. Dropping it cancels, so a refresh that - /// arrives mid-scan replaces the old one rather than racing it. + /// The scan task. Dropping it cancels, so a refresh that arrives mid-scan + /// replaces the old one rather than racing it. scan: Option>, + /// The thumbnail decode for the rows on screen, replaced whenever more of + /// them come into view. + decode: Option>, /// The 2s poll, armed only while something in the list is still being /// written (`refetchInterval`). tick: Option>, @@ -401,6 +419,7 @@ impl Recordings { search: String::new(), visible_count: RECORDINGS_PAGE_SIZE, scan: None, + decode: None, tick: None, } } @@ -410,14 +429,26 @@ impl Recordings { self.search.trim() } - /// `filteredRecordings()`: the tab, then the case-insensitive substring. - fn filtered(&self) -> Vec<&RecordingRow> { + /// `visibleRecordings()` over `filteredRecordings()`: the tab, then the + /// case-insensitive substring. + fn visible_rows(&self) -> VisibleRows { let query = self.trimmed_search().to_lowercase(); - self.items - .iter() - .flatten() - .filter(|row| matches_recording_filters(&row.item, self.tab, &query)) - .collect() + visible_rows( + self.items + .iter() + .flatten() + .map(|row| matches_recording_filters(&row.item, self.tab, &query)), + !query.is_empty(), + self.visible_count, + ) + } + + fn pending_thumbnails(&self) -> Vec<(usize, PathBuf)> { + pending_thumbnail_decodes( + self.items.as_deref().unwrap_or_default(), + &self.visible_rows().indices, + RecordingRow::pending_thumbnail, + ) } } @@ -436,12 +467,22 @@ struct ScreenshotRow { thumbnail: Option>, } +impl ScreenshotRow { + fn pending_thumbnail(&self) -> Option<&Path> { + self.item + .thumbnail + .as_deref() + .filter(|_| self.thumbnail.is_none()) + } +} + struct Screenshots { items: Option>, search_input: Entity, search: String, visible_count: usize, scan: Option>, + decode: Option>, } impl Screenshots { @@ -452,6 +493,7 @@ impl Screenshots { search: String::new(), visible_count: RECORDINGS_PAGE_SIZE, scan: None, + decode: None, } } @@ -459,16 +501,64 @@ impl Screenshots { self.search.trim() } - fn filtered(&self) -> Vec<&ScreenshotRow> { + fn visible_rows(&self) -> VisibleRows { let query = self.trimmed_search().to_lowercase(); - self.items - .iter() - .flatten() - .filter(|row| query.is_empty() || row.item.pretty_name.to_lowercase().contains(&query)) - .collect() + visible_rows( + self.items.iter().flatten().map(|row| { + query.is_empty() || row.item.pretty_name.to_lowercase().contains(&query) + }), + !query.is_empty(), + self.visible_count, + ) + } + + fn pending_thumbnails(&self) -> Vec<(usize, PathBuf)> { + pending_thumbnail_decodes( + self.items.as_deref().unwrap_or_default(), + &self.visible_rows().indices, + ScreenshotRow::pending_thumbnail, + ) } } +/// The rows a list paints, as indices into the unfiltered scan, so the list and +/// its thumbnail decode agree on what is on screen. +struct VisibleRows { + indices: Vec, + /// `filteredRecordings().length`, which the empty state and "Load more" + /// read. + matching: usize, +} + +fn visible_rows( + matches: impl IntoIterator, + has_search: bool, + visible_count: usize, +) -> VisibleRows { + let mut indices: Vec = matches + .into_iter() + .enumerate() + .filter_map(|(index, matched)| matched.then_some(index)) + .collect(); + let matching = indices.len(); + indices.truncate(visible_recordings_len(matching, has_search, visible_count)); + VisibleRows { indices, matching } +} + +/// The `(row, thumbnail)` decode jobs for the visible rows only. A library can +/// hold thousands of items while the page paints twenty; rows further down are +/// queued when "Load more", a tab, or a search brings them on screen. +fn pending_thumbnail_decodes( + rows: &[R], + visible: &[usize], + pending: impl Fn(&R) -> Option<&Path>, +) -> Vec<(usize, PathBuf)> { + visible + .iter() + .filter_map(|&index| pending(rows.get(index)?).map(|path| (index, path.to_path_buf()))) + .collect() +} + /// `visibleRecordings()`: an active search shows every match, unpaginated. fn visible_recordings_len(total: usize, has_search: bool, visible_count: usize) -> usize { if has_search { @@ -488,6 +578,13 @@ fn load_more_count(visible_count: usize, total: usize) -> usize { (visible_count + RECORDINGS_PAGE_SIZE).min(total) } +fn search_may_have_changed(event: &ui::TextInputEvent) -> bool { + matches!( + event, + ui::TextInputEvent::Changed | ui::TextInputEvent::Cancelled + ) +} + /// `emptyMessage()`. fn recordings_empty_message(tab: RecordingsTab, trimmed_search: &str) -> String { let tab_label = match tab { @@ -663,6 +760,8 @@ pub struct SettingsWindow { _sign_in_subscriptions: [gpui::Subscription; 2], user_profile: Option, profile_image: Option>, + keyboard_nav: bool, + _keyboard_nav: gpui::Subscription, } impl SettingsWindow { @@ -719,12 +818,26 @@ impl SettingsWindow { cx.subscribe(&server_url_input, |this, input, event, cx| { this.on_field_event(Field::ServerUrl, input, event, cx) }), - cx.subscribe(&recordings_search, |this, input, event, cx| { - this.on_field_event(Field::RecordingsSearch, input, event, cx) - }), - cx.subscribe(&screenshots_search, |this, input, event, cx| { - this.on_field_event(Field::ScreenshotsSearch, input, event, cx) - }), + cx.subscribe_in( + &recordings_search, + window, + |this, input, event, window, cx| { + this.on_field_event(Field::RecordingsSearch, input.clone(), event, cx); + if search_may_have_changed(event) { + this.decode_visible_recordings(window, cx); + } + }, + ), + cx.subscribe_in( + &screenshots_search, + window, + |this, input, event, window, cx| { + this.on_field_event(Field::ScreenshotsSearch, input.clone(), event, cx); + if search_may_have_changed(event) { + this.decode_visible_screenshots(window, cx); + } + }, + ), ]; let sign_in = SignIn::global(cx); @@ -749,6 +862,9 @@ impl SettingsWindow { let pages = crate::settings_pages::PagesState::new(window, cx); let page_scroll = ScrollHandle::new(); + let keyboard_nav = ui::keyboard_nav::install(window, cx, |this: &Self, _| { + this.menu.is_some() || this.shortcut_listening() || this.switch_back_running() + }); Self { theme, @@ -784,6 +900,8 @@ impl SettingsWindow { _sign_in_subscriptions: sign_in_subscriptions, user_profile, profile_image: None, + keyboard_nav: false, + _keyboard_nav: keyboard_nav, } } @@ -870,22 +988,27 @@ impl SettingsWindow { } if self.page == Page::Recordings { self.screenshots.scan = None; + self.screenshots.decode = None; self.refresh_recordings(window, cx); return; } if self.page == Page::Screenshots { self.recordings.scan = None; + self.recordings.decode = None; self.recordings.tick = None; self.refresh_screenshots(window, cx); return; } self.recordings.scan = None; + self.recordings.decode = None; self.recordings.tick = None; self.screenshots.scan = None; + self.screenshots.decode = None; } /// The `recordings` query: scan the library on the background executor, - /// then decode the thumbnails there through [`library::spawn_decode_pool`]. + /// then decode the visible rows' thumbnails there through + /// [`library::spawn_decode_pool`]. /// /// Same two-stage shape as the main window's Recents (`refresh_recents`): /// the list lands first so the rows can paint with their grey placeholder, @@ -905,17 +1028,39 @@ impl SettingsWindow { .await; tracing::info!(count = items.len(), "scanned the recordings library"); - let Ok(thumbnails) = this.update_in(cx, |this, window, cx| { - this.set_recordings(items, window, cx) - }) else { - return; - }; + this.update_in(cx, |this, window, cx| { + this.set_recordings(items, window, cx); + let pending = this.recordings.pending_thumbnails(); + this.decode_recording_thumbnails(pending, window, cx); + }) + .ok(); + })); + } - let (_decodes, results) = library::spawn_decode_pool( - cx.background_executor(), - thumbnails, - |(index, path)| library::decode_thumbnail(&path).map(|image| (index, path, image)), - ); + /// After "Load more", a tab, or a search: decode whatever just came on + /// screen. With nothing new the running batch, and the poll it arms when it + /// finishes, are left alone. + fn decode_visible_recordings(&mut self, window: &mut Window, cx: &mut Context) { + let pending = self.recordings.pending_thumbnails(); + if !pending.is_empty() { + self.decode_recording_thumbnails(pending, window, cx); + } + } + + /// Replaces the running batch rather than adding a second pool: its + /// unfinished rows are still pending, so `pending` re-queues the ones that + /// are still on screen. + fn decode_recording_thumbnails( + &mut self, + pending: Vec<(usize, PathBuf)>, + window: &mut Window, + cx: &mut Context, + ) { + self.recordings.decode = Some(cx.spawn_in(window, async move |this, cx| { + let (_decodes, results) = + library::spawn_decode_pool(cx.background_executor(), pending, |(index, path)| { + library::decode_thumbnail(&path).map(|image| (index, path, image)) + }); while let Ok(first) = results.recv_async().await { let mut batch = vec![first]; batch.extend(results.try_iter()); @@ -956,8 +1101,8 @@ impl SettingsWindow { })); } - /// Install a scan result, and hand back the thumbnails that still have to - /// be decoded. + /// Install a scan result. A row whose thumbnail still has to be decoded is + /// left `thumbnail_stale` until it is on screen. /// /// `reconcile: "path"` on the query is doing real work here: the 2s poll /// re-runs the whole scan, and a row that is still in the library must keep @@ -971,7 +1116,7 @@ impl SettingsWindow { items: Vec, window: &mut Window, cx: &mut Context, - ) -> Vec<(usize, PathBuf)> { + ) { let mut cached: std::collections::HashMap<_, _> = self .recordings .items @@ -981,11 +1126,9 @@ impl SettingsWindow { .map(|row| (row.item.path.clone(), row)) .collect(); - let mut pending = Vec::new(); let rows: Vec = items .into_iter() - .enumerate() - .map(|(index, item)| { + .map(|item| { let previous = cached.remove(&item.path); let changed = previous.as_ref().is_none_or(|row| { row.item.thumbnail != item.thumbnail @@ -994,9 +1137,6 @@ impl SettingsWindow { || row.thumbnail.is_none() }); let thumbnail = previous.and_then(|row| row.thumbnail); - if changed && let Some(path) = item.thumbnail.clone() { - pending.push((index, path)); - } RecordingRow { item, thumbnail, @@ -1017,7 +1157,6 @@ impl SettingsWindow { // recording is being written into the library, and an inactive gpui // window does not repaint from a background-driven model update. window.refresh(); - pending } /// `refetchInterval: data.some(hasActiveRecording) ? 2000 : false` -- armed @@ -1093,17 +1232,33 @@ impl SettingsWindow { .await; tracing::info!(count = items.len(), "scanned the screenshots library"); - let Ok(thumbnails) = this.update_in(cx, |this, window, cx| { - this.set_screenshots(items, window, cx) - }) else { - return; - }; + this.update_in(cx, |this, window, cx| { + this.set_screenshots(items, window, cx); + let pending = this.screenshots.pending_thumbnails(); + this.decode_screenshot_thumbnails(pending, window, cx); + }) + .ok(); + })); + } - let (_decodes, results) = library::spawn_decode_pool( - cx.background_executor(), - thumbnails, - |(index, path)| library::decode_thumbnail(&path).map(|image| (index, path, image)), - ); + fn decode_visible_screenshots(&mut self, window: &mut Window, cx: &mut Context) { + let pending = self.screenshots.pending_thumbnails(); + if !pending.is_empty() { + self.decode_screenshot_thumbnails(pending, window, cx); + } + } + + fn decode_screenshot_thumbnails( + &mut self, + pending: Vec<(usize, PathBuf)>, + window: &mut Window, + cx: &mut Context, + ) { + self.screenshots.decode = Some(cx.spawn_in(window, async move |this, cx| { + let (_decodes, results) = + library::spawn_decode_pool(cx.background_executor(), pending, |(index, path)| { + library::decode_thumbnail(&path).map(|image| (index, path, image)) + }); while let Ok(first) = results.recv_async().await { let mut batch = vec![first]; batch.extend(results.try_iter()); @@ -1141,7 +1296,7 @@ impl SettingsWindow { items: Vec, window: &mut Window, cx: &mut Context, - ) -> Vec<(usize, PathBuf)> { + ) { let mut cached: std::collections::HashMap> = self .screenshots .items @@ -1151,17 +1306,10 @@ impl SettingsWindow { .filter_map(|row| row.thumbnail.map(|image| (row.item.path, image))) .collect(); - let mut pending = Vec::new(); let rows: Vec = items .into_iter() - .enumerate() - .map(|(index, item)| { + .map(|item| { let thumbnail = cached.remove(&item.path); - if thumbnail.is_none() - && let Some(path) = item.thumbnail.clone() - { - pending.push((index, path)); - } ScreenshotRow { item, thumbnail } }) .collect(); @@ -1173,7 +1321,6 @@ impl SettingsWindow { self.screenshots.items = Some(rows); cx.notify(); window.refresh(); - pending } fn delete_screenshot(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { @@ -1553,6 +1700,10 @@ fn covers_default_exclusion(entry: &WindowExclusion, default: &WindowExclusion) impl Render for SettingsWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_appearance(window, cx); + if self.switch_back_running() { + ui::keyboard_nav::leave(window, cx); + } + self.keyboard_nav = ui::keyboard_nav::active(window, cx); let theme = self.theme; if self.page == Page::Quality && let Some(mode) = self.quality_scroll_request.take() @@ -1567,6 +1718,7 @@ impl Render for SettingsWindow { let shell = div() .track_focus(&self.focus) + .child(ui::keyboard_nav::pointer_watcher()) // `(window-chrome).tsx` binds Cmd-W to `getCurrentWindow().close()` // for every chrome window. Escape is not bound there and is not // bound here. @@ -1774,6 +1926,7 @@ impl SettingsWindow { div() .id("settings-nav") + .role(gpui::Role::TabList) .flex() .flex_col() // `.cap-settings-nav { padding: 0 var(--sidebar-padding-x) @@ -1788,6 +1941,10 @@ impl SettingsWindow { let selected = page == self.page; div() .id(SharedString::from(page.slug())) + .when(self.keyboard_nav, ui::keyboard_nav::stop) + .role(gpui::Role::Tab) + .aria_label(page.label()) + .aria_selected(selected) .flex() .flex_row() .items_center() @@ -2257,14 +2414,17 @@ impl SettingsWindow { fn render_screenshots_list(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; - let rows = self.screenshots.filtered(); - let total = rows.len(); + let rows = self.screenshots.items.as_deref().unwrap_or_default(); + let VisibleRows { + indices, + matching: total, + } = self.screenshots.visible_rows(); let has_search = !self.screenshots.trimmed_search().is_empty(); - let visible = visible_recordings_len(total, has_search, self.screenshots.visible_count); + let visible = indices.len(); let more = has_more_recordings(total, has_search, self.screenshots.visible_count); let mut items = Vec::with_capacity(visible); - for (index, row) in rows.iter().take(visible).enumerate() { + for (index, row) in indices.into_iter().map(|row| &rows[row]).enumerate() { items.push( self.render_screenshot_row(index, row, index + 1 == visible, cx) .into_any_element(), @@ -2303,10 +2463,11 @@ impl SettingsWindow { "Load more", false, cx, - |this, _window, cx| { - let total = this.screenshots.filtered().len(); + |this, window, cx| { + let total = this.screenshots.visible_rows().matching; this.screenshots.visible_count = load_more_count(this.screenshots.visible_count, total); + this.decode_visible_screenshots(window, cx); cx.notify(); }, )), @@ -2376,13 +2537,14 @@ impl SettingsWindow { { let png = png.clone(); move |_this, _window, cx| { - if let Err(error) = crate::platform::copy_image_to_clipboard(&png, cx) { - tracing::warn!("copying screenshot failed: {error}"); - cx.spawn(async move |_, _| { + let copied = crate::platform::copy_image_to_clipboard(png.clone(), cx); + cx.spawn(async move |_, _| { + if let Err(error) = copied.await { + tracing::warn!("copying screenshot failed: {error}"); crate::platform::alert_dialog("Could not copy screenshot", &error); - }) - .detach(); - } + } + }) + .detach(); } }, )) @@ -2504,11 +2666,12 @@ impl SettingsWindow { })) // `text-xs text-gray-12` .child(div().text_size(px(12.)).child(tab.label())) - .on_click(cx.listener(move |this, _, _window, cx| { + .on_click(cx.listener(move |this, _, window, cx| { this.recordings.tab = tab; // `createEffect(() => { activeTab(); ... // setVisibleCount(PAGE_SIZE) })` this.recordings.visible_count = RECORDINGS_PAGE_SIZE; + this.decode_visible_recordings(window, cx); cx.notify(); })) })); @@ -2546,16 +2709,19 @@ impl SettingsWindow { /// border-gray-3`, with the "Load more" footer under it. fn render_recordings_list(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; - let rows = self.recordings.filtered(); - let total = rows.len(); + let rows = self.recordings.items.as_deref().unwrap_or_default(); + let VisibleRows { + indices, + matching: total, + } = self.recordings.visible_rows(); let has_search = !self.recordings.trimmed_search().is_empty(); - let visible = visible_recordings_len(total, has_search, self.recordings.visible_count); + let visible = indices.len(); let more = has_more_recordings(total, has_search, self.recordings.visible_count); // Built up front rather than in a `.children(map(..))`: the closure // would have to hold `cx` across every row, which it cannot. let mut items = Vec::with_capacity(visible); - for (index, row) in rows.iter().take(visible).enumerate() { + for (index, row) in indices.into_iter().map(|row| &rows[row]).enumerate() { // `not-last:border-b` counts the *rendered* rows, so the border // stops at the last visible one rather than the last matching one. items.push( @@ -2597,10 +2763,11 @@ impl SettingsWindow { "Load more", false, cx, - |this, _window, cx| { - let total = this.recordings.filtered().len(); + |this, window, cx| { + let total = this.recordings.visible_rows().matching; this.recordings.visible_count = load_more_count(this.recordings.visible_count, total); + this.decode_visible_recordings(window, cx); cx.notify(); }, )), @@ -3052,6 +3219,7 @@ impl SettingsWindow { // The row is the *inverse* of the stored key. self.toggle( "hide-dock-icon", + "Always show dock icon", !self.settings.hide_dock_icon, cx, |this, cx| { @@ -3075,6 +3243,7 @@ impl SettingsWindow { ), self.toggle( "enable-notifications", + "System notifications", self.settings.enable_notifications, cx, |this, cx| { @@ -3134,6 +3303,7 @@ impl SettingsWindow { Some("Open the link in your browser when an upload finishes."), self.toggle( "auto-open-links", + "Open share links automatically", !self.settings.disable_auto_open_links, cx, |this, cx| { @@ -3416,6 +3586,7 @@ impl SettingsWindow { ), self.toggle( "confirm-without-mic", + "Confirm before recording without a microphone", settings.confirm_without_microphone, cx, |this, cx| { @@ -3442,6 +3613,7 @@ impl SettingsWindow { ), self.toggle( "studio-sound-default", + "Studio Sound on new recordings", settings.studio_sound_by_default, cx, |this, cx| { @@ -3486,6 +3658,7 @@ impl SettingsWindow { Some("Cap removes the local file once it has uploaded successfully."), self.toggle( "delete-after-upload", + "Delete Instant recordings after upload", settings.delete_instant_recordings_after_upload, cx, |this, cx| { @@ -3505,6 +3678,7 @@ impl SettingsWindow { ), self.toggle( "crash-recovery", + "Crash-recoverable recording", settings.crash_recovery_recording, cx, |this, cx| { @@ -3524,6 +3698,7 @@ impl SettingsWindow { ), self.toggle( "custom-cursor", + "Custom cursor capture (Studio)", settings.custom_cursor_capture, cx, |this, cx| { @@ -3541,11 +3716,18 @@ impl SettingsWindow { "Automatically add zoom segments around mouse clicks in Studio \ recordings.", ), - self.toggle("auto-zoom", settings.auto_zoom_on_clicks, cx, |this, cx| { - this.settings.auto_zoom_on_clicks = !this.settings.auto_zoom_on_clicks; - let value = this.settings.auto_zoom_on_clicks; - this.write_bool("autoZoomOnClicks", value, cx); - }) + self.toggle( + "auto-zoom", + "Auto zoom on clicks", + settings.auto_zoom_on_clicks, + cx, + |this, cx| { + this.settings.auto_zoom_on_clicks = + !this.settings.auto_zoom_on_clicks; + let value = this.settings.auto_zoom_on_clicks; + this.write_bool("autoZoomOnClicks", value, cx); + }, + ) .into_any_element(), ), self.setting_row( @@ -3574,6 +3756,7 @@ impl SettingsWindow { Some("Record key presses so you can add keyboard overlays in the editor."), self.toggle( "capture-keyboard", + "Capture keyboard presses", settings.capture_keyboard_events, cx, |this, cx| { @@ -3595,6 +3778,7 @@ impl SettingsWindow { ), self.toggle( "notch-overlay", + "Draw the MacBook notch on screen recordings", settings.macbook_notch_overlay, cx, |this, cx| { @@ -4279,6 +4463,7 @@ impl SettingsWindow { ), self.toggle( "telemetry", + "Share anonymous telemetry", self.settings.enable_telemetry, cx, |this, cx| { @@ -4347,11 +4532,13 @@ impl SettingsWindow { pub(crate) fn toggle( &self, id: impl Into, + label: impl Into, checked: bool, cx: &mut Context, on_change: impl Fn(&mut Self, &mut Context) + 'static, ) -> ui::Toggle { ui::Toggle::settings(&self.theme, id, checked) + .label(label) .on_click(cx.listener(move |this, _, _window, cx| on_change(this, cx))) } @@ -4622,13 +4809,11 @@ const ZOOM_MAX: f32 = 4.5; /// origin before storing it, and throws (leaving the setting alone) when it /// does not parse. fn origin_of(value: &str) -> Option { - let value = value.trim(); - let (scheme, rest) = value.split_once("://")?; - if !matches!(scheme, "http" | "https") || rest.is_empty() { + let url = reqwest::Url::parse(value.trim()).ok()?; + if !matches!(url.scheme(), "http" | "https") { return None; } - let authority = rest.split(['/', '?', '#']).next()?; - (!authority.is_empty()).then(|| format!("{scheme}://{authority}")) + Some(url.origin().ascii_serialization()) } /// The preview under the project-name template. @@ -4685,6 +4870,37 @@ mod tests { assert_eq!(origin_of(""), None); } + #[test] + fn invalid_server_authorities_are_rejected_before_saving() { + for value in [ + "https://cap.example.com:invalid", + "https://cap.example.com:65536", + "https://cap example.com", + "http://[::1", + "https://user@", + ] { + assert_eq!(origin_of(value), None, "{value}"); + } + } + + #[test] + fn server_origins_are_normalised_for_api_requests() { + for (value, expected) in [ + ("https://cap.example.com/", "https://cap.example.com"), + ( + "https://user:password@cap.example.com/dashboard?x=1#top", + "https://cap.example.com", + ), + (" HTTPS://CAP.EXAMPLE.COM:443/ ", "https://cap.example.com"), + ("http://localhost:3000/path", "http://localhost:3000"), + ("http://[::1]:3000/path", "http://[::1]:3000"), + ] { + let origin = origin_of(value).expect(value); + assert_eq!(origin, expected); + assert!(reqwest::Url::parse(&format!("{origin}/api/desktop/feedback")).is_ok()); + } + } + #[test] fn account_name_matches_the_tauri_profile_fallbacks() { let mut profile = UserProfile { @@ -4854,6 +5070,100 @@ mod tests { assert!(!has_more_recordings(53, true, 20)); } + #[test] + fn visible_rows_follow_the_filter_and_the_page() { + let all = visible_rows([true; 53], false, 20); + assert_eq!(all.indices, (0..20).collect::>()); + assert_eq!(all.matching, 53); + + let more = visible_rows([true; 53], false, load_more_count(20, 53)); + assert_eq!(more.indices, (0..40).collect::>()); + let last = visible_rows([true; 53], false, load_more_count(40, 53)); + assert_eq!(last.indices, (0..53).collect::>()); + + // A tab or a search matches a subset, and the indices still address the + // unfiltered rows. + let every_third: Vec = (0..90).map(|index| index % 3 == 0).collect(); + let paged = visible_rows(every_third.iter().copied(), false, 20); + assert_eq!(paged.indices, (0..60).step_by(3).collect::>()); + assert_eq!(paged.matching, 30); + let searched = visible_rows(every_third.iter().copied(), true, 20); + assert_eq!(searched.indices, (0..90).step_by(3).collect::>()); + assert_eq!(searched.matching, 30); + + let none = visible_rows([false; 5], true, 20); + assert!(none.indices.is_empty()); + assert_eq!(none.matching, 0); + } + + fn recording_row(index: usize, decoded: bool) -> RecordingRow { + let mut item = recording(&format!("Recording {index}"), RecordingMode::Studio); + item.thumbnail = Some(item.path.join("screenshots/display.jpg")); + RecordingRow { + item, + thumbnail: None, + thumbnail_stale: !decoded, + } + } + + #[test] + fn only_visible_rows_are_queued_for_a_thumbnail_decode() { + let rows: Vec = (0..500).map(|index| recording_row(index, false)).collect(); + let every_row: Vec = (0..rows.len()).collect(); + let before = pending_thumbnail_decodes(&rows, &every_row, RecordingRow::pending_thumbnail); + let visible = visible_rows(rows.iter().map(|_| true), false, RECORDINGS_PAGE_SIZE); + let after = + pending_thumbnail_decodes(&rows, &visible.indices, RecordingRow::pending_thumbnail); + println!( + "decode jobs for a 500-row scan: every row {}, visible rows {}", + before.len(), + after.len() + ); + assert_eq!(before.len(), 500); + assert_eq!( + after, + (0..20) + .map(|index| (index, rows[index].item.thumbnail.clone().unwrap())) + .collect::>() + ); + + let mut rows = rows; + for row in &mut rows[..20] { + row.thumbnail_stale = false; + } + let more = visible_rows( + rows.iter().map(|_| true), + false, + load_more_count(RECORDINGS_PAGE_SIZE, 500), + ); + let next: Vec = + pending_thumbnail_decodes(&rows, &more.indices, RecordingRow::pending_thumbnail) + .into_iter() + .map(|(index, _)| index) + .collect(); + assert_eq!(next, (20..40).collect::>()); + + // A search reaches past the first page in its own order. + let query = "recording 49"; + let searched = visible_rows( + rows.iter() + .map(|row| matches_recording_filters(&row.item, RecordingsTab::All, query)), + true, + RECORDINGS_PAGE_SIZE, + ); + let found: Vec = + pending_thumbnail_decodes(&rows, &searched.indices, RecordingRow::pending_thumbnail) + .into_iter() + .map(|(index, _)| index) + .collect(); + assert_eq!(found, [49].into_iter().chain(490..500).collect::>()); + + rows[30].item.thumbnail = None; + assert!( + pending_thumbnail_decodes(&rows, &[30], RecordingRow::pending_thumbnail).is_empty() + ); + } + /// `emptyMessage()`. #[test] fn the_empty_message_names_the_tab_and_the_search() { diff --git a/apps/desktop-gpui/src/store.rs b/apps/desktop-gpui/src/store.rs index c6c3406e0e3..8c0e2e7e28e 100644 --- a/apps/desktop-gpui/src/store.rs +++ b/apps/desktop-gpui/src/store.rs @@ -14,7 +14,12 @@ //! read-modify-write on the raw JSON that touches exactly one key: see //! [`set_store_setting`]. -use std::path::{Path, PathBuf}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError}, + time::{Duration, SystemTime}, +}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -338,6 +343,11 @@ thread_local! { }; } +#[cfg(test)] +thread_local! { + static TEST_STORE_FILE_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] pub(crate) fn set_test_tauri_store_path(path: Option) { TEST_TAURI_STORE_PATH.with(|current| *current.borrow_mut() = path); @@ -370,6 +380,8 @@ static STORE_UPDATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// *not*: it is returned as `None` so no write path can silently replace /// someone's settings with a fresh object. fn read_store(path: &std::path::Path) -> Option> { + #[cfg(test)] + TEST_STORE_FILE_READS.with(|reads| reads.set(reads.get() + 1)); let bytes = match std::fs::read(path) { Ok(bytes) => bytes, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(Map::new()), @@ -391,24 +403,113 @@ fn read_store(path: &std::path::Path) -> Option> { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct StoreFileStamp { + len: u64, + modified: Option, + #[cfg(unix)] + inode: (u64, u64), + #[cfg(unix)] + changed: (i64, i64), +} + +/// Filesystem mtimes are only as fine as the clock that stamps them (a +/// scheduler tick on Linux, two seconds on FAT), so a rewrite that lands in +/// the same tick as the read we cached can leave every field of the stamp +/// unchanged. A cached parse is only trusted once the file's mtime is older +/// than the read by more than that, the same rule git uses for racily clean +/// index entries. +const STORE_MTIME_SETTLE: Duration = Duration::from_secs(3); + +impl StoreFileStamp { + fn of(metadata: &std::fs::Metadata) -> Self { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + + Self { + len: metadata.len(), + modified: metadata.modified().ok(), + #[cfg(unix)] + inode: (metadata.dev(), metadata.ino()), + #[cfg(unix)] + changed: (metadata.ctime(), metadata.ctime_nsec()), + } + } + + fn settled_before(&self, read_at: SystemTime) -> bool { + self.modified + .and_then(|modified| modified.checked_add(STORE_MTIME_SETTLE)) + .is_some_and(|settled| settled < read_at) + } +} + +struct CachedStore { + stamp: StoreFileStamp, + read_at: SystemTime, + store: Arc>, +} + +static STORE_CACHE: LazyLock>> = + LazyLock::new(Default::default); + +fn store_cache() -> MutexGuard<'static, HashMap> { + STORE_CACHE.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// [`read_store`] for readers, which run inside render: the export page asks +/// for the auth section on every progress tick. The file is shared with the +/// Tauri app, which can rewrite it at any time, so every call still stats it +/// and re-reads when anything about the file changed; only the read and parse +/// are skipped. Writers keep reading the disk directly. +fn cached_store(path: &Path) -> Option>> { + let read_at = SystemTime::now(); + let stamp = match std::fs::metadata(path) { + Ok(metadata) => StoreFileStamp::of(&metadata), + Err(_) => { + store_cache().remove(path); + return read_store(path).map(Arc::new); + } + }; + + if let Some(cached) = store_cache().get(path) + && cached.stamp == stamp + && cached.stamp.settled_before(cached.read_at) + { + return Some(cached.store.clone()); + } + + let Some(store) = read_store(path).map(Arc::new) else { + store_cache().remove(path); + return None; + }; + store_cache().insert( + path.to_path_buf(), + CachedStore { + stamp, + read_at, + store: store.clone(), + }, + ); + Some(store) +} + /// One section of the store (`general_settings`, `recording_start_safety`, /// ...) as raw JSON. Empty when absent or the wrong shape. pub fn store_section(section: &str) -> Map { - read_store(&tauri_store_path()) - .and_then(|mut store| store.remove(section)) - .and_then(|value| match value { - Value::Object(map) => Some(map), + cached_store(&tauri_store_path()) + .and_then(|store| match store.get(section) { + Some(Value::Object(map)) => Some(map.clone()), _ => None, }) .unwrap_or_default() } pub fn notification_sounds_enabled() -> bool { - read_store(&tauri_store_path()) - .and_then(|mut store| store.remove(GENERAL_SETTINGS)) - .and_then(|settings| { - settings - .as_object() + cached_store(&tauri_store_path()) + .and_then(|store| { + store + .get(GENERAL_SETTINGS) + .and_then(Value::as_object) .map(|settings| bool_at(settings, "enableNotifications", true)) }) .unwrap_or(false) @@ -486,7 +587,12 @@ fn update_store(mutate: impl FnOnce(&mut Map) -> bool) -> bool { let Some(mut store) = read_store(&path) else { return false; }; - mutate(&mut store) && write_store(&path, &Value::Object(store)) + if !mutate(&mut store) { + return false; + } + let written = write_store(&path, &Value::Object(store)); + store_cache().remove(&path); + written } /// Pretty JSON, two-space indent, no trailing newline -- byte-shape identical @@ -1206,7 +1312,7 @@ impl Default for GeneralSettings { impl GeneralSettings { pub fn load() -> Self { - let store = read_store(&tauri_store_path()).unwrap_or_default(); + let store = cached_store(&tauri_store_path()).unwrap_or_default(); let empty = Map::new(); let section = |name: &str| store.get(name).and_then(Value::as_object).unwrap_or(&empty); Self::from_sections( @@ -1843,7 +1949,7 @@ pub enum ExportDestination { /// overwrite-on-next-save semantics as the Tauri app. pub fn automations() -> AutomationsStore { let Some(value) = - read_store(&tauri_store_path()).and_then(|mut store| store.remove(AUTOMATIONS)) + cached_store(&tauri_store_path()).and_then(|store| store.get(AUTOMATIONS).cloned()) else { return AutomationsStore::default(); }; @@ -1964,6 +2070,236 @@ mod tests { } } + fn backdate(path: &Path, modified: SystemTime) { + std::fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(modified) + .unwrap(); + } + + fn seconds_ago(seconds: u64) -> SystemTime { + SystemTime::now() + .checked_sub(Duration::from_secs(seconds)) + .unwrap() + } + + fn store_file_reads() -> usize { + TEST_STORE_FILE_READS.with(std::cell::Cell::get) + } + + #[test] + fn render_reads_parse_the_store_once_until_the_file_changes() { + let store = TempStore::new( + "cache-render-reads", + Some( + r#"{"auth":{"secret":{"token":"aaaa"},"organizations":[{"id":"1","name":"One"},{"id":"2","name":"Two"}]}}"#, + ), + ); + backdate(&store.path, seconds_ago(60)); + let before = store_file_reads(); + for _ in 0..100 { + assert!(auth_snapshot().signed_in()); + assert_eq!(auth_snapshot().organizations.len(), 2); + assert_eq!(auth_snapshot().organizations.len(), 2); + } + assert_eq!(store_file_reads() - before, 1); + + std::fs::write( + &store.path, + r#"{"auth":{"secret":{"token":"bbbb"},"organizations":[{"id":"1","name":"One"},{"id":"2","name":"Two"}]}}"#, + ) + .unwrap(); + backdate(&store.path, seconds_ago(30)); + assert_eq!(auth_snapshot().token.as_deref(), Some("bbbb")); + assert_eq!(auth_snapshot().token.as_deref(), Some("bbbb")); + assert_eq!(store_file_reads() - before, 2); + } + + #[test] + fn a_same_length_rewrite_within_the_cached_mtime_tick_is_still_read() { + let store = TempStore::new( + "cache-racy-rewrite", + Some(r#"{"general_settings":{"hasCompletedStartup":true}}"#), + ); + assert!(has_completed_startup()); + let modified = std::fs::metadata(&store.path).unwrap().modified().unwrap(); + std::fs::write( + &store.path, + r#"{"general_settings":{"hasCompletedStartup":null}}"#, + ) + .unwrap(); + backdate(&store.path, modified); + assert!(!has_completed_startup()); + } + + #[cfg(unix)] + #[test] + fn an_in_place_rewrite_that_restores_the_old_mtime_is_still_read() { + let store = TempStore::new( + "cache-restored-mtime", + Some(r#"{"general_settings":{"hasCompletedStartup":true}}"#), + ); + let modified = seconds_ago(60); + backdate(&store.path, modified); + assert!(has_completed_startup()); + std::thread::sleep(Duration::from_millis(50)); + std::fs::write( + &store.path, + r#"{"general_settings":{"hasCompletedStartup":null}}"#, + ) + .unwrap(); + backdate(&store.path, modified); + assert!(!has_completed_startup()); + } + + #[test] + fn a_store_that_stops_parsing_or_disappears_is_not_served_from_the_cache() { + let store = TempStore::new( + "cache-broken", + Some(r#"{"general_settings":{"hasCompletedStartup":true}}"#), + ); + backdate(&store.path, seconds_ago(60)); + assert!(has_completed_startup()); + std::fs::write(&store.path, "{ not json").unwrap(); + backdate(&store.path, seconds_ago(30)); + assert!(!has_completed_startup()); + assert!(!set_has_completed_onboarding(true)); + std::fs::remove_file(&store.path).unwrap(); + assert!(!has_completed_startup()); + assert!(store_section(GENERAL_SETTINGS).is_empty()); + } + + #[test] + fn every_store_writer_is_visible_to_the_next_cached_read() { + let store = TempStore::new("cache-writers", Some("{}")); + let settle = || { + backdate(&store.path, seconds_ago(60)); + store_section(GENERAL_SETTINGS); + }; + + settle(); + assert!(set_store_setting( + GENERAL_SETTINGS, + "hasCompletedStartup", + Value::Bool(true) + )); + assert!(has_completed_startup()); + assert_eq!(GeneralSettings::load(), load_general_settings_separately()); + + settle(); + assert!(set_has_completed_onboarding(true)); + assert!(has_completed_onboarding()); + + settle(); + assert!(set_store_value( + "presets", + serde_json::json!({"presets": [{"name": "Mine", "config": {}}], "default": 0}) + )); + assert_eq!(preset_names(), vec!["Mine".to_string()]); + + settle(); + let mut presets = crate::presets::PresetsStore::load(); + presets.create("Second".into(), serde_json::json!({}), true); + assert!(presets.save()); + assert_eq!(crate::presets::PresetsStore::load(), presets); + + settle(); + assert!(update_store_section(RECORDING_SETTINGS, |section| { + section.insert("mode".into(), Value::String("studio".into())); + true + })); + assert_eq!(recording_mode_slug().as_deref(), Some("studio")); + + settle(); + assert!(set_auth(Some( + serde_json::json!({"secret": {"token": "abc"}, "plan": {"upgraded": true}}) + ))); + assert!(auth_snapshot().signed_in()); + assert!(auth_snapshot().is_upgraded()); + + settle(); + assert!(set_auth(None)); + assert!(!auth_snapshot().signed_in()); + + settle(); + let mut hotkeys = Map::new(); + hotkeys.insert("startStudioRecording".into(), serde_json::json!({})); + assert!(set_hotkeys_raw(&hotkeys)); + assert_eq!(hotkeys_raw(), hotkeys); + + settle(); + let rules = AutomationsStore { + version: 7, + ..AutomationsStore::default() + }; + assert!(set_automations(&rules)); + assert_eq!(automations().version, 7); + + settle(); + assert!(set_store_setting( + GENERAL_SETTINGS, + "enableNotifications", + Value::Bool(false) + )); + assert!(!notification_sounds_enabled()); + } + + #[test] + #[ignore] + fn benchmark_cached_store_section_reads() { + use std::{hint::black_box, time::Instant}; + + let config = crate::presets::preset_config(&cap_project::ProjectConfiguration::default()); + let contents = serde_json::to_string_pretty(&serde_json::json!({ + "general_settings": {"theme": "dark", "maxFps": 60, "hasCompletedStartup": true}, + "recording_start_safety": {"confirmBeforeRecordingWithoutMicrophone": false}, + "audio_enhancement": {"enabledByDefault": true}, + "auth": { + "secret": {"api_key": "k".repeat(64)}, + "plan": {"upgraded": true, "manual": false}, + "organizations": [{"id": "1", "name": "One"}, {"id": "2", "name": "Two"}] + }, + "presets": {"presets": [{"name": "Preset", "config": config}], "default": null}, + "teleprompter": {"script": "A recording script. ".repeat(60)} + })) + .unwrap(); + let store = TempStore::new("cache-benchmark", Some(&contents)); + backdate(&store.path, seconds_ago(60)); + let uncached = || { + read_store(&tauri_store_path()) + .and_then(|mut store| store.remove("auth")) + .and_then(|value| match value { + Value::Object(map) => Some(map), + _ => None, + }) + .unwrap_or_default() + }; + assert_eq!(uncached(), store_section("auth")); + let measure = |read: &dyn Fn() -> Map| { + let start = Instant::now(); + for _ in 0..1000 { + black_box(read()); + } + start.elapsed().as_secs_f64() * 1_000_000.0 / 1000.0 + }; + let mut before = Vec::new(); + let mut after = Vec::new(); + for _ in 0..9 { + before.push(measure(&uncached)); + after.push(measure(&|| store_section("auth"))); + } + before.sort_by(f64::total_cmp); + after.sort_by(f64::total_cmp); + eprintln!( + "store_bytes={} uncached_median_us={:.3} cached_median_us={:.3} uncached_runs_us={before:?} cached_runs_us={after:?}", + contents.len(), + before[4], + after[4] + ); + } + #[test] #[ignore] fn benchmark_general_settings_load() { diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 7c0bbd63fd1..8314519dd75 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -19,14 +19,15 @@ //! Metrics are transcribed from the TSX with the Tailwind class quoted next to //! them, the same convention `main_window.rs` uses. +use std::cell::Cell; use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use cap_recording::sources::screen_capture::ScreenCaptureTarget; use gpui::{ - Animation, AnimationExt as _, App, AppContext as _, Context, Entity, FontWeight, Global, Hsla, - ImageFormat, InteractiveElement as _, IntoElement, MouseButton, MouseMoveEvent, MouseUpEvent, + App, AppContext as _, Context, Entity, FontWeight, Global, Hsla, ImageFormat, + InteractiveElement as _, IntoElement, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, SharedString, StatefulInteractiveElement as _, Styled, Window, div, img, linear_color_stop, linear_gradient, prelude::FluentBuilder as _, px, rgb, svg, @@ -80,6 +81,13 @@ fn overlay_display_is_active( cursor_display.map_or(only_overlay || window_hovered, |cursor| cursor == display) } +/// Whether a new mic level should repaint an overlay: only when it drew the +/// meter and the fill moves by a device pixel. The cluster's width bounds the +/// meter's. +fn overlay_meter_moved(drawn: Option, next: f32, scale: f32) -> bool { + drawn.is_some_and(|drawn| crate::ui::moves_a_device_pixel(drawn, next, CLUSTER_WIDTH * scale)) +} + fn overlay_viewport_size(viewport: (f32, f32), previous: (f32, f32)) -> (f32, f32) { if viewport.0.is_finite() && viewport.1.is_finite() && viewport.0 > 0. && viewport.1 > 0. { viewport @@ -141,6 +149,16 @@ const COUNTDOWN_OPTIONS: &[(u32, &str)] = &[ const SELECTION_HINT_CYCLE: Duration = Duration::from_millis(2800); +/// The hint redraws on a 30 fps timer rather than every display refresh: it +/// is a small illustration, but each frame repaints a full-screen overlay. +const SELECTION_HINT_FRAME: Duration = Duration::from_nanos(1_000_000_000 / 30); + +/// Where the hint is in its cycle, the way gpui's repeating `Animation` +/// computes it: linear time since it first drew, wrapped to `0..1`. +fn selection_hint_progress(elapsed: Duration) -> f32 { + (elapsed.as_secs_f32() / SELECTION_HINT_CYCLE.as_secs_f32()) % 1. +} + fn material_ease(progress: f32) -> f32 { let progress = progress.clamp(0., 1.); if progress <= 0. { @@ -368,28 +386,32 @@ impl TargetSelect { }) .await; - let Ok((icon_wanted, active, changed)) = this.update(cx, |this: &mut Self, cx| { - let (display, window) = probe; - let microphone_fill = - crate::ui::MicrophoneLevel::fill(crate::feeds::Feeds::global(cx).read(cx)); - let changed = this.cursor_display != display - || this.hovered_window != window - || this.microphone_fill != microphone_fill; - this.microphone_fill = microphone_fill; - this.cursor_display = display; - this.hovered_window = window; - if changed { - cx.notify(); - } + let Ok((icon_wanted, active, changed, microphone_changed)) = + this.update(cx, |this: &mut Self, cx| { + let (display, window) = probe; + let microphone_fill = crate::ui::MicrophoneLevel::capture_fill(cx); + // The mic level stays out of `changed`: only the overlays + // drawing the meter repaint for it, below. + let changed = + this.cursor_display != display || this.hovered_window != window; + let microphone_changed = this.microphone_fill != microphone_fill; + this.microphone_fill = microphone_fill; + this.cursor_display = display; + this.hovered_window = window; + if changed { + cx.notify(); + } - ( - this.active_window() - .map(|window| window.id.clone()) - .filter(|id| !this.icons.contains_key(&id.to_string())), - this.active_window().cloned(), - changed, - ) - }) else { + ( + this.active_window() + .map(|window| window.id.clone()) + .filter(|id| !this.icons.contains_key(&id.to_string())), + this.active_window().cloned(), + changed, + microphone_changed, + ) + }) + else { return; }; let fetched_icon = icon_wanted.is_some(); @@ -409,6 +431,8 @@ impl TargetSelect { // that changed nothing they draw has nothing to repaint. if changed || fetched_icon { cx.update(app_windows::refresh_target_overlays); + } else if microphone_changed { + cx.update(app_windows::refresh_microphone_overlays); } cx.background_executor().timer(POLL_INTERVAL).await; } @@ -613,6 +637,11 @@ pub struct OverlayWindow { /// Cached off the store so the start pill does not re-read the settings /// file every frame; refreshed whenever the shared sign-in reports. signed_in: bool, + /// The mic fill the last render drew, `None` when it drew no meter. + drawn_microphone_fill: Cell>, + /// When the area hint first drew since it was last hidden. + selection_hint_started: Option, + selection_hint_redraw: Option>, _main_subscription: Option, } @@ -741,6 +770,9 @@ impl OverlayWindow { empty_camera_state: crate::store::load().camera_window.unwrap_or_default(), device_menu: None, signed_in: crate::store::auth_snapshot().signed_in(), + drawn_microphone_fill: Cell::new(None), + selection_hint_started: None, + selection_hint_redraw: None, _main_subscription: main_subscription, } } @@ -917,6 +949,14 @@ impl OverlayWindow { }); } + pub fn microphone_level_moved(&self, window: &Window, cx: &App) -> bool { + overlay_meter_moved( + self.drawn_microphone_fill.get(), + self.select.read(cx).microphone_fill, + window.scale_factor(), + ) + } + /// True when the cursor is on this overlay's display -- `data-over` in the /// display variant, `isActiveDisplay` in the area variant. fn is_active_display(&self, window: &Window, cx: &App) -> bool { @@ -946,6 +986,7 @@ impl Render for OverlayWindow { self.sync_area_camera(cx); } let mode = self.select.read(cx).mode; + self.drawn_microphone_fill.set(None); let root = div() .id("overlay-root") @@ -1212,14 +1253,13 @@ impl OverlayWindow { .overflow_hidden() .when(disabled, |row| row.opacity(0.7)) .when(!camera && enabled && !disabled, |row| { + let fill = self.select.read(cx).microphone_fill; + self.drawn_microphone_fill.set(Some(fill)); row.child( - crate::ui::MicrophoneLevel::snapshot( - self.select.read(cx).microphone_fill, - theme.blue_9.into(), - ) - .absolute() - .top_0() - .left_0(), + crate::ui::MicrophoneLevel::snapshot(fill, theme.blue_9.into()) + .absolute() + .top_0() + .left_0(), ) }) .child( @@ -1611,10 +1651,13 @@ impl OverlayWindow { /// region with eight handles, a size readout on top and the start cluster /// placed against the crop. fn render_area_variant( - &self, - window: &Window, + &mut self, + window: &mut Window, cx: &mut Context, ) -> gpui::Stateful { + // Taken so the hint restarts whenever a render leaves it out, as a + // gpui animation's element state would. + let hint_started = self.selection_hint_started.take(); let interacting = self.drag.is_some(); // `shouldShowOverlay = isInteracting() || isActiveDisplay()`. let visible = self.recording_area || interacting || self.is_active_display(window, cx); @@ -1704,7 +1747,7 @@ impl OverlayWindow { return root; } if crop.is_none() && !interacting { - root = root.child(self.render_selection_hint()); + root = root.child(self.render_selection_hint(hint_started, window, cx)); } root = root.child(self.render_area_toolbar(crop, valid)); if crop.is_some() { @@ -1838,13 +1881,36 @@ impl OverlayWindow { .collect() } - fn render_selection_hint(&self) -> impl IntoElement { + fn render_selection_hint( + &mut self, + started: Option, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Div { let cursor_path = if cfg!(target_os = "windows") { "onboarding/cursor-windows.svg" } else { "onboarding/cursor-macos.svg" }; - let animation = || Animation::new(SELECTION_HINT_CYCLE).repeat(); + let started = started.unwrap_or_else(Instant::now); + self.selection_hint_started = Some(started); + // Reduced motion holds the first frame, as gpui's animations do. + let progress = if cx.reduce_motion() { + 0. + } else { + if self.selection_hint_redraw.is_none() { + self.selection_hint_redraw = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(SELECTION_HINT_FRAME).await; + this.update_in(cx, |this, window, cx| { + this.selection_hint_redraw = None; + repaint(window, cx); + }) + .ok(); + })); + } + selection_hint_progress(started.elapsed()) + }; + let drag = selection_drag_progress(progress); let selection = div() .absolute() .left(px(41.)) @@ -1856,55 +1922,37 @@ impl OverlayWindow { linear_color_stop(gpui::hsla(0.55, 1., 0.72, 0.4), 0.), linear_color_stop(gpui::hsla(0.62, 1., 0.59, 0.2), 1.), )) - .with_animation("area-selection-hint-box", animation(), |this, progress| { - let progress = selection_drag_progress(progress); - this.w(px(10. + 58. * progress)).h(px(8. + 44. * progress)) - }); + .w(px(10. + 58. * drag)) + .h(px(8. + 44. * drag)); let cursor = img(cursor_path) .absolute() - .left(px(41.)) - .top(px(26.)) + .left(px(41. + 68. * drag)) + .top(px(26. + 52. * drag)) .w(px(18.)) - .h(px(26.)) - .with_animation( - "area-selection-hint-cursor", - animation(), - |this, progress| { - let progress = selection_drag_progress(progress); - this.left(px(41. + 68. * progress)) - .top(px(26. + 52. * progress)) - }, - ); + .h(px(26.)); + let (scale, opacity) = if (0.1..0.12).contains(&progress) { + ( + (progress - 0.1) / 0.02 * 0.2, + (progress - 0.1) / 0.02 * 0.95, + ) + } else if (0.12..0.18).contains(&progress) { + ( + 0.2 + (progress - 0.12) / 0.06 * 0.65, + 0.95 * (1. - (progress - 0.12) / 0.06), + ) + } else { + (0., 0.) + }; + let size = 20. * scale; let click = div() .absolute() .rounded_full() .border_2() .border_color(gpui::hsla(0., 0., 1., 0.85)) - .with_animation( - "area-selection-hint-click", - animation(), - |this, progress| { - let (scale, opacity) = if (0.1..0.12).contains(&progress) { - ( - (progress - 0.1) / 0.02 * 0.2, - (progress - 0.1) / 0.02 * 0.95, - ) - } else if (0.12..0.18).contains(&progress) { - ( - 0.2 + (progress - 0.12) / 0.06 * 0.65, - 0.95 * (1. - (progress - 0.12) / 0.06), - ) - } else { - (0., 0.) - }; - let drag = selection_drag_progress(progress); - let size = 20. * scale; - this.left(px(41. + 68. * drag + 9. - size / 2.)) - .top(px(26. + 52. * drag + 14.3 - size / 2.)) - .size(px(size)) - .opacity(opacity) - }, - ); + .left(px(41. + 68. * drag + 9. - size / 2.)) + .top(px(26. + 52. * drag + 14.3 - size / 2.)) + .size(px(size)) + .opacity(opacity); div() .absolute() @@ -2784,6 +2832,42 @@ mod tests { assert!(guides.is_empty()); } + #[test] + fn only_an_overlay_drawing_the_meter_repaints_for_the_level() { + assert!(!overlay_meter_moved(None, 0.8, 2.)); + assert!(overlay_meter_moved(Some(0.), 0.8, 2.)); + assert!(overlay_meter_moved( + Some(0.5), + 0.5 + 1. / (CLUSTER_WIDTH * 2.), + 2. + )); + assert!(!overlay_meter_moved( + Some(0.5), + 0.5 + 0.4 / (CLUSTER_WIDTH * 2.), + 2. + )); + assert!(!overlay_meter_moved(Some(0.), 0., 1.)); + } + + #[test] + fn selection_hint_progress_repeats_like_a_gpui_animation() { + let at = |ms| selection_hint_progress(Duration::from_millis(ms)); + assert_eq!(at(0), 0.); + assert!((at(1400) - 0.5).abs() < 1e-4); + assert!((at(2800 + 700) - 0.25).abs() < 1e-4); + assert!((at(5 * 2800 + 1400) - 0.5).abs() < 1e-3); + assert!(at(2799) < 1.); + } + + #[test] + fn selection_hint_paces_to_thirty_frames_a_second() { + let frames = SELECTION_HINT_CYCLE.as_secs_f32() / SELECTION_HINT_FRAME.as_secs_f32(); + assert!((83.9..84.1).contains(&frames), "{frames}"); + // The click ring (`0.1..0.18` of the cycle) still gets several frames. + let ring = 0.08 * SELECTION_HINT_CYCLE.as_secs_f32() / SELECTION_HINT_FRAME.as_secs_f32(); + assert!(ring >= 6., "{ring}"); + } + #[test] fn selection_hint_curve_reaches_both_drag_extents() { assert_eq!(selection_drag_progress(0.), 0.); diff --git a/apps/desktop-gpui/src/target_thumbnails.rs b/apps/desktop-gpui/src/target_thumbnails.rs index c18c57d33f6..b3019eab056 100644 --- a/apps/desktop-gpui/src/target_thumbnails.rs +++ b/apps/desktop-gpui/src/target_thumbnails.rs @@ -30,6 +30,9 @@ //! //! Deliberate deviations: //! +//! * Windows and Linux capture at full resolution, so a source more than +//! eight times the thumbnail is box-averaged before the Lanczos3 pass +//! (`cap_gpui_kernels::thumbnail::fit_centered_capture`). //! * No base64 and no PNG round-trip. The Tauri command has to hand bytes to a //! webview; here the RGBA buffer goes straight to `RenderImage`, which skips //! a PNG encode and a base64 encode per target per refresh. @@ -160,9 +163,27 @@ pub enum WindowEvent { image: Option>, /// Raw PNG bytes from `Window::app_icon()` (`thumbnails/mod.rs:113-122`). app_icon: Option>, + entry: CapturedWindow, }, } +/// What a window thumbnail was captured from: the row's +/// [`window_entry_signature`] and the capture time. +#[derive(Clone, Debug)] +pub struct CapturedWindow { + signature: String, + at: std::time::Instant, +} + +impl CapturedWindow { + pub fn new(window: &WindowOption, at: std::time::Instant) -> Self { + Self { + signature: window_entry_signature(window), + at, + } + } +} + /// Per-view thumbnail state. /// /// Deliberately *not* an app-global. gpui's sprite atlas is per-window @@ -180,6 +201,9 @@ pub enum WindowEvent { pub struct ThumbnailCache { displays: HashMap>, windows: HashMap, + /// What each window thumbnail in `windows` was captured from, so a sweep + /// only re-captures the windows that changed or have gone stale. + window_entries: HashMap, /// Signature of the list the cached display thumbnails were captured from. display_signature: Option, window_signature: Option, @@ -221,13 +245,18 @@ impl ThumbnailCache { id: &WindowId, image: Option>, app_icon: Option>, + entry: CapturedWindow, ) -> Option> { - let slot = self.windows.entry(id.to_string()).or_default(); + let key = id.to_string(); + let slot = self.windows.entry(key.clone()).or_default(); // A refresh that failed to capture must not blank a thumbnail that is // already on screen: only a real capture replaces one. Same for the // icon, which the Tauri card keeps for the life of the row. let replaced = match image { - Some(image) => slot.image.replace(image), + Some(image) => { + self.window_entries.insert(key, entry); + slot.image.replace(image) + } None => None, }; if app_icon.is_some() { @@ -268,9 +297,14 @@ impl ThumbnailCache { false } }); + self.window_entries.retain(|id, _| live.contains(id)); dropped } + pub fn captured_windows(&self) -> HashMap { + self.window_entries.clone() + } + pub fn display_inflight(&self) -> bool { self.display_inflight } @@ -443,25 +477,44 @@ pub fn render_thumbnail_slot( /// only ever compared against another string this function produced. pub fn window_signature(list: &[WindowOption]) -> String { list.iter() - .map(|window| { - let (x, y) = window.position.unwrap_or((0., 0.)); - let (width, height) = window.size.unwrap_or((0, 0)); - format!( - "{}:{}:{}:{}:{}:{}:{}:{}", - window.id, - window.app, - window.label, - x, - y, - width, - height, - window.refresh_rate.unwrap_or(0.), - ) - }) + .map(window_entry_signature) .collect::>() .join("|") } +pub fn window_entry_signature(window: &WindowOption) -> String { + let (x, y) = window.position.unwrap_or((0., 0.)); + let (width, height) = window.size.unwrap_or((0, 0)); + format!( + "{}:{}:{}:{}:{}:{}:{}:{}", + window.id, + window.app, + window.label, + x, + y, + width, + height, + window.refresh_rate.unwrap_or(0.), + ) +} + +/// Whether a sweep has to capture `window` again: it is new, its last capture +/// missed, it moved, resized, or changed since, or its thumbnail is at least +/// [`THUMBNAIL_STALE_TIME`] old. A sweep only runs when the list signature +/// changed, so the age rule keeps the refresh that any list change (a focus +/// switch reorders it) used to give every window, without re-capturing a +/// window captured moments ago on every step of a drag. +pub fn window_needs_capture( + captured: &HashMap, + window: &WindowOption, + now: std::time::Instant, +) -> bool { + captured.get(&window.id.to_string()).is_none_or(|entry| { + entry.signature != window_entry_signature(window) + || now.saturating_duration_since(entry.at) >= THUMBNAIL_STALE_TIME + }) +} + /// `createDisplaySignature` (`new-main/index.tsx:400-408`): id, name, refresh /// rate. A display has no position to move to, so those three are the whole /// identity as far as its thumbnail is concerned. @@ -501,6 +554,7 @@ pub fn fitted_capture_size(source: Option<(f64, f64)>) -> (usize, usize) { } /// `normalize_thumbnail_dimensions` (`thumbnails/mod.rs:44-87`), verbatim. +#[cfg(any(target_os = "macos", test))] pub fn normalize_thumbnail_dimensions(image: &RgbaImage) -> RgbaImage { cap_gpui_kernels::thumbnail::fit_centered(image, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) } @@ -546,9 +600,13 @@ pub async fn capture_displays(events: flume::Sender) { /// The window twin. `collect_windows_with_thumbnails` (`thumbnails/mod.rs:107-137`) /// also reads `Window::app_icon()` per row, which is what the card's centred -/// icon-on-scrim overlay paints. -pub async fn capture_windows(events: flume::Sender) { - run_capture(move || capture_windows_inner(events)).await +/// icon-on-scrim overlay paints. Windows whose thumbnail in `current` is still +/// up to date are listed but not captured again. +pub async fn capture_windows( + events: flume::Sender, + current: HashMap, +) { + run_capture(move || capture_windows_inner(events, current)).await } async fn capture_displays_inner(events: flume::Sender) { @@ -607,7 +665,10 @@ async fn capture_displays_inner(events: flume::Sender) { tracing::debug!(captured, total, "display thumbnail sweep finished"); } -async fn capture_windows_inner(events: flume::Sender) { +async fn capture_windows_inner( + events: flume::Sender, + current: HashMap, +) { let targets = crate::devices::list_window_targets(); let total = targets.len(); let mut captured = 0usize; @@ -619,6 +680,15 @@ async fn capture_windows_inner(events: flume::Sender) { { return; } + let now = std::time::Instant::now(); + let targets: Vec<_> = targets + .into_iter() + .filter(|(option, _)| window_needs_capture(¤t, option, now)) + .collect(); + if targets.is_empty() { + tracing::debug!(total, "window thumbnails all current"); + return; + } let Some(Some(content)) = within_capture_timeout( "shareable content", @@ -656,6 +726,7 @@ async fn capture_windows_inner(events: flume::Sender) { } if events .send(WindowEvent::Captured { + entry: CapturedWindow::new(&option, std::time::Instant::now()), id: option.id, image, app_icon, @@ -991,8 +1062,6 @@ mod platform { use cap_recording::sources::screen_capture::ScreenCaptureTarget; use image::RgbaImage; - use super::normalize_thumbnail_dimensions; - #[derive(Clone)] pub struct ShareableContent; @@ -1027,7 +1096,7 @@ mod platform { } let image = match capture_screenshot(target).await { - Ok(image) => image.into_rgba8(), + Ok(image) => image, Err(error) => { tracing::warn!(%error, "target thumbnail capture failed"); return None; @@ -1038,7 +1107,11 @@ mod platform { return None; } - Some(normalize_thumbnail_dimensions(&image)) + Some(cap_gpui_kernels::thumbnail::fit_centered_capture( + image, + super::THUMBNAIL_WIDTH, + super::THUMBNAIL_HEIGHT, + )) } } @@ -1149,6 +1222,7 @@ mod tests { use super::*; use scap_targets::{DisplayId, WindowId}; use std::str::FromStr; + use std::time::Instant; fn solid(width: u32, height: u32, colour: [u8; 4]) -> RgbaImage { RgbaImage::from_pixel(width, height, image::Rgba(colour)) @@ -1436,13 +1510,27 @@ mod tests { assert!( cache - .insert_window(&id, Some(image.clone()), None) + .insert_window( + &id, + Some(image.clone()), + None, + CapturedWindow::new(&window("7", "Cap", "Main"), Instant::now()) + ) .is_none() ); assert!(cache.window(&id).image.is_some()); // A refresh with no capture and no icon: the slot survives untouched. - assert!(cache.insert_window(&id, None, None).is_none()); + assert!( + cache + .insert_window( + &id, + None, + None, + CapturedWindow::new(&window("7", "Cap", "Main"), Instant::now()) + ) + .is_none() + ); assert!(cache.window(&id).image.is_some()); // An icon arriving on its own does not disturb the thumbnail either. @@ -1450,7 +1538,16 @@ mod tests { gpui::ImageFormat::Png, vec![0u8; 4], )); - assert!(cache.insert_window(&id, None, Some(icon)).is_none()); + assert!( + cache + .insert_window( + &id, + None, + Some(icon), + CapturedWindow::new(&window("7", "Cap", "Main"), Instant::now()) + ) + .is_none() + ); let thumb = cache.window(&id); assert!(thumb.image.is_some()); assert!(thumb.app_icon.is_some()); @@ -1503,6 +1600,117 @@ mod tests { assert!(cache.windows_stale(&moved)); } + fn insert_captured(cache: &mut ThumbnailCache, window: &WindowOption, at: Instant) { + let image = crate::library::rgba_to_render_image(solid(2, 2, [1, 2, 3, 255])); + let _ = cache.insert_window( + &window.id, + Some(image), + None, + CapturedWindow::new(window, at), + ); + } + + #[test] + fn a_sweep_recaptures_only_the_windows_that_changed() { + let now = Instant::now(); + let mut cache = ThumbnailCache::default(); + let list = [ + window("1", "Cap", "Main"), + window("2", "Finder", "Downloads"), + window("3", "Safari", "Docs"), + ]; + assert!(list.iter().all(|window| window_needs_capture( + &cache.captured_windows(), + window, + now + ))); + + for window in &list[..2] { + insert_captured(&mut cache, window, now); + } + let _ = cache.insert_window(&list[2].id, None, None, CapturedWindow::new(&list[2], now)); + let current = cache.captured_windows(); + assert!(!window_needs_capture(¤t, &list[0], now)); + assert!(!window_needs_capture(¤t, &list[1], now)); + assert!(window_needs_capture(¤t, &list[2], now)); + + let mut moved = list[1].clone(); + moved.position = Some((40., 12.)); + let mut resized = list[0].clone(); + resized.size = Some((1280, 720)); + let mut renamed = list[0].clone(); + renamed.label = "Settings".into(); + for changed in [&moved, &resized, &renamed] { + assert!(window_needs_capture(¤t, changed, now)); + } + + cache.set_window_signature(window_signature(&list[..2])); + let reordered = [list[1].clone(), list[0].clone()]; + assert!(cache.windows_stale(&reordered)); + assert!( + reordered + .iter() + .all(|window| !window_needs_capture(¤t, window, now)) + ); + + let dropped = cache.retain_windows(&list[1..]); + assert_eq!(dropped.len(), 1); + assert!(window_needs_capture( + &cache.captured_windows(), + &list[0], + now + )); + assert!(!window_needs_capture( + &cache.captured_windows(), + &list[1], + now + )); + } + + #[test] + fn a_sweep_recaptures_unchanged_windows_once_their_thumbnail_is_stale() { + let captured_at = Instant::now(); + let mut cache = ThumbnailCache::default(); + let list = [ + window("1", "Cap", "Main"), + window("2", "Finder", "Downloads"), + ]; + insert_captured(&mut cache, &list[0], captured_at); + insert_captured(&mut cache, &list[1], captured_at + Duration::from_secs(4)); + let current = cache.captured_windows(); + + let fresh = captured_at + THUMBNAIL_STALE_TIME.saturating_sub(Duration::from_millis(1)); + assert!(!window_needs_capture(¤t, &list[0], fresh)); + assert!(!window_needs_capture(¤t, &list[1], fresh)); + + let stale = captured_at + THUMBNAIL_STALE_TIME; + assert!(window_needs_capture(¤t, &list[0], stale)); + assert!(!window_needs_capture(¤t, &list[1], stale)); + + let later = captured_at + Duration::from_secs(4) + THUMBNAIL_STALE_TIME; + assert!(window_needs_capture(¤t, &list[1], later)); + + insert_captured(&mut cache, &list[0], stale); + assert!(!window_needs_capture( + &cache.captured_windows(), + &list[0], + stale + )); + } + + #[test] + fn window_signature_is_its_entries_joined() { + let list = [window("1", "Cap", "Main"), window("2", "Cap", "Editor")]; + assert_eq!( + window_signature(&list), + format!( + "{}|{}", + window_entry_signature(&list[0]), + window_entry_signature(&list[1]) + ) + ); + } + #[test] fn prewarm_is_a_one_shot_latch() { let mut cache = ThumbnailCache::default(); diff --git a/apps/desktop-gpui/src/transcription.rs b/apps/desktop-gpui/src/transcription.rs index bc7ad223b00..4b66e2235ef 100644 --- a/apps/desktop-gpui/src/transcription.rs +++ b/apps/desktop-gpui/src/transcription.rs @@ -83,6 +83,32 @@ pub fn is_parakeet_model(model: &str) -> bool { matches!(model, "best" | "best-max") } +/// The 25 languages parakeet-tdt-0.6b-v3 transcribes. It takes no language +/// hint and its vocabulary has no other scripts, so any other language comes +/// out as phonetic Latin text. +const PARAKEET_LANGUAGES: [&str; 25] = [ + "bg", "cs", "da", "de", "el", "en", "es", "et", "fi", "fr", "hr", "hu", "it", "lt", "lv", "mt", + "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sv", "uk", +]; + +const PARAKEET_UNSUPPORTED_LANGUAGE: &str = "Parakeet cannot transcribe language"; + +/// The model that transcribes `language`: a Parakeet model hands a language +/// it cannot write to a downloaded Whisper model, larger first. +fn transcription_model<'a>( + model: &'a str, + language: &str, + downloaded: impl Fn(&str) -> bool, +) -> Result<&'a str, String> { + if !is_parakeet_model(model) || language == "auto" || PARAKEET_LANGUAGES.contains(&language) { + return Ok(model); + } + ["medium", "small"] + .into_iter() + .find(|whisper| downloaded(whisper)) + .ok_or_else(|| format!("{PARAKEET_UNSUPPORTED_LANGUAGE} {language}")) +} + /// Tauri's `appLocalDataDir()` for the `so.cap.desktop` identifier: on macOS /// `~/Library/Application Support`, on Windows `%LOCALAPPDATA%` (deliberately /// *not* the roaming dir [`store::app_data_dir`] uses), on Linux @@ -158,6 +184,12 @@ struct Hub { } impl Hub { + fn downloading(&self, model: &str) -> bool { + self.download.as_ref().is_some_and(|download| { + download.model == model && download.state == DownloadState::Downloading + }) + } + fn work_in_flight(&self) -> bool { self.download .as_ref() @@ -1738,6 +1770,15 @@ pub fn transcribe_blocking( model: &str, language: &str, ) -> Result, String> { + let requested = model; + // A download writes straight to the model's final path, so a model still + // downloading looks present but cannot load yet. + let model = transcription_model(model, language, |model| { + model_files_present(model) && !hub().downloading(model) + })?; + if model != requested { + tracing::info!("{requested} cannot transcribe {language}; using the {model} Whisper model"); + } let model_file = model_path(model); if !project_path.exists() { return Err(format!( @@ -2108,7 +2149,6 @@ pub fn sync_caption_words_with_text( .iter() .enumerate() .map(|(index, token)| { - let source = existing.get(index); let word_start = start + step * index as f64; let word_end = if index == tokens.len() - 1 { end @@ -2117,8 +2157,8 @@ pub fn sync_caption_words_with_text( }; CaptionWord { text: (*token).to_string(), - start: source.map_or(word_start as f32, |word| word.start), - end: source.map_or(word_end as f32, |word| word.end), + start: word_start as f32, + end: word_end as f32, } }) .collect() @@ -2251,6 +2291,9 @@ pub fn caption_generation_error_message(message: &str) -> String { if message.contains("Failed to load Whisper model") { return "Failed to load the caption model. Try downloading it again".to_string(); } + if message.contains(PARAKEET_UNSUPPORTED_LANGUAGE) { + return "The Recommended and High Accuracy models only transcribe English and 24 other European languages. Download the Small or Medium model to caption this language".to_string(); + } if message.contains("Parakeet transcription is not available on Intel macOS") { return "Parakeet models are not available on Intel Macs. Use a Whisper model instead" .to_string(); @@ -2265,6 +2308,38 @@ mod verify_tests; mod tests { use super::*; + #[test] + fn parakeet_keeps_the_languages_it_can_write() { + for language in ["auto", "en", "de", "ru", "uk", "pl"] { + assert_eq!(transcription_model("best", language, |_| true), Ok("best")); + assert_eq!( + transcription_model("best-max", language, |_| true), + Ok("best-max") + ); + } + } + + #[test] + fn other_languages_move_to_a_downloaded_whisper_model() { + for language in ["zh", "ja", "ko", "ar", "hi", "tr", "he", "fa"] { + assert_eq!( + transcription_model("best", language, |_| true), + Ok("medium") + ); + assert_eq!( + transcription_model("best-max", language, |model| model == "small"), + Ok("small") + ); + assert_eq!( + transcription_model("best", language, |model| model != "medium"), + Ok("small") + ); + let error = transcription_model("best", language, |_| false).unwrap_err(); + assert!(caption_generation_error_message(&error).contains("Small or Medium")); + } + assert_eq!(transcription_model("small", "zh", |_| false), Ok("small")); + } + #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] #[test] fn parakeet_chunks_cut_in_the_quiet_gap_and_cover_every_sample() { @@ -2355,6 +2430,100 @@ mod tests { } } + #[test] + fn caption_text_edits_redistribute_timings_when_word_count_changes() { + let existing = caption( + "caption", + 1.0, + 4.0, + &[("hello", 1.0, 1.5), ("world", 1.5, 4.0)], + ); + + for text in ["hello", "hello new world"] { + let words = sync_caption_words_with_text(text, &existing.words, 1.0, 4.0); + assert_eq!(caption_text_from_words(&words), text); + assert_eq!(words.first().unwrap().start, 1.0); + assert_eq!(words.last().unwrap().end, 4.0); + assert!(words.iter().all(|word| word.start < word.end)); + assert!(words.windows(2).all(|pair| pair[0].end <= pair[1].start)); + } + } + + #[test] + fn caption_spelling_edits_preserve_timings_and_empty_text_clears_words() { + let existing = caption( + "caption", + 1.0, + 4.0, + &[("hello", 1.0, 1.5), ("world", 2.0, 4.0)], + ); + let words = sync_caption_words_with_text("Hello world!", &existing.words, 1.0, 4.0); + assert_eq!(caption_text_from_words(&words), "Hello world!"); + for (word, original) in words.iter().zip(&existing.words) { + assert_eq!((word.start, word.end), (original.start, original.end)); + } + assert!(sync_caption_words_with_text(" \n\t", &existing.words, 1.0, 4.0).is_empty()); + } + + #[test] + fn caption_text_edits_survive_source_write_and_reload() { + for text in ["hello new world", "hello"] { + let mut project = ProjectConfiguration { + timeline: Some(timeline(serde_json::json!({ + "segments": [ + { "recordingSegment": 1, "timescale": 2.0, "start": 1.0, "end": 6.0 } + ], + "zoomSegments": [], + "textSegments": [ + { "start": 0.5, "end": 1.5, "layout": "fullscreen" } + ] + }))), + captions: Some(CaptionsData { + segments: vec![caption( + "caption", + 11.0, + 13.0, + &[("hello", 11.0, 11.5), ("world", 11.5, 13.0)], + )], + source_timed: true, + ..Default::default() + }), + ..Default::default() + }; + let durations = [10.0, 10.0]; + cap_project::synchronize_captions(&mut project, &durations); + project.timeline.as_mut().unwrap().caption_segments[0].text = text.into(); + + write_caption_edit_to_source(&mut project, 0, &durations); + cap_project::synchronize_captions(&mut project, &durations); + let dir = tempfile::tempdir().unwrap(); + project.write(dir.path()).unwrap(); + let mut loaded = ProjectConfiguration::load(dir.path()).unwrap(); + cap_project::synchronize_captions(&mut loaded, &durations); + + let source = &loaded.captions.as_ref().unwrap().segments[0]; + assert_eq!(source.text, text); + assert_eq!(source.words.first().unwrap().start, 11.0); + assert_eq!(source.words.last().unwrap().end, 13.0); + assert!( + source + .words + .windows(2) + .all(|pair| pair[0].end <= pair[1].start) + ); + let track = &loaded.timeline.as_ref().unwrap().caption_segments[0]; + assert_eq!(track.text, text); + assert_eq!((track.start, track.end), (0.0, 2.0)); + assert_eq!(track.words.len(), text.split_whitespace().count()); + assert!( + track + .words + .windows(2) + .all(|pair| pair[0].end <= pair[1].start) + ); + } + } + #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] #[test] fn idle_release_drops_cached_value_after_delay() { @@ -2486,6 +2655,22 @@ mod tests { assert_eq!(model_path("medium"), base.join("medium.bin")); } + #[test] + fn only_the_model_being_downloaded_counts_as_downloading() { + let mut state = Hub::default(); + assert!(!state.downloading("medium")); + state.download = Some(ModelDownload { + model: "medium".to_string(), + state: DownloadState::Downloading, + progress: 40.0, + message: "Downloading".to_string(), + }); + assert!(state.downloading("medium")); + assert!(!state.downloading("small")); + state.download.as_mut().unwrap().state = DownloadState::Completed; + assert!(!state.downloading("medium")); + } + #[test] fn downloads_generation_and_deletion_block_application_handoffs() { let mut state = Hub::default(); diff --git a/apps/desktop-gpui/src/tray.rs b/apps/desktop-gpui/src/tray.rs index 39c3acd040b..0ad5bd029d8 100644 --- a/apps/desktop-gpui/src/tray.rs +++ b/apps/desktop-gpui/src/tray.rs @@ -287,17 +287,20 @@ pub fn cover_crop_geometry(orig_w: u32, orig_h: u32, size: u32) -> (u32, u32, u3 /// `load_thumbnail_data`, ending in a PNG rather than raw RGBA -- muda hands /// its icon to AppKit as a PNG too (`PlatformIcon::to_nsimage`), so this is the -/// same conversion one step earlier. A JPEG source starts from the Recents card +/// same conversion one step earlier. An opaque source (a recording's JPEG, a +/// display or area screenshot's RGB PNG) starts from the Recents card /// thumbnail cache instead of the native-resolution original. That cache is -/// itself JPEG and drops alpha, so anything else (a transparent screenshot PNG) -/// still decodes directly. +/// itself JPEG and drops alpha, so a source that can carry alpha (a window +/// screenshot's rounded-corner RGBA PNG) still decodes directly. fn load_thumbnail_png(path: &Path) -> Option> { - use image::{GenericImageView, ImageEncoder, ImageFormat, RgbaImage, imageops::FilterType}; - - let img = if ImageFormat::from_path(path).ok() == Some(ImageFormat::Jpeg) { - image::DynamicImage::ImageRgba8(library::thumbnail_rgba(path)?) - } else { - image::load_from_memory(&std::fs::read(path).ok()?).ok()? + use image::{GenericImageView, ImageEncoder, RgbaImage, imageops::FilterType}; + + let cached = (!source_may_have_alpha(path)) + .then(|| library::thumbnail_rgba(path)) + .flatten(); + let img = match cached { + Some(rgba) => image::DynamicImage::ImageRgba8(rgba), + None => image::load_from_memory(&std::fs::read(path).ok()?).ok()?, }; let (orig_w, orig_h) = img.dimensions(); if orig_w == 0 || orig_h == 0 { @@ -325,6 +328,24 @@ fn load_thumbnail_png(path: &Path) -> Option> { Some(png) } +/// Reads only the signature and, for a PNG, the header chunks: the color type +/// says whether the pixels can carry alpha without decoding any of them. +fn source_may_have_alpha(path: &Path) -> bool { + use image::{ImageDecoder, ImageFormat, ImageReader}; + + let Ok(reader) = ImageReader::open(path).and_then(ImageReader::with_guessed_format) else { + return true; + }; + match reader.format() { + Some(ImageFormat::Jpeg) => false, + Some(ImageFormat::Png) => reader + .into_decoder() + .ok() + .is_none_or(|decoder| decoder.color_type().has_alpha()), + _ => true, + } +} + /// `load_all_previous_items` + the background thumbnail pass, in one call. /// /// Reuses the library scan the main window's Recents runs on, so the two lists @@ -904,6 +925,7 @@ mod mac { actions: Vec, mode: Mode, previous: Vec, + previous_generation: u64, recording: bool, } @@ -967,41 +989,52 @@ mod mac { } fn create_status_item() -> Option { - let mtm = MainThreadMarker::new()?; - let status_item = - NSStatusBar::systemStatusBar().statusItemWithLength(NS_VARIABLE_STATUS_ITEM_LENGTH); - let target: Retained = unsafe { msg_send![TrayTarget::alloc(mtm), init] }; - - let mode = Mode::from_store(); - let tray = Tray { - status_item, - target, - menu: None, - actions: Vec::new(), - mode, - previous: Vec::new(), - recording: false, - }; - set_button_image(&tray, mode_icon(mode)); - let button = tray.status_item.button(mtm); - let frame = button.as_ref().map_or_else( - || { + unsafe { + let status_bar: *mut AnyObject = msg_send![class!(NSStatusBar), systemStatusBar]; + if status_bar.is_null() { + return None; + } + let raw: *mut AnyObject = + msg_send![status_bar, statusItemWithLength: NS_VARIABLE_STATUS_ITEM_LENGTH]; + // `statusItemWithLength:` hands back an autoreleased object owned + // by the status bar; retain it for as long as we hold it, and never + // release it -- removing a status item is what makes it disappear + // from the menu bar, and this one lives for the process. + let status_item = Id::retain(raw)?; + let target: Id = msg_send_id![TrayTarget::alloc(), init]; + + let mode = Mode::from_store(); + let tray = Tray { + status_item, + target, + menu: None, + actions: Vec::new(), + mode, + previous: Vec::new(), + previous_generation: 0, + recording: false, + }; + set_button_image(&tray, mode_icon(mode)); + let button: *mut AnyObject = msg_send![&*tray.status_item, button]; + let visible: bool = msg_send![&*tray.status_item, isVisible]; + let frame: objc2_foundation::NSRect = if button.is_null() { objc2_foundation::NSRect::new( - objc2_foundation::NSPoint::new(0.0, 0.0), - objc2_foundation::NSSize::new(0.0, 0.0), + objc2_foundation::NSPoint::new(0., 0.), + objc2_foundation::NSSize::new(0., 0.), ) - }, - |button| button.frame(), - ); - tracing::info!( - has_button = button.is_some(), - visible = tray.status_item.isVisible(), - width = frame.size.width, - height = frame.size.height, - ?mode, - "status item created" - ); - Some(tray) + } else { + msg_send![button, frame] + }; + tracing::info!( + has_button = !button.is_null(), + visible, + width = frame.size.width, + height = frame.size.height, + ?mode, + "status item created" + ); + Some(tray) + } } /// `set_icon_for_ns_status_item_button`: an 18pt-tall template image on the @@ -1301,13 +1334,17 @@ mod mac { if !cx.has_global::() { return; } + let tray = cx.global_mut::(); + tray.previous_generation = tray.previous_generation.wrapping_add(1); + let generation = tray.previous_generation; cx.spawn(async move |cx| { let previous = cx .background_executor() .spawn(async { scan_previous(true) }) .await; cx.update(|cx| { - if !cx.has_global::() { + if !cx.has_global::() || cx.global::().previous_generation != generation + { return; } cx.global_mut::().previous = previous; @@ -1520,6 +1557,8 @@ mod tests { let icon = image::load_from_memory(&load_thumbnail_png(&png).unwrap()) .unwrap() .into_rgba8(); + assert_eq!(icon.dimensions(), (THUMBNAIL_SIZE, THUMBNAIL_SIZE)); + assert!(icon.get_pixel(0, 0)[3] < 8, "{scan} scan lost the corner"); assert!( icon.get_pixel(2, 16)[3] < 8, "{scan} scan lost the transparent half" @@ -1529,6 +1568,50 @@ mod tests { "{scan} scan lost the opaque half" ); } + let cache = library::thumbnail_cache_file(&png).unwrap(); + assert!( + !cache.exists(), + "a transparent PNG never goes through the alpha-dropping cache" + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn previous_thumbnails_reuse_the_cache_for_an_opaque_png() { + let root = temp_root("opaque-png"); + std::fs::create_dir_all(&root).unwrap(); + let png = root.join("screenshot.png"); + image::RgbImage::from_pixel(1280, 720, image::Rgb([20, 120, 220])) + .save_with_format(&png, image::ImageFormat::Png) + .unwrap(); + let cache = library::thumbnail_cache_file(&png).unwrap(); + std::fs::remove_file(&cache).ok(); + + let cold = image::load_from_memory(&load_thumbnail_png(&png).unwrap()) + .unwrap() + .into_rgba8(); + assert_eq!(cold.dimensions(), (THUMBNAIL_SIZE, THUMBNAIL_SIZE)); + assert_eq!(cold.get_pixel(0, 0)[3], 255); + assert!(cache.exists(), "the first scan fills the shared cache"); + + let marker = image::Rgb([200, 30, 90]); + image::RgbImage::from_pixel(398, 224, marker) + .save_with_format(&cache, image::ImageFormat::Jpeg) + .unwrap(); + let warm = image::load_from_memory(&load_thumbnail_png(&png).unwrap()) + .unwrap() + .into_rgba8(); + assert_eq!(warm.dimensions(), (THUMBNAIL_SIZE, THUMBNAIL_SIZE)); + assert!( + warm.pixels().all(|pixel| pixel.0[..3] + .iter() + .zip(marker.0) + .all(|(a, b)| a.abs_diff(b) <= 3) + && pixel.0[3] == 255), + "the warm icon is built from the cached thumbnail, not the original" + ); + + std::fs::remove_file(&cache).ok(); std::fs::remove_dir_all(&root).ok(); } diff --git a/apps/desktop-gpui/src/ui/button.rs b/apps/desktop-gpui/src/ui/button.rs index 048cadefad7..fc574124502 100644 --- a/apps/desktop-gpui/src/ui/button.rs +++ b/apps/desktop-gpui/src/ui/button.rs @@ -8,7 +8,7 @@ use gpui::{ prelude::FluentBuilder, px, svg, }; -use super::menu::OpenHandler; +use super::{keyboard_nav, menu::OpenHandler}; use crate::theme::Theme; /// The click handler every component takes. `cx.listener(..)` produces exactly @@ -408,7 +408,7 @@ fn radix_paint(theme: &Theme, variant: ButtonVariant) -> ButtonPaint { } impl RenderOnce for Button { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let Button { id, label, @@ -433,6 +433,8 @@ impl RenderOnce for Button { div() .id(id) .tab_index(0) + .tab_stop(!disabled) + .when(keyboard_nav::active(window, cx), keyboard_nav::ring) .flex() .flex_row() .items_center() @@ -635,7 +637,7 @@ impl IconButton { } impl RenderOnce for IconButton { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let IconButton { id, icon, @@ -659,6 +661,8 @@ impl RenderOnce for IconButton { .id(id) .when(occlude, |this| this.occlude()) .tab_index(0) + .tab_stop(!disabled) + .when(keyboard_nav::active(window, cx), keyboard_nav::ring) .flex() .items_center() .justify_center() diff --git a/apps/desktop-gpui/src/ui/keyboard_nav.rs b/apps/desktop-gpui/src/ui/keyboard_nav.rs new file mode 100644 index 00000000000..48ec552921d --- /dev/null +++ b/apps/desktop-gpui/src/ui/keyboard_nav.rs @@ -0,0 +1,202 @@ +//! Tab / Shift-Tab focus navigation for the windows that opt in. +//! +//! Nothing in a window has focus until the user presses Tab, and the main +//! window has no root focus handle, so a key binding would never match there: +//! with no focused node only the root receives keys. An owner-scoped +//! keystroke interceptor runs before bindings and element listeners instead. +//! +//! Tab stops that are not already focusable are only added while a window is +//! in keyboard mode, which starts on Tab and ends on the next mouse press. A +//! focusable element takes focus on click and fires its click handler on +//! Enter/Space key-up, so adding them unconditionally would change what mouse +//! users get. + +use std::collections::HashSet; + +use gpui::{ + App, BoxShadow, Context, DispatchPhase, Global, InteractiveElement, IntoElement, Keystroke, + MouseDownEvent, Styled, Subscription, Window, WindowId, canvas, point, px, rgb, +}; + +use crate::{theme::Theme, ui::text_input::text_input_is_composing}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + Next, + Previous, +} + +pub fn direction(keystroke: &Keystroke) -> Option { + let modifiers = keystroke.modifiers; + if keystroke.key != "tab" + || modifiers.control + || modifiers.alt + || modifiers.platform + || modifiers.function + { + return None; + } + Some(if modifiers.shift { + Direction::Previous + } else { + Direction::Next + }) +} + +fn takes_tab(blocked: bool, prompt_active: bool, composing: bool) -> bool { + !(blocked || prompt_active || composing) +} + +#[derive(Default)] +struct KeyboardWindows(HashSet); + +impl Global for KeyboardWindows {} + +pub fn active(window: &Window, cx: &App) -> bool { + cx.try_global::() + .is_some_and(|windows| windows.0.contains(&window.window_handle().window_id())) +} + +pub fn leave(window: &mut Window, cx: &mut App) { + let id = window.window_handle().window_id(); + if cx.has_global::() && cx.global_mut::().0.remove(&id) { + window.refresh(); + } +} + +/// Leaves keyboard mode on any mouse press in the window. A window-level +/// listener, because `.occlude()` hitboxes (menus, modals, the Windows header) +/// hide the root from element mouse listeners. +pub fn pointer_watcher() -> impl IntoElement { + canvas( + |_, _, _| (), + |_, (), window, _| { + window.on_mouse_event(|_: &MouseDownEvent, phase, window, cx| { + if phase == DispatchPhase::Capture { + leave(window, cx); + } + }); + }, + ) + .absolute() + .inset_0() +} + +/// Route Tab in `window` to focus navigation. `blocked` lets the owner keep +/// Tab for itself, e.g. while a shortcut recorder or a modal is up. +pub fn install( + window: &Window, + cx: &mut Context, + blocked: impl Fn(&V, &App) -> bool + 'static, +) -> Subscription { + let owner = window.window_handle().window_id(); + let view = cx.weak_entity(); + cx.intercept_keystrokes(move |event, window, cx| { + if window.window_handle().window_id() != owner { + return; + } + let Some(direction) = direction(&event.keystroke) else { + return; + }; + let Some(view) = view.upgrade() else { + return; + }; + if !takes_tab( + blocked(view.read(cx), cx), + window.has_active_prompt(), + text_input_is_composing(window, cx), + ) { + return; + } + navigate(direction, window, cx); + window.prevent_default(); + cx.stop_propagation(); + }) +} + +fn navigate(direction: Direction, window: &mut Window, cx: &mut App) { + let entered = cx + .default_global::() + .0 + .insert(window.window_handle().window_id()); + if !entered { + move_focus(direction, window, cx); + return; + } + // The stops gated on keyboard mode only exist from the next draw on, and + // `focus_next` walks the last rendered frame. Next-frame callbacks run + // before that frame draws, so the move waits one frame more. + window.refresh(); + window.on_next_frame(move |window, _| { + window.on_next_frame(move |window, cx| { + if active(window, cx) { + move_focus(direction, window, cx); + } + }); + }); +} + +fn move_focus(direction: Direction, window: &mut Window, cx: &mut App) { + match direction { + Direction::Next => window.focus_next(cx), + Direction::Previous => window.focus_prev(cx), + } +} + +pub fn ring(element: E) -> E { + element.focus_visible(|style| { + style.shadow(vec![BoxShadow { + color: rgb(Theme::SETTINGS_ACCENT).into(), + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(2.), + inset: true, + }]) + }) +} + +pub fn stop(element: E) -> E { + ring(element.tab_index(0)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed(source: &str) -> Option { + direction(&Keystroke::parse(source).unwrap()) + } + + #[test] + fn only_bare_tab_and_shift_tab_navigate() { + assert_eq!(parsed("tab"), Some(Direction::Next)); + assert_eq!(parsed("shift-tab"), Some(Direction::Previous)); + for source in [ + "ctrl-tab", + "ctrl-shift-tab", + "alt-tab", + "cmd-tab", + "fn-tab", + "secondary-tab", + "enter", + "space", + "escape", + "t", + ] { + assert_eq!(parsed(source), None, "{source}"); + } + } + + #[test] + fn tab_is_left_alone_while_anything_else_owns_it() { + assert!(takes_tab(false, false, false)); + for (blocked, prompt_active, composing) in [ + (true, false, false), + (false, true, false), + (false, false, true), + (true, true, true), + ] { + assert!(!takes_tab(blocked, prompt_active, composing)); + } + } +} diff --git a/apps/desktop-gpui/src/ui/microphone_level.rs b/apps/desktop-gpui/src/ui/microphone_level.rs index 8bc64a3ebfc..2983941b2d9 100644 --- a/apps/desktop-gpui/src/ui/microphone_level.rs +++ b/apps/desktop-gpui/src/ui/microphone_level.rs @@ -1,57 +1,132 @@ use gpui::{ - Context, Entity, Hsla, IntoElement, ParentElement as _, Render, Styled as _, Window, div, + App, Context, Entity, Hsla, IntoElement, ParentElement as _, Render, Styled as _, Window, div, prelude::FluentBuilder as _, px, }; -use crate::feeds::{self, Feeds}; +use crate::feeds::{self, Feeds, MicLevel}; +const RECORDING_BAR_TRACK_WIDTH: f32 = 20.; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MicrophoneLevelKind { + CaptureButton, + DeviceRow, + RecordingBar, +} + +impl MicrophoneLevelKind { + pub(crate) fn fill(self, db: f64) -> f32 { + if !db.is_finite() { + return 0.; + } + match self { + Self::CaptureButton | Self::DeviceRow => (1. - feeds::picker_level(db)) as f32, + Self::RecordingBar => feeds::bar_level(db) as f32, + } + } + + /// An upper bound on the fill's drawn width, in device pixels. The main + /// window meters are never wider than the window itself. + fn span(self, viewport_width: f32, scale: f32) -> f32 { + match self { + Self::CaptureButton | Self::DeviceRow => viewport_width * scale, + Self::RecordingBar => RECORDING_BAR_TRACK_WIDTH * scale, + } + } + + fn shown(self, window: &Window) -> bool { + match self { + Self::RecordingBar => true, + // Linux has no visibility query (`window_is_visible` is a stub), + // and gating on focus would freeze the meter in a visible window. + Self::CaptureButton | Self::DeviceRow => { + !cfg!(any(target_os = "macos", target_os = "windows")) + || crate::platform::window_is_visible(window) + } + } + } +} + +pub(crate) fn moves_a_device_pixel(drawn: f32, next: f32, span: f32) -> bool { + (drawn * span).round() != (next * span).round() +} + +/// A live mic level meter. It is its own view so a level change repaints +/// only when the meter would visibly move, without the window that hosts it +/// ever reading the level in render. pub(crate) struct MicrophoneLevel { + kind: MicrophoneLevelKind, active: bool, + level_db: f64, fill: f32, color: Hsla, + track: Hsla, } impl MicrophoneLevel { - pub(crate) fn new(feeds: &Entity, window: &mut Window, cx: &mut Context) -> Self { - cx.observe_in(feeds, window, |this, feeds, window, cx| { - let visible = if cfg!(any(target_os = "macos", target_os = "windows")) { - crate::platform::window_is_visible(window) - } else { - window.is_window_active() - }; - if this.active && visible { - let fill = Self::fill(feeds.read(cx)); - if this.fill != fill { - this.fill = fill; - cx.notify(); + pub(crate) fn new( + kind: MicrophoneLevelKind, + level: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + cx.observe_in(level, window, |this, level, window, cx| { + this.level_db = level.read(cx).db(); + if !this.active || !this.kind.shown(window) { + return; + } + let fill = this.kind.fill(this.level_db); + let span = this.kind.span( + f32::from(window.viewport_size().width), + window.scale_factor(), + ); + if moves_a_device_pixel(this.fill, fill, span) { + this.fill = fill; + cx.notify(); + if this.kind == MicrophoneLevelKind::RecordingBar { + window.refresh(); } } }) .detach(); Self { + kind, active: false, + level_db: level.read(cx).db(), fill: 0., color: gpui::transparent_black(), + track: gpui::transparent_black(), } } - pub(crate) fn fill(feeds: &Feeds) -> f32 { - if feeds.microphone.is_some() && feeds.mic_level_db.is_finite() { - (1. - feeds::picker_level(feeds.mic_level_db)) as f32 + /// The capture-button fill for the current level, for surfaces that + /// snapshot it outside render. + pub(crate) fn capture_fill(cx: &App) -> f32 { + let feeds = Feeds::global(cx).read(cx); + if feeds.microphone.is_some() { + MicrophoneLevelKind::CaptureButton.fill(feeds.mic_level().read(cx).db()) } else { 0. } } - pub(crate) fn configure(&mut self, active: bool, color: Hsla, cx: &mut Context) { + pub(crate) fn configure( + &mut self, + active: bool, + color: Hsla, + track: Hsla, + cx: &mut Context, + ) { let fill = if active { - Self::fill(Feeds::global(cx).read(cx)) + self.kind.fill(self.level_db) } else { 0. }; - if self.active != active || self.color != color || self.fill != fill { + if self.active != active || self.color != color || self.track != track || self.fill != fill + { self.active = active; self.color = color; + self.track = track; self.fill = fill; cx.notify(); } @@ -74,6 +149,130 @@ impl MicrophoneLevel { impl Render for MicrophoneLevel { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - Self::snapshot(self.fill, self.color) + match self.kind { + MicrophoneLevelKind::CaptureButton => Self::snapshot(self.fill, self.color), + MicrophoneLevelKind::DeviceRow => div() + .mt(px(3.)) + .h(px(3.)) + .w_full() + .rounded_full() + .overflow_hidden() + .bg(self.track) + .child( + div() + .h_full() + .rounded_full() + .bg(self.color) + .w(gpui::relative(self.fill)), + ), + MicrophoneLevelKind::RecordingBar => div() + .absolute() + .bottom(px(4.)) + .left(px(4.)) + .right(px(4.)) + .h(px(2.)) + .rounded_full() + .overflow_hidden() + .bg(self.track) + .child( + div() + .absolute() + .left_0() + .top_0() + .bottom_0() + .w(gpui::relative(self.fill)) + .bg(self.color), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn px_moved(kind: MicrophoneLevelKind, from_db: f64, to_db: f64, span: f32) -> bool { + moves_a_device_pixel(kind.fill(from_db), kind.fill(to_db), span) + } + + #[test] + fn a_level_change_of_a_device_pixel_or_more_repaints() { + let span = MicrophoneLevelKind::CaptureButton.span(320., 2.); + assert!(px_moved( + MicrophoneLevelKind::CaptureButton, + -30., + -20., + span + )); + assert!(px_moved( + MicrophoneLevelKind::CaptureButton, + -96., + -39., + span + )); + assert!(px_moved(MicrophoneLevelKind::DeviceRow, -12., -10., span)); + assert!(moves_a_device_pixel(0.5, 0.5 + 1. / span, span)); + } + + #[test] + fn a_sub_pixel_level_change_does_not_repaint() { + let span = MicrophoneLevelKind::CaptureButton.span(320., 2.); + assert!(!moves_a_device_pixel(0.5, 0.5 + 0.4 / span, span)); + assert!(!moves_a_device_pixel(0.25, 0.25, span)); + assert!(!px_moved( + MicrophoneLevelKind::CaptureButton, + -20., + -20.25, + 40. + )); + } + + #[test] + fn levels_below_the_visible_range_never_repaint() { + let span = MicrophoneLevelKind::CaptureButton.span(4096., 3.); + for db in [-96., -72., -60., -55.5, -48., -40.5, -40.] { + assert!(!px_moved( + MicrophoneLevelKind::CaptureButton, + -96., + db, + span + )); + assert!(!px_moved(MicrophoneLevelKind::DeviceRow, -96., db, span)); + } + let span = MicrophoneLevelKind::RecordingBar.span(0., 2.); + for db in [-96., -80., -60.5, -60.] { + assert!(!px_moved(MicrophoneLevelKind::RecordingBar, -96., db, span)); + } + assert!(px_moved( + MicrophoneLevelKind::RecordingBar, + -96., + -58., + span + )); + } + + #[test] + fn the_recording_bar_repaints_per_device_pixel_of_its_track() { + let kind = MicrophoneLevelKind::RecordingBar; + assert!(!px_moved(kind, -30., -30.5, kind.span(0., 2.))); + assert!(px_moved(kind, -30., -32., kind.span(0., 2.))); + assert!(!px_moved(kind, -30., -31., kind.span(0., 1.))); + assert!(px_moved(kind, -30., -33., kind.span(0., 1.))); + assert_eq!(kind.fill(-96.) * kind.span(0., 2.), 0.); + assert_eq!(kind.fill(0.) * kind.span(0., 1.), 20.); + assert_eq!(kind.fill(0.) * kind.span(0., 2.), 40.); + } + + #[test] + fn fills_match_the_existing_level_mappings() { + assert_eq!(MicrophoneLevelKind::CaptureButton.fill(-96.), 0.); + assert_eq!(MicrophoneLevelKind::CaptureButton.fill(0.), 1.); + assert_eq!(MicrophoneLevelKind::DeviceRow.fill(-40.), 0.); + assert_eq!(MicrophoneLevelKind::RecordingBar.fill(-60.), 0.); + assert_eq!(MicrophoneLevelKind::RecordingBar.fill(-30.), 0.5); + assert_eq!( + MicrophoneLevelKind::CaptureButton.fill(f64::NEG_INFINITY), + 0. + ); } } diff --git a/apps/desktop-gpui/src/ui/mod.rs b/apps/desktop-gpui/src/ui/mod.rs index e69609d4bf5..4d3b32d9460 100644 --- a/apps/desktop-gpui/src/ui/mod.rs +++ b/apps/desktop-gpui/src/ui/mod.rs @@ -46,6 +46,7 @@ mod color_picker; mod editor_button; mod field; mod kbd; +pub mod keyboard_nav; mod menu; mod microphone_level; mod number_field; @@ -89,7 +90,7 @@ pub use field::{Field, Subfield}; pub use kbd::{KbdChip, KbdSize, kbd_symbol}; #[allow(unused_imports)] pub use menu::{Menu, MenuItem, MenuKey, MenuState}; -pub(crate) use microphone_level::MicrophoneLevel; +pub(crate) use microphone_level::{MicrophoneLevel, MicrophoneLevelKind, moves_a_device_pixel}; #[allow(unused_imports)] pub use number_field::{ NumberChange, NumberField, NumberFieldState, NumberLimits, format_number, parse_number, diff --git a/apps/desktop-gpui/src/ui/select.rs b/apps/desktop-gpui/src/ui/select.rs index 172d2d3985d..3b0498493ff 100644 --- a/apps/desktop-gpui/src/ui/select.rs +++ b/apps/desktop-gpui/src/ui/select.rs @@ -12,7 +12,7 @@ use gpui::{ div, prelude::FluentBuilder, px, svg, }; -use super::menu::OpenHandler; +use super::{keyboard_nav, menu::OpenHandler}; use crate::theme::Theme; #[derive(IntoElement)] @@ -126,7 +126,7 @@ impl Select { } impl RenderOnce for Select { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let Select { id, label, @@ -148,8 +148,13 @@ impl RenderOnce for Select { on_open, } = self; + let interactive = !disabled && (on_click.is_some() || on_open.is_some()); div() .id(id) + .when( + interactive && keyboard_nav::active(window, cx), + keyboard_nav::stop, + ) .flex() .flex_row() .items_center() diff --git a/apps/desktop-gpui/src/ui/text_input.rs b/apps/desktop-gpui/src/ui/text_input.rs index 88bc8eb303e..cbf4628ab03 100644 --- a/apps/desktop-gpui/src/ui/text_input.rs +++ b/apps/desktop-gpui/src/ui/text_input.rs @@ -75,6 +75,7 @@ use gpui::{ use smallvec::SmallVec; use unicode_segmentation::UnicodeSegmentation; +use super::keyboard_nav; use crate::theme::Theme; // -- Actions and key bindings ------------------------------------------------- @@ -139,8 +140,12 @@ pub const KEY_CONTEXT: &str = "TextInput"; /// matched *before* the IME composes the character, so binding `a` would mean /// the letter `a` could never be typed. pub fn bind_keys(cx: &mut App) { + cx.bind_keys(bindings()); +} + +pub(crate) fn bindings() -> Vec { let ctx = Some(KEY_CONTEXT); - cx.bind_keys([ + let mut bindings = vec![ KeyBinding::new("backspace", Backspace, ctx), KeyBinding::new("delete", DeleteForward, ctx), KeyBinding::new("alt-backspace", DeleteWordLeft, ctx), @@ -181,7 +186,36 @@ pub fn bind_keys(cx: &mut App) { KeyBinding::new("enter", Confirm, ctx), KeyBinding::new("escape", Cancel, ctx), KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, ctx), - ]); + ]; + if cfg!(not(target_os = "macos")) { + bindings.extend(ctrl_bindings(ctx)); + } + bindings +} + +/// The fork parses `cmd` as the Windows/Super key off macOS, so the `cmd-*` +/// chords above never fire there. These are the Windows and Linux spellings of +/// the same commands. +fn ctrl_bindings(ctx: Option<&str>) -> [KeyBinding; 17] { + [ + KeyBinding::new("ctrl-a", SelectAll, ctx), + KeyBinding::new("ctrl-c", Copy, ctx), + KeyBinding::new("ctrl-x", Cut, ctx), + KeyBinding::new("ctrl-v", Paste, ctx), + KeyBinding::new("ctrl-z", Undo, ctx), + KeyBinding::new("ctrl-shift-z", Redo, ctx), + KeyBinding::new("ctrl-y", Redo, ctx), + KeyBinding::new("ctrl-left", MoveWordLeft, ctx), + KeyBinding::new("ctrl-right", MoveWordRight, ctx), + KeyBinding::new("ctrl-shift-left", SelectWordLeft, ctx), + KeyBinding::new("ctrl-shift-right", SelectWordRight, ctx), + KeyBinding::new("ctrl-backspace", DeleteWordLeft, ctx), + KeyBinding::new("ctrl-delete", DeleteWordRight, ctx), + KeyBinding::new("ctrl-home", MoveToStart, ctx), + KeyBinding::new("ctrl-end", MoveToEnd, ctx), + KeyBinding::new("ctrl-shift-home", SelectToStart, ctx), + KeyBinding::new("ctrl-shift-end", SelectToEnd, ctx), + ] } // -- The focus gate for bare printable keys ----------------------------------- @@ -213,6 +247,21 @@ pub fn text_input_has_focus(window: &Window, cx: &App) -> bool { }) } +/// Is a focused field in this window composing IME text? Tab belongs to the +/// input method then. +pub fn text_input_is_composing(window: &Window, cx: &App) -> bool { + let Some(live) = cx.try_global::() else { + return false; + }; + let inputs = live.0.borrow(); + inputs.iter().any(|weak| { + weak.upgrade().is_some_and(|entity| { + let input = entity.read(cx); + input.marked_range.is_some() && input.focus.is_focused(window) + }) + }) +} + fn register_live_input(weak: WeakEntity, cx: &mut App) { if !cx.has_global::() { cx.set_global(LiveInputs::default()); @@ -1828,7 +1877,11 @@ impl RenderOnce for TextInput { let state = self.state.clone(); let (focus, multi_line, disabled) = { let read = state.read(cx); - (read.focus.clone(), read.multi_line, read.disabled) + ( + read.focus.clone().tab_stop(!read.disabled), + read.multi_line, + read.disabled, + ) }; let focused = focus.is_focused(window); // `leading-normal` on a `text-xs` input is 1.35 in this font stack; @@ -1863,6 +1916,7 @@ impl RenderOnce for TextInput { .id(self.id.clone()) .key_context(KEY_CONTEXT) .track_focus(&focus) + .when(keyboard_nav::active(window, cx), keyboard_nav::ring) .relative() .flex() .flex_row() @@ -2380,6 +2434,164 @@ impl Element for TextBody { mod tests { use super::*; + // -- Key bindings --------------------------------------------------------- + + fn single_keystroke(binding: &KeyBinding) -> &gpui::Keystroke { + assert_eq!(binding.keystrokes().len(), 1, "{binding:?}"); + binding.keystrokes()[0].inner() + } + + fn bound<'a>(bindings: &'a [KeyBinding], source: &str) -> Vec<&'a KeyBinding> { + let keystroke = gpui::Keystroke::parse(source).unwrap(); + bindings + .iter() + .filter(|binding| single_keystroke(binding) == &keystroke) + .collect() + } + + fn assert_bound(bindings: &[KeyBinding], source: &str, action: &dyn gpui::Action) { + let matches = bound(bindings, source); + assert_eq!(matches.len(), 1, "{source} should be bound exactly once"); + assert!( + matches[0].action().partial_eq(action), + "{source} is bound to {}, expected {}", + matches[0].action().name(), + action.name() + ); + } + + const MAC_CHORDS: usize = 40; + + #[test] + fn every_binding_is_a_single_keystroke_in_the_text_input_context() { + for binding in bindings() { + single_keystroke(&binding); + assert_eq!( + binding.predicate().map(|predicate| predicate.to_string()), + Some(KEY_CONTEXT.to_string()), + "{binding:?}" + ); + } + } + + #[test] + fn no_keystroke_is_bound_twice() { + let bindings = bindings(); + let mut seen = std::collections::HashSet::new(); + for binding in &bindings { + assert!( + seen.insert(single_keystroke(binding).unparse()), + "{binding:?} is bound twice" + ); + } + } + + #[test] + fn no_bare_printable_key_is_bound() { + for binding in bindings() { + let keystroke = single_keystroke(&binding); + let modified = keystroke.modifiers.control + || keystroke.modifiers.alt + || keystroke.modifiers.platform + || keystroke.modifiers.function; + assert!( + modified || keystroke.key.chars().count() > 1, + "{binding:?} would swallow a typed character" + ); + } + } + + #[test] + fn mac_chords_are_unchanged() { + let golden: &[(&str, &dyn gpui::Action)] = &[ + ("backspace", &Backspace), + ("delete", &DeleteForward), + ("alt-backspace", &DeleteWordLeft), + ("alt-delete", &DeleteWordRight), + ("cmd-backspace", &DeleteToLineStart), + ("cmd-delete", &DeleteToLineEnd), + ("left", &MoveLeft), + ("right", &MoveRight), + ("up", &MoveUp), + ("down", &MoveDown), + ("alt-left", &MoveWordLeft), + ("alt-right", &MoveWordRight), + ("cmd-left", &MoveToLineStart), + ("cmd-right", &MoveToLineEnd), + ("home", &MoveToLineStart), + ("end", &MoveToLineEnd), + ("cmd-up", &MoveToStart), + ("cmd-down", &MoveToEnd), + ("shift-left", &SelectLeft), + ("shift-right", &SelectRight), + ("shift-up", &SelectUp), + ("shift-down", &SelectDown), + ("alt-shift-left", &SelectWordLeft), + ("alt-shift-right", &SelectWordRight), + ("cmd-shift-left", &SelectToLineStart), + ("cmd-shift-right", &SelectToLineEnd), + ("shift-home", &SelectToLineStart), + ("shift-end", &SelectToLineEnd), + ("cmd-shift-up", &SelectToStart), + ("cmd-shift-down", &SelectToEnd), + ("cmd-a", &SelectAll), + ("cmd-c", &Copy), + ("cmd-x", &Cut), + ("cmd-v", &Paste), + ("cmd-z", &Undo), + ("cmd-shift-z", &Redo), + ("cmd-y", &Redo), + ("enter", &Confirm), + ("escape", &Cancel), + ("ctrl-cmd-space", &ShowCharacterPalette), + ]; + assert_eq!(golden.len(), MAC_CHORDS); + let bindings = bindings(); + for (source, action) in golden { + assert_bound(&bindings, source, *action); + } + if cfg!(target_os = "macos") { + assert_eq!(bindings.len(), MAC_CHORDS); + } + } + + #[test] + fn ctrl_chords_edit_text_only_off_macos() { + let chords: &[(&str, &dyn gpui::Action)] = &[ + ("ctrl-a", &SelectAll), + ("ctrl-c", &Copy), + ("ctrl-x", &Cut), + ("ctrl-v", &Paste), + ("ctrl-z", &Undo), + ("ctrl-shift-z", &Redo), + ("ctrl-y", &Redo), + ("ctrl-left", &MoveWordLeft), + ("ctrl-right", &MoveWordRight), + ("ctrl-shift-left", &SelectWordLeft), + ("ctrl-shift-right", &SelectWordRight), + ("ctrl-backspace", &DeleteWordLeft), + ("ctrl-delete", &DeleteWordRight), + ("ctrl-home", &MoveToStart), + ("ctrl-end", &MoveToEnd), + ("ctrl-shift-home", &SelectToStart), + ("ctrl-shift-end", &SelectToEnd), + ]; + let bindings = bindings(); + for (source, action) in chords { + if cfg!(target_os = "macos") { + assert!( + bound(&bindings, source).is_empty(), + "{source} is bound on macOS" + ); + } else { + assert_bound(&bindings, source, *action); + } + } + if !cfg!(target_os = "macos") { + assert_eq!(bindings.len(), MAC_CHORDS + chords.len()); + } + } + // -- Word navigation ------------------------------------------------------ /// `alt-left` skips the run of separators first, then the word, which is diff --git a/apps/desktop-gpui/src/ui/toggle.rs b/apps/desktop-gpui/src/ui/toggle.rs index ff8e690b548..4a6bf7fdf13 100644 --- a/apps/desktop-gpui/src/ui/toggle.rs +++ b/apps/desktop-gpui/src/ui/toggle.rs @@ -6,9 +6,11 @@ use gpui::{ App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, - RenderOnce, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::FluentBuilder, px, }; +use super::keyboard_nav; use crate::theme::Theme; /// `cva` sizes. `lg` is `w-14 h-7 p-0.75` with a `size-6` thumb; no window @@ -54,6 +56,7 @@ pub struct Toggle { /// `bg-white` -- the thumb is white on every surface. thumb: Hsla, disabled: bool, + label: Option, on_click: Option, } @@ -68,6 +71,7 @@ impl Toggle { on_fill: Hsla::from(theme.editor.accent), thumb: gpui::white(), disabled: false, + label: None, on_click: None, } } @@ -115,6 +119,11 @@ impl Toggle { self } + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -125,7 +134,7 @@ impl Toggle { } impl RenderOnce for Toggle { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let Toggle { id, checked, @@ -134,12 +143,24 @@ impl RenderOnce for Toggle { on_fill, thumb, disabled, + label, on_click, } = self; let (width, height, padding) = size.track(); div() .id(id) + .when( + keyboard_nav::active(window, cx) && !disabled, + keyboard_nav::stop, + ) + .role(gpui::Role::Switch) + .aria_toggled(if checked { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .when_some(label, |this, label| this.aria_label(label)) .w(width) .h(height) .p(padding) diff --git a/apps/desktop-gpui/src/upload/multipart_tests.rs b/apps/desktop-gpui/src/upload/multipart_tests.rs index df5be2ea81b..954320f7761 100644 --- a/apps/desktop-gpui/src/upload/multipart_tests.rs +++ b/apps/desktop-gpui/src/upload/multipart_tests.rs @@ -756,3 +756,111 @@ async fn multipart_mock_network_benchmark() { ); } } + +/// Signs this thread's store in against `server`, so `auth::authed_request` +/// builds its URL and credentials exactly as the app does. +struct SignedIn(PathBuf); + +impl SignedIn { + fn new(server: std::net::SocketAddr) -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "cap-gpui-api-client-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::AcqRel), + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "general_settings": { "serverUrl": format!("http://{server}") }, + "auth": { "secret": { "api_key": "api-key" }, "user_id": "owner" }, + })) + .unwrap(), + ) + .unwrap(); + crate::store::set_test_tauri_store_path(Some(path)); + Self(dir) + } +} + +impl Drop for SignedIn { + fn drop(&mut self) { + crate::store::set_test_tauri_store_path(None); + let _ = std::fs::remove_dir_all(&self.0); + } +} + +async fn authed_presign(part: u32) -> Duration { + let started = Instant::now(); + let response = auth::authed_request( + reqwest::Method::POST, + &format!("/presign/{part}"), + Some(json!({ "partNumber": part })), + ) + .await + .unwrap(); + assert!(response.status().is_success()); + let body = response.json::().await.unwrap(); + assert!( + body["presignedUrl"] + .as_str() + .unwrap() + .ends_with(&format!("/part/{part}")) + ); + started.elapsed() +} + +#[tokio::test] +async fn sequential_api_requests_reuse_one_connection_until_it_idles_out() { + let server = mock_network::start(fast_local_profile()); + let _signed_in = SignedIn::new(server.addr); + let _shared_client = auth::SHARED_API_CLIENT_TEST_LOCK.lock().await; + for part in 1..=20 { + authed_presign(part).await; + } + // One more if the shared client reaches its maximum age mid-loop. + let reused = server.connections.load(Ordering::Acquire); + assert!(reused <= 2, "{reused} connections for 20 requests"); + + tokio::time::sleep(auth::API_POOL_IDLE_TIMEOUT + Duration::from_millis(500)).await; + authed_presign(21).await; + assert_eq!(server.connections.load(Ordering::Acquire), reused + 1); +} + +/// `cargo test --bin cap-gpui api_request_mock_network_benchmark -- --ignored --nocapture` +/// 100 sequential presigns through `auth::authed_request`, the way Instant +/// recording sends them, with no added latency and over a 50 ms RTT link +/// where a new connection costs a 2 RTT TCP and TLS handshake. +#[tokio::test] +#[ignore = "benchmark: 200 presigns through a throttled loopback server"] +async fn api_request_mock_network_benchmark() { + const CALLS: u32 = 100; + println!("| RTT | connections | p50 | p95 | total |"); + println!("|---|---|---|---|---|"); + for (rtt_ms, handshake_rtts) in [(0u64, 0u32), (50, 2)] { + let server = mock_network::start(mock_network::Profile { + rtt: Duration::from_millis(rtt_ms), + link_bytes_per_sec: 1e9, + max_window: 1 << 20, + handshake_rtts, + presign_server_time: Duration::ZERO, + }); + let _signed_in = SignedIn::new(server.addr); + let started = Instant::now(); + let mut latencies = Vec::new(); + for part in 1..=CALLS { + latencies.push(authed_presign(part).await); + } + let total = started.elapsed(); + latencies.sort(); + let percentile = |p: usize| latencies[(latencies.len() * p / 100).min(latencies.len() - 1)]; + println!( + "| {rtt_ms} ms | {} | {:.2?} | {:.2?} | {total:.2?} |", + server.connections.load(Ordering::Acquire), + percentile(50), + percentile(95), + ); + } +} diff --git a/apps/desktop-gpui/src/upload/queue.rs b/apps/desktop-gpui/src/upload/queue.rs index 6cf371979e4..c32fe8c917a 100644 --- a/apps/desktop-gpui/src/upload/queue.rs +++ b/apps/desktop-gpui/src/upload/queue.rs @@ -3,7 +3,9 @@ use cap_project::{InstantRecordingMeta, RecordingMetaInner}; use cap_recording::upload_resume::{UploadLock, UploadLockError, collect_segment_events}; use cap_recording::upload_verification::{UploadVerification, VerifiedUploadReceipt}; use futures_util::FutureExt as _; +use std::ffi::OsString; use std::io::Write; +use std::time::SystemTime; static STATE_CHANGED: tokio::sync::Notify = tokio::sync::Notify::const_new(); @@ -377,7 +379,7 @@ struct LiveBackend; impl UploadBackend for LiveBackend { fn check_account(&self, state: &UploadState) -> Result<(), String> { - if state.server_url != auth::server_url() + if !auth::has_same_origin(&state.server_url, &auth::server_url()) || state.owner_id != owner_id() || store_auth_missing() { @@ -536,6 +538,7 @@ struct Manager { wake: tokio::sync::Notify, confirmation_timeout: Duration, pending_rechecks: Mutex>, + skipped_meta: Mutex, } impl Manager { @@ -547,6 +550,7 @@ impl Manager { wake: tokio::sync::Notify::new(), confirmation_timeout, pending_rechecks: Mutex::new(HashMap::new()), + skipped_meta: Mutex::new(SkippedMeta::default()), } } @@ -564,6 +568,12 @@ impl Manager { } } + fn skipped_meta(&self) -> std::sync::MutexGuard<'_, SkippedMeta> { + self.skipped_meta + .lock() + .unwrap_or_else(|error| error.into_inner()) + } + fn forget_pending_rechecks(&self, video_id: &str) { self.pending_rechecks .lock() @@ -901,9 +911,93 @@ fn legacy_state(meta: &RecordingMeta) -> Option { }) } +fn upload_candidate(meta: &RecordingMeta) -> bool { + matches!( + meta.inner, + RecordingMetaInner::Instant(InstantRecordingMeta::Complete { .. }) + ) && !matches!(meta.upload, None | Some(UploadMeta::Complete)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MetaStamp { + modified: SystemTime, + len: u64, + #[cfg(unix)] + inode: u64, + #[cfg(unix)] + changed: (i64, i64), +} + +impl MetaStamp { + fn read(project: &Path) -> Option { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + let metadata = std::fs::metadata(project.join("recording-meta.json")).ok()?; + Some(Self { + modified: metadata.modified().ok()?, + len: metadata.len(), + #[cfg(unix)] + inode: metadata.ino(), + #[cfg(unix)] + changed: (metadata.ctime(), metadata.ctime_nsec()), + }) + } +} + +// Only "not a candidate" verdicts from a successfully loaded meta are cached: +// that verdict depends solely on recording-meta.json, while candidates still +// go through the upload lock and instant-upload.json (where auth changes +// apply) on every pass. A failed load may be transient (sharing violation, +// antivirus lock) and leaves the stamp unchanged, so it is retried every pass. +// Every writer replaces the file by rename, so a save also changes the inode +// on unix. +#[derive(Default)] +struct SkippedMeta { + stamps: HashMap, + pass: u64, +} + +impl SkippedMeta { + fn begin_pass(&mut self) { + self.pass = self.pass.wrapping_add(1); + } + + fn is_candidate( + &mut self, + project: &Path, + load: impl FnOnce(&Path) -> Option, + ) -> bool { + let stamp = MetaStamp::read(project); + if let Some(stamp) = stamp + && let Some((cached, seen)) = self.stamps.get_mut(project.as_os_str()) + && *cached == stamp + { + *seen = self.pass; + return false; + } + let candidate = load(project).map(|meta| upload_candidate(&meta)); + match (stamp, candidate) { + (Some(stamp), Some(false)) => { + self.stamps + .insert(project.as_os_str().to_os_string(), (stamp, self.pass)); + } + _ => { + self.stamps.remove(project.as_os_str()); + } + } + candidate.unwrap_or(false) + } + + fn finish_pass(&mut self) { + let pass = self.pass; + self.stamps.retain(|_, (_, seen)| *seen == pass); + } +} + impl Manager { async fn reconcile(self: &Arc, auth_changed: bool) { self.reap().await; + self.skipped_meta().begin_pass(); for directory in crate::library::known_recordings_dirs() { let Ok(entries) = std::fs::read_dir(directory) else { continue; @@ -913,16 +1007,9 @@ impl Manager { return; } let project = entry.path(); - let Ok(meta) = RecordingMeta::load_for_project(&project) else { - continue; - }; - if !matches!( - meta.inner, - RecordingMetaInner::Instant(InstantRecordingMeta::Complete { .. }) - ) { - continue; - } - if matches!(meta.upload, None | Some(UploadMeta::Complete)) { + if !self.skipped_meta().is_candidate(&project, |project| { + RecordingMeta::load_for_project(project).ok() + }) { continue; } let ownership = match UploadLock::acquire(&project) { @@ -951,6 +1038,7 @@ impl Manager { } } } + self.skipped_meta().finish_pass(); } async fn resume( @@ -1107,7 +1195,9 @@ pub(crate) async fn delete_recording(project: PathBuf) -> Result<(), String> { state.phase = UploadPhase::Cancelled; write_state(&canonical, &state)?; } - crate::library::delete_recording_directory(&project) + tokio::task::spawn_blocking(move || crate::library::delete_recording_directory(&project)) + .await + .map_err(|error| error.to_string())? } #[cfg(test)] @@ -2285,4 +2375,167 @@ mod tests { assert_eq!(state.phase, UploadPhase::Retrying); fixture.assert_retained(); } + + fn save_library_meta(project: &Path, upload: Option) { + std::fs::create_dir_all(project).unwrap(); + RecordingMeta { + platform: None, + project_path: project.to_path_buf(), + pretty_name: "Library recording".into(), + sharing: None, + inner: RecordingMetaInner::Instant(InstantRecordingMeta::Complete { + fps: 30, + sample_rate: None, + }), + upload, + } + .save_for_project() + .unwrap(); + } + + fn scan_library( + skipped: &mut SkippedMeta, + library: &Path, + parses: &AtomicUsize, + ) -> Vec { + skipped.begin_pass(); + let mut candidates = std::fs::read_dir(library) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .filter(|project| { + skipped.is_candidate(project, |project| { + parses.fetch_add(1, Ordering::Relaxed); + RecordingMeta::load_for_project(project).ok() + }) + }) + .collect::>(); + skipped.finish_pass(); + candidates.sort(); + candidates + } + + fn library_with_skipped_projects(skipped_projects: usize) -> Fixture { + let fixture = Fixture::new(); + for index in 0..skipped_projects { + let upload = (index % 2 == 0).then_some(UploadMeta::Complete); + save_library_meta(&fixture.0.join(format!("skip-{index}.cap")), upload); + } + std::fs::write(fixture.0.join("notes.txt"), b"not a recording").unwrap(); + fixture + } + + #[test] + fn unchanged_skipped_recordings_are_not_parsed_again() { + let fixture = library_with_skipped_projects(6); + let mut skipped = SkippedMeta::default(); + let parses = AtomicUsize::new(0); + + assert_eq!( + scan_library(&mut skipped, &fixture.0, &parses), + vec![fixture.project()] + ); + assert_eq!(parses.swap(0, Ordering::Relaxed), 8); + assert_eq!(skipped.stamps.len(), 6); + + assert_eq!( + scan_library(&mut skipped, &fixture.0, &parses), + vec![fixture.project()] + ); + assert_eq!(parses.load(Ordering::Relaxed), 2); + assert_eq!( + read_state(&fixture.project()).unwrap().unwrap().phase, + UploadPhase::Pending + ); + } + + #[test] + fn rewritten_recording_meta_is_reclassified_on_the_next_pass() { + let fixture = library_with_skipped_projects(2); + let mut skipped = SkippedMeta::default(); + let parses = AtomicUsize::new(0); + let flipped = fixture.0.join("skip-1.cap"); + scan_library(&mut skipped, &fixture.0, &parses); + + save_library_meta( + &flipped, + Some(UploadMeta::SinglePartUpload { + video_id: "flipped-video".into(), + file_path: flipped.join("content/output.mp4"), + screenshot_path: flipped.join("screenshots/display.jpg"), + recording_dir: flipped.clone(), + }), + ); + assert_eq!( + scan_library(&mut skipped, &fixture.0, &parses), + vec![fixture.project(), flipped.clone()] + ); + assert!(!skipped.stamps.contains_key(flipped.as_os_str())); + + save_library_meta(&flipped, Some(UploadMeta::Complete)); + assert_eq!( + scan_library(&mut skipped, &fixture.0, &parses), + vec![fixture.project()] + ); + parses.store(0, Ordering::Relaxed); + scan_library(&mut skipped, &fixture.0, &parses); + assert_eq!(parses.load(Ordering::Relaxed), 2); + } + + #[test] + fn recordings_whose_meta_failed_to_load_are_retried() { + let fixture = Fixture::new(); + let project = fixture.project(); + let mut skipped = SkippedMeta::default(); + + skipped.begin_pass(); + assert!(!skipped.is_candidate(&project, |_| None)); + skipped.finish_pass(); + assert!(!skipped.stamps.contains_key(project.as_os_str())); + + skipped.begin_pass(); + assert!(skipped.is_candidate(&project, |project| { + RecordingMeta::load_for_project(project).ok() + })); + skipped.finish_pass(); + assert!(skipped.stamps.is_empty()); + } + + #[test] + fn unparsable_recording_meta_is_parsed_again_every_pass() { + let fixture = library_with_skipped_projects(2); + let mut skipped = SkippedMeta::default(); + let parses = AtomicUsize::new(0); + let corrupt = fixture.0.join("skip-0.cap"); + std::fs::write(corrupt.join("recording-meta.json"), b"{ not json").unwrap(); + + scan_library(&mut skipped, &fixture.0, &parses); + assert!(!skipped.stamps.contains_key(corrupt.as_os_str())); + assert_eq!(skipped.stamps.len(), 1); + + parses.store(0, Ordering::Relaxed); + scan_library(&mut skipped, &fixture.0, &parses); + assert_eq!(parses.load(Ordering::Relaxed), 3); + assert!(!skipped.stamps.contains_key(corrupt.as_os_str())); + } + + #[test] + fn deleted_recordings_leave_the_skip_cache() { + let fixture = library_with_skipped_projects(4); + let mut skipped = SkippedMeta::default(); + let parses = AtomicUsize::new(0); + let deleted = fixture.0.join("skip-2.cap"); + scan_library(&mut skipped, &fixture.0, &parses); + assert!(skipped.stamps.contains_key(deleted.as_os_str())); + + std::fs::remove_dir_all(&deleted).unwrap(); + scan_library(&mut skipped, &fixture.0, &parses); + assert_eq!(skipped.stamps.len(), 3); + assert!(!skipped.stamps.contains_key(deleted.as_os_str())); + + std::fs::remove_dir_all(&fixture.0).unwrap(); + std::fs::create_dir_all(&fixture.0).unwrap(); + scan_library(&mut skipped, &fixture.0, &parses); + assert!(skipped.stamps.is_empty()); + } } diff --git a/apps/desktop-gpui/src/upload/queue/verify_resume_tests.rs b/apps/desktop-gpui/src/upload/queue/verify_resume_tests.rs index 5b2e8de53ca..6b073952366 100644 --- a/apps/desktop-gpui/src/upload/queue/verify_resume_tests.rs +++ b/apps/desktop-gpui/src/upload/queue/verify_resume_tests.rs @@ -126,6 +126,7 @@ fn assert_verified( #[test] fn saved_instant_upload_resumes_after_quit_mid_transfer() { let case = "queue_resume_after_quit_mid_transfer"; + let _shared_client = crate::auth::SHARED_API_CLIENT_TEST_LOCK.blocking_lock(); let scratch = Scratch::new(case); virtual_time(async { let mock = MockCap::start(vec![rule( @@ -147,6 +148,7 @@ fn saved_instant_upload_resumes_after_quit_mid_transfer() { mock.drain().await; mock.new_session(); + crate::auth::reset_shared_api_client(); let relaunched = Arc::new(Manager::new(LiveBackend, CONFIRMATION_TIMEOUT)); launch(&relaunched, &project).await; settle(&relaunched).await; @@ -178,6 +180,7 @@ fn saved_instant_upload_resumes_after_quit_mid_transfer() { #[test] fn saved_instant_upload_quit_during_verification_confirms_without_reupload() { let case = "queue_resume_after_quit_during_verification"; + let _shared_client = crate::auth::SHARED_API_CLIENT_TEST_LOCK.blocking_lock(); let scratch = Scratch::new(case); virtual_time(async { let mock = MockCap::start(vec![rule(Route::Api("recording-complete"), Fault::Hang)]).await; @@ -196,6 +199,7 @@ fn saved_instant_upload_quit_during_verification_confirms_without_reupload() { mock.drain().await; mock.new_session(); + crate::auth::reset_shared_api_client(); let relaunched = Arc::new(Manager::new(LiveBackend, CONFIRMATION_TIMEOUT)); launch(&relaunched, &project).await; settle(&relaunched).await; diff --git a/apps/desktop/src-tauri/src/editor_preparing/audio.rs b/apps/desktop/src-tauri/src/editor_preparing/audio.rs index 0a58e3d7364..a7e106ada93 100644 --- a/apps/desktop/src-tauri/src/editor_preparing/audio.rs +++ b/apps/desktop/src-tauri/src/editor_preparing/audio.rs @@ -199,6 +199,7 @@ mod tests { start_time: Some(0.0), device_id: None, gap_summary: None, + digital_silence_ms: None, }; for extension in ["m4a", "ogg"] { let relative = PathBuf::from(format!("segment-0/audio-input.{extension}")); diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs index cebe7d9c065..3b517fcd546 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -1517,6 +1517,7 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< start_time: Some(0.0), device_id: None, gap_summary: None, + digital_silence_ms: None, }) } else { None @@ -1702,6 +1703,7 @@ async fn append_mp4_to_editor_project( start_time: Some(0.0), device_id: None, gap_summary: None, + digital_silence_ms: None, }) } else { None diff --git a/apps/desktop/src-tauri/src/linux_instant_camera.rs b/apps/desktop/src-tauri/src/linux_instant_camera.rs index 56d0aaa7a4a..ff9fa79d111 100644 --- a/apps/desktop/src-tauri/src/linux_instant_camera.rs +++ b/apps/desktop/src-tauri/src/linux_instant_camera.rs @@ -462,7 +462,7 @@ mod producer { FFmpegVideoFrame, feeds::camera::{self, CameraFeed, CameraFeedLock}, instant_recording::{ - LINUX_CAMERA_MAX_MASK_AGE, LinuxCameraBlur, LinuxCameraMaskReceipt, + LINUX_CAMERA_MAX_MASK_AGE, LinuxCameraBlur, LinuxCameraMaskReceipt, LinuxCameraPixels, LinuxCameraProcessing, LinuxCameraPublisher, LinuxProcessedCameraFrame, LinuxProcessedCameraSource, }, @@ -765,7 +765,7 @@ mod producer { } } Ok(Some(LinuxProcessedCameraFrame { - bgra: Arc::from(bgra), + bgra: LinuxCameraPixels::new(bgra), dimensions: receipt.dimensions, stride: row, timestamp: receipt.timestamp, diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 757c65f87b3..55c0bd2f840 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -3422,6 +3422,10 @@ async fn start_recording_prepared( cap_recording::PipelineHealthEvent::CaptureTargetLost { target } => { Some(format!("Capture target lost: {target}")) } + cap_recording::PipelineHealthEvent::MicrophoneDigitalSilence => Some( + "Microphone is recording silence. Another app, like a call, may be using it." + .to_string(), + ), cap_recording::PipelineHealthEvent::SourceRestarted => None, }; diff --git a/apps/desktop/src-tauri/src/recording_telemetry.rs b/apps/desktop/src-tauri/src/recording_telemetry.rs index b13bcccf89f..5d3238f8762 100644 --- a/apps/desktop/src-tauri/src/recording_telemetry.rs +++ b/apps/desktop/src-tauri/src/recording_telemetry.rs @@ -139,7 +139,8 @@ impl RecordingHealthAccumulator { | PipelineHealthEvent::EncoderRebuilt { .. } | PipelineHealthEvent::SourceAudioReset { .. } | PipelineHealthEvent::RecoveryFragmentCorrupt { .. } - | PipelineHealthEvent::CaptureTargetLost { .. } => None, + | PipelineHealthEvent::CaptureTargetLost { .. } + | PipelineHealthEvent::MicrophoneDigitalSilence => None, } } diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index c8b3c478184..645385066df 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -1823,7 +1823,10 @@ export const [EditorContextProvider, useBaseEditorContext] = const visibleOrigin = origin - state.position; - const originPercentage = Math.min(1, visibleOrigin / state.zoom); + const originPercentage = Math.min( + 1, + Math.max(0, visibleOrigin / state.zoom), + ); const newVisibleOrigin = zoom * originPercentage; const newPosition = origin - newVisibleOrigin; diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 767b14c177a..65e5b101a40 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -710,7 +710,12 @@ overlap_dropped_frames: number; startup_overlap_drops: number } export type AudioInputLevelChange = number export type AudioLibraryTrack = { id: string; name: string; category: string } -export type AudioMeta = { path: string; start_time?: number | null; device_id?: string | null; gap_summary?: AudioGapSummary | null } +export type AudioMeta = { path: string; start_time?: number | null; device_id?: string | null; gap_summary?: AudioGapSummary | null; +/** + * Milliseconds the unmuted microphone delivered sustained bit-exact silence, + * e.g. while a VoIP call held the input on macOS. + */ +digital_silence_ms?: number | null } /** * A timeline-positioned audio clip (background music or imported audio). * diff --git a/apps/discord-bot/src/index.ts b/apps/discord-bot/src/index.ts index 3b2d0846a8e..1580c6183ee 100644 --- a/apps/discord-bot/src/index.ts +++ b/apps/discord-bot/src/index.ts @@ -6,6 +6,11 @@ import * as jose from "jose"; import { App } from "octokit"; import nacl from "tweetnacl"; import * as v from "valibot"; +import { + canManageGuild, + isGuildInvocation, + matchesRepositoryClaims, +} from "./release-checks"; const DISCORD_APP_ID = "1334742236096757861"; const PUBLIC_KEY = @@ -18,12 +23,16 @@ const GITHUB_APP_INSTALLATION = 63538726; const WORKFLOW_FILE = "publish.yml"; const CHANNEL_MESSAGE_WITH_SOURCE = 4; +const EPHEMERAL = 1 << 6; type InteractionBody = { id: string; token: string; channel_id: string; + guild_id?: string; + authorizing_integration_owners?: Partial>; member: { + permissions?: string; user: { id: string; username: string; @@ -59,11 +68,13 @@ type Message = { const app = new Hono<{ Bindings: Env }>(); app.post("/interactions", async (c) => { - const body = await c.req.json(); + const rawBody = await c.req.text(); - const verification = await verifyRequest(c.req.raw, body); + const verification = verifyRequest(c.req.raw, rawBody); if (!verification) return new Response("Invalid signature", { status: 401 }); + const body = JSON.parse(rawBody); + // Ping if (body.type === 1) return Response.json({ type: 1 }); @@ -107,7 +118,7 @@ const ghActionsOidc = createMiddleware<{ if (!payload.sub) return new Response("Missing sub claim", { status: 401 }); - if (!payload.sub?.includes(`${GITHUB_ORG}/${GITHUB_REPO}`)) { + if (!matchesRepositoryClaims(payload, GITHUB_ORG, GITHUB_REPO)) { return new Response("Invalid repository", { status: 401 }); } @@ -235,6 +246,25 @@ async function handleCommand(interaction: InteractionBody, env: Env) { switch (data.name) { case "release": { + if (!isGuildInvocation(interaction)) + return { + type: CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "/release can only be used in a server.", + flags: EPHEMERAL, + }, + }; + + if (!canManageGuild(interaction.member.permissions)) + return { + type: CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: + "/release requires the Manage Server or Administrator permission.", + flags: EPHEMERAL, + }, + }; + await env.release_discord_interactions.put( interaction.id, JSON.stringify(interaction), @@ -266,17 +296,16 @@ async function handleCommand(interaction: InteractionBody, env: Env) { } } -async function verifyRequest(request: Request, jsonBody: unknown) { +function verifyRequest(request: Request, rawBody: string) { const signature = request.headers.get("X-Signature-Ed25519"); - if (!signature) throw new Error("Signature header not found"); - const timestamp = request.headers.get("X-Signature-Timestamp"); - if (!timestamp) throw new Error("Timestamp header not found"); + if (!signature || !/^[0-9a-f]{128}$/i.test(signature) || !timestamp) + return false; return nacl.sign.detached.verify( - new Buffer(timestamp + JSON.stringify(jsonBody)), - new Buffer(signature, "hex"), - new Buffer(PUBLIC_KEY, "hex"), + Buffer.from(timestamp + rawBody), + Buffer.from(signature, "hex"), + Buffer.from(PUBLIC_KEY, "hex"), ); } diff --git a/apps/discord-bot/src/release-checks.test.ts b/apps/discord-bot/src/release-checks.test.ts new file mode 100644 index 00000000000..c9d68d844aa --- /dev/null +++ b/apps/discord-bot/src/release-checks.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { + canManageGuild, + isGuildInvocation, + matchesRepositoryClaims, +} from "./release-checks"; + +const GUILD_ID = "1100000000000000000"; +const USER_ID = "1200000000000000000"; + +describe("isGuildInvocation", () => { + it("accepts a guild install invocation", () => { + expect( + isGuildInvocation({ + guild_id: GUILD_ID, + member: { permissions: "8" }, + authorizing_integration_owners: { "0": GUILD_ID }, + }), + ).toBe(true); + }); + + it("accepts a guild invocation without integration owners", () => { + expect( + isGuildInvocation({ guild_id: GUILD_ID, member: { permissions: "8" } }), + ).toBe(true); + }); + + it("accepts a guild invocation authorized by both install types", () => { + expect( + isGuildInvocation({ + guild_id: GUILD_ID, + member: { permissions: "8" }, + authorizing_integration_owners: { "0": GUILD_ID, "1": USER_ID }, + }), + ).toBe(true); + }); + + it("rejects a DM invocation", () => { + expect( + isGuildInvocation({ + authorizing_integration_owners: { "1": USER_ID }, + }), + ).toBe(false); + }); + + it("rejects a bot DM invocation", () => { + expect( + isGuildInvocation({ authorizing_integration_owners: { "0": "0" } }), + ).toBe(false); + }); + + it("rejects a user install invocation inside a guild", () => { + expect( + isGuildInvocation({ + guild_id: GUILD_ID, + member: { permissions: "8" }, + authorizing_integration_owners: { "1": USER_ID }, + }), + ).toBe(false); + }); + + it("rejects a guild invocation without member data", () => { + expect(isGuildInvocation({ guild_id: GUILD_ID })).toBe(false); + }); +}); + +describe("canManageGuild", () => { + it("accepts ADMINISTRATOR", () => { + expect(canManageGuild("8")).toBe(true); + }); + + it("accepts MANAGE_GUILD", () => { + expect(canManageGuild("32")).toBe(true); + }); + + it("reads bits above 2^53 without losing precision", () => { + expect(canManageGuild(((1n << 53n) | (1n << 5n)).toString())).toBe(true); + expect(canManageGuild(((1n << 53n) | 1n).toString())).toBe(false); + }); + + it("rejects members without either permission", () => { + expect(canManageGuild("0")).toBe(false); + expect(canManageGuild(((1n << 31n) | 0b111n).toString())).toBe(false); + }); + + it("rejects missing or malformed values", () => { + expect(canManageGuild(undefined)).toBe(false); + expect(canManageGuild("")).toBe(false); + expect(canManageGuild("-8")).toBe(false); + expect(canManageGuild("0x8")).toBe(false); + expect(canManageGuild(8)).toBe(false); + }); +}); + +describe("matchesRepositoryClaims", () => { + it("accepts the exact repository and owner", () => { + expect( + matchesRepositoryClaims( + { repository: "CapSoftware/Cap", repository_owner: "CapSoftware" }, + "CapSoftware", + "Cap", + ), + ).toBe(true); + }); + + it("rejects repositories that only contain the name", () => { + for (const repository of [ + "CapSoftware/Cap-fork", + "someone/CapSoftware/Cap", + "CapSoftware/Caps", + ]) { + expect( + matchesRepositoryClaims( + { repository, repository_owner: "CapSoftware" }, + "CapSoftware", + "Cap", + ), + ).toBe(false); + } + }); + + it("rejects a different owner", () => { + expect( + matchesRepositoryClaims( + { repository: "CapSoftware/Cap", repository_owner: "someone" }, + "CapSoftware", + "Cap", + ), + ).toBe(false); + }); + + it("rejects missing claims", () => { + expect(matchesRepositoryClaims({}, "CapSoftware", "Cap")).toBe(false); + expect( + matchesRepositoryClaims( + { repository: "CapSoftware/Cap" }, + "CapSoftware", + "Cap", + ), + ).toBe(false); + }); +}); diff --git a/apps/discord-bot/src/release-checks.ts b/apps/discord-bot/src/release-checks.ts new file mode 100644 index 00000000000..e8a04b89b23 --- /dev/null +++ b/apps/discord-bot/src/release-checks.ts @@ -0,0 +1,38 @@ +const ADMINISTRATOR = 1n << 3n; +const MANAGE_GUILD = 1n << 5n; +const GUILD_INSTALL = "0"; + +export type ReleaseInvocation = { + guild_id?: string; + member?: { permissions?: string }; + authorizing_integration_owners?: Partial>; +}; + +export function isGuildInvocation(interaction: ReleaseInvocation) { + if (typeof interaction.guild_id !== "string" || !interaction.guild_id) + return false; + if (!interaction.member) return false; + + const owners = interaction.authorizing_integration_owners; + if (owners && owners[GUILD_INSTALL] !== interaction.guild_id) return false; + + return true; +} + +export function canManageGuild(permissions: unknown) { + if (typeof permissions !== "string" || !/^\d+$/.test(permissions)) + return false; + + return (BigInt(permissions) & (ADMINISTRATOR | MANAGE_GUILD)) !== 0n; +} + +export function matchesRepositoryClaims( + payload: Record, + owner: string, + repo: string, +) { + return ( + payload.repository === `${owner}/${repo}` && + payload.repository_owner === owner + ); +} diff --git a/apps/discord-bot/test/interactions.spec.ts b/apps/discord-bot/test/interactions.spec.ts new file mode 100644 index 00000000000..165c89cb5b0 --- /dev/null +++ b/apps/discord-bot/test/interactions.spec.ts @@ -0,0 +1,98 @@ +import { env } from "cloudflare:test"; +import { Buffer } from "buffer"; +import nacl from "tweetnacl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import worker from "../src/index"; + +const keyPair = nacl.sign.keyPair.fromSeed(new Uint8Array(32).fill(1)); +const verify = nacl.sign.detached.verify; + +function signedRequest(body: string) { + const timestamp = "1770000000"; + const signature = nacl.sign.detached( + Buffer.from(timestamp + body), + keyPair.secretKey, + ); + + return new Request("https://example.com/interactions", { + method: "POST", + body, + headers: { + "Content-Type": "application/json", + "X-Signature-Ed25519": Buffer.from(signature).toString("hex"), + "X-Signature-Timestamp": timestamp, + }, + }); +} + +describe("Discord interaction signatures", () => { + beforeEach(() => { + vi.spyOn(nacl.sign.detached, "verify").mockImplementation( + (message, signature) => verify(message, signature, keyPair.publicKey), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + '{"type":1}', + '{ "type": 1 }\n', + '{"type":1,"user":{"username":"caf\\u00e9"}}', + ])("acknowledges a ping signed over the exact body %s", async (body) => { + const response = await worker.fetch(signedRequest(body), env); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ type: 1 }); + }); + + it("rejects a changed body even when its parsed JSON is unchanged", async () => { + const request = new Request(signedRequest('{"type":1}'), { + body: '{ "type": 1 }', + }); + const response = await worker.fetch(request, env); + + expect(response.status).toBe(401); + }); + + it("rejects a changed timestamp", async () => { + const request = signedRequest('{"type":1}'); + request.headers.set("X-Signature-Timestamp", "1770000001"); + const response = await worker.fetch(request, env); + + expect(response.status).toBe(401); + }); + + it.each(["X-Signature-Ed25519", "X-Signature-Timestamp"])( + "rejects a missing %s header", + async (header) => { + const request = signedRequest('{"type":1}'); + request.headers.delete(header); + const response = await worker.fetch(request, env); + + expect(response.status).toBe(401); + }, + ); + + it.each(["00", "zz".repeat(64), "00".repeat(64)])( + "rejects an invalid signature %s", + async (signature) => { + const request = signedRequest('{"type":1}'); + request.headers.set("X-Signature-Ed25519", signature); + const response = await worker.fetch(request, env); + + expect(response.status).toBe(401); + }, + ); + + it("checks the signature before parsing untrusted JSON", async () => { + const request = new Request("https://example.com/interactions", { + method: "POST", + body: "not JSON", + }); + const response = await worker.fetch(request, env); + + expect(response.status).toBe(401); + }); +}); diff --git a/apps/media-server/Dockerfile b/apps/media-server/Dockerfile index c09234479ff..64b89c176ae 100644 --- a/apps/media-server/Dockerfile +++ b/apps/media-server/Dockerfile @@ -15,13 +15,17 @@ COPY apps/media-server/capcodec.md ./capcodec.md RUN bun test src/__tests__/lib/audio-quality-policy.test.ts src/__tests__/lib/audio-quality.integration.test.ts \ && bun test src/__tests__/lib/audio-quality-formats.integration.test.ts src/__tests__/lib/audio-quality-benchmark.test.ts \ && bun test src/__tests__/lib/audio-levels.integration.test.ts \ - && bun test src/__tests__/lib/drive-resumable-upload.test.ts src/__tests__/lib/storage-upload.test.ts src/__tests__/lib/container-memory.test.ts \ + && bun test src/__tests__/lib/drive-resumable-upload.test.ts src/__tests__/lib/storage-upload.test.ts src/__tests__/lib/container-memory.test.ts src/__tests__/lib/parallel-download.test.ts \ + && bun test src/__tests__/lib/loom-import-limit.test.ts \ && bun test src/__tests__/lib/media-size.test.ts src/__tests__/lib/media-probe.integration.test.ts \ && bun test src/__tests__/lib/media-audio.integration.test.ts \ && bun test src/__tests__/lib/recording-verification.integration.test.ts src/__tests__/lib/job-manager.test.ts \ - && bun test src/__tests__/lib/media-transfer.test.ts src/__tests__/lib/capcodec-encoder.test.ts \ + && bun test src/__tests__/lib/media-transfer.test.ts src/__tests__/lib/capcodec-encoder.test.ts src/__tests__/lib/nvenc-encoder.test.ts \ && bun test src/__tests__/routes/recording-verification.test.ts \ && bun test src/__tests__/routes/video.test.ts \ + && bun test src/__tests__/lib/lifecycle.test.ts src/__tests__/lib/temp-files.test.ts \ + && bun test src/__tests__/routes/lifecycle.test.ts \ + && bun test src/__tests__/routes/peer-jobs.test.ts \ && bun test src/__tests__/lib/media-routes-real-world.integration.test.ts \ && bun test src/__tests__/lib/media-video.integration.test.ts @@ -37,6 +41,6 @@ ENV PORT=3456 EXPOSE 3456 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD ["bun", "-e", "fetch(`http://127.0.0.1:${process.env.PORT || 3456}/health`).then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"] + CMD ["bun", "-e", "fetch(`http://127.0.0.1:${process.env.PORT || 3456}/health`).then(async (response) => process.exit(response.ok || (await response.json().catch(() => ({}))).status === 'draining' ? 0 : 1)).catch(() => process.exit(1))"] CMD ["bun", "run", "src/index.ts"] diff --git a/apps/media-server/src/__tests__/lib/job-manager.test.ts b/apps/media-server/src/__tests__/lib/job-manager.test.ts index ff2b43e690b..5bada4e694c 100644 --- a/apps/media-server/src/__tests__/lib/job-manager.test.ts +++ b/apps/media-server/src/__tests__/lib/job-manager.test.ts @@ -6,7 +6,9 @@ import { cleanupExpiredJobs, createJob, deleteJob, + extendJobLifetime, getJob, + getJobTotals, type JobProgress, type RecordingWorkerAcknowledgement, sendWebhook, @@ -522,3 +524,83 @@ describe("job cleanup", () => { expect(currentJob?.error).toContain("maximum lifetime of 60 minutes"); }); }); + +describe("job lifetime", () => { + test("extends the lifetime cap for long work without shortening it", () => { + const job = createTrackedJob("job-extended-lifetime"); + const now = Date.now(); + job.phase = "processing"; + job.createdAt = now - 10 * 60 * 1000; + + expect(extendJobLifetime(job.jobId, 60_000)).toBe( + job.createdAt + 60 * 60 * 1000, + ); + const deadline = extendJobLifetime(job.jobId, 2 * 60 * 60 * 1000); + expect(deadline).toBeGreaterThanOrEqual(now + 2 * 60 * 60 * 1000); + expect(extendJobLifetime(job.jobId, 60_000)).toBe(deadline); + + job.createdAt = now - 90 * 60 * 1000; + job.updatedAt = Date.now(); + expect(cleanupExpiredJobs()).toBe(0); + expect(getJob(job.jobId)?.phase).toBe("processing"); + }); + + test("keeps a hard upper bound on extended lifetimes", () => { + const job = createTrackedJob("job-capped-lifetime"); + job.phase = "uploading"; + job.createdAt = Date.now() - 10 * 60 * 1000; + + expect(extendJobLifetime(job.jobId, 24 * 60 * 60 * 1000)).toBe( + job.createdAt + 4 * 60 * 60 * 1000, + ); + expect(extendJobLifetime(job.jobId, Number.NaN)).toBeUndefined(); + expect(extendJobLifetime(job.jobId, -1)).toBeUndefined(); + }); + + test("expires a job past its extended deadline", () => { + const job = createTrackedJob("job-past-extended-lifetime"); + const abortController = new AbortController(); + updateJob(job.jobId, { phase: "processing", abortController }); + job.createdAt = Date.now() - 10 * 60 * 1000; + extendJobLifetime(job.jobId, 2 * 60 * 60 * 1000); + job.createdAt -= 3 * 60 * 60 * 1000; + job.lifetimeDeadlineAt = Date.now() - 1; + job.updatedAt = Date.now(); + + expect(cleanupExpiredJobs()).toBe(1); + expect(job.phase).toBe("error"); + expect(job.error).toContain("maximum lifetime"); + expect(abortController.signal.aborted).toBe(true); + }); + + test("does not extend terminal jobs", () => { + const job = createTrackedJob("job-terminal-lifetime"); + updateJob(job.jobId, { phase: "complete" }); + expect(extendJobLifetime(job.jobId, 2 * 60 * 60 * 1000)).toBeUndefined(); + expect(job.lifetimeDeadlineAt).toBeUndefined(); + }); +}); + +describe("job totals", () => { + test("counts created, terminal, and expired jobs", () => { + const before = getJobTotals(); + const completed = createTrackedJob("job-total-complete"); + const errored = createTrackedJob("job-total-error"); + const cancelled = createTrackedJob("job-total-cancelled"); + const stale = createTrackedJob("job-total-stale"); + updateJob(completed.jobId, { phase: "complete" }); + updateJob(completed.jobId, { phase: "error" }); + updateJob(errored.jobId, { phase: "error" }); + updateJob(cancelled.jobId, { phase: "cancelled" }); + updateJob(stale.jobId, { phase: "processing" }); + stale.updatedAt = Date.now() - 16 * 60 * 1000; + cleanupExpiredJobs(); + + const after = getJobTotals(); + expect(after.created - before.created).toBe(4); + expect(after.completed - before.completed).toBe(1); + expect(after.errored - before.errored).toBe(2); + expect(after.cancelled - before.cancelled).toBe(1); + expect(after.expired - before.expired).toBe(1); + }); +}); diff --git a/apps/media-server/src/__tests__/lib/lifecycle.test.ts b/apps/media-server/src/__tests__/lib/lifecycle.test.ts new file mode 100644 index 00000000000..bf1aa4f7eba --- /dev/null +++ b/apps/media-server/src/__tests__/lib/lifecycle.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + beginRequest, + createShutdownHandler, + getDrainBlockers, + getIdleTimeoutSeconds, + getShutdownGraceMs, + isDraining, + isIdle, + restoreIdleTimeoutOnFirstChunk, + setDraining, + setRequestIdleTimeout, + trackBackgroundTask, + waitForDrain, +} from "../../lib/lifecycle"; + +const originalGrace = process.env.MEDIA_SERVER_SHUTDOWN_GRACE_MS; +const originalIdle = process.env.MEDIA_SERVER_IDLE_TIMEOUT_SECONDS; + +function deferred() { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +afterEach(() => { + setDraining(false); + if (originalGrace === undefined) + delete process.env.MEDIA_SERVER_SHUTDOWN_GRACE_MS; + else process.env.MEDIA_SERVER_SHUTDOWN_GRACE_MS = originalGrace; + if (originalIdle === undefined) + delete process.env.MEDIA_SERVER_IDLE_TIMEOUT_SECONDS; + else process.env.MEDIA_SERVER_IDLE_TIMEOUT_SECONDS = originalIdle; +}); + +describe("shutdown configuration", () => { + test("defaults to the immediate shutdown path", () => { + delete process.env.MEDIA_SERVER_SHUTDOWN_GRACE_MS; + expect(getShutdownGraceMs()).toBe(0); + process.env.MEDIA_SERVER_SHUTDOWN_GRACE_MS = "not-a-number"; + expect(getShutdownGraceMs()).toBe(0); + process.env.MEDIA_SERVER_SHUTDOWN_GRACE_MS = "120000"; + expect(getShutdownGraceMs()).toBe(120_000); + }); + + test("bounds the idle timeout to Bun's maximum", () => { + delete process.env.MEDIA_SERVER_IDLE_TIMEOUT_SECONDS; + expect(getIdleTimeoutSeconds()).toBe(255); + process.env.MEDIA_SERVER_IDLE_TIMEOUT_SECONDS = "90"; + expect(getIdleTimeoutSeconds()).toBe(90); + process.env.MEDIA_SERVER_IDLE_TIMEOUT_SECONDS = "10000"; + expect(getIdleTimeoutSeconds()).toBe(255); + }); + + test("ignores servers without per-request timeouts", () => { + const calls: Array<[Request, number]> = []; + const request = new Request("http://localhost/video/convert"); + setRequestIdleTimeout(undefined, request, 0); + setRequestIdleTimeout( + { + timeout: (req: Request, seconds: number) => calls.push([req, seconds]), + }, + request, + 0, + ); + expect(calls).toEqual([[request, 0]]); + }); +}); + +describe("drain", () => { + test("waits for background work and in-flight requests", async () => { + const work = deferred(); + const task = trackBackgroundTask(work.promise); + const endRequest = beginRequest(); + expect(isIdle()).toBe(false); + expect(getDrainBlockers().backgroundTasks).toBeGreaterThanOrEqual(1); + + const drained = waitForDrain(5_000); + await Bun.sleep(50); + work.resolve(); + await task; + await Bun.sleep(50); + endRequest(); + + expect(await drained).toBe(true); + expect(isIdle()).toBe(true); + }); + + test("gives up when the grace period elapses", async () => { + const work = deferred(); + const task = trackBackgroundTask(work.promise); + const startedAt = Date.now(); + + expect(await waitForDrain(300)).toBe(false); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(250); + + work.resolve(); + await task; + }); + + test("stops tracking rejected background work", async () => { + const failure = trackBackgroundTask(Promise.reject(new Error("failed"))); + await failure.catch(() => {}); + await Bun.sleep(0); + expect(await waitForDrain(1_000)).toBe(true); + }); +}); + +describe("shutdown handler", () => { + test("keeps today's immediate shutdown when no grace period is set", async () => { + const work = deferred(); + const task = trackBackgroundTask(work.promise); + let finalized = 0; + const shutdown = createShutdownHandler( + async () => { + finalized++; + }, + () => 0, + ); + + await shutdown("SIGTERM"); + await shutdown("SIGTERM"); + + expect(finalized).toBe(1); + expect(isDraining()).toBe(false); + work.resolve(); + await task; + }); + + test("drains before finalizing when a grace period is set", async () => { + const work = deferred(); + const task = trackBackgroundTask(work.promise); + let finalizedWhileBusy: boolean | undefined; + const shutdown = createShutdownHandler( + async () => { + finalizedWhileBusy = !isIdle(); + }, + () => 10_000, + ); + + const shuttingDown = shutdown("SIGTERM"); + await Bun.sleep(50); + expect(isDraining()).toBe(true); + expect(finalizedWhileBusy).toBeUndefined(); + + work.resolve(); + await task; + await shuttingDown; + expect(finalizedWhileBusy).toBe(false); + }); + + test("forces the immediate path on a second signal", async () => { + const work = deferred(); + const task = trackBackgroundTask(work.promise); + let finalized = 0; + const shutdown = createShutdownHandler( + async () => { + finalized++; + }, + () => 60_000, + ); + + const shuttingDown = shutdown("SIGTERM"); + await Bun.sleep(50); + expect(finalized).toBe(0); + await shutdown("SIGINT"); + await shuttingDown; + expect(finalized).toBe(1); + + work.resolve(); + await task; + }); +}); + +describe("streamed response idle timeout", () => { + test("comes back once, on the first chunk, and cancel still reaches the source", async () => { + let restores = 0; + let cancelled = false; + let push: ReadableStreamDefaultController | undefined; + const source = new ReadableStream({ + start(controller) { + push = controller; + }, + cancel() { + cancelled = true; + }, + }); + const reader = restoreIdleTimeoutOnFirstChunk(source, () => { + restores++; + }).getReader(); + + const first = reader.read(); + await Bun.sleep(10); + expect(restores).toBe(0); + + push?.enqueue(new Uint8Array([1])); + expect((await first).value).toEqual(new Uint8Array([1])); + push?.enqueue(new Uint8Array([2])); + await reader.read(); + expect(restores).toBe(1); + + await reader.cancel(); + expect(cancelled).toBe(true); + }); +}); diff --git a/apps/media-server/src/__tests__/lib/loom-import-limit.test.ts b/apps/media-server/src/__tests__/lib/loom-import-limit.test.ts new file mode 100644 index 00000000000..cb5aa9a293b --- /dev/null +++ b/apps/media-server/src/__tests__/lib/loom-import-limit.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + downloadVideoToTemp, + LOOM_IMPORT_MAX_BYTES, +} from "../../lib/media-video"; + +const originalFetch = globalThis.fetch; +const originalDownloadConcurrency = + process.env.MEDIA_SERVER_DOWNLOAD_CONCURRENCY; + +function stubFetch(response: () => Promise) { + globalThis.fetch = Object.assign(response, { + preconnect: originalFetch.preconnect, + }); +} + +describe("Loom import file size", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + if (originalDownloadConcurrency === undefined) { + delete process.env.MEDIA_SERVER_DOWNLOAD_CONCURRENCY; + } else { + process.env.MEDIA_SERVER_DOWNLOAD_CONCURRENCY = + originalDownloadConcurrency; + } + }); + + test("permits exactly the configured boundary", async () => { + expect(LOOM_IMPORT_MAX_BYTES).toBe(10 * 1024 ** 3); + stubFetch(async () => new Response(new Uint8Array(16))); + const result = await downloadVideoToTemp( + "https://source.test/video.mp4", + ".mp4", + undefined, + 16, + ); + try { + expect(Bun.file(result.path).size).toBe(16); + } finally { + await result.cleanup(); + } + }); + + test("rejects an oversized declared body before reading it", async () => { + let cancelled = false; + stubFetch( + async () => + new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { headers: { "Content-Length": "17" } }, + ), + ); + await expect( + downloadVideoToTemp( + "https://source.test/video.mp4", + ".mp4", + undefined, + 16, + ), + ).rejects.toThrow("10 GB"); + expect(cancelled).toBe(true); + }); + + test.each([ + ["bounded Loom downloads", 16, 30 * 60 * 1000], + ["ordinary downloads", undefined, 10 * 60 * 1000], + ])("keeps the expected timeout for %s", async (_, maxBytes, timeoutMs) => { + const timeoutSpy = spyOn(AbortSignal, "timeout"); + stubFetch(async () => new Response(new Uint8Array(16))); + try { + const result = await downloadVideoToTemp( + "https://source.test/video.mp4", + ".mp4", + undefined, + maxBytes, + ); + try { + expect(timeoutSpy).toHaveBeenCalledWith(timeoutMs); + } finally { + await result.cleanup(); + } + } finally { + timeoutSpy.mockRestore(); + } + }); + + test("enforces the import limit when parallel downloads are enabled", async () => { + process.env.MEDIA_SERVER_DOWNLOAD_CONCURRENCY = "4"; + stubFetch(async () => new Response(new Uint8Array(17))); + await expect( + downloadVideoToTemp( + "https://source.test/video.mp4", + ".mp4", + undefined, + 16, + ), + ).rejects.toThrow("10 GB"); + }); + + test("cancels a chunked transfer when it exceeds the limit", async () => { + let cancelled = false; + stubFetch( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(8)); + controller.enqueue(new Uint8Array(9)); + }, + cancel() { + cancelled = true; + }, + }), + ), + ); + await expect( + downloadVideoToTemp( + "https://source.test/video.mp4", + ".mp4", + undefined, + 16, + ), + ).rejects.toThrow("10 GB"); + expect(cancelled).toBe(true); + }); + + test("rejects an oversized real HLS recording without returning truncated media", async () => { + const directory = await mkdtemp(join(tmpdir(), "cap-loom-limit-")); + let server: ReturnType | undefined; + try { + execFileSync("ffmpeg", [ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=size=64x64:rate=10", + "-t", + "2", + "-c:v", + "libx264", + "-g", + "10", + "-f", + "hls", + "-hls_time", + "1", + "-hls_segment_filename", + join(directory, "segment%d.ts"), + join(directory, "index.m3u8"), + ]); + globalThis.fetch = originalFetch; + server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname; + return /^\/(?:index\.m3u8|segment\d+\.ts)$/.test(path) + ? new Response(Bun.file(join(directory, path.slice(1)))) + : new Response(null, { status: 404 }); + }, + }); + const source = `${server.url}index.m3u8`; + const complete = await downloadVideoToTemp( + source, + ".m3u8", + undefined, + 1024 ** 2, + ); + try { + expect(Bun.file(complete.path).size).toBeGreaterThan(1); + } finally { + await complete.cleanup(); + } + await expect( + downloadVideoToTemp(source, ".m3u8", undefined, 1), + ).rejects.toThrow("10 GB"); + } finally { + server?.stop(true); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/media-server/src/__tests__/lib/media-audio.integration.test.ts b/apps/media-server/src/__tests__/lib/media-audio.integration.test.ts index 826d49234f2..44cd8698342 100644 --- a/apps/media-server/src/__tests__/lib/media-audio.integration.test.ts +++ b/apps/media-server/src/__tests__/lib/media-audio.integration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,6 +8,7 @@ import { extractAudioStream, getActiveProcessCount, } from "../../lib/media-audio"; +import * as subprocess from "../../lib/subprocess"; const FIXTURES_DIR = join(import.meta.dir, "..", "fixtures"); const TEST_VIDEO_WITH_AUDIO = `file://${join(FIXTURES_DIR, "test-with-audio.mp4")}`; @@ -223,6 +224,69 @@ describe("mediaAudio integration tests", () => { }); describe("extractAudioStream", () => { + test("preserves queued audio when the reader pauses before EOF", async () => { + const expected = await extractAudio(TEST_VIDEO_WITH_AUDIO); + const { stream, cleanup } = extractAudioStream(TEST_VIDEO_WITH_AUDIO); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + await Bun.sleep(50); + } + expect(Buffer.concat(chunks).equals(expected)).toBe(true); + } finally { + reader.releaseLock(); + cleanup(); + } + }); + + test("drains all encoded bytes when the consumer waits for FFmpeg to exit", async () => { + const expected = await extractAudio(TEST_VIDEO_WITH_AUDIO); + const registration = spyOn(subprocess, "registerSubprocess"); + const { stream, cleanup } = extractAudioStream(TEST_VIDEO_WITH_AUDIO); + try { + const proc = registration.mock.calls[0][0]; + expect(await proc.exited).toBe(0); + const actual = await readStream(stream); + expect(actual.byteLength).toBe(expected.byteLength); + expect(Buffer.from(actual).equals(expected)).toBe(true); + } finally { + cleanup(); + registration.mockRestore(); + } + }); + + test("errors the stream when FFmpeg cannot extract audio", async () => { + const beforeCount = getActiveProcessCount(); + const { stream, cleanup } = extractAudioStream(TEST_VIDEO_NO_AUDIO); + try { + await expect(readStream(stream)).rejects.toThrow("FFmpeg exited"); + await waitForAudioOperations(beforeCount); + } finally { + cleanup(); + } + }); + + test("errors the stream and terminates FFmpeg at the deadline", async () => { + const beforeCount = getActiveProcessCount(); + const registration = spyOn(subprocess, "registerSubprocess"); + const { stream, cleanup } = extractAudioStream(TEST_VIDEO_WITH_AUDIO, { + timeoutMs: 1, + }); + try { + await expect(readStream(stream)).rejects.toThrow("timed out"); + const proc = registration.mock.calls[0][0]; + expect(await proc.exited).not.toBe(0); + await waitForAudioOperations(beforeCount); + } finally { + cleanup(); + registration.mockRestore(); + } + }); + test("streams real mp3 data and cleans up its operation", async () => { const beforeCount = getActiveProcessCount(); const { stream } = extractAudioStream(TEST_VIDEO_WITH_AUDIO); diff --git a/apps/media-server/src/__tests__/lib/media-routes-real-world.integration.test.ts b/apps/media-server/src/__tests__/lib/media-routes-real-world.integration.test.ts index 1c7a4cc54eb..f59eb5b5875 100644 --- a/apps/media-server/src/__tests__/lib/media-routes-real-world.integration.test.ts +++ b/apps/media-server/src/__tests__/lib/media-routes-real-world.integration.test.ts @@ -687,6 +687,58 @@ describe("media routes real-world integration tests", () => { } }, 30_000); + test("uploads mux previews only after the recording output is verified", async () => { + const body = { + ...fencedMuxRequest("mux-previews"), + thumbnailPresignedUrl: uploadUrl("mux-previews.jpg"), + previewGifPresignedUrl: uploadUrl("mux-previews.gif"), + }; + const response = await app.fetch( + mediaPostRequest("/video/mux-segments", body), + ); + expect(response.status).toBe(200); + const { jobId } = (await response.json()) as { jobId: string }; + try { + const job = await waitForTerminalJob(jobId); + expect(job.phase).toBe("complete"); + expect(uploadRequests[0]).toBe("/uploads/mux-previews.mp4"); + expect([...uploadRequests].sort()).toEqual([ + "/uploads/mux-previews.gif", + "/uploads/mux-previews.jpg", + "/uploads/mux-previews.mp4", + ]); + expect( + uploadedBytes("/uploads/mux-previews.jpg").byteLength, + ).toBeGreaterThan(0); + expect( + uploadedBytes("/uploads/mux-previews.gif").byteLength, + ).toBeGreaterThan(0); + } finally { + deleteJob(jobId); + } + }, 30_000); + + test("skips mux previews when the recording output upload fails", async () => { + uploadFailures.set("/uploads/mux-previews-failed.mp4", 403); + const body = { + ...fencedMuxRequest("mux-previews-failed"), + thumbnailPresignedUrl: uploadUrl("mux-previews-failed.jpg"), + previewGifPresignedUrl: uploadUrl("mux-previews-failed.gif"), + }; + const response = await app.fetch( + mediaPostRequest("/video/mux-segments", body), + ); + expect(response.status).toBe(200); + const { jobId } = (await response.json()) as { jobId: string }; + try { + const job = await waitForTerminalJob(jobId); + expect(job.phase).toBe("error"); + expect(uploadRequests).toEqual(["/uploads/mux-previews-failed.mp4"]); + } finally { + deleteJob(jobId); + } + }, 30_000); + test("runs optional audio correction on the local verified file after publication and retains success if it fails", async () => { let observed: () => void = () => {}; const observations: Record[] = []; @@ -930,6 +982,28 @@ describe("media routes real-world integration tests", () => { } }, 30_000); + test("skips mux previews when the uploaded recording fails its remote check", async () => { + corruptRecordingReadback = true; + const body = { + ...fencedMuxRequest("mux-previews-corrupt"), + thumbnailPresignedUrl: uploadUrl("mux-previews-corrupt.jpg"), + previewGifPresignedUrl: uploadUrl("mux-previews-corrupt.gif"), + }; + const response = await app.fetch( + mediaPostRequest("/video/mux-segments", body), + ); + expect(response.status).toBe(200); + const { jobId } = (await response.json()) as { jobId: string }; + try { + const job = await waitForTerminalJob(jobId); + expect(job.phase).toBe("error"); + expect(job.errorCode).toBe("output-invalid"); + expect(uploadRequests).toEqual(["/uploads/mux-previews-corrupt.mp4"]); + } finally { + deleteJob(jobId); + } + }, 30_000); + test("does not overwrite an existing immutable candidate", async () => { const original = new Uint8Array([1, 2, 3]); uploadedArtifacts.set("/uploads/fenced-existing.mp4", original); @@ -1163,6 +1237,81 @@ describe("media routes real-world integration tests", () => { 30_000, ); + test("recovers a saved output after preview failure without overwriting it", async () => { + const thumbnail = spyOn(mediaVideo, "generateThumbnail").mockRejectedValue( + new Error("Thumbnail decode interrupted"), + ); + const jobs: string[] = []; + try { + const body = { + videoId: "saved-loom-output", + userId: "real-process-user", + videoUrl: fixtureUrl(), + outputPresignedUrl: uploadUrl("saved-output.mp4"), + thumbnailPresignedUrl: uploadUrl("saved-output.jpg"), + inputExtension: ".mp4", + maxImportBytes: 10 * 1024 ** 3, + }; + const first = await app.fetch(mediaPostRequest("/video/process", body)); + const firstJobId = ((await first.json()) as { jobId: string }).jobId; + jobs.push(firstJobId); + expect((await waitForTerminalJob(firstJobId)).phase).toBe("error"); + const original = Uint8Array.from( + uploadedBytes("/uploads/saved-output.mp4"), + ); + const second = await app.fetch( + mediaPostRequest("/video/process", { + ...body, + existingOutput: { + url: uploadUrl("saved-output.mp4"), + objectIdentity: objectIdentity(original), + fileSize: original.byteLength, + }, + }), + ); + expect(second.status).toBe(200); + const secondJobId = ((await second.json()) as { jobId: string }).jobId; + jobs.push(secondJobId); + expect((await waitForTerminalJob(secondJobId)).phase).toBe("complete"); + expect(uploadRequests.filter((path) => path.endsWith(".mp4"))).toEqual([ + "/uploads/saved-output.mp4", + ]); + expect(uploadedBytes("/uploads/saved-output.mp4")).toEqual(original); + } finally { + thumbnail.mockRestore(); + for (const jobId of jobs) deleteJob(jobId); + } + }, 30_000); + + test("holds a saved output whose storage identity changed without overwriting it", async () => { + const original = new Uint8Array( + await Bun.file(TEST_VIDEO_WITH_AUDIO).arrayBuffer(), + ); + uploadedArtifacts.set("/uploads/changed-output.mp4", original); + const response = await app.fetch( + mediaPostRequest("/video/process", { + videoId: "changed-output", + userId: "real-process-user", + videoUrl: fixtureUrl(), + outputPresignedUrl: uploadUrl("changed-output.mp4"), + maxImportBytes: 10 * 1024 ** 3, + existingOutput: { + url: uploadUrl("changed-output.mp4"), + objectIdentity: '"previous-identity"', + fileSize: original.byteLength, + }, + }), + ); + const { jobId } = (await response.json()) as { jobId: string }; + try { + expect((await waitForTerminalJob(jobId)).phase).toBe("error"); + expect(uploadRequests).toHaveLength(0); + expect(uploadedBytes("/uploads/changed-output.mp4")).toEqual(original); + } finally { + deleteJob(jobId); + } + }, 30_000); + test("still fails processing when the video output cannot be uploaded", async () => { uploadFailures.set("/uploads/failed-output.mp4", 403); const response = await app.fetch( diff --git a/apps/media-server/src/__tests__/lib/nvenc-encoder.test.ts b/apps/media-server/src/__tests__/lib/nvenc-encoder.test.ts new file mode 100644 index 00000000000..5892b61818c --- /dev/null +++ b/apps/media-server/src/__tests__/lib/nvenc-encoder.test.ts @@ -0,0 +1,790 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { FfmpegExitError, ffmpegErrorLines } from "../../lib/ffmpeg-errors"; +import type { VideoMetadata } from "../../lib/job-manager"; +import { decodeThreadsFromEnv } from "../../lib/media-common"; +import { + buildTranscodeEditArgs, + renderEditedVideo, +} from "../../lib/media-edit"; +import { probeVideo } from "../../lib/media-probe"; +import { + buildProcessVideoArgs, + type ProcessVideoArgsInput, + pickMobileSafeH264Level, + processVideo, +} from "../../lib/media-video"; +import { + buildNvencEncodeArgs, + canDecodeOnGpu, + encodeAttemptsBudgetMs, + fitWithinEven, + getVideoEncoderStatus, + type NvencOptions, + nvencCq, + nvencFallbackReason, + nvencOptionsFromEnv, + recordNvencAttempt, + selectedVideoEncoder, +} from "../../lib/nvenc-encoder"; + +const enabled = { CAP_MEDIA_VIDEO_ENCODER: "nvenc" }; +const defaults: NvencOptions = { + preset: "p1", + cqOffset: 8, + lookahead: 0, + spatialAq: true, + temporalAq: false, + gpuDecodeCodecs: ["hevc", "av1"], +}; +const opts: ProcessVideoArgsInput["opts"] = { + maxWidth: 1920, + maxHeight: 1080, + preset: "medium", + crf: 23, + audioBitrate: "128k", +}; +const nvencVideoArgs = [ + "-c:v", + "h264_nvenc", + "-preset", + "p1", + "-tune", + "hq", + "-rc", + "vbr", + "-cq", + "31", + "-b:v", + "0", + "-spatial-aq", + "1", + "-profile:v", + "high", + "-g", + "250", +]; +const gpuScale = "scale_cuda=format=nv12"; +const cpuScale = + "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2"; + +function metadata( + width: number, + height: number, + videoCodec: string, +): VideoMetadata { + return { + duration: 10, + width, + height, + fps: 30, + videoCodec, + audioCodec: "aac", + audioChannels: 2, + sampleRate: 48_000, + bitrate: 0, + fileSize: 0, + }; +} + +function processArgs( + source: VideoMetadata, + overrides: Partial = {}, +): ProcessVideoArgsInput { + return { + inputPath: "in.mp4", + outputPath: "out.mp4", + videoCodec: source.videoCodec, + hasAudio: true, + opts, + targetH264Level: pickMobileSafeH264Level(source, opts).ffmpegValue, + normalizeH264Level: false, + videoTranscode: true, + audioTranscode: false, + extraInputArgs: [], + extraOutputArgs: [], + decodeThreads: "2", + sourceWidth: source.width, + sourceHeight: source.height, + ...overrides, + }; +} + +const outputTail = [ + "-c:a", + "copy", + "-movflags", + "+faststart", + "-progress", + "pipe:2", + "-y", + "out.mp4", +]; + +describe("nvenc encoder selection", () => { + test("is off unless CAP_MEDIA_VIDEO_ENCODER is nvenc", () => { + expect(selectedVideoEncoder({})).toBe("libx264"); + expect(selectedVideoEncoder({ CAP_MEDIA_VIDEO_ENCODER: "x264" })).toBe( + "libx264", + ); + expect(selectedVideoEncoder({ CAP_MEDIA_VIDEO_ENCODER: "capcodec" })).toBe( + "capcodec", + ); + expect(selectedVideoEncoder(enabled)).toBe("nvenc"); + expect(nvencOptionsFromEnv({})).toBeNull(); + expect( + nvencOptionsFromEnv({ CAP_MEDIA_VIDEO_ENCODER: "capcodec" }), + ).toBeNull(); + expect(getVideoEncoderStatus({})).toEqual({ + active: "libx264", + nvenc: null, + }); + expect(getVideoEncoderStatus(enabled, false)).toEqual({ + active: "nvenc", + nvenc: null, + }); + }); + + test("uses p1, cq = crf + 8, no lookahead and spatial AQ by default", () => { + expect(nvencOptionsFromEnv(enabled)).toEqual(defaults); + }); + + test("reads tuning overrides from the environment", () => { + expect( + nvencOptionsFromEnv({ + ...enabled, + CAP_NVENC_PRESET: " P7 ", + CAP_NVENC_CQ_OFFSET: "-3", + CAP_NVENC_LOOKAHEAD: "0", + CAP_NVENC_SPATIAL_AQ: "off", + CAP_NVENC_TEMPORAL_AQ: "1", + }), + ).toEqual({ + preset: "p7", + cqOffset: -3, + lookahead: 0, + spatialAq: false, + temporalAq: true, + gpuDecodeCodecs: ["hevc", "av1"], + }); + expect( + nvencOptionsFromEnv({ + ...enabled, + CAP_NVENC_GPU_DECODE_CODECS: "h264, HEVC,vp8,nope", + })?.gpuDecodeCodecs, + ).toEqual(["h264", "hevc"]); + }); + + test("ignores invalid tuning overrides", () => { + expect( + nvencOptionsFromEnv({ + ...enabled, + CAP_NVENC_PRESET: "slow", + CAP_NVENC_CQ_OFFSET: "2.5", + CAP_NVENC_LOOKAHEAD: "64", + CAP_NVENC_SPATIAL_AQ: "maybe", + CAP_NVENC_TEMPORAL_AQ: "", + }), + ).toEqual(defaults); + }); + + test("maps crf to a bounded cq", () => { + expect(nvencCq(23, 2)).toBe("25"); + expect(nvencCq(18, 2)).toBe("20"); + expect(nvencCq(20.5, 0)).toBe("20.5"); + expect(nvencCq(50, 5)).toBe("51"); + expect(nvencCq(0, -4)).toBe("1"); + expect(nvencCq(Number.NaN, 2)).toBe("25"); + }); + + test("builds constant-quality VBR encoder args", () => { + expect(buildNvencEncodeArgs(defaults, 23)).toEqual(nvencVideoArgs); + expect( + buildNvencEncodeArgs( + { ...defaults, lookahead: 0, spatialAq: false, temporalAq: true }, + 18, + ), + ).toEqual([ + "-c:v", + "h264_nvenc", + "-preset", + "p1", + "-tune", + "hq", + "-rc", + "vbr", + "-cq", + "26", + "-b:v", + "0", + "-temporal-aq", + "1", + "-profile:v", + "high", + "-g", + "250", + ]); + }); + + test("fits the GPU resize to the CPU path's box and even dimensions", () => { + expect(fitWithinEven(3840, 2160, 1920, 1080)).toEqual({ + width: 1920, + height: 1080, + }); + expect(fitWithinEven(3024, 1964, 1920, 1080)).toEqual({ + width: 1662, + height: 1080, + }); + expect(fitWithinEven(1079, 1919, 1920, 1080)).toEqual({ + width: 606, + height: 1080, + }); + expect(fitWithinEven(1281, 721, 1920, 1080)).toEqual({ + width: 1280, + height: 720, + }); + }); + + test("decodes on the GPU only for configured, unrotated codecs", () => { + for (const codec of ["hevc", "av1"]) { + expect(canDecodeOnGpu(codec, 0)).toBe(true); + } + expect(canDecodeOnGpu("h264", 0)).toBe(false); + expect(canDecodeOnGpu("h264", 0, ["h264"])).toBe(true); + expect(canDecodeOnGpu("vp8", 0)).toBe(false); + expect(canDecodeOnGpu("prores", 0)).toBe(false); + expect(canDecodeOnGpu("hevc", 90)).toBe(false); + expect(canDecodeOnGpu("h264", null)).toBe(false); + }); +}); + +describe("nvenc fallback", () => { + test("reserves job time for the libx264 retry only when nvenc is on", () => { + expect(encodeAttemptsBudgetMs(60_000, {})).toBe(60_000); + expect(encodeAttemptsBudgetMs(60_000, enabled)).toBe(120_000); + }); + + test("falls back to libx264 on any failure that was not a cancellation", () => { + expect( + nvencFallbackReason( + new Error("FFmpeg exited with code 1. Last stderr: No device"), + false, + ), + ).toBe("FFmpeg exited with code 1. Last stderr: No device"); + expect(nvencFallbackReason("boom", false)).toBe("boom"); + expect( + nvencFallbackReason( + new Error( + "https://bucket.s3.amazonaws.com/u/v/raw.mp4?X-Amz-Signature=abc: Server returned 403", + ), + false, + ), + ).toBe(" Server returned 403"); + expect(nvencFallbackReason(new Error(""), false)).toBe("unknown error"); + expect( + nvencFallbackReason(new Error("x".repeat(2000)), false), + ).toHaveLength(500); + expect(nvencFallbackReason(new Error("killed"), true)).toBeNull(); + }); + + test("leads with the encoder error printed before the progress tail", () => { + const progress = [ + "frame=0", + "fps=0.00", + "stream_0_0_q=0.0", + "bitrate=N/A", + "total_size=0", + "out_time_us=N/A", + "out_time_ms=N/A", + "out_time=N/A", + "dup_frames=0", + "drop_frames=0", + "speed=N/A", + "progress=end", + ]; + const stderr = [ + "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'https://bucket.s3.us-east-1.amazonaws.com/user-123/video-456/raw.mp4?X-Amz-Credential=AKIAEXAMPLE%2F20261023&X-Amz-Signature=deadbeef':", + "[h264_nvenc @ 0x5581c2a0] InitializeEncoder failed: invalid param (8): Invalid Level.", + "[vost#0:0/h264_nvenc @ 0x5581c3b0] Error while opening encoder - maybe incorrect parameters such as bit_rate, rate, width or height.", + "Error opening output file /tmp/cap-media-server/instance-1/user-123-out.mp4.", + ...progress, + "Conversion failed!", + ]; + const reason = nvencFallbackReason( + new FfmpegExitError( + `FFmpeg exited with code 1. Last stderr: ${stderr.slice(-10).join(" | ")}`, + ffmpegErrorLines(stderr.join("\n")), + ), + false, + ); + expect(reason).toStartWith( + "[h264_nvenc @ 0x5581c2a0] InitializeEncoder failed: invalid param (8): Invalid Level.", + ); + expect(reason).toContain("Error while opening encoder"); + expect(reason).toContain("Error opening output file "); + expect(reason).toContain("Conversion failed!"); + expect(reason).not.toContain("amazonaws"); + expect(reason).not.toContain("X-Amz"); + expect(reason).not.toContain("user-123"); + expect(reason?.length).toBeLessThanOrEqual(500); + expect( + ffmpegErrorLines( + "Reconfiguring filter graph because video parameters changed to yuv420p(tv), 1280x720\r\nprogress=continue\nerror_rate=0\n", + ), + ).toEqual([ + "Reconfiguring filter graph because video parameters changed to yuv420p(tv), 1280x720", + ]); + expect( + ffmpegErrorLines( + [ + "[https @ 0x1] HTTP error 403 Forbidden", + `[in#0 @ 0x2] Error opening input: Server returned 403 Forbidden (access denied)`, + ].join("\n"), + ), + ).toHaveLength(2); + expect( + nvencFallbackReason( + new FfmpegExitError( + "FFmpeg exited with code 1. Last stderr: Conversion failed!", + [ + "Error opening input file https://bucket.example/raw.mp4?X-Amz-Signature=secret", + ], + ), + false, + ), + ).toBe( + "Error opening input file || FFmpeg exited with code 1. Last stderr: Conversion failed!", + ); + }); + + test("reports the last attempt through the encoder status", () => { + const before = getVideoEncoderStatus(enabled).nvenc; + recordNvencAttempt("process", "No NVENC capable devices found"); + const fellBack = getVideoEncoderStatus(enabled).nvenc; + expect(fellBack?.attempts).toBe((before?.attempts ?? 0) + 1); + expect(fellBack?.fallbacks).toBe((before?.fallbacks ?? 0) + 1); + expect(fellBack?.lastAttempt).toMatchObject({ + job: "process", + fellBack: true, + reason: "No NVENC capable devices found", + }); + recordNvencAttempt("edit"); + const succeeded = getVideoEncoderStatus(enabled).nvenc; + expect(succeeded?.fallbacks).toBe(fellBack?.fallbacks); + expect(succeeded?.lastAttempt).toMatchObject({ + job: "edit", + fellBack: false, + reason: null, + }); + }); +}); + +describe("process video args", () => { + test("keeps the default libx264 args unchanged", () => { + const source = metadata(3024, 1964, "h264"); + expect(buildProcessVideoArgs(processArgs(source))).toEqual([ + "ffmpeg", + "-threads", + "2", + "-i", + "in.mp4", + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + "23", + "-vf", + cpuScale, + "-pix_fmt", + "yuv420p", + "-level:v", + "4.2", + ...outputTail, + ]); + }); + + test("runs a 3024x1964 H.264 source fully on the GPU", () => { + const source = metadata(3024, 1964, "h264"); + expect( + buildProcessVideoArgs( + processArgs(source, { + nvenc: { options: defaults, gpuDecode: true }, + }), + ), + ).toEqual([ + "ffmpeg", + "-threads", + "2", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-c:v", + "h264_cuvid", + "-resize", + "1662x1080", + "-i", + "in.mp4", + ...nvencVideoArgs, + "-vf", + gpuScale, + "-bsf:v", + "h264_metadata=level=4.2", + ...outputTail, + ]); + }); + + test("runs a 4K HEVC source fully on the GPU", () => { + const source = metadata(3840, 2160, "hevc"); + expect( + buildProcessVideoArgs( + processArgs(source, { + inputPath: "in.mov", + audioTranscode: true, + nvenc: { options: defaults, gpuDecode: true }, + }), + ), + ).toEqual([ + "ffmpeg", + "-threads", + "2", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-c:v", + "hevc_cuvid", + "-resize", + "1920x1080", + "-i", + "in.mov", + ...nvencVideoArgs, + "-vf", + gpuScale, + "-bsf:v", + "h264_metadata=level=4.2", + "-c:a", + "aac", + "-b:a", + "128k", + "-movflags", + "+faststart", + "-progress", + "pipe:2", + "-y", + "out.mp4", + ]); + }); + + test("keeps -bf 0 for a 1080p VP9 webm", () => { + const source = metadata(1920, 1080, "vp9"); + expect( + buildProcessVideoArgs( + processArgs(source, { + inputPath: "in.webm", + audioTranscode: true, + nvenc: { options: defaults, gpuDecode: true }, + }), + ), + ).toEqual([ + "ffmpeg", + "-threads", + "2", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-c:v", + "vp9_cuvid", + "-i", + "in.webm", + ...nvencVideoArgs, + "-vf", + gpuScale, + "-bsf:v", + "h264_metadata=level=4.2", + "-bf", + "0", + "-c:a", + "aac", + "-b:a", + "128k", + "-movflags", + "+faststart", + "-progress", + "pipe:2", + "-y", + "out.mp4", + ]); + }); + + test("scales on the CPU before NVENC when the GPU cannot decode", () => { + const source = metadata(1280, 720, "vp8"); + expect( + buildProcessVideoArgs( + processArgs(source, { + inputPath: "in.webm", + decodeThreads: "4", + extraInputArgs: ["-fflags", "+genpts"], + extraOutputArgs: ["-max_muxing_queue_size", "4096"], + nvenc: { options: defaults, gpuDecode: false }, + }), + ), + ).toEqual([ + "ffmpeg", + "-threads", + "4", + "-fflags", + "+genpts", + "-reinit_filter", + "0", + "-i", + "in.webm", + ...nvencVideoArgs, + "-vf", + cpuScale, + "-pix_fmt", + "yuv420p", + "-bsf:v", + "h264_metadata=level=4.2", + "-bf", + "0", + "-c:a", + "copy", + "-movflags", + "+faststart", + "-max_muxing_queue_size", + "4096", + "-progress", + "pipe:2", + "-y", + "out.mp4", + ]); + }); + + test("never touches level normalization or stream copies", () => { + const source = metadata(1920, 1080, "h264"); + const nvenc = { options: defaults, gpuDecode: false }; + expect( + buildProcessVideoArgs( + processArgs(source, { normalizeH264Level: true, nvenc }), + ), + ).toEqual([ + "ffmpeg", + "-threads", + "2", + "-i", + "in.mp4", + "-c:v", + "copy", + "-bsf:v", + "h264_metadata=level=4.2", + ...outputTail, + ]); + expect( + buildProcessVideoArgs( + processArgs(source, { videoTranscode: false, hasAudio: false }), + ), + ).toEqual([ + "ffmpeg", + "-threads", + "2", + "-i", + "in.mp4", + "-c:v", + "copy", + "-an", + ...outputTail.slice(2), + ]); + }); +}); + +describe("edit re-encode args", () => { + const range = [{ start: 1, end: 3 }]; + + test("swaps libx264 for NVENC and keeps the rest of the edit graph", () => { + const libx264 = buildTranscodeEditArgs("in.mp4", range, "out.mp4", true); + const nvenc = buildTranscodeEditArgs( + "in.mp4", + range, + "out.mp4", + true, + 30, + defaults, + ); + expect(libx264.slice(libx264.indexOf("[v]") + 1)).toEqual([ + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "18", + "-pix_fmt", + "yuv420p", + "-enc_time_base:v", + "1/30", + "-map", + "[a]", + "-c:a", + "aac", + "-b:a", + "160k", + "-movflags", + "+faststart", + "out.mp4", + ]); + expect(nvenc).toEqual([ + "ffmpeg", + "-hide_banner", + "-y", + "-ss", + "1.000", + "-t", + "2.000", + "-i", + "in.mp4", + "-filter_complex", + "[0:v:0]fps=30,setpts=PTS-STARTPTS[v0];[0:a:0]asetpts=PTS-STARTPTS[a0];[v0][a0]concat=n=1:v=1:a=1[v][a]", + "-map", + "[v]", + "-c:v", + "h264_nvenc", + "-preset", + "p1", + "-tune", + "hq", + "-rc", + "vbr", + "-cq", + "26", + "-b:v", + "0", + "-spatial-aq", + "1", + "-profile:v", + "high", + "-g", + "250", + "-pix_fmt", + "yuv420p", + "-enc_time_base:v", + "1/30", + "-map", + "[a]", + "-c:a", + "aac", + "-b:a", + "160k", + "-movflags", + "+faststart", + "out.mp4", + ]); + }); +}); + +describe("decode thread overrides", () => { + test("keep today's defaults unless set to a valid count", () => { + const name = "MEDIA_SERVER_VERIFY_DECODE_THREADS"; + expect(decodeThreadsFromEnv(name, 1, {})).toBe("1"); + expect(decodeThreadsFromEnv(name, 1, { [name]: " " })).toBe("1"); + expect(decodeThreadsFromEnv(name, 1, { [name]: "4" })).toBe("4"); + expect(decodeThreadsFromEnv(name, 2, { [name]: "0" })).toBe("0"); + expect(decodeThreadsFromEnv(name, 2, { [name]: "-1" })).toBe("2"); + expect(decodeThreadsFromEnv(name, 2, { [name]: "1.5" })).toBe("2"); + expect(decodeThreadsFromEnv(name, 2, { [name]: "65" })).toBe("2"); + expect(decodeThreadsFromEnv(name, 2, { [name]: "auto" })).toBe("2"); + }); +}); + +describe("nvenc jobs on hosts without an NVENC device", () => { + const fixture = join( + import.meta.dir, + "..", + "fixtures", + "test-with-audio.mp4", + ); + + async function withNvencEnv(run: () => Promise): Promise { + const previous = process.env.CAP_MEDIA_VIDEO_ENCODER; + process.env.CAP_MEDIA_VIDEO_ENCODER = "nvenc"; + try { + return await run(); + } finally { + if (previous === undefined) delete process.env.CAP_MEDIA_VIDEO_ENCODER; + else process.env.CAP_MEDIA_VIDEO_ENCODER = previous; + } + } + + function expectRecordedAttempt(job: "process" | "edit", attempts: number) { + const status = getVideoEncoderStatus(enabled).nvenc; + expect(status?.attempts).toBe(attempts + 1); + expect(status?.lastAttempt?.job).toBe(job); + if (status?.lastAttempt?.fellBack) { + expect(status.lastAttempt.reason).toBeString(); + } + } + + test("does not start the encoder once the job is cancelled", async () => { + const metadata = await probeVideo(`file://${fixture}`); + const attempts = getVideoEncoderStatus(enabled).nvenc?.attempts ?? 0; + const controller = new AbortController(); + controller.abort(); + await expect( + withNvencEnv(() => + processVideo( + fixture, + metadata, + { maxWidth: 160, maxHeight: 120 }, + undefined, + controller.signal, + ), + ), + ).rejects.toThrow("Video processing was cancelled"); + expect(getVideoEncoderStatus(enabled).nvenc?.attempts ?? 0).toBe(attempts); + }); + + test("still produces the transcode", async () => { + const metadata = await probeVideo(`file://${fixture}`); + const attempts = getVideoEncoderStatus(enabled).nvenc?.attempts ?? 0; + const output = await withNvencEnv(() => + processVideo(fixture, metadata, { maxWidth: 160, maxHeight: 120 }), + ); + try { + const result = await probeVideo(`file://${output.path}`); + expect(result.videoCodec).toBe("h264"); + expect(result.width).toBe(160); + expect(result.height).toBe(120); + expect(result.audioCodec).toBe("aac"); + expectRecordedAttempt("process", attempts); + const lastAttempt = getVideoEncoderStatus(enabled).nvenc?.lastAttempt; + if (lastAttempt?.fellBack) { + const [cause] = lastAttempt.reason?.split(" || ") ?? []; + expect(cause).not.toStartWith("FFmpeg exited"); + expect(cause).not.toMatch(/out_time=|total_size=|progress=/); + } + } finally { + await output.cleanup(); + } + }); + + test("still renders the edit", async () => { + const metadata = await probeVideo(`file://${fixture}`); + const attempts = getVideoEncoderStatus(enabled).nvenc?.attempts ?? 0; + const output = await withNvencEnv(() => + renderEditedVideo({ + inputPath: fixture, + keepRanges: [{ start: 0, end: 0.5 }], + metadata, + }), + ); + try { + const result = await probeVideo(`file://${output.path}`); + expect(result.videoCodec).toBe("h264"); + expect(result.audioCodec).toBe("aac"); + expectRecordedAttempt("edit", attempts); + } finally { + await output.cleanup(); + } + }); +}); diff --git a/apps/media-server/src/__tests__/lib/parallel-download.test.ts b/apps/media-server/src/__tests__/lib/parallel-download.test.ts new file mode 100644 index 00000000000..ce8290f87fd --- /dev/null +++ b/apps/media-server/src/__tests__/lib/parallel-download.test.ts @@ -0,0 +1,193 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + downloadUrlToPathParallel, + parallelDownloadOptionsFromEnv, + planRanges, +} from "../../lib/parallel-download"; + +const MB = 1024 * 1024; +const payload = randomBytes(5 * MB + 12345); +let dir: string; +let server: ReturnType; +let rangeRequests = 0; + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "parallel-download-")); + server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + const range = req.headers.get("range")?.match(/^bytes=(\d+)-(\d+)$/); + if (url.pathname === "/plain" || !range) { + return new Response(payload, { headers: { ETag: '"v1"' } }); + } + rangeRequests++; + if (url.pathname === "/no-total" || url.pathname === "/capped") { + const end = + url.pathname === "/capped" ? 64 * 1024 - 1 : Number(range[2]); + return new Response(payload.subarray(0, end + 1), { + status: 206, + headers: { + "Content-Range": + url.pathname === "/capped" + ? `bytes 0-${end}/${payload.length}` + : `bytes 0-${end}/*`, + }, + }); + } + if (url.pathname === "/huge") { + return new Response(payload.subarray(0, MB), { + status: 206, + headers: { + "Content-Range": `bytes 0-${MB - 1}/${Number.MAX_SAFE_INTEGER}`, + }, + }); + } + if (url.pathname === "/changed" && Number(range[1]) > 0) { + return new Response("precondition", { status: 412 }); + } + const start = Number(range[1]); + const end = Math.min(Number(range[2]), payload.length - 1); + const body = + url.pathname === "/short" && start > 0 + ? payload.subarray(start, end) + : payload.subarray(start, end + 1); + return new Response(body, { + status: 206, + headers: { + ETag: '"v1"', + "Content-Range": `bytes ${start}-${end}/${payload.length}`, + }, + }); + }, + }); +}); + +afterAll(async () => { + server.stop(true); + await rm(dir, { recursive: true, force: true }); +}); + +describe("parallel download", () => { + test("env defaults keep the single-stream path", () => { + expect(parallelDownloadOptionsFromEnv({})).toEqual({ + concurrency: 1, + partBytes: 16 * MB, + }); + expect( + parallelDownloadOptionsFromEnv({ + MEDIA_SERVER_DOWNLOAD_CONCURRENCY: "8", + MEDIA_SERVER_DOWNLOAD_PART_MB: "32", + }), + ).toEqual({ concurrency: 8, partBytes: 32 * MB }); + expect( + parallelDownloadOptionsFromEnv({ + MEDIA_SERVER_DOWNLOAD_CONCURRENCY: "500", + }).concurrency, + ).toBe(32); + }); + + test("plans contiguous ranges after the first part", () => { + const ranges = planRanges(10, 4, 3); + expect(ranges.count).toBe(2); + expect([ranges.at(0), ranges.at(1)]).toEqual([ + { start: 4, end: 6 }, + { start: 7, end: 9 }, + ]); + expect(planRanges(4, 4, 3).count).toBe(0); + expect(planRanges(Number.MAX_SAFE_INTEGER, MB, MB).count).toBeGreaterThan( + 1e9, + ); + }); + + test("reassembles ranged parts byte for byte", async () => { + const path = join(dir, "ranged.bin"); + const result = await downloadUrlToPathParallel( + `http://localhost:${server.port}/file`, + path, + { concurrency: 4, partBytes: MB }, + ); + expect(result.ranged).toBe(true); + expect(result.parts).toBe(6); + expect(Buffer.compare(await readFile(path), payload)).toBe(0); + }); + + test("falls back to one stream when ranges are unsupported", async () => { + const path = join(dir, "plain.bin"); + const result = await downloadUrlToPathParallel( + `http://localhost:${server.port}/plain`, + path, + { concurrency: 4, partBytes: MB }, + ); + expect(result).toEqual({ bytes: payload.length, parts: 1, ranged: false }); + expect(Buffer.compare(await readFile(path), payload)).toBe(0); + }); + + test("fails when the object changes mid-download", async () => { + await expect( + downloadUrlToPathParallel( + `http://localhost:${server.port}/changed`, + join(dir, "changed.bin"), + { concurrency: 4, partBytes: MB }, + ), + ).rejects.toThrow("Ranged download part failed: 412"); + }); + + test.each(["no-total", "capped"])( + "restarts as one stream when the first range can't be used (%s)", + async (name) => { + const path = join(dir, `${name}.bin`); + const result = await downloadUrlToPathParallel( + `http://localhost:${server.port}/${name}`, + path, + { concurrency: 4, partBytes: MB }, + ); + expect(result).toEqual({ + bytes: payload.length, + parts: 1, + ranged: false, + }); + expect(Buffer.compare(await readFile(path), payload)).toBe(0); + }, + ); + + test("rejects an absurd Content-Range total before planning parts", async () => { + const before = rangeRequests; + await expect( + downloadUrlToPathParallel( + `http://localhost:${server.port}/huge`, + join(dir, "huge.bin"), + { concurrency: 4, partBytes: MB }, + ), + ).rejects.toThrow("invalid size"); + expect(rangeRequests).toBe(before + 1); + }); + + test("fails when a part comes back short", async () => { + await expect( + downloadUrlToPathParallel( + `http://localhost:${server.port}/short`, + join(dir, "short.bin"), + { concurrency: 4, partBytes: MB }, + ), + ).rejects.toThrow("expected"); + }); + + test("stops on abort", async () => { + const controller = new AbortController(); + controller.abort(); + const before = rangeRequests; + await expect( + downloadUrlToPathParallel( + `http://localhost:${server.port}/file`, + join(dir, "aborted.bin"), + { concurrency: 4, partBytes: MB, signal: controller.signal }, + ), + ).rejects.toThrow(); + expect(rangeRequests).toBe(before); + }); +}); diff --git a/apps/media-server/src/__tests__/lib/recording-verification.integration.test.ts b/apps/media-server/src/__tests__/lib/recording-verification.integration.test.ts index 0d11a2fb7f9..8ce747bb6db 100644 --- a/apps/media-server/src/__tests__/lib/recording-verification.integration.test.ts +++ b/apps/media-server/src/__tests__/lib/recording-verification.integration.test.ts @@ -21,6 +21,7 @@ import { import { RecordingTimingError, readRecordingVideoTiming, + timingMetadataBudget, } from "../../lib/recording-timing"; import { inspectRecordingSources, @@ -960,6 +961,26 @@ describe("complete recording decode", () => { ); }); + test("skips decoded content hashes only when nothing compares them", async () => { + const options = { expectedDuration: 5, requireAudio: true }; + const hashed = await verifyRecording(silent, options); + const unhashed = await verifyRecording(silent, { + ...options, + hashDecodedContent: false, + }); + expect(unhashed.integrity).toBeUndefined(); + expect(unhashed.video).toEqual(hashed.video); + expect(unhashed.audio).toEqual(hashed.audio); + const evidence = await inspectRecordingSources(silent, silent); + const preserved = await verifyRecording(silent, { + requireAudio: false, + hashDecodedContent: false, + sourceEvidence: evidence, + }); + expect(preserved.sourcePreserved).toBe(true); + expect(preserved.integrity).toEqual(evidence.integrity); + }); + test("accepts legacy video without audio only when audio was not requested", async () => { const input = join(FIXTURES, "test-no-audio.mp4"); const evidence = await verifyRecording(input, { @@ -1944,6 +1965,25 @@ function expectExited(pid: number) { expect(() => process.kill(pid, 0)).toThrow(); } +describe("recording timing metadata budget", () => { + const mb = 1024 * 1024; + test("keeps the 64 MB budget for normal recordings", () => { + expect(timingMetadataBudget(0)).toBe(64 * mb); + expect(timingMetadataBudget(300 * mb)).toBe(64 * mb); + expect(timingMetadataBudget(4096 * mb)).toBe(64 * mb); + }); + + test("scales with very long recordings up to 1 GB", () => { + expect(timingMetadataBudget(20 * 1024 * mb)).toBe(320 * mb); + expect(timingMetadataBudget(200 * 1024 * mb)).toBe(1024 * mb); + }); + + test("ignores an invalid size", () => { + expect(timingMetadataBudget(Number.NaN)).toBe(64 * mb); + expect(timingMetadataBudget(-1)).toBe(64 * mb); + }); +}); + describe("recording timing metadata lifetime", () => { test.each(["cancel", "timeout"])( "joins a stalled raw terminal-duration read after %s", @@ -2424,6 +2464,585 @@ function objectResponse( return new Response(bytes, { headers }); } +describe("chunked packet-proven verification", () => { + async function segmentedRecording( + name: string, + gop: number, + frames = 120, + segmentSeconds = 0.5, + ) { + const fragments = await mkdtemp(join(directory, `${name}-`)); + await run([ + "ffmpeg", + "-v", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=320x180:rate=30", + "-frames:v", + String(frames), + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-g", + String(gop), + "-keyint_min", + String(gop), + "-sc_threshold", + "0", + "-pix_fmt", + "yuv420p", + "-f", + "dash", + "-seg_duration", + String(segmentSeconds), + "-use_template", + "1", + "-use_timeline", + "1", + "-init_seg_name", + "init.mp4", + "-media_seg_name", + "fragment-$Number%05d$.m4s", + join(fragments, "manifest.mpd"), + ]); + const init = join(fragments, "init.mp4"); + const segments = (await readdir(fragments)) + .filter((file) => /^fragment-\d+\.m4s$/.test(file)) + .sort() + .map((file) => join(fragments, file)); + const combined = join(directory, `${name}-combined.mp4`); + await writeFile( + combined, + Buffer.concat( + await Promise.all([init, ...segments].map((path) => readFile(path))), + ), + ); + const output = join(directory, `${name}-output.mp4`); + await muxMediaTracksToMp4(combined, silent, output); + return { init, segments, combined, output }; + } + + const previous = process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS; + afterAll(() => { + if (previous === undefined) + delete process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS; + else process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS = previous; + }); + + async function both( + recording: Awaited>, + ) { + delete process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS; + const single = await verifyRemuxedRecording( + recording.combined, + silent, + recording.output, + { requireAudio: false }, + ); + process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS = "3"; + const warn = spyOn(console, "warn"); + try { + const chunked = await verifyRemuxedRecording( + recording.combined, + silent, + recording.output, + { requireAudio: false }, + { init: recording.init, segments: recording.segments }, + ); + const fallbacks = warn.mock.calls.filter((call) => + String(call[0]).includes("recording_chunked_decode_fallback"), + ); + return { single, chunked, fallbacks }; + } finally { + warn.mockRestore(); + delete process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS; + } + } + + test("returns the same evidence as one full decode", async () => { + const recording = await segmentedRecording("chunk-parity", 15); + expect(recording.segments.length).toBeGreaterThanOrEqual(6); + const { single, chunked, fallbacks } = await both(recording); + expect(fallbacks).toHaveLength(0); + expect(chunked).toEqual(single); + expect(chunked.video.frameCount).toBe(120); + expect(chunked.audio).not.toBeNull(); + }); + + async function misalignedRecording(name: string) { + const fragmented = join(directory, `${name}-fragmented.mp4`); + await run([ + "ffmpeg", + "-v", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=320x180:rate=30", + "-frames:v", + "120", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-g", + "45", + "-keyint_min", + "45", + "-sc_threshold", + "0", + "-pix_fmt", + "yuv420p", + "-movflags", + "empty_moov+default_base_moof+separate_moof", + "-frag_duration", + "400000", + fragmented, + ]); + const bytes = await readFile(fragmented); + const boxes: { type: string; start: number; end: number }[] = []; + for (let offset = 0; offset < bytes.length; ) { + const size = bytes.readUInt32BE(offset); + boxes.push({ + type: bytes.toString("ascii", offset + 4, offset + 8), + start: offset, + end: offset + size, + }); + offset += size; + } + const firstFragment = boxes.findIndex((box) => box.type === "moof"); + const init = join(directory, `${name}-init.mp4`); + await writeFile(init, bytes.subarray(0, boxes[firstFragment].start)); + const segments: string[] = []; + for (let index = firstFragment; index < boxes.length; index += 2) { + const segment = join( + directory, + `${name}-fragment-${segments.length}.m4s`, + ); + await writeFile( + segment, + bytes.subarray( + boxes[index].start, + boxes[index + 1]?.end ?? bytes.length, + ), + ); + segments.push(segment); + } + const output = join(directory, `${name}-output.mp4`); + await muxMediaTracksToMp4(fragmented, silent, output); + return { init, segments, combined: fragmented, output }; + } + + test("falls back to one full decode when fragments do not start on keyframes", async () => { + const recording = await misalignedRecording("chunk-misaligned"); + expect(recording.segments.length).toBeGreaterThanOrEqual(6); + const { single, chunked, fallbacks } = await both(recording); + expect(fallbacks).toHaveLength(1); + expect(chunked).toEqual(single); + expect(chunked.video.frameCount).toBe(120); + }); + + async function resizedRecording(name: string) { + const halves: string[] = []; + for (const size of ["320x180", "352x180"]) { + const half = join(directory, `${name}-${size}.h264`); + await run([ + "ffmpeg", + "-v", + "error", + "-f", + "lavfi", + "-i", + `testsrc2=size=${size}:rate=30`, + "-frames:v", + "90", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-g", + "15", + "-keyint_min", + "15", + "-sc_threshold", + "0", + "-pix_fmt", + "yuv420p", + "-bsf:v", + "h264_mp4toannexb", + "-f", + "h264", + half, + ]); + halves.push(half); + } + const stream = join(directory, `${name}.h264`); + await writeFile( + stream, + Buffer.concat(await Promise.all(halves.map((path) => readFile(path)))), + ); + const fragments = await mkdtemp(join(directory, `${name}-`)); + await run([ + "ffmpeg", + "-v", + "error", + "-framerate", + "30", + "-f", + "h264", + "-i", + stream, + "-c", + "copy", + "-f", + "dash", + "-seg_duration", + "0.5", + "-use_template", + "1", + "-use_timeline", + "1", + "-init_seg_name", + "init.mp4", + "-media_seg_name", + "fragment-$Number%05d$.m4s", + join(fragments, "manifest.mpd"), + ]); + const init = join(fragments, "init.mp4"); + const segments = (await readdir(fragments)) + .filter((file) => /^fragment-\d+\.m4s$/.test(file)) + .sort() + .map((file) => join(fragments, file)); + expect(segments.length).toBeGreaterThanOrEqual(6); + const combined = join(directory, `${name}-combined.mp4`); + await writeFile( + combined, + Buffer.concat( + await Promise.all([init, ...segments].map((path) => readFile(path))), + ), + ); + const output = join(directory, `${name}-output.mp4`); + await muxMediaTracksToMp4(combined, silent, output); + return { init, segments, combined, output }; + } + + test("falls back when a later chunk starts at a different resolution", async () => { + const recording = await resizedRecording("chunk-resize"); + const { single, chunked, fallbacks } = await both(recording); + expect(fallbacks).toHaveLength(1); + expect(String(fallbacks[0][0])).toContain( + "dimensions 0: 320x180 -> 352x180", + ); + expect(chunked).toEqual(single); + expect(chunked.video.frameCount).toBe(180); + }); + + test("stops the other chunk decoders once headers differ", async () => { + const recording = await resizedRecording("chunk-resize-early"); + const ffmpeg = Bun.which("ffmpeg"); + if (!ffmpeg) throw new Error("ffmpeg is required"); + // Every chunk after the first keeps running for 30 s after its output is complete, + // so verification only finishes quickly if those decoders are stopped early. + const bin = await mkdtemp(join(directory, "slow-ffmpeg-")); + await writeFile( + join(bin, "ffmpeg"), + [ + "#!/bin/sh", + 'case "$*" in', + `*verify-chunk-0.mp4*) exec "${ffmpeg}" "$@" ;;`, + `*verify-chunk-*) "${ffmpeg}" "$@"; status=$?; exec 1>&- 2>&-; sleep 30; exit $status ;;`, + `*) exec "${ffmpeg}" "$@" ;;`, + "esac", + "", + ].join("\n"), + { mode: 0o755 }, + ); + const path = process.env.PATH; + process.env.PATH = `${bin}:${path}`; + process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS = "3"; + const warn = spyOn(console, "warn"); + try { + const started = performance.now(); + const result = await verifyRemuxedRecording( + recording.combined, + silent, + recording.output, + { requireAudio: false }, + { init: recording.init, segments: recording.segments }, + ); + expect(performance.now() - started).toBeLessThan(15_000); + expect(result.video.frameCount).toBe(180); + expect( + warn.mock.calls.some((call) => + String(call[0]).includes("chunk stream parameters differ"), + ), + ).toBe(true); + } finally { + warn.mockRestore(); + process.env.PATH = path; + delete process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS; + } + }, 60_000); + + test("copies many fragments into one run without piling up stream listeners", async () => { + const recording = await segmentedRecording("chunk-many", 6, 240, 0.2); + expect(recording.segments.length).toBeGreaterThanOrEqual(30); + const warnings: string[] = []; + const onWarning = (warning: Error) => warnings.push(warning.name); + const events = process as unknown as NodeJS.EventEmitter; + events.on("warning", onWarning); + try { + const { single, chunked, fallbacks } = await both(recording); + expect(fallbacks).toHaveLength(0); + expect(chunked).toEqual(single); + } finally { + events.off("warning", onWarning); + } + expect(warnings).not.toContain("MaxListenersExceededWarning"); + }); + + test("still rejects a corrupt recording through the full decode", async () => { + const recording = await segmentedRecording("chunk-corrupt", 15); + const target = recording.segments[3]; + const bytes = await readFile(target); + bytes.fill( + 0, + Math.floor(bytes.length / 2), + Math.floor(bytes.length / 2) + 2048, + ); + await writeFile(target, bytes); + const combined = Buffer.concat( + await Promise.all( + [recording.init, ...recording.segments].map((path) => readFile(path)), + ), + ); + await writeFile(recording.combined, combined); + await muxMediaTracksToMp4(recording.combined, silent, recording.output); + process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS = "3"; + try { + await expect( + verifyRemuxedRecording( + recording.combined, + silent, + recording.output, + { requireAudio: false }, + { init: recording.init, segments: recording.segments }, + ), + ).rejects.toThrow(); + } finally { + delete process.env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS; + } + }); +}); + +describe("ranged remote byte verification", () => { + const payload = new Uint8Array(3 * 1024 * 1024 + 12_345).map( + (_, index) => (index * 31 + 7) % 251, + ); + const sha256 = createHash("sha256").update(payload).digest("hex"); + const identity = '"ranged-object"'; + + function rangedServer( + respond?: (start: number, end: number, attempt: number) => Response | null, + ) { + const ranges: string[] = []; + const attempts = new Map(); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + if (request.headers.get("if-match") !== identity) + return new Response("Object changed", { status: 412 }); + const range = request.headers.get("range"); + const match = range?.match(/^bytes=(\d+)-(\d+)$/); + if (!match) + return new Response(payload, { headers: { ETag: identity } }); + const start = Number(match[1]); + const end = Number(match[2]); + if (range !== "bytes=0-0") ranges.push(range ?? ""); + const attempt = (attempts.get(range ?? "") ?? 0) + 1; + attempts.set(range ?? "", attempt); + const custom = + range === "bytes=0-0" ? null : respond?.(start, end, attempt); + if (custom) return custom; + return new Response(payload.slice(start, end + 1), { + status: 206, + headers: { + ETag: identity, + "Content-Range": `bytes ${start}-${end}/${payload.length}`, + }, + }); + }, + }); + return { server, ranges }; + } + + async function verify(port: number | undefined) { + return verifyRemoteRecordingBytes( + `http://127.0.0.1:${port}/recording.mp4`, + { + expectedObjectIdentity: identity, + expectedSha256: sha256, + expectedFileSize: payload.length, + }, + ); + } + + const env = { ...process.env }; + beforeAll(() => { + process.env.MEDIA_SERVER_VERIFY_DOWNLOAD_CONCURRENCY = "3"; + process.env.MEDIA_SERVER_VERIFY_DOWNLOAD_PART_MB = "1"; + }); + afterAll(() => { + process.env.MEDIA_SERVER_VERIFY_DOWNLOAD_CONCURRENCY = + env.MEDIA_SERVER_VERIFY_DOWNLOAD_CONCURRENCY; + process.env.MEDIA_SERVER_VERIFY_DOWNLOAD_PART_MB = + env.MEDIA_SERVER_VERIFY_DOWNLOAD_PART_MB; + if (env.MEDIA_SERVER_VERIFY_DOWNLOAD_CONCURRENCY === undefined) + delete process.env.MEDIA_SERVER_VERIFY_DOWNLOAD_CONCURRENCY; + if (env.MEDIA_SERVER_VERIFY_DOWNLOAD_PART_MB === undefined) + delete process.env.MEDIA_SERVER_VERIFY_DOWNLOAD_PART_MB; + }); + + test("hashes every pinned range in order", async () => { + const { server, ranges } = rangedServer(); + try { + const result = await verify(server.port); + expect(result).toEqual({ + objectIdentity: identity, + fileSize: payload.length, + remoteSha256: sha256, + }); + const mb = 1024 * 1024; + expect([...ranges].sort()).toEqual( + [0, 1, 2, 3] + .map( + (index) => + `bytes=${index * mb}-${Math.min(payload.length, (index + 1) * mb) - 1}`, + ) + .sort(), + ); + } finally { + await server.stop(true); + } + }); + + test("rejects a range served from a different object", async () => { + const { server } = rangedServer((start, end) => + start === 2 * 1024 * 1024 + ? new Response(payload.slice(start, end + 1), { + status: 206, + headers: { + ETag: '"replaced-object"', + "Content-Range": `bytes ${start}-${end}/${payload.length}`, + }, + }) + : null, + ); + try { + await expect(verify(server.port)).rejects.toThrow( + "Recording object changed during byte verification", + ); + } finally { + await server.stop(true); + } + }); + + test("rejects a range that does not match its requested bytes", async () => { + const { server } = rangedServer((start, end) => + start === 1024 * 1024 + ? new Response(payload.slice(start, end + 1), { + status: 206, + headers: { + ETag: identity, + "Content-Range": `bytes ${start}-${end - 1}/${payload.length}`, + }, + }) + : null, + ); + try { + await expect(verify(server.port)).rejects.toThrow( + "Recording byte resume range does not match the verified object", + ); + } finally { + await server.stop(true); + } + }); + + test("retries a range after a transient storage error", async () => { + const { server, ranges } = rangedServer((start, _end, attempt) => + start === 0 && attempt === 1 + ? new Response("busy", { status: 503 }) + : null, + ); + try { + expect((await verify(server.port)).remoteSha256).toBe(sha256); + expect( + ranges.filter((range) => range.startsWith("bytes=0-")), + ).toHaveLength(2); + } finally { + await server.stop(true); + } + }); + + test("rejects an oversized range body without buffering it whole", async () => { + const { server } = rangedServer((start, end) => + start === 1024 * 1024 + ? new Response(payload, { + status: 206, + headers: { + ETag: identity, + "Content-Range": `bytes ${start}-${end}/${payload.length}`, + }, + }) + : null, + ); + try { + await expect(verify(server.port)).rejects.toThrow( + "Recording object size changed during byte verification", + ); + } finally { + await server.stop(true); + } + }); + + test("retries a range whose body ends early", async () => { + const { server, ranges } = rangedServer((start, end, attempt) => + start === 2 * 1024 * 1024 && attempt === 1 + ? new Response(payload.slice(start, start + 1000), { + status: 206, + headers: { + ETag: identity, + "Content-Range": `bytes ${start}-${end}/${payload.length}`, + }, + }) + : null, + ); + try { + expect((await verify(server.port)).remoteSha256).toBe(sha256); + expect( + ranges.filter((range) => range.startsWith(`bytes=${2 * 1024 * 1024}-`)), + ).toHaveLength(2); + } finally { + await server.stop(true); + } + }); + + test("falls back to one sequential read when storage ignores ranges", async () => { + const { server } = rangedServer( + () => new Response(payload, { headers: { ETag: identity } }), + ); + try { + expect((await verify(server.port)).remoteSha256).toBe(sha256); + } finally { + await server.stop(true); + } + }); +}); + describe("remote recording object identity", () => { test("resumes interrupted byte verification without rereading the verified prefix", async () => { const identity = '"resume-object"'; diff --git a/apps/media-server/src/__tests__/lib/storage-upload.test.ts b/apps/media-server/src/__tests__/lib/storage-upload.test.ts index f260ea8ae61..4df363215f3 100644 --- a/apps/media-server/src/__tests__/lib/storage-upload.test.ts +++ b/apps/media-server/src/__tests__/lib/storage-upload.test.ts @@ -1,9 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, open, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { uploadFileToStorage } from "../../lib/media-video"; +import { + multipartUploadConcurrencyFromEnv, + uploadFileToStorage, +} from "../../lib/media-video"; import { verifyRemoteRecordingBytes } from "../../lib/recording-verification"; const originalFetch = globalThis.fetch; @@ -29,6 +32,71 @@ describe("uploadFileToStorage", () => { globalThis.fetch = originalFetch; }); + test("uploads a 10 GB sparse file as 160 bounded parts with its Loom attempt", async () => { + const uploadFile = await createTempUploadFile(1); + const handle = await open(uploadFile.path, "r+"); + try { + await handle.truncate(10 * 1024 ** 3); + } finally { + await handle.close(); + } + let uploadedParts = 0; + let uploadedBytes = 0; + let completed = false; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/sign-part")) { + const request = JSON.parse(String(init?.body)); + expect(request.loomImportAttemptId).toBe("loom-run:0"); + expect(request.contentLength).toBe(64 * 1024 ** 2); + return Response.json({ + url: `https://storage.test/part/${request.partNumber}`, + }); + } + if (init?.method === "PUT") { + const body = init.body; + if (!(body instanceof Blob)) + throw new Error("Expected a bounded file slice"); + uploadedParts++; + uploadedBytes += body.size; + expect(body.size).toBe(64 * 1024 ** 2); + return new Response(null, { + headers: { ETag: `"part-${uploadedParts}"` }, + }); + } + if (url.endsWith("/complete")) { + const request = JSON.parse(String(init?.body)); + expect(request.loomImportAttemptId).toBe("loom-run:0"); + expect(request.parts).toHaveLength(160); + completed = true; + return Response.json({ objectIdentity: '"multipart-result"' }); + } + throw new Error("Unexpected multipart request"); + }) as typeof fetch; + try { + await uploadFileToStorage( + uploadFile.path, + { + type: "multipart", + videoId: "video", + key: "owner/video/raw-upload.mp4", + uploadId: "upload", + partSize: 64 * 1024 ** 2, + signPartUrl: "https://cap.test/sign-part", + completeUrl: "https://cap.test/complete", + abortUrl: "https://cap.test/abort", + loomImportAttemptId: "loom-run:0", + }, + "video/mp4", + ); + expect(uploadedParts).toBe(160); + expect(uploadedBytes).toBe(10 * 1024 ** 3); + expect(completed).toBe(true); + } finally { + await uploadFile.cleanup(); + } + }); + test.each([false, true])( "checks full remote bytes after Drive metadata version drift (corrupt=%s)", async (corrupt) => { @@ -314,6 +382,74 @@ describe("uploadFileToStorage", () => { }); }); + test("uploads multipart parts concurrently and completes them in order", async () => { + const uploadFile = await createTempUploadFile(partSize * 4 + 1); + let inFlight = 0; + let maxInFlight = 0; + let completed: unknown; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (init?.method === "POST") { + const json = JSON.parse(String(init.body)); + if (url.endsWith("/sign")) + return Response.json({ + url: `https://storage.example.com/part-${json.partNumber}`, + }); + completed = json; + return Response.json({ objectIdentity: '"completed"' }); + } + const partNumber = Number(url.split("part-")[1]); + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await Bun.sleep((6 - partNumber) * 5); + inFlight--; + return new Response(null, { + status: 200, + headers: { etag: `"etag-${partNumber}"` }, + }); + }) as typeof fetch; + process.env.MEDIA_SERVER_UPLOAD_CONCURRENCY = "3"; + try { + await uploadFileToStorage( + uploadFile.path, + { + type: "multipart", + videoId: "video-id", + key: "user-id/video-id/result.mp4", + uploadId: "upload-id", + partSize, + signPartUrl: "https://cap.example.com/sign", + completeUrl: "https://cap.example.com/complete", + abortUrl: "https://cap.example.com/abort", + }, + "video/mp4", + ); + } finally { + delete process.env.MEDIA_SERVER_UPLOAD_CONCURRENCY; + await uploadFile.cleanup(); + } + expect(maxInFlight).toBe(3); + expect( + (completed as { parts: Array<{ partNumber: number }> }).parts.map( + (part) => part.partNumber, + ), + ).toEqual([1, 2, 3, 4, 5]); + }); + + test("reads a bounded multipart upload concurrency", () => { + expect(multipartUploadConcurrencyFromEnv({})).toBe(1); + expect( + multipartUploadConcurrencyFromEnv({ + MEDIA_SERVER_UPLOAD_CONCURRENCY: "8", + }), + ).toBe(8); + expect( + multipartUploadConcurrencyFromEnv({ + MEDIA_SERVER_UPLOAD_CONCURRENCY: "99", + }), + ).toBe(16); + }); + test("aborts multipart uploads when a part fails", async () => { const uploadFile = await createTempUploadFile(partSize + 1); const requests: Array<{ url: string; method: string }> = []; diff --git a/apps/media-server/src/__tests__/lib/temp-files.test.ts b/apps/media-server/src/__tests__/lib/temp-files.test.ts new file mode 100644 index 00000000000..04c6b136cb6 --- /dev/null +++ b/apps/media-server/src/__tests__/lib/temp-files.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "bun"; +import { + cleanupMediaTransferCache, + sweepMediaTransferCache, +} from "../../lib/media-transfer"; +import { + cleanupStaleTempFiles, + createTempFile, + ensureTempDir, + getInstanceDir, + getTempDir, + getTempDirUsage, + getTempRoot, + removeOwnTempDir, + retainTempPath, + sweepOrphanedTempDirs, +} from "../../lib/temp-files"; + +const created: string[] = []; + +afterEach(async () => { + for (const path of created.splice(0)) { + await rm(path, { recursive: true, force: true }); + } +}); + +async function makeTree(path: string, ageMs: number, lease = false) { + created.push(path); + await mkdir(join(path, "nested", "deeper"), { recursive: true }); + await writeFile(join(path, "nested", "deeper", "segment.m4s"), "segment"); + const past = new Date(Date.now() - ageMs); + if (lease) { + await writeFile(join(path, ".lease"), ""); + await utimes(join(path, ".lease"), past, past); + } + await utimes(path, past, past); +} + +async function setAge(path: string, ageMs: number) { + const past = new Date(Date.now() - ageMs); + await utimes(path, past, past); +} + +describe("startup temp sweep", () => { + test("removes orphaned instance dirs and old legacy entries only", async () => { + await ensureTempDir(); + const root = getTempRoot(); + const staleInstance = join(root, `instance-${randomUUID()}`); + const liveInstance = join(root, `instance-${randomUUID()}`); + const oldLegacyDir = join(root, `mux-job_${randomUUID()}`); + const youngLegacyFile = join(root, `${randomUUID()}.mp4`); + await makeTree(staleInstance, 10 * 60 * 1000, true); + await makeTree(liveInstance, 10 * 60 * 1000); + await writeFile(join(liveInstance, ".lease"), ""); + await makeTree(oldLegacyDir, 2 * 60 * 60 * 1000); + created.push(youngLegacyFile); + await writeFile(youngLegacyFile, "in use by an older server version"); + + const removed = await sweepOrphanedTempDirs(); + + expect(removed).toBeGreaterThanOrEqual(2); + expect(existsSync(staleInstance)).toBe(false); + expect(existsSync(oldLegacyDir)).toBe(false); + expect(existsSync(liveInstance)).toBe(true); + expect(existsSync(youngLegacyFile)).toBe(true); + expect(existsSync(getTempDir())).toBe(true); + expect(existsSync(join(getTempDir(), ".lease"))).toBe(true); + }); + + test("never treats the server's own instance dir as orphaned", async () => { + await ensureTempDir(); + await setAge(join(getTempDir(), ".lease"), 24 * 60 * 60 * 1000); + await setAge(getTempDir(), 24 * 60 * 60 * 1000); + + await sweepOrphanedTempDirs(); + + expect(existsSync(getTempDir())).toBe(true); + }); +}); + +describe("stale temp cleanup", () => { + test("removes stale directories recursively and keeps in-use entries", async () => { + await ensureTempDir(); + const staleDir = join(getTempDir(), `stream-${randomUUID()}`); + await makeTree(staleDir, 2 * 60 * 60 * 1000); + const retainedDir = join(getTempDir(), `mux-job_${randomUUID()}`); + await makeTree(retainedDir, 2 * 60 * 60 * 1000); + const releaseRetainedDir = retainTempPath(retainedDir); + const inUse = await createTempFile(".mp4"); + created.push(inUse.path); + await writeFile(inUse.path, "input still read by ffmpeg"); + await setAge(inUse.path, 2 * 60 * 60 * 1000); + const fresh = await createTempFile(".mp4"); + created.push(fresh.path); + await writeFile(fresh.path, "fresh"); + + try { + expect(await cleanupStaleTempFiles()).toBeGreaterThanOrEqual(1); + expect(existsSync(staleDir)).toBe(false); + expect(existsSync(retainedDir)).toBe(true); + expect(existsSync(inUse.path)).toBe(true); + expect(existsSync(fresh.path)).toBe(true); + + releaseRetainedDir(); + await cleanupStaleTempFiles(); + expect(existsSync(retainedDir)).toBe(false); + } finally { + releaseRetainedDir(); + await inUse.cleanup(); + await fresh.cleanup(); + } + }); + + test("expires entries that were retained far beyond any job lifetime", async () => { + await ensureTempDir(); + const leaked = await createTempFile(".mp4"); + created.push(leaked.path); + await writeFile(leaked.path, "leaked handle"); + await setAge(leaked.path, 7 * 60 * 60 * 1000); + + await cleanupStaleTempFiles(); + + expect(existsSync(leaked.path)).toBe(false); + }); + + test("reports temp usage including nested files", async () => { + await ensureTempDir(); + const dir = join(getTempDir(), `usage-${randomUUID()}`); + await makeTree(dir, 0); + + const usage = await getTempDirUsage(); + + expect(usage.entries).toBeGreaterThanOrEqual(4); + expect(usage.bytes).toBeGreaterThanOrEqual("segment".length); + expect(usage.truncated).toBe(false); + }); + + test("removes the server's own dir on shutdown and recreates it on demand", async () => { + const handle = await createTempFile(".mp4"); + await writeFile(handle.path, "shutdown"); + + await removeOwnTempDir(); + expect(existsSync(getTempDir())).toBe(false); + + const next = await createTempFile(".mp4"); + expect(existsSync(join(getTempDir(), ".lease"))).toBe(true); + await next.cleanup(); + }); +}); + +describe("media transfer cache sweep", () => { + test("removes orphaned instance and dead-pid cache dirs but keeps a live pid's cache however old", async () => { + const base = join(tmpdir(), "cap-media-download-cache"); + const staleInstance = join(base, `instance-${randomUUID()}`); + const liveInstance = join(base, `instance-${randomUUID()}`); + const exited = spawn({ cmd: ["true"] }); + await exited.exited; + const deadPidDir = join(base, String(exited.pid)); + const oldLivePidDir = join(base, String(process.ppid)); + await makeTree(staleInstance, 10 * 60 * 1000, true); + await makeTree(liveInstance, 0, true); + await makeTree(deadPidDir, 0); + await makeTree(oldLivePidDir, 24 * 60 * 60 * 1000); + + await sweepMediaTransferCache(); + + expect(existsSync(staleInstance)).toBe(false); + expect(existsSync(deadPidDir)).toBe(false); + expect(existsSync(liveInstance)).toBe(true); + expect(existsSync(oldLivePidDir)).toBe(true); + expect(existsSync(getInstanceDir(base))).toBe(false); + await cleanupMediaTransferCache(); + }); +}); diff --git a/apps/media-server/src/__tests__/routes/lifecycle.test.ts b/apps/media-server/src/__tests__/routes/lifecycle.test.ts new file mode 100644 index 00000000000..dfd39edce3a --- /dev/null +++ b/apps/media-server/src/__tests__/routes/lifecycle.test.ts @@ -0,0 +1,600 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import app from "../../app"; +import * as jobManager from "../../lib/job-manager"; +import { + cleanupExpiredJobs, + createJob, + deleteJob, + getActiveVideoProcessCount, + getJob, + type VideoMetadata, +} from "../../lib/job-manager"; +import { + DRAINING_DETAILS, + setDraining, + waitForDrain, +} from "../../lib/lifecycle"; +import * as mediaEdit from "../../lib/media-edit"; +import * as mediaProbe from "../../lib/media-probe"; +import * as mediaVideo from "../../lib/media-video"; +import { createTempFile, type TempFileHandle } from "../../lib/temp-files"; +import { getActiveDirectVideoProcessCount } from "../../lib/video-capacity"; + +const MEDIA_SERVER_SECRET = "test-secret"; +const AUTH_HEADERS = { + "Content-Type": "application/json", + "x-media-server-secret": MEDIA_SERVER_SECRET, +}; +const originalPublicStatus = process.env.MEDIA_SERVER_PUBLIC_STATUS; + +process.env.MEDIA_SERVER_WEBHOOK_SECRET = MEDIA_SERVER_SECRET; + +const metadata: VideoMetadata = { + duration: 3 * 60 * 60, + width: 1920, + height: 1080, + fps: 30, + videoCodec: "h264", + audioCodec: "aac", + audioChannels: 2, + sampleRate: 48_000, + bitrate: 5_000_000, + fileSize: 1024 ** 3, +}; + +const createdJobs: string[] = []; + +function post(path: string, body: unknown): Request { + return new Request(`http://localhost${path}`, { + method: "POST", + headers: AUTH_HEADERS, + body: JSON.stringify(body), + }); +} + +function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function tempFileWith(extension: string, contents: string) { + const handle = await createTempFile(extension); + await writeFile(handle.path, contents); + return handle; +} + +async function waitFor(condition: () => boolean, timeoutMs = 2_000) { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error("Timed out waiting"); + await Bun.sleep(10); + } +} + +beforeEach(() => { + spyOn(jobManager, "canAcceptNewVideoProcess").mockReturnValue(true); +}); + +afterEach(async () => { + mock.restore(); + setDraining(false); + if (originalPublicStatus === undefined) + delete process.env.MEDIA_SERVER_PUBLIC_STATUS; + else process.env.MEDIA_SERVER_PUBLIC_STATUS = originalPublicStatus; + await waitForDrain(2_000); + for (const jobId of createdJobs.splice(0)) deleteJob(jobId); +}); + +describe("draining", () => { + const url = "https://example.com/video.mp4"; + const admissions: Array<[string, unknown]> = [ + [ + "/video/process", + { videoId: "v", userId: "u", videoUrl: url, outputPresignedUrl: url }, + ], + [ + "/video/import", + { + videoId: "v", + userId: "u", + videoUrl: url, + outputPresignedUrl: url, + sourcePresignedUrl: url, + }, + ], + [ + "/video/edit", + { + videoId: "v", + userId: "u", + sourceUrl: url, + outputPresignedUrl: url, + keepRanges: [{ start: 0, end: 1 }], + }, + ], + [ + "/video/mux-segments", + { + videoId: "v", + userId: "u", + outputPresignedUrl: url, + videoInitUrl: url, + videoSegmentUrls: ["https://example.com/segment.m4s"], + }, + ], + [ + "/video/verify-recording", + { + videoId: "v", + userId: "u", + videoUrl: url, + fileSize: 1, + duration: 1, + requiredAudio: false, + objectIdentity: '"etag"', + webhookUrl: "https://example.com/webhook", + }, + ], + ["/video/convert", { videoUrl: url }], + ["/video/probe", { videoUrl: url }], + ["/video/thumbnail", { videoUrl: url }], + ["/audio/check", { videoUrl: url }], + ["/audio/extract", { videoUrl: url }], + ["/audio/convert", { audioUrl: url }], + ]; + + test.each(admissions)("rejects new work on %s", async (path, body) => { + setDraining(true); + const response = await app.fetch(post(path, body)); + + expect(response.status).toBe(503); + expect(response.headers.get("Retry-After")).toBe("15"); + const data = await response.json(); + expect(data.code).toBe("SERVER_BUSY"); + expect(data.details).toBe(DRAINING_DETAILS); + }); + + test("fails health checks but keeps status endpoints working", async () => { + const job = createJob("job-draining-status", "video-id", "user-id"); + createdJobs.push(job.jobId); + setDraining(true); + + const health = await app.fetch(new Request("http://localhost/health")); + expect(health.status).toBe(503); + expect((await health.json()).status).toBe("draining"); + + const status = await app.fetch( + new Request(`http://localhost/video/process/${job.jobId}/status`), + ); + expect(status.status).toBe(200); + expect((await status.json()).jobId).toBe(job.jobId); + + const serverStatus = await app.fetch( + new Request("http://localhost/video/status"), + ); + expect(serverStatus.status).toBe(200); + }); +}); + +describe("status access", () => { + test("requires the secret when public status is disabled", async () => { + process.env.MEDIA_SERVER_PUBLIC_STATUS = "false"; + for (const path of [ + "/video/status", + "/audio/status", + "/video/process/job-missing/status", + ]) { + const anonymous = await app.fetch(new Request(`http://localhost${path}`)); + expect(anonymous.status).toBe(401); + const authenticated = await app.fetch( + new Request(`http://localhost${path}`, { headers: AUTH_HEADERS }), + ); + expect(authenticated.status).not.toBe(401); + } + }); + + test("reports runtime counters only to authenticated callers", async () => { + const job = createJob("job_runtime_report", "video-runtime", "user-id"); + createdJobs.push(job.jobId); + const anonymous = await app.fetch(new Request("http://localhost/health")); + expect((await anonymous.json()).runtime).toBeUndefined(); + + for (const path of ["/health", "/video/status"]) { + const response = await app.fetch( + new Request(`http://localhost${path}`, { headers: AUTH_HEADERS }), + ); + const { runtime } = await response.json(); + expect(runtime.activeJobs).toBeGreaterThanOrEqual(1); + expect(runtime.jobTotals.created).toBeGreaterThanOrEqual(1); + expect(typeof runtime.activeSubprocesses).toBe("number"); + expect(typeof runtime.tempDir.bytes).toBe("number"); + expect(typeof runtime.tempDir.entries).toBe("number"); + expect(typeof runtime.mediaTransferCache.bytes).toBe("number"); + expect(typeof runtime.slots.video.max).toBe("number"); + expect(typeof runtime.slots.directVideo.active).toBe("number"); + expect(typeof runtime.slots.audio.max).toBe("number"); + expect(typeof runtime.slots.probe.max).toBe("number"); + expect(JSON.stringify(runtime)).not.toContain(job.jobId); + expect(JSON.stringify(runtime)).not.toContain(job.videoId); + } + }); +}); + +describe("process jobs", () => { + test("heartbeats a slow output upload and derives the lifetime from the input", async () => { + const intervals: Array<{ + callback: () => void; + delay?: number; + handle: ReturnType; + }> = []; + const cleared = new Set(); + const originalSetInterval = globalThis.setInterval; + const originalClearInterval = globalThis.clearInterval; + spyOn(globalThis, "setInterval").mockImplementation((( + callback: () => void, + delay?: number, + ) => { + const handle = originalSetInterval(callback, delay); + intervals.push({ callback, delay, handle }); + return handle; + }) as unknown as typeof setInterval); + spyOn(globalThis, "clearInterval").mockImplementation((( + handle: ReturnType, + ) => { + cleared.add(handle); + originalClearInterval(handle); + }) as unknown as typeof clearInterval); + const files: TempFileHandle[] = []; + spyOn(mediaVideo, "downloadVideoToTemp").mockImplementation(async () => { + const input = await tempFileWith(".mp4", "input"); + files.push(input); + return input; + }); + spyOn(mediaProbe, "probeVideoFile").mockResolvedValue(metadata); + spyOn(mediaVideo, "processVideo").mockImplementation(async () => { + const output = await tempFileWith(".mp4", "output"); + files.push(output); + return output; + }); + const uploadStarted = deferred(); + const upload = deferred(); + spyOn(mediaVideo, "uploadFileToS3").mockImplementation(async () => { + uploadStarted.resolve(); + return await upload.promise; + }); + + const response = await app.fetch( + post("/video/process", { + videoId: "video-slow-upload", + userId: "user-id", + videoUrl: "https://example.com/input.mp4", + outputPresignedUrl: "https://example.com/output.mp4", + }), + ); + const { jobId } = await response.json(); + createdJobs.push(jobId); + await uploadStarted.promise; + + const job = getJob(jobId); + expect(job?.phase).toBe("uploading"); + if (!job) throw new Error("Job missing"); + expect(job.lifetimeDeadlineAt).toBeGreaterThan( + job.createdAt + 2 * 60 * 60 * 1000, + ); + expect(job.lifetimeDeadlineAt).toBeLessThanOrEqual( + job.createdAt + 4 * 60 * 60 * 1000, + ); + + job.updatedAt = Date.now() - 16 * 60 * 1000; + const heartbeats = intervals.filter( + ({ delay, handle }) => delay === 60_000 && !cleared.has(handle), + ); + expect(heartbeats).toHaveLength(1); + heartbeats[0]?.callback(); + expect(cleanupExpiredJobs()).toBe(0); + expect(job.phase).toBe("uploading"); + + upload.resolve({}); + await waitFor(() => job.phase === "complete"); + await waitForDrain(2_000); + expect(files.every((handle) => !existsSync(handle.path))).toBe(true); + }); + + function previewJob(videoId: string) { + return post("/video/process", { + videoId, + userId: "user-id", + videoUrl: "https://example.com/input.mp4", + outputPresignedUrl: "https://example.com/output.mp4", + thumbnailPresignedUrl: "https://example.com/screenshot.jpg", + previewGifPresignedUrl: "https://example.com/preview.gif", + }); + } + + function mockPreviewJob() { + const events: string[] = []; + const signals: AbortSignal[] = []; + const gifs: TempFileHandle[] = []; + spyOn(mediaVideo, "downloadVideoToTemp").mockImplementation(() => + tempFileWith(".mp4", "input"), + ); + spyOn(mediaProbe, "probeVideoFile").mockResolvedValue({ + ...metadata, + duration: 10, + }); + spyOn(mediaVideo, "processVideo").mockImplementation(() => + tempFileWith(".mp4", "output"), + ); + spyOn(mediaVideo, "generateThumbnail").mockImplementation( + async (_path, _duration, _options, signal) => { + events.push("thumbnail rendered"); + if (signal) signals.push(signal); + return new Uint8Array([1]); + }, + ); + spyOn(mediaVideo, "generatePreviewGif").mockImplementation( + async (_path, _duration, _options, signal) => { + events.push("gif rendered"); + if (signal) signals.push(signal); + const gif = await tempFileWith(".gif", "gif"); + gifs.push(gif); + return gif; + }, + ); + spyOn(mediaVideo, "uploadToS3").mockImplementation(async () => { + events.push("thumbnail uploaded"); + }); + const output = deferred(); + const outputStarted = deferred(); + const outputFailure = deferred(); + spyOn(mediaVideo, "uploadFileToS3").mockImplementation( + async (_path, _url, contentType) => { + if (contentType === "image/gif") { + events.push("gif uploaded"); + return {}; + } + events.push("output upload started"); + outputStarted.resolve(); + const failure = await Promise.race([ + output.promise.then(() => null), + outputFailure.promise, + ]); + if (failure) throw failure; + events.push("output uploaded"); + return {}; + }, + ); + return { events, signals, gifs, output, outputStarted, outputFailure }; + } + + test("renders previews while the output uploads and uploads them after it", async () => { + const preview = mockPreviewJob(); + const response = await app.fetch(previewJob("video-preview-overlap")); + const { jobId } = await response.json(); + createdJobs.push(jobId); + await preview.outputStarted.promise; + await waitFor(() => preview.events.includes("gif rendered")); + expect(preview.events).toContain("thumbnail rendered"); + expect(preview.events).not.toContain("thumbnail uploaded"); + expect(preview.events).not.toContain("gif uploaded"); + + preview.output.resolve({}); + await waitFor(() => getJob(jobId)?.phase === "complete"); + const uploaded = preview.events.indexOf("output uploaded"); + expect(preview.events.indexOf("thumbnail uploaded")).toBeGreaterThan( + uploaded, + ); + expect(preview.events.indexOf("gif uploaded")).toBeGreaterThan(uploaded); + await waitForDrain(2_000); + expect(preview.gifs.every((gif) => !existsSync(gif.path))).toBe(true); + }); + + test("reports an upload failure only after the thumbnail render has stopped", async () => { + const preview = mockPreviewJob(); + let thumbnailStopped = false; + spyOn(mediaVideo, "generateThumbnail").mockImplementation( + (_path, _duration, _options, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + setTimeout(() => { + thumbnailStopped = true; + reject(new Error("Thumbnail render stopped")); + }, 100); + }); + }), + ); + const response = await app.fetch(previewJob("video-preview-thumb-stop")); + const { jobId } = await response.json(); + createdJobs.push(jobId); + await preview.outputStarted.promise; + + preview.outputFailure.resolve(new Error("Storage upload failed: 403")); + await waitFor(() => getJob(jobId)?.phase === "error"); + expect(thumbnailStopped).toBe(true); + expect(await waitForDrain(2_000)).toBe(true); + }); + + test("uploads no previews and stops rendering when the output upload fails", async () => { + const preview = mockPreviewJob(); + const response = await app.fetch(previewJob("video-preview-upload-fails")); + const { jobId } = await response.json(); + createdJobs.push(jobId); + await preview.outputStarted.promise; + await waitFor(() => preview.events.includes("gif rendered")); + + preview.outputFailure.resolve(new Error("Storage upload failed: 403")); + expect(await waitForDrain(2_000)).toBe(true); + expect(getJob(jobId)?.phase).toBe("error"); + expect(preview.events).not.toContain("thumbnail uploaded"); + expect(preview.events).not.toContain("gif uploaded"); + expect(preview.signals.length).toBe(2); + expect(preview.signals.every((signal) => signal.aborted)).toBe(true); + expect(preview.gifs.every((gif) => !existsSync(gif.path))).toBe(true); + }); + + test("releases the job slot and temp files when processing fails", async () => { + const before = getActiveVideoProcessCount(); + let input: TempFileHandle | undefined; + spyOn(mediaVideo, "downloadVideoToTemp").mockImplementation(async () => { + input = await tempFileWith(".mp4", "input"); + return input; + }); + spyOn(mediaProbe, "probeVideoFile").mockResolvedValue({ + ...metadata, + duration: 10, + }); + spyOn(mediaVideo, "processVideo").mockRejectedValue( + new Error("FFmpeg exited with code 1"), + ); + + const response = await app.fetch( + post("/video/process", { + videoId: "video-failed-process", + userId: "user-id", + videoUrl: "https://example.com/input.mp4", + outputPresignedUrl: "https://example.com/output.mp4", + }), + ); + const { jobId } = await response.json(); + createdJobs.push(jobId); + + expect(await waitForDrain(2_000)).toBe(true); + expect(getJob(jobId)?.phase).toBe("error"); + expect(getActiveVideoProcessCount()).toBe(before); + expect(input && existsSync(input.path)).toBe(false); + }); +}); + +describe("edit jobs", () => { + test("cancellation aborts the upload and skips preview work", async () => { + const files: TempFileHandle[] = []; + spyOn(mediaVideo, "downloadVideoToTemp").mockImplementation(async () => { + const input = await tempFileWith(".mp4", "input"); + files.push(input); + return input; + }); + spyOn(mediaProbe, "probeVideoFile").mockResolvedValue({ + ...metadata, + duration: 10, + }); + spyOn(mediaEdit, "renderEditedVideo").mockImplementation(async () => { + const output = await tempFileWith(".mp4", "edited"); + files.push(output); + return output; + }); + const thumbnail = spyOn(mediaVideo, "generateThumbnail"); + const uploadStarted = deferred(); + let uploadSignal: AbortSignal | undefined; + spyOn(mediaVideo, "uploadFileToS3").mockImplementation( + (_path, _url, _contentType, signal) => { + uploadSignal = signal; + uploadStarted.resolve(); + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + }, + ); + + const response = await app.fetch( + post("/video/edit", { + videoId: "video-cancelled-edit", + userId: "user-id", + sourceUrl: "https://example.com/source.mp4", + outputPresignedUrl: "https://example.com/output.mp4", + thumbnailPresignedUrl: "https://example.com/thumbnail.jpg", + keepRanges: [{ start: 0, end: 5 }], + }), + ); + const { jobId } = await response.json(); + createdJobs.push(jobId); + await uploadStarted.promise; + + const cancel = await app.fetch(post(`/video/process/${jobId}/cancel`, {})); + expect(cancel.status).toBe(200); + expect(uploadSignal?.aborted).toBe(true); + + expect(await waitForDrain(2_000)).toBe(true); + expect(getJob(jobId)?.phase).toBe("cancelled"); + expect(thumbnail).not.toHaveBeenCalled(); + expect(files).toHaveLength(2); + expect(files.every((handle) => !existsSync(handle.path))).toBe(true); + }); +}); + +describe("direct conversions", () => { + test("release the slot when the download fails", async () => { + spyOn(mediaVideo, "downloadVideoToTemp").mockRejectedValue( + new Error("Failed to download video: 404"), + ); + + const response = await app.fetch( + post("/video/convert", { videoUrl: "https://example.com/missing.mp4" }), + ); + + expect(response.status).toBe(500); + expect(getActiveDirectVideoProcessCount()).toBe(0); + }); + + test("release the slot and temp files when the client cancels the body", async () => { + const files: TempFileHandle[] = []; + spyOn(mediaVideo, "downloadVideoToTemp").mockImplementation(async () => { + const input = await tempFileWith(".webm", "input"); + files.push(input); + return input; + }); + spyOn(mediaProbe, "probeVideoFile").mockResolvedValue({ + ...metadata, + duration: 10, + }); + spyOn(mediaVideo, "processVideo").mockImplementation(async () => { + const output = await tempFileWith(".mp4", "x".repeat(1024 * 1024)); + files.push(output); + return output; + }); + + const response = await app.fetch( + post("/video/convert", { videoUrl: "https://example.com/input.webm" }), + ); + expect(response.status).toBe(200); + expect(getActiveDirectVideoProcessCount()).toBe(1); + + await response.body?.cancel(); + await waitFor(() => getActiveDirectVideoProcessCount() === 0); + await waitFor(() => files.every((handle) => !existsSync(handle.path))); + }); + + test("release the slot after the body is fully read", async () => { + spyOn(mediaVideo, "downloadVideoToTemp").mockImplementation(() => + tempFileWith(".webm", "input"), + ); + spyOn(mediaProbe, "probeVideoFile").mockResolvedValue({ + ...metadata, + duration: 10, + }); + spyOn(mediaVideo, "processVideo").mockImplementation(() => + tempFileWith(".mp4", "converted"), + ); + + const response = await app.fetch( + post("/video/convert", { videoUrl: "https://example.com/input.webm" }), + ); + + expect(await response.text()).toBe("converted"); + await waitFor(() => getActiveDirectVideoProcessCount() === 0); + }); +}); diff --git a/apps/media-server/src/__tests__/routes/peer-jobs.test.ts b/apps/media-server/src/__tests__/routes/peer-jobs.test.ts new file mode 100644 index 00000000000..c9ef0e01c4e --- /dev/null +++ b/apps/media-server/src/__tests__/routes/peer-jobs.test.ts @@ -0,0 +1,151 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "bun:test"; +import app from "../../app"; +import { mediaServerPeers } from "../../lib/peer-jobs"; + +const SECRET = "peer-test-secret"; +process.env.MEDIA_SERVER_WEBHOOK_SECRET = SECRET; + +const peerRequests: { + method: string; + peerLookup: string | null; + secret: string | null; +}[] = []; +let respond: (request: Request) => Response | Promise = () => + new Response("not found", { status: 404 }); +let peer: ReturnType; +let missingPeer: ReturnType; + +beforeAll(() => { + peer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + peerRequests.push({ + method: request.method, + peerLookup: request.headers.get("x-cap-peer-lookup"), + secret: request.headers.get("x-media-server-secret"), + }); + return respond(request); + }, + }); + missingPeer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => new Response("not found", { status: 404 }), + }); + process.env.MEDIA_SERVER_PEERS = `http://127.0.0.1:${missingPeer.port}, http://127.0.0.1:${peer.port}/`; +}); + +afterEach(() => { + peerRequests.length = 0; + respond = () => new Response("not found", { status: 404 }); +}); + +afterAll(async () => { + delete process.env.MEDIA_SERVER_PEERS; + await peer.stop(true); + await missingPeer.stop(true); +}); + +function statusRequest(jobId: string, headers: Record = {}) { + return new Request(`http://localhost/video/process/${jobId}/status`, { + headers: { "x-media-server-secret": SECRET, ...headers }, + }); +} + +describe("peer job lookup", () => { + test("parses configured peers and drops invalid entries", () => { + expect( + mediaServerPeers({ + MEDIA_SERVER_PEERS: + "http://10.0.0.1:3456/, nonsense, ftp://x, https://b", + }), + ).toEqual(["http://10.0.0.1:3456", "https://b"]); + expect(mediaServerPeers({})).toEqual([]); + }); + + test("answers a status request with the peer that owns the job", async () => { + respond = () => + Response.json({ jobId: "job-elsewhere", videoId: "v1", phase: "error" }); + const response = await app.fetch(statusRequest("job-elsewhere")); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + jobId: "job-elsewhere", + videoId: "v1", + phase: "error", + }); + expect(peerRequests).toHaveLength(1); + expect(peerRequests[0].peerLookup).toBe("1"); + expect(peerRequests[0].secret).toBe(SECRET); + }); + + test("still answers not found when no peer has the job", async () => { + const response = await app.fetch(statusRequest("job-nowhere")); + expect(response.status).toBe(404); + expect((await response.json()).code).toBe("NOT_FOUND"); + }); + + test("does not forward a lookup that already came from a peer", async () => { + respond = () => Response.json({ jobId: "job-loop", phase: "processing" }); + const response = await app.fetch( + statusRequest("job-loop", { "x-cap-peer-lookup": "1" }), + ); + expect(response.status).toBe(404); + expect(peerRequests).toHaveLength(0); + }); + + test("does not forward unauthenticated status reads", async () => { + respond = () => Response.json({ jobId: "job-public", phase: "processing" }); + const response = await app.fetch( + new Request("http://localhost/video/process/job-public/status"), + ); + expect(response.status).toBe(404); + expect(peerRequests).toHaveLength(0); + }); + + test("ignores a peer that rejects the shared secret", async () => { + respond = () => new Response("Unauthorized", { status: 401 }); + const response = await app.fetch(statusRequest("job-rejected")); + expect(response.status).toBe(404); + }); + + test("waits for an owner that delivers its webhook before confirming a cancel", async () => { + respond = async (request) => { + if (request.method !== "POST") + return new Response("not found", { status: 404 }); + await Bun.sleep(2_500); + return Response.json({ success: true, jobId: "job-slow-cancel" }); + }; + const response = await app.fetch( + new Request("http://localhost/video/process/job-slow-cancel/cancel", { + method: "POST", + headers: { "x-media-server-secret": SECRET }, + }), + ); + expect(response.status).toBe(200); + expect((await response.json()).success).toBe(true); + }, 10_000); + + test("cancels a job on the peer that owns it", async () => { + respond = (request) => + request.method === "POST" + ? Response.json({ success: true, jobId: "job-cancel" }) + : new Response("not found", { status: 404 }); + const response = await app.fetch( + new Request("http://localhost/video/process/job-cancel/cancel", { + method: "POST", + headers: { "x-media-server-secret": SECRET }, + }), + ); + expect(response.status).toBe(200); + expect((await response.json()).success).toBe(true); + expect(peerRequests.map((request) => request.method)).toEqual(["POST"]); + }); +}); diff --git a/apps/media-server/src/__tests__/routes/recording-verification.test.ts b/apps/media-server/src/__tests__/routes/recording-verification.test.ts index 7d3beb673d7..7355cc15c6a 100644 --- a/apps/media-server/src/__tests__/routes/recording-verification.test.ts +++ b/apps/media-server/src/__tests__/routes/recording-verification.test.ts @@ -108,6 +108,17 @@ async function terminalWebhook(jobId: string): Promise { return payload; } +function captureFailureLogs() { + const info = spyOn(console, "info"); + const events = () => + info.mock.calls.flatMap(([line]) => { + if (typeof line !== "string" || !line.startsWith("{")) return []; + const event = JSON.parse(line) as Record; + return event.event === "recording_verification_failed" ? [event] : []; + }); + return { events, restore: () => info.mockRestore() }; +} + function waitForDecoderCancellation() { let joined = false; verify.mockImplementation(async (_input, options) => { @@ -508,4 +519,94 @@ describe("asynchronous recording verification", () => { expect(failed.metadata).toBeUndefined(); }, ); + + test("logs why permanent verification failed without leaking the presigned URL", async () => { + const logs = captureFailureLogs(); + try { + verify.mockRejectedValue( + new Error( + "Recording full decode failed: https://bucket.s3.amazonaws.com/user-1/video-1/raw.mp4?X-Amz-Credential=AKIAEXAMPLE&X-Amz-Signature=deadbeef: Invalid data found when processing input", + ), + ); + const jobId = await startJob(); + const failed = await terminalWebhook(jobId); + expect(failed.errorCode).toBe("source-invalid"); + const [event, ...rest] = logs.events(); + expect(rest).toEqual([]); + expect(event).toMatchObject({ + jobId, + videoId: body.videoId, + phase: "error", + errorCode: "source-invalid", + retryable: false, + stage: "decode", + reason: + "Recording full decode failed: Invalid data found when processing input", + }); + const line = JSON.stringify(event); + expect(line).not.toContain("amazonaws"); + expect(line).not.toContain("X-Amz"); + expect(line).not.toContain("deadbeef"); + expect(line).not.toContain(body.videoUrl); + } finally { + logs.restore(); + } + }); + + test("logs which uploaded metadata check failed", async () => { + const logs = captureFailureLogs(); + try { + probe.mockResolvedValue({ ...metadata, fileSize: 64, duration: 2 }); + const jobId = await startJob(); + const failed = await terminalWebhook(jobId); + expect(failed.errorCode).toBe("source-invalid"); + expect(logs.events()).toEqual([ + expect.objectContaining({ + jobId, + errorCode: "source-invalid", + retryable: false, + stage: "metadata", + reason: "Uploaded recording does not match the completed local file", + mismatch: { + expectedFileSize: 128, + actualFileSize: 64, + expectedDuration: 5, + actualDuration: 2, + requiredAudio: false, + audioCodec: "aac", + }, + }), + ]); + expect(verify).not.toHaveBeenCalled(); + } finally { + logs.restore(); + } + }); + + test("logs the original probe error behind a retryable failure", async () => { + const logs = captureFailureLogs(); + try { + probe.mockRejectedValue( + new Error( + "Media input is not accessible (503) for /tmp/cap-media-server/instance-1/user-1.mp4?X-Amz-Signature=deadbeef", + ), + ); + const jobId = await startJob(); + const failed = await terminalWebhook(jobId); + expect(failed.error).toBe( + "Recording verification temporarily unavailable (503)", + ); + expect(logs.events()).toEqual([ + expect.objectContaining({ + jobId, + errorCode: "processing-unavailable", + retryable: true, + stage: "probe", + reason: "Media input is not accessible (503) for ", + }), + ]); + } finally { + logs.restore(); + } + }); }); diff --git a/apps/media-server/src/__tests__/routes/video.test.ts b/apps/media-server/src/__tests__/routes/video.test.ts index d2643b3bf7f..51c8f19e450 100644 --- a/apps/media-server/src/__tests__/routes/video.test.ts +++ b/apps/media-server/src/__tests__/routes/video.test.ts @@ -51,6 +51,8 @@ describe("GET /video/status", () => { expect(data).toHaveProperty("canAcceptNewProbeProcess"); expect(data).toHaveProperty("jobCount"); expect(data).toHaveProperty("jobs"); + expect(data.loomMultipartUploads).toBe(true); + expect(data.loomImportMaxBytes).toBe(10 * 1024 ** 3); expect(Array.isArray(data.jobs)).toBe(true); }); }); @@ -404,6 +406,44 @@ describe("POST /video/process", () => { mock.restore(); }); + test("accepts a Loom multipart original without a single-PUT original URL", async () => { + spyOn(jobManager, "canAcceptNewVideoProcess").mockReturnValue(false); + const response = await app.fetch( + videoPostRequest("/video/import", { + videoId: "test-id", + userId: "user-id", + videoUrl: "https://example.com/video.mp4", + outputPresignedUrl: "https://s3.example.com/output", + maxImportBytes: 10 * 1024 ** 3, + sourceUpload: { + type: "multipart", + videoId: "test-id", + loomImportAttemptId: "run:0", + key: "user-id/test-id/raw-upload.mp4", + uploadId: "upload-id", + partSize: 64 * 1024 ** 2, + signPartUrl: "https://cap.example.com/sign", + completeUrl: "https://cap.example.com/complete", + abortUrl: "https://cap.example.com/abort", + }, + }), + ); + expect(response.status).toBe(503); + expect((await response.json()).code).toBe("SERVER_BUSY"); + }); + + test("rejects an import without an original upload target", async () => { + const response = await app.fetch( + videoPostRequest("/video/import", { + videoId: "test-id", + userId: "user-id", + videoUrl: "https://example.com/video.mp4", + outputPresignedUrl: "https://s3.example.com/output", + }), + ); + expect(response.status).toBe(400); + }); + test("returns 400 for missing required fields", async () => { const response = await app.fetch(videoPostRequest("/video/process", {})); diff --git a/apps/media-server/src/app.ts b/apps/media-server/src/app.ts index e50979955ad..162a7652dd0 100644 --- a/apps/media-server/src/app.ts +++ b/apps/media-server/src/app.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import { logger } from "hono/logger"; +import { beginRequest } from "./lib/lifecycle"; import audio from "./routes/audio"; import health from "./routes/health"; import video from "./routes/video"; @@ -7,6 +8,15 @@ import video from "./routes/video"; const app = new Hono(); app.use("*", logger()); +app.use("*", async (c, next) => { + if (c.req.method === "GET" || c.req.method === "HEAD") return await next(); + const end = beginRequest(); + try { + await next(); + } finally { + end(); + } +}); app.route("/health", health); app.route("/audio", audio); diff --git a/apps/media-server/src/index.ts b/apps/media-server/src/index.ts index 7a5b2151dfd..05dbdac4fd3 100644 --- a/apps/media-server/src/index.ts +++ b/apps/media-server/src/index.ts @@ -1,38 +1,52 @@ import app from "./app"; import { abortAllJobs } from "./lib/job-manager"; +import { createShutdownHandler, getIdleTimeoutSeconds } from "./lib/lifecycle"; import { cancelAllMediaOperations } from "./lib/media-operations"; -import { cleanupMediaTransferCache } from "./lib/media-transfer"; +import { + cleanupMediaTransferCache, + sweepMediaTransferCache, +} from "./lib/media-transfer"; +import { removeOwnTempDir, startTempDirMaintenance } from "./lib/temp-files"; const port = Number(process.env.PORT) || 3456; console.log(`[media-server] Starting on port ${port}`); -let shuttingDown = false; +void Promise.allSettled([ + startTempDirMaintenance(), + sweepMediaTransferCache(), +]).then(([temp, cache]) => { + const removed = + (temp.status === "fulfilled" ? temp.value : 0) + + (cache.status === "fulfilled" ? cache.value : 0); + if (removed > 0) { + console.log(`[media-server] Removed ${removed} orphaned temp entries`); + } +}); -const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - console.log("[media-server] Shutting down..."); +const shutdown = createShutdownHandler(async () => { const abortedJobs = await abortAllJobs(); if (abortedJobs > 0) { console.log(`[media-server] Aborted ${abortedJobs} active jobs`); } await cancelAllMediaOperations(); await cleanupMediaTransferCache(); + await removeOwnTempDir(); process.exit(0); -}; +}); process.on("SIGINT", () => { - void shutdown(); + void shutdown("SIGINT"); }); process.on("SIGTERM", () => { - void shutdown(); + void shutdown("SIGTERM"); }); process.on("SIGHUP", () => { - void shutdown(); + void shutdown("SIGHUP"); }); export default { port, + idleTimeout: getIdleTimeoutSeconds(), fetch: app.fetch, }; diff --git a/apps/media-server/src/lib/audio-quality.ts b/apps/media-server/src/lib/audio-quality.ts index d51fe5b7691..455824b258c 100644 --- a/apps/media-server/src/lib/audio-quality.ts +++ b/apps/media-server/src/lib/audio-quality.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { lstat, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; import { spawn } from "bun"; import { z } from "zod"; @@ -13,6 +12,7 @@ import { } from "./audio-quality-policy"; import { proveRecordingPackets } from "./recording-packet-proof"; import { registerSubprocess, unregisterSubprocess } from "./subprocess"; +import { ensureTempDir, getTempDir } from "./temp-files"; const streamSchema = z.object({ codec_type: z.string(), @@ -307,7 +307,8 @@ export async function createAudioQualityCandidate( Math.abs(videoDuration - input.duration) > 0.1 ) return { status: "unchanged", reason: "source-duration-mismatch" }; - directory = await mkdtemp(join(tmpdir(), "cap-audio-quality-")); + await ensureTempDir(); + directory = await mkdtemp(join(getTempDir(), "audio-quality-")); let path = join(directory, "candidate.mp4"); const renderArgs = [ "ffmpeg", diff --git a/apps/media-server/src/lib/auth.ts b/apps/media-server/src/lib/auth.ts index a8e322d2861..9641d67c496 100644 --- a/apps/media-server/src/lib/auth.ts +++ b/apps/media-server/src/lib/auth.ts @@ -12,3 +12,17 @@ export function validateMediaServerSecret(c: MediaServerContext): boolean { } return c.req.header("x-media-server-secret") === secret; } + +export function hasMediaServerSecret(c: MediaServerContext): boolean { + return ( + c.req.header("x-media-server-secret") !== undefined && + validateMediaServerSecret(c) + ); +} + +export function canReadStatus(c: MediaServerContext): boolean { + const publicStatus = process.env.MEDIA_SERVER_PUBLIC_STATUS?.toLowerCase(); + return publicStatus === "false" || publicStatus === "0" + ? validateMediaServerSecret(c) + : true; +} diff --git a/apps/media-server/src/lib/ffmpeg-errors.ts b/apps/media-server/src/lib/ffmpeg-errors.ts new file mode 100644 index 00000000000..b9b8998d484 --- /dev/null +++ b/apps/media-server/src/lib/ffmpeg-errors.ts @@ -0,0 +1,30 @@ +const MAX_FFMPEG_ERROR_LINES = 3; +const ERROR_LINE_PATTERN = + /error|invalid|failed|unsupported|cannot|unknown|unrecognized|not found|no such|no capable|reconfiguring/i; +const PROGRESS_LINE_PATTERN = /^\s*[\w.]+=\S*\s*$/; + +export class FfmpegExitError extends Error { + constructor( + message: string, + readonly errorLines: string[], + ) { + super(message); + } +} + +function isFfmpegErrorLine(line: string): boolean { + return ERROR_LINE_PATTERN.test(line) && !PROGRESS_LINE_PATTERN.test(line); +} + +export function collectFfmpegErrorLine(errorLines: string[], line: string) { + if (errorLines.length < MAX_FFMPEG_ERROR_LINES && isFfmpegErrorLine(line)) + errorLines.push(line.trim()); +} + +export function ffmpegErrorLines(stderr: string): string[] { + const errorLines: string[] = []; + for (const line of stderr.split(/\r\n|\r|\n/)) { + collectFfmpegErrorLine(errorLines, line); + } + return errorLines; +} diff --git a/apps/media-server/src/lib/job-manager.ts b/apps/media-server/src/lib/job-manager.ts index 74caa0deaa7..5c05238f20c 100644 --- a/apps/media-server/src/lib/job-manager.ts +++ b/apps/media-server/src/lib/job-manager.ts @@ -124,6 +124,7 @@ export interface Job { webhookLastAttemptAt?: number; recordingVerificationDeadlineAt?: number; recordingProcessingDeadlineAt?: number; + lifetimeDeadlineAt?: number; recordingVerification?: RecordingVerificationProof; manifestSha256?: string; jobId: string; @@ -147,9 +148,17 @@ export interface Job { } const jobs = new Map(); +const jobTotals = { + created: 0, + completed: 0, + errored: 0, + cancelled: 0, + expired: 0, +}; const JOB_TTL_MS = 60 * 60 * 1000; const STALE_JOB_MS = 15 * 60 * 1000; const MAX_JOB_LIFETIME_MS = 60 * 60 * 1000; +const MAX_EXTENDED_JOB_LIFETIME_MS = 4 * 60 * 60 * 1000; const MAX_RECORDING_PROCESSING_BUDGET_MS = 3 * 60 * 60 * 1000; const WEBHOOK_MAX_ATTEMPTS = 3; const WEBHOOK_RETRY_BASE_MS = 500; @@ -366,9 +375,14 @@ export function createJob( webhookSecret, }; jobs.set(jobId, job); + jobTotals.created++; return job; } +export function getJobTotals(): typeof jobTotals { + return { ...jobTotals }; +} + export function getJob(jobId: string): Job | undefined { return jobs.get(jobId); } @@ -410,12 +424,37 @@ export function updateJob( job.terminalAt ??= job.updatedAt; if (job.recordingWorkerLeaseTimer) clearTimeout(job.recordingWorkerLeaseTimer); + if (job.phase === "complete") jobTotals.completed++; + else if (job.phase === "error") jobTotals.errored++; + else jobTotals.cancelled++; } if (!isActivePhase(job.phase) && job.webhookInFlight) job.webhookPending = true; return job; } +export function extendJobLifetime( + jobId: string, + remainingBudgetMs: number, +): number | undefined { + const job = jobs.get(jobId); + if ( + !job || + !isActivePhase(job.phase) || + !Number.isFinite(remainingBudgetMs) || + remainingBudgetMs <= 0 + ) + return undefined; + job.lifetimeDeadlineAt = Math.min( + job.createdAt + MAX_EXTENDED_JOB_LIFETIME_MS, + Math.max( + job.lifetimeDeadlineAt ?? job.createdAt + MAX_JOB_LIFETIME_MS, + Date.now() + remainingBudgetMs, + ), + ); + return job.lifetimeDeadlineAt; +} + export function touchJob(jobId: string): Job | undefined { const job = jobs.get(jobId); if (!job) return undefined; @@ -556,11 +595,14 @@ export function cleanupExpiredJobs(): number { `[job-manager] Cleaning up expired job ${jobId} (phase=${job.phase}, age=${Math.round(age / 60000)}m)`, ); job.abortController?.abort(); - updateJob(jobId, { - phase: "error", - error: `Job expired: no progress update for ${Math.round(staleness / 60000)} minutes`, - message: "Processing failed (expired)", - }); + if ( + updateJob(jobId, { + phase: "error", + error: `Job expired: no progress update for ${Math.round(staleness / 60000)} minutes`, + message: "Processing failed (expired)", + }) + ) + jobTotals.expired++; void sendWebhook(job); } cleaned++; @@ -572,34 +614,40 @@ export function cleanupExpiredJobs(): number { `[job-manager] Marking stale job ${jobId} as error (phase=${job.phase}, no update for ${Math.round(staleness / 60000)}m)`, ); job.abortController?.abort(); - updateJob(jobId, { - phase: "error", - error: `Job stale: no progress update for ${Math.round(staleness / 60000)} minutes`, - message: "Processing failed (stale)", - }); + if ( + updateJob(jobId, { + phase: "error", + error: `Job stale: no progress update for ${Math.round(staleness / 60000)} minutes`, + message: "Processing failed (stale)", + }) + ) + jobTotals.expired++; void sendWebhook(job); cleaned++; continue; } + const lifetimeDeadline = + job.lifetimeDeadlineAt ?? job.createdAt + MAX_JOB_LIFETIME_MS; const deadline = job.recordingVerificationDeadlineAt ?? job.recordingProcessingDeadlineAt ?? - job.createdAt + MAX_JOB_LIFETIME_MS; + lifetimeDeadline; if (isActivePhase(job.phase) && now > deadline) { console.warn( `[job-manager] Marking long-running job ${jobId} as error (phase=${job.phase}, age=${Math.round(age / 60000)}m)`, ); job.abortController?.abort(); - updateJob(jobId, { + const expired = updateJob(jobId, { phase: "error", error: job.recordingVerificationDeadlineAt === undefined && job.recordingProcessingDeadlineAt === undefined - ? `Job exceeded maximum lifetime of ${Math.round(MAX_JOB_LIFETIME_MS / 60000)} minutes` + ? `Job exceeded maximum lifetime of ${Math.round((lifetimeDeadline - job.createdAt) / 60000)} minutes` : "Recording verification timed out", message: "Processing failed (timeout)", }); + if (expired) jobTotals.expired++; void sendWebhook(job); cleaned++; continue; diff --git a/apps/media-server/src/lib/lifecycle.ts b/apps/media-server/src/lib/lifecycle.ts new file mode 100644 index 00000000000..fd60e537a30 --- /dev/null +++ b/apps/media-server/src/lib/lifecycle.ts @@ -0,0 +1,192 @@ +import { getActiveVideoProcessCount } from "./job-manager"; +import { + getActiveAudioOperationCount, + getActiveProbeOperationCount, +} from "./media-operations"; +import { getActiveSubprocessCount } from "./subprocess"; + +const DRAIN_POLL_INTERVAL_MS = 250; +const DRAIN_LOG_INTERVAL_MS = 30_000; +export const DRAIN_RETRY_AFTER_SECONDS = 15; +export const DRAINING_DETAILS = + "Server is draining for shutdown, please retry later"; + +const MAX_IDLE_TIMEOUT_SECONDS = 255; + +let draining = false; +let inFlightRequests = 0; +const backgroundTasks = new Set>(); + +// Bun closes a connection after idleTimeout seconds without I/O, including +// while a handler is still working, and its 10 s default is shorter than the +// AWS ALB keep-alive idle timeout (60 s), which turns reused sockets into 502s. +export function getIdleTimeoutSeconds(): number { + const value = Number.parseInt( + process.env.MEDIA_SERVER_IDLE_TIMEOUT_SECONDS ?? "", + 10, + ); + return Number.isFinite(value) && value >= 0 + ? Math.min(MAX_IDLE_TIMEOUT_SECONDS, value) + : MAX_IDLE_TIMEOUT_SECONDS; +} + +// A streamed response can stay silent while ffmpeg opens its input, so the +// idle timeout only comes back once the first bytes are on their way. +export function restoreIdleTimeoutOnFirstChunk( + stream: ReadableStream, + restore: () => void, +): ReadableStream { + let restored = false; + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (!restored) { + restored = true; + restore(); + } + controller.enqueue(chunk); + }, + }), + ); +} + +type IdleTimeoutServer = { + timeout?: (request: Request, seconds: number) => void; +}; + +export function setRequestIdleTimeout( + server: unknown, + request: Request, + seconds: number, +): void { + const timeout = (server as IdleTimeoutServer | undefined)?.timeout; + if (typeof timeout !== "function") return; + try { + timeout.call(server, request, seconds); + } catch {} +} + +export function getShutdownGraceMs(): number { + const value = Number.parseInt( + process.env.MEDIA_SERVER_SHUTDOWN_GRACE_MS ?? "0", + 10, + ); + return Number.isFinite(value) && value > 0 ? value : 0; +} + +export function isDraining(): boolean { + return draining; +} + +export function setDraining(value: boolean): void { + draining = value; +} + +export function trackBackgroundTask(task: Promise): Promise { + backgroundTasks.add(task); + const settle = () => { + backgroundTasks.delete(task); + }; + task.then(settle, settle); + return task; +} + +export function beginRequest(): () => void { + inFlightRequests++; + let ended = false; + return () => { + if (ended) return; + ended = true; + inFlightRequests--; + }; +} + +export function getDrainBlockers() { + return { + activeVideoProcesses: getActiveVideoProcessCount(), + backgroundTasks: backgroundTasks.size, + inFlightRequests, + audioOperations: getActiveAudioOperationCount(), + probeOperations: getActiveProbeOperationCount(), + subprocesses: getActiveSubprocessCount(), + }; +} + +export function isIdle(): boolean { + return Object.values(getDrainBlockers()).every((count) => count === 0); +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + signal?.addEventListener("abort", done, { once: true }); + }); +} + +export async function waitForDrain( + graceMs: number, + signal?: AbortSignal, +): Promise { + const startedAt = Date.now(); + const deadline = startedAt + graceMs; + let lastLogAt = startedAt; + while (!isIdle()) { + const now = Date.now(); + if (signal?.aborted || now >= deadline) return false; + if (now - lastLogAt >= DRAIN_LOG_INTERVAL_MS) { + lastLogAt = now; + console.log("[media-server] Draining", { + remainingMs: deadline - now, + ...getDrainBlockers(), + }); + } + await wait(Math.min(DRAIN_POLL_INTERVAL_MS, deadline - now), signal); + } + return true; +} + +export function createShutdownHandler( + finalize: () => Promise, + graceMs = getShutdownGraceMs, +): (signal: string) => Promise { + let shuttingDown = false; + let forceDrain: AbortController | undefined; + + return async (signal) => { + if (forceDrain) { + console.log( + `[media-server] Received ${signal} while draining, shutting down now`, + ); + forceDrain.abort(); + return; + } + if (shuttingDown) return; + shuttingDown = true; + + const drainMs = graceMs(); + if (drainMs > 0) { + forceDrain = new AbortController(); + setDraining(true); + console.log( + `[media-server] Received ${signal}, draining for up to ${drainMs}ms`, + getDrainBlockers(), + ); + const drained = await waitForDrain(drainMs, forceDrain.signal); + console.log( + drained + ? "[media-server] Drain complete" + : "[media-server] Drain ended with work still active", + getDrainBlockers(), + ); + } else { + console.log("[media-server] Shutting down..."); + } + + await finalize(); + }; +} diff --git a/apps/media-server/src/lib/log-redaction.ts b/apps/media-server/src/lib/log-redaction.ts new file mode 100644 index 00000000000..bf30f4bca69 --- /dev/null +++ b/apps/media-server/src/lib/log-redaction.ts @@ -0,0 +1,30 @@ +const MAX_LOG_REASON_LENGTH = 300; + +const REDACTIONS: [RegExp, string][] = [ + [/[a-z][a-z0-9+.-]*:\/\/\S+/gi, ""], + [/\b[\w.-]+\.amazonaws\.com\S*/gi, ""], + [/X-Amz-[A-Za-z-]+=[^\s&]*/gi, ""], + [/\?[^\s?=]+=\S*/g, "?"], + [/\bBearer\s+\S+/gi, "Bearer "], + [ + /\b(token|secret|signature|password|credential|api[_-]?key|access[_-]?key)(\s*[=:]\s*)\S+/gi, + "$1$2", + ], + [/(?"], +]; + +export function redactLogText(text: string): string { + let redacted = text; + for (const [pattern, replacement] of REDACTIONS) { + redacted = redacted.replace(pattern, replacement); + } + return redacted.replace(/\s+/g, " ").trim(); +} + +export function logReason( + error: unknown, + maxLength = MAX_LOG_REASON_LENGTH, +): string { + const message = error instanceof Error ? error.message : String(error); + return redactLogText(message).slice(0, maxLength) || "unknown error"; +} diff --git a/apps/media-server/src/lib/media-audio.ts b/apps/media-server/src/lib/media-audio.ts index 5e438acbd2b..eeb2096c011 100644 --- a/apps/media-server/src/lib/media-audio.ts +++ b/apps/media-server/src/lib/media-audio.ts @@ -399,10 +399,12 @@ export function extractAudioStream( let timeoutId: ReturnType | undefined; let cleaned = false; let reader: ReadableStreamDefaultReader | null = null; + let streamController: ReadableStreamDefaultController | undefined; - const cleanup = () => { + const finish = (error?: Error) => { if (cleaned) return; cleaned = true; + if (error) streamController?.error(error); if (timeoutId) clearTimeout(timeoutId); if (reader) { try { @@ -414,22 +416,31 @@ export function extractAudioStream( unregisterMediaOperation(operation); void terminateProcess(proc); }; + const cleanup = () => finish(new Error("Audio extraction cancelled")); timeoutId = setTimeout(() => { - cleanup(); + finish(new Error(`Operation timed out after ${opts.timeoutMs}ms`)); }, opts.timeoutMs); void drainStream(proc.stderr as ReadableStream); - proc.exited.then(() => { - cleanup(); + proc.exited.then((exitCode) => { + if (cleaned) return; + if (exitCode !== 0) { + finish(new Error(`FFmpeg exited with code ${exitCode}`)); + return; + } + if (timeoutId) clearTimeout(timeoutId); + unregisterMediaOperation(operation); + void terminateProcess(proc); }); const originalStream = proc.stdout as ReadableStream; const stream = new ReadableStream( { - start() { + start(controller) { + streamController = controller; reader = originalStream.getReader(); }, async pull(controller) { @@ -440,15 +451,18 @@ export function extractAudioStream( try { const { done, value } = await reader.read(); + if (cleaned) return; if (done) { + await proc.exited; + if (cleaned) return; controller.close(); - cleanup(); + finish(); } else { controller.enqueue(value); } } catch (err) { controller.error(err); - cleanup(); + finish(); } }, cancel() { diff --git a/apps/media-server/src/lib/media-common.ts b/apps/media-server/src/lib/media-common.ts index ad3c692242e..875a8faba37 100644 --- a/apps/media-server/src/lib/media-common.ts +++ b/apps/media-server/src/lib/media-common.ts @@ -32,6 +32,20 @@ export const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000; export const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000; const mediaFetch: typeof fetch = globalThis.fetch.bind(globalThis); +export function decodeThreadsFromEnv( + name: string, + fallback: number, + env: Record = process.env, +): string { + const raw = env[name]?.trim(); + if (!raw) return String(fallback); + const value = Number(raw); + if (!Number.isInteger(value) || value < 0 || value > 64) { + return String(fallback); + } + return String(value); +} + export async function withTimeout( promise: Promise, timeoutMs: number, diff --git a/apps/media-server/src/lib/media-edit.ts b/apps/media-server/src/lib/media-edit.ts index aa8b69c5326..88e65bf9cf2 100644 --- a/apps/media-server/src/lib/media-edit.ts +++ b/apps/media-server/src/lib/media-edit.ts @@ -1,6 +1,15 @@ import { writeFile } from "node:fs/promises"; import { file, spawn } from "bun"; +import { FfmpegExitError, ffmpegErrorLines } from "./ffmpeg-errors"; import type { VideoMetadata } from "./job-manager"; +import { + buildNvencEncodeArgs, + logNvencFallback, + type NvencOptions, + nvencFallbackReason, + nvencOptionsFromEnv, + recordNvencAttempt, +} from "./nvenc-encoder"; import { registerSubprocess, terminateProcess } from "./subprocess"; import { createTempFile, type TempFileHandle } from "./temp-files"; @@ -24,6 +33,7 @@ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; const MAX_TIMEOUT_MS = 60 * 60 * 1000; const DEFAULT_OUTPUT_FPS = 30; const MAX_TRANSCODE_RANGES_PER_BATCH = 4; +const EDIT_CRF = 18; function roundTime(value: number) { return Math.round(value * 1000) / 1000; @@ -37,7 +47,7 @@ function getTotalRangeDuration(ranges: EditRange[]) { return ranges.reduce((total, range) => total + getRangeDuration(range), 0); } -function getTimeoutMs(ranges: EditRange[]) { +export function getTimeoutMs(ranges: EditRange[]) { const durationMs = getTotalRangeDuration(ranges) * 20_000; return Math.min(MAX_TIMEOUT_MS, Math.max(DEFAULT_TIMEOUT_MS, durationMs)); } @@ -154,6 +164,7 @@ export function buildTranscodeEditArgs( outputPath: string, hasAudio: boolean, fps = DEFAULT_OUTPUT_FPS, + nvenc: NvencOptions | null = null, ) { if (ranges.length === 0 || ranges.length > MAX_TRANSCODE_RANGES_PER_BATCH) { throw new Error( @@ -187,12 +198,9 @@ export function buildTranscodeEditArgs( [...filters, concat].join(";"), "-map", "[v]", - "-c:v", - "libx264", - "-preset", - "fast", - "-crf", - "18", + ...(nvenc + ? buildNvencEncodeArgs(nvenc, EDIT_CRF) + : ["-c:v", "libx264", "-preset", "fast", "-crf", String(EDIT_CRF)]), "-pix_fmt", "yuv420p", "-enc_time_base:v", @@ -319,8 +327,9 @@ async function runFfmpegCommand( ]); if (exitCode !== 0) { - throw new Error( + throw new FfmpegExitError( `FFmpeg exited with code ${exitCode}. Last stderr: ${stderrText.slice(-2000)}`, + ffmpegErrorLines(stderrText), ); } })(), @@ -398,6 +407,7 @@ async function renderTranscodedEdit( hasAudio: boolean, fps: number | undefined, timeoutMs: number, + nvenc: NvencOptions | null, onProgress?: ProgressCallback, abortSignal?: AbortSignal, ) { @@ -411,7 +421,14 @@ async function renderTranscodedEdit( const batchFile = await createTempFile(".mp4"); batchFiles.push(batchFile); await runFfmpegCommand( - buildTranscodeEditArgs(inputPath, batch, batchFile.path, hasAudio, fps), + buildTranscodeEditArgs( + inputPath, + batch, + batchFile.path, + hasAudio, + fps, + nvenc, + ), getRemainingTimeoutMs(startedAt, timeoutMs), abortSignal, ); @@ -456,14 +473,36 @@ export async function renderEditedVideo({ } const timeoutMs = getTimeoutMs(normalizedRanges); + const render = (nvenc: NvencOptions | null) => + renderTranscodedEdit( + inputPath, + normalizedRanges, + Boolean(metadata.audioCodec), + metadata.fps, + timeoutMs, + nvenc, + onProgress, + abortSignal, + ); - return await renderTranscodedEdit( - inputPath, - normalizedRanges, - Boolean(metadata.audioCodec), - metadata.fps, - timeoutMs, - onProgress, - abortSignal, - ); + const nvenc = nvencOptionsFromEnv(); + if (nvenc) { + try { + const outputFile = await render(nvenc); + recordNvencAttempt("edit"); + return outputFile; + } catch (error) { + const reason = nvencFallbackReason(error, abortSignal?.aborted ?? false); + if (reason === null) throw error; + recordNvencAttempt("edit", reason); + logNvencFallback({ + job: "edit", + sourceCodec: metadata.videoCodec, + gpuDecode: false, + reason, + }); + } + } + + return await render(null); } diff --git a/apps/media-server/src/lib/media-operations.ts b/apps/media-server/src/lib/media-operations.ts index 1c9901dd89e..c1af412092f 100644 --- a/apps/media-server/src/lib/media-operations.ts +++ b/apps/media-server/src/lib/media-operations.ts @@ -30,6 +30,14 @@ export function getActiveAudioOperationCount(): number { return count; } +export function getMaxConcurrentAudioOperations(): number { + return MAX_CONCURRENT_AUDIO_OPERATIONS; +} + +export function getMaxConcurrentProbeOperations(): number { + return MAX_CONCURRENT_PROBE_OPERATIONS; +} + export function canAcceptNewAudioOperation(): boolean { return ( !hasCriticalMemoryPressure() && diff --git a/apps/media-server/src/lib/media-transfer.ts b/apps/media-server/src/lib/media-transfer.ts index 62720779397..a74cbc40932 100644 --- a/apps/media-server/src/lib/media-transfer.ts +++ b/apps/media-server/src/lib/media-transfer.ts @@ -1,9 +1,15 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { mkdir, open, readdir, rm, stat } from "node:fs/promises"; +import { open, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; +import { + ensureInstanceDir, + getInstanceDir, + releaseInstanceDir, + sweepOrphanedInstanceDirs, +} from "./temp-files"; const targetSchema = z.object({ version: z.literal(1), @@ -36,46 +42,59 @@ const transfers = new AsyncLocalStorage(); const cache = new Map(); const pendingTransfers = new Map>(); const cacheBase = join(tmpdir(), "cap-media-download-cache"); -const cacheRoot = join(cacheBase, String(process.pid)); +const cacheRoot = getInstanceDir(cacheBase); const CACHE_BYTES = 2 * 1024 ** 3; const CACHE_TTL_MS = 30 * 60_000; let cacheBytes = 0; let lastPruneAt = 0; -async function initializeCache() { - await mkdir(cacheRoot, { recursive: true, mode: 0o700 }); - for (const entry of await readdir(cacheBase, { withFileTypes: true })) { - if ( - !entry.isDirectory() || - !/^\d+$/.test(entry.name) || - entry.name === String(process.pid) - ) - continue; - try { - process.kill(Number(entry.name), 0); - } catch (error) { - if ( - typeof error === "object" && - error !== null && - "code" in error && - error.code === "ESRCH" - ) - await rm(join(cacheBase, entry.name), { recursive: true, force: true }); - } +// Pre-instance-dir caches are named after their owner's pid. Only remove one +// once that process is confirmed gone; an old server sharing this volume during +// a rolling deploy may still be reading from its cache. +function isLegacyCacheOrphan(name: string) { + if (!/^\d+$/.test(name)) return false; + if (name === String(process.pid)) return false; + try { + process.kill(Number(name), 0); + return false; + } catch (error) { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ESRCH" + ); } } + +export function sweepMediaTransferCache(): Promise { + return sweepOrphanedInstanceDirs(cacheBase, isLegacyCacheOrphan); +} + +async function initializeCache() { + await ensureInstanceDir(cacheBase, 0o700); + await sweepMediaTransferCache(); +} let cacheReady: Promise | undefined; const pruneTimer = setInterval(() => { void pruneCache().catch(() => {}); }, 60_000); pruneTimer.unref?.(); +const sweepTimer = setInterval(() => { + void sweepMediaTransferCache().catch(() => {}); +}, 5 * 60_000); +sweepTimer.unref?.(); export async function cleanupMediaTransferCache() { - await rm(cacheRoot, { recursive: true, force: true }).catch(() => {}); + await releaseInstanceDir(cacheBase); cache.clear(); cacheBytes = 0; cacheReady = undefined; } +export function getMediaTransferCacheUsage() { + return { bytes: cacheBytes, entries: cache.size }; +} + export function isInternalMediaUrl(input: string): boolean { try { const url = new URL(input); diff --git a/apps/media-server/src/lib/media-video.ts b/apps/media-server/src/lib/media-video.ts index 48158979327..da16eceb2f3 100644 --- a/apps/media-server/src/lib/media-video.ts +++ b/apps/media-server/src/lib/media-video.ts @@ -10,9 +10,11 @@ import { remainingTimeoutMs, } from "./capcodec-encoder"; import { uploadDriveResumable } from "./drive-resumable-upload"; +import { collectFfmpegErrorLine, FfmpegExitError } from "./ffmpeg-errors"; import type { VideoMetadata } from "./job-manager"; import { DOWNLOAD_TIMEOUT_MS, + decodeThreadsFromEnv, normalizeLocalPath, PROCESS_TIMEOUT_MS, type ProgressCallback, @@ -21,6 +23,23 @@ import { } from "./media-common"; import { probeVideoFile } from "./media-probe"; import { fetchMedia, materializeMedia } from "./media-transfer"; +import { + buildGpuDecodeInputArgs, + buildNvencEncodeArgs, + canDecodeOnGpu, + fitWithinEven, + GPU_DECODE_FORMAT_FILTER, + logNvencFallback, + type NvencOptions, + nvencFallbackReason, + nvencOptionsFromEnv, + recordNvencAttempt, + selectedVideoEncoder, +} from "./nvenc-encoder"; +import { + downloadUrlToPathParallel, + parallelDownloadOptionsFromEnv, +} from "./parallel-download"; import { readRecordingAudioTail } from "./recording-packet-proof"; import { RecordingTimingError, @@ -41,7 +60,7 @@ const MAX_PROCESS_TIMEOUT_MS = 2 * 60 * 60 * 1000; // flat 10-minute budget is enough for typical short recordings but not for a // long (e.g. 60+ minute) manifest with 1000+ segments. const STREAMING_DOWNLOAD_TIMEOUT_PER_SECOND_MS = 5_000; -const MAX_STREAMING_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000; +const MAX_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000; const THUMBNAIL_TIMEOUT_MS = 60_000; const PREVIEW_GIF_TIMEOUT_MS = 30_000; const PROBE_H264_LEVEL_TIMEOUT_MS = 10_000; @@ -58,6 +77,7 @@ const MULTIPART_MIN_PART_SIZE_BYTES = 5 * 1024 * 1024; const MULTIPART_MAX_PARTS = 10_000; const MULTIPART_ABORT_TIMEOUT_MS = 30_000; const STORAGE_ERROR_BODY_LIMIT_BYTES = 2_048; +export const LOOM_IMPORT_MAX_BYTES = 10 * 1024 ** 3; export type StorageUploadTarget = | { @@ -77,6 +97,7 @@ export type StorageUploadTarget = completeUrl: string; abortUrl: string; webhookSecret?: string; + loomImportAttemptId?: string; }; export interface StorageUploadReceipt { @@ -1175,7 +1196,7 @@ function getStreamingDownloadTimeoutMs(durationSeconds: number | null): number { if (!durationSeconds) return DOWNLOAD_TIMEOUT_MS; return Math.min( - MAX_STREAMING_DOWNLOAD_TIMEOUT_MS, + MAX_DOWNLOAD_TIMEOUT_MS, Math.max( DOWNLOAD_TIMEOUT_MS, Math.ceil(durationSeconds * STREAMING_DOWNLOAD_TIMEOUT_PER_SECOND_MS), @@ -1186,10 +1207,24 @@ function getStreamingDownloadTimeoutMs(durationSeconds: number | null): number { async function downloadStreamingVideoToTemp( videoUrl: string, abortSignal?: AbortSignal, + maxBytes?: number, ): Promise { await ensureTempDir(); const manifestDir = await mkdtemp(join(getTempDir(), "stream-")); const tempFile = await createTempFile(".mkv"); + const sizeController = new AbortController(); + const signal = AbortSignal.any([ + sizeController.signal, + ...(abortSignal ? [abortSignal] : []), + ]); + const sizeCheck = maxBytes + ? setInterval(() => { + if (file(tempFile.path).size > maxBytes) { + sizeController.abort(new Error("Loom imports are limited to 10 GB")); + } + }, 250) + : undefined; + sizeCheck?.unref(); const cleanup = async () => { await tempFile.cleanup(); await rm(manifestDir, { force: true, recursive: true }).catch(() => {}); @@ -1200,7 +1235,7 @@ async function downloadStreamingVideoToTemp( const inputPath = await materializeStreamingInput( videoUrl, manifestDir, - abortSignal, + signal, ); const durationSeconds = await estimateMaterializedStreamingDurationSeconds(manifestDir); @@ -1213,8 +1248,11 @@ async function downloadStreamingVideoToTemp( ffmpegHlsCapabilities, ), downloadTimeoutMs, - abortSignal, + signal, ); + if (maxBytes && file(tempFile.path).size > maxBytes) { + throw new Error("Loom imports are limited to 10 GB"); + } if (file(tempFile.path).size === 0) { throw new Error("Streaming download produced empty file"); @@ -1226,7 +1264,10 @@ async function downloadStreamingVideoToTemp( }; } catch (err) { await cleanup(); + if (sizeController.signal.aborted) throw sizeController.signal.reason; throw err; + } finally { + if (sizeCheck) clearInterval(sizeCheck); } } @@ -1234,24 +1275,44 @@ export async function downloadVideoToTemp( videoUrl: string, inputExtension?: string, abortSignal?: AbortSignal, + maxBytes?: number, ): Promise { if (isStreamingUrl(videoUrl)) { - return await downloadStreamingVideoToTemp(videoUrl, abortSignal); + return await downloadStreamingVideoToTemp(videoUrl, abortSignal, maxBytes); } const local = await materializeMedia(videoUrl, abortSignal); - if (local) return { path: local.path, cleanup: async () => {} }; + if (local) { + if (maxBytes && file(local.path).size > maxBytes) { + throw new Error("Loom imports are limited to 10 GB"); + } + return { path: local.path, cleanup: async () => {} }; + } const tempFile = await createTempFile( normalizeVideoInputExtension(inputExtension), ); try { - const timeoutSignal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS); + const timeoutSignal = AbortSignal.timeout( + maxBytes === undefined ? DOWNLOAD_TIMEOUT_MS : MAX_DOWNLOAD_TIMEOUT_MS, + ); const combinedSignal = abortSignal ? AbortSignal.any([abortSignal, timeoutSignal]) : timeoutSignal; + const parallel = parallelDownloadOptionsFromEnv(); + if (parallel.concurrency > 1 && maxBytes === undefined) { + await downloadUrlToPathParallel(videoUrl, tempFile.path, { + ...parallel, + signal: combinedSignal, + }); + if (file(tempFile.path).size === 0) { + throw new Error("Downloaded video is empty"); + } + return tempFile; + } + const response = await fetchMedia(videoUrl, { signal: combinedSignal, }); @@ -1266,18 +1327,31 @@ export async function downloadVideoToTemp( if (!response.body) { throw new Error("No response body"); } + if (maxBytes && Number(response.headers.get("content-length")) > maxBytes) { + await response.body.cancel(); + throw new Error("Loom imports are limited to 10 GB"); + } const reader = response.body.getReader(); const writer = file(tempFile.path).writer(); + let downloadedBytes = 0; + let writerEnded = false; try { while (true) { const { done, value } = await reader.read(); if (done) break; + downloadedBytes += value.byteLength; + if (maxBytes && downloadedBytes > maxBytes) { + throw new Error("Loom imports are limited to 10 GB"); + } writer.write(value); } + writerEnded = true; await writer.end(); } finally { + await reader.cancel().catch(() => {}); reader.releaseLock(); + if (!writerEnded) await Promise.resolve(writer.end()).catch(() => {}); } if (file(tempFile.path).size === 0) { @@ -1311,7 +1385,7 @@ function needsAudioTranscode(metadata: VideoMetadata): boolean { return Boolean(metadata.audioCodec && metadata.audioCodec !== "aac"); } -function getProcessTimeoutMs( +export function getProcessTimeoutMs( durationSeconds: number, baseTimeoutMs: number, ): number { @@ -1425,10 +1499,11 @@ export function pickMobileSafeH264Level( return { value: 52, ffmpegValue: "5.2" }; } -async function probeH264Level( +async function probeVideoStreamEntries( inputPath: string, + showEntries: string, abortSignal?: AbortSignal, -): Promise { +): Promise { const proc = registerSubprocess( spawn({ cmd: [ @@ -1438,7 +1513,7 @@ async function probeH264Level( "-select_streams", "v:0", "-show_entries", - "stream=level", + showEntries, "-of", "default=noprint_wrappers=1:nokey=1", inputPath, @@ -1472,10 +1547,7 @@ async function probeH264Level( ); const exitCode = await proc.exited; if (exitCode !== 0) return null; - - const rawLevel = stdout.trim().split(/\s+/)[0] ?? ""; - const level = Number.parseInt(rawLevel, 10); - return Number.isFinite(level) && level > 0 ? level : null; + return stdout.trim(); } catch { return null; } finally { @@ -1486,6 +1558,37 @@ async function probeH264Level( } } +async function probeH264Level( + inputPath: string, + abortSignal?: AbortSignal, +): Promise { + const stdout = await probeVideoStreamEntries( + inputPath, + "stream=level", + abortSignal, + ); + if (stdout === null) return null; + const rawLevel = stdout.split(/\s+/)[0] ?? ""; + const level = Number.parseInt(rawLevel, 10); + return Number.isFinite(level) && level > 0 ? level : null; +} + +async function probeVideoRotation( + inputPath: string, + abortSignal?: AbortSignal, +): Promise { + const stdout = await probeVideoStreamEntries( + inputPath, + "stream_side_data=rotation", + abortSignal, + ); + if (stdout === null) return null; + const rawRotation = stdout.split(/\s+/)[0] ?? ""; + if (rawRotation === "") return 0; + const rotation = Number.parseFloat(rawRotation); + return Number.isFinite(rotation) ? rotation : null; +} + export async function processVideo( inputPath: string, metadata: VideoMetadata, @@ -1550,38 +1653,176 @@ export async function processVideo( throw err; } } + const argsInput: ProcessVideoArgsInput = { + inputPath, + outputPath: outputTempFile.path, + videoCodec: metadata.videoCodec, + hasAudio: Boolean(metadata.audioCodec), + opts, + targetH264Level: targetH264Level.ffmpegValue, + normalizeH264Level, + videoTranscode, + audioTranscode, + extraInputArgs, + extraOutputArgs, + sourceWidth: metadata.width, + sourceHeight: metadata.height, + // NVENC leaves the CPU free, so decode (and scale) can use every core. + decodeThreads: decodeThreadsFromEnv( + "MEDIA_SERVER_PROCESS_DECODE_THREADS", + selectedVideoEncoder() === "nvenc" ? 0 : 2, + ), + }; + const totalDurationUs = metadata.duration * 1_000_000; + const nvenc = + videoTranscode && !normalizeH264Level ? nvencOptionsFromEnv() : null; + + try { + if (nvenc) { + const gpuDecode = canDecodeOnGpu( + metadata.videoCodec, + await probeVideoRotation(inputPath, abortSignal), + nvenc.gpuDecodeCodecs, + ); + try { + await runTranscodeCommand( + buildProcessVideoArgs({ + ...argsInput, + nvenc: { options: nvenc, gpuDecode }, + }), + outputTempFile.path, + totalDurationUs, + processTimeoutMs, + onProgress, + abortSignal, + ); + recordNvencAttempt("process"); + return outputTempFile; + } catch (error) { + const reason = nvencFallbackReason( + error, + abortSignal?.aborted ?? false, + ); + if (reason === null) throw error; + recordNvencAttempt("process", reason); + logNvencFallback({ + job: "process", + sourceCodec: metadata.videoCodec, + gpuDecode, + reason, + }); + } + } + + await runTranscodeCommand( + buildProcessVideoArgs(argsInput), + outputTempFile.path, + totalDurationUs, + processTimeoutMs, + onProgress, + abortSignal, + ); + return outputTempFile; + } catch (err) { + await outputTempFile.cleanup(); + throw err; + } +} + +export interface ProcessVideoArgsInput { + inputPath: string; + outputPath: string; + videoCodec: string; + hasAudio: boolean; + opts: Pick< + Required, + "maxWidth" | "maxHeight" | "preset" | "crf" | "audioBitrate" + >; + targetH264Level: string; + normalizeH264Level: boolean; + videoTranscode: boolean; + audioTranscode: boolean; + extraInputArgs: string[]; + extraOutputArgs: string[]; + decodeThreads: string; + sourceWidth?: number; + sourceHeight?: number; + nvenc?: { options: NvencOptions; gpuDecode: boolean } | null; +} + +export function buildProcessVideoArgs(input: ProcessVideoArgsInput): string[] { + const { opts, nvenc } = input; + const gpuDecode = Boolean( + nvenc?.gpuDecode && input.sourceWidth && input.sourceHeight, + ); + const source = { + width: input.sourceWidth ?? 0, + height: input.sourceHeight ?? 0, + }; + const cpuScaleFilter = `scale='min(${opts.maxWidth},iw)':'min(${opts.maxHeight},ih)':force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2`; const ffmpegArgs: string[] = [ "ffmpeg", "-threads", - "2", - ...extraInputArgs, + input.decodeThreads, + ...(gpuDecode + ? buildGpuDecodeInputArgs( + input.videoCodec, + source, + fitWithinEven( + source.width, + source.height, + opts.maxWidth, + opts.maxHeight, + ), + ) + : []), + ...input.extraInputArgs, + // Browser captures change size when the tab resizes. Rebuilding the filter graph for that crashes h264_nvenc + // (SIGSEGV on FFmpeg 7.1, even with a fixed output size), so keep the first graph; scale absorbs the new size + // exactly as the libx264 fallback's auto-inserted scaler does. + ...(nvenc && !gpuDecode && input.videoTranscode && !input.normalizeH264Level + ? ["-reinit_filter", "0"] + : []), "-i", - inputPath, + input.inputPath, ]; - if (normalizeH264Level) { + if (input.normalizeH264Level) { ffmpegArgs.push( "-c:v", "copy", "-bsf:v", - `h264_metadata=level=${targetH264Level.ffmpegValue}`, + `h264_metadata=level=${input.targetH264Level}`, ); - } else if (videoTranscode) { - ffmpegArgs.push( - "-c:v", - "libx264", - "-preset", - opts.preset, - "-crf", - opts.crf.toString(), - "-vf", - `scale='min(${opts.maxWidth},iw)':'min(${opts.maxHeight},ih)':force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2`, - "-pix_fmt", - "yuv420p", - "-level:v", - targetH264Level.ffmpegValue, - ); - if (metadata.videoCodec === "vp8" || metadata.videoCodec === "vp9") { + } else if (input.videoTranscode) { + if (nvenc) { + ffmpegArgs.push( + ...buildNvencEncodeArgs(nvenc.options, opts.crf), + ...(gpuDecode + ? ["-vf", GPU_DECODE_FORMAT_FILTER] + : ["-vf", cpuScaleFilter, "-pix_fmt", "yuv420p"]), + ); + } else { + ffmpegArgs.push( + "-c:v", + "libx264", + "-preset", + opts.preset, + "-crf", + opts.crf.toString(), + "-vf", + cpuScaleFilter, + "-pix_fmt", + "yuv420p", + ); + } + if (nvenc) { + // h264_nvenc refuses to open when the nominal frame rate exceeds the level (browser WebM often reports 1000 fps); libx264 only writes it into the SPS, so tag it the same way. + ffmpegArgs.push("-bsf:v", `h264_metadata=level=${input.targetH264Level}`); + } else { + ffmpegArgs.push("-level:v", input.targetH264Level); + } + if (input.videoCodec === "vp8" || input.videoCodec === "vp9") { // Reordered B-frames can make MP4 duration exclude the tail of sparse browser captures. ffmpegArgs.push("-bf", "0"); } @@ -1589,8 +1830,8 @@ export async function processVideo( ffmpegArgs.push("-c:v", "copy"); } - if (metadata.audioCodec) { - if (audioTranscode) { + if (input.hasAudio) { + if (input.audioTranscode) { ffmpegArgs.push("-c:a", "aac", "-b:a", opts.audioBitrate); } else { ffmpegArgs.push("-c:a", "copy"); @@ -1602,13 +1843,24 @@ export async function processVideo( ffmpegArgs.push( "-movflags", "+faststart", - ...extraOutputArgs, + ...input.extraOutputArgs, "-progress", "pipe:2", "-y", - outputTempFile.path, + input.outputPath, ); + return ffmpegArgs; +} +async function runTranscodeCommand( + ffmpegArgs: string[], + outputPath: string, + totalDurationUs: number, + processTimeoutMs: number, + onProgress?: ProgressCallback, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) throw new Error("Video processing was cancelled"); const proc = registerSubprocess( spawn({ cmd: ffmpegArgs, @@ -1616,7 +1868,6 @@ export async function processVideo( stderr: "pipe", }), ); - const totalDurationUs = metadata.duration * 1_000_000; let abortCleanup: (() => void) | undefined; if (abortSignal) { abortCleanup = () => { @@ -1627,6 +1878,7 @@ export async function processVideo( const stderrLines: string[] = []; const maxStderrLines = 50; + const errorLines: string[] = []; try { await withTimeout( @@ -1653,6 +1905,7 @@ export async function processVideo( if (stderrLines.length > maxStderrLines) { stderrLines.shift(); } + collectFfmpegErrorLine(errorLines, line); const progress = parseProgressFromStderr(line, totalDurationUs); if (progress !== null && onProgress) { onProgress(progress, `Encoding: ${Math.round(progress)}%`); @@ -1665,12 +1918,13 @@ export async function processVideo( const exitCode = await proc.exited; if (exitCode !== 0) { - throw new Error( + throw new FfmpegExitError( `FFmpeg exited with code ${exitCode}. Last stderr: ${stderrLines.slice(-10).join(" | ")}`, + errorLines, ); } - const outputSize = await file(outputTempFile.path).size; + const outputSize = await file(outputPath).size; if (outputSize === 0) { throw new Error("FFmpeg produced empty output file"); } @@ -1678,11 +1932,6 @@ export async function processVideo( processTimeoutMs, () => terminateProcess(proc), ); - - return outputTempFile; - } catch (err) { - await outputTempFile.cleanup(); - throw err; } finally { if (abortCleanup) { abortSignal?.removeEventListener("abort", abortCleanup); @@ -2389,6 +2638,7 @@ async function getMultipartPartUrl( videoId: target.videoId, generation: target.generation, attemptId: target.attemptId, + loomImportAttemptId: target.loomImportAttemptId, key: target.key, uploadId: target.uploadId, partNumber, @@ -2490,6 +2740,7 @@ async function completeMultipartUpload( videoId: target.videoId, generation: target.generation, attemptId: target.attemptId, + loomImportAttemptId: target.loomImportAttemptId, key: target.key, uploadId: target.uploadId, parts, @@ -2524,6 +2775,7 @@ async function abortMultipartUpload( videoId: target.videoId, generation: target.generation, attemptId: target.attemptId, + loomImportAttemptId: target.loomImportAttemptId, key: target.key, uploadId: target.uploadId, }, @@ -2537,6 +2789,13 @@ async function abortMultipartUpload( await response.body?.cancel().catch(() => {}); } +export function multipartUploadConcurrencyFromEnv( + env: Record = process.env, +): number { + const value = Number.parseInt(env.MEDIA_SERVER_UPLOAD_CONCURRENCY ?? "", 10); + return Number.isFinite(value) && value > 0 ? Math.min(value, 16) : 1; +} + async function uploadFileMultipart( filePath: string, target: Extract, @@ -2564,25 +2823,45 @@ async function uploadFileMultipart( ); } - for (let partNumber = 1; partNumber <= partCount; partNumber++) { - const start = (partNumber - 1) * partSize; - const end = Math.min(start + partSize, contentLength); - const partLength = end - start; - const url = await getMultipartPartUrl( - target, - partNumber, - partLength, - abortSignal, - ); - const etag = await uploadMultipartPart( - url, - fileHandle.slice(start, end), - partLength, - partNumber, - abortSignal, - ); - parts.push({ partNumber, etag, size: partLength }); + const failed = new AbortController(); + const signal = abortSignal + ? AbortSignal.any([abortSignal, failed.signal]) + : failed.signal; + let nextPart = 1; + const worker = async () => { + while (nextPart <= partCount) { + const partNumber = nextPart++; + const start = (partNumber - 1) * partSize; + const end = Math.min(start + partSize, contentLength); + const partLength = end - start; + const url = await getMultipartPartUrl( + target, + partNumber, + partLength, + signal, + ); + const etag = await uploadMultipartPart( + url, + fileHandle.slice(start, end), + partLength, + partNumber, + signal, + ); + parts.push({ partNumber, etag, size: partLength }); + } + }; + const workers = Array.from( + { length: Math.min(multipartUploadConcurrencyFromEnv(), partCount) }, + () => worker(), + ); + try { + await Promise.all(workers); + } catch (error) { + failed.abort(); + await Promise.allSettled(workers); + throw error; } + parts.sort((a, b) => a.partNumber - b.partNumber); return await completeMultipartUpload(target, parts, abortSignal); } catch (error) { diff --git a/apps/media-server/src/lib/nvenc-encoder.ts b/apps/media-server/src/lib/nvenc-encoder.ts new file mode 100644 index 00000000000..162d56b65c2 --- /dev/null +++ b/apps/media-server/src/lib/nvenc-encoder.ts @@ -0,0 +1,259 @@ +import { FfmpegExitError } from "./ffmpeg-errors"; +import { redactLogText } from "./log-redaction"; + +const DEFAULT_PRESET = "p1"; +const DEFAULT_CQ_OFFSET = 8; +const DEFAULT_LOOKAHEAD = 0; +const MAX_LOOKAHEAD = 32; +const GOP_LENGTH = "250"; +const NVENC_PRESETS = new Set(["p1", "p2", "p3", "p4", "p5", "p6", "p7"]); +const NVDEC_CODECS = new Set(["h264", "hevc", "vp9", "av1"]); +// H.264 and VP9 decode faster on the host CPU than on L4 NVDEC; HEVC and AV1 do not. +const DEFAULT_GPU_DECODE_CODECS = ["hevc", "av1"]; +const MAX_FALLBACK_REASON_LENGTH = 500; +const MAX_FFMPEG_CAUSE_LENGTH = 300; + +export const NVENC_HWACCEL_INPUT_ARGS = [ + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", +]; + +export type VideoEncoderName = "libx264" | "capcodec" | "nvenc"; +export type NvencJobKind = "process" | "edit"; + +export interface NvencOptions { + preset: string; + cqOffset: number; + lookahead: number; + spatialAq: boolean; + temporalAq: boolean; + gpuDecodeCodecs: string[]; +} + +interface NvencAttempt { + job: NvencJobKind; + at: string; + fellBack: boolean; + reason: string | null; +} + +const nvencStatus: { + attempts: number; + fallbacks: number; + lastAttempt: NvencAttempt | null; +} = { attempts: 0, fallbacks: 0, lastAttempt: null }; + +type Env = Record; + +export function selectedVideoEncoder(env: Env = process.env): VideoEncoderName { + const value = env.CAP_MEDIA_VIDEO_ENCODER; + if (value === "capcodec" || value === "nvenc") return value; + return "libx264"; +} + +function parseBoundedInt( + raw: string | undefined, + min: number, + max: number, +): number | undefined { + if (raw === undefined || raw.trim() === "") return undefined; + const value = Number(raw); + if (!Number.isInteger(value) || value < min || value > max) return undefined; + return value; +} + +function parseToggle(raw: string | undefined): boolean | undefined { + const normalized = raw?.trim().toLowerCase(); + if (normalized === "1" || normalized === "true" || normalized === "on") { + return true; + } + if (normalized === "0" || normalized === "false" || normalized === "off") { + return false; + } + return undefined; +} + +export function nvencOptionsFromEnv( + env: Env = process.env, +): NvencOptions | null { + if (selectedVideoEncoder(env) !== "nvenc") return null; + const preset = env.CAP_NVENC_PRESET?.trim().toLowerCase() ?? ""; + return { + preset: NVENC_PRESETS.has(preset) ? preset : DEFAULT_PRESET, + cqOffset: + parseBoundedInt(env.CAP_NVENC_CQ_OFFSET, -51, 51) ?? DEFAULT_CQ_OFFSET, + lookahead: + parseBoundedInt(env.CAP_NVENC_LOOKAHEAD, 0, MAX_LOOKAHEAD) ?? + DEFAULT_LOOKAHEAD, + spatialAq: parseToggle(env.CAP_NVENC_SPATIAL_AQ) ?? true, + temporalAq: parseToggle(env.CAP_NVENC_TEMPORAL_AQ) ?? false, + gpuDecodeCodecs: parseGpuDecodeCodecs(env.CAP_NVENC_GPU_DECODE_CODECS), + }; +} + +function parseGpuDecodeCodecs(raw: string | undefined): string[] { + if (raw === undefined || raw.trim() === "") return DEFAULT_GPU_DECODE_CODECS; + return raw + .split(",") + .map((codec) => codec.trim().toLowerCase()) + .filter((codec) => NVDEC_CODECS.has(codec)); +} + +export function nvencCq(crf: number, cqOffset: number): string { + const base = Number.isFinite(crf) ? crf : 23; + const cq = Math.min(51, Math.max(1, base + cqOffset)); + return String(Math.round(cq * 1000) / 1000); +} + +export function buildNvencEncodeArgs( + options: NvencOptions, + crf: number, +): string[] { + return [ + "-c:v", + "h264_nvenc", + "-preset", + options.preset, + "-tune", + "hq", + "-rc", + "vbr", + "-cq", + nvencCq(crf, options.cqOffset), + "-b:v", + "0", + ...(options.spatialAq ? ["-spatial-aq", "1"] : []), + ...(options.temporalAq ? ["-temporal-aq", "1"] : []), + ...(options.lookahead > 0 + ? ["-rc-lookahead", String(options.lookahead)] + : []), + "-profile:v", + "high", + // h264_nvenc otherwise inherits the preset's GOP; pin libx264's default keyint. + "-g", + GOP_LENGTH, + ]; +} + +// Mirrors the CPU filter chain: fit within the box keeping aspect (rounded like libavfilter), then floor to even. +export function fitWithinEven( + width: number, + height: number, + maxWidth: number, + maxHeight: number, +): { width: number; height: number } { + let w = Math.min(maxWidth, width); + let h = Math.min(maxHeight, height); + const tmpW = Math.round((h * width) / height); + const tmpH = Math.round((w * height) / width); + w = Math.min(tmpW, w); + h = Math.min(tmpH, h); + return { width: Math.trunc(w / 2) * 2, height: Math.trunc(h / 2) * 2 }; +} + +// cuvid resizes inside NVDEC for free; scale_cuda then only converts 10-bit surfaces and passes 8-bit through. +export function buildGpuDecodeInputArgs( + videoCodec: string, + source: { width: number; height: number }, + target: { width: number; height: number }, +): string[] { + return [ + ...NVENC_HWACCEL_INPUT_ARGS, + "-c:v", + `${videoCodec}_cuvid`, + ...(target.width !== source.width || target.height !== source.height + ? ["-resize", `${target.width}x${target.height}`] + : []), + ]; +} + +export const GPU_DECODE_FORMAT_FILTER = "scale_cuda=format=nv12"; + +// FFmpeg skips autorotation for hardware frames, so rotated sources decode on the CPU to keep the libx264 geometry. +export function canDecodeOnGpu( + videoCodec: string, + rotation: number | null, + gpuDecodeCodecs: string[] = DEFAULT_GPU_DECODE_CODECS, +): boolean { + return gpuDecodeCodecs.includes(videoCodec) && rotation === 0; +} + +// A failed NVENC encode is retried on libx264 with a fresh timeout, so the job +// deadline has to leave room for both attempts. +export function encodeAttemptsBudgetMs( + encodeTimeoutMs: number, + env: Env = process.env, +): number { + return selectedVideoEncoder(env) === "nvenc" + ? encodeTimeoutMs * 2 + : encodeTimeoutMs; +} + +export function nvencFallbackReason( + error: unknown, + aborted: boolean, +): string | null { + if (aborted) return null; + const message = redactLogText( + error instanceof Error ? error.message : String(error), + ); + // The message tail is mostly -progress output; the encoder's own error is printed earlier. + const cause = + error instanceof FfmpegExitError && error.errorLines.length > 0 + ? `${redactLogText(error.errorLines.join(" | ")).slice(0, MAX_FFMPEG_CAUSE_LENGTH)} || ` + : ""; + return ( + ( + cause + message.slice(-(MAX_FALLBACK_REASON_LENGTH - cause.length)) + ).trim() || "unknown error" + ); +} + +export function recordNvencAttempt( + job: NvencJobKind, + fallbackReason: string | null = null, +): void { + nvencStatus.attempts += 1; + if (fallbackReason !== null) nvencStatus.fallbacks += 1; + nvencStatus.lastAttempt = { + job, + at: new Date().toISOString(), + fellBack: fallbackReason !== null, + reason: fallbackReason, + }; +} + +export function logNvencFallback(details: { + job: NvencJobKind; + sourceCodec: string; + gpuDecode: boolean; + reason: string; +}): void { + console.warn( + JSON.stringify({ + event: "nvenc_fallback_to_libx264", + timestamp: new Date().toISOString(), + ...details, + }), + ); +} + +export function getVideoEncoderStatus( + env: Env = process.env, + includeDetails = true, +) { + const active = selectedVideoEncoder(env); + return { + active, + nvenc: + active === "nvenc" && includeDetails + ? { + attempts: nvencStatus.attempts, + fallbacks: nvencStatus.fallbacks, + lastAttempt: nvencStatus.lastAttempt, + } + : null, + }; +} diff --git a/apps/media-server/src/lib/parallel-download.ts b/apps/media-server/src/lib/parallel-download.ts new file mode 100644 index 00000000000..f582f3537ba --- /dev/null +++ b/apps/media-server/src/lib/parallel-download.ts @@ -0,0 +1,195 @@ +import { open } from "node:fs/promises"; + +const MIN_PART_BYTES = 1024 * 1024; +// Far above any recording we store (the largest so far is ~47 GB). Anything +// bigger is a bad Content-Range header, not a real object. +export const MAX_RANGED_TOTAL_BYTES = 1024 ** 4; + +export interface ParallelDownloadOptions { + concurrency: number; + partBytes: number; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + +export interface ParallelDownloadResult { + bytes: number; + parts: number; + ranged: boolean; +} + +export function parallelDownloadOptionsFromEnv( + env: Record = process.env, +): Pick { + const concurrency = Number.parseInt( + env.MEDIA_SERVER_DOWNLOAD_CONCURRENCY ?? "", + 10, + ); + const partMb = Number.parseInt(env.MEDIA_SERVER_DOWNLOAD_PART_MB ?? "", 10); + return { + concurrency: + Number.isFinite(concurrency) && concurrency > 0 + ? Math.min(concurrency, 32) + : 1, + partBytes: Math.max( + MIN_PART_BYTES, + (Number.isFinite(partMb) && partMb > 0 ? partMb : 16) * 1024 * 1024, + ), + }; +} + +export function planRanges( + totalBytes: number, + firstPartBytes: number, + partBytes: number, +): { count: number; at: (index: number) => { start: number; end: number } } { + return { + count: Math.max(0, Math.ceil((totalBytes - firstPartBytes) / partBytes)), + at: (index) => { + const start = firstPartBytes + index * partBytes; + return { start, end: Math.min(totalBytes, start + partBytes) - 1 }; + }, + }; +} + +function parseContentRange( + value: string | null, +): { start: number; end: number; total: number } | null { + const match = value?.match(/^bytes\s+(\d+)-(\d+)\/(\d+)$/i); + if (!match) return null; + const [start, end, total] = match.slice(1).map(Number) as [ + number, + number, + number, + ]; + return Number.isSafeInteger(total) && start <= end && end < total + ? { start, end, total } + : null; +} + +async function readInto( + response: Response, + write: (chunk: Uint8Array, offset: number) => Promise, + start: number, + expected: number | null, +): Promise { + if (!response.body) throw new Error("No response body"); + const reader = response.body.getReader(); + let offset = start; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + await write(value, offset); + offset += value.byteLength; + } + } finally { + reader.releaseLock(); + } + const read = offset - start; + if (expected !== null && read !== expected) { + throw new Error( + `Ranged download returned ${read} bytes, expected ${expected}`, + ); + } + return read; +} + +export async function downloadUrlToPathParallel( + url: string, + path: string, + options: ParallelDownloadOptions, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const partBytes = Math.max(MIN_PART_BYTES, options.partBytes); + const handle = await open(path, "w"); + const write = async (chunk: Uint8Array, offset: number) => { + let written = 0; + while (written < chunk.byteLength) { + const { bytesWritten } = await handle.write( + chunk, + written, + chunk.byteLength - written, + offset + written, + ); + written += bytesWritten; + } + }; + const controller = new AbortController(); + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal; + const fetchOk = async (headers?: Record) => { + const response = await fetchImpl(url, { headers, signal }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error( + `Failed to download video: ${response.status} ${response.statusText}`, + ); + } + return response; + }; + try { + const first = await fetchOk({ Range: `bytes=0-${partBytes - 1}` }); + if (first.status !== 206) { + const bytes = await readInto(first, write, 0, null); + return { bytes, parts: 1, ranged: false }; + } + const range = parseContentRange(first.headers.get("content-range")); + if ( + range === null || + range.start !== 0 || + range.end + 1 < Math.min(partBytes, range.total) + ) { + // A partial reply we can't place or that the server capped below our + // part size: start over as one stream rather than keep a fragment. + await first.body?.cancel().catch(() => {}); + const bytes = await readInto(await fetchOk(), write, 0, null); + return { bytes, parts: 1, ranged: false }; + } + const { total } = range; + if (total > MAX_RANGED_TOTAL_BYTES) { + await first.body?.cancel().catch(() => {}); + throw new Error(`Ranged download reported an invalid size: ${total}`); + } + const firstBytes = range.end + 1; + const etag = first.headers.get("etag"); + const ranges = planRanges(total, firstBytes, partBytes); + let next = 0; + const worker = async () => { + while (next < ranges.count) { + signal.throwIfAborted(); + const part = ranges.at(next++); + const response = await fetchImpl(url, { + headers: { + Range: `bytes=${part.start}-${part.end}`, + ...(etag ? { "If-Match": etag } : {}), + }, + signal, + }); + if (response.status !== 206) { + await response.body?.cancel().catch(() => {}); + throw new Error( + `Ranged download part failed: ${response.status} ${response.statusText}`, + ); + } + await readInto(response, write, part.start, part.end - part.start + 1); + } + }; + const workers = Array.from( + { length: Math.max(1, Math.min(options.concurrency - 1, ranges.count)) }, + () => worker(), + ); + const firstRead = readInto(first, write, 0, firstBytes); + try { + await Promise.all([firstRead, ...workers]); + } catch (error) { + controller.abort(); + await Promise.allSettled([firstRead, ...workers]); + throw error; + } + return { bytes: total, parts: ranges.count + 1, ranged: true }; + } finally { + await handle.close(); + } +} diff --git a/apps/media-server/src/lib/peer-jobs.ts b/apps/media-server/src/lib/peer-jobs.ts new file mode 100644 index 00000000000..99318e2a13b --- /dev/null +++ b/apps/media-server/src/lib/peer-jobs.ts @@ -0,0 +1,75 @@ +const PEER_LOOKUP_HEADER = "x-cap-peer-lookup"; +const PEER_STATUS_TIMEOUT_MS = 2_000; +// The owner replies to a cancel only after delivering its webhook (up to three 5 s attempts). +const PEER_CANCEL_TIMEOUT_MS = 30_000; + +// Jobs live in the memory of the instance that runs them. Behind a load +// balancer a status or cancel request can land on another instance, so it asks +// the configured peers once before answering that the job does not exist. +export function mediaServerPeers( + env: Record = process.env, +): string[] { + return (env.MEDIA_SERVER_PEERS ?? "") + .split(",") + .map((value) => value.trim().replace(/\/+$/, "")) + .filter((value) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + }); +} + +export function isPeerLookup(request: Request): boolean { + return request.headers.get(PEER_LOOKUP_HEADER) === "1"; +} + +export async function findJobOnPeer( + path: string, + method: "GET" | "POST", +): Promise { + const secret = process.env.MEDIA_SERVER_WEBHOOK_SECRET; + const peers = mediaServerPeers(); + if (!secret || peers.length === 0) return null; + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + method === "POST" ? PEER_CANCEL_TIMEOUT_MS : PEER_STATUS_TIMEOUT_MS, + ); + try { + return await Promise.any( + peers.map(async (peer) => { + const response = await fetch(`${peer}${path}`, { + method, + headers: { + "x-media-server-secret": secret, + [PEER_LOOKUP_HEADER]: "1", + Accept: "application/json", + }, + signal: controller.signal, + redirect: "error", + }); + // Only a peer that owns the job answers 200, or 400 for a finished job's cancel. + if (response.status !== 200 && response.status !== 400) { + await response.body?.cancel().catch(() => {}); + throw new Error("Job is not on this peer"); + } + const body = await response.text(); + return new Response(body, { + status: response.status, + headers: { + "Content-Type": + response.headers.get("content-type") ?? "application/json", + }, + }); + }), + ); + } catch { + return null; + } finally { + clearTimeout(timeout); + controller.abort(); + } +} diff --git a/apps/media-server/src/lib/recording-packet-proof.ts b/apps/media-server/src/lib/recording-packet-proof.ts index 59cf3806084..67cf1ace444 100644 --- a/apps/media-server/src/lib/recording-packet-proof.ts +++ b/apps/media-server/src/lib/recording-packet-proof.ts @@ -381,41 +381,60 @@ export async function proveRecordingPackets( const before = await Promise.all( paths.map((path) => lstat(path, { bigint: true })), ); - const sourceTiming = await readRecordingVideoTiming(videoPath, { - abortSignal: signal, - timeoutMs: 45 * 60_000, - }); - if (sourceTiming.terminalPacketCount !== 1) - throw new Error("Tied terminal samples require decoded source evidence"); - const sourceVideo = await readTrack(videoPath, "video", signal); - const outputVideo = await readTrack(outputPath, "video", signal); - if (JSON.stringify(sourceVideo) !== JSON.stringify(outputVideo)) - throw new RecordingPacketMismatchError( - "Recording encoded video does not preserve the source", - ); - if (audioPath) { - const { tail: sourceTail, ...sourceAudio } = await readTrack( - audioPath, - "audio", - signal, - ); - const { tail: outputTail, ...outputAudio } = await readTrack( - outputPath, - "audio", - signal, - ); - if (JSON.stringify(sourceAudio) !== JSON.stringify(outputAudio)) + // Every read below is independent and the files are pinned by `before`, so + // run them together and still check their results in the original order. + const reads = new AbortController(); + const readSignal = AbortSignal.any([signal, reads.signal]); + const settled = (promise: Promise) => { + promise.catch(() => {}); + return promise; + }; + const timingOptions = { abortSignal: readSignal, timeoutMs: 45 * 60_000 }; + const sourceTimingRead = settled( + readRecordingVideoTiming(videoPath, timingOptions), + ); + const sourceVideoRead = settled(readTrack(videoPath, "video", readSignal)); + const outputVideoRead = settled(readTrack(outputPath, "video", readSignal)); + const sourceAudioRead = audioPath + ? settled(readTrack(audioPath, "audio", readSignal)) + : null; + const outputAudioRead = audioPath + ? settled(readTrack(outputPath, "audio", readSignal)) + : null; + const outputTimingRead = settled( + readRecordingVideoTiming(outputPath, timingOptions), + ); + let sourceTiming: Awaited; + let outputTiming: Awaited; + let sourceVideo: Awaited; + let outputVideo: Awaited; + try { + sourceTiming = await sourceTimingRead; + if (sourceTiming.terminalPacketCount !== 1) + throw new Error("Tied terminal samples require decoded source evidence"); + sourceVideo = await sourceVideoRead; + outputVideo = await outputVideoRead; + if (JSON.stringify(sourceVideo) !== JSON.stringify(outputVideo)) throw new RecordingPacketMismatchError( - "Recording encoded audio does not preserve the source", + "Recording encoded video does not preserve the source", ); - // MP4 edit-list rounding can change the reported AAC tail without changing decoded samples. - if (sourceTail?.duration !== outputTail?.duration) - throw new Error("Recording audio tail requires decoded source evidence"); + if (sourceAudioRead && outputAudioRead) { + const { tail: sourceTail, ...sourceAudio } = await sourceAudioRead; + const { tail: outputTail, ...outputAudio } = await outputAudioRead; + if (JSON.stringify(sourceAudio) !== JSON.stringify(outputAudio)) + throw new RecordingPacketMismatchError( + "Recording encoded audio does not preserve the source", + ); + // MP4 edit-list rounding can change the reported AAC tail without changing decoded samples. + if (sourceTail?.duration !== outputTail?.duration) + throw new Error( + "Recording audio tail requires decoded source evidence", + ); + } + outputTiming = await outputTimingRead; + } finally { + reads.abort(); } - const outputTiming = await readRecordingVideoTiming(outputPath, { - abortSignal: signal, - timeoutMs: 45 * 60_000, - }); const normalizeTiming = (timing: typeof sourceTiming) => ({ packets: timing.packetTimelineSha256, terminalPackets: timing.terminalPacketCount, diff --git a/apps/media-server/src/lib/recording-timing.ts b/apps/media-server/src/lib/recording-timing.ts index c6b740121b2..cec67949775 100644 --- a/apps/media-server/src/lib/recording-timing.ts +++ b/apps/media-server/src/lib/recording-timing.ts @@ -13,6 +13,11 @@ import { } from "mediabunny"; const MAX_METADATA_BYTES = 64 * 1024 * 1024; +const MAX_LONG_RECORDING_METADATA_BYTES = 1024 * 1024 * 1024; +// A long budget covers many reads; one allocation for a single header box stays bounded. +const MAX_SINGLE_METADATA_READ_BYTES = 256 * 1024 * 1024; +// Sample tables grow with a recording's length; reads stream through a fixed cache. +const METADATA_BYTES_PER_FILE_BYTE = 1 / 64; const MAX_RANGE_BYTES = 1024 * 1024; const MAX_CACHE_BYTES = 8 * 1024 * 1024; const MAX_TERMINAL_PACKETS = 1024; @@ -304,6 +309,22 @@ async function readTerminalDurations( return durations; } +export function timingMetadataBudget(fileSize: number): number { + if (!Number.isSafeInteger(fileSize) || fileSize <= 0) + return MAX_METADATA_BYTES; + return Math.min( + MAX_LONG_RECORDING_METADATA_BYTES, + Math.max( + MAX_METADATA_BYTES, + Math.ceil(fileSize * METADATA_BYTES_PER_FILE_BYTE), + ), + ); +} + +function megabytes(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(1); +} + export async function readRecordingVideoTiming( path: string, options: RecordingTimingOptions, @@ -359,13 +380,10 @@ export async function readRecordingVideoTiming( const reads = new Set>(); const reserve = (bytes: number) => { metadataBytes += bytes; - if ( - !Number.isSafeInteger(bytes) || - bytes < 0 || - metadataBytes > MAX_METADATA_BYTES - ) { + const budget = timingMetadataBudget(fileSize); + if (!Number.isSafeInteger(bytes) || bytes < 0 || metadataBytes > budget) { throw new RecordingTimingError( - "Recording timing metadata exceeds its read budget", + `Recording timing metadata exceeds its read budget (${megabytes(metadataBytes)} of ${megabytes(budget)} MB for a ${megabytes(fileSize)} MB file)`, false, ); } @@ -508,6 +526,12 @@ export async function readRecordingVideoTiming( false, ); } + if (end - start > MAX_SINGLE_METADATA_READ_BYTES) { + throw new RecordingTimingError( + `Recording timing metadata exceeds its read budget (one ${megabytes(end - start)} MB read)`, + false, + ); + } reserve(end - start); const result = new Uint8Array(end - start); for (let position = start; position < end; ) { diff --git a/apps/media-server/src/lib/recording-verification.ts b/apps/media-server/src/lib/recording-verification.ts index 9a599fbf010..51e1c69e0b5 100644 --- a/apps/media-server/src/lib/recording-verification.ts +++ b/apps/media-server/src/lib/recording-verification.ts @@ -1,11 +1,12 @@ import { spawn } from "node:child_process"; import { createHash, type Hash } from "node:crypto"; import { createReadStream } from "node:fs"; -import { lstat } from "node:fs/promises"; -import { isAbsolute } from "node:path"; +import { lstat, open, rm } from "node:fs/promises"; +import { availableParallelism } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; import { Readable } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; -import { PROCESS_TIMEOUT_MS } from "./media-common"; +import { decodeThreadsFromEnv, PROCESS_TIMEOUT_MS } from "./media-common"; import { fetchMedia, MediaTransferBudgetError, @@ -53,6 +54,9 @@ export interface RecordingVerificationOptions { hasAudio?: boolean; sourceEvidence?: RecordingSourceEvidence; allowObservedDuration?: boolean; + // Hashing every decoded frame costs as much as the decode; skip it when the + // caller has no source evidence to compare against. + hashDecodedContent?: boolean; abortSignal?: AbortSignal; timeoutMs?: number; onProgress?: (progress: { frames: number; totalFrames?: number }) => void; @@ -665,6 +669,526 @@ export async function inspectRecordingSources( return { ...result, integrity: result.integrity }; } +export interface RecordingVideoChunkSource { + init: string; + segments: string[]; +} + +export function verifyDecodeChunks( + env: Record = process.env, +): number { + const value = Number(env.MEDIA_SERVER_VERIFY_DECODE_CHUNKS?.trim()); + return Number.isInteger(value) && value > 1 ? Math.min(value, 16) : 1; +} + +class ChunkedDecodeUnavailable extends Error {} + +const DECODED_STREAM_METADATA = + /^#(tb|media_type|sample_rate|dimensions|sar|channel_layout_name) (\d+):/; + +const metadataOf = (lines: string[]) => + lines.filter((line) => DECODED_STREAM_METADATA.test(line)).join("\n"); + +function describeHeaderChange(first: string, other: string): string { + const fields = (header: string) => + new Map( + header + .split("\n") + .filter(Boolean) + .map((line) => { + const [name, ...value] = line.slice(1).split(": "); + return [name, value.join(": ")] as const; + }), + ); + const before = fields(first); + const after = fields(other); + const changes = [...new Set([...before.keys(), ...after.keys()])] + .filter((name) => before.get(name) !== after.get(name)) + .map( + (name) => + `${name}: ${before.get(name) ?? "none"} -> ${after.get(name) ?? "none"}`, + ); + return changes.join("; ").slice(0, 200) || "unknown"; +} + +async function collectDecoderLines( + stream: AsyncIterable, + lines: string[], + onLine: () => void, +): Promise { + const decoder = new TextDecoder(); + let pending = ""; + const take = (line: string) => { + const trimmed = line.trim(); + if (!trimmed) return; + lines.push(trimmed); + if (!trimmed.startsWith("#")) onLine(); + }; + for await (const chunk of stream) { + pending += decoder.decode(chunk, { stream: true }); + let newline = pending.indexOf("\n"); + while (newline !== -1) { + if (newline > MAX_OUTPUT_LINE_LENGTH) { + throw new Error("Recording decoder output exceeded its limit"); + } + take(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + newline = pending.indexOf("\n"); + } + if (pending.length > MAX_OUTPUT_LINE_LENGTH) { + throw new Error("Recording decoder output exceeded its limit"); + } + } + take(pending + decoder.decode()); +} + +async function concatFiles( + paths: string[], + target: string, + signal?: AbortSignal, +): Promise { + const output = await open(target, "w"); + try { + for (const path of paths) { + signal?.throwIfAborted(); + for await (const chunk of createReadStream(path, { signal })) { + for (let offset = 0; offset < chunk.byteLength; ) { + const { bytesWritten } = await output.write( + chunk, + offset, + chunk.byteLength - offset, + ); + if (bytesWritten <= 0) + throw new Error( + "Recording verification chunk could not be written", + ); + offset += bytesWritten; + } + } + } + } finally { + await output.close(); + } +} + +const KEYFRAME_SEARCH_SEGMENTS = 8; + +async function countOutputLines( + command: string, + args: string[], + pattern: RegExp, + signal?: AbortSignal, +): Promise { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "ignore"], + signal, + timeout: 60_000, + }); + let count = 0; + let pending = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (text: string) => { + pending += text; + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) if (pattern.test(line)) count++; + }); + const code = await new Promise((resolve) => { + child.once("error", () => resolve(null)); + child.once("close", resolve); + }); + if (pattern.test(pending)) count++; + return code === 0 ? count : null; +} + +// A fragment can open with a sync sample the decoder still cannot start from +// cleanly, so prove it: decoding it alone must yield a frame for every packet. +async function decodesCleanlyFromStart( + init: string, + segment: string, + signal?: AbortSignal, +): Promise { + const input = `concat:${init}|${segment}`; + const [packets, frames] = await Promise.all([ + countOutputLines( + "ffprobe", + [ + "-v", + "error", + "-protocol_whitelist", + "file,concat", + "-select_streams", + "v:0", + "-show_entries", + "packet=flags", + "-of", + "csv=p=0", + input, + ], + /^\S/, + signal, + ), + countOutputLines( + "ffmpeg", + [ + "-hide_banner", + "-nostdin", + "-copyts", + "-v", + "error", + "-xerror", + "-err_detect", + "explode+crccheck", + "-threads", + "1", + "-protocol_whitelist", + "file,concat", + "-f", + "mov", + "-i", + input, + "-map", + "0:v:0", + "-fps_mode", + "passthrough", + "-enc_time_base:v", + "-1", + "-c:v", + "wrapped_avframe", + "-bsf:v", + "setts=pts=N:dts=N:duration=1", + "-f", + "framecrc", + "pipe:1", + ], + /^0,/, + signal, + ), + ]); + return packets !== null && packets > 0 && frames === packets; +} + +// Splits only where a fragment decodes cleanly on its own, searching a few +// fragments past each even split point and merging runs when none does. +async function keyframeAlignedGroups( + source: RecordingVideoChunkSource, + chunkCount: number, + signal?: AbortSignal, +): Promise { + const total = source.segments.length; + const per = Math.ceil(total / chunkCount); + const targets = Array.from( + { length: chunkCount - 1 }, + (_, index) => (index + 1) * per, + ).filter((target) => target < total); + const found = await Promise.all( + targets.map(async (target) => { + for ( + let index = target; + index < Math.min(total, target + KEYFRAME_SEARCH_SEGMENTS); + index++ + ) { + if ( + await decodesCleanlyFromStart( + source.init, + source.segments[index], + signal, + ) + ) + return index; + } + return undefined; + }), + ); + const boundaries = [0]; + for (const index of found) { + if (index !== undefined && index > boundaries[boundaries.length - 1]) + boundaries.push(index); + } + return boundaries.map((start, position) => + source.segments.slice(start, boundaries[position + 1] ?? total), + ); +} + +// Decodes the packet-proven video as independent runs of whole fragments in +// parallel, then replays their frame lines through the same parser and evidence +// checks as one decode. Any decoder complaint falls back to the single decode, +// which stays the judge of whether a recording is corrupt. +async function decodePacketProvenChunks( + outputPath: string, + source: RecordingVideoChunkSource, + chunkCount: number, + options: RecordingVerificationOptions, + packetProof: Awaited>, +): Promise { + const groups = await keyframeAlignedGroups( + source, + chunkCount, + options.abortSignal, + ); + if (groups.length < 2) + throw new ChunkedDecodeUnavailable("no cleanly decodable fragment runs"); + const directory = dirname(outputPath); + const chunkPaths = groups.map((_, index) => + join(directory, `verify-chunk-${index}.mp4`), + ); + const configuredThreads = Number( + decodeThreadsFromEnv("MEDIA_SERVER_VERIFY_DECODE_THREADS", 0), + ); + const threads = String( + Math.max( + 1, + Math.floor( + (configuredThreads > 0 ? configuredThreads : availableParallelism()) / + groups.length, + ), + ), + ); + const base = [ + "-hide_banner", + "-nostdin", + "-copyts", + "-v", + "error", + "-xerror", + "-err_detect", + "explode+crccheck", + "-filter_threads", + "1", + "-filter_complex_threads", + "1", + ]; + const decoders: Awaited>[] = []; + const outputs: string[][] = []; + const readers: Promise[] = []; + let failure: Error | undefined; + let decodedFrames = 0; + let lastAdvance = performance.now(); + let lastReport = 0; + const stopAll = () => { + for (const decoder of decoders) { + if (decoder.exitCode === null) { + try { + decoder.kill("SIGKILL"); + } catch { + failure ??= new Error("Recording decoder termination failed"); + } + } + } + }; + const cancel = () => { + failure ??= new Error("Recording verification was cancelled"); + stopAll(); + }; + options.abortSignal?.addEventListener("abort", cancel, { once: true }); + const timeout = setTimeout(() => { + failure ??= new RecordingVerificationError( + "Recording verification timed out", + true, + ); + stopAll(); + }, options.timeoutMs ?? MAX_LOCAL_VERIFICATION_MS); + const stall = setInterval(() => { + if (performance.now() - lastAdvance > DECODER_STALL_MS) { + failure ??= new RecordingVerificationError( + "Recording decoder stopped making progress", + true, + ); + stopAll(); + } + }, 1000); + const onFrame = () => { + decodedFrames++; + lastAdvance = performance.now(); + if (performance.now() - lastReport >= 1000) { + lastReport = performance.now(); + options.onProgress?.({ + frames: decodedFrames, + totalFrames: packetProof.videoPackets, + }); + } + }; + try { + await Promise.all( + groups.map((group, index) => + concatFiles( + [source.init, ...group], + chunkPaths[index], + options.abortSignal, + ), + ), + ); + const jobs = [ + ...chunkPaths.map((path) => [ + ...base, + "-threads", + threads, + "-protocol_whitelist", + "file", + "-f", + "mov", + "-i", + path, + "-map", + "0:v:0", + "-fps_mode", + "passthrough", + "-enc_time_base:v", + "-1", + "-c:v", + "wrapped_avframe", + "-bsf:v", + "setts=pts=N:dts=N:duration=1", + "-max_interleave_delta", + "1", + "-f", + "framecrc", + "pipe:1", + ]), + ...(packetProof.hasAudio + ? [ + [ + ...base, + "-threads", + "1", + "-protocol_whitelist", + "file", + "-f", + "mov", + "-i", + outputPath, + "-map", + "0:a:0", + "-c:a", + "pcm_f64le", + "-threads", + "1", + "-max_interleave_delta", + "1", + "-f", + "framecrc", + "pipe:1", + ], + ] + : []), + ]; + const errors: Promise<{ error: string }>[] = []; + // A chunk's header describes its first decoded frame, so a mismatch is known + // as soon as each chunk emits one frame; stop then instead of decoding everything first. + const headers = new Map(); + const checkHeader = (index: number, lines: string[]) => { + if (headers.has(index)) return; + headers.set(index, metadataOf(lines)); + const first = headers.get(0); + if (first === undefined) return; + for (const [other, header] of headers) { + if (other === 0 || header === first) continue; + failure ??= new ChunkedDecodeUnavailable( + `chunk stream parameters differ (${describeHeaderChange(first, header)})`, + ); + stopAll(); + return; + } + }; + for (const [index, args] of jobs.entries()) { + options.abortSignal?.throwIfAborted(); + const decoder = registerSubprocess(await spawnRecordingDecoder(args, 0)); + decoders.push(decoder); + const lines: string[] = []; + outputs.push(lines); + const frames = collectDecoderLines( + decoder.stdout, + lines, + index < chunkPaths.length + ? () => { + checkHeader(index, lines); + onFrame(); + } + : onFrame, + ); + const stderr = readDecoderErrors(decoder.stderr, outputPath); + frames.catch(stopAll); + stderr.catch(stopAll); + readers.push(frames, stderr); + errors.push(stderr); + } + const exits = await Promise.all(decoders.map((decoder) => decoder.exited)); + await Promise.all(readers); + if (failure) throw failure; + const stderr = await Promise.all(errors); + for (const [index, decoder] of decoders.entries()) { + if (exits[index] !== 0 || stderr[index].error || decoder.signalCode) + throw new ChunkedDecodeUnavailable( + stderr[index].error || `decoder exited with ${exits[index]}`, + ); + } + const streams = new Map(); + const videoOutputs = outputs.slice(0, chunkPaths.length); + const videoMetadata = metadataOf(videoOutputs[0]); + let offset = 0; + for (const [index, lines] of videoOutputs.entries()) { + const metadata = metadataOf(lines); + if (index > 0 && metadata !== videoMetadata) + throw new ChunkedDecodeUnavailable( + `chunk stream parameters differ (${describeHeaderChange(videoMetadata, metadata)})`, + ); + let frames = 0; + for (const line of lines) { + if (line.startsWith("#")) { + if (index === 0) decodeLine(line, streams, true); + continue; + } + const columns = line.split(",").map((value) => value.trim()); + if (columns[0] !== "0") + throw new ChunkedDecodeUnavailable("unexpected chunk stream"); + const dts = Number(columns[1]); + const pts = Number(columns[2]); + if (dts !== frames || pts !== frames) + throw new ChunkedDecodeUnavailable("chunk frames are not contiguous"); + columns[1] = String(dts + offset); + columns[2] = String(pts + offset); + decodeLine(columns.join(", "), streams, true); + frames++; + } + offset += frames; + } + // A decoder silently skips frames until a keyframe, so a short count is a split problem, not proof. + if (offset !== packetProof.videoPackets) + throw new ChunkedDecodeUnavailable("chunk frame count differs"); + if (packetProof.hasAudio) { + for (const line of outputs[chunkPaths.length]) { + if (line.startsWith("#")) { + const metadata = line.match(DECODED_STREAM_METADATA); + if (metadata?.[2] === "0") + decodeLine(line.replace(/^(#\w+) 0:/, "$1 1:"), streams, true); + continue; + } + if (!line.startsWith("0,")) + throw new ChunkedDecodeUnavailable("unexpected audio stream"); + decodeLine(`1${line.slice(1)}`, streams, true); + } + } + return validateEvidence( + streams, + options, + true, + packetProof.outputTiming, + false, + ); + } finally { + clearTimeout(timeout); + clearInterval(stall); + options.abortSignal?.removeEventListener("abort", cancel); + stopAll(); + await Promise.allSettled([ + ...decoders.map((decoder) => decoder.exited), + ...readers, + ]); + for (const decoder of decoders) unregisterSubprocess(decoder); + await Promise.all(chunkPaths.map((path) => rm(path, { force: true }))); + } +} + export async function verifyRemuxedRecording( videoPath: string, audioPath: string | null, @@ -673,6 +1197,7 @@ export async function verifyRemuxedRecording( RecordingVerificationOptions, "requireAudio" | "abortSignal" | "timeoutMs" | "onProgress" >, + videoChunks?: RecordingVideoChunkSource, ): Promise { const signal = AbortSignal.any([ ...(options.abortSignal ? [options.abortSignal] : []), @@ -705,9 +1230,45 @@ export async function verifyRemuxedRecording( throw new Error("Recording source preservation was not verified"); return { ...result, sourcePreserved: true }; } - const result = await decodeRecording( + const decodeOptions = { + ...options, + timeoutMs: options.timeoutMs ?? MAX_LOCAL_VERIFICATION_MS, + }; + const chunkCount = verifyDecodeChunks(); + let result: RecordingVerificationResult | undefined; + if ( + videoChunks && + chunkCount > 1 && + videoChunks.segments.length >= chunkCount * 2 + ) { + try { + result = await decodePacketProvenChunks( + outputPath, + videoChunks, + chunkCount, + decodeOptions, + proof, + ); + } catch (error) { + signal.throwIfAborted(); + if ( + error instanceof RecordingVerificationError && + error.message === "Recording verification timed out" + ) + throw error; + console.warn( + JSON.stringify({ + event: "recording_chunked_decode_fallback", + reason: (error instanceof Error ? error.message : String(error)) + .replace(/https?:\/\/\S+/g, "") + .slice(0, 300), + }), + ); + } + } + result ??= await decodeRecording( outputPath, - { ...options, timeoutMs: options.timeoutMs ?? MAX_LOCAL_VERIFICATION_MS }, + decodeOptions, undefined, undefined, proof, @@ -846,8 +1407,11 @@ async function decodeRecording( ? Boolean(options.sourceEvidence.audio) : options.hasAudio : sourceAudioInput !== null; + const hashContent = + !packetProof && + (options.hashDecodedContent !== false || Boolean(options.sourceEvidence)); const streamDecodedContent = - !packetProof && process.platform !== "win32" && knownAudio !== undefined; + hashContent && process.platform !== "win32" && knownAudio !== undefined; if (streamDecodedContent && !Bun.semver.satisfies(Bun.version, ">=1.4.0")) { throw new Error( "Recording content verification requires Bun 1.4.0 or newer; upgrade the media-server runtime to avoid double-closing decoded-content pipes", @@ -890,8 +1454,9 @@ async function decodeRecording( "1", "-filter_complex_threads", "1", + // FFmpeg's H.264/HEVC decoders are bit-exact across thread counts, so decoded hashes do not depend on this. "-threads", - "1", + decodeThreadsFromEnv("MEDIA_SERVER_VERIFY_DECODE_THREADS", 1), "-protocol_whitelist", remote ? "http,https,tcp,tls" : "file", "-f", @@ -958,7 +1523,7 @@ async function decodeRecording( ] : []), ] - : packetProof + : !hashContent ? [] : [ ...outputOptions, @@ -1089,7 +1654,7 @@ async function decodeRecording( } stream.contentSha256 = digest.sha256; } - } else if (!packetProof) { + } else if (hashContent) { for (const digest of diagnostics.digests) decodeLine(digest, streams); } result = validateEvidence( @@ -1097,7 +1662,7 @@ async function decodeRecording( options, sourceAudioInput !== undefined || packetProof !== undefined, videoTiming, - !packetProof, + hashContent, ); } catch (error) { throw failure ?? error; @@ -1181,12 +1746,168 @@ async function readRemoteIdentity( } } +const DEFAULT_RANGED_VERIFY_PART_MB = 16; + +export function verifyDownloadConcurrency( + env: Record = process.env, +): number { + const value = Number(env.MEDIA_SERVER_VERIFY_DOWNLOAD_CONCURRENCY?.trim()); + return Number.isInteger(value) && value > 1 ? Math.min(value, 16) : 1; +} + +export function verifyDownloadPartBytes( + env: Record = process.env, +): number { + const value = Number(env.MEDIA_SERVER_VERIFY_DOWNLOAD_PART_MB?.trim()); + const megabytes = + Number.isInteger(value) && value >= 1 && value <= 64 + ? value + : DEFAULT_RANGED_VERIFY_PART_MB; + return megabytes * 1024 * 1024; +} + +class RangedVerificationUnsupported extends Error {} + +// Same proof as the sequential read (every byte, hashed in order, pinned to one +// ETag), fetched as concurrent ranges so large uploads re-read at network speed. +async function hashRemoteRecordingRanged( + input: string, + objectIdentity: string, + fileSize: number, + abortSignal: AbortSignal, + concurrency: number, + partBytes: number, +): Promise { + const reads = new AbortController(); + const signal = AbortSignal.any([abortSignal, reads.signal]); + const partCount = Math.ceil(fileSize / partBytes); + const readPart = async (index: number): Promise => { + const start = index * partBytes; + const end = Math.min(fileSize, start + partBytes) - 1; + for (let attempt = 0; ; attempt++) { + signal.throwIfAborted(); + let response: Response | undefined; + try { + try { + response = await fetchMedia(input, { + headers: { + "If-Match": objectIdentity, + "X-Cap-Recording-Verification": "1", + Range: `bytes=${start}-${end}`, + }, + signal, + }); + } catch (error) { + if (error instanceof MediaTransferBudgetError) throw error; + throw new RecordingVerificationError( + "Recording bytes could not be read", + true, + ); + } + if (response.status === 200) throw new RangedVerificationUnsupported(); + if (response.status !== 206) { + throw new RecordingVerificationError( + `Recording byte verification failed: ${response.status}`, + response.status === 408 || + response.status === 429 || + response.status >= 500, + ); + } + if (response.headers.get("etag") !== objectIdentity || !response.body) + throw new Error("Recording object changed during byte verification"); + if ( + response.headers.get("content-range") !== + `bytes ${start}-${end}/${fileSize}` + ) + throw new Error( + "Recording byte resume range does not match the verified object", + ); + const expected = end - start + 1; + const bytes = new Uint8Array(expected); + let received = 0; + try { + for await (const chunk of response.body) { + if (received + chunk.byteLength > expected) + throw new RecordingVerificationError( + "Recording object size changed during byte verification", + false, + ); + bytes.set(chunk, received); + received += chunk.byteLength; + } + } catch (error) { + if (error instanceof RecordingVerificationError) throw error; + throw new RecordingVerificationError( + "Recording bytes could not be read completely", + true, + ); + } + if (received !== expected) + throw new RecordingVerificationError( + "Recording bytes could not be read completely", + true, + ); + return bytes; + } catch (error) { + signal.throwIfAborted(); + if ( + !(error instanceof RecordingVerificationError) || + !error.retryable || + attempt >= 3 + ) + throw error; + } finally { + if (!response?.body?.locked) + await response?.body?.cancel().catch(() => {}); + } + await sleep(250 * 2 ** attempt, undefined, { signal }); + } + }; + const hash = createHash("sha256"); + const inFlight = new Map>(); + let requested = 0; + try { + for (let index = 0; index < partCount; index++) { + while (requested < partCount && requested < index + concurrency) { + const part = readPart(requested); + part.catch(() => {}); + inFlight.set(requested, part); + requested++; + } + const bytes = await inFlight.get(index); + inFlight.delete(index); + if (!bytes) throw new Error("Recording byte range was not requested"); + hash.update(bytes); + } + return hash.digest("hex"); + } finally { + reads.abort(); + await Promise.allSettled(inFlight.values()); + } +} + async function hashRemoteRecording( input: string, objectIdentity: string, fileSize: number, abortSignal: AbortSignal, ): Promise { + const concurrency = verifyDownloadConcurrency(); + const partBytes = verifyDownloadPartBytes(); + if (concurrency > 1 && fileSize > partBytes) { + try { + return await hashRemoteRecordingRanged( + input, + objectIdentity, + fileSize, + abortSignal, + concurrency, + partBytes, + ); + } catch (error) { + if (!(error instanceof RangedVerificationUnsupported)) throw error; + } + } const hash = createHash("sha256"); let bytesRead = 0; for (let attempt = 0; ; attempt++) { diff --git a/apps/media-server/src/lib/runtime-report.ts b/apps/media-server/src/lib/runtime-report.ts new file mode 100644 index 00000000000..bfe0bace96d --- /dev/null +++ b/apps/media-server/src/lib/runtime-report.ts @@ -0,0 +1,60 @@ +import { + getActiveVideoProcessCount, + getAllJobs, + getJobTotals, + getMaxConcurrentVideoProcesses, + getSystemResources, +} from "./job-manager"; +import { getDrainBlockers, getShutdownGraceMs, isDraining } from "./lifecycle"; +import { + getMaxConcurrentAudioOperations, + getMaxConcurrentProbeOperations, +} from "./media-operations"; +import { getMediaTransferCacheUsage } from "./media-transfer"; +import { getTempDirUsage } from "./temp-files"; +import { + getActiveDirectVideoProcessCount, + getMaxConcurrentDirectVideoProcesses, +} from "./video-capacity"; + +export async function getRuntimeReport() { + const blockers = getDrainBlockers(); + const jobs = getAllJobs(); + return { + draining: isDraining(), + shutdownGraceMs: getShutdownGraceMs(), + processUptimeSeconds: Math.round(process.uptime()), + activeJobs: jobs.filter( + (job) => + job.phase !== "complete" && + job.phase !== "error" && + job.phase !== "cancelled", + ).length, + jobsInMemory: jobs.length, + jobTotals: getJobTotals(), + activeSubprocesses: blockers.subprocesses, + backgroundTasks: blockers.backgroundTasks, + inFlightRequests: blockers.inFlightRequests, + slots: { + video: { + active: getActiveVideoProcessCount(), + max: getMaxConcurrentVideoProcesses(), + effectiveMax: getSystemResources().effectiveMax, + }, + directVideo: { + active: getActiveDirectVideoProcessCount(), + max: getMaxConcurrentDirectVideoProcesses(), + }, + audio: { + active: blockers.audioOperations, + max: getMaxConcurrentAudioOperations(), + }, + probe: { + active: blockers.probeOperations, + max: getMaxConcurrentProbeOperations(), + }, + }, + tempDir: await getTempDirUsage(), + mediaTransferCache: getMediaTransferCacheUsage(), + }; +} diff --git a/apps/media-server/src/lib/subprocess.ts b/apps/media-server/src/lib/subprocess.ts index 31ac5ee4e7a..ffdf7c3f670 100644 --- a/apps/media-server/src/lib/subprocess.ts +++ b/apps/media-server/src/lib/subprocess.ts @@ -19,6 +19,10 @@ export function unregisterSubprocess(proc: ManagedSubprocess): void { activeSubprocesses.delete(proc.pid); } +export function getActiveSubprocessCount(): number { + return activeSubprocesses.size; +} + async function waitForProcessExit( proc: Pick, timeoutMs: number, diff --git a/apps/media-server/src/lib/temp-files.ts b/apps/media-server/src/lib/temp-files.ts index 5141ad6c427..0c8ec1fdf4f 100644 --- a/apps/media-server/src/lib/temp-files.ts +++ b/apps/media-server/src/lib/temp-files.ts @@ -1,18 +1,139 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readdir, rm, stat, unlink } from "node:fs/promises"; +import type { Dirent, Stats } from "node:fs"; +import { + lstat, + mkdir, + readdir, + rm, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const TEMP_DIR = join(tmpdir(), "cap-media-server"); +const TEMP_ROOT = join(tmpdir(), "cap-media-server"); +const INSTANCE_ID = randomUUID(); +const INSTANCE_DIR_PREFIX = "instance-"; +const INSTANCE_LEASE_FILE = ".lease"; +const INSTANCE_LEASE_REFRESH_MS = 30_000; +const INSTANCE_LEASE_STALE_MS = 5 * 60 * 1000; const STALE_FILE_AGE_MS = 60 * 60 * 1000; +const MAX_ACTIVE_TEMP_AGE_MS = 6 * 60 * 60 * 1000; +const TEMP_MAINTENANCE_INTERVAL_MS = 5 * 60 * 1000; +const TEMP_USAGE_CACHE_MS = 5_000; +const TEMP_USAGE_MAX_ENTRIES = 20_000; + +const leasedDirs = new Set(); +const activeTempPaths = new Set(); +let leaseTimer: ReturnType | undefined; +let maintenanceTimer: ReturnType | undefined; export interface TempFileHandle { path: string; cleanup: () => Promise; } +export interface TempDirUsage { + bytes: number; + entries: number; + truncated: boolean; +} + +export function getInstanceDir(base: string): string { + return join(base, `${INSTANCE_DIR_PREFIX}${INSTANCE_ID}`); +} + +const TEMP_DIR = getInstanceDir(TEMP_ROOT); + +async function refreshLeases(): Promise { + const now = new Date(); + await Promise.allSettled( + Array.from(leasedDirs, async (dir) => { + const lease = join(dir, INSTANCE_LEASE_FILE); + try { + await utimes(lease, now, now); + } catch { + await mkdir(dir, { recursive: true }); + await writeFile(lease, ""); + } + }), + ); +} + +export async function ensureInstanceDir( + base: string, + mode?: number, +): Promise { + const dir = getInstanceDir(base); + await mkdir(dir, { recursive: true, mode }); + if (!leasedDirs.has(dir)) { + await writeFile(join(dir, INSTANCE_LEASE_FILE), ""); + leasedDirs.add(dir); + if (!leaseTimer) { + leaseTimer = setInterval(() => { + void refreshLeases(); + }, INSTANCE_LEASE_REFRESH_MS); + leaseTimer.unref?.(); + } + } + return dir; +} + +export async function releaseInstanceDir(base: string): Promise { + const dir = getInstanceDir(base); + leasedDirs.delete(dir); + await rm(dir, { recursive: true, force: true }).catch(() => {}); +} + +// Instance directories carry a lease so a host that shares one temp volume +// between an old (draining) and a new container never sweeps live work. +export async function sweepOrphanedInstanceDirs( + base: string, + isLegacyOrphan: (name: string, stats: Stats) => boolean, +): Promise { + let entries: Dirent[]; + try { + entries = await readdir(base, { withFileTypes: true }); + } catch { + return 0; + } + const ownDir = getInstanceDir(base); + const now = Date.now(); + let removed = 0; + + for (const entry of entries) { + const path = join(base, entry.name); + if (path === ownDir) continue; + try { + const orphaned = + entry.isDirectory() && entry.name.startsWith(INSTANCE_DIR_PREFIX) + ? now - + ( + await lstat(join(path, INSTANCE_LEASE_FILE)).catch(() => + lstat(path), + ) + ).mtimeMs > + INSTANCE_LEASE_STALE_MS + : isLegacyOrphan(entry.name, await lstat(path)); + if (!orphaned) continue; + await rm(path, { recursive: true, force: true }); + removed++; + } catch {} + } + + return removed; +} + export async function ensureTempDir(): Promise { - await mkdir(TEMP_DIR, { recursive: true }); + await ensureInstanceDir(TEMP_ROOT); +} + +export function retainTempPath(path: string): () => void { + activeTempPaths.add(path); + return () => { + activeTempPaths.delete(path); + }; } export async function createTempFile( @@ -21,6 +142,7 @@ export async function createTempFile( await ensureTempDir(); const filename = `${randomUUID()}${extension.startsWith(".") ? extension : `.${extension}`}`; const path = join(TEMP_DIR, filename); + const release = retainTempPath(path); return { path, @@ -28,32 +150,116 @@ export async function createTempFile( try { await unlink(path); } catch {} + release(); }, }; } +export function sweepOrphanedTempDirs(): Promise { + return sweepOrphanedInstanceDirs( + TEMP_ROOT, + (_name, stats) => Date.now() - stats.mtimeMs > STALE_FILE_AGE_MS, + ); +} + export async function cleanupStaleTempFiles(): Promise { + let cleaned = await sweepOrphanedTempDirs(); + let entries: string[]; try { - await ensureTempDir(); - const files = await readdir(TEMP_DIR); - const now = Date.now(); - let cleaned = 0; + entries = await readdir(TEMP_DIR); + } catch { + return cleaned; + } + const now = Date.now(); - for (const file of files) { - const filePath = join(TEMP_DIR, file); - try { - const fileStat = await stat(filePath); - if (now - fileStat.mtimeMs > STALE_FILE_AGE_MS) { - await rm(filePath, { force: true }); - cleaned++; - } - } catch {} - } + for (const entry of entries) { + if (entry === INSTANCE_LEASE_FILE) continue; + const path = join(TEMP_DIR, entry); + try { + const age = now - (await lstat(path)).mtimeMs; + if ( + age <= STALE_FILE_AGE_MS || + (activeTempPaths.has(path) && age <= MAX_ACTIVE_TEMP_AGE_MS) + ) + continue; + await rm(path, { recursive: true, force: true }); + activeTempPaths.delete(path); + cleaned++; + } catch {} + } - return cleaned; + return cleaned; +} + +export async function startTempDirMaintenance(): Promise { + if (!maintenanceTimer) { + maintenanceTimer = setInterval(() => { + void cleanupStaleTempFiles() + .then((cleaned) => { + if (cleaned > 0) + console.log(`[temp-files] Removed ${cleaned} stale temp entries`); + }) + .catch(() => {}); + }, TEMP_MAINTENANCE_INTERVAL_MS); + maintenanceTimer.unref?.(); + } + return await sweepOrphanedTempDirs(); +} + +export async function removeOwnTempDir(): Promise { + activeTempPaths.clear(); + await releaseInstanceDir(TEMP_ROOT); +} + +async function measureDir( + dir: string, + usage: TempDirUsage, +): Promise { + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); } catch { - return 0; + return usage; + } + for (const entry of entries) { + if (usage.entries >= TEMP_USAGE_MAX_ENTRIES) { + usage.truncated = true; + return usage; + } + usage.entries++; + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await measureDir(path, usage); + } else { + usage.bytes += await lstat(path).then( + (stats) => stats.size, + () => 0, + ); + } } + return usage; +} + +let tempUsageCache: + | { measuredAt: number; usage: Promise } + | undefined; + +export function getTempDirUsage(): Promise { + const now = Date.now(); + if ( + !tempUsageCache || + now - tempUsageCache.measuredAt > TEMP_USAGE_CACHE_MS + ) { + tempUsageCache = { + measuredAt: now, + usage: measureDir(TEMP_ROOT, { bytes: 0, entries: 0, truncated: false }), + }; + } + return tempUsageCache.usage; +} + +export function getTempRoot(): string { + return TEMP_ROOT; } export function getTempDir(): string { diff --git a/apps/media-server/src/lib/video-input-validation.ts b/apps/media-server/src/lib/video-input-validation.ts index 41e26532384..2adb2dbbe4d 100644 --- a/apps/media-server/src/lib/video-input-validation.ts +++ b/apps/media-server/src/lib/video-input-validation.ts @@ -1,5 +1,9 @@ import { spawn } from "bun"; -import { PROCESS_TIMEOUT_MS, withTimeout } from "./media-common"; +import { + decodeThreadsFromEnv, + PROCESS_TIMEOUT_MS, + withTimeout, +} from "./media-common"; import { registerSubprocess, terminateProcess } from "./subprocess"; const MAX_DIAGNOSTIC_LENGTH = 8_192; @@ -22,7 +26,7 @@ export async function validateVideoInput( "-err_detect", "explode+crccheck", "-threads", - "2", + decodeThreadsFromEnv("MEDIA_SERVER_VALIDATE_DECODE_THREADS", 2), "-i", inputPath, "-map", diff --git a/apps/media-server/src/routes/audio.ts b/apps/media-server/src/routes/audio.ts index d2f8cc71e2b..bd4c3a12c5d 100644 --- a/apps/media-server/src/routes/audio.ts +++ b/apps/media-server/src/routes/audio.ts @@ -1,6 +1,14 @@ -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; import { z } from "zod"; -import { validateMediaServerSecret } from "../lib/auth"; +import { canReadStatus, validateMediaServerSecret } from "../lib/auth"; +import { + DRAIN_RETRY_AFTER_SECONDS, + DRAINING_DETAILS, + getIdleTimeoutSeconds, + isDraining, + restoreIdleTimeoutOnFirstChunk, + setRequestIdleTimeout, +} from "../lib/lifecycle"; import { canAcceptNewAudioOperation as canAcceptNewProcess, checkHasAudioTrack, @@ -35,7 +43,22 @@ function isTimeoutError(err: unknown): boolean { return err instanceof Error && err.message.includes("timed out"); } +function drainingResponse(c: Context) { + c.header("Retry-After", DRAIN_RETRY_AFTER_SECONDS.toString()); + return c.json( + { + error: "Server is busy", + code: "SERVER_BUSY", + details: DRAINING_DETAILS, + }, + 503, + ); +} + audio.get("/status", (c) => { + if (!canReadStatus(c)) { + return c.json({ error: "Unauthorized" }, 401); + } return c.json({ activeProcesses: getActiveProcessCount(), canAcceptNewProcess: canAcceptNewProcess(), @@ -61,6 +84,8 @@ audio.post("/check", async (c) => { ); } + if (isDraining()) return drainingResponse(c); + try { const hasAudio = await checkHasAudioTrack(result.data.videoUrl); return c.json({ hasAudio }); @@ -120,6 +145,11 @@ audio.post("/extract", async (c) => { } const { videoUrl, stream: useStreaming } = result.data; + if (isDraining()) return drainingResponse(c); + setRequestIdleTimeout(c.env, c.req.raw, 0); + const restoreIdleTimeout = () => + setRequestIdleTimeout(c.env, c.req.raw, getIdleTimeoutSeconds()); + let streaming = false; try { const hasAudio = await checkHasAudioTrack(videoUrl); @@ -137,12 +167,16 @@ audio.post("/extract", async (c) => { cleanup(); }); - return new Response(stream, { - headers: { - "Content-Type": "audio/mpeg", - "Transfer-Encoding": "chunked", + streaming = true; + return new Response( + restoreIdleTimeoutOnFirstChunk(stream, restoreIdleTimeout), + { + headers: { + "Content-Type": "audio/mpeg", + "Transfer-Encoding": "chunked", + }, }, - }); + ); } const audioData = await extractAudio(videoUrl); @@ -186,6 +220,8 @@ audio.post("/extract", async (c) => { }, 500, ); + } finally { + if (!streaming) restoreIdleTimeout(); } }); @@ -209,6 +245,11 @@ audio.post("/convert", async (c) => { } const { audioUrl, bitrate } = result.data; + if (isDraining()) return drainingResponse(c); + setRequestIdleTimeout(c.env, c.req.raw, 0); + const restoreIdleTimeout = () => + setRequestIdleTimeout(c.env, c.req.raw, getIdleTimeoutSeconds()); + let streaming = false; try { const { stream, cleanup } = extractAudioStream(audioUrl, { @@ -220,12 +261,16 @@ audio.post("/convert", async (c) => { cleanup(); }); - return new Response(stream, { - headers: { - "Content-Type": "audio/mpeg", - "Transfer-Encoding": "chunked", + streaming = true; + return new Response( + restoreIdleTimeoutOnFirstChunk(stream, restoreIdleTimeout), + { + headers: { + "Content-Type": "audio/mpeg", + "Transfer-Encoding": "chunked", + }, }, - }); + ); } catch (err) { console.error("[audio/convert] Error:", err); @@ -259,6 +304,8 @@ audio.post("/convert", async (c) => { }, 500, ); + } finally { + if (!streaming) restoreIdleTimeout(); } }); diff --git a/apps/media-server/src/routes/health.ts b/apps/media-server/src/routes/health.ts index 07b2731ab43..c3fef26c6ad 100644 --- a/apps/media-server/src/routes/health.ts +++ b/apps/media-server/src/routes/health.ts @@ -1,11 +1,15 @@ import os from "node:os"; import { Hono } from "hono"; +import { hasMediaServerSecret } from "../lib/auth"; import { getSystemResources } from "../lib/job-manager"; +import { isDraining } from "../lib/lifecycle"; import { getMediaEngineStatus } from "../lib/media-engine"; +import { getVideoEncoderStatus } from "../lib/nvenc-encoder"; +import { getRuntimeReport } from "../lib/runtime-report"; const health = new Hono(); -health.get("/", (c) => { +health.get("/", async (c) => { const mediaEngine = getMediaEngineStatus(); const [, loadAvg5m, loadAvg15m] = os.loadavg(); const totalMemoryBytes = os.totalmem(); @@ -16,26 +20,36 @@ health.get("/", (c) => { (1 - freeMemoryBytes / totalMemoryBytes) * 100, ); const resources = getSystemResources(); + const draining = isDraining(); + const authenticated = hasMediaServerSecret(c); - return c.json({ - status: - mediaEngine.available && resources.effectiveMax > 0 ? "ok" : "degraded", - mediaEngine, - ["ff" + "mpeg"]: { - available: mediaEngine.available, - version: mediaEngine.version, + return c.json( + { + status: draining + ? "draining" + : mediaEngine.available && resources.effectiveMax > 0 + ? "ok" + : "degraded", + mediaEngine, + ["ff" + "mpeg"]: { + available: mediaEngine.available, + version: mediaEngine.version, + }, + videoEncoder: getVideoEncoderStatus(process.env, authenticated), + system: { + ...resources, + loadAvg5m, + loadAvg15m, + totalMemoryMB, + freeMemoryMB, + memoryUsagePercent, + uptimeSeconds: Math.round(os.uptime()), + processUptimeSeconds: Math.round(process.uptime()), + }, + ...(authenticated && { runtime: await getRuntimeReport() }), }, - system: { - ...resources, - loadAvg5m, - loadAvg15m, - totalMemoryMB, - freeMemoryMB, - memoryUsagePercent, - uptimeSeconds: Math.round(os.uptime()), - processUptimeSeconds: Math.round(process.uptime()), - }, - }); + draining ? 503 : 200, + ); }); export default health; diff --git a/apps/media-server/src/routes/video.ts b/apps/media-server/src/routes/video.ts index cd2b44e68b5..1bedd87772c 100644 --- a/apps/media-server/src/routes/video.ts +++ b/apps/media-server/src/routes/video.ts @@ -4,7 +4,11 @@ import { file } from "bun"; import { Hono } from "hono"; import { z } from "zod"; import { enhanceLocalRecording } from "../lib/audio-levels"; -import { validateMediaServerSecret } from "../lib/auth"; +import { + canReadStatus, + hasMediaServerSecret, + validateMediaServerSecret, +} from "../lib/auth"; import type { Job, RecordingErrorCode, @@ -17,6 +21,7 @@ import { claimRecordingWorker, createJob, deleteJob, + extendJobLifetime, forceCleanupActiveJobs, generateJobId, getActiveVideoProcessCount, @@ -30,8 +35,19 @@ import { touchJob, updateJob, } from "../lib/job-manager"; -import { PROCESS_TIMEOUT_MS } from "../lib/media-common"; -import { renderEditedVideo } from "../lib/media-edit"; +import { + DRAINING_DETAILS, + getIdleTimeoutSeconds, + isDraining, + setRequestIdleTimeout, + trackBackgroundTask, +} from "../lib/lifecycle"; +import { logReason } from "../lib/log-redaction"; +import { PROCESS_TIMEOUT_MS, UPLOAD_TIMEOUT_MS } from "../lib/media-common"; +import { + getTimeoutMs as getEditRenderTimeoutMs, + renderEditedVideo, +} from "../lib/media-edit"; import { canAcceptNewProbeOperation as canAcceptNewProbeProcess, getActiveProbeOperationCount as getActiveProbeProcessCount, @@ -47,6 +63,7 @@ import { } from "../lib/media-transfer"; import type { ResilientInputFlags, + StorageUploadReceipt, StorageUploadTarget, } from "../lib/media-video"; import { @@ -54,6 +71,8 @@ import { downloadVideoToTemp, generatePreviewGif, generateThumbnail, + getProcessTimeoutMs, + LOOM_IMPORT_MAX_BYTES, muxMediaTracksToMp4, processVideo, repairContainer, @@ -61,14 +80,19 @@ import { uploadFileToStorage, uploadToS3, } from "../lib/media-video"; +import { encodeAttemptsBudgetMs } from "../lib/nvenc-encoder"; +import { findJobOnPeer, isPeerLookup } from "../lib/peer-jobs"; import { RecordingTimingError } from "../lib/recording-timing"; import { hashRecordingFile, isRetryableRecordingVerificationError, + verifyDecodeChunks, + verifyRecording, verifyRemoteRecording, verifyRemoteRecordingBytes, verifyRemuxedRecording, } from "../lib/recording-verification"; +import { getRuntimeReport } from "../lib/runtime-report"; import type { TempFileHandle } from "../lib/temp-files"; import { cleanupStaleTempFiles } from "../lib/temp-files"; import { @@ -91,8 +115,11 @@ const PROBE_ERROR_CODE = ["FF", "PROBE_ERROR"].join(""); const VIDEO_BUSY_RETRY_AFTER_SECONDS = 15; const VIDEO_MEMORY_PRESSURE_ERROR = "SERVER_BUSY: container memory pressure is too high"; +const MUX_FAILURE_LOG_LENGTH = 1_000; const SEGMENT_DOWNLOAD_MAX_ATTEMPTS = 3; const SEGMENT_DOWNLOAD_RETRY_BASE_MS = 250; +const JOB_TRANSFER_ALLOWANCE_MS = 2 * UPLOAD_TIMEOUT_MS; +const JOB_TRANSFER_BYTES_PER_SECOND = 2 * 1024 ** 2; const probeSchema = z.object({ videoUrl: z.string().url(), @@ -111,6 +138,31 @@ const convertSchema = z.object({ inputExtension: z.string().optional(), }); +const storageUploadSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("put"), + url: z.string().url(), + ifNoneMatch: z.literal("*").optional(), + }), + z.object({ + type: z.literal("multipart"), + videoId: z.string(), + generation: z.string().min(1).max(200).optional(), + attemptId: z.string().min(1).max(200).optional(), + key: z.string().min(1), + uploadId: z.string().min(1), + partSize: z + .number() + .int() + .min(5 * 1024 * 1024), + signPartUrl: z.string().url(), + completeUrl: z.string().url(), + abortUrl: z.string().url(), + webhookSecret: z.string().optional(), + loomImportAttemptId: z.string().min(1).max(200).optional(), + }), +]); + const processSchema = z.object({ audioLevels: z.boolean().optional(), videoId: z.string(), @@ -118,6 +170,16 @@ const processSchema = z.object({ videoUrl: z.string().url(), outputPresignedUrl: z.string().url(), sourcePresignedUrl: z.string().url().optional(), + sourceUpload: storageUploadSchema.optional(), + outputUpload: storageUploadSchema.optional(), + existingOutput: z + .object({ + url: z.string().url(), + objectIdentity: z.string().regex(/^"[^"\r\n]+"$/), + fileSize: z.number().int().positive().max(LOOM_IMPORT_MAX_BYTES), + }) + .optional(), + maxImportBytes: z.literal(LOOM_IMPORT_MAX_BYTES).optional(), thumbnailPresignedUrl: z.string().url().optional(), previewGifPresignedUrl: z.string().url().optional(), webhookUrl: z.string().url().optional(), @@ -220,6 +282,12 @@ function getMuxBusyResponseBody( }; } +const DRAINING_RESPONSE_BODY = { + error: "Server is busy", + code: "SERVER_BUSY", + details: DRAINING_DETAILS, +}; + function summarizeVideoInput(videoUrl: string, inputExtension?: string) { const extension = inputExtension?.toLowerCase() ?? null; try { @@ -286,25 +354,26 @@ async function createVideoDownloadResponse( onCleanup?.(); }; + const reader = outputFile.stream().getReader(); const stream = new ReadableStream({ - async start(controller) { - const reader = outputFile.stream().getReader(); - + async pull(controller) { try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) controller.enqueue(value); + const { done, value } = await reader.read(); + if (done) { + controller.close(); + reader.releaseLock(); + await cleanup(); + } else if (value) { + controller.enqueue(value); } - controller.close(); } catch (error) { controller.error(error); - } finally { reader.releaseLock(); await cleanup(); } }, async cancel() { + await reader.cancel().catch(() => {}); await cleanup(); }, }); @@ -345,6 +414,23 @@ async function withJobHeartbeat( } } +function extendJobLifetimeForWork( + jobId: string, + workTimeoutMs: number, + transferBytes: number, +): void { + extendJobLifetime( + jobId, + encodeAttemptsBudgetMs(workTimeoutMs) + + JOB_TRANSFER_ALLOWANCE_MS + + POST_VERIFICATION_ASSET_BUDGET_MS + + Math.ceil( + (Math.max(0, transferBytes || 0) / JOB_TRANSFER_BYTES_PER_SECOND) * + 1000, + ), + ); +} + async function withMuxMemoryGuard( abortController: AbortController, operation: () => Promise, @@ -373,13 +459,18 @@ async function withMuxMemoryGuard( } } -video.get("/status", (c) => { +video.get("/status", async (c) => { + if (!canReadStatus(c)) { + return c.json({ error: "Unauthorized" }, 401); + } const jobs = getAllJobs(); const resources = getSystemResources(); const now = Date.now(); return c.json({ instanceId: getInstanceId(), pid: process.pid, + loomImportMaxBytes: LOOM_IMPORT_MAX_BYTES, + loomMultipartUploads: true, activeVideoProcesses: getActiveVideoProcessCount(), maxConcurrentVideoProcesses: getMaxConcurrentVideoProcesses(), effectiveMaxVideoProcesses: resources.effectiveMax, @@ -401,6 +492,7 @@ video.get("/status", (c) => { stalenessMinutes: Math.round((now - j.updatedAt) / 60000), error: j.error, })), + ...(hasMediaServerSecret(c) && { runtime: await getRuntimeReport() }), }); }); @@ -423,6 +515,11 @@ video.post("/probe", async (c) => { ); } + if (isDraining()) { + c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); + return c.json(DRAINING_RESPONSE_BODY, 503); + } + try { const metadata = await probeVideo(result.data.videoUrl); return c.json({ metadata }); @@ -481,6 +578,11 @@ video.post("/thumbnail", async (c) => { ); } + if (isDraining()) { + c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); + return c.json(DRAINING_RESPONSE_BODY, 503); + } + try { const metadata = await probeVideo(result.data.videoUrl); @@ -562,6 +664,17 @@ video.post("/convert", async (c) => { const requestId = crypto.randomUUID(); const startedAt = Date.now(); + if (isDraining()) { + c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); + return c.json( + { + ...getBusyResponseBody(getVideoCapacitySnapshot()), + details: DRAINING_DETAILS, + }, + 503, + ); + } + try { slot = tryAcquireDirectVideoProcessSlot(canAcceptNewVideoProcess); const capacity = getVideoCapacitySnapshot(); @@ -583,6 +696,7 @@ video.post("/convert", async (c) => { ), ...capacity, }); + setRequestIdleTimeout(c.env, c.req.raw, 0); inputTempFile = await downloadVideoToTemp( result.data.videoUrl, @@ -618,12 +732,14 @@ video.post("/convert", async (c) => { ...getVideoCapacitySnapshot(), }); + setRequestIdleTimeout(c.env, c.req.raw, getIdleTimeoutSeconds()); return await createVideoDownloadResponse( outputTempFile, [inputTempFile, outputTempFile], slot.release, ); } catch (err) { + setRequestIdleTimeout(c.env, c.req.raw, getIdleTimeoutSeconds()); slot?.release(); await cleanupTempFiles([outputTempFile, inputTempFile]); logVideoEvent("video_convert_failed", { @@ -676,7 +792,10 @@ video.on("POST", ["/process", "/import"], async (c) => { const body = await c.req.json(); const result = ( c.req.path.endsWith("/import") - ? processSchema.extend({ sourcePresignedUrl: z.string().url() }) + ? processSchema.refine( + (input) => input.sourcePresignedUrl || input.sourceUpload, + { message: "An original video upload target is required" }, + ) : processSchema ).safeParse(body); @@ -691,7 +810,29 @@ video.on("POST", ["/process", "/import"], async (c) => { ); } + if ( + result.data.existingOutput && + (result.data.sourceUpload || + result.data.sourcePresignedUrl || + result.data.outputUpload || + result.data.maxImportBytes !== LOOM_IMPORT_MAX_BYTES) + ) { + return c.json( + { + error: "Existing output cannot be combined with upload targets", + code: "INVALID_REQUEST", + }, + 400, + ); + } const capacity = getVideoCapacitySnapshot(); + if (isDraining()) { + c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); + return c.json( + { ...getBusyResponseBody(capacity), details: DRAINING_DETAILS }, + 503, + ); + } const bulkCapacityReached = result.data.priority === "bulk" && capacity.effectiveMaxVideoProcesses > 1 && @@ -715,34 +856,36 @@ video.on("POST", ["/process", "/import"], async (c) => { const jobId = generateJobId(); const job = createJob(jobId, videoId, userId, webhookUrl, webhookSecret); - withMediaTransfers(32 * 1024 ** 3, () => - processVideoAsync( - job.jobId, - videoUrl, - outputPresignedUrl, - thumbnailPresignedUrl, - previewGifPresignedUrl, - result.data, - ), - ).catch((err) => { - console.error( - `[video/process] Async processing error for job ${jobId}:`, - err, - ); - const currentJob = getJob(jobId); - if ( - currentJob && - currentJob.phase !== "error" && - currentJob.phase !== "complete" && - currentJob.phase !== "cancelled" - ) { - updateJob(jobId, { - phase: "error", - error: err instanceof Error ? err.message : String(err), - message: "Processing failed (unhandled)", - }); - } - }); + void trackBackgroundTask( + withMediaTransfers(32 * 1024 ** 3, () => + processVideoAsync( + job.jobId, + videoUrl, + outputPresignedUrl, + thumbnailPresignedUrl, + previewGifPresignedUrl, + result.data, + ), + ).catch((err) => { + console.error( + `[video/process] Async processing error for job ${jobId}:`, + err, + ); + const currentJob = getJob(jobId); + if ( + currentJob && + currentJob.phase !== "error" && + currentJob.phase !== "complete" && + currentJob.phase !== "cancelled" + ) { + updateJob(jobId, { + phase: "error", + error: err instanceof Error ? err.message : String(err), + message: "Processing failed (unhandled)", + }); + } + }), + ); return c.json({ jobId, @@ -775,6 +918,17 @@ video.post("/edit", async (c) => { ); } + if (isDraining()) { + c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); + return c.json( + { + ...getBusyResponseBody(getVideoCapacitySnapshot()), + details: DRAINING_DETAILS, + }, + 503, + ); + } + if (!canAcceptNewVideoProcess()) { c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); return c.json(getBusyResponseBody(getVideoCapacitySnapshot()), 503); @@ -794,31 +948,33 @@ video.post("/edit", async (c) => { const jobId = generateJobId(); const job = createJob(jobId, videoId, userId, webhookUrl, webhookSecret); - withMediaTransfers(32 * 1024 ** 3, () => - editVideoAsync( - job.jobId, - sourceUrl, - outputPresignedUrl, - thumbnailPresignedUrl, - previewGifPresignedUrl, - result.data, - ), - ).catch((err) => { - console.error(`[video/edit] Async edit error for job ${jobId}:`, err); - const currentJob = getJob(jobId); - if ( - currentJob && - currentJob.phase !== "error" && - currentJob.phase !== "complete" && - currentJob.phase !== "cancelled" - ) { - updateJob(jobId, { - phase: "error", - error: err instanceof Error ? err.message : String(err), - message: "Edit failed (unhandled)", - }); - } - }); + void trackBackgroundTask( + withMediaTransfers(32 * 1024 ** 3, () => + editVideoAsync( + job.jobId, + sourceUrl, + outputPresignedUrl, + thumbnailPresignedUrl, + previewGifPresignedUrl, + result.data, + ), + ).catch((err) => { + console.error(`[video/edit] Async edit error for job ${jobId}:`, err); + const currentJob = getJob(jobId); + if ( + currentJob && + currentJob.phase !== "error" && + currentJob.phase !== "complete" && + currentJob.phase !== "cancelled" + ) { + updateJob(jobId, { + phase: "error", + error: err instanceof Error ? err.message : String(err), + message: "Edit failed (unhandled)", + }); + } + }), + ); return c.json({ jobId, @@ -963,6 +1119,11 @@ async function processWithResilientRetry( progress: 10, message: "Retrying with error recovery...", }); + extendJobLifetimeForWork( + jobId, + getProcessTimeoutMs(metadata.duration, PROCESS_TIMEOUT_MS), + metadata.fileSize, + ); try { const outputFile = await processVideo( @@ -988,6 +1149,11 @@ async function processWithResilientRetry( progress: 10, message: "Attempting full repair...", }); + extendJobLifetimeForWork( + jobId, + getProcessTimeoutMs(metadata.duration, PROCESS_TIMEOUT_MS), + metadata.fileSize, + ); let lastResortRepairFile: TempFileHandle | null = null; try { @@ -1032,15 +1198,30 @@ async function generateAndUploadPreviewGif( ): Promise { if (!previewGifPresignedUrl) return; - let previewGifFile: TempFileHandle | null = null; - + const render = generatePreviewGif(inputPath, duration, {}, abortSignal); try { - previewGifFile = await generatePreviewGif( - inputPath, - duration, - {}, + await uploadRenderedPreviewGif( + render, + previewGifPresignedUrl, abortSignal, + logPrefix, + ); + } finally { + await render.then( + (gif) => gif.cleanup(), + () => {}, ); + } +} + +async function uploadRenderedPreviewGif( + render: Promise, + previewGifPresignedUrl: string, + abortSignal: AbortSignal | undefined, + logPrefix: string, +): Promise { + try { + const previewGifFile = await render; await uploadFileToS3( previewGifFile.path, previewGifPresignedUrl, @@ -1054,8 +1235,6 @@ async function generateAndUploadPreviewGif( : new Error("Preview GIF generation aborted"); } console.warn(`[${logPrefix}] Preview GIF generation failed:`, previewErr); - } finally { - await previewGifFile?.cleanup(); } } @@ -1075,21 +1254,25 @@ function isDurationClose(actual: number, expected: number) { ); } -async function waitForVerificationRetry(attempt: number) { - await new Promise((resolve) => - setTimeout(resolve, UPLOAD_VERIFICATION_RETRY_MS * (attempt + 1)), - ); +async function waitForVerificationRetry( + attempt: number, + abortSignal?: AbortSignal, +) { + await Bun.sleep(UPLOAD_VERIFICATION_RETRY_MS * (attempt + 1)); + abortSignal?.throwIfAborted(); } async function verifyUploadedVideo( outputVerificationUrl: string | undefined, expectedMetadata: VideoMetadata, + abortSignal?: AbortSignal, ) { if (!outputVerificationUrl) return; let lastError: Error | undefined; for (let attempt = 0; attempt < UPLOAD_VERIFICATION_ATTEMPTS; attempt++) { + abortSignal?.throwIfAborted(); try { const actualMetadata = await probeVideo(outputVerificationUrl); if (isDurationClose(actualMetadata.duration, expectedMetadata.duration)) { @@ -1104,7 +1287,7 @@ async function verifyUploadedVideo( } if (attempt < UPLOAD_VERIFICATION_ATTEMPTS - 1) { - await waitForVerificationRetry(attempt); + await waitForVerificationRetry(attempt, abortSignal); } } @@ -1124,7 +1307,10 @@ async function editVideoAsync( } const abortController = new AbortController(); + const signal = abortController.signal; updateJob(jobId, { abortController }); + let inputTempFile: TempFileHandle | null = null; + let outputTempFile: TempFileHandle | null = null; try { updateJob(jobId, { @@ -1137,10 +1323,11 @@ async function editVideoAsync( await sendWebhook(downloadingJob); } - const inputTempFile = await withJobHeartbeat(jobId, () => - downloadVideoToTemp(sourceUrl, ".mp4", abortController.signal), + const input = await withJobHeartbeat(jobId, () => + downloadVideoToTemp(sourceUrl, ".mp4", signal), ); - updateJob(jobId, { inputTempFile }); + inputTempFile = input; + updateJob(jobId, { inputTempFile: input }); updateJob(jobId, { phase: "probing", @@ -1152,7 +1339,15 @@ async function editVideoAsync( await sendWebhook(probingJob); } - const sourceMetadata = await probeVideoFile(inputTempFile.path); + const sourceMetadata = await withJobHeartbeat(jobId, () => + probeVideoFile(input.path), + ); + signal.throwIfAborted(); + extendJobLifetimeForWork( + jobId, + getEditRenderTimeoutMs(options.keepRanges), + sourceMetadata.fileSize, + ); updateJob(jobId, { phase: "processing", @@ -1164,12 +1359,12 @@ async function editVideoAsync( await sendWebhook(processingJob); } - const outputTempFile = await withJobHeartbeat(jobId, () => + const output = await withJobHeartbeat(jobId, () => renderEditedVideo({ - inputPath: inputTempFile.path, + inputPath: input.path, keepRanges: options.keepRanges, metadata: sourceMetadata, - abortSignal: abortController.signal, + abortSignal: signal, onProgress: (progress, message) => { updateJob(jobId, { progress: Math.min(80, 10 + progress * 0.9), @@ -1187,9 +1382,13 @@ async function editVideoAsync( }, }), ); - updateJob(jobId, { outputTempFile }); + outputTempFile = output; + updateJob(jobId, { outputTempFile: output }); - const outputMetadata = await probeVideoFile(outputTempFile.path); + const outputMetadata = await withJobHeartbeat(jobId, () => + probeVideoFile(output.path), + ); + signal.throwIfAborted(); updateJob(jobId, { metadata: outputMetadata }); updateJob(jobId, { @@ -1202,8 +1401,19 @@ async function editVideoAsync( await sendWebhook(uploadingJob); } - await uploadFileToS3(outputTempFile.path, outputPresignedUrl, "video/mp4"); - await verifyUploadedVideo(options.outputVerificationUrl, outputMetadata); + await withJobHeartbeat(jobId, async () => { + await uploadFileToS3( + output.path, + outputPresignedUrl, + "video/mp4", + signal, + ); + await verifyUploadedVideo( + options.outputVerificationUrl, + outputMetadata, + signal, + ); + }); if (thumbnailPresignedUrl || previewGifPresignedUrl) { updateJob(jobId, { @@ -1217,21 +1427,31 @@ async function editVideoAsync( } } - if (thumbnailPresignedUrl) { - const thumbnailData = await generateThumbnail( - outputTempFile.path, + await withJobHeartbeat(jobId, async () => { + if (thumbnailPresignedUrl) { + const thumbnailData = await generateThumbnail( + output.path, + outputMetadata.duration, + {}, + signal, + ); + await uploadToS3( + thumbnailData, + thumbnailPresignedUrl, + "image/jpeg", + signal, + ); + } + + await generateAndUploadPreviewGif( + output.path, outputMetadata.duration, + previewGifPresignedUrl, + signal, + "video/edit", ); - await uploadToS3(thumbnailData, thumbnailPresignedUrl, "image/jpeg"); - } - - await generateAndUploadPreviewGif( - outputTempFile.path, - outputMetadata.duration, - previewGifPresignedUrl, - abortController.signal, - "video/edit", - ); + }); + signal.throwIfAborted(); updateJob(jobId, { phase: "complete", @@ -1243,12 +1463,11 @@ async function editVideoAsync( await sendWebhook(completedJob); } - await inputTempFile.cleanup(); - await outputTempFile.cleanup(); - setTimeout(() => deleteJob(jobId), 5 * 60 * 1000); } catch (err) { - console.error(`[video/edit] Error editing job ${jobId}:`, err); + if (!signal.aborted) { + console.error(`[video/edit] Error editing job ${jobId}:`, err); + } const updatedJob = updateJob(jobId, { phase: "error", @@ -1261,16 +1480,10 @@ async function editVideoAsync( await sendWebhook(updatedJob); } } finally { - const currentJob = getJob(jobId); - if (currentJob) { - await Promise.allSettled([ - currentJob.inputTempFile?.cleanup(), - currentJob.outputTempFile?.cleanup(), - ]); - } - setTimeout(() => deleteJob(jobId), 5 * 60 * 1000); } + } finally { + await cleanupTempFiles([inputTempFile, outputTempFile]); } } @@ -1288,10 +1501,28 @@ async function processVideoAsync( } const abortController = new AbortController(); + const signal = abortController.signal; updateJob(jobId, { abortController }); + let inputTempFile: TempFileHandle | null = null; + let outputTempFile: TempFileHandle | null = null; let repairedTempFile: TempFileHandle | null = null; let lastResortRepairFile: TempFileHandle | null = null; + let sourceUploaded = false; + let outputUploaded = false; + const abortPendingUploads = async () => { + const targets = [ + !sourceUploaded ? options.sourceUpload : undefined, + !outputUploaded ? options.outputUpload : undefined, + ].filter((target): target is StorageUploadTarget => target !== undefined); + await Promise.all( + targets.map((target) => + abortStorageUploadTarget(target).catch((error) => { + console.warn("Loom multipart cleanup failed", error); + }), + ), + ); + }; try { updateJob(jobId, { @@ -1301,34 +1532,60 @@ async function processVideoAsync( }); await sendWebhook(job); - const inputTempFile = await withJobHeartbeat(jobId, () => + const input = await withJobHeartbeat(jobId, () => downloadVideoToTemp( - videoUrl, + options.existingOutput?.url ?? videoUrl, options.inputExtension, - abortController.signal, + signal, + options.maxImportBytes, ), ); - updateJob(jobId, { inputTempFile }); + inputTempFile = input; + updateJob(jobId, { inputTempFile: input }); + if (options.existingOutput) { + const existing = options.existingOutput; + await withJobHeartbeat(jobId, async () => { + await verifyRecording(input.path, { + requireAudio: false, + allowObservedDuration: true, + abortSignal: signal, + }); + await verifyRemoteRecordingBytes(existing.url, { + expectedObjectIdentity: existing.objectIdentity, + expectedFileSize: existing.fileSize, + expectedSha256: await hashRecordingFile(input.path, signal), + abortSignal: signal, + }); + }); + } const sourcePresignedUrl = options.sourcePresignedUrl; - if (sourcePresignedUrl) { + if (sourcePresignedUrl || options.sourceUpload) { updateJob(jobId, { message: "Saving original video..." }); await sendWebhook(job); await withJobHeartbeat(jobId, () => - uploadFileToS3( - inputTempFile.path, - sourcePresignedUrl, - "video/mp4", - abortController.signal, - ), + options.sourceUpload + ? uploadFileToStorage( + input.path, + options.sourceUpload, + "video/mp4", + signal, + ) + : uploadFileToS3( + input.path, + sourcePresignedUrl as string, + "video/mp4", + signal, + ), ); + sourceUploaded = true; } const isWebm = isWebmInput(options.inputExtension); if (isWebm) { updateJob(jobId, { message: "Checking the original recording..." }); await withJobHeartbeat(jobId, () => - validateVideoInput(inputTempFile.path, abortController.signal), + validateVideoInput(input.path, signal), ); } @@ -1339,17 +1596,24 @@ async function processVideoAsync( }); await sendWebhook(job); - const { metadata, repairedFile } = await probeWithRepairFallback( - inputTempFile.path, - isWebm, - abortController.signal, + const { metadata, repairedFile } = await withJobHeartbeat(jobId, () => + options.existingOutput + ? probeVideoFile(input.path).then((metadata) => ({ + metadata, + repairedFile: null, + })) + : probeWithRepairFallback(input.path, isWebm, signal), ); repairedTempFile = repairedFile; + signal.throwIfAborted(); updateJob(jobId, { metadata }); + extendJobLifetimeForWork( + jobId, + getProcessTimeoutMs(metadata.duration, PROCESS_TIMEOUT_MS), + metadata.fileSize, + ); - const processingInputPath = repairedFile - ? repairedFile.path - : inputTempFile.path; + const processingInputPath = repairedFile ? repairedFile.path : input.path; updateJob(jobId, { phase: "processing", @@ -1360,18 +1624,27 @@ async function processVideoAsync( }); await sendWebhook(job); - const { outputFile: outputTempFile, lastResortRepairFile: lrrf } = - await processWithResilientRetry( - processingInputPath, - inputTempFile.path, - metadata, - options, - isWebm, - jobId, - abortController.signal, - ); - lastResortRepairFile = lrrf; - updateJob(jobId, { outputTempFile }); + const processed = options.existingOutput + ? { outputFile: input, lastResortRepairFile: null } + : await processWithResilientRetry( + processingInputPath, + input.path, + metadata, + options, + isWebm, + jobId, + signal, + ); + outputTempFile = processed.outputFile; + lastResortRepairFile = processed.lastResortRepairFile; + const outputPath = processed.outputFile.path; + updateJob(jobId, { outputTempFile: processed.outputFile }); + if ( + options.maxImportBytes && + file(outputPath).size > options.maxImportBytes + ) { + throw new Error("Loom imports are limited to 10 GB"); + } updateJob(jobId, { phase: "uploading", @@ -1380,54 +1653,93 @@ async function processVideoAsync( }); await sendWebhook(job); - const uploadReceipt = await uploadFileToS3( - outputTempFile.path, - outputPresignedUrl, - "video/mp4", - abortController.signal, - ); - - if (thumbnailPresignedUrl || previewGifPresignedUrl) { - updateJob(jobId, { - phase: "generating_thumbnail", - progress: 90, - message: "Generating preview assets...", - }); - await sendWebhook(job); - } - - if (thumbnailPresignedUrl) { - // The uploaded MP4 has not been independently verified, so decode failures must still fail the job. - const thumbnailData = await generateThumbnail( - outputTempFile.path, - metadata.duration, - {}, - abortController.signal, + // Previews only read the finished output, so render them while it uploads; they are still + // uploaded after the output, so a failed upload never leaves previews behind. + let uploadReceipt: StorageUploadReceipt; + const previewAbort = new AbortController(); + const previewSignal = AbortSignal.any([signal, previewAbort.signal]); + const thumbnailRender = thumbnailPresignedUrl + ? generateThumbnail(outputPath, metadata.duration, {}, previewSignal) + : undefined; + const previewGifRender = previewGifPresignedUrl + ? generatePreviewGif(outputPath, metadata.duration, {}, previewSignal) + : undefined; + thumbnailRender?.catch(() => {}); + previewGifRender?.catch(() => {}); + try { + uploadReceipt = await withJobHeartbeat(jobId, async () => + options.existingOutput + ? { objectIdentity: options.existingOutput.objectIdentity } + : options.outputUpload + ? uploadFileToStorage( + outputPath, + options.outputUpload, + "video/mp4", + signal, + ) + : uploadFileToS3( + outputPath, + outputPresignedUrl, + "video/mp4", + signal, + ), ); - try { - await uploadToS3( - thumbnailData, - thumbnailPresignedUrl, - "image/jpeg", - abortController.signal, - ); - } catch (error) { - abortController.signal.throwIfAborted(); - console.warn( - `[video/process] Thumbnail upload failed for ${jobId}:`, - error, - ); + outputUploaded = true; + + if (thumbnailPresignedUrl || previewGifPresignedUrl) { + updateJob(jobId, { + phase: "generating_thumbnail", + progress: 90, + message: "Generating preview assets...", + }); + await sendWebhook(job); } - } - await generateAndUploadPreviewGif( - outputTempFile.path, - metadata.duration, - previewGifPresignedUrl, - abortController.signal, - "video/process", - ); - abortController.signal.throwIfAborted(); + await withJobHeartbeat(jobId, async () => { + if (thumbnailRender && thumbnailPresignedUrl) { + // Only the fully verified reuse path can finish without a decoded preview. + const thumbnailData = await thumbnailRender.catch((error) => { + signal.throwIfAborted(); + if (!options.existingOutput) throw error; + console.warn( + "Verified Loom output preview could not be generated", + error, + ); + return undefined; + }); + try { + if (thumbnailData) + await uploadToS3( + thumbnailData, + thumbnailPresignedUrl, + "image/jpeg", + signal, + ); + } catch (error) { + signal.throwIfAborted(); + console.warn( + `[video/process] Thumbnail upload failed for ${jobId}:`, + error, + ); + } + } + + if (previewGifRender && previewGifPresignedUrl) + await uploadRenderedPreviewGif( + previewGifRender, + previewGifPresignedUrl, + signal, + "video/process", + ); + }); + } finally { + previewAbort.abort(); + await Promise.allSettled([ + thumbnailRender, + previewGifRender?.then((gif) => gif.cleanup()), + ]); + } + signal.throwIfAborted(); updateJob(jobId, { phase: "complete", @@ -1441,7 +1753,7 @@ async function processVideoAsync( if (options.audioLevels) await enhanceLocalRecording({ - path: outputTempFile.path, + path: outputPath, videoId: options.videoId, userId: options.userId, jobId, @@ -1452,14 +1764,10 @@ async function processVideoAsync( webhookSecret: options.webhookSecret, }); - await inputTempFile.cleanup(); - await outputTempFile.cleanup(); - await repairedTempFile?.cleanup(); - await lastResortRepairFile?.cleanup(); - setTimeout(() => deleteJob(jobId), 5 * 60 * 1000); } catch (err) { - if (!abortController.signal.aborted) { + await abortPendingUploads(); + if (!signal.aborted) { console.error(`[video/process] Error processing job ${jobId}:`, err); } const failedJob = getJob(jobId); @@ -1469,7 +1777,7 @@ async function processVideoAsync( failedJob.phase !== "cancelled" && failedJob.phase !== "error" ) { - const cancelled = abortController.signal.aborted; + const cancelled = signal.aborted; const updatedJob = updateJob(jobId, { phase: cancelled ? "cancelled" : "error", error: cancelled @@ -1483,22 +1791,36 @@ async function processVideoAsync( await sendWebhook(updatedJob); } } - - const currentJob = getJob(jobId); - if (currentJob) { - await currentJob.inputTempFile?.cleanup(); - await currentJob.outputTempFile?.cleanup(); - } - await repairedTempFile?.cleanup(); - await lastResortRepairFile?.cleanup(); + } finally { + await cleanupTempFiles([ + inputTempFile, + outputTempFile, + repairedTempFile, + lastResortRepairFile, + ]); } } video.get("/process/:jobId/status", async (c) => { + if (!canReadStatus(c)) { + return c.json({ error: "Unauthorized" }, 401); + } const jobId = c.req.param("jobId"); const job = getJob(jobId); + const accept = c.req.header("Accept"); if (!job) { + if ( + hasMediaServerSecret(c) && + !isPeerLookup(c.req.raw) && + !accept?.includes("text/event-stream") + ) { + const owner = await findJobOnPeer( + `/video/process/${encodeURIComponent(jobId)}/status`, + "GET", + ); + if (owner) return owner; + } return c.json( { error: "Job not found", @@ -1510,8 +1832,6 @@ video.get("/process/:jobId/status", async (c) => { ); } - const accept = c.req.header("Accept"); - if (accept?.includes("text/event-stream")) { const stream = new ReadableStream({ start(controller) { @@ -1581,6 +1901,13 @@ video.post("/process/:jobId/cancel", async (c) => { const job = getJob(jobId); if (!job) { + if (!isPeerLookup(c.req.raw)) { + const owner = await findJobOnPeer( + `/video/process/${encodeURIComponent(jobId)}/cancel`, + "POST", + ); + if (owner) return owner; + } return c.json( { error: "Job not found", @@ -1648,30 +1975,6 @@ video.post("/force-cleanup", (c) => { }); }); -const muxSegmentsOutputUploadSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("put"), - url: z.string().url(), - ifNoneMatch: z.literal("*").optional(), - }), - z.object({ - type: z.literal("multipart"), - videoId: z.string(), - generation: z.string().min(1).max(200).optional(), - attemptId: z.string().min(1).max(200).optional(), - key: z.string().min(1), - uploadId: z.string().min(1), - partSize: z - .number() - .int() - .min(5 * 1024 * 1024), - signPartUrl: z.string().url(), - completeUrl: z.string().url(), - abortUrl: z.string().url(), - webhookSecret: z.string().optional(), - }), -]); - const strongObjectIdentitySchema = z .string() .max(1_024) @@ -1845,6 +2148,16 @@ video.post("/verify-recording", async (c) => { ); return c.json(recordingWorkerResponse(existing, ownerJobId)); } + if (isDraining()) { + c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); + return c.json( + { + ...getMuxBusyResponseBody(getVideoCapacitySnapshot()), + details: DRAINING_DETAILS, + }, + 503, + ); + } if (!canAcceptNewVideoProcess(body.processingPriority)) { c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); return c.json(getMuxBusyResponseBody(getVideoCapacitySnapshot()), 503); @@ -1877,19 +2190,34 @@ video.post("/verify-recording", async (c) => { deleteJob(jobId); return c.json(recordingWorkerResponse(job, ownerJobId)); } - void withMediaTransfers(body.downloadBudgetBytes ?? body.fileSize * 3, () => - verifyUploadedRecordingAsync(jobId, body), - ).catch((error: unknown) => { - const current = getJob(jobId); - if (!current || ["complete", "error", "cancelled"].includes(current.phase)) - return; - updateJob(jobId, { - phase: "error", - error: "Recording transfer could not complete", - errorCode: classifySourceError(error), - }); - sendCurrentJobWebhook(jobId); - }); + void trackBackgroundTask( + withMediaTransfers(body.downloadBudgetBytes ?? body.fileSize * 3, () => + verifyUploadedRecordingAsync(jobId, body), + ).catch((error: unknown) => { + const current = getJob(jobId); + if ( + !current || + ["complete", "error", "cancelled"].includes(current.phase) + ) + return; + const errorCode = classifySourceError(error); + logVideoEvent("recording_verification_failed", { + jobId, + videoId: body.videoId, + phase: "error", + errorCode, + retryable: errorCode === "processing-unavailable", + stage: "transfer", + reason: logReason(error), + }); + updateJob(jobId, { + phase: "error", + error: "Recording transfer could not complete", + errorCode, + }); + sendCurrentJobWebhook(jobId); + }), + ); return c.json(recordingWorkerResponse(job, jobId)); }); @@ -1900,21 +2228,35 @@ async function verifyUploadedRecordingAsync( const abortController = new AbortController(); if (!updateJob(jobId, { abortController, phase: "processing", progress: 0 })) return; + let stage = "probe"; + let cause: unknown; + let mismatch: Record | undefined; try { const metadata = await probeVideo(body.videoUrl).catch((error: unknown) => { if (error instanceof MediaTransferBudgetError) throw error; + cause = error; throw new Error(RECORDING_VERIFICATION_RETRY_ERROR); }); + stage = "metadata"; if ( metadata.fileSize !== body.fileSize || (body.duration !== undefined && !isDurationClose(metadata.duration, body.duration)) || (body.requiredAudio && !metadata.audioCodec) ) { + mismatch = { + expectedFileSize: body.fileSize, + actualFileSize: metadata.fileSize, + expectedDuration: body.duration, + actualDuration: metadata.duration, + requiredAudio: body.requiredAudio, + audioCodec: metadata.audioCodec, + }; throw new Error( "Uploaded recording does not match the completed local file", ); } + stage = "decode"; if ( !beginRecordingVerification( jobId, @@ -1933,10 +2275,12 @@ async function verifyUploadedRecordingAsync( body.sourceObjectIdentity ?? body.objectIdentity, expectedFileSize: body.fileSize, hashContent: Boolean(body.generation), + hashDecodedContent: false, abortSignal: abortController.signal, }), ), ); + stage = "identity"; if (verified.fileSize !== body.fileSize) throw new Error("Verified recording size does not match the local file"); if (body.generation && !verified.remoteSha256) @@ -1974,15 +2318,27 @@ async function verifyUploadedRecordingAsync( (error instanceof Error && (error.message === VIDEO_MEMORY_PRESSURE_ERROR || error.message === RECORDING_VERIFICATION_RETRY_ERROR)); + const phase = + !retryable && abortController.signal.aborted ? "cancelled" : "error"; + const errorCode = retryable + ? "processing-unavailable" + : classifySourceError(error); + logVideoEvent("recording_verification_failed", { + jobId, + videoId: body.videoId, + phase, + errorCode, + retryable: errorCode === "processing-unavailable", + stage, + reason: logReason(cause ?? error), + ...(mismatch && { mismatch }), + }); updateJob(jobId, { - phase: - !retryable && abortController.signal.aborted ? "cancelled" : "error", + phase, error: retryable ? RECORDING_VERIFICATION_RETRY_ERROR : "Uploaded recording content could not be verified; retain the local recording", - errorCode: retryable - ? "processing-unavailable" - : classifySourceError(error), + errorCode, }); } const job = getJob(jobId); @@ -2013,7 +2369,7 @@ const muxSegmentsSchema = z videoId: z.string(), userId: z.string(), outputPresignedUrl: z.string().url().optional(), - outputUpload: muxSegmentsOutputUploadSchema.optional(), + outputUpload: storageUploadSchema.optional(), thumbnailPresignedUrl: z.string().url().optional(), previewGifPresignedUrl: z.string().url().optional(), webhookUrl: z.string().url().optional(), @@ -2174,6 +2530,17 @@ video.post("/mux-segments", async (c) => { }); } + if (isDraining()) { + c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); + return c.json( + { + ...getMuxBusyResponseBody(getVideoCapacitySnapshot()), + details: DRAINING_DETAILS, + }, + 503, + ); + } + if (!canAcceptNewVideoProcess(body.data.processingPriority)) { c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString()); return c.json(getMuxBusyResponseBody(getVideoCapacitySnapshot()), 503); @@ -2215,36 +2582,38 @@ video.post("/mux-segments", async (c) => { }); } - withMediaTransfers(body.data.downloadBudgetBytes ?? 32 * 1024 ** 3, () => - muxSegmentsAsync( - jobId, - videoId, - outputUpload, - thumbnailPresignedUrl, - previewGifPresignedUrl, - videoInitUrl, - videoSegUrls, - audioInitUrl ?? null, - audioSegUrls ?? null, - body.data.outputVerificationUrl, - body.data, - ), - ).catch((err) => { - console.error(`[mux-segments] Async mux error for job ${jobId}:`, err); - const currentJob = getJob(jobId); - if ( - currentJob && - currentJob.phase !== "error" && - currentJob.phase !== "complete" && - currentJob.phase !== "cancelled" - ) { - updateJob(jobId, { - phase: "error", - error: err instanceof Error ? err.message : String(err), - }); - sendCurrentJobWebhook(jobId); - } - }); + void trackBackgroundTask( + withMediaTransfers(body.data.downloadBudgetBytes ?? 32 * 1024 ** 3, () => + muxSegmentsAsync( + jobId, + videoId, + outputUpload, + thumbnailPresignedUrl, + previewGifPresignedUrl, + videoInitUrl, + videoSegUrls, + audioInitUrl ?? null, + audioSegUrls ?? null, + body.data.outputVerificationUrl, + body.data, + ), + ).catch((err) => { + console.error(`[mux-segments] Async mux error for job ${jobId}:`, err); + const currentJob = getJob(jobId); + if ( + currentJob && + currentJob.phase !== "error" && + currentJob.phase !== "complete" && + currentJob.phase !== "cancelled" + ) { + updateJob(jobId, { + phase: "error", + error: err instanceof Error ? err.message : String(err), + }); + sendCurrentJobWebhook(jobId); + } + }), + ); return c.json({ ...recordingWorkerResponse(job, jobId), @@ -2546,6 +2915,22 @@ async function downloadSegmentsBatchTracked( return outputPaths; } +function muxFailureErrorCode( + error: unknown, + stageErrorCode: RecordingErrorCode, +): RecordingErrorCode { + if (error instanceof MediaTransferBudgetError) + return "processing-budget-exhausted"; + if (error instanceof MediaDownloadError) return error.errorCode; + if ( + isRetryableRecordingVerificationError(error) || + isBusyError(error) || + isTimeoutError(error) + ) + return "processing-unavailable"; + return stageErrorCode; +} + function sendCurrentJobWebhook(jobId: string): void { const job = getJob(jobId); if (!job) { @@ -2568,15 +2953,14 @@ async function muxSegmentsAsync( outputVerificationUrl?: string, context: MuxContext = {}, ): Promise { - const { ensureTempDir } = await import("../lib/temp-files"); + const { ensureTempDir, getTempDir, retainTempPath } = await import( + "../lib/temp-files" + ); const { lstat, mkdir, rm } = await import("node:fs/promises"); const { join } = await import("node:path"); - const workDir = join( - (await import("node:os")).tmpdir(), - "cap-media-server", - `mux-${jobId}`, - ); + const workDir = join(getTempDir(), `mux-${jobId}`); + const releaseWorkDir = retainTempPath(workDir); const abortController = new AbortController(); updateJob(jobId, { abortController }); let outputUploadStarted = false; @@ -2586,6 +2970,8 @@ async function muxSegmentsAsync( ? new Map(context.sourceObjects.map((source) => [source.url, source])) : undefined; const startedAt = Date.now(); + let previewAssets: Promise | undefined; + const previewAbort = new AbortController(); try { if (!beginRecordingProcessing(jobId, SEGMENTED_RECORDING_TIMEOUT_MS)) @@ -2666,12 +3052,19 @@ async function muxSegmentsAsync( const combinedVideoPath = join(workDir, "combined_video.mp4"); const videoInitPath = join(videoDir, "init.mp4"); - await streamConcatFiles( - [videoInitPath, ...videoSegmentFiles], - combinedVideoPath, - abortController.signal, + await withJobHeartbeat(jobId, () => + streamConcatFiles( + [videoInitPath, ...videoSegmentFiles], + combinedVideoPath, + abortController.signal, + ), ); - await rm(videoDir, { recursive: true, force: true }); + // Parallel verification decodes runs of these fragments; otherwise free them now. + const videoChunks = + verifyDecodeChunks() > 1 + ? { init: videoInitPath, segments: videoSegmentFiles } + : undefined; + if (!videoChunks) await rm(videoDir, { recursive: true, force: true }); let combinedAudioPath: string | null = null; @@ -2679,10 +3072,13 @@ async function muxSegmentsAsync( combinedAudioPath = join(workDir, "combined_audio.mp4"); const audioInitPath = join(audioDir, "init.mp4"); - await streamConcatFiles( - [audioInitPath, ...audioSegmentFiles], - combinedAudioPath, - abortController.signal, + const audioOutputPath = combinedAudioPath; + await withJobHeartbeat(jobId, () => + streamConcatFiles( + [audioInitPath, ...audioSegmentFiles], + audioOutputPath, + abortController.signal, + ), ); await rm(audioDir, { recursive: true, force: true }); } @@ -2744,9 +3140,11 @@ async function muxSegmentsAsync( }); }, }, + videoChunks, ), ), ); + if (videoChunks) await rm(videoDir, { recursive: true, force: true }); logVideoEvent("video_mux_verification_complete", { jobId, videoId, @@ -2788,8 +3186,21 @@ async function muxSegmentsAsync( durationMs: Date.now() - startedAt, resources: getSystemResources(), }); + const outputReadyAt = Date.now(); + // The result is final here, so render previews while the upload and its + // remote check run; they are still only uploaded once the recording passes. + previewAssets = renderPreviewAssets( + resultPath, + localVerified.video.duration, + Boolean(thumbnailPresignedUrl), + Boolean(previewGifPresignedUrl), + AbortSignal.any([abortController.signal, previewAbort.signal]), + videoId, + ); - let metadata = await probeVideoFile(resultPath); + let metadata = await withJobHeartbeat(jobId, () => + probeVideoFile(resultPath), + ); if ( !Number.isFinite(metadata.duration) || metadata.duration <= 0 || @@ -2903,37 +3314,43 @@ async function muxSegmentsAsync( sendCurrentJobWebhook(jobId); } - if (thumbnailPresignedUrl) { - try { - const duration = metadata?.duration ?? 0; - const thumbnailData = await generateThumbnail( - resultPath, - duration, - {}, - abortController.signal, - ); - await uploadToS3( - thumbnailData, - thumbnailPresignedUrl, - "image/jpeg", - abortController.signal, - ); - } catch (thumbErr) { - abortController.signal.throwIfAborted(); - console.warn( - `[mux-segments] Thumbnail generation failed for ${videoId}:`, - thumbErr, - ); + const uploadedAt = Date.now(); + await withJobHeartbeat(jobId, async () => { + const assets = await previewAssets; + abortController.signal.throwIfAborted(); + if (thumbnailPresignedUrl && assets?.thumbnail) { + try { + await uploadToS3( + assets.thumbnail, + thumbnailPresignedUrl, + "image/jpeg", + abortController.signal, + ); + } catch (thumbErr) { + abortController.signal.throwIfAborted(); + console.warn( + `[mux-segments] Thumbnail generation failed for ${videoId}:`, + thumbErr, + ); + } } - } - - await generateAndUploadPreviewGif( - resultPath, - metadata?.duration ?? 0, - previewGifPresignedUrl, - abortController.signal, - "mux-segments", - ); + if (previewGifPresignedUrl && assets?.previewGif) { + try { + await uploadFileToS3( + assets.previewGif.path, + previewGifPresignedUrl, + "image/gif", + abortController.signal, + ); + } catch (previewErr) { + abortController.signal.throwIfAborted(); + console.warn( + "[mux-segments] Preview GIF generation failed:", + previewErr, + ); + } + } + }); abortController.signal.throwIfAborted(); updateJob(jobId, { @@ -2947,6 +3364,8 @@ async function muxSegmentsAsync( jobId, videoId, durationMs: Date.now() - startedAt, + uploadMs: uploadedAt - outputReadyAt, + previewMs: Date.now() - uploadedAt, metadata, resources: getSystemResources(), }); @@ -2977,7 +3396,8 @@ async function muxSegmentsAsync( jobId, videoId, durationMs: Date.now() - startedAt, - error: error instanceof Error ? error.message : String(error), + errorCode: muxFailureErrorCode(error, errorCode), + error: logReason(error, MUX_FAILURE_LOG_LENGTH), resources: getSystemResources(), }); if (!outputUploadStarted) { @@ -2994,16 +3414,7 @@ async function muxSegmentsAsync( abortController.signal.aborted && !isBusyError(error) ? "cancelled" : "error", - errorCode: - error instanceof MediaTransferBudgetError - ? "processing-budget-exhausted" - : error instanceof MediaDownloadError - ? error.errorCode - : isRetryableRecordingVerificationError(error) || - isBusyError(error) || - isTimeoutError(error) - ? "processing-unavailable" - : errorCode, + errorCode: muxFailureErrorCode(error, errorCode), error: isRetryableRecordingVerificationError(error) ? "Recording verification temporarily unavailable (503)" : error instanceof Error @@ -3013,8 +3424,48 @@ async function muxSegmentsAsync( sendCurrentJobWebhook(jobId); } finally { if (processingTimeout) clearTimeout(processingTimeout); + // Previews still rendering here belong to a job that failed before using them. + previewAbort.abort(); + await (await previewAssets)?.previewGif?.cleanup(); await rm(workDir, { recursive: true, force: true }).catch(() => {}); + releaseWorkDir(); } } +interface PreviewAssets { + thumbnail: Uint8Array | null; + previewGif: TempFileHandle | null; +} + +// Never rejects: a failed preview only costs the asset, and the caller checks for abort. +async function renderPreviewAssets( + resultPath: string, + duration: number, + thumbnail: boolean, + previewGif: boolean, + signal: AbortSignal, + videoId: string, +): Promise { + const [thumbnailResult, previewGifResult] = await Promise.allSettled([ + thumbnail ? generateThumbnail(resultPath, duration, {}, signal) : null, + previewGif ? generatePreviewGif(resultPath, duration, {}, signal) : null, + ]); + if (thumbnailResult.status === "rejected" && !signal.aborted) + console.warn( + `[mux-segments] Thumbnail generation failed for ${videoId}:`, + thumbnailResult.reason, + ); + if (previewGifResult.status === "rejected" && !signal.aborted) + console.warn( + "[mux-segments] Preview GIF generation failed:", + previewGifResult.reason, + ); + return { + thumbnail: + thumbnailResult.status === "fulfilled" ? thumbnailResult.value : null, + previewGif: + previewGifResult.status === "fulfilled" ? previewGifResult.value : null, + }; +} + export default video; diff --git a/apps/mobile/app/(tabs)/upload.tsx b/apps/mobile/app/(tabs)/upload.tsx index 0f0a76369dd..4e509d9a050 100644 --- a/apps/mobile/app/(tabs)/upload.tsx +++ b/apps/mobile/app/(tabs)/upload.tsx @@ -166,6 +166,9 @@ export default function UploadScreen() { const mountedRef = useRef(true); const activeIdRef = useRef(null); const sourceBusyRef = useRef(false); + const processingIdsRef = useRef(new Set()); + const processingClientRef = useRef(null); + processingClientRef.current = auth.status === "signedIn" ? auth.client : null; const uploadSourceError = sourceError?.source === "files" || sourceError?.source === "photos" ? sourceError.message @@ -340,14 +343,27 @@ export default function UploadScreen() { }; const waitForProcessing = async (queueItemId: string, capId: string) => { - if (auth.status !== "signedIn") return; + if (!mountedRef.current || processingClientRef.current !== auth.client) { + return; + } + + processingIdsRef.current.add(queueItemId); + const isActive = () => + mountedRef.current && + processingClientRef.current === auth.client && + processingIdsRef.current.has(queueItemId); - for (const delayMs of processingPollDelaysMs) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - if (!mountedRef.current) return; + try { + for (let attempt = 0; isActive(); attempt += 1) { + const delayMs = + processingPollDelaysMs[ + Math.min(attempt, processingPollDelaysMs.length - 1) + ]; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + if (!isActive()) return; - try { const detail = await auth.client.getCap(capId); + if (!isActive()) return; const action = uploadQueueActionFromCapUpload( queueItemId, detail.cap.upload, @@ -361,9 +377,11 @@ export default function UploadScreen() { return; } } - } catch { - return; } + } catch { + return; + } finally { + processingIdsRef.current.delete(queueItemId); } }; @@ -508,6 +526,7 @@ export default function UploadScreen() { const removeQueueItem = (item: UploadQueueItem) => { if (activeIdRef.current !== null) return; + processingIdsRef.current.delete(item.id); dispatch({ type: "remove", id: item.id }); }; diff --git a/apps/mobile/src/auth/AuthContext.tsx b/apps/mobile/src/auth/AuthContext.tsx index 7c4902a5b97..e8061333142 100644 --- a/apps/mobile/src/auth/AuthContext.tsx +++ b/apps/mobile/src/auth/AuthContext.tsx @@ -9,6 +9,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { @@ -70,6 +71,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { null, ); const [loading, setLoading] = useState(true); + const sessionRevision = useRef(0); const client = useMemo( () => @@ -89,8 +91,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const refresh = useCallback(async () => { + const revision = sessionRevision.current; const response = await client.bootstrap(); - setBootstrap(response); + if (revision === sessionRevision.current) setBootstrap(response); }, [client]); useEffect(() => { @@ -126,7 +129,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { return; } + const revision = sessionRevision.current; refresh().catch(() => { + if (revision !== sessionRevision.current) return; + sessionRevision.current += 1; setApiKey(null); setUserId(null); setBootstrap(null); @@ -137,6 +143,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const storeSession = useCallback( async (session: { apiKey: string; userId: string | null }) => { + sessionRevision.current += 1; await SecureStore.setItemAsync( sessionKey, session.apiKey, @@ -219,6 +226,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const signOut = useCallback(async () => { + sessionRevision.current += 1; if (apiKey) { await client.revokeSession().catch(() => {}); } @@ -226,6 +234,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { SecureStore.deleteItemAsync(sessionKey), SecureStore.deleteItemAsync(userIdKey), ]); + sessionRevision.current += 1; setApiKey(null); setUserId(null); setBootstrap(null); @@ -233,8 +242,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const setActiveOrganization = useCallback( async (organizationId: string) => { + const revision = sessionRevision.current; const nextBootstrap = await client.setActiveOrganization(organizationId); - setBootstrap(nextBootstrap); + if (revision === sessionRevision.current) setBootstrap(nextBootstrap); }, [client], ); diff --git a/apps/mobile/src/auth/AuthProvider.test.tsx b/apps/mobile/src/auth/AuthProvider.test.tsx index 05f98ea89ee..469e20d3c84 100644 --- a/apps/mobile/src/auth/AuthProvider.test.tsx +++ b/apps/mobile/src/auth/AuthProvider.test.tsx @@ -31,6 +31,8 @@ const apiMock = vi.hoisted(() => ({ workosAuthAvailable: true, }), ), + revokeSession: vi.fn(() => Promise.resolve({ success: true })), + setActiveOrganization: vi.fn(), createSessionRequestUrl: vi.fn( () => "https://cap.so/api/mobile/session/request", ), @@ -70,8 +72,8 @@ vi.mock("@/api/mobile", () => ({ createMobileApiClient: vi.fn(() => ({ bootstrap: apiMock.bootstrap, getAuthConfig: apiMock.getAuthConfig, - revokeSession: vi.fn(() => Promise.resolve({ success: true })), - setActiveOrganization: vi.fn(), + revokeSession: apiMock.revokeSession, + setActiveOrganization: apiMock.setActiveOrganization, })), createSessionRequestUrl: apiMock.createSessionRequestUrl, })); @@ -87,6 +89,16 @@ const flushMicrotasks = async () => { await Promise.resolve(); }; +const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, reject, resolve }; +}; + const states: Array<{ apiKey: string | null; status: string; @@ -128,6 +140,9 @@ describe("AuthProvider", () => { googleAuthAvailable: true, workosAuthAvailable: true, }); + apiMock.revokeSession.mockReset(); + apiMock.revokeSession.mockResolvedValue({ success: true }); + apiMock.setActiveOrganization.mockReset(); apiMock.createSessionRequestUrl.mockClear(); webBrowserMock.openAuthSessionAsync.mockReset(); }); @@ -251,4 +266,175 @@ describe("AuthProvider", () => { "cap.mobile.userId", ); }); + + it("does not restore account data when a refresh finishes after sign-out", async () => { + secureStoreMock.getItemAsync.mockImplementation((key: string) => + Promise.resolve(key === "cap.mobile.apiKey" ? "key_123" : "user_123"), + ); + const staleBootstrap = createDeferred<{ + activeOrganizationId: string; + user: { email: string; name: string }; + }>(); + apiMock.bootstrap.mockResolvedValueOnce({ + activeOrganizationId: "org_123", + user: { email: "old@cap.so", name: "Old" }, + }); + apiMock.bootstrap.mockReturnValueOnce(staleBootstrap.promise); + + await act(async () => { + TestRenderer.create( + React.createElement(AuthProvider, null, React.createElement(Probe)), + ); + await flushMicrotasks(); + }); + expect(latestAuth?.bootstrap?.user.email).toBe("old@cap.so"); + + let refreshPromise: Promise | undefined; + await act(async () => { + refreshPromise = latestAuth?.refresh(); + await Promise.resolve(); + }); + await act(async () => { + await latestAuth?.signOut(); + }); + expect(latestAuth?.bootstrap).toBeNull(); + + await act(async () => { + staleBootstrap.resolve({ + activeOrganizationId: "org_123", + user: { email: "old@cap.so", name: "Old" }, + }); + await refreshPromise; + }); + expect(latestAuth?.status).toBe("signedOut"); + expect(latestAuth?.bootstrap).toBeNull(); + }); + + it("discards a refresh started while sign-out is revoking the session", async () => { + secureStoreMock.getItemAsync.mockImplementation((key: string) => + Promise.resolve(key === "cap.mobile.apiKey" ? "key_123" : "user_123"), + ); + const revocation = createDeferred<{ success: boolean }>(); + const staleBootstrap = createDeferred<{ + activeOrganizationId: string; + user: { email: string; name: string }; + }>(); + apiMock.revokeSession.mockReturnValueOnce(revocation.promise); + apiMock.bootstrap.mockResolvedValueOnce({ + activeOrganizationId: "org_123", + user: { email: "old@cap.so", name: "Old" }, + }); + apiMock.bootstrap.mockReturnValueOnce(staleBootstrap.promise); + + await act(async () => { + TestRenderer.create( + React.createElement(AuthProvider, null, React.createElement(Probe)), + ); + await flushMicrotasks(); + }); + expect(latestAuth?.bootstrap?.user.email).toBe("old@cap.so"); + + let signOutPromise: Promise | undefined; + let refreshPromise: Promise | undefined; + await act(async () => { + signOutPromise = latestAuth?.signOut(); + refreshPromise = latestAuth?.refresh(); + }); + expect(apiMock.bootstrap).toHaveBeenCalledTimes(2); + await act(async () => { + revocation.resolve({ success: true }); + await signOutPromise; + }); + expect(latestAuth?.status).toBe("signedOut"); + + await act(async () => { + staleBootstrap.resolve({ + activeOrganizationId: "org_123", + user: { email: "old@cap.so", name: "Old" }, + }); + await refreshPromise; + }); + expect(latestAuth?.bootstrap).toBeNull(); + }); + + it("keeps a new session when an old automatic bootstrap fails", async () => { + secureStoreMock.getItemAsync.mockImplementation((key: string) => + Promise.resolve(key === "cap.mobile.apiKey" ? "old_key" : "old_user"), + ); + const oldBootstrap = createDeferred<{ + activeOrganizationId: string; + user: { email: string; name: string }; + }>(); + apiMock.bootstrap.mockReturnValueOnce(oldBootstrap.promise); + apiMock.bootstrap.mockResolvedValueOnce({ + activeOrganizationId: "new_org", + user: { email: "new@cap.so", name: "New" }, + }); + webBrowserMock.openAuthSessionAsync.mockResolvedValueOnce({ + type: "success", + url: "cap://auth?api_key=new_key&user_id=new_user", + }); + + await act(async () => { + TestRenderer.create( + React.createElement(AuthProvider, null, React.createElement(Probe)), + ); + await flushMicrotasks(); + }); + await act(async () => { + await latestAuth?.signInWithApple(); + await flushMicrotasks(); + }); + expect(latestAuth?.bootstrap?.user.email).toBe("new@cap.so"); + + await act(async () => { + oldBootstrap.reject(new Error("Old session expired")); + await flushMicrotasks(); + }); + expect(latestAuth?.status).toBe("signedIn"); + expect(latestAuth?.apiKey).toBe("new_key"); + expect(latestAuth?.bootstrap?.user.email).toBe("new@cap.so"); + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled(); + }); + + it("discards an organization switch from an old session", async () => { + secureStoreMock.getItemAsync.mockImplementation((key: string) => + Promise.resolve(key === "cap.mobile.apiKey" ? "old_key" : "old_user"), + ); + const oldSwitch = createDeferred<{ + activeOrganizationId: string; + user: { email: string; name: string }; + }>(); + apiMock.setActiveOrganization.mockReturnValueOnce(oldSwitch.promise); + webBrowserMock.openAuthSessionAsync.mockResolvedValueOnce({ + type: "success", + url: "cap://auth?api_key=new_key&user_id=new_user", + }); + + await act(async () => { + TestRenderer.create( + React.createElement(AuthProvider, null, React.createElement(Probe)), + ); + await flushMicrotasks(); + }); + let switchPromise: Promise | undefined; + await act(async () => { + switchPromise = latestAuth?.setActiveOrganization("old_org"); + await Promise.resolve(); + }); + await act(async () => { + await latestAuth?.signInWithApple(); + await flushMicrotasks(); + }); + expect(latestAuth?.bootstrap?.user.email).toBe("richie@cap.so"); + + await act(async () => { + oldSwitch.resolve({ + activeOrganizationId: "old_org", + user: { email: "old@cap.so", name: "Old" }, + }); + await switchPromise; + }); + expect(latestAuth?.bootstrap?.user.email).toBe("richie@cap.so"); + }); }); diff --git a/apps/mobile/src/auth/SignInPanel.test.tsx b/apps/mobile/src/auth/SignInPanel.test.tsx index 54ddee08255..365cb15f050 100644 --- a/apps/mobile/src/auth/SignInPanel.test.tsx +++ b/apps/mobile/src/auth/SignInPanel.test.tsx @@ -934,6 +934,72 @@ describe("SignInPanel", () => { ).toBe(true); }); + it("shows a retry-later message when code requests are throttled", async () => { + const { MobileApiError } = await import("@/api/mobile"); + authFns.requestEmailCode.mockRejectedValueOnce( + new MobileApiError("Too Many Requests", 429, null), + ); + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + expect(getTextNodes(renderer.toJSON())).toContain( + "Too many codes requested. Try again in a few minutes.", + ); + }); + + it("shows a retry-later message when code verification is throttled", async () => { + const { MobileApiError } = await import("@/api/mobile"); + authFns.verifyEmailCode.mockRejectedValueOnce( + new MobileApiError("Too Many Requests", 429, null), + ); + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + const [codeInput] = renderer.root.findAllByProps({ + textContentType: "oneTimeCode", + }); + if (!codeInput) throw new Error("One-time code input was not rendered"); + + await act(async () => { + codeInput.props.onChangeText("123456"); + await Promise.resolve(); + }); + + expect(getTextNodes(renderer.toJSON())).toContain( + "Too many attempts. Try again in a few minutes.", + ); + }); + it("switches to a web-like verification code step after requesting email", async () => { const renderer = await renderPanel(); const [emailInput] = renderer.root.findAllByProps({ diff --git a/apps/mobile/src/auth/SignInPanel.tsx b/apps/mobile/src/auth/SignInPanel.tsx index 1a57ad267a2..3ec3beb48c4 100644 --- a/apps/mobile/src/auth/SignInPanel.tsx +++ b/apps/mobile/src/auth/SignInPanel.tsx @@ -33,6 +33,9 @@ const getEmailRequestErrorMessage = (error: unknown) => { if (error.status === 403) { return "This email cannot be used to sign in to Cap."; } + if (error.status === 429) { + return "Too many codes requested. Try again in a few minutes."; + } } return error instanceof Error ? error.message @@ -43,6 +46,9 @@ const getCodeVerificationErrorMessage = (error: unknown) => { if (error instanceof MobileApiError) { if (error.status === 400) return "Enter a valid email and 6-digit code."; if (error.status === 403) return "That code is invalid or expired."; + if (error.status === 429) { + return "Too many attempts. Try again in a few minutes."; + } } return error instanceof Error ? error.message : "Unable to verify that code."; }; diff --git a/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx b/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx index 2971152e27e..0654133cd7b 100644 --- a/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx +++ b/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx @@ -4,12 +4,13 @@ import TestRenderer, { type ReactTestRenderer, type ReactTestRendererJSON, } from "react-test-renderer"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MobileCapSummary } from "@/api/mobile"; import CapsScreen from "../../app/(tabs)"; import UploadScreen from "../../app/(tabs)/upload"; type AuthStub = { - status: "signedIn"; + status: "signedIn" | "signedOut"; apiKey: string; bootstrap: { activeOrganizationId: string; @@ -42,7 +43,7 @@ type AuthStub = { }>; getCap: (id: string) => Promise<{ cap: { - upload: null; + upload: MobileCapSummary["upload"]; }; }>; getCapStatuses: (ids: readonly string[]) => Promise<{ @@ -177,9 +178,9 @@ const uploadQueueState = vi.hoisted( }), ); -const uploadQueueActionsState = vi.hoisted((): { value: unknown[] } => ({ - value: [], -})); +const uploadQueueActionsState = vi.hoisted( + (): { value: unknown[]; reduce: boolean } => ({ value: [], reduce: false }), +); const recordingUploadState = vi.hoisted(() => ({ libraryRevision: 0, @@ -592,9 +593,14 @@ vi.mock("@/uploads/uploadQueue", async (importOriginal) => { ...actual, emptyUploadQueue: uploadQueueState.value, uploadProgressPercent: (progress: number) => Math.round(progress * 100), - uploadQueueReducer: (state: { items: unknown[] }, action: unknown) => { + uploadQueueReducer: ( + state: Parameters[0], + action: Parameters[1], + ) => { uploadQueueActionsState.value.push(action); - return state; + return uploadQueueActionsState.reduce + ? actual.uploadQueueReducer(state, action) + : state; }, uploadQueueStatusText: (item: { processingMessage?: string | null; @@ -1754,6 +1760,221 @@ describe("upload and dashboard visibility", () => { } }); + describe("processing status polling", () => { + const processingDetail = { + cap: { + upload: { + phase: "processing" as const, + processingError: null, + processingMessage: "Converting video", + processingProgress: 42, + total: 100, + uploaded: 100, + }, + }, + }; + const cancellations = [ + "removal", + "unmount", + "sign-out", + "new session", + ] as const; + let auth: AuthStub; + let renderer: ReactTestRenderer | undefined; + + beforeEach(async () => { + vi.useFakeTimers(); + uploadQueueActionsState.reduce = true; + auth = createAuth(); + auth.refresh = vi.fn(() => Promise.resolve()); + auth.client.getCap = vi.fn(() => Promise.resolve(processingDetail)); + authState.value = auth; + const DocumentPicker = await import("expo-document-picker"); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValueOnce({ + assets: [ + { + name: "slow.mp4", + uri: "file:///tmp/slow.mp4", + size: 100, + lastModified: 0, + }, + ], + canceled: false, + }); + const { runMobileUpload } = await import("@/uploads/runMobileUpload"); + vi.mocked(runMobileUpload).mockImplementationOnce( + async ({ onCreated }) => { + onCreated?.("video_123", "raw.mp4"); + return { id: "video_123" } as Awaited< + ReturnType + >; + }, + ); + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + uploadQueueActionsState.reduce = false; + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + const startImport = async () => { + renderer = await renderComponent(React.createElement(UploadScreen)); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + if (!browseButton) + throw new Error("Browse Files button was not rendered"); + await act(async () => { + void browseButton.props.onPress(); + }); + return renderer; + }; + + const cancel = async (reason: (typeof cancellations)[number]) => { + if (!renderer) throw new Error("Upload screen was not rendered"); + if (reason === "removal") { + const { ActionSheetIOS } = await import("react-native"); + const sheet = vi.mocked(ActionSheetIOS.showActionSheetWithOptions); + sheet.mockClear(); + const [menu] = renderer.root.findAllByProps({ + accessibilityLabel: "More actions for slow.mp4", + }); + if (!menu) throw new Error("Upload queue menu was not rendered"); + await act(async () => { + menu.props.onPress({ stopPropagation: vi.fn() }); + }); + const call = sheet.mock.calls.at(-1); + if (!call) throw new Error("Upload queue sheet did not open"); + await act(async () => { + call[1](call[0].options.indexOf("Remove from Queue")); + }); + expect( + renderer.root.findAllByProps({ + accessibilityLabel: "Upload slow.mp4", + }), + ).toHaveLength(0); + return; + } + if (reason === "unmount") { + await act(async () => renderer?.unmount()); + renderer = undefined; + return; + } + authState.value = + reason === "sign-out" + ? { ...auth, status: "signedOut" } + : { ...createAuth(), apiKey: "new-api-key" }; + await act(async () => + renderer?.update(React.createElement(UploadScreen)), + ); + }; + + it.each(["complete", "error"] as const)( + "observes a late %s after the initial four checks", + async (phase) => { + const startedAt = Date.now(); + auth.client.getCap = vi.fn(async () => { + if (Date.now() - startedAt < 30_000) return processingDetail; + return { + cap: { + upload: { + ...processingDetail.cap.upload, + phase, + processingError: phase === "error" ? "Conversion failed" : null, + }, + }, + }; + }); + const screen = await startImport(); + await act(async () => { + await vi.advanceTimersByTimeAsync(17_500); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(4); + expect(getTextNodes(screen.toJSON()).join(" ")).toContain( + "Converting video", + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(16_000); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(6); + expect(getTextNodes(screen.toJSON()).join(" ")).toContain( + phase === "complete" ? "Ready to view" : "Conversion failed", + ); + expect(auth.refresh).toHaveBeenCalledTimes( + phase === "complete" ? 2 : 1, + ); + if (phase === "error") { + expect( + screen.root.findAllByProps({ + accessibilityLabel: "Retry upload slow.mp4", + }).length, + ).toBeGreaterThan(0); + } + await act(async () => { + await vi.advanceTimersByTimeAsync(32_000); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(6); + }, + ); + + it.each(cancellations)( + "stops requests after %s while waiting", + async (reason) => { + await startImport(); + await cancel(reason); + await act(async () => { + await vi.advanceTimersByTimeAsync(40_000); + }); + expect(auth.client.getCap).not.toHaveBeenCalled(); + expect(auth.refresh).toHaveBeenCalledTimes(1); + }, + ); + + it.each(cancellations)( + "ignores an in-flight reply after %s", + async (reason) => { + const detail = + createDeferred>>(); + auth.client.getCap = vi.fn(() => detail.promise); + await startImport(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(1); + await cancel(reason); + await act(async () => { + detail.resolve({ cap: { upload: null } }); + }); + expect(uploadQueueActionsState.value).not.toContainEqual( + expect.objectContaining({ type: "complete" }), + ); + expect(auth.refresh).toHaveBeenCalledTimes(1); + await act(async () => { + await vi.advanceTimersByTimeAsync(40_000); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(1); + }, + ); + + it("does not start an old upload's poll after a session change during refresh", async () => { + const refresh = createDeferred(); + auth.refresh = vi.fn(() => refresh.promise); + await startImport(); + expect(auth.refresh).toHaveBeenCalledTimes(1); + await cancel("new session"); + await act(async () => { + refresh.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(40_000); + }); + expect(auth.client.getCap).not.toHaveBeenCalled(); + }); + }); + it("opens the native iOS upload queue sheet", async () => { uploadQueueState.value.items = [ { diff --git a/apps/render-farm/src/coordinator.test.ts b/apps/render-farm/src/coordinator.test.ts index 661af17e1fa..1a79b5dc25f 100644 --- a/apps/render-farm/src/coordinator.test.ts +++ b/apps/render-farm/src/coordinator.test.ts @@ -8,6 +8,7 @@ import { import { readFileSync } from "node:fs"; import { ANNEX_B_PARAMETER_SETS } from "./boxes.test-util"; import type { Job, TaskState } from "./coordinator"; +import type { Engine } from "./engine"; import * as fmp4 from "./fmp4"; import * as hls from "./hls"; import * as mp4 from "./mp4"; @@ -31,6 +32,11 @@ function harness(env: Record = {}) { let copyGate: (source: string) => Promise | undefined = () => undefined; const completedParts: number[][] = []; const timers: (() => void)[] = []; + const timerDelays: number[] = []; + const clearedTimers: unknown[] = []; + const engineCalls: ReturnType>[] = []; + const engineKills: (NodeJS.Signals | undefined)[] = []; + const engineLabels: string[] = []; const watchdogs: (() => void)[] = []; let putGate: Promise | undefined; let headGate: Promise | undefined; @@ -166,8 +172,38 @@ function harness(env: Record = {}) { }, s3ConfigFromEnv: () => ({}), mediaS3ConfigFromEnv: () => ({}), + ProjectCache: class { + constructor( + _s3: unknown, + readonly root: string, + ) {} + async materialize() { + return { bytes: 0, requests: 0, ms: 0 }; + } + close() {} + }, ProbeEngine: class {}, - Engine: class {}, + Engine: class { + alive = true; + private pending?: ReturnType>; + constructor( + _binary: string, + _env: Record, + readonly label: string, + ) { + engineLabels.push(label); + } + request() { + this.pending = Promise.withResolvers(); + engineCalls.push(this.pending); + return this.pending.promise; + } + kill(signal?: NodeJS.Signals) { + engineKills.push(signal); + this.alive = false; + this.pending?.reject(new Error("audio engine exited")); + } + }, process: { env: { RF_TOKEN: "test", @@ -185,11 +221,12 @@ function harness(env: Record = {}) { setInterval: (callback: () => void) => { watchdogs.push(callback); }, - setTimeout: (callback: () => void) => { + setTimeout: (callback: () => void, delay: number) => { timers.push(callback); + timerDelays.push(delay); return { unref() {} }; }, - clearTimeout: () => {}, + clearTimeout: (timer: unknown) => clearedTimers.push(timer), fetch: async (url: string, init: RequestInit) => { callbacks.push({ url, init }); return new Response("ok"); @@ -202,8 +239,13 @@ function harness(env: Record = {}) { const compiled = new Bun.Transpiler({ loader: "ts" }).transformSync(source); const coordinator = new Function( ...Object.keys(deps), - `${compiled}\nreturn {jobs, queue, straggler, dispatchedTask, onVideoDone, onAudioDone, publishPlaylist, journalJob, resumeJobs, newHlsState, finish, requeue, sourceIndex, assemble, stitchAhead, sweepStashes, setPlanner: (fn) => { planJob = fn; }};`, + `${compiled}\nreturn {withAudioEngine, pumpLocalAudio, jobs, queue, straggler, dispatchedTask, onVideoDone, onAudioDone, publishPlaylist, journalJob, resumeJobs, newHlsState, finish, requeue, sourceIndex, assemble, stitchAhead, sweepStashes, setPlanner: (fn) => { planJob = fn; }};`, )(...Object.values(deps)) as { + pumpLocalAudio: () => void; + withAudioEngine: ( + fn: (engine: Engine) => Promise, + timeoutMs: number, + ) => Promise; setPlanner: (fn: (job: Job) => Promise) => void; sourceIndex: (prefix: string, sourceRoot?: string) => Promise; assemble: (job: Job) => Promise; @@ -250,6 +292,11 @@ function harness(env: Record = {}) { }, callbacks, timers, + timerDelays, + clearedTimers, + engineCalls, + engineKills, + engineLabels, watchdogs, fetch: (request: Request) => fetchHandler(request), gate: (gate?: Promise) => { @@ -1368,3 +1415,85 @@ describe("jobs for the product", () => { expect(summary.progress).toBe(0.75); }); }); + +describe("local audio deadlines", () => { + test("local sections include preroll in their deadlines and fail after three hung attempts", async () => { + const h = harness({ RF_LOCAL_AUDIO_SLOTS: "1" }); + const j = job(); + const state: TaskState = { + task: { + kind: "audio", + taskId: "job:a0", + jobId: j.id, + section: 0, + fps: 30, + range: [0, 300 * 48_000], + preroll: 30 * 48_000, + files: [], + }, + state: "queued", + attempts: 0, + }; + j.tasks.set(state.task.taskId, state); + h.jobs.set(j.id, j); + h.queue.push(state); + h.pumpLocalAudio(); + for (let attempt = 1; attempt <= 3; attempt++) { + await Bun.sleep(0); + expect(h.engineCalls).toHaveLength(attempt); + expect(h.timerDelays.at(-1)).toBe(110_000); + h.timers.at(-1)?.(); + await Bun.sleep(0); + if (attempt < 3) { + expect(j.status).toBe("rendering"); + expect(h.timerDelays.at(-1)).toBe(1000 * attempt); + h.timers.at(-1)?.(); + } + } + expect(j.status).toBe("error"); + expect(j.error).toBe("audio engine exited"); + expect(state.attempts).toBe(3); + expect(h.engineKills).toEqual(["SIGKILL", "SIGKILL", "SIGKILL"]); + expect(j.audioSections.size).toBe(0); + expect(h.queue).toHaveLength(0); + }); + + test("a hung engine is killed and replaced before queued work runs", async () => { + const h = harness({ RF_LOCAL_AUDIO_SLOTS: "1" }); + const first = h.withAudioEngine( + (engine) => engine.request("audio", {}), + 60_000, + ); + first.catch(() => {}); + const second = h.withAudioEngine( + (engine) => engine.request("audio", {}), + 90_000, + ); + expect(h.engineCalls).toHaveLength(1); + expect(h.timerDelays).toEqual([60_000]); + h.timers[0]?.(); + await expect(first).rejects.toThrow("audio engine exited"); + expect(h.engineKills).toEqual(["SIGKILL"]); + expect(h.engineLabels).toEqual(["audio0", "audio0"]); + expect(h.engineCalls).toHaveLength(2); + expect(h.timerDelays).toEqual([60_000, 90_000]); + h.engineCalls[1]?.resolve("recovered"); + expect(await second).toBe("recovered"); + expect(h.clearedTimers).toHaveLength(2); + }); + + test("successful requests clear their deadlines and keep the warm engine", async () => { + const h = harness({ RF_LOCAL_AUDIO_SLOTS: "1" }); + for (let index = 0; index < 2; index++) { + const pending = h.withAudioEngine( + (engine) => engine.request("audio", {}), + 60_000, + ); + h.engineCalls[index]?.resolve(index); + expect(await pending).toBe(index); + } + expect(h.engineLabels).toEqual(["audio0"]); + expect(h.engineKills).toEqual([]); + expect(h.clearedTimers).toHaveLength(2); + }); +}); diff --git a/apps/render-farm/src/coordinator.ts b/apps/render-farm/src/coordinator.ts index 2a0b544f7ae..2d717bd5474 100644 --- a/apps/render-farm/src/coordinator.ts +++ b/apps/render-farm/src/coordinator.ts @@ -119,15 +119,23 @@ const audioEngines = Array.from( ); const idleAudioEngines = [...audioEngines]; const localAudioQueue: (() => void)[] = []; -async function withAudioEngine(fn: (engine: Engine) => Promise) { +async function withAudioEngine( + fn: (engine: Engine) => Promise, + timeoutMs: number, +) { while (idleAudioEngines.length === 0) { await new Promise((resolve) => localAudioQueue.push(resolve)); } let engine = idleAudioEngines.pop() as Engine; if (!engine.alive) engine = new Engine(ENGINE_BIN, {}, engine.label); + const watchdog = setTimeout(() => { + console.error(`${engine.label}: audio section timed out, killing engine`); + engine.kill("SIGKILL"); + }, timeoutMs); try { return await fn(engine); } finally { + clearTimeout(watchdog); idleAudioEngines.push(engine); localAudioQueue.shift()?.(); } @@ -171,19 +179,23 @@ async function runAudioLocally(job: Job, state: TaskState) { const fetch = await cache.materialize(task.files); const out = join(cache.root, `a${task.section}.aac`); const engineStarted = performance.now(); - const result = await withAudioEngine((engine) => - engine.request<{ - first_packet: number; - sizes: number[]; - extradata: string; - timings: Record; - }>("audio", { - project: cache.root, - fps: task.fps, - section: task.range, - preroll: task.preroll, - out, - }), + const seconds = + (task.range[1] - task.range[0] + task.preroll) / SAMPLE_RATE; + const result = await withAudioEngine( + (engine) => + engine.request<{ + first_packet: number; + sizes: number[]; + extradata: string; + timings: Record; + }>("audio", { + project: cache.root, + fps: task.fps, + section: task.range, + preroll: task.preroll, + out, + }), + Math.max(60_000, (seconds * 1000) / 3), ); const data = new Uint8Array(await Bun.file(out).arrayBuffer()); rmSync(out, { force: true }); diff --git a/apps/render-farm/src/mp4.test.ts b/apps/render-farm/src/mp4.test.ts index 7bcc2691524..0a9fbe5006d 100644 --- a/apps/render-farm/src/mp4.test.ts +++ b/apps/render-farm/src/mp4.test.ts @@ -98,24 +98,28 @@ describe("byteRangeFor", () => { describe("buildHeader", () => { test("lays out ftyp, moov, padding and a 64-bit mdat header", () => { + const videoSizes = Uint32Array.of(0x01020304, 0x80000000, 300); + const audioSizes = Uint32Array.of(10, 20); + const videoBytes = videoSizes.reduce((sum, size) => sum + size, 0); + const payloadSize = videoBytes + 30; const header = buildHeader({ width: 1920, height: 1080, fps: 30, video: { - sizes: Uint32Array.from([100, 200, 300]), + sizes: videoSizes, runs: [{ first: 0, count: 3, offset: 0 }], keyframes: Uint32Array.from([0]), avcC: avcC(ANNEX_B_PARAMETER_SETS), }, audio: { - sizes: Uint32Array.from([10, 10]), - runs: [{ first: 0, count: 2, offset: 600 }], + sizes: audioSizes, + runs: [{ first: 0, count: 2, offset: videoBytes }], asc: Uint8Array.of(0x11, 0x90), totalSamples: 1024, priming: 1024, }, - payloadSize: 620, + payloadSize, minimumSize: 64 * 1024, }); expect(header.byteLength).toBe(64 * 1024); @@ -128,17 +132,29 @@ describe("buildHeader", () => { ]); const mdat = boxes[3]; expect(u32(header, mdat?.start ?? 0)).toBe(1); - expect(u64(header, (mdat?.start ?? 0) + 8)).toBe(16 + 620); + expect(u64(header, (mdat?.start ?? 0) + 8)).toBe(16 + payloadSize); const moov = boxes[1]; if (!moov) throw new Error("no moov"); const traks = parseBoxes(moov.body).filter((box) => box.type === "trak"); expect(traks).toHaveLength(2); - const stbl = child( - child(child(traks[0] ?? moov, "mdia") ?? moov, "minf") ?? moov, - "stbl", - ); - const co64 = stbl && child(stbl, "co64"); - expect(co64 && u64(co64.body, 8)).toBe(header.byteLength); + for (const [index, sizes] of [videoSizes, audioSizes].entries()) { + const stbl = child( + child(child(traks[index] ?? moov, "mdia") ?? moov, "minf") ?? moov, + "stbl", + ); + const stsz = stbl && child(stbl, "stsz"); + if (!stsz) throw new Error("no sample sizes"); + expect(stsz.body.byteLength).toBe(12 + sizes.length * 4); + expect(u32(stsz.body, 4)).toBe(0); + expect(u32(stsz.body, 8)).toBe(sizes.length); + expect(sizes.map((_, sample) => u32(stsz.body, 12 + sample * 4))).toEqual( + sizes, + ); + const co64 = stbl && child(stbl, "co64"); + expect(co64 && u64(co64.body, 8)).toBe( + header.byteLength + (index === 0 ? 0 : videoBytes), + ); + } }); }); diff --git a/apps/render-farm/src/mp4.ts b/apps/render-farm/src/mp4.ts index 8c4ba67d8ae..10b92b398d3 100644 --- a/apps/render-farm/src/mp4.ts +++ b/apps/render-farm/src/mp4.ts @@ -485,18 +485,13 @@ function sampleTable( }), ), ); - parts.push( - fullBox( - "stsz", - 0, - 0, - build((writer) => { - writer.u32(0); - writer.u32(table.sizes.length); - for (const size of table.sizes) writer.u32(size); - }), - ), - ); + const sampleSizes = new Uint8Array(8 + table.sizes.length * 4); + const sampleSizesView = new DataView(sampleSizes.buffer); + sampleSizesView.setUint32(4, table.sizes.length); + table.sizes.forEach((size, index) => { + sampleSizesView.setUint32(8 + index * 4, size); + }); + parts.push(fullBox("stsz", 0, 0, sampleSizes)); parts.push( fullBox( "co64", diff --git a/apps/render-farm/src/transcode.integration.test.ts b/apps/render-farm/src/transcode.integration.test.ts new file mode 100644 index 00000000000..4fb8da23c78 --- /dev/null +++ b/apps/render-farm/src/transcode.integration.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { transcodeArgs } from "./transcode"; + +test.skipIf(!Bun.which("ffmpeg") || !Bun.which("ffprobe")).each([ + [641, 360], + [640, 361], + [641, 361], +])("CPU transcodes accept %i × %i recordings", (width, height) => { + const directory = mkdtempSync(join(tmpdir(), "cap-transcode-")); + const input = join(directory, "input.webm"); + const output = join(directory, "output.mp4"); + try { + const generated = Bun.spawnSync([ + "ffmpeg", + "-v", + "error", + "-f", + "lavfi", + "-i", + `testsrc=size=${width}x${height}:rate=10`, + "-t", + "1", + "-pix_fmt", + "yuv420p", + "-c:v", + "libvpx", + "-deadline", + "realtime", + input, + ]); + expect(generated.exitCode, generated.stderr.toString()).toBe(0); + const encoded = Bun.spawnSync([ + "ffmpeg", + ...transcodeArgs(input, output, 1, "libx264"), + ]); + expect(encoded.exitCode, encoded.stderr.toString()).toBe(0); + const decoded = Bun.spawnSync([ + "ffprobe", + "-v", + "error", + "-count_frames", + "-show_entries", + "stream=width,height,nb_read_frames,duration", + "-of", + "json", + output, + ]); + expect(decoded.exitCode, decoded.stderr.toString()).toBe(0); + expect(decoded.stderr.toString()).toBe(""); + expect(JSON.parse(decoded.stdout.toString())).toMatchObject({ + streams: [ + { + width: Math.ceil(width / 2) * 2, + height: Math.ceil(height / 2) * 2, + nb_read_frames: "10", + duration: "1.000000", + }, + ], + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/render-farm/src/transcode.ts b/apps/render-farm/src/transcode.ts index 0e7571ab244..5c93a35245f 100644 --- a/apps/render-farm/src/transcode.ts +++ b/apps/render-farm/src/transcode.ts @@ -28,6 +28,7 @@ export function transcodeArgs( "-an", "-fps_mode", "passthrough", + ...(nvenc ? [] : ["-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"]), "-pix_fmt", "yuv420p", "-c:v", diff --git a/apps/render-farm/src/worker.test.ts b/apps/render-farm/src/worker.test.ts index a9e9e99c025..39d144ece20 100644 --- a/apps/render-farm/src/worker.test.ts +++ b/apps/render-farm/src/worker.test.ts @@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { MIN_PART, type TranscodeTask, type WorkItem } from "./protocol"; +import { + MIN_PART, + type TranscodeTask, + type VideoTask, + type WorkItem, +} from "./protocol"; import * as stitch from "./stitch"; import * as transcode from "./transcode"; @@ -26,6 +31,8 @@ function harness( randomUUID, join, ...transcode, + processCpuSeconds: async () => 0, + sampleThreads: async () => ({}), availableParallelism: () => 1, hostname: () => "worker-test", mkdirSync: () => {}, @@ -34,6 +41,13 @@ function harness( s3ConfigFromEnv: () => ({}), ...stitch, MIN_PART, + ProjectCache: class { + root = "/work/job"; + async materialize() { + return { bytes: 0, ms: 0, requests: 0 }; + } + close() {} + }, S3: class { async presignFresh() { await options.presignGate; @@ -97,8 +111,18 @@ function harness( const compiled = new Bun.Transpiler({ loader: "ts" }).transformSync(source); const worker = new Function( ...Object.keys(deps), - `${compiled}\nreturn {runTranscode, cancel, busy, progress, transcoders, engineEnv, uploadLayout};`, + `${compiled}\nreturn {runVideo, runTranscode, cancel, busy, progress, transcoders, engineEnv, uploadLayout};`, )(...Object.values(deps)) as { + runVideo: ( + task: VideoTask, + engine: { + pid: number; + onEvent: ((event: Record) => void) | null; + onProgress: ((frames: number) => void) | null; + request: () => Promise; + }, + queuedMs: number, + ) => Promise; runTranscode: (task: TranscodeTask, slot: number) => Promise; cancel: (taskIds: string[]) => void; busy: Map; @@ -227,3 +251,41 @@ describe("chunk upload", () => { }); }); }); + +test("a failed video render removes its temporary output", async () => { + const worker = harness(); + const task: VideoTask = { + kind: "video", + taskId: "job:v0", + jobId: "job", + chunk: 0, + fps: 30, + resolution: [1920, 1080], + bpp: 0.1, + frames: [0, 30], + threads: 1, + files: [], + upload: { + key: "out/job.mp4", + uploadId: "upload", + firstPart: 2, + partLimit: 3, + partTarget: MIN_PART, + stashKey: "stash/job/0", + }, + audio: null, + hls: null, + }; + const engine = { + pid: 1, + onEvent: null, + onProgress: null, + request: () => Promise.reject(new Error("render failed")), + }; + await expect(worker.runVideo(task, engine, 0)).rejects.toThrow( + "render failed", + ); + expect(worker.removed).toHaveLength(1); + expect(worker.removed[0]).toStartWith("/work/job/v0-"); + expect(worker.removed[0]).toEndWith(".h264"); +}); diff --git a/apps/render-farm/src/worker.ts b/apps/render-farm/src/worker.ts index 4de9dfdbf24..e6e129f212b 100644 --- a/apps/render-farm/src/worker.ts +++ b/apps/render-farm/src/worker.ts @@ -505,110 +505,116 @@ async function runVideo( cache.root, `v${task.chunk}-${randomUUID().slice(0, 6)}.h264`, ); - const engineStarted = performance.now(); - const slot = engines.indexOf(engine); - const entry = { - taskId: task.taskId, - kind: "video" as const, - frames: 0, - total: task.frames[1] - task.frames[0], - startedAt: Date.now(), - lastProgressAt: Date.now(), - }; - progress.set(slot, entry); - const stream = task.hls - ? new SegmentStream({ ...task, hls: task.hls }, out, engineStarted) - : null; - engine.onEvent = stream ? (event) => stream.onEvent(event) : null; - // Ask for the next task once this one is ~PREFETCH_LEAD_MS from done, so - // its sources download while this one finishes instead of after. - engine.onProgress = (frames) => { - entry.frames = frames; - entry.lastProgressAt = Date.now(); - const elapsed = performance.now() - engineStarted; - const remainingMs = - ((entry.total - frames) * elapsed) / Math.max(1, frames); - if (frames > 0 && remainingMs <= PREFETCH_LEAD_MS) nearEnd(); - }; - const request = engine.request<{ - width: number; - height: number; - sizes: number[]; - keyframes: number[]; - extradata: string; - bytes: number; - timings: Record; - }>("video", { - project: cache.root, - fps: task.fps, - resolution: task.resolution, - bpp: task.bpp, - frames: task.frames, - threads: task.threads, - out, - }); - const threadsPromise: Promise> = - process.env.RF_PROFILE_THREADS === "1" - ? sampleThreads(engine.pid, request) - : Promise.resolve({}); - const result = await request.finally(() => { - engine.onProgress = null; - engine.onEvent = null; - progress.delete(slot); - }); - nearEnd(); - const segmentsDone = stream?.finish(result.sizes, result.extradata); - segmentsDone?.catch(() => {}); - const engineMs = performance.now() - engineStarted; - const threads = await threadsPromise; - const waitStarted = performance.now(); - const audio = await audioPromise; - const audioWaitMs = performance.now() - waitStarted; - const uploadStarted = performance.now(); - const plan = layout(task, out, result.sizes, audio); - const [{ parts, stash }, segments] = await Promise.all([ - uploadLayout(task, plan.segments, plan.bytes), - segmentsDone ?? Promise.resolve(0), - ]); - const uploadMs = performance.now() - uploadStarted; - rmSync(out, { force: true }); - const timings: TaskTimings = { - queuedMs, - fetch: fetchStats, - engine: { - ...result.timings, - ...Object.fromEntries( - Object.entries(threads).map(([name, value]) => [ - `thread:${name}`, - value, - ]), - ), - ...(stream - ? { segments, firstSegmentMs: Math.round(stream.firstUploadedMs ?? -1) } - : {}), - }, - engineMs, - audioWaitMs, - uploadMs, - totalMs: performance.now() - started, - cpuSeconds: (await processCpuSeconds(engine.pid)) - cpuBefore, - }; - return { - taskId: task.taskId, - worker: WORKER_ID, - sizes: result.sizes, - keyframes: result.keyframes, - extradata: result.extradata, - width: result.width, - height: result.height, - videoRuns: plan.videoRuns, - audioRuns: plan.audioRuns, - firstPart: task.upload.firstPart, - stash, - parts, - bytes: plan.bytes, - timings, - }; + try { + const engineStarted = performance.now(); + const slot = engines.indexOf(engine); + const entry = { + taskId: task.taskId, + kind: "video" as const, + frames: 0, + total: task.frames[1] - task.frames[0], + startedAt: Date.now(), + lastProgressAt: Date.now(), + }; + progress.set(slot, entry); + const stream = task.hls + ? new SegmentStream({ ...task, hls: task.hls }, out, engineStarted) + : null; + engine.onEvent = stream ? (event) => stream.onEvent(event) : null; + // Ask for the next task once this one is ~PREFETCH_LEAD_MS from done, so + // its sources download while this one finishes instead of after. + engine.onProgress = (frames) => { + entry.frames = frames; + entry.lastProgressAt = Date.now(); + const elapsed = performance.now() - engineStarted; + const remainingMs = + ((entry.total - frames) * elapsed) / Math.max(1, frames); + if (frames > 0 && remainingMs <= PREFETCH_LEAD_MS) nearEnd(); + }; + const request = engine.request<{ + width: number; + height: number; + sizes: number[]; + keyframes: number[]; + extradata: string; + bytes: number; + timings: Record; + }>("video", { + project: cache.root, + fps: task.fps, + resolution: task.resolution, + bpp: task.bpp, + frames: task.frames, + threads: task.threads, + out, + }); + const threadsPromise: Promise> = + process.env.RF_PROFILE_THREADS === "1" + ? sampleThreads(engine.pid, request) + : Promise.resolve({}); + const result = await request.finally(() => { + engine.onProgress = null; + engine.onEvent = null; + progress.delete(slot); + }); + nearEnd(); + const segmentsDone = stream?.finish(result.sizes, result.extradata); + segmentsDone?.catch(() => {}); + const engineMs = performance.now() - engineStarted; + const threads = await threadsPromise; + const waitStarted = performance.now(); + const audio = await audioPromise; + const audioWaitMs = performance.now() - waitStarted; + const uploadStarted = performance.now(); + const plan = layout(task, out, result.sizes, audio); + const [{ parts, stash }, segments] = await Promise.all([ + uploadLayout(task, plan.segments, plan.bytes), + segmentsDone ?? Promise.resolve(0), + ]); + const uploadMs = performance.now() - uploadStarted; + const timings: TaskTimings = { + queuedMs, + fetch: fetchStats, + engine: { + ...result.timings, + ...Object.fromEntries( + Object.entries(threads).map(([name, value]) => [ + `thread:${name}`, + value, + ]), + ), + ...(stream + ? { + segments, + firstSegmentMs: Math.round(stream.firstUploadedMs ?? -1), + } + : {}), + }, + engineMs, + audioWaitMs, + uploadMs, + totalMs: performance.now() - started, + cpuSeconds: (await processCpuSeconds(engine.pid)) - cpuBefore, + }; + return { + taskId: task.taskId, + worker: WORKER_ID, + sizes: result.sizes, + keyframes: result.keyframes, + extradata: result.extradata, + width: result.width, + height: result.height, + videoRuns: plan.videoRuns, + audioRuns: plan.audioRuns, + firstPart: task.upload.firstPart, + stash, + parts, + bytes: plan.bytes, + timings, + }; + } finally { + rmSync(out, { force: true }); + } } async function runAudio(task: AudioTask, engine: Engine, queuedMs: number) { diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 5592afb8359..62a0507cbc8 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -2,7 +2,7 @@ FROM oven/bun:1.4.0-alpine AS bun -FROM node:24-alpine AS base +FROM node:24.22.1-alpine AS base FROM base AS builder WORKDIR /app diff --git a/apps/web/__tests__/integration/desktop-recording-production-replay.test.ts b/apps/web/__tests__/integration/desktop-recording-production-replay.test.ts index 4f810487ce8..0e223ed7c11 100644 --- a/apps/web/__tests__/integration/desktop-recording-production-replay.test.ts +++ b/apps/web/__tests__/integration/desktop-recording-production-replay.test.ts @@ -165,7 +165,7 @@ function replay(sample: Sample) { getInternalSignedObjectUrl: (name: string) => checked(() => `https://replay.invalid/${name}`), }; - mocks.storage.mockReturnValue(Effect.succeed([bucket])); + mocks.storage.mockReturnValue(Effect.succeed([bucket, Option.none()])); const manifest = (complete: boolean) => { const parsed = readCompletedRecordingManifest( JSON.stringify(sample.manifest), diff --git a/apps/web/__tests__/integration/loom-concierge-database.test.ts b/apps/web/__tests__/integration/loom-concierge-database.test.ts index f296f046cfa..2604688e7e0 100644 --- a/apps/web/__tests__/integration/loom-concierge-database.test.ts +++ b/apps/web/__tests__/integration/loom-concierge-database.test.ts @@ -256,6 +256,11 @@ describe.skipIf(!databaseUrl)("Loom concierge migration database", () => { (...args: Parameters) => rawActions.updateLoomMigrationStatus(...args), ), + reconcileLoomMigrationInventory: unwrapped( + ( + ...args: Parameters + ) => rawActions.reconcileLoomMigrationInventory(...args), + ), }; const loom = { importFromLoomCsvForConcierge: unwrapped( @@ -729,6 +734,120 @@ describe.skipIf(!databaseUrl)("Loom concierge migration database", () => { expect(reopened.id).not.toBe(requestId); }); + it.each(["recording", "source mapping"])( + "does not complete or notify when a migrated %s disappears", + async (missing) => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + const videoId = Video.VideoId.make(id()); + await db().insert(videos).values({ + id: videoId, + name: "Finished import", + ownerId, + orgId: organizationId, + }); + await db().insert(importedVideos).values({ + id: videoId, + orgId: organizationId, + source: "loom", + sourceId: loomId(), + }); + await db().insert(loomMigrationImports).values({ videoId, requestId }); + if (missing === "recording") { + await db().delete(videos).where(eq(videos.id, videoId)); + } else { + await db().delete(importedVideos).where(eq(importedVideos.id, videoId)); + } + signInAs(operatorId); + fixture.sendEmail.mockClear(); + await expect( + actions.updateLoomMigrationStatus({ + requestId, + nextStatus: "completed", + message: "All done", + verified: true, + expectedVideoCount: 1, + importedVideoCount: 1, + }), + ).rejects.toThrow("missing or changed"); + const request = await getRequest(requestId); + expect(request.status).not.toBe("completed"); + expect(request.activeOrganizationId).toBe(organizationId); + expect(request.completedAt).toBeNull(); + expect(fixture.sendEmail).not.toHaveBeenCalled(); + }, + ); + + it("reconciles only deleted inventory entries in the selected request", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + const other = await makeOrganization(); + const otherRequestId = await requestMigration( + other.organizationId, + other.ownerId, + ); + const retainedVideoId = Video.VideoId.make(id()); + const deletedVideoId = Video.VideoId.make(id()); + const otherDeletedVideoId = Video.VideoId.make(id()); + await db().insert(videos).values({ + id: retainedVideoId, + name: "Retained", + ownerId, + orgId: organizationId, + }); + await db().insert(importedVideos).values({ + id: retainedVideoId, + orgId: organizationId, + source: "loom", + sourceId: loomId(), + }); + await db() + .insert(loomMigrationImports) + .values([ + { videoId: retainedVideoId, requestId }, + { videoId: deletedVideoId, requestId }, + { videoId: otherDeletedVideoId, requestId: otherRequestId }, + ]); + signInAs(ownerId); + await expect( + actions.reconcileLoomMigrationInventory({ requestId, verified: true }), + ).rejects.toThrow(); + signInAs(operatorId); + await expect( + actions.reconcileLoomMigrationInventory({ requestId, verified: false }), + ).rejects.toThrow("source list first"); + fixture.sendEmail.mockClear(); + await expect( + actions.reconcileLoomMigrationInventory({ requestId, verified: true }), + ).resolves.toEqual({ removedCount: 1, hasMore: false }); + expect( + await db() + .select({ videoId: loomMigrationImports.videoId }) + .from(loomMigrationImports) + .where(eq(loomMigrationImports.requestId, requestId)), + ).toEqual([{ videoId: retainedVideoId }]); + expect( + await db() + .select({ videoId: loomMigrationImports.videoId }) + .from(loomMigrationImports) + .where(eq(loomMigrationImports.requestId, otherRequestId)), + ).toEqual([{ videoId: otherDeletedVideoId }]); + expect( + await db() + .select({ id: videos.id }) + .from(videos) + .where(eq(videos.id, retainedVideoId)), + ).toHaveLength(1); + expect( + await db() + .select({ id: importedVideos.id }) + .from(importedVideos) + .where(eq(importedVideos.id, retainedVideoId)), + ).toHaveLength(1); + expect((await getRequest(requestId)).status).not.toBe("completed"); + expect(fixture.sendEmail).not.toHaveBeenCalled(); + }); + it("uploads restricted MP4 files to storage and starts processing", async () => { const { ownerId, organizationId } = await makeOrganization({ privateByDefault: true, diff --git a/apps/web/__tests__/integration/sso-database.test.ts b/apps/web/__tests__/integration/sso-database.test.ts index 5d4c6ce4857..8492cadf6f0 100644 --- a/apps/web/__tests__/integration/sso-database.test.ts +++ b/apps/web/__tests__/integration/sso-database.test.ts @@ -6,6 +6,7 @@ import { organizationSso, organizations, users, + verificationTokens, } from "@cap/database/schema"; import { Organisation, User } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; @@ -344,5 +345,62 @@ describe.runIf(Boolean(databaseUrl))( .where(eq(organizationMembers.userId, userId)), ).toHaveLength(0); }); + + it("burns an email OTP exactly once under concurrent guesses", async () => { + const identifier = `${id()}@example.com`; + const token = "482913"; + const { useVerificationToken } = DrizzleAdapter(database()); + if (!useVerificationToken) { + throw new Error("Missing useVerificationToken adapter."); + } + await database() + .insert(verificationTokens) + .values({ identifier, token, expires: new Date(Date.now() + 600_000) }); + + const attempts = await Promise.all( + Array.from({ length: 8 }, () => + useVerificationToken({ identifier, token }), + ), + ); + + expect(attempts.filter((result) => result !== null)).toHaveLength(1); + expect( + await database() + .select() + .from(verificationTokens) + .where(eq(verificationTokens.identifier, identifier)), + ).toHaveLength(0); + }); + + it("never authenticates a wrong guess when it races the correct one", async () => { + const identifier = `${id()}@example.com`; + const token = "531942"; + const { useVerificationToken } = DrizzleAdapter(database()); + if (!useVerificationToken) { + throw new Error("Missing useVerificationToken adapter."); + } + await database() + .insert(verificationTokens) + .values({ identifier, token, expires: new Date(Date.now() + 600_000) }); + + const guesses = ["000000", "111111", token, "222222", "333333"]; + const attempts = await Promise.all( + guesses.map((guess) => + useVerificationToken({ identifier, token: guess }), + ), + ); + + const successes = attempts.filter((result) => result !== null); + expect(successes.length).toBeLessThanOrEqual(1); + for (const success of successes) { + expect(success).toMatchObject({ identifier, token }); + } + expect( + await database() + .select() + .from(verificationTokens) + .where(eq(verificationTokens.identifier, identifier)), + ).toHaveLength(0); + }); }, ); diff --git a/apps/web/__tests__/unit/add-folder-videos.test.ts b/apps/web/__tests__/unit/add-folder-videos.test.ts new file mode 100644 index 00000000000..04cd3a0fe3a --- /dev/null +++ b/apps/web/__tests__/unit/add-folder-videos.test.ts @@ -0,0 +1,139 @@ +import { Folder, Organisation, Space, Video } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { addVideosToFolder } from "@/actions/folders/add-videos"; + +const mocks = vi.hoisted(() => ({ query: vi.fn(), user: vi.fn() })); + +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + return { db: () => drizzle(mocks.query) }; +}); +vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: mocks.user })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +const folderId = Folder.FolderId.make("folder-1"); +const orgId = Organisation.OrganisationId.make("org-1"); +const existingId = Video.VideoId.make("existing-video"); +const newId = Video.VideoId.make("new-video"); + +beforeEach(() => { + mocks.user.mockResolvedValue({ id: "owner", activeOrganizationId: orgId }); +}); + +describe.each([ + { + name: "space", + spaceId: Space.SpaceId.make("space-1"), + table: "space_videos", + }, + { name: "organization", spaceId: orgId, table: "shared_videos" }, +])("adding videos to a $name folder", ({ spaceId, table }) => { + function setup(ownedIds: Video.VideoId[], sharedIds: Video.VideoId[]) { + mocks.query.mockImplementation(async (query: string) => { + if (query.startsWith("select")) { + if (query.includes("from `folders`")) { + return { rows: [[folderId, spaceId]] }; + } + if (query.includes("from `videos`")) { + return { rows: ownedIds.map((id) => [id]) }; + } + return { rows: sharedIds.map((id) => [id]) }; + } + return { rows: [{ insertId: 1, affectedRows: 1 }] }; + }); + } + + it("moves an existing share without inserting another sharing row", async () => { + setup([existingId], [existingId]); + + await expect( + addVideosToFolder(folderId, [existingId], spaceId), + ).resolves.toMatchObject({ success: true, addedCount: 1 }); + + const writes = mocks.query.mock.calls.filter(([query]) => + String(query).startsWith("insert"), + ); + expect(writes).toHaveLength(0); + const update = mocks.query.mock.calls.find(([query]) => + String(query).startsWith(`update \`${table}\``), + ); + expect(update?.[1]).toEqual([folderId, spaceId, existingId]); + }); + + it("inserts only missing shares when moving a mixed selection", async () => { + setup([existingId, newId], [existingId]); + + await expect( + addVideosToFolder(folderId, [existingId, newId], spaceId), + ).resolves.toMatchObject({ success: true, addedCount: 2 }); + + const insert = mocks.query.mock.calls.find(([query]) => + String(query).startsWith(`insert into \`${table}\``), + ); + expect(insert?.[1]).toContain(newId); + expect(insert?.[1]).not.toContain(existingId); + const existingQuery = mocks.query.mock.calls.find(([query]) => + String(query).includes(`from \`${table}\``), + ); + expect(existingQuery?.[1]).toEqual([spaceId, existingId, newId]); + expect(existingQuery?.[0]).not.toContain("folderId"); + const update = mocks.query.mock.calls.find(([query]) => + String(query).startsWith(`update \`${table}\``), + ); + expect(update?.[1]).toEqual([folderId, spaceId, existingId, newId]); + }); + + it("creates the share when the video is not in this collection", async () => { + setup([newId], []); + + await expect( + addVideosToFolder(folderId, [newId], spaceId), + ).resolves.toMatchObject({ success: true, addedCount: 1 }); + + const insert = mocks.query.mock.calls.find(([query]) => + String(query).startsWith(`insert into \`${table}\``), + ); + expect(insert?.[1]).toEqual([ + expect.any(String), + ...(table === "shared_videos" + ? [newId, folderId, spaceId, "owner"] + : [spaceId, folderId, newId, "owner"]), + ]); + }); + + it("does not share requested videos the caller does not own", async () => { + setup([newId], []); + + await expect( + addVideosToFolder(folderId, [existingId, newId], spaceId), + ).resolves.toMatchObject({ success: true, addedCount: 1 }); + + const writes = mocks.query.mock.calls.filter( + ([query]) => !String(query).startsWith("select"), + ); + expect(writes).toHaveLength(2); + for (const [, params] of writes) { + expect(params).toContain(newId); + expect(params).not.toContain(existingId); + } + }); + + it("does not write when the sharing lookup fails", async () => { + setup([existingId], [existingId]); + vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.query + .mockResolvedValueOnce({ rows: [[folderId, spaceId]] }) + .mockResolvedValueOnce({ rows: [[existingId]] }) + .mockRejectedValueOnce(new Error("Database unavailable")); + + await expect( + addVideosToFolder(folderId, [existingId], spaceId), + ).resolves.toMatchObject({ success: false }); + + expect( + mocks.query.mock.calls.every(([query]) => + String(query).startsWith("select"), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/__tests__/unit/agent-s3-endpoint-policy.test.ts b/apps/web/__tests__/unit/agent-s3-endpoint-policy.test.ts new file mode 100644 index 00000000000..7dc6ebfdd0a --- /dev/null +++ b/apps/web/__tests__/unit/agent-s3-endpoint-policy.test.ts @@ -0,0 +1,202 @@ +import { Agent as HttpAgent } from "node:http"; +import { Agent as HttpsAgent } from "node:https"; +import type { S3ClientConfig } from "@aws-sdk/client-s3"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + isCap: "true" as string | undefined, + nodeEnv: "production", + s3Configs: [] as Array>, + send: vi.fn(async () => ({})), +})); + +vi.mock("server-only", () => ({})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + isRateLimited: vi.fn(async () => false), + RATE_LIMIT_IDS: { + AGENT_TOKEN_EXCHANGE: "rl_agent_token_exchange", + AGENT_AUTHORIZATION: "rl_agent_authorization", + AGENT_UNLOCK: "rl_agent_unlock", + AGENT_LOOM_IMPORT: "rl_loom_import_per_user", + }, +})); + +vi.mock("@cap/env", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildEnv: new Proxy(actual.buildEnv, { + get: (target, property) => + property === "NEXT_PUBLIC_IS_CAP" + ? state.isCap + : Reflect.get(target, property), + }), + get NODE_ENV() { + return state.nodeEnv; + }, + }; +}); + +vi.mock("@aws-sdk/client-s3", async (importOriginal) => { + const actual = await importOriginal(); + class S3Client extends actual.S3Client { + constructor(config: S3ClientConfig) { + super(config); + const credentials = config.credentials as + | { accessKeyId?: string } + | undefined; + if (credentials?.accessKeyId === "AKIASYNTHETIC") { + state.s3Configs.push(config as Record); + this.send = state.send as unknown as S3Client["send"]; + } + } + } + return { ...actual, S3Client }; +}); + +vi.mock("@/lib/agent-write", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runAgentExternalMutation: (input: { + execute: (providerIdempotencyKey: string) => unknown; + }) => input.execute("synthetic-provider-key"), + }; +}); + +const principalRow = { + tokenId: "agk_synthetic", + scopes: ["caps:read", "integrations:read", "integrations:write"], + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + revokedAt: null, + lastUsedAt: new Date(), + id: "usr_synthetic", + email: "owner@example.com", + activeOrganizationId: "org_synthetic", + role: "owner", + hasProSeat: true, + stripeSubscriptionStatus: "active", + thirdPartyStripeSubscriptionId: null, +}; + +const query = (): unknown => + new Proxy(() => undefined, { + get: (_, property) => + property === "then" + ? (resolve: (value: unknown) => void) => resolve([principalRow]) + : query, + apply: query, + }); + +vi.mock("@cap/database", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, db: query }; +}); + +const token = `cap_cli_${"A".repeat(43)}`; + +describe("agent API S3 endpoint policy", () => { + let POST: typeof import("@/app/api/v1/[...route]/route").POST; + let PUT: typeof import("@/app/api/v1/[...route]/route").PUT; + let blockedS3EndpointMessage: string; + + beforeAll(async () => { + Object.assign(process.env, { + NEXT_PUBLIC_WEB_URL: "http://localhost", + WEB_URL: "http://localhost", + DATABASE_URL: "mysql://unused:unused@127.0.0.1:3306/unused", + NEXTAUTH_SECRET: "synthetic-test-secret-that-is-long-enough", + NEXTAUTH_URL: "http://localhost", + CAP_AWS_BUCKET: "synthetic", + CAP_AWS_REGION: "us-east-1", + CAP_AWS_ACCESS_KEY: "synthetic", + CAP_AWS_SECRET_KEY: "synthetic", + NODE_ENV: "test", + }); + const route = await import("@/app/api/v1/[...route]/route"); + POST = route.POST; + PUT = route.PUT; + ({ blockedS3EndpointMessage } = await import("@/lib/s3-endpoint-policy")); + }); + + beforeEach(() => { + state.isCap = "true"; + state.nodeEnv = "production"; + state.s3Configs.length = 0; + state.send.mockClear(); + }); + + const s3Request = (method: "POST" | "PUT", endpoint: string) => + new Request( + `http://localhost/api/v1/organizations/org_synthetic/storage/s3${ + method === "POST" ? "/test" : "" + }`, + { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Idempotency-Key": "synthetic-idempotency-key", + "X-Cap-Confirmation": "user", + }, + body: JSON.stringify({ + provider: "aws", + accessKeyId: "AKIASYNTHETIC", + secretAccessKey: "synthetic-secret", + endpoint, + bucketName: "synthetic-bucket", + region: "us-east-1", + }), + }, + ); + + it.each([ + ["PUT", "http://169.254.169.254"], + ["PUT", "http://localhost:9000"], + ["POST", "http://127.0.0.1:9000"], + ["POST", " http://[::1]:9000 "], + ] as const)( + "rejects %s with private endpoint %s on Cap Cloud", + async (method, endpoint) => { + const handler = method === "POST" ? POST : PUT; + const response = await handler(s3Request(method, endpoint)); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ + code: "INVALID_REQUEST", + message: blockedS3EndpointMessage, + }); + expect(state.s3Configs).toHaveLength(0); + expect(state.send).not.toHaveBeenCalled(); + }, + ); + + it("tests public endpoints through public-only agents on Cap Cloud", async () => { + const response = await POST(s3Request("POST", "https://8.8.8.8")); + + expect(response.status).toBe(200); + expect(state.s3Configs).toHaveLength(1); + const requestHandler = state.s3Configs[0]?.requestHandler as { + httpAgent?: unknown; + httpsAgent?: unknown; + }; + expect(requestHandler.httpAgent).toBeInstanceOf(HttpAgent); + expect(requestHandler.httpsAgent).toBeInstanceOf(HttpsAgent); + expect(state.send).toHaveBeenCalledTimes(1); + }); + + it("allows local endpoints on self-hosted deployments", async () => { + state.isCap = undefined; + const response = await POST(s3Request("POST", "http://localhost:9000")); + + expect(response.status).toBe(200); + expect(state.s3Configs).toHaveLength(1); + expect(state.s3Configs[0]).toMatchObject({ + endpoint: "http://localhost:9000", + }); + expect(state.s3Configs[0]?.requestHandler).toBeUndefined(); + expect(state.send).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/__tests__/unit/agent-upload-entitlement.test.ts b/apps/web/__tests__/unit/agent-upload-entitlement.test.ts index bc0adfdb5ed..70afdaf6a35 100644 --- a/apps/web/__tests__/unit/agent-upload-entitlement.test.ts +++ b/apps/web/__tests__/unit/agent-upload-entitlement.test.ts @@ -136,6 +136,32 @@ describe("agent upload entitlement", () => { expect(accept).toBeLessThan(processing); }); + it("checks the accepted upload key against the video before processing", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const completionSource = source.slice( + source.indexOf('.handle("completeUpload"'), + source.indexOf('.handle("importLoomCap"'), + ); + const acceptedKeyCheck = completionSource.search( + /isVideoStorageKey\(\s*\{ id: path\.id, ownerId: principal\.id \},\s*acceptedRawFileKey,/, + ); + const stateKeyCheck = completionSource.search( + /isVideoStorageKey\(\s*\{ id: state\.id, ownerId: state\.ownerId \},\s*state\.rawFileKey,/, + ); + + expect(acceptedKeyCheck).toBeGreaterThan(-1); + expect(stateKeyCheck).toBeGreaterThan(-1); + expect(acceptedKeyCheck).toBeLessThan( + completionSource.indexOf("runAgentMutation"), + ); + expect(stateKeyCheck).toBeLessThan( + completionSource.indexOf("startVideoProcessingWorkflow"), + ); + }); + it("durably rejects only marked agent uploads and cleans storage first", () => { const source = readFileSync( join(process.cwd(), "app/api/v1/[...route]/route.ts"), diff --git a/apps/web/__tests__/unit/analytics-chart-dates.test.ts b/apps/web/__tests__/unit/analytics-chart-dates.test.ts new file mode 100644 index 00000000000..a7eefb2b0c4 --- /dev/null +++ b/apps/web/__tests__/unit/analytics-chart-dates.test.ts @@ -0,0 +1,81 @@ +import { createElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ChartArea from "@/app/(org)/dashboard/analytics/components/ChartArea"; + +vi.mock("recharts", () => ({ + AreaChart: ({ data }: { data: Array<{ label: string; views: number }> }) => + createElement( + "div", + null, + data.map((point) => + createElement( + "span", + { key: point.label }, + `${point.label}: ${point.views}`, + ), + ), + ), + Area: () => null, + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, +})); + +vi.mock("@/components/ui/chart", () => ({ + ChartContainer: ({ children }: { children: ReactNode }) => children, + ChartTooltip: () => null, + ChartTooltipContent: () => null, +})); + +const renderChart = (buckets: string[]) => + renderToStaticMarkup( + createElement(ChartArea, { + selectedMetrics: ["views"], + data: buckets.map((bucket, index) => ({ + bucket, + views: index + 1, + caps: 0, + comments: 0, + reactions: 0, + })), + }), + ); + +describe.each(["America/Los_Angeles", "Asia/Kolkata", "UTC"])( + "analytics chart dates in %s", + (timeZone) => { + beforeEach(() => vi.stubEnv("TZ", timeZone)); + afterEach(() => vi.unstubAllEnvs()); + it.each([ + ["2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z"], + ["2026-03-08T00:00:00Z", "2026-03-09T00:00:00Z"], + ["2026-11-01T00:00:00Z", "2026-11-02T00:00:00Z"], + ])("labels UTC daily buckets on their actual date: %s", (...buckets) => { + const chart = renderChart(buckets); + for (const [index, bucket] of buckets.entries()) { + const label = new Date(bucket).toLocaleDateString([], { + month: "short", + day: "numeric", + timeZone: "UTC", + }); + expect(chart).toContain(`${label}: ${index + 1}`); + } + }); + + it("keeps hourly labels in the viewer's local time", () => { + const buckets = ["2026-01-01T00:00:00Z", "2026-01-01T01:00:00Z"]; + const chart = renderChart(buckets); + for (const [index, bucket] of buckets.entries()) { + const label = new Date(bucket).toLocaleTimeString([], { + hour: "numeric", + }); + expect(chart).toContain(`${label}: ${index + 1}`); + } + }); + + it("shows the empty state when there are no buckets", () => { + expect(renderChart([])).toContain("No analytics data yet."); + }); + }, +); diff --git a/apps/web/__tests__/unit/assemblyai-client.test.ts b/apps/web/__tests__/unit/assemblyai-client.test.ts new file mode 100644 index 00000000000..42a926e1fb0 --- /dev/null +++ b/apps/web/__tests__/unit/assemblyai-client.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { realAssemblyAIResponse } from "../fixtures/assemblyai-responses"; + +const serverEnvMock = vi.hoisted(() => + vi.fn<() => Record>(() => ({})), +); + +vi.mock("@cap/env", () => ({ + serverEnv: serverEnvMock, +})); + +import { createAssemblyAIClient } from "@/lib/assemblyai"; + +const fetchMock = vi.fn( + async (_input: string, _init?: RequestInit) => + new Response(JSON.stringify(realAssemblyAIResponse)), +); + +describe("createAssemblyAIClient", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockClear(); + }); + + it.each([ + [undefined, "https://api.assemblyai.com"], + ["", "https://api.assemblyai.com"], + ["https://api.eu.assemblyai.com", "https://api.eu.assemblyai.com"], + ["https://api.eu.assemblyai.com/", "https://api.eu.assemblyai.com"], + ])("ASSEMBLY_BASE_URL=%s sends requests to %s", async (baseUrl, host) => { + serverEnvMock.mockReturnValue({ + ASSEMBLY_API_KEY: "key", + ASSEMBLY_BASE_URL: baseUrl, + }); + + await createAssemblyAIClient().transcripts.get(realAssemblyAIResponse.id); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + `${host}/v2/transcript/${realAssemblyAIResponse.id}`, + ); + }); +}); diff --git a/apps/web/__tests__/unit/async-video-code-reviews-page.test.ts b/apps/web/__tests__/unit/async-video-code-reviews-page.test.ts index 088cf28c473..364f2a46c7b 100644 --- a/apps/web/__tests__/unit/async-video-code-reviews-page.test.ts +++ b/apps/web/__tests__/unit/async-video-code-reviews-page.test.ts @@ -55,6 +55,13 @@ describe("AsyncVideoCodeReviewsPage component content", () => { expect(componentSource).toContain("comparisonTable"); }); + it("describes the license split in the comparison table", () => { + expect(componentSource).toContain( + '{ text: "Yes — AGPLv3 and MIT crates", status: "positive" }', + ); + expect(componentSource).not.toContain('{ text: "Yes — MIT"'); + }); + it("includes recording modes section", () => { expect(componentSource).toContain("recordingModes"); }); diff --git a/apps/web/__tests__/unit/auth-options.test.ts b/apps/web/__tests__/unit/auth-options.test.ts index 8f919507e6d..3b2ccf11cca 100644 --- a/apps/web/__tests__/unit/auth-options.test.ts +++ b/apps/web/__tests__/unit/auth-options.test.ts @@ -44,9 +44,15 @@ const env = vi.hoisted(() => ({ GOOGLE_CLIENT_ID: "google-client", GOOGLE_CLIENT_SECRET: "google-secret", NEXTAUTH_SECRET: "next-auth-secret", + OIDC_ISSUER: undefined as string | undefined, + OIDC_CLIENT_ID: undefined as string | undefined, + OIDC_CLIENT_SECRET: undefined as string | undefined, RESEND_API_KEY: undefined, WORKOS_API_KEY: "workos-secret", WORKOS_CLIENT_ID: "workos-client", + TRUSTED_PROXY_AUTH_HEADER: undefined as string | undefined, + TRUSTED_PROXY_AUTH_EMAIL: undefined as string | undefined, + TRUSTED_PROXY_AUTH_SECRET: undefined as string | undefined, })); vi.mock("@cap/env", () => ({ @@ -105,14 +111,29 @@ function callbacksFor(options: NextAuthOptions) { return { signIn, jwt }; } +// next-auth keeps a Credentials provider's custom id in `options` until it +// merges providers at runtime, so read it from there when present. +function providerIds(options: NextAuthOptions) { + return options.providers.map( + (provider) => + (provider.options as { id?: string } | undefined)?.id ?? provider.id, + ); +} + describe("authOptions", () => { beforeEach(() => { env.APPLE_CLIENT_ID = "so.cap.auth"; env.APPLE_CLIENT_SECRET = "apple-secret"; env.WORKOS_CLIENT_ID = "workos-client"; env.WORKOS_API_KEY = "workos-secret"; + env.OIDC_ISSUER = undefined; + env.OIDC_CLIENT_ID = undefined; + env.OIDC_CLIENT_SECRET = undefined; env.CAP_ALLOWED_SIGNUP_DOMAINS = undefined; env.CAP_BLOCKED_SIGNUP_DOMAINS = undefined; + env.TRUSTED_PROXY_AUTH_HEADER = undefined; + env.TRUSTED_PROXY_AUTH_EMAIL = undefined; + env.TRUSTED_PROXY_AUTH_SECRET = undefined; mocks.validate.mockReset().mockResolvedValue(IDENTITY); mocks.provision.mockReset().mockResolvedValue(undefined); mocks.adapter.mockClear(); @@ -167,6 +188,229 @@ describe("authOptions", () => { }, ); + it("enables trusted-proxy sign-in when header, email and secret are configured", () => { + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + + expect(providerIds(authOptions())).toContain("trusted-proxy"); + }); + + it("does not expose trusted-proxy sign-in by default", () => { + expect(providerIds(authOptions())).not.toContain("trusted-proxy"); + }); + + it.each([ + "TRUSTED_PROXY_AUTH_HEADER", + "TRUSTED_PROXY_AUTH_EMAIL", + "TRUSTED_PROXY_AUTH_SECRET", + ] as const)( + "does not expose a partially configured trusted-proxy provider when %s is missing", + (key) => { + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + env[key] = undefined; + + expect(providerIds(authOptions())).not.toContain("trusted-proxy"); + }, + ); + + it.each([undefined, "", " "])( + "does not register trusted-proxy with only header/email and an unusable secret (%s)", + (secret) => { + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + env.TRUSTED_PROXY_AUTH_SECRET = secret; + + expect(providerIds(authOptions())).not.toContain("trusted-proxy"); + }, + ); + + it.each([undefined, "proxy-secret"])( + "preserves independent OIDC sign-in and its verified-email gate with proxy secret %s", + async (secret) => { + env.OIDC_ISSUER = "https://idp.example"; + env.OIDC_CLIENT_ID = "oidc-client"; + env.OIDC_CLIENT_SECRET = "oidc-secret"; + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + env.TRUSTED_PROXY_AUTH_SECRET = secret; + const options = authOptions(); + expect(providerIds(options)).toContain("oidc"); + expect(providerIds(options).includes("trusted-proxy")).toBe(!!secret); + const { signIn } = callbacksFor(options); + const account: Account = { + provider: "oidc", + providerAccountId: "oidc-user", + type: "oauth", + }; + for (const email_verified of [undefined, false, true]) { + const profile = { email: PROFILE.email, email_verified }; + await expect( + signIn({ + user: AUTHENTICATED_USER, + account, + profile, + }), + ).resolves.toBe( + email_verified === true ? true : "/login?error=OidcEmailUnverified", + ); + } + }, + ); + + describe("trusted-proxy authorize", () => { + type Authorize = ( + credentials: Record | undefined, + req: { headers?: Record }, + ) => Promise; + + function trustedProxyAuthorize() { + const options = authOptions(); + const provider = options.providers[ + providerIds(options).indexOf("trusted-proxy") + ] as { options?: { authorize?: Authorize } } | undefined; + const authorize = provider?.options?.authorize; + if (!authorize) throw new Error("trusted-proxy provider is missing"); + return authorize; + } + + beforeEach(() => { + env.TRUSTED_PROXY_AUTH_HEADER = "X-OpenHost-Is-Owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = "Alex@Company.example"; + env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + }); + + const validHeaders = { + "x-openhost-is-owner": "true", + "x-trusted-proxy-secret": "proxy-secret", + }; + + it("signs in the configured user when the proxy sets both headers", async () => { + await expect( + trustedProxyAuthorize()({}, { headers: validHeaders }), + ).resolves.toMatchObject({ + id: AUTHENTICATED_USER.id, + email: AUTHENTICATED_USER.email, + }); + }); + + it.each([ + ["header is absent", { "x-trusted-proxy-secret": "proxy-secret" }], + [ + "header is not exactly 'true'", + { ...validHeaders, "x-openhost-is-owner": "1" }, + ], + ])("fails closed when the %s", async (_label, headers) => { + await expect( + trustedProxyAuthorize()({}, { headers }), + ).resolves.toBeNull(); + expect(mocks.select).not.toHaveBeenCalled(); + }); + + it("fails closed when the configured user does not exist", async () => { + env.TRUSTED_PROXY_AUTH_EMAIL = "nobody@company.example"; + + await expect( + trustedProxyAuthorize()({}, { headers: validHeaders }), + ).resolves.toBeNull(); + expect(mocks.select).toHaveBeenCalledOnce(); + }); + + it.each([undefined, "", " "])( + "fails closed if the configured secret becomes unusable after registration (%s)", + async (secret) => { + const authorize = trustedProxyAuthorize(); + env.TRUSTED_PROXY_AUTH_SECRET = secret; + await expect( + authorize({}, { headers: validHeaders }), + ).resolves.toBeNull(); + expect(mocks.select).not.toHaveBeenCalled(); + }, + ); + + it.each([undefined, "", "wrong", "x".repeat(1024), ["proxy-secret"]])( + "rejects missing, empty, incorrect or malformed request secrets (%s)", + async (secret) => { + await expect( + trustedProxyAuthorize()( + {}, + { + headers: { + ...validHeaders, + "x-trusted-proxy-secret": secret, + } as Record, + }, + ), + ).resolves.toBeNull(); + expect(mocks.select).not.toHaveBeenCalled(); + }, + ); + + it("rejects requests without headers", async () => { + await expect(trustedProxyAuthorize()({}, {})).resolves.toBeNull(); + expect(mocks.select).not.toHaveBeenCalled(); + }); + + it("always requires the shared secret", async () => { + const authorize = trustedProxyAuthorize(); + + await expect( + authorize({}, { headers: { "x-openhost-is-owner": "true" } }), + ).resolves.toBeNull(); + await expect( + authorize( + {}, + { + headers: { + "x-openhost-is-owner": "true", + "x-trusted-proxy-secret": "wrong", + }, + }, + ), + ).resolves.toBeNull(); + await expect( + authorize( + {}, + { + headers: { + "x-openhost-is-owner": "true", + "x-trusted-proxy-secret": "proxy-secret", + }, + }, + ), + ).resolves.toMatchObject({ id: AUTHENTICATED_USER.id }); + }); + }); + + // authOptions() is rebuilt on every session lookup, so a per-instance + // warning would log on every authenticated request. + it("warns about trusted-proxy sign-in once per process", async () => { + vi.resetModules(); + const { authOptions: freshAuthOptions } = await import( + "@cap/database/auth/auth-options" + ); + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + void freshAuthOptions().providers; + expect(warn).not.toHaveBeenCalled(); + env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + for (let i = 0; i < 3; i++) void freshAuthOptions().providers; + + expect( + warn.mock.calls.filter(([message]) => + String(message).includes("trusted-proxy sign-in is ENABLED"), + ), + ).toHaveLength(1); + } finally { + warn.mockRestore(); + } + }); + // Without an explicit maxAge next-auth falls back to 24 hours, which is far // too long for a 6-digit code and contradicts what the OTP email tells users. it("expires email verification codes after the advertised 10 minutes", () => { diff --git a/apps/web/__tests__/unit/cloudfront-signed-url.test.ts b/apps/web/__tests__/unit/cloudfront-signed-url.test.ts new file mode 100644 index 00000000000..809bb2095a6 --- /dev/null +++ b/apps/web/__tests__/unit/cloudfront-signed-url.test.ts @@ -0,0 +1,85 @@ +import { generateKeyPairSync } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { + createCloudFrontSignedUrl, + getCloudFrontObjectUrl, +} from "../../../../packages/web-backend/src/S3Buckets/cloudfront-signed-url"; + +const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); + +const bucketUrl = "https://cdn.example.com"; + +const sign = (key: string) => + createCloudFrontSignedUrl({ + bucketUrl, + key, + keyPairId: "K2JCJMDEHXQW5F", + privateKey, + expiresAt: 1_900_000_000, + }); + +const decodePolicy = (signedUrl: string) => { + const encoded = new URL(signedUrl).searchParams.get("Policy") ?? ""; + const base64 = encoded + .replace(/-/g, "+") + .replace(/_/g, "=") + .replace(/~/g, "/"); + return JSON.parse(Buffer.from(base64, "base64").toString("utf8")) as { + Statement: { Resource: string }[]; + }; +}; + +const resourceOf = (signedUrl: string) => + decodePolicy(signedUrl).Statement[0]?.Resource; + +const baseOf = (signedUrl: string) => + signedUrl.slice(0, signedUrl.indexOf("?")); + +describe("createCloudFrontSignedUrl", () => { + it("signs ordinary keys for the exact object URL", () => { + const signedUrl = sign("user/video/result.mp4"); + + expect(resourceOf(signedUrl)).toBe( + "https://cdn.example.com/user/video/result.mp4", + ); + expect(baseOf(signedUrl)).toBe(resourceOf(signedUrl)); + }); + + it("does not leave CloudFront wildcards in the policy resource", () => { + for (const key of [ + "user/video/*", + "user/video/?", + "user/video/*.mp4", + "user/video/a?b/*", + ]) { + const signedUrl = sign(key); + const resource = resourceOf(signedUrl) ?? ""; + + expect(resource).not.toMatch(/[*?]/); + expect(baseOf(signedUrl)).toBe(resource); + } + }); + + it("percent-encodes wildcard and reserved characters per path segment", () => { + expect(getCloudFrontObjectUrl(bucketUrl, "user/video/a*b?c d#e%f")).toBe( + "https://cdn.example.com/user/video/a%2Ab%3Fc%20d%23e%25f", + ); + expect(getCloudFrontObjectUrl(bucketUrl, "user/video/(1)!'.mp4")).toBe( + "https://cdn.example.com/user/video/%281%29%21%27.mp4", + ); + }); + + it("keeps the returned URL aligned with the signed resource for encoded keys", () => { + const signedUrl = sign("user/video/my clip (1).mp4"); + + expect(resourceOf(signedUrl)).toBe( + "https://cdn.example.com/user/video/my%20clip%20%281%29.mp4", + ); + expect(baseOf(signedUrl)).toBe(resourceOf(signedUrl)); + }); +}); diff --git a/apps/web/__tests__/unit/comment-recorder-lifecycle.test.ts b/apps/web/__tests__/unit/comment-recorder-lifecycle.test.ts new file mode 100644 index 00000000000..9ecdf58cb3e --- /dev/null +++ b/apps/web/__tests__/unit/comment-recorder-lifecycle.test.ts @@ -0,0 +1,186 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + display: vi.fn(), + mic: vi.fn(), + mixer: vi.fn(), +})); + +vi.mock("@cap/recorder-core/capture-streams", () => ({ + acquireCameraStream: vi.fn(), + acquireCameraWithMicStream: vi.fn(), + acquireDisplayStream: mocks.display, + acquireMicStream: mocks.mic, + createAudioMixer: mocks.mixer, + getCaptureErrorMessage: (error: Error) => error.message, +})); + +import { useCommentRecorder } from "@/app/s/[videoId]/_components/timeline/recording/useCommentRecorder"; + +class Track extends EventTarget { + kind: "audio" | "video"; + readyState: MediaStreamTrackState = "live"; + stop = vi.fn(() => { + this.readyState = "ended"; + }); + + constructor(kind: "audio" | "video") { + super(); + this.kind = kind; + } + + end() { + this.readyState = "ended"; + this.dispatchEvent(new Event("ended")); + } + + getSettings() { + return {}; + } +} + +class Stream { + constructor(private readonly tracks: Track[]) {} + + getTracks() { + return this.tracks; + } + + getVideoTracks() { + return this.tracks.filter((track) => track.kind === "video"); + } + + getAudioTracks() { + return this.tracks.filter((track) => track.kind === "audio"); + } +} + +class Recorder { + static isTypeSupported = () => true; + static startError: Error | null = null; + + state: RecordingState = "inactive"; + ondataavailable: ((event: BlobEvent) => void) | null = null; + onstop: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + mimeType = "video/webm"; + + constructor(private readonly stream: Stream) {} + + start() { + if (Recorder.startError) throw Recorder.startError; + if (this.stream.getVideoTracks()[0]?.readyState === "ended") { + throw new DOMException("The video track ended", "InvalidStateError"); + } + this.state = "recording"; + } + + stop() { + this.state = "inactive"; + } +} + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +describe("comment recorder lifecycle", () => { + let root: Root; + let container: HTMLDivElement; + let recorder: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + Recorder.startError = null; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("MediaStream", Stream); + vi.stubGlobal("MediaRecorder", Recorder); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + function Harness() { + recorder = useCommentRecorder({ + onFinish: vi.fn(), + selectedCameraId: null, + selectedMicId: "mic", + }); + return null; + } + await act(async () => root.render(createElement(Harness))); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("releases capture when screen sharing ends during microphone setup", async () => { + const videoTrack = new Track("video"); + const micTrack = new Track("audio"); + const mixedTrack = new Track("audio"); + const displayStream = new Stream([videoTrack]); + const micStream = new Stream([micTrack]); + const closeMixer = vi.fn(async () => {}); + const pendingMic = deferred(); + const start = vi.spyOn(Recorder.prototype, "start"); + mocks.display.mockResolvedValue(displayStream); + mocks.mic.mockReturnValue(pendingMic.promise); + mocks.mixer.mockResolvedValue({ + context: {}, + stream: new Stream([mixedTrack]), + setMicStream: vi.fn(), + close: closeMixer, + }); + + let starting!: Promise; + await act(async () => { + starting = recorder.startScreen(); + await Promise.resolve(); + }); + expect(recorder.phase).toBe("requesting"); + + videoTrack.end(); + await act(async () => { + pendingMic.resolve(micStream); + await starting; + }); + + expect(recorder.phase).toBe("idle"); + expect(recorder.error).toBe( + "The recording source ended before recording could start.", + ); + expect(start).not.toHaveBeenCalled(); + expect(videoTrack.stop).toHaveBeenCalled(); + expect(micTrack.stop).toHaveBeenCalled(); + expect(mixedTrack.stop).toHaveBeenCalled(); + expect(closeMixer).toHaveBeenCalled(); + }); + + it("releases capture when MediaRecorder rejects a live source", async () => { + const videoTrack = new Track("video"); + mocks.display.mockResolvedValue(new Stream([videoTrack])); + mocks.mic.mockRejectedValue(new Error("No microphone")); + Recorder.startError = new DOMException( + "The recorder could not start", + "InvalidStateError", + ); + + await act(async () => recorder.startScreen()); + + expect(recorder.phase).toBe("idle"); + expect(recorder.error).toBe( + "Recording couldn't start. Please choose the source again.", + ); + expect(videoTrack.stop).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/comment-reply-parent.test.ts b/apps/web/__tests__/unit/comment-reply-parent.test.ts new file mode 100644 index 00000000000..793464700ab --- /dev/null +++ b/apps/web/__tests__/unit/comment-reply-parent.test.ts @@ -0,0 +1,180 @@ +import * as Db from "@cap/database/schema"; +import type { SQL } from "drizzle-orm"; +import { MySqlDialect, type MySqlTable } from "drizzle-orm/mysql-core"; +import { Effect, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = Record; + +const videoId = "accessible-video"; +const commentId = "new-comment"; +const mediaKey = `owner/${videoId}/comments/${commentId}/media.webm`; + +const mocks = vi.hoisted(() => ({ + notify: vi.fn(async () => undefined), + state: { parents: [] as Row[], inserted: [] as Row[] }, +})); + +const dialect = new MySqlDialect(); + +function matchesParent(condition: SQL, row: Row) { + const { sql, params } = dialect.sqlToQuery(condition); + let index = 0; + return sql + .replaceAll(/[()]/g, "") + .split(" and ") + .every((clause) => { + const match = /^`comments`\.`([^`]+)` = \?$/.exec(clause); + if (!match?.[1]) throw new Error(`Unsupported predicate: ${clause}`); + return row[match[1]] === params[index++]; + }); +} + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: (table: MySqlTable) => ({ + where: (condition: SQL) => ({ + limit: async () => { + if (table === Db.videos) return [{ id: videoId }]; + if (table !== Db.comments) throw new Error("Unexpected table"); + return mocks.state.parents.filter((row) => + matchesParent(condition, row), + ); + }, + }), + }), + }), + insert: (table: MySqlTable) => ({ + values: async (row: Row) => { + if (table !== Db.comments) throw new Error("Unexpected insert"); + mocks.state.inserted.push(row); + }, + }), + }), +})); +vi.mock("@cap/database/helpers", async (importOriginal) => ({ + ...(await importOriginal()), + nanoId: () => commentId, +})); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => ({ id: "viewer", name: "Viewer" }), +})); +vi.mock("@cap/web-domain", () => ({ + Comment: { CommentId: { make: (id: string) => id } }, + Policy: { withPublicPolicy: () => (effect: unknown) => effect }, +})); +vi.mock("@cap/web-backend", () => ({ + provideOptionalAuth: (effect: unknown) => effect, + verifyCommentMediaUploadToken: () => ({ + userId: "viewer", + videoId, + commentId, + kind: "audio", + key: mediaKey, + thumbnailKey: null, + contentType: "audio/webm", + }), + VideosRepo: Effect.succeed({ + getById: () => + Effect.succeed(Option.some([{ id: videoId, ownerId: "owner" }])), + }), + VideosPolicy: Effect.succeed({ canView: () => Effect.void }), + Storage: { + getAccessForVideo: () => + Effect.succeed([ + { headObject: () => Effect.succeed({ ContentLength: 2048 }) }, + ]), + }, +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/comment-media", () => ({ sanitizeWaveform: () => undefined })); +vi.mock("@/lib/media-comments", () => ({ + canUseMediaComments: async () => true, +})); +vi.mock("@/lib/Notification", () => ({ createNotification: mocks.notify })); +vi.mock("@/lib/server", () => ({ runPromiseExit: Effect.runPromiseExit })); + +import { newComment } from "@/actions/videos/new-comment"; +import { newMediaComment } from "@/actions/videos/new-media-comment"; + +function postText(parentCommentId = "parent") { + return newComment({ + content: "A reply", + videoId: videoId as never, + type: "text", + authorImage: null, + parentCommentId: parentCommentId as never, + timestamp: null, + }); +} + +function postMedia(parentCommentId = "parent") { + return newMediaComment({ + videoId: videoId as never, + commentId: commentId as never, + uploadToken: "token", + kind: "audio", + durationSeconds: 3, + parentCommentId: parentCommentId as never, + timestamp: null, + authorImage: null, + }); +} + +const rejectedParents = [ + { + name: "belongs to another video", + parents: [{ id: "parent", videoId: "private-video", type: "text" }], + }, + { name: "does not exist", parents: [] }, +]; + +beforeEach(() => { + mocks.state.parents = [{ id: "parent", videoId, type: "text" }]; + mocks.state.inserted = []; + mocks.notify.mockClear(); +}); + +describe.each([ + { action: "newComment", post: postText }, + { action: "newMediaComment", post: postMedia }, +])("$action reply parent authorization", ({ post }) => { + it.each(rejectedParents)( + "rejects a parent that $name", + async ({ parents }) => { + mocks.state.parents = parents; + await expect(post()).rejects.toThrow("Comment not found"); + expect(mocks.state.inserted).toEqual([]); + expect(mocks.notify).not.toHaveBeenCalled(); + }, + ); + + it.each(["text", "emoji", "video", "audio"])( + "preserves replies to a %s comment on the same video", + async (type) => { + mocks.state.parents = [{ id: "parent", videoId, type }]; + await expect(post()).resolves.toMatchObject({ + parentCommentId: "parent", + videoId, + }); + expect(mocks.state.inserted).toHaveLength(1); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: "reply", + parentCommentId: "parent", + videoId, + }), + ); + }, + ); + + it("preserves top-level comments", async () => { + mocks.state.parents = []; + await expect(post("")).resolves.toMatchObject({ parentCommentId: "" }); + expect(mocks.state.inserted).toHaveLength(1); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: "comment" }), + ); + }); +}); diff --git a/apps/web/__tests__/unit/cookie-write-site.test.ts b/apps/web/__tests__/unit/cookie-write-site.test.ts new file mode 100644 index 00000000000..564ff3db045 --- /dev/null +++ b/apps/web/__tests__/unit/cookie-write-site.test.ts @@ -0,0 +1,473 @@ +import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site"; +import { + Database, + getCurrentUser, + HttpAuthMiddlewareLive, + RpcAuthMiddlewareLive, +} from "@cap/web-backend"; +import { + CurrentUser, + DatabaseError, + HttpAuthMiddleware, + RpcAuthMiddleware, +} from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpServer, + HttpServerRequest, +} from "@effect/platform"; +import { Rpc, RpcGroup, RpcSerialization, RpcServer } from "@effect/rpc"; +import { Effect, Layer, Option, Schema } from "effect"; +import { Hono } from "hono"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const cookieUser = { + id: "cookie-user", + email: "cookie@example.com", + activeOrganizationId: "org", + image: null, + }; + const keyUser = { + id: "key-user", + email: "key@example.com", + activeOrganizationId: "org", + image: null, + }; + const client = { + select: () => ({ + from: () => ({ + where: async () => [cookieUser], + leftJoin: () => ({ where: async () => [{ users: keyUser }] }), + }), + }), + update: () => ({ set: () => ({ where: async () => undefined }) }), + }; + return { + cookieUser, + keyUser, + client, + getServerSession: vi.fn(async () => ({ user: { id: cookieUser.id } })), + getSessionUser: vi.fn(async () => cookieUser), + }; +}); + +vi.mock("server-only", () => ({})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ WEB_URL: "https://cap.so" }), + buildEnv: { + NEXT_PUBLIC_WEB_URL: "https://cap.so", + NEXT_PUBLIC_IS_CAP: "true", + }, +})); + +vi.mock("@cap/database", () => ({ db: () => mocks.client })); + +vi.mock("@cap/database/auth/auth-options", () => ({ + authOptions: () => ({}), + getServerSession: mocks.getServerSession, +})); + +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: mocks.getSessionUser, +})); + +import { POST as updateUserName } from "@/app/api/settings/user/name/route"; +import { withAuth, withOptionalAuth } from "@/app/api/utils"; + +const API_KEY = "0123456789abcdef0123456789abcdef0123"; + +const request = ( + method: string, + headers: Record = {}, + url = "https://cap.so/api/test", +) => + new Request(url, { + method, + headers: { host: new URL(url).host, ...headers }, + }); + +const crossSite = { + "sec-fetch-site": "cross-site", + origin: "https://other.example", +}; +const sameOrigin = { + "sec-fetch-site": "same-origin", + origin: "https://cap.so", +}; +const sameSite = { + "sec-fetch-site": "same-site", + origin: "https://www.cap.so", +}; + +beforeEach(() => { + mocks.getServerSession.mockClear(); + mocks.getSessionUser.mockClear(); +}); + +describe("isCrossSiteCookieWrite", () => { + it.each(["POST", "PUT", "PATCH", "DELETE"])( + "flags a %s with Sec-Fetch-Site cross-site", + (method) => { + expect(isCrossSiteCookieWrite(request(method, crossSite))).toBe(true); + }, + ); + + it("flags a write whose Origin is another site when fetch metadata is missing", () => { + expect( + isCrossSiteCookieWrite( + request("POST", { origin: "https://other.example" }), + ), + ).toBe(true); + expect(isCrossSiteCookieWrite(request("POST", { origin: "null" }))).toBe( + true, + ); + }); + + it.each([ + ["same-origin", sameOrigin], + ["same-site", sameSite], + ["user-initiated", { "sec-fetch-site": "none" }], + ["app origin without fetch metadata", { origin: "https://cap.so" }], + ["no browser headers", {}], + ])("allows a %s write", (_label, headers) => { + expect(isCrossSiteCookieWrite(request("POST", headers))).toBe(false); + }); + + it("treats the request host as an own origin for custom domains and previews", () => { + expect( + isCrossSiteCookieWrite( + request( + "POST", + { origin: "https://videos.customer.example" }, + "https://videos.customer.example/api/analytics/track", + ), + ), + ).toBe(false); + expect( + isCrossSiteCookieWrite( + request( + "POST", + { + origin: "https://cap-preview.vercel.app", + host: "internal:3000", + "x-forwarded-host": "cap-preview.vercel.app", + }, + "http://internal:3000/api/test", + ), + ), + ).toBe(false); + expect( + isCrossSiteCookieWrite({ + method: "POST", + url: "http://localhost:3000/api/test", + headers: new Headers({ origin: "http://localhost:3000" }), + }), + ).toBe(false); + }); + + it("still flags a cross-site write that carries an Authorization header", () => { + expect( + isCrossSiteCookieWrite( + request("POST", { ...crossSite, authorization: `Bearer ${API_KEY}` }), + ), + ).toBe(true); + expect( + isCrossSiteCookieWrite( + request("POST", { ...crossSite, authorization: "Bearer bogus" }), + ), + ).toBe(true); + }); + + it.each(["GET", "HEAD", "OPTIONS"])("leaves %s alone", (method) => { + expect(isCrossSiteCookieWrite(request(method, crossSite))).toBe(false); + }); +}); + +describe("Hono cookie fallback", () => { + const app = new Hono() + .post("/required", withAuth, (c) => c.json({ user: c.get("user").id })) + .get("/required", withAuth, (c) => c.json({ user: c.get("user").id })) + .post("/optional", withOptionalAuth, (c) => + c.json({ user: c.get("user")?.id ?? null }), + ); + + const send = ( + path: string, + method: string, + headers: Record, + ) => app.request(request(method, headers, `https://cap.so${path}`)); + + it("does not authenticate a cross-site cookie write", async () => { + const response = await send("/required", "POST", crossSite); + expect(response.status).toBe(401); + expect(mocks.getSessionUser).not.toHaveBeenCalled(); + }); + + it("does not authenticate a cross-site cookie write carrying a bogus bearer", async () => { + const response = await send("/required", "POST", { + ...crossSite, + authorization: "Bearer bogus", + }); + expect(response.status).toBe(401); + expect(mocks.getSessionUser).not.toHaveBeenCalled(); + }); + + it("keeps optional endpoints working anonymously for cross-site writes", async () => { + const response = await send("/optional", "POST", crossSite); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: null }); + }); + + it("keeps optional endpoints anonymous for a cross-site write with a bogus bearer", async () => { + const response = await send("/optional", "POST", { + ...crossSite, + authorization: "Bearer bogus", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: null }); + }); + + it.each([ + ["same-origin", sameOrigin], + ["same-site", sameSite], + ])("authenticates a %s cookie write", async (_label, headers) => { + const response = await send("/required", "POST", headers); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "cookie-user" }); + }); + + it("authenticates a bearer write from any site", async () => { + const response = await send("/required", "POST", { + ...crossSite, + authorization: `Bearer ${API_KEY}`, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "key-user" }); + }); + + it("authenticates a cross-site GET with the cookie", async () => { + const response = await send("/required", "GET", crossSite); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "cookie-user" }); + }); +}); + +describe("App Router cookie writes", () => { + const rename = (headers: Record) => + updateUserName( + new Request("https://cap.so/api/settings/user/name", { + method: "POST", + headers: { host: "cap.so", ...headers }, + body: JSON.stringify({ firstName: "A", lastName: "B" }), + }) as never, + ); + + it("does not authenticate a cross-site cookie write", async () => { + const response = await rename(crossSite); + expect(response.status).toBe(401); + expect(mocks.getSessionUser).not.toHaveBeenCalled(); + }); + + it("authenticates a same-origin cookie write", async () => { + const response = await rename(sameOrigin); + expect(response.status).toBe(200); + expect(mocks.getSessionUser).toHaveBeenCalledTimes(1); + }); +}); + +const database = Layer.succeed( + Database, + Database.make({ + use: (callback) => + Effect.tryPromise({ + try: () => callback(mocks.client as never), + catch: (cause) => new DatabaseError({ cause }), + }), + }), +); + +describe("Effect getCurrentUser", () => { + const run = (web?: Request) => + Effect.runPromise( + getCurrentUser.pipe( + Effect.map(Option.map((user) => user.id)), + Effect.provide(database), + (effect) => + web + ? Effect.provideService( + effect, + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(web), + ) + : effect, + ), + ); + + it.each([ + ["plain cross-site write", request("POST", crossSite)], + [ + "cross-site write carrying a bogus bearer", + request("POST", { ...crossSite, authorization: "Bearer other" }), + ], + ])("returns no user for a %s", async (_label, web) => { + expect(await run(web)).toEqual(Option.none()); + expect(mocks.getServerSession).not.toHaveBeenCalled(); + }); + + it.each([ + ["same-origin write", request("POST", sameOrigin)], + ["same-site write", request("POST", sameSite)], + ["cross-site GET", request("GET", crossSite)], + ["call outside a request", undefined], + ])("returns the cookie user for a %s", async (_label, web) => { + expect(await run(web)).toEqual(Option.some("cookie-user")); + }); +}); + +const WhoAmI = HttpApiEndpoint.post("whoAmI", "/api/who-am-i") + .addSuccess(Schema.Struct({ user: Schema.String })) + .middleware(HttpAuthMiddleware); +const WhoAmIGet = HttpApiEndpoint.get("whoAmIGet", "/api/who-am-i") + .addSuccess(Schema.Struct({ user: Schema.String })) + .middleware(HttpAuthMiddleware); +class WhoAmIApi extends HttpApi.make("whoAmI").add( + HttpApiGroup.make("whoAmI").add(WhoAmI).add(WhoAmIGet), +) {} + +const httpApi = HttpApiBuilder.api(WhoAmIApi).pipe( + Layer.provide( + HttpApiBuilder.group(WhoAmIApi, "whoAmI", (handlers) => + handlers + .handle("whoAmI", () => + Effect.map(CurrentUser, (user) => ({ user: user.id })), + ) + .handle("whoAmIGet", () => + Effect.map(CurrentUser, (user) => ({ user: user.id })), + ), + ), + ), + Layer.provide(HttpAuthMiddlewareLive), + Layer.provide(database), + Layer.merge(HttpServer.layerContext), + HttpApiBuilder.toWebHandler, +); + +const RpcWhoAmI = Rpc.make("WhoAmI", { success: Schema.String }).middleware( + RpcAuthMiddleware, +); +const rpcGroup = RpcGroup.make(RpcWhoAmI); +const rpcApi = RpcServer.toWebHandler(rpcGroup, { + layer: Layer.mergeAll( + rpcGroup.toLayer({ + WhoAmI: () => Effect.map(CurrentUser, (user) => user.id), + }), + RpcAuthMiddlewareLive, + RpcSerialization.layerJson, + HttpServer.layerContext, + ).pipe(Layer.provide(database)), +}); + +afterAll(async () => { + await httpApi.dispose(); + await rpcApi.dispose(); +}); + +describe("HttpApi auth middleware", () => { + const send = (method: string, headers: Record) => + httpApi.handler(request(method, headers, "https://cap.so/api/who-am-i")); + + it("does not authenticate a cross-site cookie write", async () => { + const response = await send("POST", crossSite); + expect(response.status).toBe(401); + }); + + it("does not authenticate a cross-site cookie write carrying a bogus bearer", async () => { + const response = await send("POST", { + ...crossSite, + authorization: "Bearer bogus", + }); + expect(response.status).toBe(401); + }); + + it.each([ + ["same-origin", sameOrigin], + ["same-site", sameSite], + ])("authenticates a %s cookie write", async (_label, headers) => { + const response = await send("POST", headers); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "cookie-user" }); + }); + + it("authenticates a bearer write from any site", async () => { + const response = await send("POST", { + ...crossSite, + authorization: `Bearer ${API_KEY}`, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "key-user" }); + }); + + it("authenticates a cross-site GET with the cookie", async () => { + const response = await send("GET", crossSite); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ user: "cookie-user" }); + }); +}); + +describe("RPC auth middleware", () => { + const call = async ( + headers: Record, + rpcHeaders: [string, string][] = [], + ) => { + const response = await rpcApi.handler( + new Request("https://cap.so/api/erpc", { + method: "POST", + headers: { + host: "cap.so", + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify({ + _tag: "Request", + id: "1", + tag: "WhoAmI", + payload: {}, + headers: rpcHeaders, + }), + }), + ); + const [message] = (await response.json()) as [ + { exit: { _tag: string; value?: unknown; cause?: unknown } }, + ]; + return message.exit; + }; + + it("does not authenticate a cross-site cookie call", async () => { + const exit = await call(crossSite); + expect(exit._tag).toBe("Failure"); + expect(JSON.stringify(exit.cause)).toContain("UnauthenticatedError"); + }); + + it("reads fetch metadata from the HTTP request, not the RPC payload headers", async () => { + const exit = await call(crossSite, [ + ["sec-fetch-site", "same-origin"], + ["origin", "https://cap.so"], + ]); + expect(exit._tag).toBe("Failure"); + }); + + it.each([ + ["same-origin", sameOrigin], + ["same-site", sameSite], + ])("authenticates a %s cookie call", async (_label, headers) => { + expect(await call(headers)).toEqual({ + _tag: "Success", + value: "cookie-user", + }); + }); +}); diff --git a/apps/web/__tests__/unit/dashboard-video-count.test.ts b/apps/web/__tests__/unit/dashboard-video-count.test.ts new file mode 100644 index 00000000000..991854bc824 --- /dev/null +++ b/apps/web/__tests__/unit/dashboard-video-count.test.ts @@ -0,0 +1,112 @@ +import type { Database, ImageUploads, Spaces } from "@cap/web-backend"; +import { Effect, Layer } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CapsPage from "@/app/(org)/dashboard/caps/page"; +import SharedCapsPage from "@/app/(org)/dashboard/spaces/[spaceId]/page"; + +const mocks = vi.hoisted(() => ({ query: vi.fn() })); + +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const client = drizzle(mocks.query); + return { db: () => client }; +}); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => ({ id: "user-1", activeOrganizationId: "org-1" }), +})); +vi.mock("@cap/env", () => ({ serverEnv: () => ({}) })); +vi.mock("@cap/web-backend", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const { Effect, Layer } = await import("effect"); + const client = drizzle(mocks.query); + class Database extends Effect.Service()("Database", { + succeed: { + use: (callback: (db: typeof client) => Promise) => + Effect.promise(() => callback(client)), + }, + }) {} + class ImageUploads extends Effect.Service()("ImageUploads", { + succeed: { resolveImageUrl: (value: string) => Effect.succeed(value) }, + }) {} + class Spaces extends Effect.Service()("Spaces", { + succeed: { + getSpaceOrOrg: () => + Effect.succeed({ + variant: "organization", + organization: { id: "org-1" }, + }), + }, + }) {} + return { + Database, + ImageUploads, + Spaces, + makeCurrentUserLayer: () => Layer.empty, + }; +}); +vi.mock("@/lib/server", async () => { + const { Database, ImageUploads, Spaces } = await import("@cap/web-backend"); + return { + runPromise: ( + effect: Effect.Effect, + ) => + Effect.runPromise( + Effect.provide( + effect, + Layer.mergeAll( + Database.Default, + ImageUploads.Default, + Spaces.Default, + ), + ), + ), + }; +}); +vi.mock("@/app/(org)/dashboard/caps/Caps", () => ({ Caps: () => null })); +vi.mock("@/app/(org)/dashboard/spaces/[spaceId]/SharedCaps", () => ({ + SharedCaps: () => null, +})); + +beforeEach(() => { + mocks.query.mockResolvedValue({ rows: [] }); +}); + +describe("dashboard pagination counts", () => { + it.each([ + { + name: "personal Caps", + folderTable: "videos", + load: () => + CapsPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({}), + }), + }, + { + name: "organization Caps", + folderTable: "shared_videos", + load: () => + SharedCapsPage({ + params: Promise.resolve({ spaceId: "org-1" }), + searchParams: Promise.resolve({}), + }), + }, + ])( + "counts the same root videos it lists for $name", + async ({ load, folderTable }) => { + await load(); + const queries = mocks.query.mock.calls.map(([query]) => String(query)); + const countQuery = queries.find((query) => + query.startsWith("select count(*)"), + ); + const listQuery = queries.find((query) => query.includes(" group by ")); + const folderFilter = `\`${folderTable}\`.\`folderId\` is null`; + + expect(listQuery).toContain(folderFilter); + expect(countQuery).toContain(folderFilter); + if (folderTable === "shared_videos") { + expect(countQuery).not.toContain("`videos`.`folderId`"); + } + }, + ); +}); diff --git a/apps/web/__tests__/unit/desktop-recording-source.test.ts b/apps/web/__tests__/unit/desktop-recording-source.test.ts index 023f65f5024..2172d261b22 100644 --- a/apps/web/__tests__/unit/desktop-recording-source.test.ts +++ b/apps/web/__tests__/unit/desktop-recording-source.test.ts @@ -7,11 +7,33 @@ import { z } from "zod"; import type { DesktopRecordingSourceCheckpoint } from "@/lib/desktop-recording-source-checkpoint"; import type { RecordingVerification } from "@/lib/desktop-recording-verification"; -const mocks = vi.hoisted(() => ({ storage: vi.fn() })); +const mocks = vi.hoisted(() => ({ + storage: vi.fn(), + pageSize: undefined as number | undefined, + treePageSize: undefined as number | undefined, +})); vi.mock("@cap/web-backend/src/Storage/index", () => ({ Storage: { getAccessForVideo: mocks.storage }, })); +vi.mock("@/lib/desktop-recording-source-checkpoint", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/desktop-recording-source-checkpoint") + >(); + return { + ...actual, + get SOURCE_COMMIT_PAGE_SIZE() { + return mocks.pageSize ?? actual.SOURCE_COMMIT_PAGE_SIZE; + }, + get SOURCE_COMMIT_AWS_PAGE_SIZE() { + return mocks.pageSize ?? actual.SOURCE_COMMIT_AWS_PAGE_SIZE; + }, + get SOURCE_COMMIT_TREE_PAGE_SIZE() { + return mocks.treePageSize ?? actual.SOURCE_COMMIT_TREE_PAGE_SIZE; + }, + }; +}); vi.mock("@/lib/workflow-runtime", async () => { const { Effect } = await import("effect"); return { runWorkflowPromise: Effect.runPromise }; @@ -99,7 +121,7 @@ function recording(source: VideoRow["source"] = { type: "desktopSegments" }) { return { id: "owned-video", ownerId: "owned-user", source } as VideoRow; } -function storageFixture(provider = "s3") { +function storageFixture(provider = "s3", customBucket = false) { const objects = new Map(); const uploads = new Map< string, @@ -311,7 +333,12 @@ function storageFixture(provider = "s3") { ), }, }; - mocks.storage.mockReturnValue(Effect.succeed([bucket])); + mocks.storage.mockReturnValue( + Effect.succeed([ + bucket, + customBucket ? Option.some({ id: "custom-bucket" }) : Option.none(), + ]), + ); return { bucket, objects, uploads, seed, object }; } @@ -422,6 +449,8 @@ function seedSegments( beforeEach(() => { vi.clearAllMocks(); + mocks.pageSize = undefined; + mocks.treePageSize = undefined; }); describe("recording source preparation during upload", () => { @@ -1369,13 +1398,16 @@ describe("large desktop recording snapshots", () => { ).toBe(8 * 1024 ** 3); }); - it.each(["s3", "googleDrive"])( + it.each([ + ["s3", 128], + ["googleDrive", 16], + ] as const)( "resumes a completed %s copy when the database checkpoint write was lost", - async (provider) => { + async (provider, page) => { const storage = storageFixture(provider); seedSegments( storage, - Array.from({ length: 33 }, (_, index) => index + 1), + Array.from({ length: 129 }, (_, index) => index + 1), false, ); const video = recording(); @@ -1389,19 +1421,19 @@ describe("large desktop recording snapshots", () => { checkpoint, ); if (!("checkpoint" in copied)) throw new Error("Unexpected completion"); - expect(copied.checkpoint.cursor).toBe(16); - expect(storage.bucket.copyObject).toHaveBeenCalledTimes(16); + expect(copied.checkpoint.cursor).toBe(page); + expect(storage.bucket.copyObject).toHaveBeenCalledTimes(page); const replay = await advanceDesktopRecordingSourceCommit( video, checkpoint, ); if (!("checkpoint" in replay)) throw new Error("Unexpected completion"); - expect(storage.bucket.copyObject).toHaveBeenCalledTimes(16); + expect(storage.bucket.copyObject).toHaveBeenCalledTimes(page); expect(replay.checkpoint.snapshotId).toBe(checkpoint.snapshotId); const source = await finishSnapshot(video, replay.checkpoint); const urls = await buildDesktopRecordingSourceUrls(video, source); - expect(urls.sourceObjects).toHaveLength(34); - expect(storage.bucket.copyObject).toHaveBeenCalledTimes(34); + expect(urls.sourceObjects).toHaveLength(130); + expect(storage.bucket.copyObject).toHaveBeenCalledTimes(130); expect(JSON.stringify(replay.checkpoint).length).toBeLessThan(5_000); }, ); @@ -1627,6 +1659,313 @@ describe("large desktop recording snapshots", () => { }); }); +describe("source commit page size", () => { + const segmentCount = 130; + + function pagedFixture(provider = "s3", customBucket = false) { + const storage = storageFixture(provider, customBucket); + seedSegments( + storage, + Array.from({ length: segmentCount }, (_, index) => index + 1), + ); + return storage; + } + + async function advanceWithPageSize( + pageSize: number, + checkpoint: DesktopRecordingSourceCheckpoint, + done: (checkpoint: DesktopRecordingSourceCheckpoint) => boolean = () => + false, + ) { + mocks.pageSize = pageSize; + let checkpoints = 0; + while (!done(checkpoint)) { + const result = await advanceDesktopRecordingSourceCommit( + recording(), + checkpoint, + ); + checkpoints++; + if ("source" in result) + return { source: result.source, checkpoint, checkpoints }; + checkpoint = result.checkpoint; + } + return { source: undefined, checkpoint, checkpoints }; + } + + function leafSizes( + storage: ReturnType, + inventoryKey: string, + ) { + const page = z.discriminatedUnion("type", [ + z.object({ type: z.literal("leaf"), entries: z.array(z.unknown()) }), + z.object({ + type: z.literal("branch"), + children: z.array(z.object({ key: z.string() })), + }), + ]); + const sizes = (key: string): number[] => { + const node = page.parse(JSON.parse(storage.object(key).body ?? "")); + return node.type === "leaf" + ? [node.entries.length] + : node.children.flatMap((child) => sizes(child.key)); + }; + return z + .object({ roots: z.array(z.object({ key: z.string() })) }) + .parse(JSON.parse(storage.object(inventoryKey).body ?? "")) + .roots.flatMap((root) => sizes(root.key)); + } + + async function snapshotOutput( + storage: ReturnType, + source: Awaited>, + ) { + const objects = readInventory(storage, source.inventoryKey).objects.map( + (object) => { + const copy = storage.object(object.key); + expect(copy.identity).toBe(object.objectIdentity); + const receiptKey = object.key.replace( + /\/(video|audio)\/\d+\.(mp4|m4s)$/, + "/receipt.json", + ); + expect(JSON.parse(storage.object(receiptKey).body ?? "")).toEqual( + object, + ); + return { + key: object.key.replace(/\/copies\/(\d+)\/[0-9a-f-]{36}\//, "/$1/"), + originalKey: object.originalKey, + originalIdentity: object.originalIdentity, + size: object.size, + track: object.track, + index: object.index, + body: copy.body, + }; + }, + ); + const urls = await buildDesktopRecordingSourceUrls(recording(), source); + return { + source: { + kind: source.kind, + manifestSha256: source.manifestSha256, + requiredAudio: source.requiredAudio, + }, + objects, + urls: { + sourceObjects: urls.sourceObjects.map(({ size }) => size), + videoSegments: urls.videoSegmentUrls.length, + audioSegments: urls.audioSegmentUrls.length, + videoInit: urls.videoInitUrl !== undefined, + audioInit: urls.audioInitUrl !== undefined, + }, + copies: storage.bucket.copyObject.mock.calls.length, + }; + } + + async function referenceOutput(pageSize: number) { + const storage = pagedFixture(); + const result = await advanceWithPageSize(pageSize, snapshotCheckpoint()); + if (!result.source) throw new Error("Snapshot did not complete"); + return { + output: await snapshotOutput(storage, result.source), + checkpoints: result.checkpoints, + leaves: leafSizes(storage, result.source.inventoryKey), + }; + } + + it("uses 128-entry pages for Cap's bucket and keeps 16 elsewhere, both within the leaf schema", async () => { + const actual = await vi.importActual< + typeof import("@/lib/desktop-recording-source-checkpoint") + >("@/lib/desktop-recording-source-checkpoint"); + expect(actual.SOURCE_COMMIT_AWS_PAGE_SIZE).toBe(128); + expect(actual.SOURCE_COMMIT_PAGE_SIZE).toBe(16); + expect(actual.SOURCE_COMMIT_AWS_PAGE_SIZE).toBeLessThanOrEqual( + actual.SOURCE_COMMIT_TREE_PAGE_SIZE, + ); + expect(actual.SOURCE_COMMIT_TREE_PAGE_SIZE).toBeLessThanOrEqual( + actual.SOURCE_COMMIT_MAX_TREE_PAGE_ENTRIES, + ); + }); + + it("resumes a commit saved with larger tree pages using today's 128-entry writer, as after a rollback", async () => { + const reference = await referenceOutput(128); + const storage = pagedFixture(); + mocks.treePageSize = 256; + const partial = await advanceWithPageSize( + 256, + snapshotCheckpoint(), + (checkpoint) => checkpoint.phase === "verify", + ); + expect(partial.source).toBeUndefined(); + expect(partial.checkpoint.phase).toBe("verify"); + mocks.treePageSize = undefined; + const resumed = await advanceWithPageSize(128, partial.checkpoint); + if (!resumed.source) throw new Error("Resumed commit did not complete"); + expect(await snapshotOutput(storage, resumed.source)).toEqual( + reference.output, + ); + }); + + it.each([ + [256, true], + [257, false], + ] as const)( + "reads a commit with %i-entry tree pages only within the read limit", + async (size, readable) => { + const storage = storageFixture("s3"); + seedSegments( + storage, + Array.from({ length: 300 }, (_, index) => index + 1), + ); + mocks.treePageSize = size; + const commit = advanceWithPageSize(size, snapshotCheckpoint()); + if (readable) { + const result = await commit; + if (!result.source) throw new Error("Commit did not complete"); + expect( + Math.max(...leafSizes(storage, result.source.inventoryKey)), + ).toBe(size); + } else { + await expect(commit).rejects.toMatchObject({ code: "source-invalid" }); + } + }, + ); + + it.each([ + ["a custom S3-compatible bucket", "s3", true], + ["Google Drive", "googleDrive", false], + ] as const)( + "keeps 16-entry checkpoints for %s", + async (_, provider, customBucket) => { + pagedFixture(provider, customBucket); + mocks.pageSize = undefined; + let checkpoint = snapshotCheckpoint(); + let checkpoints = 0; + while (true) { + const result = await advanceDesktopRecordingSourceCommit( + recording(), + checkpoint, + ); + checkpoints++; + if ("source" in result) break; + checkpoint = result.checkpoint; + } + expect(checkpoints).toBe(2 + 3 * Math.ceil((segmentCount + 3) / 16)); + }, + ); + + it("commits more than 64 objects with the same inventory and receipts as 16-entry pages", async () => { + const legacy = await referenceOutput(16); + vi.clearAllMocks(); + const current = await referenceOutput(64); + const objectCount = segmentCount + 3; + expect(current.output.objects).toHaveLength(objectCount); + expect(current.output).toEqual(legacy.output); + expect(current.output.copies).toBe(objectCount); + expect(legacy.checkpoints).toBe(2 + 3 * Math.ceil(objectCount / 16)); + expect(current.checkpoints).toBe(2 + 3 * Math.ceil(objectCount / 64)); + expect(legacy.leaves.every((size) => size <= 16)).toBe(true); + expect(current.leaves).toEqual([64, 64, objectCount - 128]); + }); + + for (const [from, to] of [ + [16, 64], + [64, 16], + ] as const) { + it.each(["enumerate", "copy", "verify"] as const)( + `resumes a %s checkpoint saved with ${from}-entry pages after switching to ${to}`, + async (phase) => { + const reference = await referenceOutput(64); + vi.clearAllMocks(); + const storage = pagedFixture(); + const saved = await advanceWithPageSize( + from, + snapshotCheckpoint(), + (checkpoint) => + checkpoint.phase === phase && checkpoint.cursor === 64, + ); + expect(saved.source).toBeUndefined(); + const next = await advanceDesktopRecordingSourceCommit( + recording(), + saved.checkpoint, + ); + if (!("checkpoint" in next)) throw new Error("Unexpected completion"); + expect(next.checkpoint).toMatchObject({ + phase, + cursor: 64 + from, + plan: saved.checkpoint.plan, + snapshotId: saved.checkpoint.snapshotId, + }); + const resumed = await advanceWithPageSize(to, next.checkpoint); + if (!resumed.source) throw new Error("Snapshot did not complete"); + expect(await snapshotOutput(storage, resumed.source)).toEqual( + reference.output, + ); + const leaves = leafSizes(storage, resumed.source.inventoryKey); + expect(leaves.reduce((total, size) => total + size, 0)).toBe( + segmentCount + 3, + ); + if (phase !== "verify") expect(new Set(leaves).size).toBeGreaterThan(1); + }, + ); + } + + it.each([ + ["Cap's AWS bucket", "s3", false, 128], + ["a custom S3-compatible bucket", "s3", true, 8], + ["Google Drive", "googleDrive", false, 8], + ] as const)( + "bounds parallel storage requests for %s", + async (_, provider, customBucket, expected) => { + const storage = pagedFixture(provider, customBucket); + const active = { head: 0, copy: 0 }; + const peak = { head: 0, copy: 0 }; + const track = ( + name: "head" | "copy", + effect: Effect.Effect, + ) => + Effect.suspend(() => { + active[name]++; + peak[name] = Math.max(peak[name], active[name]); + return Effect.sleep("1 millis").pipe( + Effect.zipRight(effect), + Effect.ensuring( + Effect.sync(() => { + active[name]--; + }), + ), + ); + }); + const head = storage.bucket.headObject.getMockImplementation(); + const copy = storage.bucket.copyObject.getMockImplementation(); + if (!head || !copy) throw new Error("Missing storage fixture"); + const enumerate = await advanceToPhase( + recording(), + snapshotCheckpoint(), + "enumerate", + ); + storage.bucket.headObject.mockImplementation((key) => + track("head", head(key)), + ); + storage.bucket.copyObject.mockImplementation((source, target, options) => + track("copy", copy(source, target, options)), + ); + const planned = await advanceDesktopRecordingSourceCommit( + recording(), + enumerate, + ); + if (!("checkpoint" in planned)) throw new Error("Unexpected completion"); + expect(peak.head).toBe(expected); + const source = await finishSnapshot(recording(), planned.checkpoint); + expect(peak.copy).toBe(expected); + // Each copy/verify worker checks the copy and its original together, so the + // bound is on objects in flight; requests peak at two per object. + expect(peak.head).toBeLessThanOrEqual(expected * 2); + expect(readInventory(storage, source.inventoryKey).objects).toHaveLength( + segmentCount + 3, + ); + }, + ); +}); + describe("committed source access", () => { it("classifies malformed committed inventory JSON as a permanent source error", async () => { const storage = storageFixture(); diff --git a/apps/web/__tests__/unit/developer-documentation-videos-page.test.ts b/apps/web/__tests__/unit/developer-documentation-videos-page.test.ts index 0f2815f6600..616de9caf62 100644 --- a/apps/web/__tests__/unit/developer-documentation-videos-page.test.ts +++ b/apps/web/__tests__/unit/developer-documentation-videos-page.test.ts @@ -59,6 +59,13 @@ describe("DeveloperDocumentationVideosPage component content", () => { expect(componentSource).toContain("comparisonTable"); }); + it("describes the license split in the comparison table", () => { + expect(componentSource).toContain( + '{ text: "Yes — AGPLv3 and MIT crates", status: "positive" }', + ); + expect(componentSource).not.toContain('{ text: "Yes — MIT"'); + }); + it("includes recording modes section", () => { expect(componentSource).toContain("recordingModes"); }); diff --git a/apps/web/__tests__/unit/finalize-desktop-recording.test.ts b/apps/web/__tests__/unit/finalize-desktop-recording.test.ts index b62ab316f7e..dcebcc4ae92 100644 --- a/apps/web/__tests__/unit/finalize-desktop-recording.test.ts +++ b/apps/web/__tests__/unit/finalize-desktop-recording.test.ts @@ -1,5 +1,5 @@ import type { User, Video } from "@cap/web-domain"; -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DesktopRecordingAttempt, @@ -33,6 +33,8 @@ const mocks = vi.hoisted(() => ({ put: vi.fn(), databaseRows: vi.fn(), reserveBudget: vi.fn(), + storage: vi.fn(), + multipartCreate: vi.fn(), })); vi.mock("@/lib/media-processing-budget", () => ({ reserveMediaProcessingBudget: mocks.reserveBudget, @@ -97,20 +99,13 @@ vi.mock("@cap/web-backend/src/Storage/index", async () => { const { Effect } = await import("effect"); return { Storage: { - getAccessForVideo: () => - Effect.succeed([ - { - provider: "test", - getInternalSignedObjectUrl: (key: string) => - Effect.succeed(`https://storage.test/${key}`), - getInternalPresignedPutUrl: mocks.put, - }, - ]), + getAccessForVideo: () => Effect.sync(mocks.storage), }, }; }); import { + commitAndStartDesktopRecordingAttempt, commitDesktopRecordingAttempt, finalizeDesktopRecordingWorkflow, pollDesktopRecordingAttempt, @@ -150,6 +145,19 @@ const fixture: DesktopRecordingAttempt = { }; let current: DesktopRecordingJob | null; +function storageAccess(provider: string, customBucket = false) { + return [ + { + provider, + getInternalSignedObjectUrl: (key: string) => + Effect.succeed(`https://storage.test/${key}`), + getInternalPresignedPutUrl: mocks.put, + multipart: { create: mocks.multipartCreate }, + }, + customBucket ? Option.some({ id: "bucket" }) : Option.none(), + ]; +} + function withCurrent(update: Partial) { if (!current) throw new Error("Fixture job disappeared"); current = { ...current, ...update }; @@ -157,6 +165,10 @@ function withCurrent(update: Partial) { beforeEach(() => { mocks.reserveBudget.mockResolvedValue(100_000_000); + mocks.storage.mockImplementation(() => storageAccess("test")); + mocks.multipartCreate.mockImplementation(() => + Effect.succeed({ UploadId: "upload" }), + ); mocks.databaseRows.mockResolvedValue([ { id: "video", ownerId: "user", source: { type: "desktopSegments" } }, ]); @@ -317,6 +329,30 @@ describe("durable post-publication transcription enqueue", () => { expect(mocks.claim).not.toHaveBeenCalled(); }); + it("finishes after repeated transcription queue failures", async () => { + withCurrent({ state: "verified" }); + mocks.transcribe.mockResolvedValue({ + success: false, + message: "Queue unavailable", + }); + mocks.sleep.mockImplementation(async () => { + if (mocks.transcribe.mock.calls.length >= 12) { + throw new Error("Transcription retry limit exceeded"); + } + }); + + await expect( + finalizeDesktopRecordingWorkflow({ + videoId, + userId, + generation: fixture.generation, + }), + ).resolves.toEqual({ success: true }); + expect(mocks.transcribe).toHaveBeenCalledTimes(12); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.claim).not.toHaveBeenCalled(); + }); + it.each(["unconfigured", "deleted"])( "skips %s transcription without retrying forever", async (reason) => { @@ -555,6 +591,144 @@ describe("bounded source commitment steps", () => { expect(mocks.persist).not.toHaveBeenCalled(); }); + it("logs the timing of every commit page without storage keys", async () => { + let clock = 0; + const now = vi.spyOn(performance, "now").mockImplementation(() => clock); + mocks.commitSource.mockImplementation( + async (_video, checkpoint, _verification, onProgress) => { + clock += 5; + await onProgress(); + clock += 100; + if (checkpoint.revision === 1) return { source }; + return { + checkpoint: { + ...checkpoint, + phase: "copy", + revision: checkpoint.revision + 1, + }, + }; + }, + ); + mocks.heartbeat.mockImplementation(async () => { + clock += 7; + return true; + }); + mocks.saveCheckpoint.mockImplementation(async (_attempt, checkpoint) => { + clock += 20; + withCurrent({ output: checkpoint }); + return true; + }); + mocks.persist.mockImplementation(async (_attempt, savedSource) => { + clock += 30; + withCurrent({ source: savedSource, state: "processing" }); + return true; + }); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + try { + expect(await commitDesktopRecordingAttempt(fixture)).toBe("ready"); + const events = info.mock.calls + .map(([line]) => JSON.parse(String(line))) + .filter((event) => event.event === "desktop_source_commit_advance"); + expect(events).toEqual([ + { + event: "desktop_source_commit_advance", + videoId: "video", + attemptId: "attempt", + phase: "plan", + next: "copy", + outcome: "progress", + loadMs: 0, + heartbeatMs: 7, + advanceMs: 112, + persistMs: 20, + }, + { + event: "desktop_source_commit_advance", + videoId: "video", + attemptId: "attempt", + phase: "copy", + next: "committed", + outcome: "ready", + loadMs: 0, + heartbeatMs: 7, + advanceMs: 112, + persistMs: 30, + }, + ]); + expect(JSON.stringify(events)).not.toContain(source.inventoryKey); + } finally { + info.mockRestore(); + now.mockRestore(); + } + }); + + it("dispatches in the same step when the commit finishes inside its batch budget", async () => { + withCurrent({ source: null }); + mocks.commitSource.mockResolvedValue({ source }); + await expect( + commitAndStartDesktopRecordingAttempt(fixture), + ).resolves.toEqual({ + committed: "ready", + dispatched: true, + started: "remote-job", + }); + expect(mocks.persist).toHaveBeenCalledOnce(); + expect(mocks.fetch).toHaveBeenCalledOnce(); + }); + + it("returns the accepted job without committing or dispatching again when the combined step is retried", async () => { + withCurrent({ source: null }); + mocks.commitSource.mockResolvedValue({ source }); + mocks.attach.mockImplementation( + async ({ remoteJobId }: { remoteJobId: string }) => { + withCurrent({ remoteJobId }); + return true; + }, + ); + await expect( + commitAndStartDesktopRecordingAttempt(fixture), + ).resolves.toEqual({ + committed: "ready", + dispatched: true, + started: "remote-job", + }); + await expect( + commitAndStartDesktopRecordingAttempt(fixture), + ).resolves.toEqual({ + committed: "ready", + dispatched: true, + started: "remote-job", + }); + expect(mocks.commitSource).toHaveBeenCalledOnce(); + expect(mocks.persist).toHaveBeenCalledOnce(); + expect(mocks.fetch).toHaveBeenCalledOnce(); + expect(mocks.sourceUrls).toHaveBeenCalledOnce(); + }); + + it("leaves dispatch to its own step after a slow commit", async () => { + withCurrent({ source: null }); + mocks.commitSource.mockImplementation(async () => { + vi.setSystemTime(Date.now() + 15_000); + return { source }; + }); + await expect( + commitAndStartDesktopRecordingAttempt(fixture), + ).resolves.toEqual({ committed: "ready" }); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("does not dispatch while the source commit is still in progress", async () => { + withCurrent({ source: null }); + mocks.commitSource.mockImplementation(async (_video, checkpoint) => { + vi.setSystemTime(Date.now() + 15_000); + return advanceCheckpoint(_video, checkpoint); + }); + await expect( + commitAndStartDesktopRecordingAttempt(fixture), + ).resolves.toEqual({ committed: "progress" }); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + it("includes checkpoint persistence latency in the batch budget", async () => { mocks.commitSource.mockImplementation(advanceCheckpoint); mocks.saveCheckpoint.mockImplementation(async (_attempt, checkpoint) => { @@ -867,6 +1041,57 @@ describe("source commitment and media request compatibility", () => { ); }); + function withSourceBytes(size: number) { + mocks.sourceUrls.mockResolvedValue({ + videoInitUrl: "https://source.test/init.mp4", + videoSegmentUrls: ["https://source.test/segment.m4s"], + sourceObjects: [ + { + url: "https://source.test/segment.m4s", + objectIdentity: '"segment"', + size, + }, + ], + }); + } + + it("uploads a small Cap storage output with one PUT instead of multipart", async () => { + mocks.storage.mockImplementation(() => storageAccess("s3")); + withSourceBytes(64 * 1024 * 1024); + await startDesktopRecordingJob(fixture); + const body = JSON.parse(mocks.fetch.mock.calls[0]?.[1]?.body ?? "{}"); + expect(body.outputUpload).toEqual({ + type: "put", + url: "https://storage.test/user/video/.recording/outputs/generation/attempt.mp4", + ifNoneMatch: "*", + }); + expect(mocks.multipartCreate).not.toHaveBeenCalled(); + }); + + it("keeps multipart output uploads for recordings larger than one part", async () => { + mocks.storage.mockImplementation(() => storageAccess("s3")); + withSourceBytes(64 * 1024 * 1024 + 1); + await startDesktopRecordingJob(fixture); + const body = JSON.parse(mocks.fetch.mock.calls[0]?.[1]?.body ?? "{}"); + expect(body.outputUpload).toMatchObject({ + type: "multipart", + uploadId: "upload", + partSize: 64 * 1024 * 1024, + signPartUrl: + "https://cap.test/api/webhooks/media-server/multipart/sign-part", + }); + expect(mocks.multipartCreate).toHaveBeenCalledOnce(); + }); + + it("keeps multipart output uploads for small recordings in a custom bucket", async () => { + mocks.storage.mockImplementation(() => storageAccess("s3", true)); + withSourceBytes(1024); + await startDesktopRecordingJob(fixture); + const body = JSON.parse(mocks.fetch.mock.calls[0]?.[1]?.body ?? "{}"); + expect(body.outputUpload).toMatchObject({ type: "multipart" }); + expect(mocks.multipartCreate).toHaveBeenCalledOnce(); + }); + it("reuses a recorded remote job after a step replay instead of submitting another mux", async () => { withCurrent({ remoteJobId: "existing-worker" }); expect(await startDesktopRecordingJob(fixture)).toBe("existing-worker"); @@ -1091,6 +1316,40 @@ describe("workflow retry lifetime", () => { ); }); + it.each([ + ["uses the generation it was started with", "generation", 0], + ["looks the job up when started without a generation", undefined, 1], + ])("%s", async (_name, generation, lookups) => { + withCurrent({ + state: "queued", + leaseExpiresAt: null, + attemptId: null, + attemptCount: 0, + }); + mocks.env.mockReturnValue({ WEB_URL: "https://self-hosted.test" }); + const result = await finalizeDesktopRecordingWorkflow({ + videoId, + userId, + generation, + }); + expect(result).toEqual({ + success: false, + reason: "media-server-unconfigured", + }); + expect(mocks.ensure).toHaveBeenCalledTimes(lookups); + expect(mocks.defer).toHaveBeenCalledOnce(); + }); + + it("reports a missing media server from the dispatch step without dispatching", async () => { + mocks.env.mockReturnValue({ WEB_URL: "https://self-hosted.test" }); + await expect(startDesktopRecordingJob(fixture)).resolves.toEqual({ + status: "media-server-unconfigured", + }); + expect(mocks.defer).toHaveBeenCalledOnce(); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.sourceUrls).not.toHaveBeenCalled(); + }); + it("preserves the self-hosted playback fallback without issuing a verified cleanup receipt", async () => { withCurrent({ state: "queued", diff --git a/apps/web/__tests__/unit/folder-access.test.ts b/apps/web/__tests__/unit/folder-access.test.ts new file mode 100644 index 00000000000..eff4d9f69bd --- /dev/null +++ b/apps/web/__tests__/unit/folder-access.test.ts @@ -0,0 +1,100 @@ +import { Organisation, Space, User } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + organizationAccess: vi.fn(), + spaceAccess: vi.fn(), +})); + +vi.mock("@/actions/organization/authorization", () => ({ + getOrganizationAccess: mocks.organizationAccess, +})); +vi.mock("@/actions/organization/space-authorization", () => ({ + getSpaceAccess: mocks.spaceAccess, +})); + +import { canViewFolder } from "@/actions/folders/folder-access"; + +const userId = User.UserId.make("user-1"); +const otherUserId = User.UserId.make("user-2"); +const organizationId = Organisation.OrganisationId.make("org-1"); +const spaceId = Space.SpaceId.make("space-1"); + +describe("canViewFolder", () => { + beforeEach(() => { + mocks.organizationAccess.mockReset(); + mocks.spaceAccess.mockReset(); + }); + + it("limits personal folders to their creator", async () => { + const folder = { organizationId, spaceId: null, createdById: userId }; + + await expect(canViewFolder(userId, folder)).resolves.toBe(true); + await expect(canViewFolder(otherUserId, folder)).resolves.toBe(false); + expect(mocks.organizationAccess).not.toHaveBeenCalled(); + expect(mocks.spaceAccess).not.toHaveBeenCalled(); + }); + + it("allows organization members into organization folders", async () => { + const folder = { + organizationId, + spaceId: organizationId, + createdById: otherUserId, + }; + + mocks.organizationAccess.mockResolvedValueOnce({ role: "member" }); + await expect(canViewFolder(userId, folder)).resolves.toBe(true); + + mocks.organizationAccess.mockResolvedValueOnce(null); + await expect(canViewFolder(userId, folder)).resolves.toBe(false); + expect(mocks.organizationAccess).toHaveBeenCalledWith( + userId, + organizationId, + ); + expect(mocks.spaceAccess).not.toHaveBeenCalled(); + }); + + it("requires an organization or space role for space folders", async () => { + const folder = { organizationId, spaceId, createdById: otherUserId }; + + mocks.spaceAccess.mockResolvedValueOnce({ + organizationId, + organizationRole: null, + spaceRole: "member", + }); + await expect(canViewFolder(userId, folder)).resolves.toBe(true); + + mocks.spaceAccess.mockResolvedValueOnce({ + organizationId, + organizationRole: "admin", + spaceRole: null, + }); + await expect(canViewFolder(userId, folder)).resolves.toBe(true); + + mocks.spaceAccess.mockResolvedValueOnce({ + organizationId, + organizationRole: null, + spaceRole: null, + }); + await expect(canViewFolder(userId, folder)).resolves.toBe(false); + + mocks.spaceAccess.mockResolvedValueOnce(null); + await expect(canViewFolder(userId, folder)).resolves.toBe(false); + }); + + it("rejects space folders whose space belongs to another organization", async () => { + mocks.spaceAccess.mockResolvedValueOnce({ + organizationId: Organisation.OrganisationId.make("org-2"), + organizationRole: "owner", + spaceRole: "admin", + }); + + await expect( + canViewFolder(userId, { + organizationId, + spaceId, + createdById: otherUserId, + }), + ).resolves.toBe(false); + }); +}); diff --git a/apps/web/__tests__/unit/hipaa-compliant-screen-recording-page.test.ts b/apps/web/__tests__/unit/hipaa-compliant-screen-recording-page.test.ts index a756172802d..5d22c146067 100644 --- a/apps/web/__tests__/unit/hipaa-compliant-screen-recording-page.test.ts +++ b/apps/web/__tests__/unit/hipaa-compliant-screen-recording-page.test.ts @@ -59,6 +59,13 @@ describe("HipaaCompliantScreenRecordingPage component content", () => { expect(componentSource).toContain("comparisonTable"); }); + it("describes the license split in the comparison table", () => { + expect(componentSource).toContain( + '{ text: "Yes — AGPLv3 and MIT crates", status: "positive" }', + ); + expect(componentSource).not.toContain('{ text: "Yes — MIT"'); + }); + it("includes recording modes section", () => { expect(componentSource).toContain("recordingModes"); }); @@ -107,7 +114,8 @@ describe("HipaaCompliantScreenRecordingPage FAQ schema", () => { { question: "Is Cap open source and auditable for HIPAA vendor assessments?", - answer: "Yes. Cap is MIT-licensed and fully open source on GitHub.", + answer: + "Yes. Most Cap code is under AGPLv3, with specified Rust crates under MIT. The source is available on GitHub.", }, { question: "Can I disable AI transcription in Cap for HIPAA compliance?", diff --git a/apps/web/__tests__/unit/homepage-seo.test.ts b/apps/web/__tests__/unit/homepage-seo.test.ts index aeaadf3cc99..82e6735a817 100644 --- a/apps/web/__tests__/unit/homepage-seo.test.ts +++ b/apps/web/__tests__/unit/homepage-seo.test.ts @@ -3,7 +3,15 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import robots from "@/app/robots"; -import { homePageMetadata } from "@/components/pages/HomeTwo/metadata"; +import { DISCORD_URL } from "@/components/pages/about/content"; +import { + DiscordLinkPreview, + discordLinkPreview, +} from "@/components/pages/HomeTwo/DiscordLinkPreview"; +import { + homePageMetadata, + homepageOgImageUrl, +} from "@/components/pages/HomeTwo/metadata"; import { HomeTwoSchema } from "@/components/pages/HomeTwo/Schema"; import { homepageSchema, homepageSeo } from "@/components/pages/HomeTwo/seo"; import { PRICING } from "@/data/pricing"; @@ -81,4 +89,31 @@ describe("homepage SEO", () => { expect(JSON.parse(json ?? "")).toEqual(homepageSchema); expect(homePageMetadata.description).toBe(homepageSeo.description); }); + + it("renders a Discord component link preview with a community button", () => { + const html = renderToStaticMarkup(createElement(DiscordLinkPreview)); + const json = html.match( + / + ); +} diff --git a/apps/web/components/pages/HomeTwo/Hero.tsx b/apps/web/components/pages/HomeTwo/Hero.tsx index 29517df3deb..2b45c665a65 100644 --- a/apps/web/components/pages/HomeTwo/Hero.tsx +++ b/apps/web/components/pages/HomeTwo/Hero.tsx @@ -122,37 +122,37 @@ export const Hero = () => { />
trackEvent("release_notice_clicked", { source_page: "home_hero", - release: "v0.6", + release: "v0.6.1", }) } - className="group mb-7 inline-flex max-w-full items-center gap-2.5 rounded-full bg-[#111111] py-1.5 pl-1.5 pr-3.5 text-[13px] leading-none text-white shadow-[0_1px_2px_rgba(17,17,17,0.12),0_10px_24px_-14px_rgba(17,17,17,0.6)] transition-colors duration-200 hover:bg-[#2A2A2A] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#111111] focus-visible:ring-offset-2 focus-visible:ring-offset-[#EDF1F6] md:mb-9" + className="ht-rise group mb-7 inline-flex [--d:0.2s] max-w-full items-center gap-2.5 rounded-full bg-[#111111] py-1.5 pl-1.5 pr-3.5 text-[13px] leading-none text-white shadow-[0_1px_2px_rgba(17,17,17,0.12),0_10px_24px_-14px_rgba(17,17,17,0.6)] transition-colors duration-200 hover:bg-[#2A2A2A] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#111111] focus-visible:ring-offset-2 focus-visible:ring-offset-[#EDF1F6] md:mb-9" > New - Cap v0.6 is here + Cap's migration to GPUI - See what's new + Read the deep dive

- One open source app for every way you capture your screen. Start free - and move to Cap Pro when you need more, whether you work alone, with a - small team, or across an entire organization. + Record your screen, then share it as a link the moment you stop or + polish it in the built-in editor. Trusted by over 80,000 teams and + creators, and free to start.

-
+
@@ -207,7 +207,7 @@ export const Hero = () => { cta_location: "under_primary", }) } - className="group mt-5 inline-flex items-center gap-2 rounded-[6px] text-[14px] text-[rgba(17,17,17,0.55)] transition-colors duration-200 hover:text-[#111111] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#111111] focus-visible:ring-offset-4 focus-visible:ring-offset-[#F8FAFC]" + className="ht-rise group mt-5 inline-flex items-center gap-2 rounded-[6px] [--d:1.05s] text-[14px] text-[rgba(17,17,17,0.55)] transition-colors duration-200 hover:text-[#111111] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#111111] focus-visible:ring-offset-4 focus-visible:ring-offset-[#F8FAFC]" > { -
+
Also available on {isWindows || isLinux ? ( diff --git a/apps/web/components/pages/HomeTwo/HeroHeadline.tsx b/apps/web/components/pages/HomeTwo/HeroHeadline.tsx index 74b54489c27..f76fb8b174f 100644 --- a/apps/web/components/pages/HomeTwo/HeroHeadline.tsx +++ b/apps/web/components/pages/HomeTwo/HeroHeadline.tsx @@ -14,15 +14,15 @@ type SlideKey = ModeKey | "all"; type Slide = { key: SlideKey; - - words: [string, string, string]; + /** The end of "The open source screen recorder for …". */ + tail: string; }; const SLIDES: Slide[] = [ - { key: "all", words: ["Record.", "Edit.", "Share."] }, - { key: "instant", words: ["Record.", "Stop.", "Instant Share."] }, - { key: "studio", words: ["Record.", "Edit.", "Export."] }, - { key: "screenshot", words: ["Screenshot.", "Style.", "Share."] }, + { key: "all", tail: "everyone." }, + { key: "instant", tail: "quick shares." }, + { key: "studio", tail: "polished videos." }, + { key: "screenshot", tail: "screenshots, too." }, ]; const NUDGE = "Click me \u{1F440}"; @@ -52,8 +52,6 @@ const PILL_ICON = { screenshot: ScreenshotIcon, } as const; -const SLOT_DELAY = [0, 120, 240]; - const STRIKE_MS = 260; const HOLD_MS = 110; @@ -253,7 +251,7 @@ export const HeroHeadline = () => { {CSS} -
+
{PILLS.map((pill) => { const active = index === pill.slide; @@ -303,25 +301,27 @@ export const HeroHeadline = () => {
-

+

- Record. Edit. Share. Cap has three recording modes: Instant, Studio, - and Screenshot. + The open source screen recorder for everyone. Cap has three recording + modes: Instant, Studio, and Screenshot. -

@@ -329,9 +329,36 @@ export const HeroHeadline = () => { }; const CSS = ` +/* The hero's entrance, staggered by --d on each piece. */ +.ht-rise { animation: ht-rise 1.1s cubic-bezier(0.16, 1, 0.3, 1) var(--d, 0s) both; } +.ht-rise-soft { animation-name: ht-rise-soft; animation-duration: 1.3s; } +@keyframes ht-rise { + from { opacity: 0; transform: translate3d(0, 14px, 0); } + to { opacity: 1; transform: none; } +} +@keyframes ht-rise-soft { + from { opacity: 0; transform: translate3d(0, 18px, 0); filter: blur(10px); } + to { opacity: 1; transform: none; filter: blur(0); } +} + /* The site's base sheet pins spans to line-height 1.5rem, which would collapse the slot line boxes. Restore the H_HERO leading. */ .ht-headline, .ht-headline span { line-height: 0.98; } +.ht-tail { + margin-top: 0.06em; + font-family: var(--font-ht-serif), Georgia, serif; + font-style: italic; + font-weight: 400; + font-size: 1.06em; + letter-spacing: -0.025em; + white-space: nowrap; +} +/* Never let "for" and the ending split across lines: on narrow phones the + line shrinks until the longest ending ("for screenshots, too.", about + 8.6em wide) fits inside the hero's padding. */ +@media (max-width: 639px) { + .ht-tail { font-size: min(1.06em, calc((100vw - 64px) / 8.8)); } +} .ht-slot { position: relative; @@ -471,6 +498,7 @@ const CSS = ` @media (min-width: 1024px) { .ht-nudge { right: 94%; } } @media (prefers-reduced-motion: reduce) { + .ht-rise { animation: none; } .ht-slot { transition: none; } .ht-nudge, .ht-nudge-chip { transition: none; } .ht-strike, .ht-out, .ht-in, .ht-caret { animation: none; } diff --git a/apps/web/components/pages/HomeTwo/HeroSky.tsx b/apps/web/components/pages/HomeTwo/HeroSky.tsx index f9a7ebd4589..be1c23bc0dc 100644 --- a/apps/web/components/pages/HomeTwo/HeroSky.tsx +++ b/apps/web/components/pages/HomeTwo/HeroSky.tsx @@ -1,59 +1,66 @@ "use client"; import { classNames } from "@cap/utils/helpers"; -import Image from "next/image"; import { useRef } from "react"; -import { BAND, GRAIN } from "./theme"; +import { BAND } from "./theme"; import { useInView, usePageVisible } from "./visibility"; +/** + * The desktop app's welcome screen sky + * (apps/desktop-gpui/src/onboarding_window.rs, `welcome_sky`) over the + * landing page's clouds: light rays behind and in front of them, a sun in + * the top right, moving film grain, and an entrance that lifts a pale wash + * off it all. + */ + const ART = { - cumulusA: { src: "/backgrounds/clouds/cumulus-a.webp", w: 896, h: 387 }, - cumulusB: { src: "/backgrounds/clouds/cumulus-b.webp", w: 760, h: 360 }, - tuft: { src: "/backgrounds/clouds/tuft-a.webp", w: 600, h: 300 }, - bank: { src: "/backgrounds/clouds/bank-a.webp", w: 864, h: 241 }, - wisp: { src: "/backgrounds/clouds/wisp-a.webp", w: 860, h: 203 }, + cumulusA: { src: "/backgrounds/clouds/cumulus-a.webp", aspect: 387 / 896 }, + cumulusB: { src: "/backgrounds/clouds/cumulus-b.webp", aspect: 360 / 760 }, + tuft: { src: "/backgrounds/clouds/tuft-a.webp", aspect: 300 / 600 }, + bank: { src: "/backgrounds/clouds/bank-a.webp", aspect: 241 / 864 }, + wisp: { src: "/backgrounds/clouds/wisp-a.webp", aspect: 203 / 860 }, } as const; type Art = keyof typeof ART; - -type CloudSpec = { +/** `drift` is seconds per crossing and `offset` how far into it a cloud + * starts, so they never line up. Far clouds sit behind the mist. */ +type Cloud = { art: Art; top: string; width: number; opacity: number; drift: number; offset: number; - bob: number; - near?: boolean; + far?: boolean; }; -const CLOUDS: CloudSpec[] = [ +const CLOUDS: Cloud[] = [ { art: "wisp", top: "4%", width: 300, - opacity: 0.62, + opacity: 0.55, drift: 280, offset: -100, - bob: 19, + far: true, }, { art: "tuft", top: "2%", width: 210, - opacity: 0.62, + opacity: 0.55, drift: 250, offset: -210, - bob: 23, + far: true, }, { art: "bank", top: "11%", width: 340, - opacity: 0.6, + opacity: 0.5, drift: 300, offset: -40, - bob: 20, + far: true, }, { art: "cumulusB", @@ -62,7 +69,6 @@ const CLOUDS: CloudSpec[] = [ opacity: 0.86, drift: 210, offset: -150, - bob: 21, }, { art: "tuft", @@ -71,7 +77,6 @@ const CLOUDS: CloudSpec[] = [ opacity: 0.82, drift: 220, offset: -30, - bob: 25, }, { art: "cumulusA", @@ -80,8 +85,6 @@ const CLOUDS: CloudSpec[] = [ opacity: 0.97, drift: 175, offset: -115, - bob: 27, - near: true, }, { art: "bank", @@ -90,8 +93,6 @@ const CLOUDS: CloudSpec[] = [ opacity: 0.94, drift: 185, offset: -60, - bob: 29, - near: true, }, { art: "wisp", @@ -100,46 +101,39 @@ const CLOUDS: CloudSpec[] = [ opacity: 0.82, drift: 230, offset: -190, - bob: 24, }, ]; -const Cloud = ({ spec }: { spec: CloudSpec }) => { - const art = ART[spec.art]; - const height = Math.round((art.h / art.w) * spec.width); - return ( -
- -
- ); -}; +const CloudSprite = ({ cloud }: { cloud: Cloud }) => ( + // The track is the sky plus the cloud's own width, so each one crosses + // from fully off the left edge to fully off the right. +
+ {/* biome-ignore lint/performance/noImgElement: decorative art on a CSS track */} + +
+); const SKY = [ - "radial-gradient(36% 32% at 70% 2%, rgba(255,224,192,0.8) 0%, rgba(255,224,192,0) 70%)", "radial-gradient(80% 42% at 50% 74%, rgba(255,240,224,0.5) 0%, rgba(255,240,224,0) 72%)", - `linear-gradient(180deg, #C8DDF6 0%, #D9E7F8 34%, #E6EDF6 66%, ${BAND} 100%)`, + `linear-gradient(180deg, #A3BFEA 0%, #C8DBF4 34%, #E2EAF5 64%, ${BAND} 92%)`, ].join(","); export const HeroSky = ({ className }: { className?: string }) => { @@ -152,62 +146,176 @@ export const HeroSky = ({ className }: { className?: string }) => { ref={rootRef} aria-hidden="true" className={classNames( - "pointer-events-none absolute inset-0 overflow-hidden", + "ht-sky pointer-events-none absolute inset-0 overflow-hidden", className ?? "rounded-t-[24px]", drifting ? "" : "ht-sky-idle", )} - style={{ - backgroundColor: BAND, - backgroundImage: SKY, - }} + style={{ backgroundColor: BAND, backgroundImage: SKY }} > -
- {CLOUDS.map((spec) => ( - +
+
+
+
+ {CLOUDS.filter((cloud) => cloud.far).map((cloud) => ( + ))} +
+ {CLOUDS.filter((cloud) => !cloud.far).map((cloud) => ( + + ))} +
+
+
+
+
-
+
+
+
); }; const CSS = ` -.ht-cloud-track { +.ht-cloud { + position: absolute; + will-change: transform; + animation: ht-cloud-drift linear infinite; +} +@keyframes ht-cloud-drift { + from { transform: translate3d(0, 0, 0); } + to { transform: translate3d(100%, 0, 0); } +} + +/* Clouds and light arrive after the wash starts lifting. */ +.ht-clouds, .ht-light { position: absolute; inset: 0; - -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 62%, rgba(0,0,0,0) 96%); - mask-image: linear-gradient(180deg, #000 0%, #000 62%, rgba(0,0,0,0) 96%); + animation: ht-fade-in 2.2s cubic-bezier(0.4, 0, 0.2, 1) 0.25s both; } -.ht-cloud { +.ht-light { animation-duration: 2.8s; animation-delay: 0.45s; } +.ht-clouds { + -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 60%, transparent 94%); + mask-image: linear-gradient(180deg, #000 0%, #000 60%, transparent 94%); +} +@keyframes ht-fade-in { from { opacity: 0; } to { opacity: 1; } } + +.ht-mist { position: absolute; - left: 0; - will-change: transform; - animation-name: ht-cloud-drift; - animation-timing-function: linear; - animation-iteration-count: infinite; + left: -10%; + top: 0; + width: 120%; + height: 150%; + background: + radial-gradient(66.7% 15% at 41.7% 35%, rgba(255,255,255,0.28) 0%, rgba(244,248,253,0.12) 50%, rgba(244,248,253,0) 100%), + radial-gradient(66.7% 15% at 62.5% 56%, rgba(255,255,255,0.22) 0%, rgba(244,248,253,0.1) 50%, rgba(244,248,253,0) 100%); + animation: ht-mist 44s ease-in-out infinite alternate; } -.ht-cloud-bob { - animation-name: ht-cloud-bob; - animation-timing-function: ease-in-out; - animation-iteration-count: infinite; - animation-direction: alternate; +@keyframes ht-mist { + from { transform: translate3d(-20px, 0, 0); } + to { transform: translate3d(20px, 0, 0); } } -@keyframes ht-cloud-drift { - from { transform: translate3d(-100%, 0, 0); } - to { transform: translate3d(100vw, 0, 0); } + +/* Shafts fan down and to the left from just above the top right corner, + baked from the welcome screen's blurred SVGs (a live SVG filter on a + stretched background can drop its blur when Chrome re-rasterises it). + Each layer sways and breathes on its own period so they never line up. */ +.ht-rays { + position: absolute; + top: 0; + bottom: 0; + left: -40px; + right: -40px; + background-size: 100% 100%; + background-repeat: no-repeat; +} +.ht-rays-far { + background-image: url(/backgrounds/hero/rays-far.webp); + opacity: 0.75; + animation: ht-sway-far 16s ease-in-out infinite alternate, + ht-breathe 10s ease-in-out infinite alternate; +} +.ht-rays-near { + background-image: url(/backgrounds/hero/rays-near.webp); + opacity: 0.7; + animation: ht-sway-near 21s ease-in-out -4s infinite alternate, + ht-breathe 13s ease-in-out -6s infinite alternate; +} +@keyframes ht-sway-far { + from { transform: translate3d(-14px, 0, 0); } + to { transform: translate3d(14px, 0, 0); } +} +@keyframes ht-sway-near { + from { transform: translate3d(18px, 0, 0); } + to { transform: translate3d(-18px, 0, 0); } +} +@keyframes ht-breathe { + from { opacity: 0.62; } + to { opacity: 1; } } -@keyframes ht-cloud-bob { - from { transform: translate3d(0, -5px, 0); } - to { transform: translate3d(0, 5px, 0); } + +.ht-sun { + position: absolute; + left: 84%; + top: -6%; + width: 75%; + aspect-ratio: 1; + transform: translate(-50%, -50%); + background: radial-gradient(closest-side, rgba(255,248,236,0.95) 0%, rgba(255,227,196,0.5) 35%, rgba(255,227,196,0) 100%); + animation: ht-sun 9s ease-in-out infinite alternate; +} +@keyframes ht-sun { from { opacity: 0.8; } to { opacity: 1; } } + +/* Darkens the top corners only; the bottom has to meet the band cleanly. */ +.ht-vignette { + position: absolute; + inset: 0; + background: radial-gradient(75% 75% at 50% 50%, rgba(52,80,122,0) 50%, rgba(52,80,122,0.14) 100%); + -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 35%, transparent 75%); + mask-image: linear-gradient(180deg, #000 0%, #000 35%, transparent 75%); } -.ht-sky-idle .ht-cloud, .ht-sky-idle .ht-cloud-bob { animation-play-state: paused; } +.ht-ground { + position: absolute; + inset: auto 0 0 0; + height: 28%; + background: linear-gradient(180deg, ${BAND}00 0%, ${BAND} 100%); +} + +/* Film grain, jumping to a new offset 12 times a second. */ +.ht-grain { + position: absolute; + inset: -200px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.12'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='260' height='260' filter='url(%23n)'/%3E%3C/svg%3E"); + background-size: 260px 260px; + animation: ht-grain 0.84s steps(1, end) infinite; +} +@keyframes ht-grain { + 0% { transform: translate3d(0, 0, 0); } + 10% { transform: translate3d(-97px, 61px, 0); } + 20% { transform: translate3d(43px, -128px, 0); } + 30% { transform: translate3d(-151px, -22px, 0); } + 40% { transform: translate3d(118px, 87px, 0); } + 50% { transform: translate3d(-36px, 143px, 0); } + 60% { transform: translate3d(71px, -59px, 0); } + 70% { transform: translate3d(-122px, -114px, 0); } + 80% { transform: translate3d(160px, 19px, 0); } + 90% { transform: translate3d(-64px, 102px, 0); } +} + +.ht-wash { + position: absolute; + inset: 0; + background: linear-gradient(180deg, #F6F8FB 0%, #F6F8FB 60%, ${BAND} 100%); + animation: ht-wash 1.9s cubic-bezier(0.4, 0, 0.2, 1) 0.05s both; +} +@keyframes ht-wash { from { opacity: 1; } to { opacity: 0; } } + +.ht-sky-idle *, .ht-sky-idle *::before { animation-play-state: paused; } @media (prefers-reduced-motion: reduce) { - .ht-cloud, .ht-cloud-bob { animation-play-state: paused; } + .ht-sky * { animation: none !important; } + .ht-wash { opacity: 0; } } `; diff --git a/apps/web/components/pages/HomeTwo/fonts.ts b/apps/web/components/pages/HomeTwo/fonts.ts index 7ca3b75d704..da3b8594212 100644 --- a/apps/web/components/pages/HomeTwo/fonts.ts +++ b/apps/web/components/pages/HomeTwo/fonts.ts @@ -15,7 +15,7 @@ export const htSans = Instrument_Sans({ export const htSerif = Source_Serif_4({ subsets: ["latin"], weight: ["300", "400"], - style: ["normal"], + style: ["normal", "italic"], display: "swap", variable: "--font-ht-serif", }); diff --git a/apps/web/components/pages/HomeTwo/index.tsx b/apps/web/components/pages/HomeTwo/index.tsx index 6dcbf4b079e..aa5f4a8a4bd 100644 --- a/apps/web/components/pages/HomeTwo/index.tsx +++ b/apps/web/components/pages/HomeTwo/index.tsx @@ -1,5 +1,6 @@ import { Agents } from "@/components/pages/HomeTwo/Agents"; import { DeepDives } from "@/components/pages/HomeTwo/DeepDives"; +import { DiscordLinkPreview } from "@/components/pages/HomeTwo/DiscordLinkPreview"; import { Faq } from "@/components/pages/HomeTwo/Faq"; import { Features } from "@/components/pages/HomeTwo/Features"; import { FinalCta } from "@/components/pages/HomeTwo/FinalCta"; @@ -28,7 +29,8 @@ export function HomeTwoPage() { style={grainBg(SHELL)} > -
+ +
{ const allUpdates = getBlogPosts() as BlogPost[]; @@ -56,12 +56,42 @@ export const UpdatesPage = () => { className="overflow-hidden w-full rounded-xl border transition-shadow bg-gray-1 border-gray-5 hover:shadow-md" > - {post.metadata.image ? ( + {"video" in post.metadata && post.metadata.video ? ( +
+ {/* The animated cover from og:video. Its first frame is + saved next to it as the poster. */} +
+ ) : post.metadata.image ? (
{post.metadata.title} ( - Trusted by 50,000+ users + Trusted by 80,000+ users Trusted by teams and creators diff --git a/apps/web/components/pages/seo/AsyncVideoCodeReviewsPage.tsx b/apps/web/components/pages/seo/AsyncVideoCodeReviewsPage.tsx index b7903003862..e1e8d371cd0 100644 --- a/apps/web/components/pages/seo/AsyncVideoCodeReviewsPage.tsx +++ b/apps/web/components/pages/seo/AsyncVideoCodeReviewsPage.tsx @@ -136,7 +136,7 @@ export const asyncVideoCodeReviewsContent: SeoPageContent = { ], [ "Open source", - { text: "Yes — MIT", status: "positive" }, + { text: "Yes — AGPLv3 and MIT crates", status: "positive" }, { text: "No", status: "negative" }, { text: "Open source (GitHub)", status: "positive" }, { text: "No", status: "negative" }, @@ -159,7 +159,7 @@ export const asyncVideoCodeReviewsContent: SeoPageContent = { { title: "Cap vs Loom for Code Reviews", description: - "Loom is a capable async video tool but limits recording length and quality on its free plan, and stores all video on Loom's servers with no self-hosting option. Cap records at 4K with no recording-length limits in Studio Mode and supports self-hosted S3 storage for teams with data residency requirements. Cap is also MIT-licensed and fully open source.", + "Loom is a capable async video tool but limits recording length and quality on its free plan, and stores all video on Loom's servers with no self-hosting option. Cap records at 4K with no recording-length limits in Studio Mode and supports self-hosted S3 storage for teams with data residency requirements. Cap is also open source, with most code under AGPLv3 and specified Rust crates under MIT.", }, { title: "Cap vs Written PR Comments", @@ -271,7 +271,7 @@ export const asyncVideoCodeReviewsContent: SeoPageContent = { { question: "What is the best tool for async video code reviews?", answer: - "Cap is the best tool for async video code reviews for engineering teams that want 4K quality, instant shareable links, timestamped commenting, and the option to self-host recordings. It's free, open-source under the MIT license, and available for Mac and Windows. Unlike general async video tools, Cap's instant-mode and studio-mode workflows are optimized for the review cycle engineers actually use.", + "Cap is the best tool for async video code reviews for engineering teams that want 4K quality, instant shareable links, timestamped commenting, and the option to self-host recordings. It's free and open source, with most code under AGPLv3, and available for Mac and Windows. Unlike general async video tools, Cap's instant-mode and studio-mode workflows are optimized for the review cycle engineers actually use.", }, ], diff --git a/apps/web/components/pages/seo/DeveloperDocumentationVideosPage.tsx b/apps/web/components/pages/seo/DeveloperDocumentationVideosPage.tsx index acaf4921d29..f75801c207b 100644 --- a/apps/web/components/pages/seo/DeveloperDocumentationVideosPage.tsx +++ b/apps/web/components/pages/seo/DeveloperDocumentationVideosPage.tsx @@ -130,7 +130,7 @@ export const developerDocumentationVideosContent: SeoPageContent = { ], [ "Open source", - { text: "Yes — MIT", status: "positive" }, + { text: "Yes — AGPLv3 and MIT crates", status: "positive" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, @@ -160,7 +160,7 @@ export const developerDocumentationVideosContent: SeoPageContent = { { title: "Cap vs Loom for Developer Documentation", description: - "Loom is a capable async video tool but limits recording quality and length on its free plan, and has no self-hosting option for teams with data residency requirements. Cap records at 4K with no recording-length limits in Studio Mode and supports self-hosted S3 storage. Cap is also fully open source under the MIT license — developers can audit the code, contribute features, and trust the tool they're using to document their own products.", + "Loom is a capable async video tool but limits recording quality and length on its free plan, and has no self-hosting option for teams with data residency requirements. Cap records at 4K with no recording-length limits in Studio Mode and supports self-hosted S3 storage. Cap is also open source, with most code under AGPLv3 and specified Rust crates under MIT — developers can audit the code, contribute features, and trust the tool they're using to document their own products.", }, { title: "Cap vs Written Documentation", @@ -265,7 +265,7 @@ export const developerDocumentationVideosContent: SeoPageContent = { { question: "What is the best screen recorder for developer documentation?", answer: - "Cap is the best screen recorder for developer documentation because it combines 4K recording quality, instant shareable links, AI-generated transcripts, and self-hosted storage in a single free, open-source tool. Unlike general-purpose video tools, Cap is optimized for the async sharing workflows developers actually use — paste a link in a README, embed in a docs site, or share in a PR comment. It's free, MIT-licensed, and available for Mac and Windows.", + "Cap is the best screen recorder for developer documentation because it combines 4K recording quality, instant shareable links, AI-generated transcripts, and self-hosted storage in a single free, open-source tool. Unlike general-purpose video tools, Cap is optimized for the async sharing workflows developers actually use — paste a link in a README, embed in a docs site, or share in a PR comment. It's free and open source, with most code under AGPLv3, and available for Mac and Windows.", }, { question: "Does Cap work for recording API demos and SDK walkthroughs?", diff --git a/apps/web/components/pages/seo/GoogleDriveScreenRecorderPage.tsx b/apps/web/components/pages/seo/GoogleDriveScreenRecorderPage.tsx index 76eacc6a0af..d0bd5767659 100644 --- a/apps/web/components/pages/seo/GoogleDriveScreenRecorderPage.tsx +++ b/apps/web/components/pages/seo/GoogleDriveScreenRecorderPage.tsx @@ -134,7 +134,7 @@ const comparisonTable: { ], [ "Open source", - { text: "Yes, MIT licensed", status: "positive" }, + { text: "Yes, AGPLv3 and MIT crates", status: "positive" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, ], diff --git a/apps/web/components/pages/seo/HipaaCompliantScreenRecordingPage.tsx b/apps/web/components/pages/seo/HipaaCompliantScreenRecordingPage.tsx index b794d7488dc..a3a6de507ca 100644 --- a/apps/web/components/pages/seo/HipaaCompliantScreenRecordingPage.tsx +++ b/apps/web/components/pages/seo/HipaaCompliantScreenRecordingPage.tsx @@ -27,7 +27,7 @@ export const hipaaCompliantScreenRecordingContent: SeoPageContent = { { title: "Open-Source and Auditable", description: - "Cap is MIT-licensed and fully open source on GitHub. Your security and compliance teams can audit every line of code. No black-box behavior, no undisclosed data collection — full transparency for your compliance reviews and vendor assessments.", + "Cap is open source on GitHub, with most code under AGPLv3 and specified Rust crates under MIT. Your security and compliance teams can audit every line of code. No black-box behavior, no undisclosed data collection — full transparency for your compliance reviews and vendor assessments.", }, { title: "Access-Controlled Sharing", @@ -107,7 +107,7 @@ export const hipaaCompliantScreenRecordingContent: SeoPageContent = { ], [ "Open-source code", - { text: "Yes — MIT", status: "positive" }, + { text: "Yes — AGPLv3 and MIT crates", status: "positive" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, @@ -257,7 +257,7 @@ export const hipaaCompliantScreenRecordingContent: SeoPageContent = { question: "Is Cap open source and auditable for HIPAA vendor assessments?", answer: - "Yes. Cap is MIT-licensed and fully open source on GitHub. Your security and compliance teams can audit the complete source code to verify data handling behavior, network calls, and storage logic. This transparency simplifies HIPAA vendor security reviews compared to closed-source recording tools.", + "Yes. Cap is open source on GitHub, with most code under AGPLv3 and specified Rust crates under MIT. Your security and compliance teams can audit the complete source code to verify data handling behavior, network calls, and storage logic. This transparency simplifies HIPAA vendor security reviews compared to closed-source recording tools.", }, { question: "Can I disable AI transcription in Cap for HIPAA compliance?", diff --git a/apps/web/components/pages/seo/ObsAlternativePage.tsx b/apps/web/components/pages/seo/ObsAlternativePage.tsx index 545a35a654e..67cb6fa2bed 100644 --- a/apps/web/components/pages/seo/ObsAlternativePage.tsx +++ b/apps/web/components/pages/seo/ObsAlternativePage.tsx @@ -53,7 +53,7 @@ export const obsAlternativeContent: SeoPageContent = { { title: "Open Source Like OBS", description: - "Cap is MIT-licensed and fully open source on GitHub, just like OBS. You can audit the code, contribute features, and self-host the entire platform. You get OBS's commitment to transparency with the usability of a modern sharing tool.", + "Cap is open source on GitHub, with most code under AGPLv3 and specified Rust crates under MIT. You can audit the code, contribute features, and self-host the entire platform. You get OBS's commitment to transparency with the usability of a modern sharing tool.", }, ], @@ -117,7 +117,7 @@ export const obsAlternativeContent: SeoPageContent = { ], [ "Open source", - { text: "Yes — MIT licensed", status: "positive" }, + { text: "Yes — AGPLv3 and MIT crates", status: "positive" }, { text: "Yes — GPL licensed", status: "positive" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, @@ -250,7 +250,7 @@ export const obsAlternativeContent: SeoPageContent = { { question: "Is Cap open source like OBS?", answer: - "Yes. Cap is fully open source and MIT-licensed on GitHub. You can inspect the complete codebase, contribute features, and self-host the entire platform. Both Cap and OBS are open source — but Cap uses the permissive MIT License while OBS uses the GPL.", + "Yes. Cap's source is available on GitHub. Most Cap code uses AGPLv3, with specified Rust crates under MIT; OBS uses the GPL. You can inspect the code, contribute features, and self-host the platform.", }, { question: "Can Cap do live streaming like OBS?", diff --git a/apps/web/components/pages/seo/OpenSourceScreenRecorderPage.tsx b/apps/web/components/pages/seo/OpenSourceScreenRecorderPage.tsx index 737c7190a29..4d10007e96c 100644 --- a/apps/web/components/pages/seo/OpenSourceScreenRecorderPage.tsx +++ b/apps/web/components/pages/seo/OpenSourceScreenRecorderPage.tsx @@ -16,7 +16,7 @@ export const openSourceScreenRecorderContent: SeoPageContent = { { title: "Fully Open Source on GitHub", description: - "Cap's entire codebase is publicly available and MIT-licensed on GitHub. Inspect every line of code, understand exactly what happens to your recordings, and contribute features or fixes back to the community.", + "Cap's source is publicly available on GitHub. Most code is licensed under AGPLv3, with specified Rust crate families under MIT. Inspect every line of code, understand exactly what happens to your recordings, and contribute features or fixes back to the community.", }, { title: "Self-Host Your Recordings", @@ -87,7 +87,7 @@ export const openSourceScreenRecorderContent: SeoPageContent = { rows: [ [ "Open source", - { text: "Yes — MIT licensed", status: "positive" }, + { text: "Yes — AGPLv3 and MIT crates", status: "positive" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, @@ -212,7 +212,7 @@ export const openSourceScreenRecorderContent: SeoPageContent = { { question: "Is Cap really open source?", answer: - "Yes. Cap is fully open source and MIT-licensed. The complete codebase — desktop app, web app, Rust media processing pipeline, and all backend services — is publicly available on GitHub. You can inspect every line, fork the project, and contribute back to the community.", + "Yes. Cap is open source, with most code under AGPLv3 and specified Rust crates under MIT. The complete codebase — desktop app, web app, Rust media processing pipeline, and all backend services — is publicly available on GitHub. You can inspect every line, fork the project, and contribute back to the community.", }, { question: "Can I self-host Cap's screen recordings?", @@ -247,7 +247,7 @@ export const openSourceScreenRecorderContent: SeoPageContent = { { question: "What license does Cap use?", answer: - "Cap is released under the MIT License, one of the most permissive open-source licenses available. You are free to use, copy, modify, merge, publish, distribute, sublicense, and sell copies of the software. Commercial use is permitted. Attribution is required.", + "Most Cap code is licensed under AGPLv3. The cap-camera* and scap-* Rust crate families are licensed under MIT. See the repository LICENSE for the terms that apply to each part of the project.", }, ], diff --git a/apps/web/components/pages/seo/SelfHostedScreenRecordingPage.tsx b/apps/web/components/pages/seo/SelfHostedScreenRecordingPage.tsx index 37cf1602f6e..e9b00a64e96 100644 --- a/apps/web/components/pages/seo/SelfHostedScreenRecordingPage.tsx +++ b/apps/web/components/pages/seo/SelfHostedScreenRecordingPage.tsx @@ -33,7 +33,7 @@ export const selfHostedScreenRecordingContent: SeoPageContent = { { title: "Open Source and Auditable", description: - "Cap is MIT-licensed and fully open source on GitHub. Your security team can audit every line of code that handles recording, uploading, and sharing. No black-box behavior, no undisclosed data flows — complete transparency for enterprise security reviews.", + "Cap is open source on GitHub, with most code under AGPLv3 and specified Rust crates under MIT. Your security team can audit every line of code that handles recording, uploading, and sharing. No black-box behavior, no undisclosed data flows — complete transparency for enterprise security reviews.", }, { title: "Instant Shareable Links from Your Own Storage", @@ -103,7 +103,7 @@ export const selfHostedScreenRecordingContent: SeoPageContent = { ], [ "Open source", - { text: "Yes — MIT", status: "positive" }, + { text: "Yes — AGPLv3 and MIT crates", status: "positive" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, { text: "No", status: "negative" }, @@ -206,7 +206,7 @@ export const selfHostedScreenRecordingContent: SeoPageContent = { { title: "Self-Hosting Enthusiasts and Privacy Advocates", description: - "Individuals and teams who prefer to own their tools deploy the full Cap platform — web app, API, and storage — on their own VPS or cloud account. MIT-licensed code means no restrictions on how you run it or modify it for your needs.", + "Individuals and teams who prefer to own their tools deploy the full Cap platform — web app, API, and storage — on their own VPS or cloud account. The repository LICENSE explains the terms for modifying and deploying the code.", }, ], @@ -250,7 +250,7 @@ export const selfHostedScreenRecordingContent: SeoPageContent = { { question: "Can I self-host the entire Cap platform, not just storage?", answer: - "Yes. Cap is fully open source under the MIT license. You can deploy the complete Cap platform — including the Next.js web application, API, and all services — on your own infrastructure. See Cap's self-hosting documentation for deployment instructions and configuration options.", + "Yes. Cap is open source, with most code under AGPLv3 and specified Rust crates under MIT. You can deploy the complete Cap platform — including the Next.js web application, API, and all services — on your own infrastructure. See Cap's self-hosting documentation for deployment instructions and configuration options.", }, { question: "Is self-hosted Cap suitable for HIPAA compliance?", @@ -266,7 +266,7 @@ export const selfHostedScreenRecordingContent: SeoPageContent = { { question: "What is the best self-hosted screen recorder?", answer: - "Cap is the best self-hosted screen recorder for teams that need both data control and a modern async video experience. It combines S3-compatible custom storage, instant shareable links, webcam overlay, AI captions, and thread commenting — all in an MIT-licensed open-source package. Unlike local-only recorders, Cap gives you the collaboration layer. Unlike closed-source SaaS tools, Cap gives you full data ownership.", + "Cap is the best self-hosted screen recorder for teams that need both data control and a modern async video experience. It combines S3-compatible custom storage, instant shareable links, webcam overlay, AI captions, and thread commenting in an open-source package with most code under AGPLv3. Unlike local-only recorders, Cap gives you the collaboration layer. Unlike closed-source SaaS tools, Cap gives you full data ownership.", }, ], diff --git a/apps/web/components/pages/seo/VideoRecordingSoftwarePage.tsx b/apps/web/components/pages/seo/VideoRecordingSoftwarePage.tsx index 657506171c3..8726b937213 100644 --- a/apps/web/components/pages/seo/VideoRecordingSoftwarePage.tsx +++ b/apps/web/components/pages/seo/VideoRecordingSoftwarePage.tsx @@ -36,7 +36,7 @@ export const videoRecordingSoftwareContent: SeoPageContent = { { title: "Open Source and Auditable", description: - "Cap is fully open-source on GitHub and MIT-licensed. Inspect every line of code, verify how your recordings are handled, and contribute features back to the community. No black boxes, no vendor lock-in. Learn more about Cap as open-source software.", + "Cap is open source on GitHub, with most code under AGPLv3 and specified Rust crates under MIT. Inspect every line of code, verify how your recordings are handled, and contribute features back to the community. No black boxes, no vendor lock-in. Learn more about Cap as open-source software.", }, { title: "Separate Screen and Webcam Tracks", @@ -249,7 +249,7 @@ export const videoRecordingSoftwareContent: SeoPageContent = { { question: "Is Cap open-source video recording software?", answer: - "Yes. Cap is fully open-source and MIT-licensed. The complete codebase — desktop app, web app, and media processing pipeline — is publicly available on GitHub. You can audit every line of code, fork the project, and contribute back to the community. Learn more about Cap as open-source software.", + "Yes. Cap is open source, with most code under AGPLv3 and specified Rust crates under MIT. The complete codebase — desktop app, web app, and media processing pipeline — is publicly available on GitHub. You can audit every line of code, fork the project, and contribute back to the community. Learn more about Cap as open-source software.", }, ], diff --git a/apps/web/content/blog/rebuilding-cap-in-gpui.mdx b/apps/web/content/blog/rebuilding-cap-in-gpui.mdx new file mode 100644 index 00000000000..6b3ec0690d4 --- /dev/null +++ b/apps/web/content/blog/rebuilding-cap-in-gpui.mdx @@ -0,0 +1,209 @@ +--- +title: "A technical deep dive into Cap's migration to GPUI" +description: Why we moved Cap's desktop app off Tauri and web views, how we rebuilt it in GPUI, and what changed once there was no browser inside it. +publishedAt: "2026-10-21" +category: Technical +image: /blog/rebuilding-cap-in-gpui.jpg +video: /blog/rebuilding-cap-in-gpui.mp4 +author: Richie McIlroy +tags: Engineering, Rust, GPUI, Performance +layout: feature +--- + +Cap 0.6.1 is out. It's the biggest change we've made to the desktop app since we started, and it's the first version that doesn't have a web browser inside it. + + + +Until now, Cap's desktop app was built with [Tauri](https://tauri.app). Tauri is a lot like Electron, in that you build your app's interface with HTML, CSS and JavaScript. The difference is that Electron ships a full copy of Chromium inside every app, while Tauri uses the browser engine that already comes with your operating system, which is called a web view. So every window in Cap was really a small web page, running on Safari's engine on a Mac, Microsoft Edge's on Windows and WebKitGTK on Linux. + +That kept Cap small and fast for a long time. It also meant our interface was running in three different browsers that we didn't control, and a lot of our bugs came from there. Cap's panels overflowed on Windows if you'd changed your text size, windows showed up blank on some Linux graphics drivers, and area selection on WebKit cancelled itself as soon as you started dragging. + +In 0.6.1 every window is drawn on your GPU by Rust code instead, using [GPUI](https://www.gpui.rs), the UI framework the Zed team built for their code editor. It should look and work like the Cap you're used to. + + + +We had the first GPUI build working overnight in August. We could have shipped it sooner, but we wanted to roll it out safely, so it spent a few weeks as an experimental option in 0.6.0 first. The engine mostly came across as it was, with a few changes to get the most out of the new app. + +## Drawn like a game + +If I had to explain it in one line, Cap's interface used to work like a web page, and now it works more like a video game. + +A web page is a document. Each Cap window was a tree of HTML elements, and the browser had to style, lay out, paint and composite all of it, using whatever browser engine your OS shipped with. + +Games work differently. They describe the whole screen and draw it on the GPU each time, and GPUI does the same. When a window changes, GPUI lays it out again and turns it into one flat list of shapes. Cap's main window only uses five kinds of shape. + +- **Shadows.** Blurred rounded rectangles under the window and its cards. +- **Quads.** Every box, with its fill, border and rounded corners. +- **Glyphs.** The text. Each letter is cut out of a texture that stays on the GPU. +- **Icons.** SVGs, rasterized once and reused on every frame. +- **Images.** Full-colour pictures, like the Cap logo. + +That list is sent to the GPU in batches and drawn by a handful of small shaders, using Metal on macOS, Direct3D 11 on Windows and Vulkan on Linux. There's no DOM, CSS engine or JavaScript involved. One thing Cap doesn't copy from games is redrawing 60 times a second for no reason. If a window has nothing new to show, it stops asking for frames (more on that further down). + +Zed wrote about this in more depth in [Leveraging Rust and the GPU to render user interfaces at 120 FPS](https://zed.dev/blog/videogame). + +## Where we started + +Cap has always been made of two parts. + +The first is the engine. It captures your screen, camera, mic and system audio, writes everything to disk without dropping frames, renders your edits on the GPU and encodes your exports. It's been written in Rust from day one, and it lives in a set of crates (Rust's name for libraries) that don't know anything about windows or buttons. + +The second is the interface. Up to 0.6.0 that was a [SolidJS](https://www.solidjs.com) app running inside Tauri. Each Cap window (the main window, the camera bubble, the recording bar, the editor and so on) had its own web view, which is basically a browser tab without the browser around it. The JavaScript running in those web views couldn't touch the recording engine directly, so every click turned into a message to the Rust side, which did the work and sent the result back. + +You also don't ship the web view yourself. On a Mac it's WKWebView, which updates along with Safari and macOS. On Windows it's WebView2, which is built on Microsoft Edge and updates itself in the background. On Linux it's WebKitGTK, in whatever version your distro packages. So the browser running Cap's interface was different on every platform, and it could change without us shipping anything. + +We love Tauri. It let a small team ship a fast recorder on three platforms, and I don't think Cap would be where it is today without it. For a lot of apps it's still the right choice. Cap just ended up needing more control over its interface than a web view can give. + +## Why the rewrite + +### Three operating systems, three browsers + +Using the system web view means you're really using three different browsers. The same CSS and JavaScript went through three different layout engines and three different text renderers, and each of them had its own quirks. These are some of the ones we ran into this year. + +- **Blank windows on Linux.** On some graphics drivers, WebKitGTK's DMA-BUF renderer leaves web view windows completely blank. We had to ship with it turned off. +- **Text size on Windows.** WebView2 picks up its zoom level from the Windows "Text size" setting, so anyone who had turned that up saw Cap's panels overflow. We ended up pinning the zoom through WebView2's own APIs. +- **Moving between monitors on Windows.** If you dragged a window onto a monitor with different scaling, WebView2 didn't always pick up the new scale, so we had to listen for the change and pass it on ourselves. +- **Area selection on WebKit.** When you clicked and dragged to select an area, WebKit could fire a blur event before it had finished capturing the pointer. Our code saw that as the drag ending, so the selection cancelled itself straight away, for both recordings and screenshots. +- **Lost clicks.** The overlay you pick a display or window from was its own web view. In some cases it closed itself before the click on the record button had made it across to the Rust side, so the click just disappeared. There was no recording and no error, and because the main window had already hidden itself, it looked like Cap had quit. +- **Crashes in the macOS picker.** Opening and closing the display picker a few times could crash Cap on a Mac. The picker was a web view inside a floating panel, and the library that turned its window into a panel was releasing that window one time too many while WebKit was still attached to it. Fixing it meant forking the panel library. + +All of these came from the browser layer, which we couldn't control or update, and we usually heard about them from someone whose setup we'd never seen. Getting rid of that whole category of problem is the main reason for this release. The speed improvements are great and I'll get to them, but more than anything we wanted Cap to behave the same on every computer. + +### A detour through Electron + +In August I tried moving Cap from Tauri to Electron, mostly out of curiosity. Electron ships its own copy of Chromium, so at least we'd have the same browser on every platform. After a night of optimising, the Electron build was faster than the Tauri app we had in production. That wasn't a completely fair comparison, since I'd made optimisations specifically for the Electron build that the Tauri app never got. It still got me wondering how far we could go with a fully native interface. + + + + + +That first GPUI build was a prototype. It was fast, but a lot of the app was missing. We shipped it in 0.6.0 as an opt-in preview under **Settings > Experimental**, then spent the next five weeks filling in the gaps until it could replace the Tauri app completely. That's what 0.6.1 is. + +### Getting the community involved + +We wanted the community to be part of this, and to show what open source can really do. So I asked for help on X, and said anyone who sent us a decent performance PR would get sponsored on GitHub. A lot of people took us up on it. Since mid-August, 65 people have had pull requests merged into Cap, and 111 of those PRs were performance work from 42 different people. Cap now sponsors 20 developers on GitHub. Two of the export improvements later in this post came from them. [Hona](https://github.com/Hona) fixed Windows exports and [Aqua-123](https://github.com/Aqua-123) made ProRes exports faster. + +Having that many people involved also meant Cap was tested on far more kinds of machines than we have on our desks, and that's how a lot of the edge cases turned up. + +### Testing all of it + +More PRs meant more to test, and CI quickly became the slowest part of the process. GitHub Actions only lets us run five macOS jobs at a time, so PRs sat in a queue waiting for a Mac. [Blacksmith](https://www.blacksmith.sh) generously offered to sponsor Cap's CI, and moving our test suites over to them got rid of the queue. Their jobs start straight away and there's no limit on how many run at once. + +We've had help with reviews too. [Greptile](https://www.greptile.com) sponsors the AI code review that checks our PRs for bugs, and [Superagent](https://superagent.sh) keeps watch over security. + +## Picking a stack + +Moving off the web view meant we had to draw the interface ourselves. Realistically we could use a native toolkit on each platform (AppKit, WinUI and GTK), Electron, or a cross-platform Rust UI framework. Going with native toolkits would have meant building Cap's interface three times. Electron fixes the different-browsers problem by bundling one, but the app gets bigger, and our own tests already had a GPU-drawn app well ahead of it. + +We went with GPUI for four reasons. + +**It's Rust.** So is Cap's engine. In Tauri, the interface and the engine talked to each other over a bridge, and every command was converted to JSON on one side and parsed on the other. GPUI doesn't need that. The editor calls the same engine crates as the CLI, with the same types, in the same process. + +**Everything is drawn on the GPU.** It's the game approach from earlier. There's no browser engine sitting between Cap and the screen, and we can read and change every part of it. + +**There's one implementation for every platform.** Layout, text and drawing all run the same code on Mac, Windows and Linux. The only thing that changes is a thin layer at the bottom, which talks to Metal on macOS, Direct3D 11 on Windows and Vulkan on Linux. If there's a layout bug on one platform, it shows up on all of them, so we actually see it and can fix it. + +**It still reads like Tailwind.** I didn't expect this to matter as much as it did. Below is the same toggle switch in the old app and in the new one. + + + +Width, height, padding, flex, rounded corners and backgrounds are all written almost the same way. That's a big part of how we managed to port the whole app without redesigning it. + +A few people have pointed out that GPUI isn't "native" in the same way AppKit or WinUI is, and they have a point. Cap doesn't use the system's own buttons and text fields. The windows themselves are real platform windows, and everything inside them is drawn by Rust talking directly to the GPU. There's no WebKit, Chromium or JavaScript anywhere in the app. + +## How it's built + +We rewrote Cap's interface and kept the engine. The old app and the new one share fourteen engine crates, covering screen and camera capture, the recorder, the renderer (backgrounds, zooms, cursors and captions), the editor's playback engine and the exporter. The Cap CLI is built on the same crates and has been exporting videos with no web view since 2024, so we were confident they'd hold up without Tauri. The Tauri app actually exported videos by launching a renamed copy of the CLI as a separate process. The new app just calls the exporter directly. + +Your files carry over too. 0.6.1 reads the same projects, settings and recordings as 0.6.0 and updates in place, so you don't need to migrate anything. + +### Getting video frames to the screen + +The biggest single change is in the editor. When you hit play, Cap's renderer puts together your recording, background, cursor, camera, zooms and captions on the GPU, 60 times a second. That bit is the same in both apps. What's changed is how each finished frame gets onto your screen. + +A web page has no access to the GPU memory Rust is using, so in the Tauri app every frame went through six steps. + +1. Rendered on the GPU +2. Copied into regular memory +3. Sent over a local WebSocket +4. Passed to a JavaScript worker +5. Uploaded to the GPU again +6. Composited by the browser, and then displayed + +At the default preview size that adds up to around 216 MB of raw pixels every second, plus about 5,000 lines of Rust and TypeScript that existed purely to make the trip fast enough. + +On a Mac, the new app copies each frame into an IOSurface, which is a chunk of graphics memory that macOS lets two renderers share, and GPUI draws directly from that. The frame stays on the GPU the whole way. + + + +## Making it feel like the same app + +We wanted 0.6.1 to feel like the Cap you already know. So we went through every window (the recorder, target picker, camera bubble, recording bar, settings, editor, screenshot editor, onboarding and teleprompter) and checked it against the old version, one interaction at a time. The engine was the easy part. Most of the hard work was rebuilding things a browser handles for you without you ever thinking about them. + + + +These are a few of them. + +- **Text fields.** In a browser you get typing, selection, copy and paste, undo, accents and input methods for languages like Japanese for free. In GPUI you build the text field yourself on top of the operating system's text input APIs. Ours is about 2,700 lines of Rust. +- **One-line CSS.** Things like backdrop blur, masks and fancy gradients are one line of CSS. In GPUI some of them became shaders, some needed a patch to the framework, and for some we changed the design. The Liquid Glass background on macOS 26 is the actual system material placed behind the GPUI window, which ended up being easier than trying to fake it. +- **Windows that aren't normal windows.** In the Tauri app the recording bar was a web view that could only be fully clickable or fully click-through. To make it work we checked where its buttons were 20 times a second and switched the whole window between the two modes. In the new app the bar is a native panel and that loop doesn't exist anymore. +- **A brand new macOS.** On macOS 26, windows started reporting that they were visible using an undocumented flag, and GPUI waits for a window to be visible before it draws anything. So every window drew a single frame and then froze. We now patch that check when the app launches. Having the framework's source code is what made that fix possible. +- **Idling properly.** GPUI wakes up on every screen refresh to check if anything needs redrawing, which is 120 times a second on a ProMotion display. We patched it so that if a window has had nothing to draw for 100 ms, it stops checking. An idle Cap window on macOS went from 121 wake-ups a second to 1. + +## Performance + +### The numbers + +These measurements are from August. They compare the Tauri app we were shipping, the Electron experiment and the first GPUI build, on the same Mac with the same recording. You can switch between playback and recording. + + + +Most of the CPU saving comes from the new frame path described above. Most of the memory saving comes from dropping the browser engine. Every Tauri window had its own web view, with its own JavaScript runtime, layout engine and caches, even when it was sitting there doing nothing. + +### Exports + +Exports never ran inside the web view, so moving to GPUI doesn't speed them up on its own. A lot of export work landed in this release anyway, and on some machines it makes a big difference. + +On one of our Windows test laptops, an 83 second recording used to take 99.5 seconds to export at 1080p60. The renderer was waiting on Windows' default 15.6 ms timer between frames, which left the GPU and the hardware encoder idle most of the time. Fixing that, moving audio onto its own thread and removing a few remaining sleeps got it down to about 20 seconds. + +On Mac, MP4 exports were already running as fast as Apple's hardware encoder can go, so we worked on everything around it. Exports now use about 11% less CPU, audio is encoded with Apple's AudioToolbox on a separate thread, and ProRes exports use every CPU core instead of just one. + + + +Mac exports are smaller now too. Because of a missing setting, the hardware encoder was writing a full keyframe every 12 frames. We changed it to one every two seconds, and a one minute recording exported at 4K Maximum went from 93 MB to 30 MB with no visible drop in quality. + +### Download size + +Dropping the web view also means there's less to download. Every 0.6.1 download is smaller than before, and on macOS and Linux they're about half the size. + +| Download | 0.6.0 | 0.6.1 | Smaller by | +| --- | ---: | ---: | ---: | +| macOS, Apple silicon (.dmg) | 189.6 MB | 95.8 MB | 49% | +| macOS, Intel (.dmg) | 200.9 MB | 106.1 MB | 47% | +| Windows (.exe) | 203.7 MB | 133.4 MB | 35% | +| Linux (.deb) | 224.4 MB | 94.9 MB | 58% | +| Linux (.AppImage) | 264.7 MB | 129.0 MB | 51% | +| Linux (.rpm) | 202.8 MB | 99.4 MB | 51% | + +Not all of that is down to GPUI. 0.6.0 included both apps (the Tauri app and the native preview) plus a second copy of the CLI, and we also switched every installer to stronger compression. The Linux AppImage used to bundle WebKitGTK and all of its dependencies, and our release build now fails if any of them get added back. + +## Trade-offs + +Going native wasn't free, and some of it we're still paying for. + +- **We have to build things the browser used to give us.** Text fields, menus, scroll views and focus handling are all our own code now. The new app is about 182,000 lines of Rust, which is almost exactly the size of the 181,000 lines of TypeScript and Rust it replaced. +- **Keyboard navigation isn't everywhere yet.** In a browser, tabbing between controls just works. In GPUI we have to set it up window by window, and we haven't finished. +- **No hot reload.** The old app used Vite, so a style tweak showed up in the running app the moment you saved. Rebuilding the GPUI app only takes a few seconds, but you still relaunch it and click back to whatever you were working on. +- **A smaller ecosystem.** GPUI is young and it's built for Zed first. There are far fewer libraries to pull in than on the web, and we carry four small patches on top of it. + +## Where we go from here + +Everything we build from now on will be on top of the native app. Next on the list is full keyboard navigation in every window, Cap in more languages, and more performance work, now that we can change every step from capture to screen. + +You can download Cap 0.6.1 at [Cap.so/download](https://cap.so/download), and if you already have Cap installed it'll update in place. The [changelog](/changelog/desktop) lists the 180+ other fixes and improvements that shipped alongside it. + +Thanks to everyone who turned on the preview in 0.6.0 and told us what was broken. And a huge thank you to the 41 people who contributed to this release. + +@yashranaway, @Hona, @AatmanAJ, @AksharP5, @tadhgdowdall, @jatmn, @celeroncoder, @YoavLevavi, @Theophilus2003, @viralcodex, @unhingedpanda, @notkainoa, @nikolas-sapa, @himanshu748, @dheeraj-juvvadi, @cesarfrancots, @x1xhlol, @wleeaf, @vagxrth, @shubhook, @qflen, @patrickballard, @mihir0209, @melcheikh, @maddiedreese, @hridaya423, @harshav167, @dennisimoo, @colinvkim, @cartesian-plane, @apoorvdarshan, @ajanraj, @Zingzy, @The-Best-Codes, @Supernova-45, @SoulSniper-V2, @OptimistOtaku, @JeffreyLin1, @Ehsan-hash, @EfeDurmaz16 and @Aqua-123. + +Richie diff --git a/apps/web/content/docs/self-hosting.mdx b/apps/web/content/docs/self-hosting.mdx index 45219a81bd0..32e16f0f975 100644 --- a/apps/web/content/docs/self-hosting.mdx +++ b/apps/web/content/docs/self-hosting.mdx @@ -160,6 +160,12 @@ AI_CHAT_MODEL=your-model # Support chat AI_STREAM_MODEL=your-model # Streaming docs Ask AI ``` +To keep transcription in the EU, set: + +```bash +ASSEMBLY_BASE_URL=https://api.eu.assemblyai.com +``` + For the AssemblyAI EU gateway, set `AI_BASE_URL=https://llm-gateway.eu.assemblyai.com/v1`. The EU gateway only serves Claude and Gemini models, so you must also override `AI_MODEL` and diff --git a/apps/web/data/homepage-copy.ts b/apps/web/data/homepage-copy.ts index 8b199734e45..4dcfd7a16a1 100644 --- a/apps/web/data/homepage-copy.ts +++ b/apps/web/data/homepage-copy.ts @@ -301,7 +301,7 @@ export const homepageCopy: HomePageCopy = { title: "Simple, Honest Pricing", subtitle: "Start free, upgrade when you need more. Early adopter pricing locked in forever.", - lovedBy: "Trusted by 50,000+ users", + lovedBy: "Trusted by 80,000+ users", commercial: { title: "Desktop License", description: diff --git a/apps/web/instrumentation-client.ts b/apps/web/instrumentation-client.ts index 408230ef532..401283631e4 100644 --- a/apps/web/instrumentation-client.ts +++ b/apps/web/instrumentation-client.ts @@ -10,6 +10,7 @@ if (dsn) { replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 0, maxBreadcrumbs: 30, + denyUrls: [/^(?:chrome|moz|safari(?:-web)?)-extension:\/\//i], integrations: (integrations) => integrations.filter( ({ name }) => name !== "BrowserTracing" && name !== "BrowserSession", diff --git a/apps/web/lib/assemblyai.ts b/apps/web/lib/assemblyai.ts index 0c743dc0f36..21d1fbc66d3 100644 --- a/apps/web/lib/assemblyai.ts +++ b/apps/web/lib/assemblyai.ts @@ -1,8 +1,18 @@ +import { serverEnv } from "@cap/env"; import { AI_GENERATION_LANGUAGE_AUTO, type AiGenerationLanguage, type AiGenerationLanguageCode, } from "@cap/web-domain"; +import { AssemblyAI } from "assemblyai"; + +export function createAssemblyAIClient() { + const env = serverEnv(); + return new AssemblyAI({ + apiKey: env.ASSEMBLY_API_KEY as string, + baseUrl: env.ASSEMBLY_BASE_URL, + }); +} export const ASSEMBLYAI_SPEECH_MODELS = [ "universal-3-5-pro", diff --git a/apps/web/lib/checkout-quantity.ts b/apps/web/lib/checkout-quantity.ts new file mode 100644 index 00000000000..131b84a1fe2 --- /dev/null +++ b/apps/web/lib/checkout-quantity.ts @@ -0,0 +1,11 @@ +export const MAX_CHECKOUT_QUANTITY = 10000; + +export const parseCheckoutQuantity = (quantity: unknown): number | null => { + if (quantity === undefined || quantity === null) return 1; + return typeof quantity === "number" && + Number.isInteger(quantity) && + quantity >= 1 && + quantity <= MAX_CHECKOUT_QUANTITY + ? quantity + : null; +}; diff --git a/apps/web/lib/comment-reply-parent.ts b/apps/web/lib/comment-reply-parent.ts new file mode 100644 index 00000000000..7e9c3e8514a --- /dev/null +++ b/apps/web/lib/comment-reply-parent.ts @@ -0,0 +1,16 @@ +import { db } from "@cap/database"; +import { comments } from "@cap/database/schema"; +import type { Comment, Video } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; + +export async function isReplyParentOnVideo( + parentCommentId: Comment.CommentId, + videoId: Video.VideoId, +) { + const [parent] = await db() + .select({ id: comments.id }) + .from(comments) + .where(and(eq(comments.id, parentCommentId), eq(comments.videoId, videoId))) + .limit(1); + return parent !== undefined; +} diff --git a/apps/web/lib/desktop-recording-source-checkpoint.ts b/apps/web/lib/desktop-recording-source-checkpoint.ts index 328a6d25335..ec741b9b2fb 100644 --- a/apps/web/lib/desktop-recording-source-checkpoint.ts +++ b/apps/web/lib/desktop-recording-source-checkpoint.ts @@ -1,8 +1,13 @@ import { z } from "zod"; export const SOURCE_COMMIT_PAGE_SIZE = 16; +// Cap's own AWS bucket answers fast enough to finish 8x larger pages well inside a step's deadline. +export const SOURCE_COMMIT_AWS_PAGE_SIZE = 128; export const SOURCE_COMMIT_PART_BATCH_SIZE = 4; export const SOURCE_COMMIT_PART_PAGE_SIZE = 64; +// Readers accept larger tree pages than writers produce, so a deployment can be rolled back while commits written by a newer one are in flight. +export const SOURCE_COMMIT_MAX_TREE_PAGE_ENTRIES = 256; +export const SOURCE_COMMIT_TREE_PAGE_SIZE = 128; export const SOURCE_COMMIT_MAX_OBJECTS = 100_002; const sha256 = z.string().regex(/^[a-f0-9]{64}$/); diff --git a/apps/web/lib/desktop-recording-source.ts b/apps/web/lib/desktop-recording-source.ts index 514421bb076..3492c7247bf 100644 --- a/apps/web/lib/desktop-recording-source.ts +++ b/apps/web/lib/desktop-recording-source.ts @@ -9,10 +9,13 @@ import { z } from "zod"; import { type DesktopRecordingSourceCheckpoint, desktopRecordingSourceCheckpointSchema, + SOURCE_COMMIT_AWS_PAGE_SIZE, SOURCE_COMMIT_MAX_OBJECTS, + SOURCE_COMMIT_MAX_TREE_PAGE_ENTRIES, SOURCE_COMMIT_PAGE_SIZE, SOURCE_COMMIT_PART_BATCH_SIZE, SOURCE_COMMIT_PART_PAGE_SIZE, + SOURCE_COMMIT_TREE_PAGE_SIZE, type SourceCommitPageReference, type SourceCommitReference, sourceCommitPageReferenceSchema, @@ -60,9 +63,10 @@ export type DesktopRecordingSource = z.infer< >; type SourceObject = z.infer; type DbVideo = typeof videos.$inferSelect; -type RecordingStorage = Effect.Effect.Success< +type RecordingStorageAccess = Effect.Effect.Success< ReturnType ->[0]; +>; +type RecordingStorage = RecordingStorageAccess[0]; const sourceInventorySchema = z.object({ version: z.literal(1), @@ -199,6 +203,8 @@ type SourceContext = { bucket: RecordingStorage; prefix: string; run: SourceRun; + concurrency: number; + pageSize: number; }; type TreeKind = "plan" | "objects" | "parts"; @@ -229,7 +235,10 @@ const treePageSchema = z.discriminatedUnion("type", [ kind: z.enum(["plan", "objects", "parts"]), scope: z.string(), start: z.number().int().nonnegative(), - entries: z.array(z.unknown()).min(1).max(SOURCE_COMMIT_PART_PAGE_SIZE), + entries: z + .array(z.unknown()) + .min(1) + .max(SOURCE_COMMIT_MAX_TREE_PAGE_ENTRIES), }), z.object({ version: z.literal(1), @@ -256,6 +265,20 @@ const committedInventorySchema = z.union([ ]); const SOURCE_COMMIT_STEP_TIMEOUT_MS = 180_000; const SOURCE_COMMIT_IO_TIMEOUT_MS = 60_000; +const SOURCE_COMMIT_CONCURRENCY = 8; +const SOURCE_COMMIT_AWS_CONCURRENCY = 128; + +function sourceLimits([bucket, customBucket]: RecordingStorageAccess) { + return bucket.provider === "s3" && Option.isNone(customBucket) + ? { + concurrency: SOURCE_COMMIT_AWS_CONCURRENCY, + pageSize: SOURCE_COMMIT_AWS_PAGE_SIZE, + } + : { + concurrency: SOURCE_COMMIT_CONCURRENCY, + pageSize: SOURCE_COMMIT_PAGE_SIZE, + }; +} function invalidSource(message: string): never { throw new DesktopRecordingSourceError("source-invalid", message); @@ -272,12 +295,13 @@ function parseSourceJson(content: string): unknown { async function mapBounded( items: T[], operation: (item: T) => Promise, + concurrency = SOURCE_COMMIT_CONCURRENCY, ) { const output = new Array(items.length); const entries = items.entries(); let failure: unknown; await Promise.all( - Array.from({ length: Math.min(8, items.length) }, async () => { + Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (failure === undefined) { const next = entries.next(); if (next.done) return; @@ -367,7 +391,7 @@ async function appendTree( scope: string, entries: unknown[], ) { - if (entries.length === 0 || entries.length > SOURCE_COMMIT_PART_PAGE_SIZE) + if (entries.length === 0 || entries.length > SOURCE_COMMIT_TREE_PAGE_SIZE) invalidSource("Invalid source page size"); const start = treeCount(roots); const leaf = await writeReference(context, { @@ -424,51 +448,57 @@ async function readTree( let pending = roots.filter(overlaps); const leaves: { start: number; entries: T[] }[] = []; while (pending.length > 0) { - const pages = await mapBounded(pending, async (reference) => { - const parsed = treePageSchema.safeParse( - await readReference(context, reference), - ); - if (!parsed.success) invalidSource("Recording source page is invalid"); - const page = parsed.data; - if (page.kind !== kind || page.scope !== scope) - invalidSource("Recording source page belongs to a different snapshot"); - if (page.type === "leaf") { + const pages = await mapBounded( + pending, + async (reference) => { + const parsed = treePageSchema.safeParse( + await readReference(context, reference), + ); + if (!parsed.success) invalidSource("Recording source page is invalid"); + const page = parsed.data; + if (page.kind !== kind || page.scope !== scope) + invalidSource( + "Recording source page belongs to a different snapshot", + ); + if (page.type === "leaf") { + if ( + reference.level !== 0 || + page.start !== reference.start || + page.entries.length !== reference.count + ) + invalidSource( + "Recording source leaf does not match its committed range", + ); + return { + children: [], + leaf: { + start: page.start, + entries: page.entries.map((entry) => { + const parsed = schema.safeParse(entry); + if (!parsed.success) + invalidSource( + "Recording source page contains an invalid receipt", + ); + return parsed.data; + }), + }, + }; + } + const [left, right] = page.children; if ( - reference.level !== 0 || - page.start !== reference.start || - page.entries.length !== reference.count + left.start !== reference.start || + left.start + left.count !== right.start || + left.count + right.count !== reference.count || + left.level + 1 !== reference.level || + right.level !== left.level ) invalidSource( - "Recording source leaf does not match its committed range", + "Recording source branch does not match its committed range", ); - return { - children: [], - leaf: { - start: page.start, - entries: page.entries.map((entry) => { - const parsed = schema.safeParse(entry); - if (!parsed.success) - invalidSource( - "Recording source page contains an invalid receipt", - ); - return parsed.data; - }), - }, - }; - } - const [left, right] = page.children; - if ( - left.start !== reference.start || - left.start + left.count !== right.start || - left.count + right.count !== reference.count || - left.level + 1 !== reference.level || - right.level !== left.level - ) - invalidSource( - "Recording source branch does not match its committed range", - ); - return { children: page.children.filter(overlaps), leaf: null }; - }); + return { children: page.children.filter(overlaps), leaf: null }; + }, + context.concurrency, + ); pending = []; for (const page of pages) { pending.push(...page.children); @@ -922,6 +952,8 @@ export async function prepareDesktopRecordingSegments( bucket, run, prefix: sourcePrefix(video).slice(0, -1), + concurrency: SOURCE_COMMIT_CONCURRENCY, + pageSize: SOURCE_COMMIT_PAGE_SIZE, }; const unique = [ ...new Map( @@ -1153,7 +1185,7 @@ async function advanceSourceSnapshot( invalidSource("Recording source plan cursor is incomplete"); const end = Math.min( plan.objectCount, - checkpoint.cursor + SOURCE_COMMIT_PAGE_SIZE, + checkpoint.cursor + context.pageSize, ); const originals = await mapBounded( Array.from( @@ -1166,6 +1198,7 @@ async function advanceSourceSnapshot( originalAt(context.video, plan, position), verification, ), + context.concurrency, ); const planRoots = await appendTree( context, @@ -1198,7 +1231,7 @@ async function advanceSourceSnapshot( scope, originalObjectSchema, checkpoint.cursor, - Math.min(plan.objectCount, checkpoint.cursor + SOURCE_COMMIT_PAGE_SIZE), + Math.min(plan.objectCount, checkpoint.cursor + context.pageSize), ); const large = context.bucket.provider === "s3" @@ -1235,6 +1268,7 @@ async function advanceSourceSnapshot( position, plan.sourcePreparation === true, ), + context.concurrency, ); } const receiptRoots = await appendTree( @@ -1261,7 +1295,7 @@ async function advanceSourceSnapshot( if (checkpoint.phase === "verify") { const end = Math.min( plan.objectCount, - checkpoint.cursor + SOURCE_COMMIT_PAGE_SIZE, + checkpoint.cursor + context.pageSize, ); const [originals, objects] = await Promise.all([ readTree( @@ -1299,6 +1333,7 @@ async function advanceSourceSnapshot( ); await checkCopy(context, original, object.key, object.objectIdentity); }, + context.concurrency, ); return { checkpoint: { @@ -1419,7 +1454,7 @@ export async function advanceDesktopRecordingSourceCommit( ); }; try { - const [bucket] = await run( + const access = await run( Storage.getAccessForVideo(decodeStorageVideo(video), { resolvePublishedOutput: false, }), @@ -1427,9 +1462,10 @@ export async function advanceDesktopRecordingSourceCommit( const result = await advanceSourceSnapshot( { video, - bucket, + bucket: access[0], run, prefix: `${sourcePrefix(video)}${parsed.generation}/${parsed.snapshotId}`, + ...sourceLimits(access), }, parsed, verification, @@ -1480,9 +1516,10 @@ export async function buildDesktopRecordingSourceUrls( source: DesktopRecordingSource, ) { assertSourceKey(video, source.inventoryKey); - const [bucket] = await runWorkflowPromise( + const access = await runWorkflowPromise( Storage.getAccessForVideo(decodeStorageVideo(video)), ); + const [bucket] = access; const content = await readRequiredObject(bucket, source.inventoryKey); if (hash(content) !== source.inventorySha256) { throw new DesktopRecordingSourceError( @@ -1526,6 +1563,7 @@ export async function buildDesktopRecordingSourceUrls( bucket, run, prefix: sourcePrefix(video).slice(0, -1), + ...sourceLimits(access), }, inventory.roots, "objects", diff --git a/apps/web/lib/email-code-rate-limit.ts b/apps/web/lib/email-code-rate-limit.ts new file mode 100644 index 00000000000..95cb0e7e3bc --- /dev/null +++ b/apps/web/lib/email-code-rate-limit.ts @@ -0,0 +1,32 @@ +import crypto from "node:crypto"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; + +export function normalizeSignInEmail(value: unknown) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed.split("@").length !== 2 || trimmed.includes('"')) return null; + const [local, domain] = trimmed.toLowerCase().split("@"); + const host = domain?.split(",")[0]; + if (!local || !host || !host.includes(".")) return null; + return `${local}@${host}`; +} + +// Verification rows are looked up under MySQL's accent-insensitive default +// collation, so accented spellings of an address share one bucket. +const emailCodeRateLimitKey = (scope: string, email: string) => + `${scope}:${crypto + .createHash("sha256") + .update(email.normalize("NFKD").replace(/\p{M}/gu, "")) + .digest("hex")}`; + +export const isEmailCodeSendLimited = (email: string, headers?: Headers) => + isRateLimited(RATE_LIMIT_IDS.AUTH_OTP_SEND, { + key: emailCodeRateLimitKey("email-code-send", email), + headers, + }); + +export const isEmailCodeVerifyLimited = (email: string, headers?: Headers) => + isRateLimited(RATE_LIMIT_IDS.AUTH_OTP_VERIFY, { + key: emailCodeRateLimitKey("email-code-verify", email), + headers, + }); diff --git a/apps/web/lib/loom-csv.ts b/apps/web/lib/loom-csv.ts index 3aaea1f1968..99cbb24414d 100644 --- a/apps/web/lib/loom-csv.ts +++ b/apps/web/lib/loom-csv.ts @@ -50,6 +50,35 @@ export function parseLoomCsvRecords(text: string) { return records; } +export const LOOM_CSV_RESULTS_HEADERS = [ + "row", + "loom_video_url", + "user_email", + "space_name", + "status", + "error", +]; + +const GUARDED_FORMULA_PREFIX = /^'*[=+\-@\t\r]/; +const FORMULA_GUARD = /^'(?='*[=+\-@\t\r])/; + +export function formatLoomCsvCell(value: string) { + const literal = GUARDED_FORMULA_PREFIX.test(value) ? `'${value}` : value; + if (/[",\n\r]/.test(literal)) { + return `"${literal.replace(/"/g, '""')}"`; + } + return literal; +} + +export function isLoomCsvResultsFile(headers: string[]) { + const normalized = new Set(headers.map((header) => header.toLowerCase())); + return LOOM_CSV_RESULTS_HEADERS.every((header) => normalized.has(header)); +} + +export function readLoomCsvCell(value: string) { + return value.replace(FORMULA_GUARD, ""); +} + export function parseConciergeLoomCsv(text: string): LoomCsvImportRow[] { const records = parseLoomCsvRecords(text); const headers = records[0]?.map((value) => value.toLowerCase()) ?? []; diff --git a/apps/web/lib/loom-download-url.ts b/apps/web/lib/loom-download-url.ts new file mode 100644 index 00000000000..1efde716b32 --- /dev/null +++ b/apps/web/lib/loom-download-url.ts @@ -0,0 +1,281 @@ +import { randomUUID } from "node:crypto"; + +export class LoomDownloadTemporaryError extends Error { + constructor(public readonly retryAfterMs: number) { + super("Loom is temporarily unavailable. Please retry the import later."); + } +} + +export function getReusableLoomDownloadUrl(value: string | undefined) { + if (!value) return null; + try { + const url = new URL(value); + if ( + url.protocol !== "https:" || + !["cdn.loom.com", "luna.loom.com"].includes(url.hostname) || + url.username || + url.password + ) { + return null; + } + if (!url.search) return url.href; + let expiresAt = Number(url.searchParams.get("Expires")); + const encodedPolicy = url.searchParams.get("Policy"); + if (encodedPolicy) { + const policy: unknown = JSON.parse( + Buffer.from( + encodedPolicy + .replace(/-/g, "+") + .replace(/_/g, "=") + .replace(/~/g, "/"), + "base64", + ).toString("utf8"), + ); + if ( + typeof policy !== "object" || + policy === null || + !("Statement" in policy) || + !Array.isArray(policy.Statement) + ) { + return null; + } + const statement = policy.Statement[0] as + | { Condition?: { DateLessThan?: { "AWS:EpochTime"?: unknown } } } + | undefined; + expiresAt = Number(statement?.Condition?.DateLessThan?.["AWS:EpochTime"]); + } + return Number.isFinite(expiresAt) && + expiresAt * 1000 > Date.now() + 60 * 60 * 1000 + ? url.href + : null; + } catch { + return null; + } +} + +function getRetryAfterMs(value: string | null): number { + if (!value) return 60_000; + const seconds = Number(value); + const delay = Number.isFinite(seconds) + ? seconds * 1000 + : Date.parse(value) - Date.now(); + return Number.isFinite(delay) ? Math.max(30_000, delay) : 60_000; +} + +async function fetchEndpoint( + videoId: string, + endpoint: string, + includeBody: boolean, +): Promise { + let response: Response; + try { + response = await fetch( + `https://www.loom.com/api/campaigns/sessions/${encodeURIComponent(videoId)}/${endpoint}`, + { + method: "POST", + signal: AbortSignal.timeout(15_000), + ...(includeBody + ? { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + anonID: randomUUID(), + deviceID: null, + force_original: false, + password: null, + }), + } + : {}), + }, + ); + } catch { + throw new LoomDownloadTemporaryError(60_000); + } + if (response.status === 429 || response.status >= 500) { + await response.body?.cancel(); + throw new LoomDownloadTemporaryError( + getRetryAfterMs(response.headers.get("retry-after")), + ); + } + if (!response.ok || response.status === 204) return null; + try { + const text = await response.text(); + if (!text.trim()) return null; + const data: unknown = JSON.parse(text); + return parseLoomDownloadResponse(data); + } catch { + throw new LoomDownloadTemporaryError(60_000); + } +} + +function parseLoomDownloadResponse(data: unknown): string | null { + if ( + typeof data !== "object" || + data === null || + !("url" in data) || + typeof data.url !== "string" + ) { + return null; + } + const url = new URL(data.url); + if (url.protocol !== "https:" || url.username || url.password) return null; + const pathname = url.pathname.toLowerCase(); + if ( + (pathname.endsWith(".m3u8") || pathname.endsWith(".mpd")) && + "part_credentials" in data && + data.part_credentials !== null + ) { + const credentials = data.part_credentials; + if ( + typeof credentials !== "object" || + !("Signature" in credentials) || + typeof credentials.Signature !== "string" || + !credentials.Signature || + !("Key-Pair-Id" in credentials) || + typeof credentials["Key-Pair-Id"] !== "string" || + !credentials["Key-Pair-Id"] + ) { + throw new LoomDownloadTemporaryError(60_000); + } + if ( + "Policy" in credentials && + typeof credentials.Policy === "string" && + credentials.Policy + ) { + url.searchParams.set("Policy", credentials.Policy); + url.searchParams.delete("Expires"); + } else if ( + "Expires" in credentials && + Number.isFinite(Number(credentials.Expires)) && + Number(credentials.Expires) > 0 + ) { + url.searchParams.set("Expires", String(credentials.Expires)); + url.searchParams.delete("Policy"); + } else { + throw new LoomDownloadTemporaryError(60_000); + } + url.searchParams.set("Signature", credentials.Signature); + url.searchParams.set("Key-Pair-Id", credentials["Key-Pair-Id"]); + } + return url.href; +} + +async function fetchPublicLoomPlaybackUrl( + videoId: string, +): Promise { + let response: Response; + try { + response = await fetch("https://www.loom.com/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + operationName: "GetVideoSource", + variables: { + videoId, + password: null, + acceptableMimes: ["DASH", "M3U8", "MP4", "WEBM"], + }, + query: + "query GetVideoSource($videoId: ID!, $password: String, $acceptableMimes: [CloudfrontVideoAcceptableMime]) { getVideo(id: $videoId, password: $password) { ... on RegularUserVideo { id downloadable download_enabled nullableRawCdnUrl(acceptableMimes: $acceptableMimes, password: $password) { url credentials { Policy Signature KeyPairId } } } __typename } }", + }), + signal: AbortSignal.timeout(15_000), + }); + } catch { + throw new LoomDownloadTemporaryError(60_000); + } + if (response.status === 429 || response.status >= 500) { + await response.body?.cancel(); + throw new LoomDownloadTemporaryError( + getRetryAfterMs(response.headers.get("retry-after")), + ); + } + if (!response.ok || response.status === 204) return null; + try { + const body: unknown = await response.json(); + if (typeof body !== "object" || body === null) return null; + if ( + "errors" in body && + Array.isArray(body.errors) && + body.errors.length > 0 + ) + throw new LoomDownloadTemporaryError(60_000); + if ( + !("data" in body) || + typeof body.data !== "object" || + body.data === null || + !("getVideo" in body.data) + ) + return null; + const video = body.data.getVideo; + if ( + typeof video !== "object" || + video === null || + !("id" in video) || + video.id !== videoId || + !("downloadable" in video) || + video.downloadable !== true || + !("download_enabled" in video) || + video.download_enabled !== true || + !("nullableRawCdnUrl" in video) + ) + return null; + const source = video.nullableRawCdnUrl; + if (typeof source !== "object" || source === null) + throw new LoomDownloadTemporaryError(60_000); + const credentials = "credentials" in source ? source.credentials : null; + const partCredentials = + typeof credentials === "object" && + credentials !== null && + "Policy" in credentials && + "Signature" in credentials && + "KeyPairId" in credentials + ? { + Policy: credentials.Policy, + Signature: credentials.Signature, + "Key-Pair-Id": credentials.KeyPairId, + } + : null; + const url = parseLoomDownloadResponse({ + ...source, + part_credentials: partCredentials, + }); + if (!url) throw new LoomDownloadTemporaryError(60_000); + return url; + } catch { + throw new LoomDownloadTemporaryError(60_000); + } +} + +export async function getLoomDownloadUrl( + videoId: string, +): Promise { + let streamingUrl: string | null = null; + for (const { endpoint, includeBody } of [ + { endpoint: "transcoded-url", includeBody: true }, + { endpoint: "raw-url", includeBody: true }, + { endpoint: "transcoded-url", includeBody: false }, + { endpoint: "raw-url", includeBody: false }, + ]) { + let url: string | null; + try { + url = await fetchEndpoint(videoId, endpoint, includeBody); + } catch (error) { + if (streamingUrl && error instanceof LoomDownloadTemporaryError) { + return streamingUrl; + } + throw error; + } + if (!url) continue; + const pathname = new URL(url).pathname.toLowerCase(); + if (!pathname.endsWith(".m3u8") && !pathname.endsWith(".mpd")) { + return url; + } + streamingUrl ??= url; + } + return streamingUrl ?? fetchPublicLoomPlaybackUrl(videoId); +} diff --git a/apps/web/lib/loom-file-cleanup.ts b/apps/web/lib/loom-file-cleanup.ts new file mode 100644 index 00000000000..3f1aa44c2e2 --- /dev/null +++ b/apps/web/lib/loom-file-cleanup.ts @@ -0,0 +1,47 @@ +import { db } from "@cap/database"; +import { videos } from "@cap/database/schema"; +import { Storage } from "@cap/web-backend/src/Storage/index"; +import { Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { Effect } from "effect"; +import { isStorageObjectNotFound } from "@/lib/storage-not-found"; +import { decodeStorageVideo } from "@/lib/video-storage"; +import { runWorkflowPromise } from "@/lib/workflow-runtime"; + +export async function cleanupLoomFileSource({ + videoId, + userId, + rawFileKey, +}: { + videoId: string; + userId: string; + rawFileKey: string; +}): Promise { + "use step"; + + const outputKey = `${userId}/${videoId}/result.mp4`; + if ( + rawFileKey === outputKey || + !rawFileKey.startsWith(`${userId}/${videoId}/`) + ) { + return; + } + try { + const [video] = await db() + .select() + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))); + if (!video || video.ownerId !== userId) return; + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), + ).pipe(runWorkflowPromise); + const output = await bucket.headObject(outputKey).pipe( + Effect.catchIf(isStorageObjectNotFound, () => Effect.succeed(null)), + runWorkflowPromise, + ); + if (!output?.ContentLength || output.ContentLength <= 0) return; + await bucket.deleteObject(rawFileKey).pipe(runWorkflowPromise); + } catch (error) { + console.warn("[loom-file] Failed to clean up the processed source", error); + } +} diff --git a/apps/web/lib/loom-file-processing.ts b/apps/web/lib/loom-file-processing.ts new file mode 100644 index 00000000000..0c9d1c70d5c --- /dev/null +++ b/apps/web/lib/loom-file-processing.ts @@ -0,0 +1,59 @@ +import { + LoomImportStartError, + startLoomImportWorkflow, +} from "@/lib/loom-import-start"; +import { + setVideoProcessingError, + type startVideoProcessingWorkflow, + transitionVideoToProcessing, + type VideoProcessingStartStatus, +} from "@/lib/video-processing"; + +export const LOOM_FILE_SINGLE_UPLOAD_MAX_BYTES = 5 * 1024 ** 3; + +export async function startLoomFileProcessingWorkflow({ + loomVideoId, + ...input +}: Pick< + Parameters[0], + | "videoId" + | "userId" + | "rawFileKey" + | "bucketId" + | "processingMessage" + | "startFailureMessage" +> & { + loomVideoId: string; +}): Promise { + if (!/^[a-f0-9]{32}$/i.test(loomVideoId)) { + throw new Error("The uploaded file has no valid Loom source mapping."); + } + const status = await transitionVideoToProcessing({ + videoId: input.videoId, + rawFileKey: input.rawFileKey, + processingMessage: input.processingMessage, + }); + if (status !== "started") return status; + + try { + await startLoomImportWorkflow({ + videoId: input.videoId, + userId: input.userId, + rawFileKey: input.rawFileKey, + bucketId: input.bucketId, + loomVideoId, + reuseExistingRawUpload: true, + cleanupRawUpload: true, + }); + return "started"; + } catch (error) { + if (!(error instanceof LoomImportStartError) || error.canRetry) { + await setVideoProcessingError( + input.videoId, + input.startFailureMessage, + error, + ); + } + throw error; + } +} diff --git a/apps/web/lib/loom-import-limits.ts b/apps/web/lib/loom-import-limits.ts new file mode 100644 index 00000000000..c40e9853eeb --- /dev/null +++ b/apps/web/lib/loom-import-limits.ts @@ -0,0 +1,2 @@ +export const LOOM_IMPORT_MAX_BYTES = 10 * 1024 ** 3; +export const LOOM_IMPORT_PART_BYTES = 64 * 1024 ** 2; diff --git a/apps/web/lib/loom-import-start.ts b/apps/web/lib/loom-import-start.ts new file mode 100644 index 00000000000..d850973791d --- /dev/null +++ b/apps/web/lib/loom-import-start.ts @@ -0,0 +1,179 @@ +import { db } from "@cap/database"; +import { videos, videoUploads } from "@cap/database/schema"; +import type { VideoMetadata } from "@cap/database/types"; +import { User, Video } from "@cap/web-domain"; +import { and, eq, sql } from "drizzle-orm"; +import { getRun, start } from "workflow/api"; +import { getWorld } from "workflow/runtime"; +import { isLoomMediaJobFailed } from "@/lib/loom-media-dispatch"; +import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; + +type LoomImportRun = NonNullable; +type ImportPayload = Parameters[0]; + +export async function restoreLoomImportStartError( + videoId: string, + phase: "uploading" | "processing", + claimedAt: Date, + message: string, +) { + await db() + .update(videoUploads) + .set({ + phase: "error", + processingProgress: 0, + processingMessage: null, + processingError: message, + updatedAt: new Date(), + }) + .where( + and( + eq(videoUploads.videoId, Video.VideoId.make(videoId)), + eq(videoUploads.phase, phase), + eq(videoUploads.updatedAt, claimedAt), + ), + ); +} + +export class LoomImportStartError extends Error { + constructor( + readonly canRetry: boolean, + cause: unknown, + ) { + super( + canRetry + ? "Loom import could not start. Please try again." + : "We are confirming whether your Loom import started. Please check its progress before retrying.", + { cause }, + ); + this.name = "LoomImportStartError"; + } +} + +export const LOOM_IMPORT_CONFIRMATION_MESSAGE = + "We're confirming whether your Loom import started. Please contact support before retrying."; + +export async function getLoomImportRunState( + receipt?: LoomImportRun, +): Promise<"inactive" | "pending" | "active" | "unconfirmed"> { + if (!receipt || receipt.dispatch === "rejected") return "inactive"; + try { + const status = await getRun(receipt.runId).status; + if ( + status === "completed" || + status === "failed" || + status === "cancelled" + ) { + const mediaDispatch = receipt.mediaDispatch; + if (mediaDispatch?.state === "accepted") { + return mediaDispatch.jobId && + mediaDispatch.videoId && + (await isLoomMediaJobFailed( + mediaDispatch.jobId, + mediaDispatch.videoId, + )) + ? "inactive" + : "unconfirmed"; + } + return receipt.mediaDispatch?.state === "uncertain" + ? "unconfirmed" + : "inactive"; + } + return status === "pending" ? "pending" : "active"; + } catch { + return "unconfirmed"; + } +} + +async function saveReceipt(payload: ImportPayload, receipt: LoomImportRun) { + const result = await db() + .update(videos) + .set({ + metadata: + receipt.dispatch === "pending" + ? sql`JSON_SET(COALESCE(${videos.metadata}, JSON_OBJECT()), '$.loomImportRun', JSON_EXTRACT(${JSON.stringify(receipt)}, '$'))` + : sql`JSON_SET(COALESCE(${videos.metadata}, JSON_OBJECT()), '$.loomImportRun', JSON_MERGE_PATCH(COALESCE(JSON_EXTRACT(${videos.metadata}, '$.loomImportRun'), JSON_OBJECT()), JSON_EXTRACT(${JSON.stringify(receipt)}, '$')))`, + }) + .where( + and( + eq(videos.id, Video.VideoId.make(payload.videoId)), + eq(videos.ownerId, User.UserId.make(payload.userId)), + receipt.dispatch === "pending" + ? undefined + : sql`JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomImportRun.runId')) = ${receipt.runId}`, + ), + ); + const affectedRows = Array.isArray(result) + ? (result[0] as { affectedRows?: number } | undefined)?.affectedRows + : (result as { affectedRows?: number }).affectedRows; + if (affectedRows !== 1) + throw new Error("Could not record Loom import startup"); +} + +function isQueueRejection(error: unknown) { + return ( + error instanceof Error && + [ + "BadRequestError", + "UnauthorizedError", + "ForbiddenError", + "TooManyRequestsError", + ].includes(error.name) + ); +} + +export async function startLoomImportWorkflow(payload: ImportPayload) { + let attempted = false; + let accepted = false; + let rejected = false; + let receipt: LoomImportRun | undefined; + try { + const world = getWorld(); + const observedWorld: ReturnType = { + ...world, + queue: async (name, message, options) => { + if (!("runId" in message)) + throw new Error("Missing Loom import run ID"); + receipt = { runId: message.runId, dispatch: "pending" }; + await saveReceipt(payload, receipt); + attempted = true; + try { + const result = await world.queue(name, message, options); + accepted = true; + return result; + } catch (error) { + rejected = isQueueRejection(error); + throw error; + } + }, + }; + await start(importLoomVideoWorkflow, [payload], { world: observedWorld }); + } catch (error) { + if (!accepted) { + let canRetry = !attempted || rejected; + if (receipt) { + try { + await saveReceipt(payload, { + ...receipt, + dispatch: canRetry ? "rejected" : "uncertain", + }); + } catch (receiptError) { + canRetry = false; + console.error( + "Could not record Loom import dispatch failure", + receiptError, + ); + } + } + throw new LoomImportStartError(canRetry, error); + } + console.error("Loom import queued despite a startup error", error); + } + if (receipt) { + await saveReceipt(payload, { ...receipt, dispatch: "accepted" }).catch( + (error: unknown) => { + console.error("Could not record accepted Loom import dispatch", error); + }, + ); + } +} diff --git a/apps/web/lib/loom-import-upload.ts b/apps/web/lib/loom-import-upload.ts new file mode 100644 index 00000000000..ba779b31b3b --- /dev/null +++ b/apps/web/lib/loom-import-upload.ts @@ -0,0 +1,188 @@ +import { db } from "@cap/database"; +import { videos } from "@cap/database/schema"; +import type { VideoMetadata } from "@cap/database/types"; +import type { Storage } from "@cap/web-backend/src/Storage/index"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { Effect } from "effect"; +import { + LOOM_IMPORT_MAX_BYTES, + LOOM_IMPORT_PART_BYTES, +} from "@/lib/loom-import-limits"; +import { isStorageObjectNotFound } from "@/lib/storage-not-found"; +import { runWorkflowPromise } from "@/lib/workflow-runtime"; + +export { + LOOM_IMPORT_MAX_BYTES, + LOOM_IMPORT_PART_BYTES, +} from "@/lib/loom-import-limits"; + +type Bucket = Effect.Effect.Success< + ReturnType +>[0]; +type UploadReceipt = NonNullable; + +export type LoomMultipartUploadTarget = { + type: "multipart"; + videoId: string; + key: string; + uploadId: string; + partSize: number; + signPartUrl: string; + completeUrl: string; + abortUrl: string; + webhookSecret: string; + loomImportAttemptId: string; +}; + +export async function prepareLoomMultipartUploads({ + video, + bucket, + attemptId, + sourceKey, + webhookBaseUrl, + webhookSecret, +}: { + video: typeof videos.$inferSelect; + bucket: Bucket; + attemptId: string; + sourceKey?: string; + webhookBaseUrl: string; + webhookSecret?: string; +}): Promise<{ + sourceUpload?: LoomMultipartUploadTarget; + outputUpload?: LoomMultipartUploadTarget; + existingOutput?: { url: string; objectIdentity: string; fileSize: number }; +}> { + if (bucket.provider !== "s3") return {}; + if (!webhookSecret) + throw new Error( + "Loom multipart uploads require media server authentication", + ); + const run = video.metadata?.loomImportRun; + if (run && !attemptId.startsWith(`${run.runId}:`)) { + throw new Error("Loom import workflow ownership changed"); + } + const previous = video.metadata?.loomImportUpload; + const outputKey = `${video.ownerId}/${video.id}/result.mp4`; + if ( + previous?.ownerId === video.ownerId && + previous.organizationId === video.orgId && + previous.output.key === outputKey + ) { + const output = await bucket.headObject(outputKey).pipe( + Effect.catchIf(isStorageObjectNotFound, () => Effect.succeed(null)), + runWorkflowPromise, + ); + if (output) { + if ( + !output.ETag || + !/^"[^"\r\n]+"$/.test(output.ETag) || + !output.ContentLength || + output.ContentLength > LOOM_IMPORT_MAX_BYTES + ) { + throw new Error("Existing Loom output requires storage reconciliation"); + } + return { + existingOutput: { + url: await bucket + .getInternalSignedObjectUrl(outputKey, { expiresIn: 3 * 60 * 60 }) + .pipe(runWorkflowPromise), + objectIdentity: output.ETag, + fileSize: output.ContentLength, + }, + }; + } + } + if ( + sourceKey && + (!sourceKey.startsWith(`${video.ownerId}/${video.id}/`) || + sourceKey === outputKey) + ) { + throw new Error("Loom import source key does not match the recording"); + } + const reusable = + previous?.attemptId === attemptId && + previous.ownerId === video.ownerId && + previous.organizationId === video.orgId && + previous.output.key === outputKey && + (!sourceKey || previous.source?.key === sourceKey); + let receipt = reusable ? previous : undefined; + const created: Array<{ key: string; uploadId: string }> = []; + const create = async (key: string) => { + const { UploadId: uploadId } = await bucket.multipart + .create(key, { ContentType: "video/mp4" }) + .pipe(runWorkflowPromise); + if (!uploadId) + throw new Error("Storage did not return a multipart upload id"); + const upload = { key, uploadId }; + created.push(upload); + return upload; + }; + try { + if (!receipt) { + receipt = { + attemptId, + ownerId: video.ownerId, + organizationId: video.orgId, + source: sourceKey ? await create(sourceKey) : undefined, + output: await create(outputKey), + }; + const result = await db() + .update(videos) + .set({ + metadata: sql`JSON_SET(COALESCE(${videos.metadata}, JSON_OBJECT()), '$.loomImportUpload', JSON_EXTRACT(${JSON.stringify(receipt)}, '$'))`, + }) + .where( + and( + eq(videos.id, video.id), + eq(videos.ownerId, video.ownerId), + video.orgId ? eq(videos.orgId, video.orgId) : isNull(videos.orgId), + run + ? sql`JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomImportRun.runId')) = ${run.runId}` + : sql`JSON_EXTRACT(${videos.metadata}, '$.loomImportRun') IS NULL`, + previous + ? sql`JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomImportUpload.attemptId')) = ${previous.attemptId}` + : sql`JSON_EXTRACT(${videos.metadata}, '$.loomImportUpload') IS NULL`, + ), + ); + const affectedRows = Array.isArray(result) + ? (result[0] as { affectedRows?: number } | undefined)?.affectedRows + : (result as { affectedRows?: number }).affectedRows; + if (affectedRows !== 1) + throw new Error("Loom import upload ownership changed"); + } + } catch (error) { + await Promise.all( + created.map(({ key, uploadId }) => + bucket.multipart.abort(key, uploadId).pipe( + Effect.catchAll(() => Effect.void), + runWorkflowPromise, + ), + ), + ); + throw error; + } + const target = ( + upload: UploadReceipt["output"], + ): LoomMultipartUploadTarget => { + const callback = (action: string) => + new URL(`/api/webhooks/media-server/multipart/${action}`, webhookBaseUrl) + .href; + return { + type: "multipart", + videoId: video.id, + ...upload, + partSize: LOOM_IMPORT_PART_BYTES, + signPartUrl: callback("sign-part"), + completeUrl: callback("complete"), + abortUrl: callback("abort"), + webhookSecret, + loomImportAttemptId: attemptId, + }; + }; + return { + sourceUpload: + sourceKey && receipt.source ? target(receipt.source) : undefined, + outputUpload: target(receipt.output), + }; +} diff --git a/apps/web/lib/loom-media-dispatch.ts b/apps/web/lib/loom-media-dispatch.ts new file mode 100644 index 00000000000..a9b73e8a639 --- /dev/null +++ b/apps/web/lib/loom-media-dispatch.ts @@ -0,0 +1,76 @@ +import { db } from "@cap/database"; +import { videos } from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import { User, Video } from "@cap/web-domain"; +import { and, eq, or, sql } from "drizzle-orm"; + +export async function recordLoomMediaDispatch({ + videoId, + userId, + runId, + attemptId, + state, + jobId, +}: { + videoId: string; + userId: string; + runId: string; + attemptId: string; + state: "accepted" | "rejected" | "uncertain"; + jobId?: string; +}) { + const receipt = { + runId, + dispatch: "accepted", + mediaDispatch: { attemptId, videoId, state, jobId: jobId ?? null }, + }; + const result = await db() + .update(videos) + .set({ + metadata: sql`JSON_SET(COALESCE(${videos.metadata}, JSON_OBJECT()), '$.loomImportRun', JSON_MERGE_PATCH(COALESCE(JSON_EXTRACT(${videos.metadata}, '$.loomImportRun'), JSON_OBJECT()), JSON_EXTRACT(${JSON.stringify(receipt)}, '$')))`, + }) + .where( + and( + eq(videos.id, Video.VideoId.make(videoId)), + eq(videos.ownerId, User.UserId.make(userId)), + or( + sql`JSON_EXTRACT(${videos.metadata}, '$.loomImportRun') IS NULL`, + sql`JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomImportRun.runId')) = ${runId}`, + ), + ), + ); + const affectedRows = Array.isArray(result) + ? (result[0] as { affectedRows?: number } | undefined)?.affectedRows + : (result as { affectedRows?: number }).affectedRows; + if (affectedRows !== 1) + throw new Error("Loom media dispatch ownership changed"); +} + +export async function isLoomMediaJobFailed(jobId: string, videoId: string) { + const env = serverEnv(); + if (!env.MEDIA_SERVER_URL || !env.MEDIA_SERVER_WEBHOOK_SECRET) return false; + try { + const response = await fetch( + `${env.MEDIA_SERVER_URL}/video/process/${encodeURIComponent(jobId)}/status`, + { + headers: { "x-media-server-secret": env.MEDIA_SERVER_WEBHOOK_SECRET }, + signal: AbortSignal.timeout(15_000), + redirect: "error", + }, + ); + if (!response.ok) return false; + const progress: unknown = await response.json(); + return ( + typeof progress === "object" && + progress !== null && + "jobId" in progress && + progress.jobId === jobId && + "videoId" in progress && + progress.videoId === videoId && + "phase" in progress && + progress.phase === "error" + ); + } catch { + return false; + } +} diff --git a/apps/web/lib/loom-migration-inventory.ts b/apps/web/lib/loom-migration-inventory.ts new file mode 100644 index 00000000000..6c335a4c7ae --- /dev/null +++ b/apps/web/lib/loom-migration-inventory.ts @@ -0,0 +1,90 @@ +import { db } from "@cap/database"; +import { + loomMigrationImports, + loomMigrationRequests, + videos, +} from "@cap/database/schema"; +import { and, eq, inArray, isNull, notExists } from "drizzle-orm"; +import { requireMigrationOperator } from "@/lib/loom-concierge"; +import { LoomMigrationError } from "@/lib/loom-migration-state"; + +const RECONCILE_BATCH_SIZE = 100; + +export async function reconcileMissingLoomMigrationImports({ + requestId, + verified, +}: { + requestId: string; + verified: boolean; +}) { + const operator = await requireMigrationOperator(); + if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) + throw new LoomMigrationError("Invalid migration request."); + if (verified !== true) + throw new LoomMigrationError( + "Reconcile deleted recordings against the source list first.", + ); + return db().transaction(async (tx) => { + const [request] = await tx + .select() + .from(loomMigrationRequests) + .where(eq(loomMigrationRequests.id, requestId)) + .limit(1) + .for("update"); + if ( + !request || + request.status === "completed" || + request.activeOrganizationId !== request.organizationId || + (request.activeImportCount > 0 && + (!request.activeImportLeaseUntil || + request.activeImportLeaseUntil > new Date())) + ) { + throw new LoomMigrationError( + "The migration is inactive or imports are starting. Refresh and try again.", + ); + } + const missing = await tx + .select({ videoId: loomMigrationImports.videoId }) + .from(loomMigrationImports) + .leftJoin(videos, eq(videos.id, loomMigrationImports.videoId)) + .where( + and(eq(loomMigrationImports.requestId, requestId), isNull(videos.id)), + ) + .limit(RECONCILE_BATCH_SIZE + 1) + .for("update"); + if (missing.length === 0) return { removedCount: 0, hasMore: false }; + const result = await tx.delete(loomMigrationImports).where( + and( + eq(loomMigrationImports.requestId, requestId), + inArray( + loomMigrationImports.videoId, + missing.slice(0, RECONCILE_BATCH_SIZE).map((row) => row.videoId), + ), + notExists( + tx + .select({ id: videos.id }) + .from(videos) + .where(eq(videos.id, loomMigrationImports.videoId)), + ), + ), + ); + const removedCount = (Array.isArray(result) ? result[0] : result) as { + affectedRows?: number; + }; + if (typeof removedCount.affectedRows !== "number") + throw new Error("Could not verify inventory reconciliation"); + const now = new Date(); + await tx + .update(loomMigrationRequests) + .set({ + lastOperatorUserId: operator.id, + lastOperatorAt: now, + updatedAt: now, + }) + .where(eq(loomMigrationRequests.id, requestId)); + return { + removedCount: removedCount.affectedRows, + hasMore: missing.length > RECONCILE_BATCH_SIZE, + }; + }); +} diff --git a/apps/web/lib/loom-migration-state.ts b/apps/web/lib/loom-migration-state.ts index 79ab2a6c476..34c29adb1a7 100644 --- a/apps/web/lib/loom-migration-state.ts +++ b/apps/web/lib/loom-migration-state.ts @@ -44,6 +44,41 @@ export function normalizeMigrationText( return normalized; } +export function validateLoomMigrationInventory({ + organizationId, + imports, +}: { + organizationId: string; + imports: ReadonlyArray<{ + videoId: string | null; + videoOrganizationId: string | null; + mappingId: string | null; + mappingOrganizationId: string | null; + source: string | null; + phase: string | null; + }>; +}) { + if ( + imports.some( + (row) => + !row.videoId || + row.videoOrganizationId !== organizationId || + row.mappingId !== row.videoId || + row.mappingOrganizationId !== organizationId || + row.source !== "loom", + ) + ) { + throw new LoomMigrationError( + "Loom import recordings or source mappings are missing or changed. Re-import missing sources, then use Reconcile deleted recordings for deleted inventory entries. Restore changed mappings before completion.", + ); + } + if (imports.some((row) => row.phase !== null && row.phase !== "complete")) { + throw new LoomMigrationError( + "Loom imports are still processing or have errors. Resolve them before completion.", + ); + } +} + export function validateOperatorMigrationUpdate({ currentStatus, nextStatus, diff --git a/apps/web/lib/s3-endpoint-policy.ts b/apps/web/lib/s3-endpoint-policy.ts new file mode 100644 index 00000000000..f30b64a45fe --- /dev/null +++ b/apps/web/lib/s3-endpoint-policy.ts @@ -0,0 +1,134 @@ +import { lookup as dnsLookup, type LookupAddress } from "node:dns"; +import { lookup } from "node:dns/promises"; +import { Agent as HttpAgent } from "node:http"; +import { Agent as HttpsAgent } from "node:https"; +import { BlockList, isIP, type LookupFunction } from "node:net"; +import { buildEnv, NODE_ENV } from "@cap/env"; + +const privateRanges = new BlockList(); + +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const) { + privateRanges.addSubnet(network, prefix, "ipv4"); +} + +// BlockList already applies the IPv4 rules to IPv4-mapped addresses +// (::ffff:a.b.c.d and ::ffff:7f00:1). Adding ::ffff:0:0/96 here would also +// match every plain IPv4 address, so only the IPv4-compatible (::/96) and NAT64 +// (64:ff9b::/96) forms are blocked outright. +for (const [network, prefix] of [ + ["::", 96], + ["64:ff9b::", 96], + ["fc00::", 7], + ["fe80::", 10], + ["fec0::", 10], + ["ff00::", 8], +] as const) { + privateRanges.addSubnet(network, prefix, "ipv6"); +} + +export function isPrivateAddress(address: string) { + const family = isIP(address); + if (family === 4) return privateRanges.check(address, "ipv4"); + if (family === 6) return privateRanges.check(address, "ipv6"); + return true; +} + +export function shouldBlockPrivateS3Endpoints() { + return buildEnv.NEXT_PUBLIC_IS_CAP === "true" && NODE_ENV === "production"; +} + +export async function isBlockedS3Endpoint( + endpoint: string, + resolve: (hostname: string) => Promise = async (hostname) => + (await lookup(hostname, { all: true, verbatim: true })).map( + (entry) => entry.address, + ), +) { + let url: URL; + try { + url = new URL(endpoint); + } catch { + return true; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return true; + if (url.username || url.password) return true; + + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, ""); + if (!hostname) return true; + if ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".internal") + ) { + return true; + } + + if (isIP(hostname)) return isPrivateAddress(hostname); + + try { + const addresses = await resolve(hostname); + return addresses.length === 0 || addresses.some(isPrivateAddress); + } catch { + return true; + } +} + +export const publicOnlyLookup: LookupFunction = ( + hostname, + options, + callback, +) => { + dnsLookup(hostname, options, (error, address, family) => { + if (error) { + callback(error, address, family); + return; + } + + const addresses: LookupAddress[] = Array.isArray(address) + ? address + : [{ address, family }]; + const blocked = addresses.find((entry) => isPrivateAddress(entry.address)); + if (blocked) { + const blockedError: NodeJS.ErrnoException = new Error( + `Refused to connect to private address ${blocked.address}`, + ); + blockedError.code = "ECONNREFUSED"; + callback(blockedError, address, family); + return; + } + + callback(null, address, family); + }); +}; + +export const blockedS3EndpointMessage = + "Invalid endpoint. Please provide a public S3-compatible endpoint URL."; + +export const isDisallowedS3Endpoint = async (endpoint: string) => + Boolean(endpoint) && + shouldBlockPrivateS3Endpoints() && + (await isBlockedS3Endpoint(endpoint)); + +export const publicOnlyS3Agents = () => + shouldBlockPrivateS3Endpoints() + ? { + httpAgent: new HttpAgent({ lookup: publicOnlyLookup }), + httpsAgent: new HttpsAgent({ lookup: publicOnlyLookup }), + } + : undefined; diff --git a/apps/web/lib/segments-audio-download.ts b/apps/web/lib/segments-audio-download.ts index 3103e6515a4..8c7342c98d6 100644 --- a/apps/web/lib/segments-audio-download.ts +++ b/apps/web/lib/segments-audio-download.ts @@ -1,4 +1,5 @@ import { createWriteStream } from "node:fs"; +import { pipeline } from "node:stream/promises"; /** * Byte-level segment assembly. Lives apart from the pure planning helpers in @@ -83,15 +84,9 @@ export async function downloadConcatenatedSegments( ); const fetchImpl = options.fetchImpl ?? fetch; - const stream = createWriteStream(outputPath); let totalBytes = 0; - const write = (chunk: Buffer) => - new Promise((resolve, reject) => { - stream.write(chunk, (error) => (error ? reject(error) : resolve())); - }); - - try { + async function* downloadSegments() { for (let start = 0; start < urls.length; start += concurrency) { const batch = urls.slice(start, start + concurrency); const buffers = await Promise.all( @@ -113,12 +108,11 @@ export async function downloadConcatenatedSegments( `Segment audio exceeds ${MAX_SEGMENTS_AUDIO_BYTES} bytes`, ); } - await write(buffer); + yield buffer; } } - } finally { - await new Promise((resolve) => stream.end(() => resolve())); } + await pipeline(downloadSegments(), createWriteStream(outputPath)); return totalBytes; } diff --git a/apps/web/lib/seo-metadata.ts b/apps/web/lib/seo-metadata.ts index dade659baa2..99da8b2cbcb 100644 --- a/apps/web/lib/seo-metadata.ts +++ b/apps/web/lib/seo-metadata.ts @@ -229,7 +229,7 @@ export const seoMetadata = { "open-source-screen-recorder": { title: "Open Source Screen Recorder — Free, Private, Self-Hostable | Cap", description: - "Cap is the leading open-source screen recorder for Mac and Windows. Audit the code, self-host your recordings, and own your data. MIT-licensed, 4K quality, no watermarks.", + "Cap is the leading open-source screen recorder for Mac and Windows. Audit the code, self-host your recordings, and own your data. AGPLv3 and MIT crates, 4K quality, no watermarks.", keywords: [ "open source screen recorder", "open source screen recording", @@ -317,7 +317,7 @@ export const seoMetadata = { title: "Self-Hosted Screen Recording — Own Your Data, No Vendor Lock-In | Cap", description: - "Cap lets you self-host screen recordings on your own S3-compatible storage. Connect AWS S3, Cloudflare R2, or MinIO — recordings go directly to your infrastructure. Open source, MIT-licensed, free to use.", + "Cap lets you self-host screen recordings on your own S3-compatible storage. Connect AWS S3, Cloudflare R2, or MinIO — recordings go directly to your infrastructure. Open source with most code under AGPLv3, free to use.", keywords: [ "self hosted screen recording", "self hosted screen recorder", diff --git a/apps/web/lib/share-playback.ts b/apps/web/lib/share-playback.ts index 00da5402807..bb8625a2c40 100644 --- a/apps/web/lib/share-playback.ts +++ b/apps/web/lib/share-playback.ts @@ -20,9 +20,9 @@ export const getSharePlaybackUrl = (video: SharePlaybackVideo) => updatedAt: video.updatedAt.toISOString(), }); const [bucket] = yield* Storage.getAccessForVideo(loadedVideo); - return yield* bucket.getSignedObjectUrl( - `${video.owner.id}/${video.id}/result.mp4`, - ); + const source = Video.Video.getSource(loadedVideo); + if (!(source instanceof Video.Mp4Source)) return null; + return yield* bucket.getSignedObjectUrl(source.getFileKey()); }) .pipe(runPromise) .catch(() => null); diff --git a/apps/web/lib/share-video-metadata.ts b/apps/web/lib/share-video-metadata.ts index 969d25e531a..73fb96cd29b 100644 --- a/apps/web/lib/share-video-metadata.ts +++ b/apps/web/lib/share-video-metadata.ts @@ -15,6 +15,8 @@ export type ShareVideoMetadataInput = { name: string; sourceType: ShareVideoSourceType; webUrl: string; + width: number | null; + height: number | null; /** * Two surfaces only answer on the default Cap origin: `proxy.ts` redirects * `/embed/` away from a custom domain, and `parseCapShareUrl` accepts share @@ -79,6 +81,8 @@ export const buildShareVideoMetadata = ({ webUrl, canonicalWebUrl, advertiseIframelyPlayer = false, + width, + height, }: ShareVideoMetadataInput): Metadata => { const urls = getShareVideoUrls({ videoId, @@ -88,6 +92,10 @@ export const buildShareVideoMetadata = ({ }); const title = `${name} | Cap Recording`; const description = "Watch this video on Cap"; + const hasDimensions = + width != null && height != null && width > 0 && height > 0; + const playerWidth = hasDimensions ? width : PLAYER_WIDTH; + const playerHeight = hasDimensions ? height : PLAYER_HEIGHT; return { title, @@ -100,7 +108,7 @@ export const buildShareVideoMetadata = ({ rel: "iframely player", url: urls.playerUrl, type: "text/html", - media: "(aspect-ratio: 16/9)", + media: `(aspect-ratio: ${playerWidth}/${playerHeight})`, }, ], }, @@ -142,8 +150,8 @@ export const buildShareVideoMetadata = ({ { url: urls.streamUrl, secureUrl: urls.streamUrl, - width: PLAYER_WIDTH, - height: PLAYER_HEIGHT, + width: playerWidth, + height: playerHeight, type: urls.streamContentType, }, ], @@ -156,8 +164,8 @@ export const buildShareVideoMetadata = ({ players: { playerUrl: urls.playerUrl, streamUrl: urls.streamUrl, - width: PLAYER_WIDTH, - height: PLAYER_HEIGHT, + width: playerWidth, + height: playerHeight, }, }, other: { diff --git a/apps/web/lib/storage-not-found.ts b/apps/web/lib/storage-not-found.ts new file mode 100644 index 00000000000..642ff187328 --- /dev/null +++ b/apps/web/lib/storage-not-found.ts @@ -0,0 +1,25 @@ +// A transient HEAD failure (5xx, SlowDown, network) must not read as "missing": +// save-edits would then copy the edited result over the preserved original. +export function isStorageObjectNotFound(error: unknown): boolean { + const visited = new Set(); + let value: unknown = error; + for (let depth = 0; depth < 16; depth++) { + if (typeof value !== "object" || value === null || visited.has(value)) + return false; + visited.add(value); + const record = value as Record; + if (record.name === "NoSuchKey" || record.name === "NotFound") return true; + if (record.name === "GoogleDriveRequestError" && record.status === 404) + return true; + const metadata = record.$metadata; + if ( + typeof metadata === "object" && + metadata !== null && + "httpStatusCode" in metadata && + metadata.httpStatusCode === 404 + ) + return true; + value = record.cause; + } + return false; +} diff --git a/apps/web/lib/video-edits.ts b/apps/web/lib/video-edits.ts index 94ce746eeaf..83ad8685366 100644 --- a/apps/web/lib/video-edits.ts +++ b/apps/web/lib/video-edits.ts @@ -520,8 +520,17 @@ export function getTimelineDisplaySegments( const normalized = normalizeTimelineState(state); const keepRanges = getTimelineKeepRanges(normalized); const segments: VideoTimelineDisplaySegment[] = []; + let deletedRangeIndex = 0; + let deletedBefore = 0; for (const range of keepRanges) { + let deletedRange = normalized.deletedRanges[deletedRangeIndex]; + while (deletedRange && deletedRange.end <= range.start + EPSILON) { + deletedBefore += deletedRange.end - deletedRange.start; + deletedRangeIndex++; + deletedRange = normalized.deletedRanges[deletedRangeIndex]; + } + const boundaries = [ range.start, ...normalized.splitPoints.filter( @@ -536,11 +545,8 @@ export function getTimelineDisplaySegments( if (end - start < MIN_RANGE_DURATION) continue; const id = getSegmentId(start, end); - const displayStart = mapTimelineSourceTimeToDisplayTime( - normalized, - start, - ); - const displayEnd = mapTimelineSourceTimeToDisplayTime(normalized, end); + const displayStart = roundEditTime(Math.max(0, start - deletedBefore)); + const displayEnd = roundEditTime(Math.max(0, end - deletedBefore)); if (displayEnd - displayStart < MIN_RANGE_DURATION) continue; segments.push({ @@ -565,6 +571,7 @@ export function getTimelineDisplaySplitPoints( const segments = getTimelineDisplaySegments(normalized); const sortedSplitPoints = [...normalized.splitPoints].sort((a, b) => a - b); const markers: VideoTimelineDisplaySplitPoint[] = []; + let splitIndex = 0; for (let index = 0; index < segments.length - 1; index++) { const current = segments[index]; @@ -576,11 +583,17 @@ export function getTimelineDisplaySplitPoints( sourceTimes.push(next.start); } - const splitIndices = sortedSplitPoints.flatMap((point, splitIndex) => - sourceTimes.some((sourceTime) => Math.abs(point - sourceTime) <= EPSILON) - ? [splitIndex] - : [], - ); + const splitIndices: number[] = []; + for (const sourceTime of sourceTimes) { + let point = sortedSplitPoints[splitIndex]; + while (point !== undefined && point - sourceTime <= EPSILON) { + if (Math.abs(point - sourceTime) <= EPSILON) { + splitIndices.push(splitIndex); + } + splitIndex++; + point = sortedSplitPoints[splitIndex]; + } + } const time = current.displayEnd; markers.push({ id: `${roundEditTime(time)}:${sourceTimes.map(roundEditTime).join(":")}`, diff --git a/apps/web/lib/video-pipeline-recovery.ts b/apps/web/lib/video-pipeline-recovery.ts index 060df34392d..67b2fda0835 100644 --- a/apps/web/lib/video-pipeline-recovery.ts +++ b/apps/web/lib/video-pipeline-recovery.ts @@ -23,8 +23,13 @@ import { import { start } from "workflow/api"; import { isAiGenerationEnabledForUser } from "@/lib/ai-generation-entitlement"; import { startAiGeneration } from "@/lib/generate-ai"; +import { + getLoomImportRunState, + LOOM_IMPORT_CONFIRMATION_MESSAGE, + LoomImportStartError, + startLoomImportWorkflow, +} from "@/lib/loom-import-start"; import { transcribeVideo } from "@/lib/transcribe"; -import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; import { processVideoWorkflow } from "@/workflows/process-video"; export const STALLED_PIPELINE_MIN_AGE_MS = 60 * 60 * 1000; @@ -40,11 +45,13 @@ const STALLED_MEDIA_PROCESSING_MESSAGES = [ "Queued for video processing...", "Queued for Loom import processing...", "Processing recovery will retry automatically", + LOOM_IMPORT_CONFIRMATION_MESSAGE, ] as const; type PipelineRecoveryStatus = | "started" | "already-claimed" + | "reconciliation-required" | "retry-scheduled" | "failed"; @@ -128,6 +135,7 @@ type MediaCandidate = { bucketId: string | null; rawFileKey: string; loomVideoId: string | null; + metadata: VideoMetadata | null; processingMessage: string; updatedAt: Date; }; @@ -135,13 +143,43 @@ type MediaCandidate = { async function recoverMediaCandidate( candidate: MediaCandidate, ): Promise { + const receipt = candidate.metadata?.loomImportRun; + const receiptCondition = candidate.loomVideoId + ? receipt + ? sql`EXISTS (SELECT 1 FROM ${videos} WHERE ${videos.id} = ${candidate.videoId} AND JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomImportRun.runId')) = ${receipt.runId})` + : sql`EXISTS (SELECT 1 FROM ${videos} WHERE ${videos.id} = ${candidate.videoId} AND JSON_EXTRACT(${videos.metadata}, '$.loomImportRun') IS NULL)` + : undefined; + if (candidate.loomVideoId) { + const state = await getLoomImportRunState(receipt); + if (state === "active") return "already-claimed"; + if (!receipt || state !== "inactive") { + const result = await db() + .update(videoUploads) + .set({ + processingMessage: LOOM_IMPORT_CONFIRMATION_MESSAGE, + updatedAt: new Date(), + }) + .where( + and( + eq(videoUploads.videoId, candidate.videoId), + eq(videoUploads.phase, "processing"), + eq(videoUploads.updatedAt, candidate.updatedAt), + receiptCondition, + ), + ); + return getAffectedRows(result) === 1 + ? "reconciliation-required" + : "already-claimed"; + } + } + const claimedAt = new Date(Math.floor(Date.now() / 1000) * 1000); const claimResult = await db() .update(videoUploads) .set({ processingProgress: 0, processingMessage: "Restarting video processing", processingError: null, - updatedAt: new Date(), + updatedAt: claimedAt, }) .where( and( @@ -151,6 +189,7 @@ async function recoverMediaCandidate( eq(videoUploads.processingMessage, candidate.processingMessage), eq(videoUploads.rawFileKey, candidate.rawFileKey), eq(videoUploads.updatedAt, candidate.updatedAt), + receiptCondition, ), ); @@ -158,16 +197,14 @@ async function recoverMediaCandidate( try { if (candidate.loomVideoId) { - await start(importLoomVideoWorkflow, [ - { - videoId: candidate.videoId, - userId: candidate.userId, - rawFileKey: candidate.rawFileKey, - bucketId: candidate.bucketId, - loomVideoId: candidate.loomVideoId, - reuseExistingRawUpload: true, - }, - ]); + await startLoomImportWorkflow({ + videoId: candidate.videoId, + userId: candidate.userId, + rawFileKey: candidate.rawFileKey, + bucketId: candidate.bucketId, + loomVideoId: candidate.loomVideoId, + reuseExistingRawUpload: true, + }); } else { await start(processVideoWorkflow, [ { @@ -180,20 +217,27 @@ async function recoverMediaCandidate( } return "started"; - } catch { + } catch (error) { + const needsReconciliation = + Boolean(candidate.loomVideoId) && + (!(error instanceof LoomImportStartError) || !error.canRetry); await db() .update(videoUploads) .set({ - processingMessage: "Processing recovery will retry automatically", + processingMessage: needsReconciliation + ? LOOM_IMPORT_CONFIRMATION_MESSAGE + : "Processing recovery will retry automatically", updatedAt: new Date(), }) .where( and( eq(videoUploads.videoId, candidate.videoId), eq(videoUploads.phase, "processing"), + eq(videoUploads.updatedAt, claimedAt), + eq(videoUploads.processingMessage, "Restarting video processing"), ), ); - return "retry-scheduled"; + return needsReconciliation ? "reconciliation-required" : "retry-scheduled"; } } @@ -300,6 +344,7 @@ export async function recoverStalledVideoPipeline({ bucketId: videos.bucket, rawFileKey: videoUploads.rawFileKey, loomVideoId: importedVideos.sourceId, + metadata: videos.metadata, processingMessage: videoUploads.processingMessage, updatedAt: videoUploads.updatedAt, }) diff --git a/apps/web/lib/video-processing-recovery.ts b/apps/web/lib/video-processing-recovery.ts index c7fce3e6685..4bdd3d8a753 100644 --- a/apps/web/lib/video-processing-recovery.ts +++ b/apps/web/lib/video-processing-recovery.ts @@ -1,5 +1,6 @@ import { db } from "@cap/database"; import { importedVideos, videos, videoUploads } from "@cap/database/schema"; +import type { VideoMetadata } from "@cap/database/types"; import type { S3Bucket, Video } from "@cap/web-domain"; import { and, @@ -14,9 +15,14 @@ import { sql, } from "drizzle-orm"; import { start } from "workflow/api"; +import { + getLoomImportRunState, + LOOM_IMPORT_CONFIRMATION_MESSAGE, + LoomImportStartError, + startLoomImportWorkflow, +} from "@/lib/loom-import-start"; import { setVideoProcessingError } from "@/lib/video-processing"; import { WORKFLOW_UPGRADE_ERROR_FRAGMENT } from "@/lib/workflow-recovery"; -import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; import { processVideoWorkflow } from "@/workflows/process-video"; export const VIDEO_PROCESSING_RECOVERY_BATCH_SIZE = 20; @@ -44,6 +50,7 @@ export type FailedVideoProcessingRecoveryStatus = | "already-claimed" | "retry-scheduled" | "missing-source" + | "reconciliation-required" | "failed"; export type FailedVideoProcessingRecoverySummary = { @@ -129,16 +136,62 @@ async function recoverLoomCandidate({ loomVideoId, rawFileKey: existingRawFileKey, phase, + uploadUpdatedAt, + receipt, }: { videoId: Video.VideoId; userId: string; bucketId: S3Bucket.S3BucketId | null; loomVideoId: string; rawFileKey: string | null; - phase: "uploading" | "error"; + phase: "uploading" | "processing" | "error"; + uploadUpdatedAt: Date; + receipt: VideoMetadata["loomImportRun"]; }): Promise { + const runState = await getLoomImportRunState(receipt); + const receiptCondition = receipt + ? sql`EXISTS (SELECT 1 FROM ${videos} WHERE ${videos.id} = ${videoId} AND JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomImportRun.runId')) = ${receipt.runId})` + : sql`EXISTS (SELECT 1 FROM ${videos} WHERE ${videos.id} = ${videoId} AND JSON_EXTRACT(${videos.metadata}, '$.loomImportRun') IS NULL)`; + if (runState === "active") { + await db() + .update(videoUploads) + .set({ updatedAt: new Date() }) + .where( + and( + eq(videoUploads.videoId, videoId), + eq(videoUploads.phase, phase), + eq(videoUploads.updatedAt, uploadUpdatedAt), + receiptCondition, + ), + ); + return "already-claimed"; + } + if ( + runState === "pending" || + runState === "unconfirmed" || + (phase === "processing" && !receipt) + ) { + const result = await db() + .update(videoUploads) + .set({ + processingMessage: LOOM_IMPORT_CONFIRMATION_MESSAGE, + updatedAt: new Date(), + }) + .where( + and( + eq(videoUploads.videoId, videoId), + eq(videoUploads.phase, phase), + eq(videoUploads.updatedAt, uploadUpdatedAt), + receiptCondition, + ), + ); + return getAffectedRows(result) === 1 + ? "reconciliation-required" + : "already-claimed"; + } const rawFileKey = existingRawFileKey ?? `${userId}/${videoId}/raw-upload.mp4`; + const claimedAt = new Date(Math.floor(Date.now() / 1000) * 1000); const claimResult = await db() .update(videoUploads) .set({ @@ -147,21 +200,38 @@ async function recoverLoomCandidate({ processingMessage: LOOM_IMPORT_RECOVERY_MESSAGE, processingError: null, rawFileKey, - updatedAt: new Date(), + updatedAt: claimedAt, }) .where( and( eq(videoUploads.videoId, videoId), eq(videoUploads.phase, phase), + eq(videoUploads.updatedAt, uploadUpdatedAt), + receiptCondition, ...(phase === "error" ? [ eq(videoUploads.rawFileKey, rawFileKey), recoverableProcessingError(), ] - : [ - isNull(videoUploads.rawFileKey), - eq(videoUploads.processingMessage, LOOM_IMPORT_PENDING_MESSAGE), - ]), + : phase === "processing" + ? [ + existingRawFileKey + ? eq(videoUploads.rawFileKey, existingRawFileKey) + : isNull(videoUploads.rawFileKey), + ] + : [ + isNull(videoUploads.rawFileKey), + or( + eq( + videoUploads.processingMessage, + LOOM_IMPORT_PENDING_MESSAGE, + ), + eq( + videoUploads.processingMessage, + LOOM_IMPORT_CONFIRMATION_MESSAGE, + ), + ), + ]), ), ); @@ -170,21 +240,23 @@ async function recoverLoomCandidate({ } try { - await start(importLoomVideoWorkflow, [ - { - videoId, - userId, - rawFileKey, - bucketId, - loomDownloadUrl: "", - loomVideoId, - reuseExistingRawUpload: phase === "error", - }, - ]); + await startLoomImportWorkflow({ + videoId, + userId, + rawFileKey, + bucketId, + loomDownloadUrl: "", + loomVideoId, + reuseExistingRawUpload: + phase !== "uploading" && existingRawFileKey !== null, + }); return "started"; } catch (error) { + if (!(error instanceof LoomImportStartError) || !error.canRetry) { + return "already-claimed"; + } const message = error instanceof Error ? error.message : String(error); - if (phase === "error") { + if (phase !== "uploading") { await db() .update(videoUploads) .set({ @@ -199,6 +271,7 @@ async function recoverLoomCandidate({ and( eq(videoUploads.videoId, videoId), eq(videoUploads.phase, "processing"), + eq(videoUploads.updatedAt, claimedAt), eq(videoUploads.rawFileKey, rawFileKey), eq(videoUploads.processingMessage, LOOM_IMPORT_RECOVERY_MESSAGE), ), @@ -220,6 +293,7 @@ async function recoverLoomCandidate({ and( eq(videoUploads.videoId, videoId), eq(videoUploads.phase, "processing"), + eq(videoUploads.updatedAt, claimedAt), eq(videoUploads.rawFileKey, rawFileKey), eq(videoUploads.processingMessage, LOOM_IMPORT_RECOVERY_MESSAGE), ), @@ -236,36 +310,90 @@ export async function recoverFailedVideoProcessing({ const staleLoomBefore = new Date( Date.now() - LOOM_IMPORT_RECOVERY_MIN_AGE_MS, ); - const pendingLoomCandidates = await db() - .select({ - videoId: videos.id, - userId: videos.ownerId, - bucketId: videos.bucket, - loomVideoId: importedVideos.sourceId, - rawFileKey: videoUploads.rawFileKey, - }) - .from(videos) - .innerJoin(videoUploads, eq(videos.id, videoUploads.videoId)) - .innerJoin( - importedVideos, - and( - eq(videos.id, importedVideos.id), - eq(videos.orgId, importedVideos.orgId), - eq(importedVideos.source, "loom"), - ), - ) - .where( - and( - eq(importedVideos.source, "loom"), - eq(videoUploads.phase, "uploading"), - isNull(videoUploads.rawFileKey), - eq(videoUploads.processingMessage, LOOM_IMPORT_PENDING_MESSAGE), - lte(videoUploads.updatedAt, staleLoomBefore), - ), + const selectStaleLoomCandidates = (phase: "uploading" | "processing") => + db() + .select({ + videoId: videos.id, + userId: videos.ownerId, + bucketId: videos.bucket, + loomVideoId: importedVideos.sourceId, + rawFileKey: videoUploads.rawFileKey, + uploadUpdatedAt: videoUploads.updatedAt, + metadata: videos.metadata, + phase: videoUploads.phase, + }) + .from(videos) + .innerJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .innerJoin( + importedVideos, + and( + eq(videos.id, importedVideos.id), + eq(videos.orgId, importedVideos.orgId), + eq(importedVideos.source, "loom"), + ), + ) + .where( + and( + eq(importedVideos.source, "loom"), + eq(videoUploads.phase, phase), + phase === "uploading" + ? or( + and( + isNull(videoUploads.rawFileKey), + eq( + videoUploads.processingMessage, + LOOM_IMPORT_PENDING_MESSAGE, + ), + ), + and( + sql`JSON_EXTRACT(${videos.metadata}, '$.loomImportRun.runId') IS NOT NULL`, + eq( + videoUploads.processingMessage, + LOOM_IMPORT_CONFIRMATION_MESSAGE, + ), + ), + ) + : and( + sql`JSON_EXTRACT(${videos.metadata}, '$.loomImportRun.runId') IS NOT NULL`, + or( + eq( + videoUploads.processingMessage, + LOOM_IMPORT_RECOVERY_MESSAGE, + ), + eq(videoUploads.processingMessage, "Retrying Loom import..."), + eq( + videoUploads.processingMessage, + "Processing Loom video...", + ), + eq( + videoUploads.processingMessage, + LOOM_IMPORT_CONFIRMATION_MESSAGE, + ), + ), + ), + lte(videoUploads.updatedAt, staleLoomBefore), + ), + ) + .orderBy(asc(videoUploads.updatedAt)) + .limit(Math.min(limit, LOOM_IMPORT_RECOVERY_BATCH_SIZE)); + const pendingLoomCandidates = ( + await Promise.all([ + selectStaleLoomCandidates("uploading"), + selectStaleLoomCandidates("processing"), + ]) + ) + .flat() + .sort( + (left, right) => + left.uploadUpdatedAt.getTime() - right.uploadUpdatedAt.getTime(), ) - .orderBy(asc(videoUploads.updatedAt)) - .limit(Math.min(limit, LOOM_IMPORT_RECOVERY_BATCH_SIZE)); + .slice(0, Math.min(limit, LOOM_IMPORT_RECOVERY_BATCH_SIZE)); + const loomRecoveryBudget = Math.min(limit, LOOM_IMPORT_RECOVERY_BATCH_SIZE); + const startupCountWithFailedImport = Math.min( + pendingLoomCandidates.length, + Math.max(0, loomRecoveryBudget - 1), + ); const failedLoomUploads = await db() .select({ videoId: videos.id, @@ -273,6 +401,8 @@ export async function recoverFailedVideoProcessing({ orgId: videos.orgId, bucketId: videos.bucket, rawFileKey: videoUploads.rawFileKey, + uploadUpdatedAt: videoUploads.updatedAt, + metadata: videos.metadata, }) .from(videos) .innerJoin(videoUploads, eq(videos.id, videoUploads.videoId)) @@ -285,13 +415,7 @@ export async function recoverFailedVideoProcessing({ ), ) .orderBy(asc(videoUploads.updatedAt)) - .limit( - Math.max( - 0, - Math.min(limit, LOOM_IMPORT_RECOVERY_BATCH_SIZE) - - pendingLoomCandidates.length, - ), - ); + .limit(Math.max(0, loomRecoveryBudget - startupCountWithFailedImport)); const failedLoomCandidates = ( await Promise.all( @@ -315,10 +439,15 @@ export async function recoverFailedVideoProcessing({ ).filter((candidate) => candidate !== null); const loomCandidates = [ - ...pendingLoomCandidates.map((candidate) => ({ - ...candidate, - phase: "uploading" as const, - })), + ...pendingLoomCandidates + .slice(0, Math.max(0, loomRecoveryBudget - failedLoomCandidates.length)) + .map((candidate) => ({ + ...candidate, + phase: + candidate.phase === "processing" + ? ("processing" as const) + : ("uploading" as const), + })), ...failedLoomCandidates.map((candidate) => ({ ...candidate, phase: "error" as const, @@ -374,6 +503,8 @@ export async function recoverFailedVideoProcessing({ loomVideoId: candidate.loomVideoId, rawFileKey: candidate.rawFileKey, phase: candidate.phase, + uploadUpdatedAt: candidate.uploadUpdatedAt, + receipt: candidate.metadata?.loomImportRun, }); } catch (error) { status = "failed"; diff --git a/apps/web/lib/video-processing.ts b/apps/web/lib/video-processing.ts index 54333399da1..1e43663f6a0 100644 --- a/apps/web/lib/video-processing.ts +++ b/apps/web/lib/video-processing.ts @@ -67,6 +67,7 @@ export async function transitionVideoToProcessing({ : and( eq(videoUploads.videoId, videoId), ne(videoUploads.phase, "processing"), + ne(videoUploads.phase, "generating_thumbnail"), // "complete" is terminal for implicit starts: a multipart // completion retried after an uncertain network outcome can // land after the first processing run already finished, and @@ -89,7 +90,10 @@ export async function transitionVideoToProcessing({ throw new Error("No upload record found"); } - if (upload.phase === "processing") { + if ( + upload.phase === "processing" || + upload.phase === "generating_thumbnail" + ) { return "already-processing"; } diff --git a/apps/web/lib/video-status-query.ts b/apps/web/lib/video-status-query.ts new file mode 100644 index 00000000000..722dcbbe492 --- /dev/null +++ b/apps/web/lib/video-status-query.ts @@ -0,0 +1,28 @@ +import type { Video } from "@cap/web-domain"; +import { + getVideoStatus, + type VideoStatusResult, +} from "@/actions/videos/get-status"; + +export class VideoStatusNotFoundError extends Error { + constructor() { + super("This video is no longer available"); + this.name = "VideoStatusNotFoundError"; + } +} + +export function videoStatusQueryOptions(videoId: Video.VideoId) { + return { + queryKey: ["videoStatus", videoId], + queryFn: async (): Promise => { + const result = await getVideoStatus(videoId); + if ("success" in result) { + if (result.reason === "not_found") throw new VideoStatusNotFoundError(); + throw new Error("Failed to fetch video status"); + } + return result; + }, + retry: (failureCount: number, error: Error) => + !(error instanceof VideoStatusNotFoundError) && failureCount < 3, + }; +} diff --git a/apps/web/lib/video-storage-key.ts b/apps/web/lib/video-storage-key.ts new file mode 100644 index 00000000000..6d22be47d12 --- /dev/null +++ b/apps/web/lib/video-storage-key.ts @@ -0,0 +1,12 @@ +export function isVideoStorageKey( + video: { id: string; ownerId: string }, + key: string, +) { + return ( + key.startsWith(`${video.ownerId}/${video.id}/`) && + !key.includes("\\") && + key + .split("/") + .every((part) => part.length > 0 && part !== "." && part !== "..") + ); +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 657f6a3bba2..ce8e40d1738 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -96,6 +96,23 @@ const nextConfig = { }, ].filter(Boolean), }, + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-DNS-Prefetch-Control", value: "off" }, + ], + }, + { + source: "/((?!api/auth/).*)", + headers: [ + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + ], + }, + ]; + }, async rewrites() { return [ { diff --git a/apps/web/package.json b/apps/web/package.json index 66e0418cfd0..3508f64ca60 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -136,6 +136,7 @@ "react-rnd": "^10.4.1", "react-scroll-parallax": "^3.4.5", "react-tooltip": "^5.26.3", + "react-tweet": "^3.3.1", "recharts": "^3.3.0", "rehype-pretty-code": "^0.14.1", "remark-gfm": "^4.0.1", diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 3f2a43baca6..80acfcc16e7 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -35,7 +35,42 @@ const mainOrigins = [ addHttps(serverEnv().VERCEL_PROJECT_PRODUCTION_URL_HOST), ].filter(Boolean) as string[]; +const FRAME_PROTECTED_PATHS = [ + "/dashboard", + "/onboarding", + "/invite", + "/cli/authorize", + "/mcp/authorize", + "/admin", + "/signup", + "/verify-otp", +]; + +const isFrameProtectedPath = (path: string) => + FRAME_PROTECTED_PATHS.some( + (protectedPath) => + path === protectedPath || path.startsWith(`${protectedPath}/`), + ); + +const setFrameHeaders = (response: NextResponse) => { + response.headers.set("X-Frame-Options", "SAMEORIGIN"); + response.headers.set( + "Content-Security-Policy", + "frame-ancestors https://cap.so", + ); + return response; +}; + export async function proxy(request: NextRequest) { + const path = new URL(request.url).pathname; + + if (path.startsWith("/login")) return setFrameHeaders(NextResponse.next()); + + const response = await route(request); + return isFrameProtectedPath(path) ? setFrameHeaders(response) : response; +} + +async function route(request: NextRequest) { const url = new URL(request.url); const path = url.pathname; @@ -43,16 +78,6 @@ export async function proxy(request: NextRequest) { return NextResponse.redirect(new URL("/dashboard/caps", url.origin)); } - if (path.startsWith("/login")) { - const response = NextResponse.next(); - response.headers.set("X-Frame-Options", "SAMEORIGIN"); - response.headers.set( - "Content-Security-Policy", - "frame-ancestors https://cap.so", - ); - return response; - } - const shareIframeRedirectUrl = getShareIframeRedirectUrl({ method: request.method, requestUrl: request.url, diff --git a/apps/web/public/backgrounds/hero/rays-far.webp b/apps/web/public/backgrounds/hero/rays-far.webp new file mode 100644 index 0000000000000000000000000000000000000000..8b3a49b8c62367d2c723a4a376c041123edcdd6e GIT binary patch literal 42836 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 V2d@RC-C@gR#3`%#5C8xG000=t2=V{` literal 0 HcmV?d00001 diff --git a/apps/web/public/backgrounds/hero/rays-near.webp b/apps/web/public/backgrounds/hero/rays-near.webp new file mode 100644 index 0000000000000000000000000000000000000000..d48a34cb08e4363c9dcb90c9ea675b590cd6e9e8 GIT binary patch literal 37410 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 + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/public/blog/gpui/cursor-windows.svg b/apps/web/public/blog/gpui/cursor-windows.svg new file mode 100644 index 00000000000..81a3415ada9 --- /dev/null +++ b/apps/web/public/blog/gpui/cursor-windows.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/public/blog/gpui/editor-061.webp b/apps/web/public/blog/gpui/editor-061.webp new file mode 100644 index 0000000000000000000000000000000000000000..89799cb670084b697bd8674e53cae652bc982dbc GIT binary patch literal 129026 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

PXqn9ZkXy0Mu<%bgE}B5L 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%0DL&67cL02&p7YdN3>wyiS)JqMWCD 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%0A)D$%Uc2VB#l*+K*=#A9JzWHodrpS=3aR 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%0AE0$zn&vv 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

+^`B+Ti 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

eNk3tcZap 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 LNkvXXu0mjfc3&v_ literal 0 HcmV?d00001 diff --git a/apps/web/public/blog/gpui/main-window-061-glass.png b/apps/web/public/blog/gpui/main-window-061-glass.png new file mode 100644 index 0000000000000000000000000000000000000000..3261aad2be4d20ff29d23b2d2c2e13a2f27f3b09 GIT binary patch literal 297100 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

t#cMttFkI8I=vmJo7w;O8 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⁡L8g9>g3XU(L3m~RhlC4@N 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

o(Wy8ngXRw&Sqh`7cKDNr2>Z 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 PZa1%e(~TxX9DV;Edr9hh literal 0 HcmV?d00001 diff --git a/apps/web/public/blog/rebuilding-cap-in-gpui.jpg b/apps/web/public/blog/rebuilding-cap-in-gpui.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e60cb1bb09ed1f6b12ff88d6f102714901f6fae6 GIT binary patch literal 231649 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ν) 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

    r8H1M)jS~Lh&goTFBMI$KV5Y4NRlq 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

    O%E4qm74bWfA47ys28j;rn&UOzm)CebJ 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 { "use workflow"; - const generation = await ensureWorkflowJob(payload); + // Every step costs a queue hop (~0.7 s in prod), so skip the lookup when the caller already knows the generation. + const generation = payload.generation ?? (await ensureWorkflowJob(payload)); let completedJobId: string | undefined; let mediaServerUnavailable = false; let retainedAttempt: DesktopRecordingAttempt | undefined; @@ -137,19 +143,21 @@ export async function finalizeDesktopRecordingWorkflow( } const attempt = next.attempt; try { - let committed = await commitDesktopRecordingAttempt(attempt); - while (committed === "progress") { - committed = await commitDesktopRecordingAttempt(attempt); + let committed = await commitAndStartDesktopRecordingAttempt(attempt); + while (committed.committed === "progress") { + committed = await commitAndStartDesktopRecordingAttempt(attempt); } - if (committed !== "ready") { - return { success: false, reason: committed }; + if (committed.committed !== "ready") { + return { success: false, reason: committed.committed }; } - if (await completeWithoutMediaServerIfUnavailable(attempt)) { - mediaServerUnavailable = true; - break; - } - const started = await startDesktopRecordingJob(attempt); + const started = committed.dispatched + ? committed.started + : await startDesktopRecordingJob(attempt); if (typeof started === "object") { + if (started.status === "media-server-unconfigured") { + mediaServerUnavailable = true; + break; + } if (started.status === "capacity") { retainedAttempt = attempt; await sleep(started.retryAfterMs); @@ -190,7 +198,7 @@ export async function finalizeDesktopRecordingWorkflow( if (retained !== "retry") return { success: false, reason: retained }; } } - for (let attempt = 0; ; attempt++) { + for (let attempt = 0; attempt < TRANSCRIPTION_QUEUE_MAX_ATTEMPTS; attempt++) { let queued: boolean; try { queued = await queueFinalizedRecordingTranscription( @@ -201,7 +209,9 @@ export async function finalizeDesktopRecordingWorkflow( queued = false; } if (queued) break; - await sleep(Math.min(15_000 * 2 ** Math.min(attempt, 5), 300_000)); + if (attempt + 1 < TRANSCRIPTION_QUEUE_MAX_ATTEMPTS) { + await sleep(Math.min(15_000 * 2 ** Math.min(attempt, 5), 300_000)); + } } return mediaServerUnavailable ? { success: false, reason: "media-server-unconfigured" } @@ -268,6 +278,36 @@ export async function commitDesktopRecordingAttempt( ): Promise { "use step"; + return commitDesktopRecordingBatch(attempt); +} + +// Each step costs a queue hop, so a commit that finishes inside its batch budget dispatches in the same step. +// A slow commit returns without dispatching and the workflow runs startDesktopRecordingJob as its own step. +export async function commitAndStartDesktopRecordingAttempt( + attempt: DesktopRecordingAttempt, +): Promise< + | { committed: SourceCommitResult; dispatched?: undefined } + | { committed: "ready"; dispatched: true; started: DesktopRecordingDispatch } +> { + "use step"; + + const startedAt = Date.now(); + const committed = await commitDesktopRecordingBatch(attempt); + if ( + committed !== "ready" || + Date.now() - startedAt >= SOURCE_COMMIT_BATCH_DURATION_MS + ) + return { committed }; + return { + committed, + dispatched: true, + started: await dispatchDesktopRecordingJob(attempt), + }; +} + +async function commitDesktopRecordingBatch( + attempt: DesktopRecordingAttempt, +): Promise { const startedAt = Date.now(); for (let index = 0; index < SOURCE_COMMIT_BATCH_MAX_CHECKPOINTS; index++) { try { @@ -293,6 +333,7 @@ export async function commitDesktopRecordingAttempt( async function advanceDesktopRecordingAttempt( attempt: DesktopRecordingAttempt, ): Promise { + const startedAt = performance.now(); const current = await getProcessingState(attempt); if (!current || current.attemptId !== attempt.attemptId) return "superseded"; if (current.source) return "ready"; @@ -306,24 +347,45 @@ async function advanceDesktopRecordingAttempt( try { const checkpoint = await initializeSourceCommitCheckpoint(attempt); if (!checkpoint) return "superseded"; + const loadedAt = performance.now(); + let heartbeatMs = 0; const result = await advanceDesktopRecordingSourceCommit( video, checkpoint, current.verification ?? undefined, async () => { - if (!(await heartbeatAttempt(attempt))) { + const heartbeatAt = performance.now(); + const alive = await heartbeatAttempt(attempt); + heartbeatMs += performance.now() - heartbeatAt; + if (!alive) { throw new Error("Recording source commit lease was superseded"); } }, ); - if ("checkpoint" in result) { - return (await persistSourceCommitCheckpoint(attempt, result.checkpoint)) - ? "progress" - : "superseded"; - } - return (await persistCommittedSource(attempt, result.source)) - ? "ready" - : "superseded"; + const advancedAt = performance.now(); + const outcome = + "checkpoint" in result + ? (await persistSourceCommitCheckpoint(attempt, result.checkpoint)) + ? "progress" + : "superseded" + : (await persistCommittedSource(attempt, result.source)) + ? "ready" + : "superseded"; + console.info( + JSON.stringify({ + event: "desktop_source_commit_advance", + videoId: attempt.videoId, + attemptId: attempt.attemptId, + phase: checkpoint.phase, + next: "checkpoint" in result ? result.checkpoint.phase : "committed", + outcome, + loadMs: Math.round(loadedAt - startedAt), + heartbeatMs: Math.round(heartbeatMs), + advanceMs: Math.round(advancedAt - loadedAt), + persistMs: Math.round(performance.now() - advancedAt), + }), + ); + return outcome; } catch (error) { const code = getSourceErrorCode(error); if (!code) throw error; @@ -339,8 +401,6 @@ async function advanceDesktopRecordingAttempt( async function completeWithoutMediaServerIfUnavailable( attempt: DesktopRecordingAttempt, ): Promise { - "use step"; - if (serverEnv().MEDIA_SERVER_URL) return false; const deferred = await deferWithoutMediaServer(attempt); if (deferred) { @@ -356,11 +416,13 @@ async function completeWithoutMediaServerIfUnavailable( async function buildDesktopSegmentsOutput({ video, attempt, + sourceBytes, }: { video: typeof videos.$inferSelect; attempt: DesktopRecordingAttemptFence; + sourceBytes: number; }) { - const [bucket] = await Storage.getAccessForVideo( + const [bucket, customBucket] = await Storage.getAccessForVideo( decodeStorageVideo(video), ).pipe(runWorkflowPromise); const outputKey = getDesktopRecordingOutputKey( @@ -390,7 +452,11 @@ async function buildDesktopSegmentsOutput({ url: outputPresignedUrl, ifNoneMatch: "*", }; - if (bucket.provider === "s3" && webhookSecret) { + const singlePut = + bucket.provider === "s3" && + Option.isNone(customBucket) && + sourceBytes <= SINGLE_PUT_OUTPUT_MAX_SOURCE_BYTES; + if (bucket.provider === "s3" && webhookSecret && !singlePut) { const multipart = await bucket.multipart .create(outputKey, { ContentType: "video/mp4" }) .pipe(runWorkflowPromise); @@ -446,16 +512,28 @@ async function buildDesktopSegmentsOutput({ }; } -export async function startDesktopRecordingJob( - attempt: DesktopRecordingAttempt, -): Promise< +type DesktopRecordingDispatch = | string | undefined | { status: "deferred" } | { status: "capacity"; retryAfterMs: number } -> { + | { status: "media-server-unconfigured" }; + +export async function startDesktopRecordingJob( + attempt: DesktopRecordingAttempt, +): Promise { "use step"; + return dispatchDesktopRecordingJob(attempt); +} + +async function dispatchDesktopRecordingJob( + attempt: DesktopRecordingAttempt, +): Promise { + if (await completeWithoutMediaServerIfUnavailable(attempt)) { + return { status: "media-server-unconfigured" }; + } + const current = await getProcessingState(attempt); if (!current || current.attemptId !== attempt.attemptId || !current.source) { throw new Error("Recording processing attempt was superseded"); @@ -497,16 +575,17 @@ export async function startDesktopRecordingJob( } throw error; }); + const sourceBytes = urls.sourceObjects.reduce( + (total, object) => total + object.size, + 0, + ); let downloadBudgetBytes: number; try { downloadBudgetBytes = await reserveMediaProcessingBudget({ videoId: current.videoId, generation: current.generation, attemptId: attempt.attemptId, - sourceBytes: urls.sourceObjects.reduce( - (total, object) => total + object.size, - 0, - ), + sourceBytes, }); } catch (error) { if (error instanceof MediaProcessingBudgetError) { @@ -574,7 +653,7 @@ export async function startDesktopRecordingJob( audioLevels: true, ...context, ...urls, - ...(await buildDesktopSegmentsOutput({ video, attempt })), + ...(await buildDesktopSegmentsOutput({ video, attempt, sourceBytes })), manifestSha256: current.source.manifestSha256, }; } @@ -780,7 +859,7 @@ async function queueFinalizedRecordingTranscription( ); if (result.success) return true; console.warn( - "[finalizeDesktopRecordingWorkflow] Transcription enqueue will retry", + "[finalizeDesktopRecordingWorkflow] Transcription enqueue failed", { videoId, message: result.message }, ); } catch (error) { diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts index e821e92d885..2177e5f00a7 100644 --- a/apps/web/workflows/import-loom-video.ts +++ b/apps/web/workflows/import-loom-video.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { db } from "@cap/database"; import { agentApiOperations, videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; @@ -6,11 +5,32 @@ import { Storage } from "@cap/web-backend/src/Storage/index"; import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect } from "effect"; -import { FatalError, sleep } from "workflow"; +import { + FatalError, + getWorkflowMetadata, + RetryableError, + sleep, +} from "workflow"; +import { + getLoomDownloadUrl, + getReusableLoomDownloadUrl, + LoomDownloadTemporaryError, +} from "@/lib/loom-download-url"; +import { cleanupLoomFileSource } from "@/lib/loom-file-cleanup"; +import { + LOOM_IMPORT_MAX_BYTES, + type LoomMultipartUploadTarget, + prepareLoomMultipartUploads, +} from "@/lib/loom-import-upload"; +import { + isLoomMediaJobFailed, + recordLoomMediaDispatch, +} from "@/lib/loom-media-dispatch"; import { createMediaServerCapacityError, isMediaServerCapacityError, } from "@/lib/media-server-backpressure"; +import { isStorageObjectNotFound } from "@/lib/storage-not-found"; import { runWorkflowPromise } from "@/lib/workflow-runtime"; import { type ProcessedVideoMetadata, @@ -27,6 +47,7 @@ interface ImportLoomPayload { loomDownloadUrl?: string; agentOperationId?: string; reuseExistingRawUpload?: boolean; + cleanupRawUpload?: boolean; } const MINIMUM_VIDEO_SIZE = 1024; @@ -44,72 +65,32 @@ function isStreamingUrl(url: string): boolean { return path.endsWith(".m3u8") || path.endsWith(".mpd"); } -async function fetchLoomCdnUrl( - videoId: string, - endpoint: string, - includeBody: boolean, -): Promise { +async function fetchFreshLoomDownloadUrl( + loomVideoId: string, + existingUrl?: string, +): Promise { + "use step"; + + const reusableUrl = getReusableLoomDownloadUrl(existingUrl); + if (reusableUrl) return reusableUrl; try { - const options: RequestInit = { - method: "POST", - signal: AbortSignal.timeout(15_000), - }; - if (includeBody) { - options.headers = { - "Content-Type": "application/json", - Accept: "application/json", - }; - options.body = JSON.stringify({ - anonID: randomUUID(), - deviceID: null, - force_original: false, - password: null, + const url = await getLoomDownloadUrl(loomVideoId); + if (url) return url; + } catch (error) { + if (error instanceof LoomDownloadTemporaryError) { + throw new RetryableError(error.message, { + retryAfter: error.retryAfterMs, }); } - - const response = await fetch( - `https://www.loom.com/api/campaigns/sessions/${videoId}/${endpoint}`, - options, - ); - - if (!response.ok || response.status === 204) return null; - - const text = await response.text(); - if (!text.trim()) return null; - - const data = JSON.parse(text) as { url?: string }; - return data.url ?? null; - } catch { - return null; - } -} - -async function fetchFreshLoomDownloadUrl(loomVideoId: string): Promise { - const requestVariants: Array<{ endpoint: string; includeBody: boolean }> = [ - { endpoint: "transcoded-url", includeBody: true }, - { endpoint: "raw-url", includeBody: true }, - { endpoint: "transcoded-url", includeBody: false }, - { endpoint: "raw-url", includeBody: false }, - ]; - - let fallbackStreamingUrl: string | null = null; - - for (const { endpoint, includeBody } of requestVariants) { - const url = await fetchLoomCdnUrl(loomVideoId, endpoint, includeBody); - if (!url) continue; - - if (!isStreamingUrl(url)) return url; - - if (!fallbackStreamingUrl) fallbackStreamingUrl = url; + throw error; } - - if (fallbackStreamingUrl) return fallbackStreamingUrl; - throw new FatalError( "Could not retrieve a download URL from Loom. The video may be private, password-protected, or the link may have expired.", ); } +fetchFreshLoomDownloadUrl.maxRetries = 8; + interface VideoProcessingResult { success: boolean; message: string; @@ -125,6 +106,41 @@ interface LoomProcessingInput { importFromLoom?: boolean; } +async function requireLoomMediaServerSupport() { + "use step"; + + const env = serverEnv(); + if (!env.MEDIA_SERVER_URL) + throw new FatalError("MEDIA_SERVER_URL is not configured"); + const response = await fetch(`${env.MEDIA_SERVER_URL}/video/status`, { + headers: env.MEDIA_SERVER_WEBHOOK_SECRET + ? { "x-media-server-secret": env.MEDIA_SERVER_WEBHOOK_SECRET } + : undefined, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) + throw new RetryableError( + "Could not verify Loom import worker capabilities", + { retryAfter: 60_000 }, + ); + const capabilities: unknown = await response.json(); + if ( + typeof capabilities !== "object" || + capabilities === null || + !("loomMultipartUploads" in capabilities) || + capabilities.loomMultipartUploads !== true || + !("loomImportMaxBytes" in capabilities) || + capabilities.loomImportMaxBytes !== LOOM_IMPORT_MAX_BYTES + ) { + throw new RetryableError( + "The media server must be updated for 10 GB Loom imports", + { retryAfter: 60_000 }, + ); + } +} + +requireLoomMediaServerSupport.maxRetries = 60; + async function claimAgentImport(operationId: string | undefined) { "use step"; @@ -195,6 +211,7 @@ export async function importLoomVideoWorkflow( ): Promise { "use workflow"; + const { workflowRunId } = getWorkflowMetadata(); if (!(await claimAgentImport(payload.agentOperationId))) { return { success: true, message: "Loom import is already running" }; } @@ -204,13 +221,29 @@ export async function importLoomVideoWorkflow( let processingInput: LoomProcessingInput = reuseExistingRawUpload ? {} : { importFromLoom: true }; + await requireLoomMediaServerSupport(); let metadata: ProcessedVideoMetadata; for (let processingAttempt = 0; ; processingAttempt++) { + let mediaJobId: string; let capacityRetryCount = 0; + let previousSourceUrl = + processingAttempt === 0 ? payload.loomDownloadUrl : undefined; while (true) { try { - await processVideoOnMediaServer(payload, processingInput); + const loomSourceUrl = processingInput.importFromLoom + ? await fetchFreshLoomDownloadUrl( + payload.loomVideoId, + previousSourceUrl, + ) + : undefined; + previousSourceUrl = loomSourceUrl; + mediaJobId = await processVideoOnMediaServer( + payload, + loomSourceUrl, + `${workflowRunId}:${processingAttempt}`, + workflowRunId, + ); break; } catch (error) { if (!isMediaServerCapacityError(error)) throw error; @@ -229,6 +262,11 @@ export async function importLoomVideoWorkflow( ) { throw error; } + if (!(await confirmLoomMediaJobFailure(mediaJobId, payload.videoId))) { + throw new FatalError( + "The previous Loom worker must be reconciled before retrying", + ); + } await markLoomImportWaitingForCapacity(payload.videoId); if (await hasExistingLoomUpload(payload)) processingInput = {}; await sleep(15_000 * (processingAttempt + 1)); @@ -236,6 +274,7 @@ export async function importLoomVideoWorkflow( } await saveMetadataAndComplete(payload.videoId, metadata); await completeAgentImport(payload.agentOperationId, payload.videoId); + if (payload.cleanupRawUpload) await cleanupLoomFileSource(payload); return { success: true, @@ -268,6 +307,12 @@ function getInputExtension(url: string): string | undefined { return undefined; } +async function confirmLoomMediaJobFailure(jobId: string, videoId: string) { + "use step"; + + return isLoomMediaJobFailed(jobId, videoId); +} + async function hasExistingLoomUpload( payload: ImportLoomPayload, ): Promise { @@ -290,18 +335,33 @@ async function hasExistingLoomUpload( metadata: video.metadata, }); const [bucket] = yield* Storage.getAccessForVideo(videoDomain); - const object = yield* bucket.headObject(payload.rawFileKey); - return (object.ContentLength ?? 0) >= MINIMUM_VIDEO_SIZE; - }).pipe( - Effect.catchAll(() => Effect.succeed(false)), - runWorkflowPromise, - ); + const previous = video.metadata?.loomImportUpload; + if ( + previous?.ownerId === video.ownerId && + previous.organizationId === video.orgId && + previous.output.key === `${video.ownerId}/${video.id}/result.mp4` + ) { + const savedOutput = yield* bucket.headObject(previous.output.key).pipe( + Effect.map( + (object) => (object.ContentLength ?? 0) >= MINIMUM_VIDEO_SIZE, + ), + Effect.catchIf(isStorageObjectNotFound, () => Effect.succeed(false)), + ); + if (savedOutput) return true; + } + return yield* bucket.headObject(payload.rawFileKey).pipe( + Effect.map((object) => (object.ContentLength ?? 0) >= MINIMUM_VIDEO_SIZE), + Effect.catchIf(isStorageObjectNotFound, () => Effect.succeed(false)), + ); + }).pipe(runWorkflowPromise); } async function waitForRetry(delayMs: number): Promise { await new Promise((resolve) => setTimeout(resolve, delayMs)); } +class LoomMediaRequestRejectedError extends Error {} + async function startMediaServerProcessJob( mediaServerUrl: string, body: { @@ -309,6 +369,10 @@ async function startMediaServerProcessJob( userId: string; videoUrl: string; sourcePresignedUrl?: string; + sourceUpload?: LoomMultipartUploadTarget; + outputUpload?: LoomMultipartUploadTarget; + existingOutput?: { url: string; objectIdentity: string; fileSize: number }; + maxImportBytes: number; outputPresignedUrl: string; thumbnailPresignedUrl: string; previewGifPresignedUrl: string; @@ -317,6 +381,10 @@ async function startMediaServerProcessJob( inputExtension?: string; priority: "bulk"; }, + recordDispatch: ( + state: "accepted" | "rejected" | "uncertain", + jobId?: string, + ) => Promise, ): Promise { for (let attempt = 0; attempt < MEDIA_SERVER_START_MAX_ATTEMPTS; attempt++) { const headers: Record = { @@ -326,7 +394,9 @@ async function startMediaServerProcessJob( headers["x-media-server-secret"] = body.webhookSecret; } - const endpoint = body.sourcePresignedUrl ? "import" : "process"; + const endpoint = + body.sourcePresignedUrl || body.sourceUpload ? "import" : "process"; + await recordDispatch("uncertain"); const response = await fetch(`${mediaServerUrl}/video/${endpoint}`, { method: "POST", headers, @@ -334,10 +404,22 @@ async function startMediaServerProcessJob( }); if (response.ok) { - const { jobId } = (await response.json()) as { jobId: string }; + const accepted: unknown = await response.json(); + if ( + typeof accepted !== "object" || + accepted === null || + !("jobId" in accepted) || + typeof accepted.jobId !== "string" || + !accepted.jobId + ) { + throw new Error("Loom worker acceptance requires job reconciliation"); + } + const jobId = accepted.jobId; + await recordDispatch("accepted", jobId).catch((error) => { + console.error("Could not record accepted Loom media dispatch", error); + }); return jobId; } - const errorData = (await response.json().catch(() => ({}))) as { error?: string; code?: string; @@ -348,9 +430,9 @@ async function startMediaServerProcessJob( errorData.details || "Video processing failed to start"; const shouldRetry = - response.status === 503 && - (errorData.code === "SERVER_BUSY" || - errorMessage.includes("Server is busy")); + response.status === 503 && errorData.code === "SERVER_BUSY"; + if ((response.status >= 400 && response.status < 500) || shouldRetry) + await recordDispatch("rejected"); if (shouldRetry && attempt < MEDIA_SERVER_START_MAX_ATTEMPTS - 1) { await waitForRetry(MEDIA_SERVER_START_RETRY_BASE_MS * 2 ** attempt); @@ -366,6 +448,8 @@ async function startMediaServerProcessJob( }); } + if (response.status >= 400 && response.status < 500) + throw new LoomMediaRequestRejectedError(errorMessage); throw new Error(errorMessage); } @@ -374,11 +458,13 @@ async function startMediaServerProcessJob( async function processVideoOnMediaServer( payload: ImportLoomPayload, - processingInput: LoomProcessingInput, -): Promise { + loomSourceUrl: string | undefined, + attemptId: string, + runId: string, +): Promise { "use step"; - const { videoId, userId, rawFileKey, loomVideoId } = payload; + const { videoId, userId, rawFileKey } = payload; const mediaServerUrl = serverEnv().MEDIA_SERVER_URL; if (!mediaServerUrl) { @@ -388,15 +474,16 @@ async function processVideoOnMediaServer( const webhookBaseUrl = serverEnv().MEDIA_SERVER_WEBHOOK_URL || serverEnv().WEB_URL; - const loomSourceUrl = processingInput.importFromLoom - ? await fetchFreshLoomDownloadUrl(loomVideoId) - : undefined; const { rawVideoUrl, sourcePresignedUrl, outputPresignedUrl, thumbnailPresignedUrl, previewGifPresignedUrl, + sourceUpload, + outputUpload, + existingOutput, + abortUploads, } = await Effect.gen(function* () { const [video] = yield* Effect.promise(() => db() @@ -407,6 +494,9 @@ async function processVideoOnMediaServer( if (!video) { return yield* Effect.fail(new FatalError("Video does not exist")); } + if (video.ownerId !== userId) { + return yield* Effect.fail(new FatalError("Loom import owner changed")); + } const videoDomain = Video.Video.decodeSync({ ...video, bucketId: video.bucket, @@ -456,45 +546,105 @@ async function processVideoOnMediaServer( }, { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, ); + const multipart = yield* Effect.promise(() => + prepareLoomMultipartUploads({ + video, + bucket, + attemptId, + sourceKey: + loomSourceUrl && !isStreamingUrl(loomSourceUrl) + ? rawFileKey + : undefined, + webhookBaseUrl, + webhookSecret: serverEnv().MEDIA_SERVER_WEBHOOK_SECRET || undefined, + }), + ); return { - rawVideoUrl, - sourcePresignedUrl, + rawVideoUrl: multipart.existingOutput?.url ?? rawVideoUrl, + sourcePresignedUrl: + multipart.sourceUpload || multipart.existingOutput + ? undefined + : sourcePresignedUrl, outputPresignedUrl, thumbnailPresignedUrl, previewGifPresignedUrl, + ...multipart, + abortUploads: async () => { + await Promise.all( + [multipart.sourceUpload, multipart.outputUpload] + .filter( + (upload): upload is LoomMultipartUploadTarget => + upload !== undefined, + ) + .map((upload) => + bucket.multipart.abort(upload.key, upload.uploadId).pipe( + Effect.catchAll(() => Effect.void), + runWorkflowPromise, + ), + ), + ); + }, }; }).pipe(runWorkflowPromise); const webhookUrl = `${webhookBaseUrl}/api/webhooks/media-server/progress?retryable=true`; const webhookSecret = serverEnv().MEDIA_SERVER_WEBHOOK_SECRET; - await db() - .update(videoUploads) - .set({ - phase: "processing", - processingProgress: 0, - processingMessage: "Starting video processing...", - processingError: null, - updatedAt: new Date(), - }) - .where(eq(videoUploads.videoId, videoId as Video.VideoId)); + try { + await db() + .update(videoUploads) + .set({ + phase: "processing", + processingProgress: 0, + processingMessage: "Starting video processing...", + processingError: null, + updatedAt: new Date(), + }) + .where(eq(videoUploads.videoId, videoId as Video.VideoId)); + } catch (error) { + await abortUploads(); + throw error; + } - await startMediaServerProcessJob(mediaServerUrl, { - videoId, - userId, - videoUrl: rawVideoUrl, - sourcePresignedUrl, - outputPresignedUrl, - thumbnailPresignedUrl, - previewGifPresignedUrl, - webhookUrl, - webhookSecret: webhookSecret || undefined, - inputExtension: getInputExtension(rawVideoUrl), - priority: "bulk", - }); + try { + return await startMediaServerProcessJob( + mediaServerUrl, + { + videoId, + userId, + videoUrl: rawVideoUrl, + sourcePresignedUrl, + sourceUpload, + outputUpload, + existingOutput, + maxImportBytes: LOOM_IMPORT_MAX_BYTES, + outputPresignedUrl, + thumbnailPresignedUrl, + previewGifPresignedUrl, + webhookUrl, + webhookSecret: webhookSecret || undefined, + inputExtension: getInputExtension(rawVideoUrl), + priority: "bulk", + }, + (state, jobId) => + recordLoomMediaDispatch({ + videoId, + userId, + runId, + attemptId, + state, + jobId, + }), + ); + } catch (error) { + if (error instanceof LoomMediaRequestRejectedError) await abortUploads(); + throw error; + } } +processVideoOnMediaServer.maxRetries = 0; + async function saveMetadataAndComplete( videoId: string, metadata: { duration: number; width: number; height: number; fps: number }, diff --git a/apps/web/workflows/live-transcribe.ts b/apps/web/workflows/live-transcribe.ts index 04cab104464..591dc8948e8 100644 --- a/apps/web/workflows/live-transcribe.ts +++ b/apps/web/workflows/live-transcribe.ts @@ -8,13 +8,13 @@ import { type User, Video, } from "@cap/web-domain"; -import { AssemblyAI } from "assemblyai"; import { and, eq, isNull, sql } from "drizzle-orm"; import { Either, Option, Schema } from "effect"; import { isAiGenerationEnabledForUser } from "@/lib/ai-generation-entitlement"; import { ASSEMBLYAI_SPEECH_MODELS, ASSEMBLYAI_SUPPORTED_LANGUAGES, + createAssemblyAIClient, getAssemblyAITranscriptionOptions, } from "@/lib/assemblyai"; import { @@ -433,9 +433,7 @@ async function processNextLiveChunk(options: { // ffmpeg — the binary doesn't exist in the serverless runtime. const audioBuffer = await downloadConcatenatedSegmentsToBuffer(segmentUrls); - const client = new AssemblyAI({ - apiKey: serverEnv().ASSEMBLY_API_KEY as string, - }); + const client = createAssemblyAIClient(); const language = ( ASSEMBLYAI_SUPPORTED_LANGUAGES as readonly string[] ).includes(options.language) diff --git a/apps/web/workflows/transcribe.ts b/apps/web/workflows/transcribe.ts index a281d25f155..7be4f9171a1 100644 --- a/apps/web/workflows/transcribe.ts +++ b/apps/web/workflows/transcribe.ts @@ -17,12 +17,14 @@ import { parseAiGenerationLanguage, Video, } from "@cap/web-domain"; -import { AssemblyAI } from "assemblyai"; import { and, eq, isNull, sql } from "drizzle-orm"; import { Either, Option, Schema } from "effect"; import { FatalError } from "workflow"; import { start } from "workflow/api"; -import { getAssemblyAITranscriptionOptions } from "@/lib/assemblyai"; +import { + createAssemblyAIClient, + getAssemblyAITranscriptionOptions, +} from "@/lib/assemblyai"; import { ENHANCED_AUDIO_CONTENT_TYPE, ENHANCED_AUDIO_EXTENSION, @@ -782,9 +784,7 @@ async function transcribeWithAssemblyAI( } const audioBuffer = Buffer.from(await audioResponse.arrayBuffer()); - const client = new AssemblyAI({ - apiKey: serverEnv().ASSEMBLY_API_KEY as string, - }); + const client = createAssemblyAIClient(); const transcript = await client.transcripts.transcribe({ audio: audioBuffer, @@ -835,9 +835,7 @@ async function transcribeEditTranscriptWithAssemblyAI( } const audioBuffer = Buffer.from(await audioResponse.arrayBuffer()); - const client = new AssemblyAI({ - apiKey: serverEnv().ASSEMBLY_API_KEY as string, - }); + const client = createAssemblyAIClient(); const transcript = await client.transcripts.transcribe({ audio: audioBuffer, ...getAssemblyAITranscriptionOptions("auto"), diff --git a/bun.lock b/bun.lock index c16651409a3..7d6d401f860 100644 --- a/bun.lock +++ b/bun.lock @@ -367,6 +367,7 @@ "react-rnd": "^10.4.1", "react-scroll-parallax": "^3.4.5", "react-tooltip": "^5.26.3", + "react-tweet": "^3.3.1", "recharts": "^3.3.0", "rehype-pretty-code": "^0.14.1", "remark-gfm": "^4.0.1", @@ -4999,6 +5000,8 @@ "react-tooltip": ["react-tooltip@5.30.1", "", { "dependencies": { "@floating-ui/dom": "1.7.6", "classnames": "2.5.1" }, "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0" } }, "sha512-1lSPLQXuVooePxadUpmcwLgOsF1mIty7UZTJ9XnyfX4drOzStYs4JMXnazcDLguQr41W5OUZddOp9kfvArdpEQ=="], + "react-tweet": ["react-tweet@3.3.1", "", { "dependencies": { "@swc/helpers": "^0.5.3", "clsx": "^2.0.0", "swr": "^2.2.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-0Gj1YgBTe1K85NBMoWBlUc17Rdc67gIJLlacrVgj+3jWIoXpFfxPdZ1U5OnWkkF9zxhphqs2pY1i//JBfP3Qog=="], + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], @@ -5419,6 +5422,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "swr": ["swr@2.5.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], @@ -6997,6 +7002,8 @@ "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + "react-tweet/@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/crates/audio/src/progressive.rs b/crates/audio/src/progressive.rs index ba9299f376e..702524e859c 100644 --- a/crates/audio/src/progressive.rs +++ b/crates/audio/src/progressive.rs @@ -17,6 +17,8 @@ use std::{ use tokio::sync::watch; const BLOCK_FRAMES: usize = 32_768; +// Eight lanes never run on a decoded block: 8 * 4_800 > BLOCK_FRAMES. +const WAVEFORM_PEAK_LANES: usize = 4; pub fn waveform_peaks<'a>(slices: impl IntoIterator, channels: u16) -> Vec { let block_samples = AudioData::SAMPLE_RATE as usize / 10 * usize::from(channels.max(1)); @@ -25,14 +27,20 @@ pub fn waveform_peaks<'a>(slices: impl IntoIterator, channels: let mut count = 0; for mut slice in slices { while !slice.is_empty() { - let (window, rest) = slice.split_at((block_samples - count).min(slice.len())); - sum = window.iter().fold(sum, |sum, sample| sum + sample.abs()); - count += window.len(); - slice = rest; - if count == block_samples { - peaks.push(sum / count as f32); - sum = 0.0; - count = 0; + if count == 0 && slice.len() >= block_samples * WAVEFORM_PEAK_LANES { + let (group, rest) = slice.split_at(block_samples * WAVEFORM_PEAK_LANES); + push_waveform_peak_lanes(&mut peaks, group, block_samples); + slice = rest; + } else { + let (window, rest) = slice.split_at((block_samples - count).min(slice.len())); + sum = window.iter().fold(sum, |sum, sample| sum + sample.abs()); + count += window.len(); + slice = rest; + if count == block_samples { + peaks.push(sum / count as f32); + sum = 0.0; + count = 0; + } } } } @@ -49,6 +57,27 @@ pub fn waveform_peaks<'a>(slices: impl IntoIterator, channels: peaks } +fn push_waveform_peak_lanes(peaks: &mut Vec, group: &[f32], block_samples: usize) { + let (first, rest) = group.split_at(block_samples); + let (second, rest) = rest.split_at(block_samples); + let (third, fourth) = rest.split_at(block_samples); + let mut first_sum = 0.0_f32; + let mut second_sum = 0.0_f32; + let mut third_sum = 0.0_f32; + let mut fourth_sum = 0.0_f32; + for index in 0..block_samples { + first_sum += first[index].abs(); + second_sum += second[index].abs(); + third_sum += third[index].abs(); + fourth_sum += fourth[index].abs(); + } + let scale = block_samples as f32; + peaks.push(first_sum / scale); + peaks.push(second_sum / scale); + peaks.push(third_sum / scale); + peaks.push(fourth_sum / scale); +} + pub struct DecodedAudio { storage: AudioStorage, channels: u16, @@ -1202,4 +1231,176 @@ mod tests { .is_err() ); } + + fn xorshift_samples(len: usize) -> Vec { + let mut state = 0x1234_5678_u32; + (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state as f32 / u32::MAX as f32 * 2.0 - 1.0 + }) + .collect() + } + + fn sequential_waveform_peaks(samples: &[f32], channels: u16) -> Vec { + let window_samples = 4_800 * usize::from(channels.max(1)); + samples + .chunks(window_samples) + .map(|window| { + let average = window + .iter() + .fold(0.0_f32, |sum, sample| sum + sample.abs()) + / window.len() as f32; + if average > 0.0 { + 20.0 * average.log10() + } else { + -60.0 + } + }) + .collect() + } + + fn assert_peak_bits(actual: &[f32], expected: &[f32], channels: u16, block_len: usize) { + assert_eq!(actual.len(), expected.len(), "{channels} {block_len}"); + for (index, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_eq!( + actual.to_bits(), + expected.to_bits(), + "{channels} {block_len} at {index}" + ); + } + } + + #[test] + fn waveform_peaks_match_a_sequential_sum_across_uneven_blocks() { + let samples = xorshift_samples(48_000 * 3 + 1_234); + for channels in [1, 2] { + let expected = sequential_waveform_peaks(&samples, channels); + let window = 4_800 * usize::from(channels); + for block_len in [ + 1, + 7, + 4_799, + window - 1, + window, + window * WAVEFORM_PEAK_LANES - 1, + window * WAVEFORM_PEAK_LANES, + window * WAVEFORM_PEAK_LANES + 1, + 32_768 * usize::from(channels), + ] { + let blocks: Vec<&[f32]> = samples.chunks(block_len).collect(); + assert_peak_bits( + &waveform_peaks(blocks.iter().copied(), channels), + &expected, + channels, + block_len, + ); + } + let mut with_empty: Vec<&[f32]> = Vec::new(); + for chunk in samples.chunks(7) { + with_empty.push(chunk); + with_empty.push(&[]); + } + assert_peak_bits( + &waveform_peaks(with_empty, channels), + &expected, + channels, + 7, + ); + assert_peak_bits( + &waveform_peaks([&samples[..]], channels), + &expected, + channels, + samples.len(), + ); + } + + let contiguous = xorshift_samples(48_000 * 120 + 17); + for channels in [1, 2] { + let expected = sequential_waveform_peaks(&contiguous, channels); + assert_peak_bits( + &waveform_peaks([&contiguous[..]], channels), + &expected, + channels, + contiguous.len(), + ); + let block_len = 32_768 * usize::from(channels); + let blocks: Vec<&[f32]> = contiguous.chunks(block_len).collect(); + assert_peak_bits( + &waveform_peaks(blocks, channels), + &expected, + channels, + block_len, + ); + } + } + + fn serial_waveform_peaks<'a>( + slices: impl IntoIterator, + channels: u16, + ) -> Vec { + let block_samples = AudioData::SAMPLE_RATE as usize / 10 * usize::from(channels.max(1)); + let mut peaks = Vec::new(); + let mut sum = 0.0_f32; + let mut count = 0; + for mut slice in slices { + while !slice.is_empty() { + let (window, rest) = slice.split_at((block_samples - count).min(slice.len())); + sum = window.iter().fold(sum, |sum, sample| sum + sample.abs()); + count += window.len(); + slice = rest; + if count == block_samples { + peaks.push(sum / count as f32); + sum = 0.0; + count = 0; + } + } + } + if count > 0 { + peaks.push(sum / count as f32); + } + for peak in &mut peaks { + *peak = if *peak > 0.0 { + 20.0 * peak.log10() + } else { + -60.0 + }; + } + peaks + } + + #[test] + #[ignore] + fn benchmark_waveform_peaks() { + use std::{hint::black_box, time::Instant}; + + for (track, channels) in [("mic", 1_u16), ("system audio", 2)] { + let samples = xorshift_samples(48_000 * 3_600 * usize::from(channels)); + let blocks: Vec<&[f32]> = samples.chunks(32_768 * usize::from(channels)).collect(); + let serial = serial_waveform_peaks(blocks.iter().copied(), channels); + let lanes = waveform_peaks(blocks.iter().copied(), channels); + assert_peak_bits(&lanes, &serial, channels, 32_768 * usize::from(channels)); + let mut serial_runs = Vec::new(); + let mut lane_runs = Vec::new(); + for _ in 0..5 { + let start = Instant::now(); + black_box(serial_waveform_peaks(blocks.iter().copied(), channels)); + serial_runs.push(start.elapsed().as_secs_f64() * 1000.0); + let start = Instant::now(); + black_box(waveform_peaks(blocks.iter().copied(), channels)); + lane_runs.push(start.elapsed().as_secs_f64() * 1000.0); + } + serial_runs.sort_by(f64::total_cmp); + lane_runs.sort_by(f64::total_cmp); + println!( + "{{\"track\":\"{track}\",\"channels\":{channels},\"duration_seconds\":3600,\"block_frames\":32768,\"serial_median_ms\":{:.1},\"serial_min_ms\":{:.1},\"lanes_median_ms\":{:.1},\"lanes_min_ms\":{:.1}}}", + serial_runs[serial_runs.len() / 2], + serial_runs[0], + lane_runs[lane_runs.len() / 2], + lane_runs[0] + ); + } + } } diff --git a/crates/camera-effects/src/lib.rs b/crates/camera-effects/src/lib.rs index e6c0d9c4554..2251507c9f0 100644 --- a/crates/camera-effects/src/lib.rs +++ b/crates/camera-effects/src/lib.rs @@ -404,6 +404,7 @@ pub struct BlurProcessor { last_output_status: Option, frame_synchronous: bool, frame_time: Option, + output_format: wgpu::TextureFormat, // Keep last: must drop after every other field (see BlurSessionHandle). _blur_session: BlurSessionHandle, } @@ -560,6 +561,7 @@ impl BlurProcessor { last_output_status: None, frame_synchronous: false, frame_time: None, + output_format, _blur_session: blur_session, }) } @@ -824,13 +826,8 @@ impl BlurProcessor { wgpu::TextureFormat::Rgba16Float, tex_usage, ); - let output_texture = create_rgba_texture( - "Blur Output", - width, - height, - wgpu::TextureFormat::Rgba8Unorm, - tex_usage, - ); + let output_texture = + create_rgba_texture("Blur Output", width, height, self.output_format, tex_usage); let mask_texture = create_mask_texture(device, self.mask_dimensions); self.textures = Some(ProcessorTextures { diff --git a/crates/editor/examples/editor-startup-benchmark.rs b/crates/editor/examples/editor-startup-benchmark.rs index e4a8b44d465..8af88febee2 100644 --- a/crates/editor/examples/editor-startup-benchmark.rs +++ b/crates/editor/examples/editor-startup-benchmark.rs @@ -32,6 +32,18 @@ fn percentile(data: &[f64], p: f64) -> f64 { sorted[idx.min(sorted.len() - 1)] } +fn clone_shared_device( + shared: &cap_rendering::SharedWgpuDevice, +) -> cap_rendering::SharedWgpuDevice { + cap_rendering::SharedWgpuDevice { + instance: shared.instance.clone(), + adapter: shared.adapter.clone(), + device: shared.device.clone(), + queue: shared.queue.clone(), + is_software_adapter: shared.is_software_adapter, + } +} + fn has_flag(args: &[String], flag: &str) -> bool { args.iter().any(|arg| arg == flag) } @@ -43,6 +55,7 @@ fn push_stage(stages: &mut Vec<(&'static str, f64)>, name: &'static str, start: async fn profile_startup_stages( recording_path: PathBuf, + shared_device: Option, ) -> Result, String> { let mut stages = Vec::new(); let total_start = Instant::now(); @@ -64,15 +77,23 @@ async fn profile_startup_stages( )?); push_stage(&mut stages, "recordings metadata", &mut stage_start); - let render_constants = Arc::new( + let render_constants = Arc::new(if let Some(shared) = shared_device { + RenderVideoConstants::new_with_device( + shared, + &recordings.segments, + recording_meta.clone(), + (**meta).clone(), + ) + .map_err(|e| format!("Failed to create render constants: {e}"))? + } else { RenderVideoConstants::new( &recordings.segments, recording_meta.clone(), (**meta).clone(), ) .await - .map_err(|e| format!("Failed to create render constants: {e}"))?, - ); + .map_err(|e| format!("Failed to create render constants: {e}"))? + }); push_stage(&mut stages, "render constants", &mut stage_start); let layers_rx = start_renderer_layers_creation(&render_constants, &project); @@ -100,13 +121,20 @@ async fn profile_startup_stages( Ok(stages) } -async fn run_stage_profile(recording_path: PathBuf, runs: usize) { +async fn run_stage_profile( + recording_path: PathBuf, + runs: usize, + shared_device: Option<&cap_rendering::SharedWgpuDevice>, +) { let mut stage_values: Vec<(&'static str, Vec)> = Vec::new(); for _ in 0..runs { - let stages = profile_startup_stages(recording_path.clone()) - .await - .expect("Failed to profile startup stages"); + let stages = profile_startup_stages( + recording_path.clone(), + shared_device.map(clone_shared_device), + ) + .await + .expect("Failed to profile startup stages"); for (stage_idx, (name, value)) in stages.into_iter().enumerate() { if stage_values.len() <= stage_idx { @@ -167,6 +195,8 @@ async fn main() { let profile_stages = has_flag(&args, "--profile-stages"); let profile_stages_only = has_flag(&args, "--profile-stages-only"); let prewarm_fonts = has_flag(&args, "--prewarm-fonts"); + let shared_device_mode = has_flag(&args, "--shared-device"); + let prewarm_renderer = has_flag(&args, "--prewarm-renderer"); let resolution_base = cap_project::XY::new(1248, 702); println!("{}", "=".repeat(64)); @@ -187,8 +217,44 @@ async fn main() { started.elapsed().as_secs_f64() * 1000.0 ); } + let mut shared_device = None; + if prewarm_renderer { + let started = Instant::now(); + tokio::task::spawn_blocking(cap_rendering::prewarm_fonts) + .await + .expect("Font preparation failed"); + let shared = cap_rendering::create_shared_device() + .await + .expect("Failed to create shared device"); + let (device, queue, software) = ( + shared.device.clone(), + shared.queue.clone(), + shared.is_software_adapter, + ); + let mut layers = tokio::task::spawn_blocking(move || { + cap_rendering::RendererLayers::new_with_options(&device, &queue, software) + }) + .await + .expect("Renderer warmup failed"); + cap_rendering::prewarm_first_frame(clone_shared_device(&shared), &mut layers) + .await + .expect("Renderer warmup frame failed"); + drop(layers); + shared_device = Some(shared); + println!( + "Renderer warmup before startup timing: {:.3}ms", + started.elapsed().as_secs_f64() * 1000.0 + ); + } if profile_stages_only { - run_stage_profile(recording_path, runs).await; + if shared_device_mode && shared_device.is_none() { + shared_device = Some( + cap_rendering::create_shared_device() + .await + .expect("Failed to create shared device"), + ); + } + run_stage_profile(recording_path, runs, shared_device.as_ref()).await; return; } @@ -203,13 +269,20 @@ async fn main() { let (frame_tx, frame_rx) = mpsc::channel::<()>(); let start = Instant::now(); + if (shared_device_mode || prewarm_renderer) && shared_device.is_none() { + shared_device = Some( + cap_rendering::create_shared_device() + .await + .expect("Failed to create shared device"), + ); + } let editor = EditorInstance::new( recording_path.clone(), |_| {}, Box::new(move |_: EditorFrameOutput, _: FrameLayout| { let _ = frame_tx.send(()); }), - None, + shared_device.as_ref().map(clone_shared_device), ) .await .expect("Failed to create editor instance"); @@ -267,6 +340,6 @@ async fn main() { ); if profile_stages { - run_stage_profile(recording_path, runs).await; + run_stage_profile(recording_path, runs, shared_device.as_ref()).await; } } diff --git a/crates/editor/src/audio.rs b/crates/editor/src/audio.rs index f9abf96021d..278a40fa66a 100644 --- a/crates/editor/src/audio.rs +++ b/crates/editor/src/audio.rs @@ -20,8 +20,8 @@ use ringbuf::{ use std::{ collections::{HashMap, VecDeque}, sync::{ - Arc, - atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}, + Arc, Condvar, Mutex, MutexGuard, PoisonError, Weak, + atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}, }, }; use tracing::{info, warn}; @@ -125,6 +125,15 @@ impl AudioSegmentTrack { pub fn offset(&self, offsets: &ClipOffsets) -> f32 { (self.get_offset)(offsets) + self.timing_offset_secs } + + fn same_source(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.data, &other.data) + && std::ptr::fn_addr_eq(self.get_gain, other.get_gain) + && std::ptr::fn_addr_eq(self.get_stereo_mode, other.get_stereo_mode) + && std::ptr::fn_addr_eq(self.get_offset, other.get_offset) + && self.timing_offset_secs.to_bits() == other.timing_offset_secs.to_bits() + && self.is_microphone == other.is_microphone + } } struct TimelineCursor<'a> { @@ -1467,6 +1476,10 @@ fn silent_audio_frame(samples: usize) -> FFAudio { frame } +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + /// Shared state between the background render thread and the audio callback. /// /// The timeline mix is rendered progressively into `samples` (f32 bit @@ -1487,9 +1500,55 @@ struct ProgressiveTimeline { /// Tells the producer to bail out early (playback stopped). stop: AtomicBool, complete: AtomicBool, + /// Pass 1 has written everything it ever will. + pass1_done: AtomicBool, + /// A playback cache keeps this render once its readers are gone. + retained: AtomicBool, + retain_slot: Mutex, + readers: AtomicUsize, + wake: Mutex, + woken: Condvar, + created: std::time::Instant, + paused_at_nanos: AtomicU64, + retired: Mutex, +} + +/// A replaced cached render, released only once this render stops working +/// (finished, stopped or parked), so freeing its buffer never competes with +/// the start of playback. +#[derive(Default)] +struct Retired { + idle: bool, + render: Option, +} + +#[derive(Default)] +struct RetainSlot { + release: Option, + exited: bool, } impl ProgressiveTimeline { + fn new(len: usize, primary_start: usize) -> Self { + Self { + samples: Self::allocate_samples(len), + primary_start, + primary_end: AtomicUsize::new(primary_start), + wrap_end: AtomicUsize::new(0), + stop: AtomicBool::new(false), + complete: AtomicBool::new(false), + pass1_done: AtomicBool::new(false), + retained: AtomicBool::new(false), + retain_slot: Mutex::default(), + readers: AtomicUsize::new(1), + wake: Mutex::new(0), + woken: Condvar::new(), + created: std::time::Instant::now(), + paused_at_nanos: AtomicU64::new(0), + retired: Mutex::default(), + } + } + /// `AtomicU32` has the same size, alignment and bit validity as `u32` /// (documented guarantee), so a zeroed `u32` allocation — which the /// allocator can hand out as untouched zero pages, unlike constructing @@ -1498,6 +1557,107 @@ impl ProgressiveTimeline { let raw = vec![0u32; len].into_boxed_slice(); unsafe { Box::from_raw(Box::into_raw(raw) as *mut [AtomicU32]) } } + + /// Whether every sample in `[from, to)` is already rendered (or will + /// never be written, so it reads as silence either way). + fn covers(&self, from: usize, to: usize) -> bool { + let to = to.min(self.samples.len()); + if from >= to || self.complete.load(Ordering::Acquire) { + return true; + } + let primary = to <= self.primary_start + || self.pass1_done.load(Ordering::Acquire) + || self.primary_end.load(Ordering::Acquire) >= to; + let wrap = from >= self.primary_start + || self.wrap_end.load(Ordering::Acquire) >= to.min(self.primary_start); + primary && wrap + } + + /// Hands the render to a playback cache. Fails once the render thread + /// has exited, since nothing would release it then. + fn retain(&self, release: CacheRelease) -> bool { + let mut slot = lock(&self.retain_slot); + if slot.exited { + return false; + } + slot.release = Some(release); + self.retained.store(true, Ordering::Release); + true + } + + fn retire(&self, previous: Option) { + let mut retired = lock(&self.retired); + if retired.idle { + drop(retired); + drop(previous); + } else { + retired.render = previous; + } + } + + /// Releases the retired render. `idle` marks a render that will not + /// resume, so later retirements are released at once. + fn release_retired(&self, idle: bool) { + let previous = { + let mut retired = lock(&self.retired); + retired.idle |= idle; + retired.render.take() + }; + drop(previous); + } + + fn attach_reader(&self) { + self.readers.fetch_add(1, Ordering::AcqRel); + self.notify(); + } + + fn notify(&self) { + *lock(&self.wake) += 1; + self.woken.notify_all(); + } + + fn wake_generation(&self) -> u64 { + *lock(&self.wake) + } + + /// Parks the render thread until a `notify` after `seen` was read, or + /// the timeout. Callers read `seen` before checking the conditions they + /// wait on, so a notify racing those checks is never missed. + fn wait(&self, seen: u64, timeout: std::time::Duration) { + let deadline = std::time::Instant::now() + timeout; + let mut wake = lock(&self.wake); + while *wake == seen { + let now = std::time::Instant::now(); + if now >= deadline { + return; + } + wake = self + .woken + .wait_timeout(wake, deadline - now) + .unwrap_or_else(PoisonError::into_inner) + .0; + } + } + + fn detach_reader(&self) { + if self.readers.fetch_sub(1, Ordering::AcqRel) == 1 { + self.paused_at_nanos + .store(self.created.elapsed().as_nanos() as u64, Ordering::Release); + } + } + + fn paused_for(&self) -> std::time::Duration { + self.created + .elapsed() + .saturating_sub(std::time::Duration::from_nanos( + self.paused_at_nanos.load(Ordering::Acquire), + )) + } + + fn shut_down(&self) { + self.stop.store(true, Ordering::Release); + self.notify(); + } } const MAX_PROGRESSIVE_BUFFER_BYTES: usize = 512 * 1024 * 1024; @@ -1588,6 +1748,326 @@ pub(crate) fn preparing_audio_output_policy( }) } +const PLAYBACK_AUDIO_IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(120); + +type CacheEntry = Arc>>; + +static PLAYBACK_AUDIO_CACHES: Mutex>>>> = + Mutex::new(Vec::new()); + +/// Drops every cached playback render in the process, so memory returns to +/// what it is on main while paused. Called before exports and on memory +/// pressure. +pub fn release_playback_audio_caches() { + let caches = lock(&PLAYBACK_AUDIO_CACHES) + .iter() + .filter_map(Weak::upgrade) + .collect::>(); + for cache in caches { + *lock(&cache) = None; + } +} + +#[derive(Clone)] +pub struct PlaybackAudioCache { + entry: CacheEntry, + idle_release: std::time::Duration, +} + +impl Default for PlaybackAudioCache { + fn default() -> Self { + Self::with_idle_release(PLAYBACK_AUDIO_IDLE_RELEASE) + } +} + +impl PlaybackAudioCache { + fn with_idle_release(idle_release: std::time::Duration) -> Self { + #[cfg(target_os = "macos")] + memory_pressure::watch(); + let entry = CacheEntry::default(); + let mut caches = lock(&PLAYBACK_AUDIO_CACHES); + caches.retain(|cache| cache.strong_count() > 0); + caches.push(Arc::downgrade(&entry)); + Self { + entry, + idle_release, + } + } + + pub fn clear(&self) { + *lock(&self.entry) = None; + } +} + +#[cfg(target_os = "macos")] +mod memory_pressure { + use std::ffi::c_void; + + #[repr(C)] + struct DispatchSourceType { + _private: [u8; 0], + } + + const DISPATCH_MEMORYPRESSURE_WARN: usize = 0x02; + const DISPATCH_MEMORYPRESSURE_CRITICAL: usize = 0x04; + const QOS_CLASS_UTILITY: isize = 0x11; + + unsafe extern "C" { + static _dispatch_source_type_memorypressure: DispatchSourceType; + fn dispatch_get_global_queue(identifier: isize, flags: usize) -> *mut c_void; + fn dispatch_source_create( + kind: *const DispatchSourceType, + handle: usize, + mask: usize, + queue: *mut c_void, + ) -> *mut c_void; + fn dispatch_source_set_event_handler_f( + source: *mut c_void, + handler: extern "C" fn(*mut c_void), + ); + fn dispatch_resume(object: *mut c_void); + } + + pub(super) extern "C" fn on_memory_pressure(_context: *mut c_void) { + super::release_playback_audio_caches(); + } + + /// Installs one process-wide memory pressure source. It lives for the + /// rest of the process, so it is never released. + /// + /// Not installed in unit tests: a real pressure event on the host would + /// release the caches of every test running in the process at once. + /// Tests drive `on_memory_pressure` directly instead. + pub(super) fn watch() { + if cfg!(test) { + return; + } + static WATCH: std::sync::Once = std::sync::Once::new(); + WATCH.call_once(|| unsafe { + let source = dispatch_source_create( + &_dispatch_source_type_memorypressure, + 0, + DISPATCH_MEMORYPRESSURE_WARN | DISPATCH_MEMORYPRESSURE_CRITICAL, + dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), + ); + if source.is_null() { + return; + } + dispatch_source_set_event_handler_f(source, on_memory_pressure); + dispatch_resume(source); + }); + } +} + +struct CachePlan { + key: ProgressiveRenderKey, + shift_from: usize, + shift: usize, + ready_at: usize, + rate: u32, + channels: usize, +} + +struct CachedProgressiveRender { + key: ProgressiveRenderKey, + render: Arc, +} + +struct ProgressiveRenderKey { + segments: Vec, + music: Vec<(String, Arc)>, + project: Vec, + duration_bits: u64, + sample_rate: u32, + channels: usize, +} + +impl ProgressiveRenderKey { + fn new( + segments: Vec, + music: &MusicTracks, + project: &ProjectConfiguration, + duration_secs: f64, + output_info: AudioInfo, + ) -> Option { + let mut music = music + .iter() + .map(|(path, data)| (path.clone(), data.clone())) + .collect::>(); + music.sort_by(|a, b| a.0.cmp(&b.0)); + Some(Self { + segments, + music, + project: audio_project_key(project)?, + duration_bits: duration_secs.to_bits(), + sample_rate: output_info.sample_rate, + channels: output_info.channels, + }) + } +} + +impl PartialEq for ProgressiveRenderKey { + fn eq(&self, other: &Self) -> bool { + self.project == other.project + && self.duration_bits == other.duration_bits + && self.sample_rate == other.sample_rate + && self.channels == other.channels + && self.music.len() == other.music.len() + && self + .music + .iter() + .zip(&other.music) + .all(|(a, b)| a.0 == b.0 && Arc::ptr_eq(&a.1, &b.1)) + && self.segments.len() == other.segments.len() + && self.segments.iter().zip(&other.segments).all(|(a, b)| { + a.tracks.len() == b.tracks.len() + && a.tracks + .iter() + .zip(&b.tracks) + .all(|(a, b)| a.same_source(b)) + }) + } +} + +/// Serializes everything the audio renderer reads from a project. Both +/// structs are destructured without `..`, so a new field fails to compile +/// here until someone decides whether it changes playback audio. Fields +/// below `segments`, `clips` and `audio` are kept whole, so new nested +/// fields change the key on their own. +fn audio_project_key(project: &ProjectConfiguration) -> Option> { + let ProjectConfiguration { + aspect_ratio: _, + background: _, + camera: _, + audio, + cursor: _, + hotkeys: _, + timeline, + overlay_order: _, + captions: _, + keyboard: _, + clips, + annotations: _, + hidden_text_segments: _, + screen_motion_blur: _, + screen_movement_spring: _, + color_correction: _, + text_size_version: _, + text_anim_version: _, + } = project; + let timeline = timeline.as_ref().map(|timeline| { + let TimelineConfiguration { + segments, + transitions, + zoom_segments: _, + scene_segments: _, + mask_segments: _, + text_segments: _, + caption_segments: _, + keyboard_segments: _, + audio_segments, + style_segments: _, + image_segments: _, + camera3d_segments: _, + } = timeline; + let segments = segments + .iter() + .map(|segment| cap_project::TimelineSegment { + name: None, + hide_cursor: None, + ..segment.clone() + }) + .collect::>(); + ( + segments, + transitions, + audio_segments, + timeline.hold_windows(), + ) + }); + serde_json::to_vec(&(audio, clips, timeline)).ok() +} + +/// Owner handle of a progressive render. Dropping the last one (readers and +/// the cache) stops its render thread. +struct ProgressiveRender { + timeline: Arc, +} + +impl Drop for ProgressiveRender { + fn drop(&mut self) { + self.timeline.shut_down(); + } +} + +/// Whether every sample of a progressive render is a pure function of its +/// timeline position, so a render started anywhere holds the same samples a +/// render started at another playhead would. Studio Sound, retimed audio, +/// transitions, text holds and resampling all carry state from wherever the +/// render began, so any of them rules reuse out. +fn render_is_origin_independent( + project: &ProjectConfiguration, + output_info: AudioInfo, + duration_secs: f64, +) -> bool { + if output_info.sample_rate != AudioData::SAMPLE_RATE + || output_info.channels != usize::from(AudioRenderer::CHANNELS) + || (project.audio.improve && !project.audio.mute) + { + return false; + } + let Some(timeline) = &project.timeline else { + return false; + }; + if !timeline.transitions.is_empty() || !timeline.hold_windows().is_empty() { + return false; + } + let rate = AudioData::SAMPLE_RATE as f64; + let mut content_end = 0.0; + let segments_stateless = timeline.segments.iter().all(|segment| { + content_end += segment.duration(); + if segment.speed_audio_mode == Some(ClipSpeedAudioMode::Mute) { + return true; + } + let start = segment.start * rate; + segment.timescale == 1.0 && (start - start.floor() - 0.5).abs() > 1e-4 + }); + segments_stateless + && duration_secs.to_bits() == timeline.duration().to_bits() + && (content_end * rate).round() as usize <= (duration_secs * rate) as usize +} + +/// Frames between the slot pass 1 starts writing at and the source sample it +/// starts rendering from. The two round differently, so a render begun at +/// `pass1_start_secs` holds the origin independent samples shifted by this +/// many frames from its first slot on. +fn origin_shift(pass1_start_secs: f64) -> Option { + let slot = output_sample_index(pass1_start_secs, AudioData::SAMPLE_RATE, 1, usize::MAX); + let source = (pass1_start_secs * AudioData::SAMPLE_RATE as f64).round() as usize; + source.checked_sub(slot).filter(|shift| *shift <= 1) +} + +struct CacheRelease { + cache: Weak>>, + idle_release: std::time::Duration, +} + +impl CacheRelease { + fn release(self, timeline: &Arc) { + timeline.retained.store(false, Ordering::Release); + let Some(cache) = self.cache.upgrade() else { + return; + }; + let mut entry = lock(&cache); + if entry + .as_ref() + .is_some_and(|cached| Arc::ptr_eq(&cached.render.timeline, timeline)) + { + *entry = None; + } + } +} + enum PrerenderedAudioBufferMode { Progressive(ProgressiveAudioBuffer), Streaming(Box>), @@ -1606,8 +2086,13 @@ pub struct PrerenderedAudioBuffer { } struct ProgressiveAudioBuffer { - timeline: Arc, - ready_rx: std::sync::mpsc::Receiver<()>, + render: Arc, + ready_rx: Option>, + /// A reader replaying a cached render reads slot `index + shift` from + /// `shift_from` on, which is where its own fresh render would have put + /// each sample. + shift_from: usize, + shift: usize, read_position: usize, sample_rate: u32, channels: usize, @@ -1616,18 +2101,63 @@ struct ProgressiveAudioBuffer { impl Drop for ProgressiveAudioBuffer { fn drop(&mut self) { - self.timeline.stop.store(true, Ordering::Release); + self.render.timeline.detach_reader(); } } impl> PrerenderedAudioBuffer { - pub fn new( + #[cfg(test)] + fn new( + segments: Vec, + music: MusicTracks, + project: &ProjectConfiguration, + output_info: AudioInfo, + duration_secs: f64, + start_playhead_secs: f64, + ) -> Self { + Self::create( + segments, + music, + project, + output_info, + duration_secs, + start_playhead_secs, + None, + ) + } + + /// Like [`Self::new`], but replays the cache's render when it provably + /// holds the same samples a fresh render would, and otherwise keeps the + /// fresh render for the next play when it can be replayed later. + #[allow(clippy::too_many_arguments)] + pub fn new_cached( + segments: Vec, + music: MusicTracks, + project: &ProjectConfiguration, + output_info: AudioInfo, + duration_secs: f64, + start_playhead_secs: f64, + cache: &PlaybackAudioCache, + ) -> Self { + Self::create( + segments, + music, + project, + output_info, + duration_secs, + start_playhead_secs, + Some(cache), + ) + } + + fn create( segments: Vec, music: MusicTracks, project: &ProjectConfiguration, output_info: AudioInfo, duration_secs: f64, start_playhead_secs: f64, + cache: Option<&PlaybackAudioCache>, ) -> Self { let output_info = output_info.for_ffmpeg_output(); let estimated_output_samples = progressive_buffer_samples( @@ -1638,16 +2168,42 @@ impl> PrerenderedAudioBuffer { let estimated_memory_bytes = progressive_buffer_bytes(estimated_output_samples); let mode = if estimated_memory_bytes <= MAX_PROGRESSIVE_BUFFER_BYTES { - PrerenderedAudioBufferMode::Progressive(ProgressiveAudioBuffer::new( - segments, - music, - project, - output_info, - duration_secs, - start_playhead_secs, - estimated_output_samples, - )) + let plan = cache.and_then(|_| { + ProgressiveAudioBuffer::::cache_plan( + segments.clone(), + &music, + project, + output_info, + duration_secs, + start_playhead_secs, + estimated_output_samples, + ) + }); + let cached = cache + .zip(plan.as_ref()) + .and_then(|(cache, plan)| ProgressiveAudioBuffer::from_cache(cache, plan)); + PrerenderedAudioBufferMode::Progressive(match cached { + Some(cached) => cached, + None => { + let fresh = ProgressiveAudioBuffer::new( + segments, + music, + project, + output_info, + duration_secs, + start_playhead_secs, + estimated_output_samples, + ); + match cache { + Some(cache) => fresh.retain_in_cache(cache, plan), + None => fresh, + } + } + }) } else { + if let Some(cache) = cache { + cache.clear(); + } info!( duration_secs = duration_secs, estimated_memory_mb = estimated_memory_bytes / (1024 * 1024), @@ -1803,9 +2359,7 @@ impl> ProgressiveAudioBuffer { "Starting progressive audio pre-render" ); - let pass1_start_secs = (start_playhead_secs - Self::START_PREROLL_SECS) - .max(0.0) - .min(duration_secs); + let pass1_start_secs = Self::pass1_start_secs(start_playhead_secs, duration_secs); let primary_start = output_sample_index( pass1_start_secs, output_info.sample_rate, @@ -1821,26 +2375,16 @@ impl> ProgressiveAudioBuffer { output_info.channels, estimated_output_samples, ); - let ready_window_samples = output_sample_index( - Self::READY_WINDOW_SECS, - output_info.sample_rate, - output_info.channels, - usize::MAX, - ); - let ready_at_index = start_index.saturating_add(ready_window_samples); + let ready_at_index = start_index.saturating_add(Self::ready_window_samples(output_info)); - let timeline = Arc::new(ProgressiveTimeline { - samples: ProgressiveTimeline::allocate_samples(estimated_output_samples), + let timeline = Arc::new(ProgressiveTimeline::new( + estimated_output_samples, primary_start, - primary_end: AtomicUsize::new(primary_start), - wrap_end: AtomicUsize::new(0), - stop: AtomicBool::new(false), - complete: AtomicBool::new(false), - }); + )); let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - spawn_progressive_render( + if !spawn_progressive_render( timeline.clone(), segments, music, @@ -1850,11 +2394,15 @@ impl> ProgressiveAudioBuffer { pass1_start_secs, ready_at_index, ready_tx, - ); + ) { + lock(&timeline.retain_slot).exited = true; + } Self { - timeline, - ready_rx, + render: Arc::new(ProgressiveRender { timeline }), + ready_rx: Some(ready_rx), + shift_from: usize::MAX, + shift: 0, read_position: 0, sample_rate: output_info.sample_rate, channels: output_info.channels, @@ -1862,36 +2410,146 @@ impl> ProgressiveAudioBuffer { } } - /// Blocks until the initial window around the start position is rendered - /// (or the timeout elapses; unrendered reads are silence, never garbage). - pub fn wait_until_ready(&self, timeout: std::time::Duration) { - let _ = self.ready_rx.recv_timeout(timeout); + fn pass1_start_secs(start_playhead_secs: f64, duration_secs: f64) -> f64 { + (start_playhead_secs - Self::START_PREROLL_SECS) + .max(0.0) + .min(duration_secs) } - #[cfg(test)] - fn wait_until_fully_rendered(&self) { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while !self.timeline.complete.load(Ordering::Acquire) { - assert!( - std::time::Instant::now() < deadline, - "progressive audio render did not complete in time" - ); - std::thread::sleep(std::time::Duration::from_millis(1)); - } + fn ready_window_samples(output_info: AudioInfo) -> usize { + output_sample_index( + Self::READY_WINDOW_SECS, + output_info.sample_rate, + output_info.channels, + usize::MAX, + ) } - /// Whether the next `samples` reads are rendered; `None` once the - /// render can no longer produce them. - #[cfg(feature = "test-hooks")] - fn rendered(&self, samples: usize) -> Option { - let timeline = &self.timeline; - let start = self.read_position.min(timeline.samples.len()); - let end = (self.read_position + samples).min(timeline.samples.len()); - if start >= end || timeline.complete.load(Ordering::Acquire) { - return Some(true); - } - let primary_end = timeline.primary_end.load(Ordering::Acquire); - let wrap_end = timeline.wrap_end.load(Ordering::Acquire); + /// Runs after this fresh render is already on its way, so a play that + /// misses the cache starts exactly like an uncached one. Replays the + /// cached render when it holds this play's samples and has rendered at + /// least what the fresh render would have by now; otherwise keeps the + /// fresh render for later plays when its samples are origin independent. + #[allow(clippy::too_many_arguments)] + /// The cache key and sample offsets a render starting at + /// `start_playhead_secs` would use, when its output can be shared. + fn cache_plan( + segments: Vec, + music: &MusicTracks, + project: &ProjectConfiguration, + output_info: AudioInfo, + duration_secs: f64, + start_playhead_secs: f64, + len: usize, + ) -> Option { + let pass1_start_secs = Self::pass1_start_secs(start_playhead_secs, duration_secs); + let shift = origin_shift(pass1_start_secs)?; + let key = render_is_origin_independent(project, output_info, duration_secs) + .then(|| { + ProgressiveRenderKey::new(segments, music, project, duration_secs, output_info) + }) + .flatten()?; + let (rate, channels) = (output_info.sample_rate, output_info.channels); + Some(CachePlan { + key, + shift_from: output_sample_index(pass1_start_secs, rate, channels, len), + shift: shift * channels, + ready_at: output_sample_index(start_playhead_secs, rate, channels, len) + .saturating_add(Self::ready_window_samples(output_info)), + rate, + channels, + }) + } + + /// A reader over the cached render when it matches `plan` and already + /// covers the start window. Checked before a fresh render is built, so a + /// replay never allocates a second full-timeline buffer. + fn from_cache(cache: &PlaybackAudioCache, plan: &CachePlan) -> Option { + let entry = lock(&cache.entry); + let cached = entry.as_ref()?; + if cached.key != plan.key + || cached.render.timeline.stop.load(Ordering::Acquire) + || !cached.render.timeline.covers( + plan.shift_from + plan.shift, + plan.ready_at.saturating_add(plan.shift), + ) + { + return None; + } + let render = cached.render.clone(); + drop(entry); + render.timeline.attach_reader(); + info!("Reusing rendered playback audio"); + Some(Self { + render, + ready_rx: None, + shift_from: plan.shift_from, + shift: plan.shift, + read_position: 0, + sample_rate: plan.rate, + channels: plan.channels, + _format: std::marker::PhantomData, + }) + } + + fn retain_in_cache(self, cache: &PlaybackAudioCache, plan: Option) -> Self { + let Some(plan) = plan else { + let previous = lock(&cache.entry).take(); + self.render.timeline.retire(previous); + return self; + }; + let mut entry = lock(&cache.entry); + let previous = if plan.shift == 0 + && self.render.timeline.retain(CacheRelease { + cache: Arc::downgrade(&cache.entry), + idle_release: cache.idle_release, + }) { + entry.replace(CachedProgressiveRender { + key: plan.key, + render: self.render.clone(), + }) + } else if entry.as_ref().is_some_and(|cached| cached.key != plan.key) { + entry.take() + } else { + None + }; + drop(entry); + self.render.timeline.retire(previous); + self + } + + /// Blocks until the initial window around the start position is rendered + /// (or the timeout elapses; unrendered reads are silence, never garbage). + pub fn wait_until_ready(&self, timeout: std::time::Duration) { + if let Some(ready_rx) = &self.ready_rx { + let _ = ready_rx.recv_timeout(timeout); + } + } + + #[cfg(test)] + fn wait_until_fully_rendered(&self) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while !self.render.timeline.complete.load(Ordering::Acquire) { + assert!( + std::time::Instant::now() < deadline, + "progressive audio render did not complete in time" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + + /// Whether the next `samples` reads are rendered; `None` once the + /// render can no longer produce them. + #[cfg(feature = "test-hooks")] + fn rendered(&self, samples: usize) -> Option { + let timeline = &self.render.timeline; + let start = self.read_position.min(timeline.samples.len()); + let end = (self.read_position + samples).min(timeline.samples.len()); + if start >= end || timeline.complete.load(Ordering::Acquire) { + return Some(true); + } + let primary_end = timeline.primary_end.load(Ordering::Acquire); + let wrap_end = timeline.wrap_end.load(Ordering::Acquire); let primary = timeline.primary_start; let covered = if start >= primary { primary_end >= end @@ -1911,7 +2569,7 @@ impl> ProgressiveAudioBuffer { pub fn set_playhead(&mut self, playhead_secs: f64) { let sample_position = (playhead_secs * self.sample_rate as f64) as usize * self.channels; - self.read_position = sample_position.min(self.timeline.samples.len()); + self.read_position = sample_position.min(self.render.timeline.samples.len()); } pub fn current_audible_playhead(&self, device_latency_secs: f64) -> f64 { @@ -1925,22 +2583,27 @@ impl> ProgressiveAudioBuffer { } pub fn fill(&mut self, buffer: &mut [T]) { - let samples = &self.timeline.samples; + let timeline = &self.render.timeline; + let samples = &timeline.samples; let available = samples.len().saturating_sub(self.read_position); let to_copy = buffer.len().min(available); if to_copy > 0 { - let primary_start = self.timeline.primary_start; - let primary_end = self.timeline.primary_end.load(Ordering::Acquire); - let wrap_end = self.timeline.wrap_end.load(Ordering::Acquire); + let primary_start = timeline.primary_start; + let primary_end = timeline.primary_end.load(Ordering::Acquire); + let wrap_end = timeline.wrap_end.load(Ordering::Acquire); for (i, slot) in buffer[..to_copy].iter_mut().enumerate() { - let idx = self.read_position + i; - let rendered = if idx >= primary_start { - idx < primary_end - } else { - idx < wrap_end - }; + let mut idx = self.read_position + i; + if idx >= self.shift_from { + idx += self.shift; + } + let rendered = idx < samples.len() + && if idx >= primary_start { + idx < primary_end + } else { + idx < wrap_end + }; *slot = if rendered { ::from_sample(f32::from_bits( samples[idx].load(Ordering::Relaxed), @@ -2381,11 +3044,16 @@ fn spawn_progressive_render( pass1_start_secs: f64, ready_at_index: usize, ready_tx: std::sync::mpsc::Sender<()>, -) { +) -> bool { let spawn_result = std::thread::Builder::new() .name("cap-audio-prerender".into()) .spawn(move || { let render_start = std::time::Instant::now(); + let mut control = RenderControl { + timeline: timeline.clone(), + release: None, + adopted: false, + }; let mut renderer = AudioRenderer::new(segments).with_music(music); let chunk_size = 1024usize; @@ -2406,6 +3074,7 @@ fn spawn_progressive_render( // original blocking pre-render loop chunk for chunk so the sample // values are identical. let render_pass = |renderer: &mut AudioRenderer, + control: &mut RenderControl, start_secs: f64, from_source: usize, to_source: usize, @@ -2423,7 +3092,7 @@ fn spawn_progressive_render( let mut rendered_source = from_source; while rendered_source < to_source { - if timeline.stop.load(Ordering::Acquire) { + if !control.should_continue() { return false; } @@ -2468,6 +3137,7 @@ fn spawn_progressive_render( // Pass 1: playhead (minus pre-roll) to the end of the timeline. let pass1_ok = render_pass( &mut renderer, + &mut control, pass1_start_secs, pass1_source_start, total_source_samples, @@ -2478,10 +3148,15 @@ fn spawn_progressive_render( &mut ready_sent, ); + if pass1_ok { + timeline.pass1_done.store(true, Ordering::Release); + } + // Pass 2: wrap around to cover [0, playhead) for backwards seeks. let pass2_ok = if pass1_ok && pass1_source_start > 0 { render_pass( &mut renderer, + &mut control, 0.0, 0, pass1_source_start, @@ -2497,7 +3172,8 @@ fn spawn_progressive_render( send_ready(&mut ready_sent); - if pass1_ok && pass2_ok { + let completed = pass1_ok && pass2_ok; + if completed { timeline.complete.store(true, Ordering::Release); info!( total_samples = buffer_len, @@ -2506,19 +3182,115 @@ fn spawn_progressive_render( "Progressive audio pre-render complete" ); } + drop(renderer); + timeline.release_retired(true); + control.finish(completed); }); - if let Err(e) = spawn_result { + if let Err(e) = &spawn_result { tracing::error!("Failed to spawn audio pre-render thread: {e}"); } + spawn_result.is_ok() +} + +/// Lets a cached render outlive its readers: it parks while nobody is +/// playing, resumes when a replay attaches, and frees itself once the editor +/// stays paused for the idle timeout. +struct RenderControl { + timeline: Arc, + release: Option, + adopted: bool, +} + +impl RenderControl { + fn should_continue(&mut self) -> bool { + loop { + let seen = self.timeline.wake_generation(); + if self.timeline.stop.load(Ordering::Acquire) { + return false; + } + if !self.timeline.retained.load(Ordering::Acquire) { + return true; + } + self.adopt_release(); + if self.release_if_idle() { + continue; + } + if self.timeline.readers.load(Ordering::Acquire) > 0 { + return true; + } + self.timeline.release_retired(false); + self.wait_for_wake(seen); + } + } + + fn adopt_release(&mut self) { + if !self.adopted { + self.adopted = true; + self.release = lock(&self.timeline.retain_slot).release.take(); + } + } + + fn finish(mut self, completed: bool) { + { + let mut slot = lock(&self.timeline.retain_slot); + slot.exited = true; + if !self.adopted { + self.adopted = true; + self.release = slot.release.take(); + } + } + if completed { + loop { + let seen = self.timeline.wake_generation(); + if self.release.is_none() || self.timeline.stop.load(Ordering::Acquire) { + break; + } + if self.release_if_idle() { + return; + } + self.wait_for_wake(seen); + } + } + if let Some(release) = self.release.take() { + release.release(&self.timeline); + } + } + + fn release_if_idle(&mut self) -> bool { + let Some(release) = &self.release else { + return false; + }; + if self.timeline.readers.load(Ordering::Acquire) == 0 + && self.timeline.paused_for() >= release.idle_release + && let Some(release) = self.release.take() + { + release.release(&self.timeline); + return true; + } + false + } + + fn wait_for_wake(&self, seen: u64) { + let idle_release = self + .release + .as_ref() + .map_or(PLAYBACK_AUDIO_IDLE_RELEASE, |release| release.idle_release); + let timeout = if self.timeline.readers.load(Ordering::Acquire) > 0 { + idle_release + } else { + idle_release.saturating_sub(self.timeline.paused_for()) + }; + self.timeline.wait(seen, timeout); + } } #[cfg(test)] mod tests { use super::*; use cap_project::{ - ClipConfiguration, ClipTransition, ProjectConfiguration, TimelineConfiguration, - TimelineSegment, + ClipConfiguration, ClipTransition, ClipTransitionType, ProjectConfiguration, + TimelineConfiguration, TimelineSegment, }; use std::{path::Path, sync::Arc}; use tempfile::TempDir; @@ -3955,4 +4727,733 @@ mod tests { assert!((left_at_second(&stream, 0) - expected(4_000)).abs() < 0.02); assert!((left_at_second(&stream, 1) - expected(10_000)).abs() < 0.02); } + + static CACHE_RELEASE: std::sync::RwLock<()> = std::sync::RwLock::new(()); + + struct ProgressiveFixture { + _dir: TempDir, + segments: Vec, + music: MusicTracks, + music_path: String, + project: ProjectConfiguration, + } + + impl ProgressiveFixture { + fn new() -> Self { + let _ = ffmpeg::init(); + let dir = tempfile::tempdir().unwrap(); + let mic_path = dir.path().join("mic.wav"); + let system_path = dir.path().join("system.wav"); + let music_path = dir.path().join("music.wav"); + write_sine_wav(&mic_path, 440.0, 8.0); + write_sine_wav(&system_path, 220.0, 8.0); + write_step_wav(&music_path, &[3_000, 6_000]); + let load = |path: &Path| { + Arc::new(DecodedAudio::from(Arc::new( + AudioData::from_file(path).unwrap(), + ))) + }; + let segments = vec![crate::segments::audio_segment_from_decoded( + Some(load(&mic_path)), + Some(load(&system_path)), + crate::SegmentAudioTimingRepair::default(), + )]; + let mut quiet = segment(0, 6.0, 8.0, 1.0); + quiet.volume = Some(0.5); + let music_path = music_path.to_string_lossy().into_owned(); + let project = ProjectConfiguration { + timeline: Some(TimelineConfiguration { + segments: vec![segment(0, 0.0, 3.0, 1.0), segment(0, 3.5, 6.0, 1.0), quiet], + transitions: Vec::new(), + zoom_segments: Vec::new(), + scene_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), + mask_segments: Vec::new(), + text_segments: Vec::new(), + caption_segments: Vec::new(), + keyboard_segments: Vec::new(), + audio_segments: vec![music_track_segment(&music_path, 0.5, 4.0, 0.3, 0.4)], + camera3d_segments: Vec::new(), + }), + clips: vec![ClipConfiguration { + index: 0, + ..Default::default() + }], + ..Default::default() + }; + let mut music = MusicTracks::new(); + music.insert( + music_path.clone(), + Arc::new(AudioData::from_file(&music_path).unwrap()), + ); + Self { + _dir: dir, + segments, + music, + music_path, + project, + } + } + + fn timeline(&mut self) -> &mut TimelineConfiguration { + self.project.timeline.as_mut().unwrap() + } + + fn duration(&self) -> f64 { + self.project.timeline.as_ref().unwrap().duration() + } + + fn buffer(&self, output_info: AudioInfo, start: f64) -> PrerenderedAudioBuffer { + PrerenderedAudioBuffer::new( + self.segments.clone(), + self.music.clone(), + &self.project, + output_info, + self.duration(), + start, + ) + } + + fn cached( + &self, + cache: &PlaybackAudioCache, + output_info: AudioInfo, + start: f64, + ) -> PrerenderedAudioBuffer { + PrerenderedAudioBuffer::new_cached( + self.segments.clone(), + self.music.clone(), + &self.project, + output_info, + self.duration(), + start, + cache, + ) + } + + fn reference(&self, output_info: AudioInfo, start: f64) -> Vec { + reference_progressive_render( + self.segments.clone(), + self.music.clone(), + &self.project, + output_info, + self.duration(), + start, + ) + } + } + + type FixtureEdit = fn(&mut ProgressiveFixture); + + fn stateful_edits() -> [(&'static str, FixtureEdit); 5] { + [ + ("studio sound", |f| f.project.audio.improve = true), + ("speed", |f| f.timeline().segments[1].timescale = 1.5), + ("transition", |f| { + f.timeline().transitions = vec![ClipTransition { + segment_index: 1, + kind: ClipTransitionType::CrossFade, + duration: 0.5, + }] + }), + ("text hold", |f| { + let mut text: cap_project::TextSegment = + serde_json::from_value(serde_json::json!({ "start": 2.0, "end": 3.0 })) + .unwrap(); + text.enabled = true; + text.layout = cap_project::TextLayout::Fullscreen; + f.timeline().text_segments = vec![text]; + }), + ("segment start on a sample tie", |f| { + f.timeline().segments[1].start = 3.5 + 0.5 / AudioData::SAMPLE_RATE as f64 + }), + ] + } + + fn stereo_48k() -> AudioInfo { + AudioInfo::new_raw(AudioData::SAMPLE_FORMAT, 48_000, 2) + } + + #[allow(clippy::too_many_arguments)] + fn reference_pass( + out: &mut [f32], + renderer: &mut AudioRenderer, + project: &ProjectConfiguration, + render_info: AudioInfo, + start_secs: f64, + from_source: usize, + to_source: usize, + mut out_idx: usize, + out_limit: usize, + ) { + let mut resampler = AudioResampler::new(render_info).unwrap(); + renderer.set_playhead(start_secs, project); + let mut store = |bytes: &[u8], out_idx: &mut usize| { + for chunk in bytes.chunks_exact(f32::BYTE_SIZE) { + if *out_idx >= out_limit { + break; + } + out[*out_idx] = f32::from_bytes(chunk); + *out_idx += 1; + } + }; + let mut rendered = from_source; + while rendered < to_source { + let frame = match renderer.render_frame(1024, project) { + Some(frame) => frame, + None => silent_audio_frame(1024), + }; + store(resampler.queue_and_process_frame(&frame), &mut out_idx); + rendered += 1024; + } + while let Some(flushed) = resampler.flush_frame() { + if flushed.is_empty() || out_idx >= out_limit { + break; + } + store(flushed, &mut out_idx); + } + } + + fn reference_progressive_render( + segments: Vec, + music: MusicTracks, + project: &ProjectConfiguration, + output_info: AudioInfo, + duration: f64, + start: f64, + ) -> Vec { + let output_info = output_info.for_ffmpeg_output(); + let (rate, channels) = (output_info.sample_rate, output_info.channels); + let render_info = AudioInfo::new_raw(AudioData::SAMPLE_FORMAT, rate, channels as u16); + let len = progressive_buffer_samples(duration, rate, channels); + let mut out = vec![0.0f32; len]; + let mut renderer = AudioRenderer::new(segments).with_music(music); + let pass1_start = (start - 1.0).max(0.0).min(duration); + let primary_start = output_sample_index(pass1_start, rate, channels, len); + let total = (duration * AudioData::SAMPLE_RATE as f64) as usize; + let pass1_source = (pass1_start * AudioData::SAMPLE_RATE as f64) as usize; + reference_pass( + &mut out, + &mut renderer, + project, + render_info, + pass1_start, + pass1_source, + total, + primary_start, + len, + ); + if pass1_source > 0 { + reference_pass( + &mut out, + &mut renderer, + project, + render_info, + 0.0, + 0, + pass1_source, + 0, + primary_start, + ); + } + out + } + + fn progressive_timeline(buffer: &PrerenderedAudioBuffer) -> Arc { + let PrerenderedAudioBufferMode::Progressive(progressive) = &buffer.mode else { + panic!("expected a progressive buffer"); + }; + progressive.render.timeline.clone() + } + + fn read_everything(buffer: &mut PrerenderedAudioBuffer) -> Vec { + buffer.wait_until_fully_rendered(); + let len = progressive_timeline(buffer).samples.len(); + buffer.set_playhead(0.0); + let mut out = vec![1.0f32; len]; + buffer.fill(&mut out); + out + } + + fn assert_bit_identical(actual: &[f32], expected: &[f32], context: &str) { + assert_eq!(actual.len(), expected.len(), "{context}"); + if let Some(index) = actual + .iter() + .zip(expected) + .position(|(a, b)| a.to_bits() != b.to_bits()) + { + panic!( + "{context}: sample {index} differs: {} vs {}", + actual[index], expected[index] + ); + } + } + + fn wait_until_released(timeline: Weak, within: std::time::Duration) { + let deadline = std::time::Instant::now() + within; + while timeline.strong_count() > 0 { + assert!( + std::time::Instant::now() < deadline, + "cached render was not released" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + + #[test] + fn origin_shift_matches_the_render_rounding() { + let rate = AudioData::SAMPLE_RATE as f64; + assert_eq!(origin_shift(0.0), Some(0)); + assert_eq!(origin_shift(1.0), Some(0)); + assert_eq!(origin_shift(2.0 + 0.4 / rate), Some(0)); + assert_eq!(origin_shift(2.0 + 0.6 / rate), Some(1)); + for frame in 0..2_000u32 { + let secs = (f64::from(frame) / 30.0 + 0.0123 - 1.0).max(0.0); + let slot = output_sample_index(secs, AudioData::SAMPLE_RATE, 1, usize::MAX); + let source = (secs * rate).round() as usize; + assert_eq!(origin_shift(secs), Some(source - slot)); + } + } + + #[test] + fn uncached_progressive_playback_is_one_uninterrupted_render() { + let mut fixture = ProgressiveFixture::new(); + for improve in [false, true] { + fixture.project.audio.improve = improve; + for output_info in [ + stereo_48k(), + AudioInfo::new_raw(AudioData::SAMPLE_FORMAT, 44_100, 2), + ] { + for start in [0.0, 2.7] { + let context = format!( + "improve {improve}, {} Hz, start {start}", + output_info.sample_rate + ); + let mut buffer = fixture.buffer(output_info, start); + buffer.set_playhead(start); + assert!(buffer.wait_until_ready(std::time::Duration::from_secs(30))); + assert_bit_identical( + &read_everything(&mut buffer), + &fixture.reference(output_info, start), + &context, + ); + } + } + } + } + + #[test] + fn replays_from_any_start_match_a_fresh_render_from_that_start() { + let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); + let fixture = ProgressiveFixture::new(); + let output_info = stereo_48k(); + let rate = AudioData::SAMPLE_RATE as f64; + let cache = PlaybackAudioCache::default(); + let first = fixture.cached(&cache, output_info, 1.0); + first.wait_until_fully_rendered(); + let timeline = progressive_timeline(&first); + drop(first); + + let mut shifts = [0usize; 2]; + let starts = [ + 0.0, + 0.4, + 2.7, + 3.0 + 1.0 / 3.0, + 4.0 + 0.6 / rate, + 5.0 + 0.4 / rate, + 6.123_456_789, + 7.4, + 7.5, + 9.0, + ] + .into_iter() + .chain((0..40).map(|frame| f64::from(frame) * 0.187 + 0.0213)); + for start in starts { + let context = format!("start {start}"); + shifts[origin_shift((start - 1.0).clamp(0.0, fixture.duration())).unwrap()] += 1; + let mut replay = fixture.cached(&cache, output_info, start); + assert!( + Arc::ptr_eq(&timeline, &progressive_timeline(&replay)), + "{context}: must replay the cached render" + ); + replay.set_playhead(start); + assert!(replay.wait_until_ready(std::time::Duration::from_secs(1))); + let replayed = read_everything(&mut replay); + drop(replay); + assert_bit_identical(&replayed, &fixture.reference(output_info, start), &context); + let mut fresh = fixture.buffer(output_info, start); + assert_bit_identical(&replayed, &read_everything(&mut fresh), &context); + } + assert!(shifts.iter().all(|count| *count > 0), "{shifts:?}"); + } + + #[test] + fn stateful_audio_and_resampled_output_never_replay() { + let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); + let mut cases: Vec<(String, ProgressiveFixture, AudioInfo)> = stateful_edits() + .into_iter() + .map(|(name, edit)| { + let mut fixture = ProgressiveFixture::new(); + edit(&mut fixture); + (name.to_string(), fixture, stereo_48k()) + }) + .collect(); + for (rate, channels) in [(44_100, 2), (96_000, 2), (48_000, 1), (48_000, 6)] { + cases.push(( + format!("{rate} Hz x{channels}"), + ProgressiveFixture::new(), + AudioInfo::new_raw(AudioData::SAMPLE_FORMAT, rate, channels), + )); + } + for (name, fixture, output_info) in cases { + let cache = PlaybackAudioCache::default(); + let first = fixture.cached(&cache, output_info, 1.0); + first.wait_until_fully_rendered(); + let timeline = progressive_timeline(&first); + drop(first); + assert!(lock(&cache.entry).is_none(), "{name} must not be cached"); + for start in [1.0, 3.3] { + let mut replay = fixture.cached(&cache, output_info, start); + assert!( + !Arc::ptr_eq(&timeline, &progressive_timeline(&replay)), + "{name} must render fresh" + ); + assert_bit_identical( + &read_everything(&mut replay), + &fixture.reference(output_info, start), + &name, + ); + } + } + } + + #[test] + fn an_unrendered_start_renders_fresh() { + let fixture = ProgressiveFixture::new(); + let output_info = stereo_48k(); + let len = progressive_buffer_samples(fixture.duration(), 48_000, 2); + let primary_start = output_sample_index(4.0, 48_000, 2, len); + let timeline = ProgressiveTimeline::new(len, primary_start); + timeline + .primary_end + .store(primary_start + 1_000, Ordering::Release); + assert!(timeline.covers(primary_start, primary_start + 1_000)); + assert!(!timeline.covers(primary_start, primary_start + 1_001)); + assert!(!timeline.covers(primary_start - 2, primary_start + 10)); + timeline.wrap_end.store(primary_start, Ordering::Release); + assert!(timeline.covers(0, primary_start + 1_000)); + timeline.pass1_done.store(true, Ordering::Release); + assert!(timeline.covers(0, len + 10)); + + let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); + let cache = PlaybackAudioCache::default(); + let parked = fixture.cached(&cache, output_info, 0.0); + let cached = progressive_timeline(&parked); + cached.stop.store(true, Ordering::Release); + drop(parked); + let replay = fixture.cached(&cache, output_info, 0.0); + assert!(!Arc::ptr_eq(&cached, &progressive_timeline(&replay))); + } + + #[test] + fn every_audio_change_renders_fresh_and_visual_changes_reuse() { + let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); + let audio_edits: [(&str, FixtureEdit); 11] = [ + ("mic volume", |f| f.project.audio.mic_volume_db = -6.0), + ("system volume", |f| f.project.audio.system_volume_db = -6.0), + ("mute", |f| f.project.audio.mute = true), + ("stereo mode", |f| { + f.project.audio.mic_stereo_mode = cap_project::StereoMode::MonoL; + f.project.audio.mic_volume_db = -1.0; + }), + ("clip volume", |f| { + f.timeline().segments[0].volume = Some(0.25) + }), + ("muted speed", |f| { + let segment = &mut f.timeline().segments[1]; + segment.timescale = 1.5; + segment.speed_audio_mode = Some(ClipSpeedAudioMode::Mute); + }), + ("trim", |f| f.timeline().segments[2].end = 7.0), + ("clip offset", |f| f.project.clips[0].offsets.mic = 0.2), + ("music volume", |f| { + f.timeline().audio_segments[0].volume_db = -9.0 + }), + ("music", |f| { + let path = f.music_path.clone(); + f.timeline() + .audio_segments + .push(music_track_segment(&path, 5.0, 6.0, 0.0, 0.0)) + }), + ("split", |f| { + let timeline = f.timeline(); + let mut second = timeline.segments[0].clone(); + timeline.segments[0].end = 1.5; + second.start = 1.5; + timeline.segments.insert(1, second); + }), + ]; + let visual_edits: [(&str, FixtureEdit); 4] = [ + ("zoom", |f| { + f.timeline().zoom_segments = vec![cap_project::ZoomSegment { + start: 1.0, + end: 2.0, + amount: 2.0, + mode: cap_project::ZoomMode::Auto, + glide_direction: Default::default(), + glide_speed: 1.0, + instant_animation: false, + edge_snap_ratio: 0.1, + }] + }), + ("motion blur", |f| f.project.screen_motion_blur = 0.9), + ("clip name", |f| { + f.timeline().segments[0].name = Some("Intro".into()) + }), + ("hide cursor", |f| { + f.timeline().segments[0].hide_cursor = Some(true) + }), + ]; + let output_info = stereo_48k(); + + for (name, edit) in audio_edits.into_iter().chain(stateful_edits()) { + let mut fixture = ProgressiveFixture::new(); + let cache = PlaybackAudioCache::default(); + let mut before = fixture.cached(&cache, output_info, 0.0); + let before_audio = read_everything(&mut before); + let before_timeline = progressive_timeline(&before); + drop(before); + edit(&mut fixture); + let mut after = fixture.cached(&cache, output_info, 0.0); + assert!( + !Arc::ptr_eq(&before_timeline, &progressive_timeline(&after)), + "{name} must not reuse the cached render" + ); + let after_audio = read_everything(&mut after); + assert_bit_identical(&after_audio, &fixture.reference(output_info, 0.0), name); + assert!( + name == "split" + || before_audio.len() != after_audio.len() + || before_audio + .iter() + .zip(&after_audio) + .any(|(a, b)| a.to_bits() != b.to_bits()), + "{name} must change the rendered audio" + ); + } + + for (name, edit) in visual_edits { + let mut fixture = ProgressiveFixture::new(); + let cache = PlaybackAudioCache::default(); + let before = fixture.cached(&cache, output_info, 0.0); + before.wait_until_fully_rendered(); + let before_timeline = progressive_timeline(&before); + drop(before); + edit(&mut fixture); + let after = fixture.cached(&cache, output_info, 0.0); + assert!( + Arc::ptr_eq(&before_timeline, &progressive_timeline(&after)), + "{name} must reuse the cached render" + ); + } + } + + #[test] + fn a_stale_render_is_released_once_the_next_render_finishes() { + let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); + let mut fixture = ProgressiveFixture::new(); + let output_info = stereo_48k(); + let within = std::time::Duration::from_secs(10); + + let cache = PlaybackAudioCache::default(); + let mut playing = fixture.cached(&cache, output_info, 0.0); + read_everything(&mut playing); + let stale = Arc::downgrade(&progressive_timeline(&playing)); + drop(playing); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!(stale.strong_count() > 0, "a paused render stays cached"); + + fixture.project.audio.mic_volume_db = -12.0; + let mut edited = fixture.cached(&cache, output_info, 2.0); + let fresh = progressive_timeline(&edited); + assert!(!Arc::ptr_eq(&fresh, &stale.upgrade().unwrap())); + edited.set_playhead(2.0); + assert!(edited.wait_until_ready(std::time::Duration::from_secs(30))); + wait_until_released(stale, within); + assert!( + lock(&cache.entry) + .as_ref() + .is_some_and(|cached| Arc::ptr_eq(&cached.render.timeline, &fresh)) + ); + drop(fresh); + read_everything(&mut edited); + let kept = Arc::downgrade(&progressive_timeline(&edited)); + drop(edited); + + fixture.project.audio.improve = true; + let mut stateful = fixture.cached(&cache, output_info, 2.0); + stateful.set_playhead(2.0); + assert!(stateful.wait_until_ready(std::time::Duration::from_secs(30))); + wait_until_released(kept, within); + assert!(lock(&cache.entry).is_none()); + drop(stateful); + + fixture.project.audio.improve = false; + let closed = fixture.cached(&cache, output_info, 0.0); + let timeline = Arc::downgrade(&progressive_timeline(&closed)); + drop(closed); + drop(cache); + wait_until_released(timeline, within); + } + + #[test] + fn cache_key_changes_with_every_audio_input_and_ignores_visual_fields() { + let fixture = ProgressiveFixture::new(); + let original = audio_project_key(&fixture.project).unwrap(); + let key = |edit: FixtureEdit| { + let mut edited = ProgressiveFixture::new(); + edited.project = fixture.project.clone(); + edit(&mut edited); + audio_project_key(&edited.project).unwrap() + }; + + let audio: [(&str, FixtureEdit); 9] = [ + ("audio", |f| f.project.audio.system_volume_db = -3.0), + ("clip offsets", |f| { + f.project.clips[0].offsets.system_audio = 0.1 + }), + ("segment", |f| f.timeline().segments[0].end = 2.5), + ("segment volume", |f| { + f.timeline().segments[0].volume = Some(0.8) + }), + ("speed mode", |f| { + f.timeline().segments[0].speed_audio_mode = Some(ClipSpeedAudioMode::Mute) + }), + ("music", |f| f.timeline().audio_segments[0].fade_in = 0.1), + ("transition", |f| { + f.timeline().transitions = vec![ClipTransition { + segment_index: 1, + kind: ClipTransitionType::CrossFade, + duration: 0.25, + }] + }), + ("text hold", stateful_edits()[3].1), + ("no timeline", |f| f.project.timeline = None), + ]; + for (name, edit) in audio { + assert_ne!(key(edit), original, "{name} must change the key"); + } + + let visual: [(&str, FixtureEdit); 6] = [ + ("background", |f| f.project.background.padding = 12.5), + ("cursor", |f| f.project.cursor.size = 150), + ("motion blur", |f| f.project.screen_motion_blur = 0.9), + ("clip name", |f| { + f.timeline().segments[0].name = Some("Intro".into()) + }), + ("hide cursor", |f| { + f.timeline().segments[0].hide_cursor = Some(true) + }), + ("overlay text", |f| { + let mut text: cap_project::TextSegment = + serde_json::from_value(serde_json::json!({ "start": 2.0, "end": 3.0 })) + .unwrap(); + text.enabled = true; + f.timeline().text_segments = vec![text]; + }), + ]; + for (name, edit) in visual { + assert_eq!(key(edit), original, "{name} must keep the key"); + } + } + + #[test] + fn a_replay_resumes_a_parked_render() { + let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); + let fixture = ProgressiveFixture::new(); + let output_info = stereo_48k(); + for _ in 0..20 { + let cache = PlaybackAudioCache::default(); + let mut first = fixture.cached(&cache, output_info, 0.0); + first.set_playhead(0.0); + assert!(first.wait_until_ready(std::time::Duration::from_secs(30))); + let timeline = progressive_timeline(&first); + drop(first); + let mut replay = fixture.cached(&cache, output_info, 0.0); + assert!(Arc::ptr_eq(&timeline, &progressive_timeline(&replay))); + assert_bit_identical( + &read_everything(&mut replay), + &fixture.reference(output_info, 0.0), + "resumed render", + ); + } + } + + #[test] + fn idle_pause_releases_the_cached_render() { + let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); + let fixture = ProgressiveFixture::new(); + let output_info = stereo_48k(); + let idle = std::time::Duration::from_millis(300); + + for complete in [true, false] { + let cache = PlaybackAudioCache::with_idle_release(idle); + let mut playing = fixture.cached(&cache, output_info, 0.0); + if complete { + read_everything(&mut playing); + } + std::thread::sleep(idle * 2); + assert!( + lock(&cache.entry).is_some(), + "a playing render must stay cached" + ); + let timeline = Arc::downgrade(&progressive_timeline(&playing)); + let paused = std::time::Instant::now(); + drop(playing); + wait_until_released(timeline, std::time::Duration::from_secs(10)); + assert!(paused.elapsed() >= idle); + assert!(lock(&cache.entry).is_none()); + } + } + + #[test] + fn export_and_memory_pressure_release_every_cached_render() { + let _release = CACHE_RELEASE + .write() + .unwrap_or_else(PoisonError::into_inner); + let fixture = ProgressiveFixture::new(); + let output_info = stereo_48k(); + type Release = fn(); + let releases: Vec<(&str, Release)> = vec![ + ("export", release_playback_audio_caches), + #[cfg(target_os = "macos")] + ("memory pressure", || { + memory_pressure::on_memory_pressure(std::ptr::null_mut()) + }), + ]; + for (name, release) in releases { + let caches = [PlaybackAudioCache::default(), PlaybackAudioCache::default()]; + let timelines = caches + .iter() + .map(|cache| { + let paused = fixture.cached(cache, output_info, 0.0); + Arc::downgrade(&progressive_timeline(&paused)) + }) + .collect::>(); + assert!( + caches.iter().all(|cache| lock(&cache.entry).is_some()), + "{name}" + ); + release(); + for timeline in timelines { + wait_until_released(timeline, std::time::Duration::from_secs(10)); + } + assert!( + caches.iter().all(|cache| lock(&cache.entry).is_none()), + "{name}" + ); + } + } } diff --git a/crates/editor/src/audio_output.rs b/crates/editor/src/audio_output.rs index d7d5f7218f5..3de0d2f2be0 100644 --- a/crates/editor/src/audio_output.rs +++ b/crates/editor/src/audio_output.rs @@ -33,7 +33,7 @@ use cpal::{ use tokio::sync::watch; use tracing::{error, info}; -use crate::audio::{AudioSegment, MusicTracks, PrerenderedAudioBuffer}; +use crate::audio::{AudioSegment, MusicTracks, PlaybackAudioCache, PrerenderedAudioBuffer}; use crate::preparing_audio::{ PreparingAudioBuffer, PreparingAudioOutputHandle, PreparingAudioOutputSnapshot, PreparingAudioSources, @@ -950,6 +950,7 @@ fn install_source>( output_info: AudioInfo, use_device_latency_hint: bool, install_tx: &std_mpsc::Sender>, + cache: &PlaybackAudioCache, ) -> Result<(), String> { let preparing_request = ack.preparing_request(); if preparing_request @@ -1009,13 +1010,14 @@ fn install_source>( let mut buffer = if matches!(ack, SourceAcknowledgement::Refresh(..)) { PrerenderedAudioBuffer::::bounded(segments, music, &project, output_info, start_playhead) } else { - PrerenderedAudioBuffer::::new( + PrerenderedAudioBuffer::::new_cached( segments, music, &project, output_info, duration_secs, start_playhead, + cache, ) }; if !matches!(ack, SourceAcknowledgement::Refresh(..)) { @@ -1134,6 +1136,7 @@ fn control_thread_headless(control_rx: std_mpsc::Receiver, mut tap: let (source_tx, source_rx) = std_mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); + let cache = PlaybackAudioCache::default(); let pump = { let stop = stop.clone(); @@ -1191,6 +1194,7 @@ fn control_thread_headless(control_rx: std_mpsc::Receiver, mut tap: output_info, false, &source_tx, + &cache, ) { error!(%error, "Could not update headless audio playback"); } @@ -1210,6 +1214,7 @@ fn control_thread_headless(control_rx: std_mpsc::Receiver, mut tap: output_info, false, &source_tx, + &cache, ) { Ok(()) => ack_rx.recv_timeout(SOURCE_ACK_TIMEOUT).is_ok(), Err(e) => { @@ -1235,6 +1240,7 @@ fn control_thread_headless(control_rx: std_mpsc::Receiver, mut tap: output_info, false, &source_tx, + &cache, ) { error!("Failed to install preparing headless audio source: {error}"); request.complete(false); @@ -1352,6 +1358,7 @@ fn control_thread_offline( HEADLESS_SAMPLE_RATE, HEADLESS_CHANNELS, ); + let cache = PlaybackAudioCache::default(); while let Ok(msg) = control_rx.recv() { match msg { ControlMsg::Refresh { @@ -1367,6 +1374,7 @@ fn control_thread_offline( output_info, false, &source_tx, + &cache, ) { error!(%error, "Could not update offline audio playback"); } @@ -1384,6 +1392,7 @@ fn control_thread_offline( output_info, false, &source_tx, + &cache, ) .inspect_err(|e| error!("Failed to install offline audio source: {e}")) .is_ok(); @@ -1566,9 +1575,18 @@ where .map_err(|e| format!("Failed to start audio output stream: {e}"))?; let install_tx = source_tx.clone(); + let cache = PlaybackAudioCache::default(); let install = Box::new( move |spec: Box, generation: u64, ack: SourceAcknowledgement| { - install_source::(spec, generation, ack, output_info, true, &install_tx) + install_source::( + spec, + generation, + ack, + output_info, + true, + &install_tx, + &cache, + ) }, ); @@ -1636,6 +1654,7 @@ mod tests { output_info, use_device_latency_hint, &install_tx, + &PlaybackAudioCache::default(), ) .unwrap(); let SourceCommand::Install(source) = install_rx.recv().unwrap() else { diff --git a/crates/editor/src/audio_output/native_tests.rs b/crates/editor/src/audio_output/native_tests.rs index d64b46ebca6..3f7c2b7fda9 100644 --- a/crates/editor/src/audio_output/native_tests.rs +++ b/crates/editor/src/audio_output/native_tests.rs @@ -310,6 +310,7 @@ async fn preparing_dropped_acknowledgement_wakes_and_cancels_without_waiting_for AudioInfo::new_raw(AudioData::SAMPLE_FORMAT, 48_000, 2), false, &source_tx, + &crate::audio::PlaybackAudioCache::default(), ) .unwrap(); drop(source_rx.recv().unwrap()); @@ -337,6 +338,7 @@ fn preparing_callback_silences_active_cancelled_source_without_a_remove_command( AudioInfo::new_raw(AudioData::SAMPLE_FORMAT, 48_000, 2), false, &source_tx, + &crate::audio::PlaybackAudioCache::default(), ) .unwrap(); let SourceCommand::Install(mut source) = source_rx.recv().unwrap() else { @@ -366,6 +368,7 @@ fn preparing_expired_install_does_not_replace_an_active_ordinary_source() { AudioInfo::new_raw(AudioData::SAMPLE_FORMAT, 48_000, 2), false, &source_tx, + &crate::audio::PlaybackAudioCache::default(), ) .unwrap(); request.cancel(); diff --git a/crates/editor/src/completed_audio.rs b/crates/editor/src/completed_audio.rs index 357581e9418..c2e8c788669 100644 --- a/crates/editor/src/completed_audio.rs +++ b/crates/editor/src/completed_audio.rs @@ -187,6 +187,7 @@ pub(crate) mod tests { start_time: Some(0.125), device_id: Some("mic".into()), gap_summary: None, + digital_silence_ms: None, }), system_audio: Some(AudioMeta { path: format!("content/segments/segment-{index}/system_audio.ogg") @@ -194,6 +195,7 @@ pub(crate) mod tests { start_time: Some(-0.25), device_id: None, gap_summary: None, + digital_silence_ms: None, }), cursor: None, keyboard: None, diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index cecbe1db9ce..4a726bc66d4 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -8,8 +8,8 @@ use cap_project::{ }; use cap_rendering::{ PrecomputedCursorTimeline, ProjectRecordingsMeta, ProjectUniforms, RecordingSegmentDecoders, - RenderVideoConstants, SegmentVideoPaths, SharedWgpuDevice, Video, ZoomTransformTimeline, - get_duration, spring_mass_damper::SpringMassDamperSimulationConfig, + RenderVideoConstants, RendererLayers, SegmentVideoPaths, SharedWgpuDevice, Video, + ZoomTransformTimeline, get_duration, spring_mass_damper::SpringMassDamperSimulationConfig, }; use std::{ collections::HashMap, @@ -248,6 +248,7 @@ pub struct EditorInstance { meta: RecordingMeta, pub export_preview_active: AtomicBool, pub export_active: AtomicBool, + pub preview_layers: std::sync::Mutex>, runtime_handle: tokio::runtime::Handle, audio_output: Arc, } @@ -655,6 +656,7 @@ impl EditorInstance { playback_active_rx, export_preview_active: AtomicBool::new(false), export_active: AtomicBool::new(false), + preview_layers: std::sync::Mutex::new(None), playback_epoch: AtomicU64::new(0), runtime_handle: tokio::runtime::Handle::current(), audio_output, @@ -781,6 +783,7 @@ impl EditorInstance { meta: self.meta.clone(), export_preview_active: AtomicBool::new(false), export_active: AtomicBool::new(false), + preview_layers: std::sync::Mutex::new(None), runtime_handle: self.runtime_handle.clone(), audio_output: self.audio_output.clone(), }); diff --git a/crates/editor/src/export_audio.rs b/crates/editor/src/export_audio.rs index 58973230dd0..b65ad5370dc 100644 --- a/crates/editor/src/export_audio.rs +++ b/crates/editor/src/export_audio.rs @@ -324,6 +324,17 @@ impl ExportAudioRenderer { { return false; } + // Studio Sound re-reads up to VOICE_PREROLL_SAMPLES before each cut to warm up the + // enhancer. The streaming reader only moves forward, so a cut shorter than that + // within one clip has to use the in-memory export path instead. An exact split + // (nothing removed) keeps the enhancer contiguous and still streams. + let studio_sound = + project.audio.improve && !project.audio.mute && project.audio.mic_volume_db > -30.0; + let min_gap = if studio_sound { + (VOICE_PREROLL_SAMPLES + VOICE_WINDOW_PADDING_SAMPLES) as f64 / 48_000.0 + } else { + 0.0 + }; let mut ends = HashMap::new(); let mut duration = holds.iter().map(|(start, end)| end - start).sum::(); if !duration.is_finite() || duration * 48_000.0 >= isize::MAX as f64 / 2.0 { @@ -344,7 +355,11 @@ impl ExportAudioRenderer { return false; } if let Some(previous_end) = ends.insert(segment.recording_clip, segment.end) - && (!timeline.transitions.is_empty() || segment.start < previous_end) + && (!timeline.transitions.is_empty() + || segment.start < previous_end + || (min_gap > 0.0 + && segment.start > previous_end + && segment.start < previous_end + min_gap)) { return false; } @@ -1481,6 +1496,42 @@ mod tests { assert_bounded_sink_variants(22..26, &[1, 997, 4_800, 48_001]); } + #[test] + fn eligibility_rejects_studio_sound_cuts_shorter_than_voice_preroll() { + let mut short_cut = project(); + short_cut + .timeline + .as_mut() + .unwrap() + .segments + .push(segment(0, 0.34, 0.36)); + assert!(ExportAudioRenderer::eligible_sources(&short_cut, 3)); + short_cut.audio.improve = true; + assert!(!ExportAudioRenderer::eligible_sources(&short_cut, 3)); + short_cut.audio.mute = true; + assert!(ExportAudioRenderer::eligible_sources(&short_cut, 3)); + + let mut long_cut = project(); + long_cut.audio.improve = true; + long_cut + .timeline + .as_mut() + .unwrap() + .segments + .push(segment(0, 1.0, 1.5)); + assert!(ExportAudioRenderer::eligible_sources(&long_cut, 3)); + + let mut exact_split = project(); + exact_split.audio.improve = true; + exact_split + .timeline + .as_mut() + .unwrap() + .segments + .push(segment(0, 0.33, 0.9)); + assert!(ExportAudioRenderer::eligible_sources(&exact_split, 3)); + } + #[test] fn studio_sound_transition_with_single_sample_requests_keeps_lookahead() { assert_bounded_sink_variants([19], &[1]); diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 16a618e214d..afe97ba7b1f 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -14,7 +14,7 @@ mod segments; mod telemetry; mod thumbnail; -pub use audio::{AudioRenderer, AudioSegment, MusicTracks}; +pub use audio::{AudioRenderer, AudioSegment, MusicTracks, release_playback_audio_caches}; #[cfg(feature = "test-hooks")] pub use audio_output::OfflineAudioSink; pub use audio_output::{ diff --git a/crates/enc-ffmpeg/src/audio/aac.rs b/crates/enc-ffmpeg/src/audio/aac.rs index e6291de13b7..ce3a9a28963 100644 --- a/crates/enc-ffmpeg/src/audio/aac.rs +++ b/crates/enc-ffmpeg/src/audio/aac.rs @@ -162,7 +162,7 @@ impl AACEncoder { let Some(&rate) = rates .iter() .find(|r| **r >= input_config.rate()) - .or(rates.first()) + .or(rates.last()) else { return Err(AACEncoderError::RateNotSupported(input_config.rate())); }; @@ -250,6 +250,10 @@ impl AACEncoder { ) -> Result<(), ffmpeg::Error> { self.base.send_frame(frame, timestamp, sink) } + + pub fn flush_to(&mut self, sink: &mut impl PacketSink) -> Result<(), ffmpeg::Error> { + self.base.flush(sink) + } } impl AudioEncoder for AACEncoder { @@ -275,6 +279,74 @@ mod tests { use super::*; use ffmpeg::ChannelLayout; + #[test] + fn sample_rate_selection_preserves_supported_rates_and_clamps_outliers() { + let directory = tempfile::tempdir().unwrap(); + for (input_rate, expected_rate) in [ + (4_000, 7_350), + (44_100, 44_100), + (45_000, 48_000), + (96_000, 96_000), + (192_000, 96_000), + ] { + let mut output = + format::output(&directory.path().join(format!("{input_rate}.mp4"))).unwrap(); + let encoder = AACEncoder::init( + AudioInfo::new_raw(Sample::F32(Type::Packed), input_rate, 1), + &mut output, + ) + .unwrap(); + let stream = output.stream(encoder.stream_index()).unwrap(); + let decoder = context::Context::from_parameters(stream.parameters()) + .unwrap() + .decoder() + .audio() + .unwrap(); + assert_eq!(decoder.rate(), expected_rate, "input rate {input_rate}"); + } + } + + #[test] + fn high_rate_input_keeps_high_frequency_audio_through_encode_decode() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("high-rate.mp4"); + let mut output = format::output(&path).unwrap(); + let mut encoder = AACEncoder::init( + AudioInfo::new_raw(Sample::F32(Type::Packed), 192_000, 1), + &mut output, + ) + .unwrap(); + output.write_header().unwrap(); + + let mut audio = frame::Audio::new(Sample::F32(Type::Packed), 192_000, ChannelLayout::MONO); + audio.set_rate(192_000); + audio.set_pts(Some(0)); + for (index, sample) in audio.plane_mut::(0).iter_mut().enumerate() { + *sample = 0.5 * (std::f32::consts::TAU * 12_000.0 * index as f32 / 192_000.0).sin(); + } + encoder + .send_frame(audio, Duration::MAX, &mut output) + .unwrap(); + encoder.flush(&mut output).unwrap(); + output.write_trailer().unwrap(); + drop(encoder); + drop(output); + + let (samples, track_end) = decode_first_channel(&path); + assert_eq!(track_end, 48_000); + assert!(samples.len() >= 96_000, "decoded {} samples", samples.len()); + let mut sine = 0.0; + let mut cosine = 0.0; + let signal = &samples[9_600..86_400]; + for (index, sample) in signal.iter().enumerate() { + let phase = std::f64::consts::TAU * 12_000.0 * index as f64 / 96_000.0; + sine += f64::from(*sample) * phase.sin(); + cosine += f64::from(*sample) * phase.cos(); + } + let amplitude = 2.0 * sine.hypot(cosine) / signal.len() as f64; + assert!(amplitude > 0.4, "12 kHz tone amplitude {amplitude}"); + } + fn input_frame(start: i64, samples: usize) -> frame::Audio { let mut audio = frame::Audio::new(Sample::F32(Type::Packed), samples, ChannelLayout::STEREO); diff --git a/crates/enc-ffmpeg/src/audio/opus.rs b/crates/enc-ffmpeg/src/audio/opus.rs index 3bcfb974cff..5182c29687b 100644 --- a/crates/enc-ffmpeg/src/audio/opus.rs +++ b/crates/enc-ffmpeg/src/audio/opus.rs @@ -106,6 +106,19 @@ impl OpusEncoder { pub fn flush(&mut self, output: &mut format::context::Output) -> Result<(), ffmpeg::Error> { self.base.flush(output) } + + pub fn queue_frame_to( + &mut self, + frame: frame::Audio, + timestamp: Duration, + sink: &mut impl crate::PacketSink, + ) -> Result<(), ffmpeg::Error> { + self.base.send_frame(frame, timestamp, sink) + } + + pub fn flush_to(&mut self, sink: &mut impl crate::PacketSink) -> Result<(), ffmpeg::Error> { + self.base.flush(sink) + } } fn select_output_rate(input_rate: i32, supported_rates: &[i32]) -> Option { diff --git a/crates/enc-ffmpeg/src/mux/dash_audio.rs b/crates/enc-ffmpeg/src/mux/dash_audio.rs index b3dfcc0079e..9fef8384949 100644 --- a/crates/enc-ffmpeg/src/mux/dash_audio.rs +++ b/crates/enc-ffmpeg/src/mux/dash_audio.rs @@ -1,6 +1,9 @@ use super::fragment_metadata::read_fragment_metadata; use crate::audio::aac::{AACEncoder, AACEncoderError}; -use crate::mux::segmented_stream::{SegmentCompletedEvent, SegmentMediaType}; +use crate::mux::segmented_stream::{ + SegmentCompletedEvent, SegmentMediaType, SegmentSender, notify_segment, +}; +use crate::mux::write_behind::{AUDIO_BUDGET_BYTES, MuxSink, MuxTarget}; use cap_media_info::AudioInfo; use ffmpeg::{format, frame}; use serde::Serialize; @@ -47,6 +50,12 @@ fn sync_file(path: &Path) { pub struct DashAudioSegmentEncoder { base_path: PathBuf, encoder: AACEncoder, + mux: MuxSink, + segment_tx: SegmentSender, +} + +struct AudioSegmentMux { + base_path: PathBuf, output: format::context::Output, initial_padding: Duration, @@ -56,10 +65,30 @@ pub struct DashAudioSegmentEncoder { frames_since_segment_scan: u32, - segment_tx: Option>, + segment_tx: SegmentSender, init_notified: bool, } +impl MuxTarget for AudioSegmentMux { + fn output(&self) -> &format::context::Output { + &self.output + } + + fn output_mut(&mut self) -> &mut format::context::Output { + &mut self.output + } + + fn packet_written(&mut self) { + self.try_notify_init_segment(); + + self.frames_since_segment_scan += 1; + if self.frames_since_segment_scan >= 10 { + self.frames_since_segment_scan = 0; + self.flush_pending_segments(); + } + } +} + #[derive(Debug, Clone)] pub struct AudioSegmentInfo { pub path: PathBuf, @@ -197,33 +226,103 @@ impl DashAudioSegmentEncoder { "Initialized DASH audio segment encoder (init.mp4 + m4s segments)" ); - let instance = Self { - base_path, - encoder, + let mux = AudioSegmentMux { + base_path: base_path.clone(), output, initial_padding, current_index: 1, completed_segments: Vec::new(), frames_since_segment_scan: 0, - segment_tx: None, + segment_tx: SegmentSender::default(), init_notified: false, }; + let segment_tx = mux.segment_tx.clone(); + + mux.write_in_progress_manifest(); + + Ok(Self { + base_path, + encoder, + mux: MuxSink::Direct(mux), + segment_tx, + }) + } - instance.write_in_progress_manifest(); + /// Moves muxing, segment publication and manifest writes onto a + /// dedicated writer thread so a stalled output volume no longer blocks + /// the caller. Bytes on disk and their order are unchanged. + pub fn enable_write_behind(&mut self) -> std::io::Result<()> { + self.mux + .enable_write_behind("audio-segments", AUDIO_BUDGET_BYTES) + } - Ok(instance) + pub fn is_write_behind(&self) -> bool { + self.mux.is_write_behind() } pub fn set_segment_callback(&mut self, tx: std::sync::mpsc::Sender) { - self.segment_tx = Some(tx); - self.try_notify_init_segment(); + *self + .segment_tx + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(tx); + if !self.mux.is_write_behind() { + self.mux + .with_target(AudioSegmentMux::try_notify_init_segment); + } + } + + pub fn queue_frame( + &mut self, + frame: frame::Audio, + timestamp: Duration, + ) -> Result<(), QueueFrameError> { + self.encoder + .send_frame_to(frame, timestamp, &mut self.mux)?; + + self.mux.frame_queued(); + + Ok(()) } + pub fn finish(&mut self) -> Result<(), FinishError> { + let encoder = &mut self.encoder; + self.mux + .finish( + |mux| encoder.flush_to(mux), + |mux: &mut AudioSegmentMux, flush_result| { + let flush_result = flush_result.inspect_err(|error| { + tracing::warn!(%error, "Audio encoder flush failed"); + }); + mux.finish(flush_result) + }, + ) + .map_err(std::io::Error::from)? + } + + pub fn finish_with_timestamp(&mut self, _timestamp: Duration) -> Result<(), FinishError> { + self.finish() + } + + pub fn completed_segments(&self) -> Vec { + self.mux + .with_target_ref(|mux| mux.completed_segments.clone()) + } + + pub fn base_path(&self) -> &Path { + &self.base_path + } + + pub fn init_segment_path(&self) -> PathBuf { + self.base_path.join(INIT_SEGMENT_NAME) + } +} + +impl AudioSegmentMux { fn try_notify_init_segment(&mut self) { if self.init_notified { return; } - let init_path = self.init_segment_path(); + let init_path = self.base_path.join(INIT_SEGMENT_NAME); if let Ok(meta) = std::fs::metadata(&init_path) && meta.len() > 0 { @@ -240,30 +339,7 @@ impl DashAudioSegmentEncoder { } fn notify_segment(&self, event: SegmentCompletedEvent) { - if let Some(tx) = &self.segment_tx - && let Err(e) = tx.send(event) - { - tracing::warn!("Failed to send audio segment completed event: {e}"); - } - } - - pub fn queue_frame( - &mut self, - frame: frame::Audio, - timestamp: Duration, - ) -> Result<(), QueueFrameError> { - self.encoder - .send_frame(frame, timestamp, &mut self.output)?; - - self.try_notify_init_segment(); - - self.frames_since_segment_scan += 1; - if self.frames_since_segment_scan >= 10 { - self.frames_since_segment_scan = 0; - self.flush_pending_segments(); - } - - Ok(()) + notify_segment(&self.segment_tx, event); } fn flush_pending_segments(&mut self) { @@ -367,10 +443,7 @@ impl DashAudioSegmentEncoder { } } - pub fn finish(&mut self) -> Result<(), FinishError> { - let flush_result = self.encoder.flush(&mut self.output).inspect_err(|error| { - tracing::warn!(%error, "Audio encoder flush failed"); - }); + fn finish(&mut self, flush_result: Result<(), ffmpeg::Error>) -> Result<(), FinishError> { let trailer_result = self.output.write_trailer().inspect_err(|error| { tracing::warn!(%error, "Audio trailer publication failed"); }); @@ -394,10 +467,6 @@ impl DashAudioSegmentEncoder { Ok(()) } - pub fn finish_with_timestamp(&mut self, _timestamp: Duration) -> Result<(), FinishError> { - self.finish() - } - fn finalize_pending_tmp_files(&self) { let Ok(entries) = std::fs::read_dir(&self.base_path) else { return; @@ -523,18 +592,6 @@ impl DashAudioSegmentEncoder { let manifest_path = self.base_path.join("manifest.json"); atomic_write_json(&manifest_path, &manifest) } - - pub fn completed_segments(&self) -> &[AudioSegmentInfo] { - &self.completed_segments - } - - pub fn base_path(&self) -> &Path { - &self.base_path - } - - pub fn init_segment_path(&self) -> PathBuf { - self.base_path.join(INIT_SEGMENT_NAME) - } } #[cfg(test)] @@ -570,6 +627,62 @@ mod tests { frame } + fn record_audio_segments(base_path: &Path, write_behind: bool) -> Vec<(u32, u64, bool)> { + let mut encoder = DashAudioSegmentEncoder::init( + base_path.to_path_buf(), + test_audio_info(), + DashAudioSegmentEncoderConfig::default(), + ) + .unwrap(); + if write_behind { + encoder.enable_write_behind().unwrap(); + } + let (tx, rx) = mpsc::channel(); + encoder.set_segment_callback(tx); + for i in 0..500u64 { + encoder + .queue_frame( + create_test_audio_frame(1024, i * 1024), + Duration::from_secs_f64(i as f64 * 1024.0 / 48_000.0), + ) + .unwrap(); + } + encoder.finish().unwrap(); + drop(encoder); + rx.try_iter() + .map(|event| (event.index, event.file_size, event.is_init)) + .collect() + } + + #[test] + fn write_behind_publishes_the_same_audio_segments_as_inline_muxing() { + ffmpeg::init().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let inline = temp.path().join("inline"); + let behind = temp.path().join("behind"); + + let inline_events = record_audio_segments(&inline, false); + let behind_events = record_audio_segments(&behind, true); + + assert!(inline_events.len() >= 3); + assert_eq!(inline_events, behind_events); + let files = |path: &Path| { + let mut files: Vec<(String, Vec)> = std::fs::read_dir(path) + .unwrap() + .flatten() + .map(|entry| { + ( + entry.file_name().to_string_lossy().into_owned(), + std::fs::read(entry.path()).unwrap(), + ) + }) + .collect(); + files.sort(); + files + }; + assert_eq!(files(&inline), files(&behind)); + } + #[test] fn audio_manifest_and_events_match_muxer_timeline() { ffmpeg::init().unwrap(); diff --git a/crates/enc-ffmpeg/src/mux/fragmented_audio.rs b/crates/enc-ffmpeg/src/mux/fragmented_audio.rs index cf865c77c56..c5e9d8db7d9 100644 --- a/crates/enc-ffmpeg/src/mux/fragmented_audio.rs +++ b/crates/enc-ffmpeg/src/mux/fragmented_audio.rs @@ -2,15 +2,28 @@ use cap_media_info::AudioInfo; use ffmpeg::{format, frame}; use std::{path::PathBuf, time::Duration}; +use super::write_behind::{AUDIO_BUDGET_BYTES, MuxSink, MuxTarget}; use crate::audio::aac::{AACEncoder, AACEncoderError}; pub struct FragmentedAudioFile { encoder: AACEncoder, - output: format::context::Output, + output: MuxSink, finished: bool, has_frames: bool, } +struct AudioOutput(format::context::Output); + +impl MuxTarget for AudioOutput { + fn output(&self) -> &format::context::Output { + &self.0 + } + + fn output_mut(&mut self) -> &mut format::context::Output { + &mut self.0 + } +} + #[derive(thiserror::Error, Debug)] pub enum InitError { #[error("FFmpeg: {0}")] @@ -103,12 +116,19 @@ impl FragmentedAudioFile { Ok(Self { encoder, - output, + output: MuxSink::Direct(AudioOutput(output)), finished: false, has_frames: false, }) } + /// Hands encoded packets to a dedicated writer thread so a stalled + /// output volume no longer blocks the caller (an async runtime worker in + /// the recording pipeline). Bytes on disk and their order are unchanged. + pub fn enable_write_behind(&mut self) -> std::io::Result<()> { + self.output.enable_write_behind("audio", AUDIO_BUDGET_BYTES) + } + pub fn encoder(&self) -> &AACEncoder { &self.encoder } @@ -118,8 +138,16 @@ impl FragmentedAudioFile { frame: frame::Audio, timestamp: Duration, ) -> Result<(), ffmpeg::Error> { + if self.finished && self.output.is_write_behind() { + return Err(ffmpeg::Error::Exit); + } self.has_frames = true; - self.encoder.send_frame(frame, timestamp, &mut self.output) + self.encoder + .send_frame_to(frame, timestamp, &mut self.output) + } + + pub fn is_write_behind(&self) -> bool { + self.output.is_write_behind() } pub fn finish(&mut self) -> Result, FinishError> { @@ -136,12 +164,21 @@ impl FragmentedAudioFile { self.finished = true; - let flush_result = if self.has_frames { - self.encoder.flush(&mut self.output) - } else { - Ok(()) - }; - let trailer_result = self.output.write_trailer(); + let has_frames = self.has_frames; + let encoder = &mut self.encoder; + let (flush_result, trailer_result) = self + .output + .finish( + |output| { + if has_frames { + encoder.flush_to(output) + } else { + Ok(()) + } + }, + |output: &mut AudioOutput, flush_result| (flush_result, output.0.write_trailer()), + ) + .map_err(|stalled| FinishError::WriteTrailerFailed(stalled.into()))?; finish_result(flush_result, trailer_result) } } @@ -242,6 +279,48 @@ mod publication_tests { output.finish().unwrap().unwrap(); } + #[test] + fn write_behind_audio_matches_inline_bytes() { + let directory = tempfile::tempdir().unwrap(); + let info = AudioInfo::new_raw(Sample::F32(Type::Packed), 48_000, 1); + let inline_path = directory.path().join("inline.m4a"); + let mut inline = FragmentedAudioFile::init(inline_path.clone(), info).unwrap(); + queue_tone(&mut inline, 300); + inline.finish().unwrap().unwrap(); + + let behind_path = directory.path().join("behind.m4a"); + let mut behind = FragmentedAudioFile::init(behind_path.clone(), info).unwrap(); + behind.enable_write_behind().unwrap(); + queue_tone(&mut behind, 300); + behind.output.drain(); + let mut input = format::input(&behind_path).unwrap(); + assert!(input.packets().next().is_some()); + drop(input); + behind.finish().unwrap().unwrap(); + + assert_eq!( + std::fs::read(&inline_path).unwrap(), + std::fs::read(&behind_path).unwrap() + ); + } + + #[test] + fn write_behind_audio_rejects_frames_after_finish() { + let directory = tempfile::tempdir().unwrap(); + let info = AudioInfo::new_raw(Sample::F32(Type::Packed), 48_000, 1); + let mut output = + FragmentedAudioFile::init(directory.path().join("finished.m4a"), info).unwrap(); + output.enable_write_behind().unwrap(); + queue_tone(&mut output, 10); + output.finish().unwrap().unwrap(); + let frame = + ffmpeg::frame::Audio::new(Sample::F32(Type::Packed), 1024, ffmpeg::ChannelLayout::MONO); + assert_eq!( + output.queue_frame(frame, Duration::from_secs(1)), + Err(ffmpeg::Error::Exit) + ); + } + #[test] fn short_fragmented_audio_remains_readable_after_finish() { let directory = tempfile::tempdir().unwrap(); @@ -306,7 +385,7 @@ mod publication_tests { fn set_output_error(output: &mut FragmentedAudioFile) { unsafe { - let context = output.output.as_mut_ptr(); + let context = output.output.with_target(|output| output.0.as_mut_ptr()); assert!(!(*context).pb.is_null()); (*(*context).pb).error = ffmpeg::Error::Other { errno: ffmpeg::error::EIO, diff --git a/crates/enc-ffmpeg/src/mux/mod.rs b/crates/enc-ffmpeg/src/mux/mod.rs index c31ea535e38..67a71c34364 100644 --- a/crates/enc-ffmpeg/src/mux/mod.rs +++ b/crates/enc-ffmpeg/src/mux/mod.rs @@ -10,3 +10,4 @@ pub mod mp4; pub mod ogg; pub mod segmented_audio; pub mod segmented_stream; +pub(crate) mod write_behind; diff --git a/crates/enc-ffmpeg/src/mux/ogg.rs b/crates/enc-ffmpeg/src/mux/ogg.rs index c8d700c661f..e5649d6c8bb 100644 --- a/crates/enc-ffmpeg/src/mux/ogg.rs +++ b/crates/enc-ffmpeg/src/mux/ogg.rs @@ -1,14 +1,27 @@ use ffmpeg::{format, frame}; use std::{path::PathBuf, time::Duration}; +use super::write_behind::{AUDIO_BUDGET_BYTES, MuxSink, MuxTarget}; use crate::audio::opus::{OpusEncoder, OpusEncoderError}; pub struct OggFile { encoder: OpusEncoder, - output: format::context::Output, + output: MuxSink, finished: bool, } +struct OggOutput(format::context::Output); + +impl MuxTarget for OggOutput { + fn output(&self) -> &format::context::Output { + &self.0 + } + + fn output_mut(&mut self) -> &mut format::context::Output { + &mut self.0 + } +} + #[derive(thiserror::Error, Debug)] pub enum FinishError { #[error("Already finished")] @@ -32,11 +45,18 @@ impl OggFile { Ok(Self { encoder, - output, + output: MuxSink::Direct(OggOutput(output)), finished: false, }) } + /// Hands encoded packets to a dedicated writer thread so a stalled + /// output volume no longer blocks the caller. Bytes on disk and their + /// order are unchanged. + pub fn enable_write_behind(&mut self) -> std::io::Result<()> { + self.output.enable_write_behind("ogg", AUDIO_BUDGET_BYTES) + } + pub fn encoder(&self) -> &OpusEncoder { &self.encoder } @@ -46,7 +66,15 @@ impl OggFile { frame: frame::Audio, timestamp: Duration, ) -> Result<(), ffmpeg::Error> { - self.encoder.queue_frame(frame, timestamp, &mut self.output) + if self.finished && self.output.is_write_behind() { + return Err(ffmpeg::Error::Exit); + } + self.encoder + .queue_frame_to(frame, timestamp, &mut self.output) + } + + pub fn is_write_behind(&self) -> bool { + self.output.is_write_behind() } pub fn finish(&mut self) -> Result, FinishError> { @@ -56,10 +84,15 @@ impl OggFile { self.finished = true; - let flush_result = self.encoder.flush(&mut self.output); - self.output - .write_trailer() - .map_err(FinishError::WriteTrailerFailed)?; + let encoder = &mut self.encoder; + let (flush_result, trailer_result) = self + .output + .finish( + |output| encoder.flush_to(output), + |output: &mut OggOutput, flush_result| (flush_result, output.0.write_trailer()), + ) + .map_err(|stalled| FinishError::WriteTrailerFailed(stalled.into()))?; + trailer_result.map_err(FinishError::WriteTrailerFailed)?; Ok(flush_result) } @@ -79,6 +112,15 @@ mod tests { use ffmpeg::ChannelLayout; fn write_recording(path: std::path::PathBuf, input_rate: u32, total_samples: usize) { + write_recording_with(path, input_rate, total_samples, false); + } + + fn write_recording_with( + path: std::path::PathBuf, + input_rate: u32, + total_samples: usize, + write_behind: bool, + ) { let info = AudioInfo::new_raw( format::Sample::F32(format::sample::Type::Packed), input_rate, @@ -86,6 +128,9 @@ mod tests { ); let mut file = OggFile::init(path, OpusEncoder::factory(info)).unwrap(); + if write_behind { + file.enable_write_behind().unwrap(); + } let chunk = 1024usize; let mut written = 0usize; @@ -142,6 +187,22 @@ mod tests { ); } + #[test] + fn write_behind_output_has_the_same_duration_and_size() { + let dir = tempfile::tempdir().unwrap(); + let inline = dir.path().join("inline.ogg"); + let behind = dir.path().join("behind.ogg"); + + write_recording_with(inline.clone(), 48_000, 48_000 * 5, false); + write_recording_with(behind.clone(), 48_000, 48_000 * 5, true); + + assert_eq!( + std::fs::metadata(&inline).unwrap().len(), + std::fs::metadata(&behind).unwrap().len() + ); + assert!((probed_duration_secs(&behind) - probed_duration_secs(&inline)).abs() < 1e-6); + } + #[test] fn sub_frame_input_is_flushed_not_dropped() { // Less input than one opus frame (20ms): finish() must drain the diff --git a/crates/enc-ffmpeg/src/mux/segmented_stream.rs b/crates/enc-ffmpeg/src/mux/segmented_stream.rs index d252a921c6c..0ea15ad1566 100644 --- a/crates/enc-ffmpeg/src/mux/segmented_stream.rs +++ b/crates/enc-ffmpeg/src/mux/segmented_stream.rs @@ -1,4 +1,5 @@ use super::fragment_metadata::read_fragment_metadata; +use super::write_behind::{MuxSink, MuxTarget}; use cap_media_info::VideoInfo; use ffmpeg::{format, frame}; use serde::Serialize; @@ -26,6 +27,22 @@ pub struct DiskSpaceWarning { pub type DiskSpaceCallback = Arc; +/// Shared so a write-behind muxer can install the callback without waiting +/// for the writer thread, which may be blocked on storage. +pub(crate) type SegmentSender = + Arc>>>; + +pub(crate) fn notify_segment(sender: &SegmentSender, event: SegmentCompletedEvent) { + let sender = sender + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(tx) = sender.as_ref() + && let Err(e) = tx.send(event) + { + tracing::warn!("Failed to send segment completed event: {e}"); + } +} + fn atomic_write_json(path: &Path, data: &T) -> std::io::Result<()> { let temp_path = path.with_extension("json.tmp"); let json = serde_json::to_string_pretty(data) @@ -76,10 +93,19 @@ pub struct SegmentedVideoEncoder { base_path: PathBuf, encoder: H264Encoder, + mux: MuxSink, + segment_tx: SegmentSender, + + segment_duration: Duration, + + disk_space_callback: Option, +} + +struct SegmentMux { + base_path: PathBuf, output: format::context::Output, current_index: u32, - segment_duration: Duration, completed_segments: Vec, @@ -87,9 +113,36 @@ pub struct SegmentedVideoEncoder { codec_info: CodecInfo, - disk_space_callback: Option, - segment_tx: Option>, + segment_tx: SegmentSender, init_notified: bool, + initial_manifest_pending: bool, +} + +impl MuxTarget for SegmentMux { + fn output(&self) -> &format::context::Output { + &self.output + } + + fn output_mut(&mut self) -> &mut format::context::Output { + &mut self.output + } + + fn writer_started(&mut self) { + self.write_initial_manifest(); + } + + fn packet_written(&mut self) { + // The encoder holds one packet back to stamp real durations, so the + // init segment materializes a frame later than the first queue call; + // keep trying until it lands (no-op once notified). + self.try_notify_init_segment(); + + self.frames_since_segment_scan += 1; + if self.frames_since_segment_scan >= 10 { + self.frames_since_segment_scan = 0; + self.flush_pending_segments(); + } + } } #[derive(Debug, Clone)] @@ -198,7 +251,7 @@ impl SegmentedVideoEncoder { video_config: VideoInfo, config: SegmentedVideoEncoderConfig, ) -> Result { - Self::init_with_timed_fragments(base_path, video_config, config, false) + Self::init_with_options(base_path, video_config, config, false, false) } pub fn init_with_timed_fragments( @@ -206,6 +259,31 @@ impl SegmentedVideoEncoder { video_config: VideoInfo, config: SegmentedVideoEncoderConfig, timed_fragments: bool, + ) -> Result { + Self::init_with_options(base_path, video_config, config, timed_fragments, false) + } + + /// Like [`Self::init_with_timed_fragments`], but muxing, segment + /// publication and manifest writes (including the initial manifest) run on + /// a dedicated writer thread with a memory budget sized from the stream + /// bitrate, so storage stalls stop blocking the encoder. Output bytes and + /// their order are unchanged. Falls back to inline muxing if the thread + /// cannot start. + pub fn init_write_behind( + base_path: PathBuf, + video_config: VideoInfo, + config: SegmentedVideoEncoderConfig, + timed_fragments: bool, + ) -> Result { + Self::init_with_options(base_path, video_config, config, timed_fragments, true) + } + + fn init_with_options( + base_path: PathBuf, + video_config: VideoInfo, + config: SegmentedVideoEncoderConfig, + timed_fragments: bool, + write_behind: bool, ) -> Result { std::fs::create_dir_all(&base_path)?; @@ -309,21 +387,40 @@ impl SegmentedVideoEncoder { "Initialized segmented video encoder with FFmpeg DASH muxer (init.mp4 + m4s segments)" ); - let instance = Self { - base_path, - encoder, + let mux = SegmentMux { + base_path: base_path.clone(), output, current_index: 1, - segment_duration: config.segment_duration, completed_segments: Vec::new(), frames_since_segment_scan: 0, codec_info, - disk_space_callback: None, - segment_tx: None, + segment_tx: SegmentSender::default(), init_notified: false, + initial_manifest_pending: true, }; + let segment_tx = mux.segment_tx.clone(); - instance.write_in_progress_manifest(); + let mut instance = Self { + base_path, + encoder, + mux: MuxSink::Direct(mux), + segment_tx, + segment_duration: config.segment_duration, + disk_space_callback: None, + }; + + if write_behind { + let budget = super::write_behind::video_budget_bytes(instance.encoder.bit_rate()); + match instance.mux.enable_write_behind("video", budget) { + Ok(()) => tracing::debug!(budget_bytes = budget, "Video write-behind enabled"), + Err(error) => { + tracing::warn!(%error, "Video write-behind unavailable, muxing inline"); + } + } + } + if !instance.mux.is_write_behind() { + instance.mux.with_target(SegmentMux::write_initial_manifest); + } Ok(instance) } @@ -333,27 +430,12 @@ impl SegmentedVideoEncoder { } pub fn set_segment_callback(&mut self, tx: std::sync::mpsc::Sender) { - self.segment_tx = Some(tx); - self.try_notify_init_segment(); - } - - fn try_notify_init_segment(&mut self) { - if self.init_notified { - return; - } - let init_path = self.init_segment_path(); - if let Ok(meta) = std::fs::metadata(&init_path) - && meta.len() > 0 - { - self.init_notified = true; - self.notify_segment(SegmentCompletedEvent { - path: init_path, - index: 0, - duration: 0.0, - file_size: meta.len(), - is_init: true, - media_type: SegmentMediaType::Video, - }); + *self + .segment_tx + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(tx); + if !self.mux.is_write_behind() { + self.mux.with_target(SegmentMux::try_notify_init_segment); } } @@ -391,30 +473,123 @@ impl SegmentedVideoEncoder { // stream restarts, dropped frames) stay in the timeline instead of // compressing it and drifting video ahead of audio. self.encoder - .queue_frame(frame, timestamp, &mut self.output)?; + .queue_frame_to(frame, timestamp, &mut self.mux)?; - // The encoder holds one packet back to stamp real durations, so the - // init segment materializes a frame later than the first queue call; - // keep trying until it lands (no-op once notified). - self.try_notify_init_segment(); + self.mux.frame_queued(); - self.frames_since_segment_scan += 1; - if self.frames_since_segment_scan >= 10 { - self.frames_since_segment_scan = 0; - self.flush_pending_segments(); + Ok(()) + } + + pub fn finish(&mut self) -> Result<(), FinishError> { + let encoder = &mut self.encoder; + self.mux + .finish( + |mux| encoder.flush_to(mux), + |mux: &mut SegmentMux, flush_result| { + let flush_result = flush_result.inspect_err(|error| { + tracing::warn!(%error, "Video encoder flush failed"); + }); + mux.finish(flush_result) + }, + ) + .map_err(std::io::Error::from)? + } + + pub fn finish_with_timestamp(&mut self, _timestamp: Duration) -> Result<(), FinishError> { + self.finish() + } + + pub fn completed_segments(&self) -> Vec { + self.mux + .with_target_ref(|mux| mux.completed_segments.clone()) + } + + pub fn current_encoder(&self) -> Option<&H264Encoder> { + Some(&self.encoder) + } + + pub fn current_encoder_mut(&mut self) -> Option<&mut H264Encoder> { + Some(&mut self.encoder) + } + + pub fn base_path(&self) -> &Path { + &self.base_path + } + + pub fn segment_duration(&self) -> Duration { + self.segment_duration + } + + pub fn current_index(&self) -> u32 { + self.mux.with_target_ref(|mux| mux.current_index) + } + + pub fn init_segment_path(&self) -> PathBuf { + self.base_path.join(INIT_SEGMENT_NAME) + } + + pub fn validate_init_segment(&self) -> Result<(), String> { + let init_path = self.init_segment_path(); + + if !init_path.exists() { + return Err(format!( + "init.mp4 is missing at {}. M4S segments will be unplayable without it.", + init_path.display() + )); } - Ok(()) + match std::fs::metadata(&init_path) { + Ok(metadata) => { + let size = metadata.len(); + if size < 100 { + return Err(format!( + "init.mp4 at {} is too small ({} bytes). It may be corrupted.", + init_path.display(), + size + )); + } + Ok(()) + } + Err(e) => Err(format!( + "Cannot read init.mp4 metadata at {}: {}", + init_path.display(), + e + )), + } + } +} + +impl SegmentMux { + fn write_initial_manifest(&mut self) { + if std::mem::take(&mut self.initial_manifest_pending) { + self.write_in_progress_manifest(); + } } - fn notify_segment(&self, event: SegmentCompletedEvent) { - if let Some(tx) = &self.segment_tx - && let Err(e) = tx.send(event) + fn try_notify_init_segment(&mut self) { + if self.init_notified { + return; + } + let init_path = self.base_path.join(INIT_SEGMENT_NAME); + if let Ok(meta) = std::fs::metadata(&init_path) + && meta.len() > 0 { - tracing::warn!("Failed to send segment completed event: {e}"); + self.init_notified = true; + self.notify_segment(SegmentCompletedEvent { + path: init_path, + index: 0, + duration: 0.0, + file_size: meta.len(), + is_init: true, + media_type: SegmentMediaType::Video, + }); } } + fn notify_segment(&self, event: SegmentCompletedEvent) { + notify_segment(&self.segment_tx, event); + } + fn flush_pending_segments(&mut self) { let first_index = self.current_index; loop { @@ -507,10 +682,7 @@ impl SegmentedVideoEncoder { } } - pub fn finish(&mut self) -> Result<(), FinishError> { - let flush_result = self.encoder.flush(&mut self.output).inspect_err(|error| { - tracing::warn!(%error, "Video encoder flush failed"); - }); + fn finish(&mut self, flush_result: Result<(), ffmpeg::Error>) -> Result<(), FinishError> { let trailer_result = self.output.write_trailer().inspect_err(|error| { tracing::warn!(%error, "Video trailer publication failed"); }); @@ -534,10 +706,6 @@ impl SegmentedVideoEncoder { Ok(()) } - pub fn finish_with_timestamp(&mut self, _timestamp: Duration) -> Result<(), FinishError> { - self.finish() - } - fn finalize_pending_tmp_files(&self) { let Ok(entries) = std::fs::read_dir(&self.base_path) else { return; @@ -702,64 +870,6 @@ impl SegmentedVideoEncoder { let manifest_path = self.base_path.join("manifest.json"); atomic_write_json(&manifest_path, &manifest) } - - pub fn completed_segments(&self) -> &[VideoSegmentInfo] { - &self.completed_segments - } - - pub fn current_encoder(&self) -> Option<&H264Encoder> { - Some(&self.encoder) - } - - pub fn current_encoder_mut(&mut self) -> Option<&mut H264Encoder> { - Some(&mut self.encoder) - } - - pub fn base_path(&self) -> &Path { - &self.base_path - } - - pub fn segment_duration(&self) -> Duration { - self.segment_duration - } - - pub fn current_index(&self) -> u32 { - self.current_index - } - - pub fn init_segment_path(&self) -> PathBuf { - self.base_path.join(INIT_SEGMENT_NAME) - } - - pub fn validate_init_segment(&self) -> Result<(), String> { - let init_path = self.init_segment_path(); - - if !init_path.exists() { - return Err(format!( - "init.mp4 is missing at {}. M4S segments will be unplayable without it.", - init_path.display() - )); - } - - match std::fs::metadata(&init_path) { - Ok(metadata) => { - let size = metadata.len(); - if size < 100 { - return Err(format!( - "init.mp4 at {} is too small ({} bytes). It may be corrupted.", - init_path.display(), - size - )); - } - Ok(()) - } - Err(e) => Err(format!( - "Cannot read init.mp4 metadata at {}: {}", - init_path.display(), - e - )), - } - } } #[cfg(test)] @@ -852,6 +962,128 @@ mod tests { ); } + fn record_segments(base_path: &Path, write_behind: bool) -> Vec<(u32, bool, u64)> { + let mut encoder = if write_behind { + SegmentedVideoEncoder::init_write_behind( + base_path.to_path_buf(), + test_video_info(), + SegmentedVideoEncoderConfig::default(), + false, + ) + } else { + SegmentedVideoEncoder::init( + base_path.to_path_buf(), + test_video_info(), + SegmentedVideoEncoderConfig::default(), + ) + } + .unwrap(); + assert_eq!(encoder.mux.is_write_behind(), write_behind); + let (tx, rx) = mpsc::channel(); + encoder.set_segment_callback(tx); + for i in 0..200 { + encoder + .queue_frame( + create_test_frame(320, 240), + Duration::from_micros(i * 33_320), + ) + .unwrap(); + } + encoder + .finish_with_timestamp(Duration::from_secs(7)) + .unwrap(); + drop(encoder); + rx.try_iter() + .map(|event| { + assert_eq!( + event.file_size, + std::fs::metadata(&event.path).unwrap().len() + ); + (event.index, event.is_init, (event.duration * 1e6) as u64) + }) + .collect() + } + + fn published_index(base_path: &Path) -> (Vec, String, serde_json::Value) { + let mut names: Vec = std::fs::read_dir(base_path) + .unwrap() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + let mpd = std::fs::read_to_string(base_path.join("dash_manifest.mpd")).unwrap(); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(base_path.join("manifest.json")).unwrap()) + .unwrap(); + for segment in manifest["segments"].as_array_mut().unwrap() { + segment.as_object_mut().unwrap().remove("file_size"); + } + (names, mpd, manifest) + } + + #[test] + fn write_behind_publishes_the_initial_manifest_from_the_writer_thread() { + ffmpeg::init().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let encoder = SegmentedVideoEncoder::init_write_behind( + temp.path().to_path_buf(), + test_video_info(), + SegmentedVideoEncoderConfig::default(), + false, + ) + .unwrap(); + let manifest_path = temp.path().join("manifest.json"); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while !manifest_path.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + assert_eq!(manifest["is_complete"], false); + assert_eq!(manifest["segments"][0]["index"], 1); + drop(encoder); + } + + #[test] + fn write_behind_segment_callback_does_not_wait_for_stalled_storage() { + ffmpeg::init().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let mut encoder = SegmentedVideoEncoder::init_write_behind( + temp.path().to_path_buf(), + test_video_info(), + SegmentedVideoEncoderConfig::default(), + false, + ) + .unwrap(); + encoder.mux.drain(); + let stall = encoder.mux.stall_storage_for(Duration::from_millis(800)); + let started = std::time::Instant::now(); + let (tx, _rx) = mpsc::channel(); + encoder.set_segment_callback(tx); + encoder + .queue_frame(create_test_frame(320, 240), Duration::ZERO) + .unwrap(); + assert!(started.elapsed() < Duration::from_millis(400)); + stall.join().unwrap(); + } + + #[test] + fn write_behind_publishes_the_same_segments_as_inline_muxing() { + ffmpeg::init().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let inline = temp.path().join("inline"); + let behind = temp.path().join("behind"); + + let inline_events = record_segments(&inline, false); + let behind_events = record_segments(&behind, true); + + // VideoToolbox payload sizes are not reproducible across sessions, so + // the published timeline is compared rather than media bytes. + assert!(inline_events.len() >= 3); + assert_eq!(inline_events, behind_events); + assert_eq!(published_index(&inline), published_index(&behind)); + } + #[test] fn failed_dash_manifest_publication_retains_video_without_claiming_completion() { ffmpeg::init().unwrap(); diff --git a/crates/enc-ffmpeg/src/mux/write_behind.rs b/crates/enc-ffmpeg/src/mux/write_behind.rs new file mode 100644 index 00000000000..d0155f1c48f --- /dev/null +++ b/crates/enc-ffmpeg/src/mux/write_behind.rs @@ -0,0 +1,1005 @@ +//! Write-behind muxing: encoders hand finished packets to a dedicated writer +//! thread that owns the `AVFormatContext`, so a stalled output volume (slow +//! SD card, external or network drive) blocks only that thread instead of +//! the encoder or the async runtime driving it. +//! +//! Packets are written in exactly the order they were produced, so the +//! on-disk bytes are identical to writing inline. Memory is bounded by a byte +//! budget; once it is full the producer blocks until the writer catches up, +//! which is the inline behaviour. +//! +//! Finishing is one bounded operation: the writer drains the queue, then +//! runs the finalization (trailer, final manifest) itself. When storage is +//! still accepting writes after [`FINISH_DRAIN_LIMIT`], the packets still +//! queued are discarded so finalization can start. If finalization has not +//! completed within [`FINISH_LIMIT`], finishing +//! returns [`StorageStalled`]: finalization is cancelled, the files keep the +//! state recovery expects from an interrupted recording, and the packets +//! still queued reach disk once storage recovers, as they would from an +//! encoder blocked in an inline write. Dropping a writer that was never +//! finished discards its queue and touches nothing after its current call. + +use crate::base::PacketSink; +use ffmpeg::{Packet, Rational, format}; +use std::{ + collections::VecDeque, + sync::{ + Arc, Condvar, Mutex, MutexGuard, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +const VIDEO_BUFFER_WINDOW_SECS: u64 = 40; +const MIN_VIDEO_BUDGET_BYTES: usize = 32 * 1024 * 1024; +const MAX_VIDEO_BUDGET_BYTES: usize = 512 * 1024 * 1024; +const VIDEO_BUDGET_MEMORY_DIVISOR: u64 = 32; +pub(crate) const AUDIO_BUDGET_BYTES: usize = 16 * 1024 * 1024; +const STALL_LOG_THRESHOLD: Duration = Duration::from_secs(1); +const WRITE_ERROR_LOG_INTERVAL: u64 = 100; +// Muxers join their encoder thread for up to 5 s before finishing, and an +// unjoined pipeline stop gives up after 10 s; finishing within 4 s keeps the +// whole stop inside that window. +pub(crate) const FINISH_DRAIN_LIMIT: Duration = Duration::from_secs(3); +pub(crate) const FINISH_LIMIT: Duration = Duration::from_secs(4); +const STORAGE_MOVING_WINDOW: Duration = Duration::from_secs(1); +const DROP_JOIN_LIMIT: Duration = Duration::from_millis(250); +const WAIT_POLL: Duration = Duration::from_millis(50); + +pub(crate) fn video_budget_bytes(bits_per_second: u64) -> usize { + video_budget_for(bits_per_second, total_memory_bytes()) +} + +fn video_budget_for(bits_per_second: u64, total_memory: Option) -> usize { + let ceiling = total_memory + .map(|memory| usize::try_from(memory / VIDEO_BUDGET_MEMORY_DIVISOR).unwrap_or(usize::MAX)) + .unwrap_or(MAX_VIDEO_BUDGET_BYTES) + .clamp(MIN_VIDEO_BUDGET_BYTES, MAX_VIDEO_BUDGET_BYTES); + let bytes = (bits_per_second / 8).saturating_mul(VIDEO_BUFFER_WINDOW_SECS); + usize::try_from(bytes) + .unwrap_or(usize::MAX) + .clamp(MIN_VIDEO_BUDGET_BYTES, ceiling) +} + +fn total_memory_bytes() -> Option { + let system = sysinfo::System::new_with_specifics( + sysinfo::RefreshKind::new().with_memory(sysinfo::MemoryRefreshKind::new().with_ram()), + ); + Some(system.total_memory()).filter(|memory| *memory > 0) +} + +pub(crate) trait MuxTarget: Send + 'static { + fn output(&self) -> &format::context::Output; + + fn output_mut(&mut self) -> &mut format::context::Output; + + fn writer_started(&mut self) {} + + fn packet_written(&mut self) {} +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct StorageStalled; + +impl From for std::io::Error { + fn from(_: StorageStalled) -> Self { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "output storage stalled while finishing; recording left for recovery", + ) + } +} + +impl From for ffmpeg::Error { + fn from(_: StorageStalled) -> Self { + ffmpeg::Error::Other { + errno: ffmpeg::error::EIO, + } + } +} + +pub(crate) enum MuxSink { + Direct(M), + Behind(WriteBehind), + Detached, +} + +impl MuxSink { + pub fn enable_write_behind( + &mut self, + label: &'static str, + budget: usize, + ) -> std::io::Result<()> { + if !matches!(self, Self::Direct(_)) { + return Ok(()); + } + let Self::Direct(target) = std::mem::replace(self, Self::Detached) else { + unreachable!("checked above"); + }; + match WriteBehind::spawn(label, target, budget, FinishLimits::DEFAULT) { + Ok(writer) => { + *self = Self::Behind(writer); + Ok(()) + } + Err((target, error)) => { + *self = Self::Direct(target); + Err(error) + } + } + } + + pub fn is_write_behind(&self) -> bool { + matches!(self, Self::Behind(_)) + } + + pub fn with_target(&mut self, f: impl FnOnce(&mut M) -> R) -> R { + match self { + Self::Direct(target) => f(target), + Self::Behind(writer) => f(&mut writer.target()), + Self::Detached => unreachable!("mux sink detached"), + } + } + + pub fn with_target_ref(&self, f: impl FnOnce(&M) -> R) -> R { + match self { + Self::Direct(target) => f(target), + Self::Behind(writer) => f(&writer.target()), + Self::Detached => unreachable!("mux sink detached"), + } + } + + pub fn frame_queued(&mut self) { + if let Self::Direct(target) = self { + target.packet_written(); + } + } + + /// Runs the encoder's end-of-stream `flush` into this sink, then + /// `finalize` with the flush result, which also carries write errors of + /// the end-of-stream packets as an inline flush would. With write-behind + /// both run within [`FINISH_LIMIT`]; `Err` means storage stalled and the + /// output was left unfinalized. + pub fn finish( + &mut self, + flush: impl FnOnce(&mut Self) -> Result<(), ffmpeg::Error>, + finalize: impl FnOnce(&mut M, Result<(), ffmpeg::Error>) -> R + Send + 'static, + ) -> Result { + if let Self::Behind(writer) = self { + writer.begin_finish(); + } + let flush_result = flush(self); + match self { + Self::Direct(target) => Ok(finalize(target, flush_result)), + Self::Behind(writer) => writer.finish(flush_result, finalize), + Self::Detached => unreachable!("mux sink detached"), + } + } + + #[cfg(test)] + pub fn drain(&self) { + if let Self::Behind(writer) = self { + writer.drain(); + } + } + + #[cfg(test)] + pub fn stall_storage_for(&self, duration: Duration) -> std::thread::JoinHandle<()> { + let Self::Behind(writer) = self else { + panic!("write-behind not enabled"); + }; + let shared = writer.shared.clone(); + let (held_tx, held_rx) = mpsc::channel(); + let handle = std::thread::spawn(move || { + let _held = lock(&shared.target); + held_tx.send(()).unwrap(); + std::thread::sleep(duration); + }); + held_rx.recv().unwrap(); + handle + } +} + +impl PacketSink for MuxSink { + fn stream_time_base(&self, stream_index: usize) -> Rational { + match self { + Self::Direct(target) => target.output().stream_time_base(stream_index), + Self::Behind(writer) => writer.time_bases[stream_index], + Self::Detached => unreachable!("mux sink detached"), + } + } + + fn write_packet(&mut self, packet: Packet) -> Result<(), ffmpeg::Error> { + match self { + Self::Direct(target) => packet.write_interleaved(target.output_mut()), + Self::Behind(writer) => writer.write_packet(packet), + Self::Detached => unreachable!("mux sink detached"), + } + } +} + +#[derive(Clone, Copy)] +struct FinishLimits { + drain: Duration, + total: Duration, +} + +impl FinishLimits { + const DEFAULT: Self = Self { + drain: FINISH_DRAIN_LIMIT, + total: FINISH_LIMIT, + }; +} + +type Finalizer = Box) + Send>; + +enum Job { + Packet { packet: Packet, end_of_stream: bool }, + Finalize(Finalizer), +} + +struct Queue { + jobs: VecDeque>, + bytes: usize, + in_flight: bool, + closed: bool, + finishing: bool, + exited: bool, + last_write_completed: Option, + write_errors: u64, + end_of_stream_error: Option, +} + +struct Shared { + queue: Mutex>, + changed: Condvar, + abandoned: AtomicBool, + finalize_cancelled: AtomicBool, + target: Mutex, +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +impl Shared { + fn wait<'a>( + &self, + queue: MutexGuard<'a, Queue>, + timeout: Duration, + ) -> MutexGuard<'a, Queue> { + self.changed + .wait_timeout(queue, timeout) + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .0 + } + + fn discard_packets(&self, queue: &mut Queue) -> (usize, usize) { + let mut packets = 0; + let mut bytes = 0; + queue.jobs.retain(|job| match job { + Job::Packet { packet, .. } => { + packets += 1; + bytes += packet.size(); + false + } + Job::Finalize(_) => true, + }); + queue.bytes -= bytes; + self.changed.notify_all(); + (packets, bytes) + } + + fn abandon(&self, queue: &mut Queue) { + self.abandoned.store(true, Ordering::Release); + self.finalize_cancelled.store(true, Ordering::Release); + queue.jobs.clear(); + queue.bytes = 0; + self.changed.notify_all(); + } +} + +pub(crate) struct WriteBehind { + shared: Arc>, + time_bases: Vec, + budget: usize, + label: &'static str, + limits: FinishLimits, + thread: Option>, +} + +impl WriteBehind { + fn spawn( + label: &'static str, + target: M, + budget: usize, + limits: FinishLimits, + ) -> Result { + let time_bases = target + .output() + .streams() + .map(|stream| stream.time_base()) + .collect(); + let shared = Arc::new(Shared { + queue: Mutex::new(Queue { + jobs: VecDeque::new(), + bytes: 0, + in_flight: false, + closed: false, + finishing: false, + exited: false, + last_write_completed: None, + write_errors: 0, + end_of_stream_error: None, + }), + changed: Condvar::new(), + abandoned: AtomicBool::new(false), + finalize_cancelled: AtomicBool::new(false), + target: Mutex::new(target), + }); + let spawned = std::thread::Builder::new() + .name(format!("mux-writer-{label}")) + .spawn({ + let shared = shared.clone(); + move || run_writer(&shared, label) + }); + let thread = match spawned { + Ok(thread) => thread, + Err(error) => { + let target = Arc::try_unwrap(shared) + .ok() + .expect("writer thread never started") + .target + .into_inner() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + return Err((target, error)); + } + }; + Ok(Self { + shared, + time_bases, + budget, + label, + limits, + thread: Some(thread), + }) + } + + fn target(&self) -> MutexGuard<'_, M> { + lock(&self.shared.target) + } + + fn begin_finish(&mut self) { + lock(&self.shared.queue).finishing = true; + } + + fn write_packet(&mut self, packet: Packet) -> Result<(), ffmpeg::Error> { + let size = packet.size(); + let mut queue = lock(&self.shared.queue); + let mut blocked_since = None; + loop { + if queue.exited { + return Err(ffmpeg::Error::Bug); + } + if queue.finishing || queue.bytes == 0 || queue.bytes + size <= self.budget { + break; + } + if blocked_since.is_none() { + blocked_since = Some(Instant::now()); + tracing::warn!( + writer = self.label, + queued_bytes = queue.bytes, + budget_bytes = self.budget, + "Output write-behind budget exhausted; encoder now waits for storage" + ); + } + queue = self.shared.wait(queue, WAIT_POLL); + } + queue.bytes += size; + let end_of_stream = queue.finishing; + queue.jobs.push_back(Job::Packet { + packet, + end_of_stream, + }); + drop(queue); + self.shared.changed.notify_all(); + + if let Some(since) = blocked_since { + tracing::warn!( + writer = self.label, + waited_ms = since.elapsed().as_millis() as u64, + "Output write-behind budget recovered" + ); + } + + Ok(()) + } + + #[cfg(test)] + fn drain(&self) { + let mut queue = lock(&self.shared.queue); + while (!queue.jobs.is_empty() || queue.in_flight) && !queue.exited { + queue = self.shared.wait(queue, WAIT_POLL); + } + } + + fn finish( + &mut self, + flush_result: Result<(), ffmpeg::Error>, + finalize: impl FnOnce(&mut M, Result<(), ffmpeg::Error>) -> R + Send + 'static, + ) -> Result { + let Some(thread) = self.thread.take() else { + return Err(StorageStalled); + }; + let (result_tx, result_rx) = mpsc::sync_channel(1); + { + let mut queue = lock(&self.shared.queue); + queue + .jobs + .push_back(Job::Finalize(Box::new(move |target, end_of_stream| { + let _ = result_tx.send(finalize(target, flush_result.and(end_of_stream))); + }))); + queue.closed = true; + } + self.shared.changed.notify_all(); + + let started = Instant::now(); + let mut truncated = false; + loop { + match result_rx.recv_timeout(WAIT_POLL) { + Ok(result) => { + let _ = thread.join(); + return Ok(result); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = thread.join(); + tracing::error!(writer = self.label, "Output writer exited before finishing"); + return Err(StorageStalled); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + let storage_moving = lock(&self.shared.queue) + .last_write_completed + .is_some_and(|completed| completed.elapsed() < STORAGE_MOVING_WINDOW); + if !truncated && storage_moving && started.elapsed() >= self.limits.drain { + truncated = true; + let (packets, bytes) = self.shared.discard_packets(&mut lock(&self.shared.queue)); + if packets > 0 { + tracing::warn!( + writer = self.label, + discarded_packets = packets, + discarded_bytes = bytes, + "Output storage too slow to drain the write-behind buffer; finishing without its tail" + ); + } + } + if started.elapsed() >= self.limits.total { + self.shared + .finalize_cancelled + .store(true, Ordering::Release); + let queue = lock(&self.shared.queue); + tracing::error!( + writer = self.label, + queued_bytes = queue.bytes, + "Output storage stalled while finishing; leaving the recording for recovery and writing the buffer once storage recovers" + ); + return Err(StorageStalled); + } + } + } +} + +impl Drop for WriteBehind { + fn drop(&mut self) { + let Some(thread) = self.thread.take() else { + return; + }; + let mut queue = lock(&self.shared.queue); + queue.closed = true; + self.shared.abandon(&mut queue); + let deadline = Instant::now() + DROP_JOIN_LIMIT; + while !queue.exited && Instant::now() < deadline { + queue = self.shared.wait(queue, WAIT_POLL.min(DROP_JOIN_LIMIT)); + } + let exited = queue.exited; + drop(queue); + if exited { + let _ = thread.join(); + } + } +} + +struct WriterExit<'a, M>(&'a Shared); + +impl Drop for WriterExit<'_, M> { + fn drop(&mut self) { + let mut queue = lock(&self.0.queue); + queue.exited = true; + queue.in_flight = false; + drop(queue); + self.0.changed.notify_all(); + } +} + +fn run_writer(shared: &Shared, label: &'static str) { + let _exit = WriterExit(shared); + { + let mut target = lock(&shared.target); + if !shared.abandoned.load(Ordering::Acquire) { + target.writer_started(); + } + } + loop { + let next = { + let mut queue = lock(&shared.queue); + loop { + if shared.abandoned.load(Ordering::Acquire) { + break None; + } + if let Some(next) = queue.jobs.pop_front() { + queue.in_flight = true; + break Some(next); + } + if queue.closed { + break None; + } + queue = shared + .changed + .wait(queue) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + } + }; + let Some(job) = next else { + return; + }; + + let (packet, end_of_stream) = match job { + Job::Packet { + packet, + end_of_stream, + } => (packet, end_of_stream), + Job::Finalize(finalize) => { + let end_of_stream_error = lock(&shared.queue).end_of_stream_error.take(); + let mut target = lock(&shared.target); + if !shared.finalize_cancelled.load(Ordering::Acquire) { + finalize(&mut target, end_of_stream_error.map_or(Ok(()), Err)); + } + drop(target); + lock(&shared.queue).in_flight = false; + shared.changed.notify_all(); + continue; + } + }; + + let size = packet.size(); + let started = Instant::now(); + let result = { + let mut target = lock(&shared.target); + if shared.abandoned.load(Ordering::Acquire) { + None + } else { + let result = packet.write_interleaved(target.output_mut()); + if !shared.abandoned.load(Ordering::Acquire) { + target.packet_written(); + } + Some(result) + } + }; + let elapsed = started.elapsed(); + + let (backlog, error_count) = { + let mut queue = lock(&shared.queue); + queue.bytes = queue.bytes.saturating_sub(size); + queue.in_flight = false; + queue.last_write_completed = Some(Instant::now()); + if let Some(Err(error)) = &result { + queue.write_errors += 1; + if end_of_stream && queue.end_of_stream_error.is_none() { + queue.end_of_stream_error = Some(*error); + } + } + (queue.bytes, queue.write_errors) + }; + shared.changed.notify_all(); + + if let Some(Err(error)) = result + && (error_count == 1 || error_count.is_multiple_of(WRITE_ERROR_LOG_INTERVAL)) + { + tracing::warn!( + writer = label, + %error, + failed_writes = error_count, + "Failed to write output packet" + ); + } + + if elapsed >= STALL_LOG_THRESHOLD { + tracing::warn!( + writer = label, + stalled_ms = elapsed.as_millis() as u64, + backlog_bytes = backlog, + "Output storage write stalled; capture continued into the write-behind buffer" + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct NutTarget { + output: format::context::Output, + write_delay: Duration, + panic_on_write: bool, + } + + impl MuxTarget for NutTarget { + fn output(&self) -> &format::context::Output { + &self.output + } + + fn output_mut(&mut self) -> &mut format::context::Output { + assert!(!self.panic_on_write, "simulated writer failure"); + std::thread::sleep(self.write_delay); + &mut self.output + } + } + + fn nut_target(path: &std::path::Path) -> NutTarget { + ffmpeg::init().unwrap(); + let mut output = format::output_as(path, "nut").unwrap(); + let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::PCM_S16LE).unwrap(); + let mut stream = output.add_stream(codec).unwrap(); + stream.set_time_base(Rational::new(1, 48_000)); + unsafe { + let parameters = (*stream.as_mut_ptr()).codecpar; + (*parameters).codec_type = ffmpeg::ffi::AVMediaType::AVMEDIA_TYPE_AUDIO; + (*parameters).codec_id = ffmpeg::ffi::AVCodecID::AV_CODEC_ID_PCM_S16LE; + (*parameters).sample_rate = 48_000; + ffmpeg::ffi::av_channel_layout_default(&mut (*parameters).ch_layout, 1); + } + output.write_header().unwrap(); + NutTarget { + output, + write_delay: Duration::ZERO, + panic_on_write: false, + } + } + + fn packet(pts: i64, size: usize) -> Packet { + let mut packet = Packet::copy(&vec![0u8; size]); + packet.set_stream(0); + packet.set_pts(Some(pts)); + packet.set_dts(Some(pts)); + packet.set_duration(1024); + packet + } + + fn invalid_packet(pts: i64) -> Packet { + let mut packet = packet(pts, 16); + packet.set_stream(7); + packet + } + + fn behind(target: NutTarget, budget: usize, limits: FinishLimits) -> MuxSink { + MuxSink::Behind( + WriteBehind::spawn("test", target, budget, limits) + .ok() + .unwrap(), + ) + } + + fn shared(sink: &MuxSink) -> Arc> { + match sink { + MuxSink::Behind(writer) => writer.shared.clone(), + _ => panic!("write-behind not enabled"), + } + } + + fn header_only_len(directory: &std::path::Path) -> u64 { + let path = directory.join("header-only.nut"); + drop(nut_target(&path)); + std::fs::metadata(path).unwrap().len() + } + + fn finish_with_trailer(sink: &mut MuxSink) -> Result<(), ffmpeg::Error> { + sink.finish( + |_| Ok(()), + |target, result| { + target.output.write_trailer().unwrap(); + result + }, + ) + .unwrap() + } + + fn written_pts(path: &std::path::Path) -> Vec { + let mut input = format::input(path).unwrap(); + input + .packets() + .filter_map(|(_, packet)| packet.pts()) + .collect() + } + + #[test] + fn producer_runs_ahead_of_a_stalled_writer_and_order_is_preserved() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("out.nut"); + let mut sink = MuxSink::Direct(nut_target(&path)); + sink.enable_write_behind("test", 1 << 20).unwrap(); + assert!(sink.is_write_behind()); + + let shared = shared(&sink); + let stalled = lock(&shared.target); + let started = Instant::now(); + for pts in 0..64 { + sink.write_packet(packet(pts * 1024, 2048)).unwrap(); + } + assert!(started.elapsed() < Duration::from_secs(1)); + drop(stalled); + + finish_with_trailer(&mut sink).unwrap(); + assert_eq!( + written_pts(&path), + (0..64).map(|pts| pts * 1024).collect::>() + ); + } + + #[test] + fn exhausted_budget_falls_back_to_waiting_for_storage() { + let directory = tempfile::tempdir().unwrap(); + let mut sink = MuxSink::Direct(nut_target(&directory.path().join("out.nut"))); + sink.enable_write_behind("test", 8 * 1024).unwrap(); + let shared = shared(&sink); + + let stalled = lock(&shared.target); + for pts in 0..4 { + sink.write_packet(packet(pts * 1024, 2048)).unwrap(); + } + let producer = std::thread::spawn(move || { + let started = Instant::now(); + sink.write_packet(packet(4 * 1024, 2048)).unwrap(); + (sink, started.elapsed()) + }); + std::thread::sleep(Duration::from_millis(300)); + drop(stalled); + let (mut sink, waited) = producer.join().unwrap(); + assert!(waited >= Duration::from_millis(250)); + finish_with_trailer(&mut sink).unwrap(); + } + + #[test] + fn oversized_packet_is_accepted_into_an_empty_queue() { + let directory = tempfile::tempdir().unwrap(); + let mut sink = MuxSink::Direct(nut_target(&directory.path().join("out.nut"))); + sink.enable_write_behind("test", 1024).unwrap(); + sink.write_packet(packet(0, 4096)).unwrap(); + finish_with_trailer(&mut sink).unwrap(); + } + + #[test] + fn earlier_write_errors_never_abort_the_end_of_stream_flush() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("out.nut"); + let mut sink = MuxSink::Direct(nut_target(&path)); + sink.enable_write_behind("test", 1 << 20).unwrap(); + + sink.write_packet(packet(0, 64)).unwrap(); + sink.write_packet(invalid_packet(1024)).unwrap(); + sink.drain(); + sink.write_packet(packet(2048, 64)).unwrap(); + let result = sink + .finish( + |sink| sink.write_packet(packet(3072, 64)), + |target, result| { + target.output.write_trailer().unwrap(); + result + }, + ) + .unwrap(); + + assert_eq!(result, Ok(())); + assert_eq!(written_pts(&path), vec![0, 2048, 3072]); + } + + #[test] + fn end_of_stream_write_errors_are_reported_like_an_inline_flush() { + let directory = tempfile::tempdir().unwrap(); + let mut sink = MuxSink::Direct(nut_target(&directory.path().join("out.nut"))); + sink.enable_write_behind("test", 1 << 20).unwrap(); + sink.write_packet(packet(0, 64)).unwrap(); + let result = sink + .finish( + |sink| sink.write_packet(invalid_packet(1024)), + |_, result| result, + ) + .unwrap(); + assert!(result.is_err()); + } + + #[test] + fn stalled_finish_returns_in_time_and_keeps_the_buffer_for_when_storage_recovers() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("out.nut"); + let packets = 8usize; + let packet_size = 64 * 1024; + let finalized = Arc::new(AtomicBool::new(false)); + { + let limits = FinishLimits { + drain: Duration::from_millis(100), + total: Duration::from_millis(300), + }; + let mut sink = behind(nut_target(&path), 1 << 20, limits); + let shared = shared(&sink); + let stalled = lock(&shared.target); + for pts in 0..packets { + sink.write_packet(packet(pts as i64 * 1024, packet_size)) + .unwrap(); + } + let started = Instant::now(); + let finalized_flag = finalized.clone(); + let result = sink.finish( + |_| Ok(()), + move |_, _| finalized_flag.store(true, Ordering::Release), + ); + assert_eq!(result, Err(StorageStalled)); + assert!(started.elapsed() < Duration::from_secs(1)); + drop(sink); + drop(stalled); + let deadline = Instant::now() + Duration::from_secs(2); + while Arc::strong_count(&shared) > 1 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(Arc::strong_count(&shared), 1); + } + assert!(!finalized.load(Ordering::Acquire)); + assert!( + std::fs::metadata(&path).unwrap().len() + >= header_only_len(directory.path()) + (packets * packet_size) as u64 + ); + } + + #[test] + fn slow_storage_finishes_without_the_tail_within_the_limit() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("out.nut"); + let mut target = nut_target(&path); + target.write_delay = Duration::from_millis(60); + let limits = FinishLimits { + drain: Duration::from_millis(300), + total: Duration::from_secs(2), + }; + let mut sink = behind(target, 1 << 20, limits); + for pts in 0..40 { + sink.write_packet(packet(pts * 1024, 64)).unwrap(); + } + let started = Instant::now(); + let result = sink + .finish( + |_| Ok(()), + |target, result| { + target.write_delay = Duration::ZERO; + target.output.write_trailer().unwrap(); + result + }, + ) + .unwrap(); + assert_eq!(result, Ok(())); + assert!(started.elapsed() < Duration::from_secs(2)); + let pts = written_pts(&path); + assert!(!pts.is_empty() && pts.len() < 40); + assert_eq!( + pts, + (0..pts.len() as i64) + .map(|pts| pts * 1024) + .collect::>() + ); + } + + #[test] + fn slow_finalization_counts_against_the_finish_limit() { + let directory = tempfile::tempdir().unwrap(); + let limits = FinishLimits { + drain: Duration::from_millis(100), + total: Duration::from_millis(300), + }; + let mut sink = behind( + nut_target(&directory.path().join("out.nut")), + 1 << 20, + limits, + ); + sink.write_packet(packet(0, 64)).unwrap(); + let started = Instant::now(); + let result = sink.finish( + |_| Ok(()), + |_, _| std::thread::sleep(Duration::from_secs(1)), + ); + assert_eq!(result, Err(StorageStalled)); + assert!(started.elapsed() < Duration::from_millis(900)); + } + + #[test] + fn dropping_without_finish_stops_the_writer_promptly() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("out.nut"); + let shared; + { + let mut sink = MuxSink::Direct(nut_target(&path)); + sink.enable_write_behind("test", 1 << 20).unwrap(); + shared = self::shared(&sink); + let stalled = lock(&shared.target); + for pts in 0..16 { + sink.write_packet(packet(pts * 1024, 64 * 1024)).unwrap(); + } + let dropping = std::thread::spawn(move || { + let started = Instant::now(); + drop(sink); + started.elapsed() + }); + std::thread::sleep(Duration::from_millis(50)); + let drop_time = dropping.join().unwrap(); + assert!(drop_time < Duration::from_millis(600)); + drop(stalled); + } + let deadline = Instant::now() + Duration::from_secs(2); + while Arc::strong_count(&shared) > 1 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(Arc::strong_count(&shared), 1); + drop(shared); + assert_eq!( + std::fs::metadata(&path).unwrap().len(), + header_only_len(directory.path()) + ); + } + + #[test] + fn idle_writer_drop_joins_the_thread() { + let directory = tempfile::tempdir().unwrap(); + let mut sink = MuxSink::Direct(nut_target(&directory.path().join("out.nut"))); + sink.enable_write_behind("test", 1 << 20).unwrap(); + let shared = shared(&sink); + drop(sink); + assert_eq!(Arc::strong_count(&shared), 1); + } + + #[test] + fn writer_panic_is_reported_on_the_next_enqueue() { + let directory = tempfile::tempdir().unwrap(); + let mut target = nut_target(&directory.path().join("out.nut")); + target.panic_on_write = true; + let mut sink = behind(target, 1 << 20, FinishLimits::DEFAULT); + sink.write_packet(packet(0, 64)).unwrap(); + let shared = shared(&sink); + let deadline = Instant::now() + Duration::from_secs(2); + while !lock(&shared.queue).exited && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(sink.write_packet(packet(1024, 64)), Err(ffmpeg::Error::Bug)); + assert_eq!( + sink.finish(|_| Ok(()), |_, result| result), + Err(StorageStalled) + ); + } + + #[test] + fn video_budget_scales_with_bitrate_and_machine_memory() { + const GIB: u64 = 1024 * 1024 * 1024; + assert_eq!(video_budget_for(0, Some(16 * GIB)), MIN_VIDEO_BUDGET_BYTES); + assert_eq!(video_budget_for(20_000_000, Some(16 * GIB)), 100_000_000); + assert_eq!( + video_budget_for(u64::MAX, Some(16 * GIB)), + MAX_VIDEO_BUDGET_BYTES + ); + assert_eq!(video_budget_for(u64::MAX, Some(8 * GIB)), 256 * 1024 * 1024); + assert_eq!( + video_budget_for(u64::MAX, Some(GIB)), + MIN_VIDEO_BUDGET_BYTES + ); + assert_eq!(video_budget_for(u64::MAX, None), MAX_VIDEO_BUDGET_BYTES); + } +} diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index 2cdc2f6105f..2bc025dcb71 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -11,7 +11,7 @@ use ffmpeg::{ }; use tracing::{debug, error, info, trace, warn}; -use crate::base::EncoderBase; +use crate::base::{EncoderBase, PacketSink}; use crate::video::h264_packet::H264PacketEncoder; #[cfg(target_os = "macos")] use crate::video::videotoolbox_hw::VideoToolboxHwFrames; @@ -879,6 +879,14 @@ impl H264Encoder { .map_err(|e| H264EncoderError::VideoToolboxHwInput(e.to_string())) } + /// Whether the selected codec is a hardware encoder, which can fail at + /// runtime after opening (a busy or reset media engine). + pub fn is_hardware(&self) -> bool { + self.encoder + .codec() + .is_some_and(|codec| is_hardware_h264(codec.name())) + } + pub fn conversion_requirements(&self) -> ConversionRequirements { let needs_conversion = self.input_format != self.output_format || self.input_width != self.output_width @@ -896,9 +904,18 @@ impl H264Encoder { pub fn queue_frame( &mut self, - mut frame: frame::Video, + frame: frame::Video, timestamp: Duration, output: &mut format::context::Output, + ) -> Result<(), QueueFrameError> { + self.queue_frame_to(frame, timestamp, output) + } + + pub fn queue_frame_to( + &mut self, + mut frame: frame::Video, + timestamp: Duration, + output: &mut S, ) -> Result<(), QueueFrameError> { self.base .update_pts(&mut frame, timestamp, &mut self.encoder); @@ -906,8 +923,7 @@ impl H264Encoder { let frame_to_send = if let Some(converter) = &mut self.converter { let pts = frame.pts(); let converted = self.converted_frame_pool.as_mut().unwrap(); - converter - .run(&frame, converted) + super::packed_yuv::convert_or_scale(&frame, converted, converter) .map_err(QueueFrameError::Converter)?; converted.set_pts(pts); converted as &frame::Video @@ -936,8 +952,7 @@ impl H264Encoder { let converted = converted_frame.get_or_insert_with(|| { frame::Video::new(self.output_format, self.output_width, self.output_height) }); - converter - .run(frame, converted) + super::packed_yuv::convert_or_scale(frame, converted, converter) .map_err(QueueFrameError::Converter)?; converted.set_pts(pts); converted as &frame::Video @@ -982,6 +997,28 @@ impl H264Encoder { self.base.process_eof(output, &mut self.encoder) } + pub fn flush_to( + &mut self, + output: &mut S, + ) -> Result<(), ffmpeg::Error> { + self.base.process_eof(output, &mut self.encoder) + } + + /// Configured target bitrate in bits per second, estimated from the + /// output geometry when the encoder runs without a bitrate target. + pub fn bit_rate(&self) -> u64 { + let configured = unsafe { (*self.encoder.as_ptr()).bit_rate }; + if configured > 0 { + return configured as u64; + } + get_bitrate( + self.output_width, + self.output_height, + 30.0, + H264EncoderBuilder::QUALITY_BPP, + ) as u64 + } + pub fn set_packet_stats(&mut self, stats: Arc) { self.base.set_packet_stats(stats); } diff --git a/crates/enc-ffmpeg/src/video/h264_packet.rs b/crates/enc-ffmpeg/src/video/h264_packet.rs index fb7596f7c21..e344a09e500 100644 --- a/crates/enc-ffmpeg/src/video/h264_packet.rs +++ b/crates/enc-ffmpeg/src/video/h264_packet.rs @@ -140,8 +140,7 @@ impl H264PacketEncoder { let converted = self.converted_frame_pool.as_mut().expect( "converted_frame_pool present whenever converter is Some (invariant preserved by from_opened)", ); - converter - .run(&frame, converted) + super::packed_yuv::convert_or_scale(&frame, converted, converter) .map_err(EncodePacketError::Converter)?; converted.set_pts(pts); converted as &frame::Video @@ -173,8 +172,7 @@ impl H264PacketEncoder { let converted = converted_frame.get_or_insert_with(|| { frame::Video::new(self.output_format, self.output_width, self.output_height) }); - converter - .run(frame, converted) + super::packed_yuv::convert_or_scale(frame, converted, converter) .map_err(EncodePacketError::Converter)?; converted.set_pts(pts); converted as &frame::Video diff --git a/crates/enc-ffmpeg/src/video/mod.rs b/crates/enc-ffmpeg/src/video/mod.rs index 104261fc075..040480d0f28 100644 --- a/crates/enc-ffmpeg/src/video/mod.rs +++ b/crates/enc-ffmpeg/src/video/mod.rs @@ -2,6 +2,7 @@ pub mod colour; pub mod h264; pub mod h264_packet; pub mod hevc; +mod packed_yuv; pub mod prores; #[cfg(target_os = "macos")] pub mod videotoolbox_hw; diff --git a/crates/enc-ffmpeg/src/video/packed_yuv.rs b/crates/enc-ffmpeg/src/video/packed_yuv.rs new file mode 100644 index 00000000000..2bc80f474a9 --- /dev/null +++ b/crates/enc-ffmpeg/src/video/packed_yuv.rs @@ -0,0 +1,906 @@ +use ffmpeg::format::Pixel; +use ffmpeg::frame; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Packed422 { + Uyvy, + Yuyv, +} + +#[cfg(target_arch = "x86_64")] +struct Job { + src: *const u8, + src_stride: usize, + width: usize, + height: usize, + kind: Packed422, + y: *mut u8, + y_stride: usize, + u: *mut u8, + u_stride: usize, + v: *mut u8, + v_stride: usize, + uv: *mut u8, + uv_stride: usize, + nv12: bool, +} + +#[cfg(target_arch = "x86_64")] +fn positive_linesize(frame: &frame::Video, plane: usize) -> Option { + if plane >= frame.planes() { + return None; + } + let raw = unsafe { (*frame.as_ptr()).linesize[plane] }; + if raw <= 0 { None } else { Some(raw as usize) } +} + +#[cfg(target_arch = "x86_64")] +fn covers(stride: usize, row_bytes: usize, height: usize, len: usize) -> bool { + if row_bytes == 0 || height == 0 { + return true; + } + if stride < row_bytes { + return false; + } + stride + .checked_mul(height - 1) + .and_then(|last| last.checked_add(row_bytes)) + .is_some_and(|need| need <= len) +} + +/// Same-size UYVY422 or YUYV422 into `dst` when both dimensions are even, +/// AVX2 is available at runtime, and `dst` is YUV420P or NV12. Any other +/// input returns false so the caller keeps swscale. +pub(crate) fn try_convert(src: &frame::Video, dst: &mut frame::Video) -> bool { + if src.width() != dst.width() || src.height() != dst.height() { + return false; + } + let kind = match src.format() { + Pixel::UYVY422 => Packed422::Uyvy, + Pixel::YUYV422 => Packed422::Yuyv, + _ => return false, + }; + let nv12 = match dst.format() { + Pixel::YUV420P => false, + Pixel::NV12 => true, + _ => return false, + }; + let width = src.width() as usize; + let height = src.height() as usize; + if !width.is_multiple_of(2) || !height.is_multiple_of(2) { + return false; + } + if width == 0 || height == 0 { + return true; + } + #[cfg(not(target_arch = "x86_64"))] + { + let _ = (kind, nv12); + false + } + #[cfg(target_arch = "x86_64")] + { + if !std::arch::is_x86_feature_detected!("avx2") { + return false; + } + let Some(src_stride) = positive_linesize(src, 0) else { + return false; + }; + let Some(y_stride) = positive_linesize(dst, 0) else { + return false; + }; + if !covers(src_stride, width * 2, height, src.data(0).len()) + || !covers(y_stride, width, height, dst.data(0).len()) + { + return false; + } + let chroma_w = width / 2; + let chroma_h = height / 2; + let (u_stride, v_stride, uv_stride) = if nv12 { + let Some(uv_stride) = positive_linesize(dst, 1) else { + return false; + }; + if !covers(uv_stride, chroma_w * 2, chroma_h, dst.data(1).len()) { + return false; + } + (0, 0, uv_stride) + } else { + let Some(u_stride) = positive_linesize(dst, 1) else { + return false; + }; + let Some(v_stride) = positive_linesize(dst, 2) else { + return false; + }; + if !covers(u_stride, chroma_w, chroma_h, dst.data(1).len()) + || !covers(v_stride, chroma_w, chroma_h, dst.data(2).len()) + { + return false; + } + (u_stride, v_stride, 0) + }; + + let src_ptr = src.data(0).as_ptr(); + let y_ptr = dst.data_mut(0).as_mut_ptr(); + let (u_ptr, v_ptr, uv_ptr) = if nv12 { + ( + std::ptr::null_mut(), + std::ptr::null_mut(), + dst.data_mut(1).as_mut_ptr(), + ) + } else { + let u_ptr = dst.data_mut(1).as_mut_ptr(); + let v_ptr = dst.data_mut(2).as_mut_ptr(); + (u_ptr, v_ptr, std::ptr::null_mut()) + }; + + let job = Job { + src: src_ptr, + src_stride, + width, + height, + kind, + y: y_ptr, + y_stride, + u: u_ptr, + u_stride, + v: v_ptr, + v_stride, + uv: uv_ptr, + uv_stride, + nv12, + }; + // SAFETY: AVX2 was detected, and `covers` checked every active plane. + unsafe { avx2::execute(&job) }; + true + } +} + +pub(crate) fn convert_or_scale( + src: &frame::Video, + dst: &mut frame::Video, + scaler: &mut ffmpeg::software::scaling::Context, +) -> Result<(), ffmpeg::Error> { + if try_convert(src, dst) { + Ok(()) + } else { + scaler.run(src, dst) + } +} + +#[cfg(target_arch = "x86_64")] +mod avx2 { + use std::arch::x86_64::*; + + use super::{Job, Packed422}; + + #[target_feature(enable = "avx2")] + pub(super) unsafe fn execute(job: &Job) { + unsafe { + let masks = match job.kind { + Packed422::Uyvy => Masks { + y: uyvy_y_mask(), + u: uyvy_u_mask(), + v: uyvy_v_mask(), + }, + Packed422::Yuyv => Masks { + y: yuyv_y_mask(), + u: yuyv_u_mask(), + v: yuyv_v_mask(), + }, + }; + if job.nv12 { + convert_frame::(job, &masks); + } else { + convert_frame::(job, &masks); + } + } + } + + struct Masks { + y: __m256i, + u: __m256i, + v: __m256i, + } + + #[target_feature(enable = "avx2")] + unsafe fn convert_frame(job: &Job, masks: &Masks) { + unsafe { + let mut top = 0; + while top < job.height { + let row = Row16 { + src0: job.src.add(top * job.src_stride), + src1: job.src.add((top + 1) * job.src_stride), + y0: job.y.add(top * job.y_stride), + y1: job.y.add((top + 1) * job.y_stride), + top, + }; + let mut x = 0; + while x + 32 <= job.width { + convert16::(job, masks, &row, x); + convert16::(job, masks, &row, x + 16); + x += 32; + } + while x + 16 <= job.width { + convert16::(job, masks, &row, x); + x += 16; + } + if x < job.width { + scalar_tail::(job, top, x); + } + top += 2; + } + } + } + + struct Row16 { + src0: *const u8, + src1: *const u8, + y0: *mut u8, + y1: *mut u8, + top: usize, + } + + #[target_feature(enable = "avx2")] + unsafe fn convert16(job: &Job, masks: &Masks, row: &Row16, x: usize) { + unsafe { + let packed0 = _mm256_loadu_si256(row.src0.add(x * 2).cast()); + store_luma(packed0, masks.y, row.y0.add(x)); + let u0 = pack_chroma8(packed0, masks.u); + let v0 = pack_chroma8(packed0, masks.v); + let packed1 = _mm256_loadu_si256(row.src1.add(x * 2).cast()); + store_luma(packed1, masks.y, row.y1.add(x)); + let u1 = pack_chroma8(packed1, masks.u); + let v1 = pack_chroma8(packed1, masks.v); + let u = _mm_avg_epu8(u0, u1); + let v = _mm_avg_epu8(v0, v1); + store_chroma8::(job, row.top / 2, x / 2, u, v); + } + } + + #[target_feature(enable = "avx2")] + unsafe fn store_luma(packed: __m256i, mask: __m256i, dst: *mut u8) { + unsafe { + let shuffled = _mm256_shuffle_epi8(packed, mask); + let lo = _mm256_castsi256_si128(shuffled); + let hi = _mm256_extracti128_si256(shuffled, 1); + let y = _mm_unpacklo_epi64(lo, hi); + _mm_storeu_si128(dst.cast(), y); + } + } + + #[target_feature(enable = "avx2")] + unsafe fn pack_chroma8(packed: __m256i, mask: __m256i) -> __m128i { + let shuffled = _mm256_shuffle_epi8(packed, mask); + let lo = _mm256_castsi256_si128(shuffled); + let hi = _mm256_extracti128_si256(shuffled, 1); + _mm_unpacklo_epi32(lo, hi) + } + + #[target_feature(enable = "avx2")] + unsafe fn store_chroma8( + job: &Job, + row: usize, + x: usize, + u: __m128i, + v: __m128i, + ) { + unsafe { + if NV12 { + let interleaved = _mm_unpacklo_epi8(u, v); + _mm_storeu_si128(job.uv.add(row * job.uv_stride + x * 2).cast(), interleaved); + } else { + _mm_storel_epi64(job.u.add(row * job.u_stride + x).cast(), u); + _mm_storel_epi64(job.v.add(row * job.v_stride + x).cast(), v); + } + } + } + + #[target_feature(enable = "avx2")] + unsafe fn uyvy_y_mask() -> __m256i { + _mm256_setr_epi8( + 1, 3, 5, 7, 9, 11, 13, 15, -1, -1, -1, -1, -1, -1, -1, -1, 1, 3, 5, 7, 9, 11, 13, 15, + -1, -1, -1, -1, -1, -1, -1, -1, + ) + } + + #[target_feature(enable = "avx2")] + unsafe fn uyvy_u_mask() -> __m256i { + _mm256_setr_epi8( + 0, 4, 8, 12, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 4, 8, 12, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, + ) + } + + #[target_feature(enable = "avx2")] + unsafe fn uyvy_v_mask() -> __m256i { + _mm256_setr_epi8( + 2, 6, 10, 14, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 2, 6, 10, 14, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, + ) + } + + #[target_feature(enable = "avx2")] + unsafe fn yuyv_y_mask() -> __m256i { + _mm256_setr_epi8( + 0, 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1, 0, 2, 4, 6, 8, 10, 12, 14, + -1, -1, -1, -1, -1, -1, -1, -1, + ) + } + + #[target_feature(enable = "avx2")] + unsafe fn yuyv_u_mask() -> __m256i { + _mm256_setr_epi8( + 1, 5, 9, 13, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1, 5, 9, 13, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, + ) + } + + #[target_feature(enable = "avx2")] + unsafe fn yuyv_v_mask() -> __m256i { + _mm256_setr_epi8( + 3, 7, 11, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 3, 7, 11, 15, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, + ) + } + + unsafe fn scalar_tail(job: &Job, top: usize, x_start: usize) { + let mut x = x_start; + while x < job.width { + unsafe { + let (y0, y1, u0, v0) = load_macro(job, top, x); + let (y2, y3, u1, v1) = load_macro(job, top + 1, x); + *job.y.add(top * job.y_stride + x) = y0; + *job.y.add(top * job.y_stride + x + 1) = y1; + *job.y.add((top + 1) * job.y_stride + x) = y2; + *job.y.add((top + 1) * job.y_stride + x + 1) = y3; + let cx = x / 2; + let cy = top / 2; + let u = avg(u0, u1); + let v = avg(v0, v1); + if NV12 { + let dst = job.uv.add(cy * job.uv_stride + cx * 2); + *dst = u; + *dst.add(1) = v; + } else { + *job.u.add(cy * job.u_stride + cx) = u; + *job.v.add(cy * job.v_stride + cx) = v; + } + } + x += 2; + } + } + + unsafe fn load_macro(job: &Job, row: usize, x: usize) -> (u8, u8, u8, u8) { + unsafe { + let pixel = job.src.add(row * job.src_stride + x * 2); + let b0 = *pixel; + let b1 = *pixel.add(1); + let b2 = *pixel.add(2); + let b3 = *pixel.add(3); + match job.kind { + Packed422::Uyvy => (b1, b3, b0, b2), + Packed422::Yuyv => (b0, b2, b1, b3), + } + } + } + + fn avg(a: u8, b: u8) -> u8 { + ((u16::from(a) + u16::from(b) + 1) >> 1) as u8 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ffmpeg::software::scaling::{self, flag::Flags}; + + fn avx2_ready() -> bool { + #[cfg(target_arch = "x86_64")] + { + std::arch::is_x86_feature_detected!("avx2") + } + #[cfg(not(target_arch = "x86_64"))] + { + false + } + } + + fn fast_scaler(src: Pixel, dst: Pixel, width: u32, height: u32) -> scaling::Context { + scaling::Context::get(src, width, height, dst, width, height, Flags::FAST_BILINEAR).unwrap() + } + + fn zero_frame(frame: &mut frame::Video) { + for plane in 0..frame.planes() { + frame.data_mut(plane).fill(0); + } + } + + fn fill_packed(frame: &mut frame::Video) { + let uyvy = frame.format() == Pixel::UYVY422; + let width = frame.width() as usize; + let height = frame.height() as usize; + let stride = frame.stride(0); + let data = frame.data_mut(0); + for y in 0..height { + let mut x = 0; + while x + 1 < width { + let y0 = ((x * 17 + y * 3) % 256) as u8; + let y1 = ((x * 13 + y * 11 + 40) % 256) as u8; + let u = ((x * 5 + y * 7 + 16) % 256) as u8; + let v = ((x * 9 + y * 2 + 80) % 256) as u8; + let off = y * stride + x * 2; + if uyvy { + data[off..off + 4].copy_from_slice(&[u, y0, v, y1]); + } else { + data[off..off + 4].copy_from_slice(&[y0, u, y1, v]); + } + x += 2; + } + } + } + + fn reference(src: &frame::Video) -> (Vec, Vec, Vec) { + let width = src.width() as usize; + let height = src.height() as usize; + let stride = src.stride(0); + let data = src.data(0); + let uyvy = src.format() == Pixel::UYVY422; + let cw = width / 2; + let ch = height / 2; + let mut y_plane = vec![0u8; width * height]; + let mut u_plane = vec![0u8; cw * ch]; + let mut v_plane = vec![0u8; cw * ch]; + let mut row = 0; + while row < height { + let mut x = 0; + while x < width { + let (y0, y1, u0, v0) = macro_at(data, stride, row, x, uyvy); + let (y2, y3, u1, v1) = macro_at(data, stride, row + 1, x, uyvy); + y_plane[row * width + x] = y0; + y_plane[row * width + x + 1] = y1; + y_plane[(row + 1) * width + x] = y2; + y_plane[(row + 1) * width + x + 1] = y3; + let cx = x / 2; + let cy = row / 2; + u_plane[cy * cw + cx] = ((u16::from(u0) + u16::from(u1) + 1) >> 1) as u8; + v_plane[cy * cw + cx] = ((u16::from(v0) + u16::from(v1) + 1) >> 1) as u8; + x += 2; + } + row += 2; + } + (y_plane, u_plane, v_plane) + } + + fn macro_at(data: &[u8], stride: usize, row: usize, x: usize, uyvy: bool) -> (u8, u8, u8, u8) { + let off = row * stride + x * 2; + let b0 = data[off]; + let b1 = data[off + 1]; + let b2 = data[off + 2]; + let b3 = data[off + 3]; + if uyvy { + (b1, b3, b0, b2) + } else { + (b0, b2, b1, b3) + } + } + + fn assert_matches_reference(actual: &frame::Video, y: &[u8], u: &[u8], v: &[u8]) { + let width = actual.width() as usize; + let height = actual.height() as usize; + let cw = width / 2; + let ch = height / 2; + for row in 0..height { + let got = &actual.data(0)[row * actual.stride(0)..][..width]; + assert_eq!(got, &y[row * width..][..width], "y row {row}"); + } + if actual.format() == Pixel::NV12 { + for row in 0..ch { + let got = &actual.data(1)[row * actual.stride(1)..][..cw * 2]; + for cx in 0..cw { + assert_eq!(got[cx * 2], u[row * cw + cx], "nv12 u {cx},{row}"); + assert_eq!(got[cx * 2 + 1], v[row * cw + cx], "nv12 v {cx},{row}"); + } + } + } else { + for row in 0..ch { + let got_u = &actual.data(1)[row * actual.stride(1)..][..cw]; + let got_v = &actual.data(2)[row * actual.stride(2)..][..cw]; + assert_eq!(got_u, &u[row * cw..][..cw], "u row {row}"); + assert_eq!(got_v, &v[row * cw..][..cw], "v row {row}"); + } + } + } + + struct PlaneStats { + max_diff: u8, + mean: f64, + col: usize, + row: usize, + } + + fn plane_delta( + actual: &frame::Video, + expected: &frame::Video, + plane: usize, + width: usize, + height: usize, + ) -> PlaneStats { + let mut max_diff = 0u8; + let mut sum = 0u64; + let mut count = 0u64; + let mut col = 0; + let mut row_at = 0; + for row in 0..height { + let actual_row = &actual.data(plane)[row * actual.stride(plane)..]; + let expected_row = &expected.data(plane)[row * expected.stride(plane)..]; + for x in 0..width { + let diff = actual_row[x].abs_diff(expected_row[x]); + sum += u64::from(diff); + count += 1; + if diff > max_diff { + max_diff = diff; + col = x; + row_at = row; + } + } + } + PlaneStats { + max_diff, + mean: sum as f64 / count as f64, + col, + row: row_at, + } + } + + fn assert_within_swscale(actual: &frame::Video, expected: &frame::Video) { + let width = actual.width() as usize; + let height = actual.height() as usize; + let y_stats = plane_delta(actual, expected, 0, width, height); + let y_limit = if actual.format() == Pixel::NV12 { 4 } else { 2 }; + assert!( + y_stats.max_diff <= y_limit && y_stats.mean < 1.0, + "{width}x{height} luma max {} mean {:.3} at {},{}", + y_stats.max_diff, + y_stats.mean, + y_stats.col, + y_stats.row + ); + let cw = width / 2; + let ch = height / 2; + if actual.format() == Pixel::NV12 { + let uv_stats = plane_delta(actual, expected, 1, cw * 2, ch); + assert!( + uv_stats.max_diff <= 3 && uv_stats.mean < 1.5, + "{width}x{height} nv12 max {} mean {:.3} at {},{}", + uv_stats.max_diff, + uv_stats.mean, + uv_stats.col, + uv_stats.row + ); + } else { + let u_stats = plane_delta(actual, expected, 1, cw, ch); + let v_stats = plane_delta(actual, expected, 2, cw, ch); + assert!( + u_stats.max_diff <= 3 + && v_stats.max_diff <= 3 + && u_stats.mean < 1.5 + && v_stats.mean < 1.5, + "{width}x{height} chroma u max {} mean {:.3} at {},{} v max {} mean {:.3} at {},{}", + u_stats.max_diff, + u_stats.mean, + u_stats.col, + u_stats.row, + v_stats.max_diff, + v_stats.mean, + v_stats.col, + v_stats.row + ); + } + } + + fn assert_active_eq(actual: &frame::Video, expected: &frame::Video) { + for plane in 0..expected.planes() { + let row_bytes = if expected.format() == Pixel::NV12 && plane == 1 { + expected.width() as usize + } else { + expected.plane_width(plane) as usize + }; + for row in 0..expected.plane_height(plane) as usize { + let got = &actual.data(plane)[row * actual.stride(plane)..][..row_bytes]; + let want = &expected.data(plane)[row * expected.stride(plane)..][..row_bytes]; + assert_eq!(got, want, "plane {plane} row {row}"); + } + } + } + + fn check_even(format: Pixel, width: u32, height: u32, dst_format: Pixel) { + let mut src = frame::Video::new(format, width, height); + fill_packed(&mut src); + let (y, u, v) = reference(&src); + let mut kernel = frame::Video::new(dst_format, width, height); + let mut sws = frame::Video::new(dst_format, width, height); + let mut scaler = fast_scaler(format, dst_format, width, height); + scaler.run(&src, &mut sws).unwrap(); + if avx2_ready() { + assert!( + try_convert(&src, &mut kernel), + "{format:?} {width}x{height} -> {dst_format:?}" + ); + assert_matches_reference(&kernel, &y, &u, &v); + assert_within_swscale(&kernel, &sws); + let mut via = frame::Video::new(dst_format, width, height); + let mut via_scaler = fast_scaler(format, dst_format, width, height); + convert_or_scale(&src, &mut via, &mut via_scaler).unwrap(); + assert_active_eq(&via, &kernel); + } else { + assert!(!try_convert(&src, &mut kernel)); + let mut via = frame::Video::new(dst_format, width, height); + let mut via_scaler = fast_scaler(format, dst_format, width, height); + convert_or_scale(&src, &mut via, &mut via_scaler).unwrap(); + assert_active_eq(&via, &sws); + } + } + + #[test] + fn even_uyvy_and_yuyv_match_reference_and_swscale() { + ffmpeg::init().unwrap(); + let sizes = [ + (2u32, 2u32), + (16, 2), + (18, 4), + (32, 8), + (48, 6), + (64, 48), + (66, 40), + (320, 180), + ]; + for format in [Pixel::UYVY422, Pixel::YUYV422] { + for (width, height) in sizes { + check_even(format, width, height, Pixel::YUV420P); + } + } + } + + #[test] + fn even_nv12_matches_where_the_encoder_outputs_nv12() { + ffmpeg::init().unwrap(); + for format in [Pixel::UYVY422, Pixel::YUYV422] { + for (width, height) in [(16u32, 2u32), (18, 4), (64, 48), (66, 40)] { + check_even(format, width, height, Pixel::NV12); + } + } + } + + #[test] + fn odd_sizes_fall_back_to_swscale() { + ffmpeg::init().unwrap(); + let mut compared = 0u32; + for format in [Pixel::UYVY422, Pixel::YUYV422] { + for (width, height) in [ + (2u32, 1u32), + (64, 63), + (32, 15), + (1, 2), + (65, 64), + (31, 17), + (65, 63), + ] { + let mut src = frame::Video::new(format, width, height); + fill_packed(&mut src); + let mut probe = frame::Video::new(Pixel::YUV420P, width, height); + assert!( + !try_convert(&src, &mut probe), + "{format:?} {width}x{height} should stay on swscale" + ); + let Ok(mut via_scaler) = scaling::Context::get( + format, + width, + height, + Pixel::YUV420P, + width, + height, + Flags::FAST_BILINEAR, + ) else { + continue; + }; + let Ok(mut direct_scaler) = scaling::Context::get( + format, + width, + height, + Pixel::YUV420P, + width, + height, + Flags::FAST_BILINEAR, + ) else { + continue; + }; + let mut via = frame::Video::new(Pixel::YUV420P, width, height); + let mut direct = frame::Video::new(Pixel::YUV420P, width, height); + // av_frame_get_buffer does not clear memory, and swscale can + // leave the odd-height chroma tail untouched. + zero_frame(&mut via); + zero_frame(&mut direct); + convert_or_scale(&src, &mut via, &mut via_scaler).unwrap(); + direct_scaler.run(&src, &mut direct).unwrap(); + assert_active_eq(&via, &direct); + compared += 1; + } + } + assert!(compared > 0, "swscale rejected every odd size"); + } + + #[test] + fn scaling_and_other_formats_stay_on_swscale() { + ffmpeg::init().unwrap(); + let src = frame::Video::new(Pixel::UYVY422, 64, 48); + let mut scaled = frame::Video::new(Pixel::YUV420P, 32, 24); + assert!(!try_convert(&src, &mut scaled)); + let mut yuv422 = frame::Video::new(Pixel::YUV422P, 64, 48); + assert!(!try_convert(&src, &mut yuv422)); + for format in [Pixel::BGRA, Pixel::YUV422P, Pixel::NV12, Pixel::RGB24] { + let other = frame::Video::new(format, 32, 16); + let mut yuv = frame::Video::new(Pixel::YUV420P, 32, 16); + assert!(!try_convert(&other, &mut yuv), "{format:?}"); + } + + let mut packed = frame::Video::new(Pixel::YUYV422, 64, 48); + fill_packed(&mut packed); + let mut scaler = scaling::Context::get( + Pixel::YUYV422, + 64, + 48, + Pixel::YUV420P, + 32, + 24, + Flags::BICUBIC, + ) + .unwrap(); + let mut direct_scaler = scaling::Context::get( + Pixel::YUYV422, + 64, + 48, + Pixel::YUV420P, + 32, + 24, + Flags::BICUBIC, + ) + .unwrap(); + let mut via = frame::Video::new(Pixel::YUV420P, 32, 24); + let mut direct = frame::Video::new(Pixel::YUV420P, 32, 24); + convert_or_scale(&packed, &mut via, &mut scaler).unwrap(); + direct_scaler.run(&packed, &mut direct).unwrap(); + assert_active_eq(&via, &direct); + } + + #[test] + fn libx264_queue_accepts_packed_yuv() { + ffmpeg::init().unwrap(); + queue_one(Pixel::UYVY422); + encode_one(Pixel::YUYV422); + } + + fn queue_one(format: Pixel) { + if ffmpeg::codec::encoder::find_by_name("libx264").is_none() { + return; + } + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("packed.mp4"); + let mut output = ffmpeg::format::output_as(&path, "mp4").unwrap(); + let info = cap_media_info::VideoInfo::from_raw_ffmpeg(format, 64, 48, 30); + let mut encoder = super::super::h264::H264Encoder::builder(info) + .with_preset(super::super::h264::H264Preset::Ultrafast) + .with_crf(18) + .with_encoder_priority_override(&["libx264"]) + .build(&mut output) + .unwrap(); + let requirements = encoder.conversion_requirements(); + assert_eq!(requirements.output_format, Pixel::YUV420P); + assert!(requirements.needs_conversion); + let mut frame = frame::Video::new(format, 64, 48); + fill_packed(&mut frame); + let mut sink = crate::PacketQueue::new(ffmpeg::Rational(1, 90_000)); + encoder + .queue_frame_to(frame, std::time::Duration::ZERO, &mut sink) + .unwrap(); + encoder.flush_to(&mut sink).unwrap(); + assert!(!sink.take().is_empty()); + } + + fn encode_one(format: Pixel) { + if ffmpeg::codec::encoder::find_by_name("libx264").is_none() { + return; + } + let info = cap_media_info::VideoInfo::from_raw_ffmpeg(format, 64, 48, 30); + let mut encoder = super::super::h264::H264Encoder::builder(info) + .with_preset(super::super::h264::H264Preset::Ultrafast) + .with_crf(18) + .with_encoder_priority_override(&["libx264"]) + .build_standalone() + .unwrap(); + assert_eq!(encoder.output_format(), Pixel::YUV420P); + assert!(encoder.needs_conversion()); + let mut frame = frame::Video::new(format, 64, 48); + fill_packed(&mut frame); + let mut packets = Vec::new(); + encoder + .encode_frame(frame, std::time::Duration::ZERO, |packet| { + packets.push(packet.data); + Ok(()) + }) + .unwrap(); + encoder + .flush(|packet| { + packets.push(packet.data); + Ok(()) + }) + .unwrap(); + assert!(!packets.is_empty()); + } + + #[test] + #[ignore = "release timing bench, run with --ignored --nocapture"] + fn bench_packed_yuv_conversion() { + ffmpeg::init().unwrap(); + let model = std::fs::read_to_string("/proc/cpuinfo") + .ok() + .and_then(|text| { + text.lines() + .find(|line| line.starts_with("model name")) + .and_then(|line| line.split(':').nth(1)) + .map(|value| value.trim().to_string()) + }) + .unwrap_or_else(|| "unknown".into()); + println!("cpu: {model}"); + println!("avx2: {}", avx2_ready()); + println!("warmup 5 excluded; median is the middle of 31 sorted samples"); + for format in [Pixel::UYVY422, Pixel::YUYV422] { + for (width, height) in [(1280u32, 720u32), (1920, 1080), (3840, 2160)] { + bench_conversion(format, width, height); + } + } + } + + fn bench_conversion(format: Pixel, width: u32, height: u32) { + let mut src = frame::Video::new(format, width, height); + fill_packed(&mut src); + let mut kernel_dst = frame::Video::new(Pixel::YUV420P, width, height); + let mut sws_dst = frame::Video::new(Pixel::YUV420P, width, height); + let mut kernel_scaler = fast_scaler(format, Pixel::YUV420P, width, height); + let mut sws_scaler = fast_scaler(format, Pixel::YUV420P, width, height); + let sws = time_median(5, 31, || { + sws_scaler.run(&src, &mut sws_dst).unwrap(); + let _ = std::hint::black_box(sws_dst.data(0)); + let _ = std::hint::black_box(sws_dst.data(1)); + let _ = std::hint::black_box(sws_dst.data(2)); + }); + let kernel = time_median(5, 31, || { + convert_or_scale(&src, &mut kernel_dst, &mut kernel_scaler).unwrap(); + let _ = std::hint::black_box(kernel_dst.data(0)); + let _ = std::hint::black_box(kernel_dst.data(1)); + let _ = std::hint::black_box(kernel_dst.data(2)); + }); + println!( + "{format:?} {width}x{height} swscale_fast_bilinear median {:.3} ms n=31", + sws / 1_000_000.0 + ); + println!( + "{format:?} {width}x{height} convert_or_scale median {:.3} ms n=31 speedup {:.2}x", + kernel / 1_000_000.0, + sws / kernel + ); + } + + fn time_median(warmup: usize, runs: usize, mut body: impl FnMut()) -> f64 { + for _ in 0..warmup { + body(); + } + let mut samples = Vec::with_capacity(runs); + for _ in 0..runs { + let started = std::time::Instant::now(); + body(); + samples.push(started.elapsed().as_nanos()); + } + samples.sort_unstable(); + samples[runs / 2] as f64 + } +} diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index b1cae02e4ec..9a214783fa2 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -3,6 +3,10 @@ name = "cap-export" version = "0.1.0" edition = "2024" +[features] +# Encoder failure injection for the cap-verify suite. Never enabled by the app. +test-hooks = [] + [dependencies] cap-utils = { path = "../utils" } cap-project = { path = "../project" } diff --git a/crates/export/src/estimates.rs b/crates/export/src/estimates.rs index 99bd390dd54..3d245e93164 100644 --- a/crates/export/src/estimates.rs +++ b/crates/export/src/estimates.rs @@ -289,6 +289,8 @@ pub async fn estimate_export( audio_cancellation: None, sample_windows: Some(windows.clone()), sample_timing: Some(timing.clone()), + #[cfg(feature = "test-hooks")] + video_encoder_failure_at: None, }; let progress_editor = editor.clone(); let progress_cancel = cancel.clone(); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 86b5514ed3a..805ace27777 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -120,6 +120,7 @@ impl ExporterBuilder { ) -> Result { type Error = ExporterBuildError; + cap_editor::release_playback_audio_caches(); let mut project_config = self.load_project_config()?; let recording_meta = @@ -167,6 +168,12 @@ impl ExporterBuilder { let output_path = self .output_path .unwrap_or_else(|| recording_meta.output_path()); + + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| ExporterBuildError::IO(parent.to_path_buf(), e))?; + } + let streaming_output = prepare_streaming_output( &output_path, cancellation.is_some() && ExportAudioRenderer::eligible(&project_config, studio_meta), @@ -235,11 +242,6 @@ impl ExporterBuilder { } } - if let Some(parent) = output_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| ExporterBuildError::IO(parent.to_path_buf(), e))?; - } - Ok(ExporterBase { output_path, studio_meta: studio_meta.clone(), @@ -254,6 +256,8 @@ impl ExporterBuilder { audio_cancellation, sample_windows: None, sample_timing: None, + #[cfg(feature = "test-hooks")] + video_encoder_failure_at: None, }) } } @@ -440,6 +444,11 @@ impl Mp4ExporterBase { pub fn uses_streaming_audio(&self) -> bool { self.0.streaming_audio.is_some() } + + #[cfg(feature = "test-hooks")] + pub fn fail_video_encoder_at(&mut self, frames: u32) { + self.0.fail_video_encoder_at(frames); + } } pub struct ExporterBase { @@ -456,9 +465,55 @@ pub struct ExporterBase { audio_cancellation: Option, sample_windows: Option, sample_timing: Option>, + #[cfg(feature = "test-hooks")] + video_encoder_failure_at: Option, } impl ExporterBase { + /// Makes the MP4 export's video encoder fail after `frames` frames the + /// way a hardware encoder does at runtime, once, so the software + /// encoder fallback runs on every machine. + #[cfg(feature = "test-hooks")] + pub fn fail_video_encoder_at(&mut self, frames: u32) { + self.video_encoder_failure_at = Some(frames); + } + + /// Reopens what an MP4 export consumes (the staged output and the + /// streaming audio with its cancellation) so the export can start over. + async fn reopen_streaming( + &mut self, + user_cancellation: Option>, + ) -> Result<(), String> { + let Some(user) = user_cancellation else { + return Ok(()); + }; + let control = ExportAudioCancellation::new(user); + let recording = self.recording_meta.clone(); + let studio = self.studio_meta.clone(); + let cancellation = control.user.clone(); + let abort = control.stop.clone(); + let preparation = tokio::task::spawn_blocking(move || { + ExportAudioPreparation::open(&recording, &studio, cancellation, abort) + }) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string())?; + self.streaming_audio = Some( + preparation + .finish(&self.segments) + .map_err(|error| error.to_string())?, + ); + // The first attempt staged its output, so the restart must too: if + // something created the destination meanwhile, writing in place + // would truncate it. + self.streaming_output = Some( + prepare_streaming_output(&self.output_path, true) + .ok_or("Could not stage the export output to restart on the software encoder")?, + ); + self.audio_cancellation = Some(control); + Ok(()) + } + pub fn total_frames(&self, fps: u32) -> u32 { let duration = cap_rendering::get_duration( &self.recordings, @@ -799,6 +854,7 @@ mod clip_offset_tests { start_time: Some(start), device_id: None, gap_summary: None, + digital_silence_ms: None, }; StudioRecordingMeta::MultipleSegments { inner: MultipleSegments { diff --git a/crates/export/src/mov.rs b/crates/export/src/mov.rs index a71d1da8a8a..c26d17dc30f 100644 --- a/crates/export/src/mov.rs +++ b/crates/export/src/mov.rs @@ -5,9 +5,9 @@ use cap_rendering::{ProjectUniforms, RenderSegment, RenderedFrame}; use futures::FutureExt; use serde::{Deserialize, Serialize}; use specta::Type; -use std::{path::PathBuf, time::Duration}; +use std::{path::PathBuf, sync::Arc, time::Duration}; -use crate::{ExportError, ExporterBase}; +use crate::{ExportError, ExporterBase, mp4::temporary_export_output}; #[derive(Serialize, Deserialize, Clone, Copy, Debug, Type)] pub struct MovExportSettings { @@ -79,13 +79,17 @@ impl MovExportSettings { if let Some(parent) = mov_output_path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } + let temporary_output = Arc::new(temporary_export_output(&mov_output_path)?); + let encoder_output_path = temporary_output.to_path_buf(); + let encoder_temporary_output = temporary_output.clone(); let video_info = VideoInfo::from_raw(RawVideoFormat::Rgba, output_size.0, output_size.1, fps); let sample_timing = base.sample_timing.clone(); let encoder_thread = tokio::task::spawn_blocking(move || { - let mut mov_encoder = MOVFile::init(mov_output_path.clone(), |output| { + let _temporary_output_guard = encoder_temporary_output; + let mut mov_encoder = MOVFile::init(encoder_output_path, |output| { ProResEncoder::builder(video_info).build(output) }) .map_err(|e| ExportError::Other(format!("Failed to create MOV encoder: {e}")))?; @@ -131,7 +135,7 @@ impl MovExportSettings { .finish() .map_err(|e| ExportError::Other(format!("Failed to finish MOV: {e}")))?; - Ok(mov_output_path) + Ok(()) }) .then(|f| async { f.map_err(|e| e.to_string()) @@ -160,10 +164,15 @@ impl MovExportSettings { ) .then(|f| async { f.map_err(|v| v.to_string()) }); - let (output_path, _) = - tokio::try_join!(encoder_thread, render_video_task).map_err(|e| e.to_string())?; + let (encoder_result, render_result) = tokio::join!(encoder_thread, render_video_task); + encoder_result?; + render_result?; + Arc::try_unwrap(temporary_output) + .map_err(|_| "Export output is still in use".to_string())? + .persist_replace(&mov_output_path) + .map_err(|error| error.to_string())?; - Ok(output_path) + Ok(mov_output_path) } } diff --git a/crates/export/src/mp4.rs b/crates/export/src/mp4.rs index 8395ed74090..be25ee2e62f 100644 --- a/crates/export/src/mp4.rs +++ b/crates/export/src/mp4.rs @@ -16,7 +16,7 @@ use specta::Type; use std::{ path::PathBuf, sync::{ - Arc, Mutex, + Arc, Mutex, PoisonError, atomic::{AtomicBool, AtomicU64, Ordering}, }, time::Duration, @@ -169,6 +169,7 @@ impl ExportCompression { #[derive(Clone, Default)] struct ExportNv12Mode { stop_after_frames_sent: Option, + software_encoder: bool, record_first_queued_ms_since_pipeline: Option>, nv12_render_startup_breakdown_ms: Option>>>, @@ -294,6 +295,7 @@ impl Mp4ExportSettings { |_| true, ExportNv12Mode { stop_after_frames_sent: Some(1), + software_encoder: false, record_first_queued_ms_since_pipeline: Some(first_ms_enc), nv12_render_startup_breakdown_ms: Some(breakdown_enc), }, @@ -326,7 +328,64 @@ impl Mp4ExportSettings { output_size: (u32, u32), fps: u32, on_progress: impl FnMut(u32) -> bool + Send + 'static, + mut mode: ExportNv12Mode, + ) -> Result { + let on_progress = Arc::new(Mutex::new(on_progress)); + let user_cancellation = base + .audio_cancellation + .as_ref() + .map(|control| control.user.clone()); + loop { + let progress = on_progress.clone(); + let hardware_encoder_failed = Arc::new(AtomicBool::new(false)); + let result = self + .export_nv12_attempt( + &mut base, + output_size, + fps, + move |frame| { + let mut on_progress = + progress.lock().unwrap_or_else(PoisonError::into_inner); + (*on_progress)(frame) + }, + mode.clone(), + hardware_encoder_failed.clone(), + ) + .await; + // A hardware encoder can fail mid-stream when the media engine is + // busy or resets. The MP4 cannot switch codecs partway, so the + // export starts over once with the software encoder. + match result { + Err(error) + if restart_on_software_encoder( + mode.software_encoder, + base.sample_timing.is_some(), + hardware_encoder_failed.load(Ordering::Relaxed), + user_cancellation + .as_ref() + .is_some_and(|cancel| cancel.load(Ordering::Relaxed)), + ) => + { + warn!( + %error, + "Hardware video encoder failed during export, restarting with the software encoder" + ); + base.reopen_streaming(user_cancellation.clone()).await?; + mode.software_encoder = true; + } + result => return result, + } + } + } + + async fn export_nv12_attempt( + self, + base: &mut ExporterBase, + output_size: (u32, u32), + fps: u32, + on_progress: impl FnMut(u32) -> bool + Send + 'static, mode: ExportNv12Mode, + hardware_encoder_failed: Arc, ) -> Result { let pipeline_start = std::time::Instant::now(); let sample_timing = base.sample_timing.clone(); @@ -379,6 +438,9 @@ impl Mp4ExportSettings { let has_audio = has_recording_audio || !music.is_empty(); let record_first_queued_ms = mode.record_first_queued_ms_since_pipeline; + let software_encoder = mode.software_encoder; + #[cfg(feature = "test-hooks")] + let injected_failure_at = base.video_encoder_failure_at.filter(|_| !software_encoder); let nv12_render_startup_breakdown_ms = mode.nv12_render_startup_breakdown_ms; let project_for_audio = base.project_config.clone(); @@ -401,6 +463,7 @@ impl Mp4ExportSettings { } #[cfg(target_os = "macos")] let want_hw_input = !self.optimize_filesize + && !software_encoder && matches!( first_frame.as_ref().map(|frame| &frame.payload), Some(ExportFramePayload::Surface(_)) @@ -442,6 +505,8 @@ impl Mp4ExportSettings { .with_external_conversion(); let builder = if self.optimize_filesize { builder.with_crf(self.compression.crf_value()) + } else if software_encoder { + builder.with_encoder_priority_override(&["libx264"]) } else { builder }; @@ -465,8 +530,17 @@ impl Mp4ExportSettings { encoder.video_mut().set_packet_stats(timing.packet_stats()); } + let hardware_video = encoder.video().is_hardware(); + // The injected failure stands in for a hardware encoder's. + #[cfg(feature = "test-hooks")] + let hardware_video = hardware_video || injected_failure_at.is_some(); + let video_failure = |error: String| { + video_encoder_failure(hardware_video, &hardware_encoder_failed, error) + }; + info!( zero_copy_input = encoder_is_hw, + hardware_video, "Created MP4File encoder (NV12, export settings)" ); @@ -535,7 +609,7 @@ impl Mp4ExportSettings { &mut converted_frame, Duration::MAX, ) - .map_err(|err| err.to_string())?; + .map_err(|err| video_failure(err.to_string()))?; } #[cfg(target_os = "macos")] ExportFramePayload::Surface(surface) => { @@ -545,7 +619,7 @@ impl Mp4ExportSettings { .video_encoder() .wrap_videotoolbox_pixel_buffer(surface.as_pixel_buffer_ptr()) } - .map_err(|err| err.to_string())?; + .map_err(|err| video_failure(err.to_string()))?; frame.set_pts(Some(input.frame_number as i64)); encoder .queue_video_frame_reusable( @@ -553,7 +627,7 @@ impl Mp4ExportSettings { &mut converted_frame, Duration::MAX, ) - .map_err(|err| err.to_string())?; + .map_err(|err| video_failure(err.to_string()))?; } else { fill_nv12_frame_from_surface( &mut reusable_frame, @@ -571,7 +645,7 @@ impl Mp4ExportSettings { &mut converted_frame, Duration::MAX, ) - .map_err(|err| err.to_string())?; + .map_err(|err| video_failure(err.to_string()))?; } } } @@ -579,6 +653,13 @@ impl Mp4ExportSettings { worker.write_ready(&mut encoder)?; } encoded_frames += 1; + #[cfg(feature = "test-hooks")] + if injected_failure_at == Some(encoded_frames) { + return Err(video_failure( + cap_enc_ffmpeg::h264::QueueFrameError::Encode(ffmpeg::Error::External) + .to_string(), + )); + } if let Some(timing) = &sample_timing { timing.record_frame(input.timeline_frame); } @@ -617,7 +698,7 @@ impl Mp4ExportSettings { .map_err(|e| format!("Failed to finish encoding: {e}"))?; if let Err(e) = res.video_finish { - return Err(Mp4PipelineError::Failure(format!("Video encoding failed: {e}"))); + return Err(video_failure(format!("Video encoding failed: {e}"))); } if let Err(e) = res.audio_finish { return Err(Mp4PipelineError::Failure(format!("Audio encoding failed: {e}"))); @@ -735,6 +816,27 @@ impl Mp4ExportSettings { } } +/// A video encoder error that fails this export attempt. A hardware +/// encoder's error also sets `restart`, so the export starts over on libx264. +fn video_encoder_failure(hardware: bool, restart: &AtomicBool, error: String) -> Mp4PipelineError { + if hardware { + restart.store(true, Ordering::Relaxed); + } + Mp4PipelineError::Failure(error) +} + +/// Whether a failed attempt starts over on the software encoder: after a +/// hardware encoder error, once, and not for size-estimate samples or a +/// cancelled export. +fn restart_on_software_encoder( + attempt_was_software: bool, + sampled: bool, + hardware_encoder_failed: bool, + cancelled: bool, +) -> bool { + hardware_encoder_failed && !attempt_was_software && !sampled && !cancelled +} + enum ExportFramePayload { Cpu(SharedNv12Buffer), #[cfg(target_os = "macos")] @@ -1158,25 +1260,35 @@ impl TemporaryExportOutput { self.path.to_path_buf() } + #[cfg(unix)] + fn restore_permissions(&self, output: Option<&std::path::Path>) -> std::io::Result<()> { + use std::os::unix::fs::MetadataExt; + let path_metadata = std::fs::symlink_metadata(&self.path)?; + let file_metadata = self.file.metadata()?; + if !path_metadata.is_file() + || path_metadata.dev() != file_metadata.dev() + || path_metadata.ino() != file_metadata.ino() + { + return Err(std::io::Error::other("Export temporary file was replaced")); + } + let existing_permissions = match output { + Some(path) => match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_file() => Some(metadata.permissions()), + Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error), + _ => None, + }, + None => None, + }; + self.file + .set_permissions(existing_permissions.unwrap_or_else(|| self.final_permissions.clone())) + } + pub(crate) fn persist_noclobber( self, output: &std::path::Path, ) -> Result<(), tempfile::PathPersistError> { #[cfg(unix)] - let prepare_permissions = || { - use std::os::unix::fs::MetadataExt; - let path_metadata = std::fs::symlink_metadata(&self.path)?; - let file_metadata = self.file.metadata()?; - if !path_metadata.is_file() - || path_metadata.dev() != file_metadata.dev() - || path_metadata.ino() != file_metadata.ino() - { - return Err(std::io::Error::other("Export temporary file was replaced")); - } - self.file.set_permissions(self.final_permissions.clone()) - }; - #[cfg(unix)] - if let Err(error) = prepare_permissions() { + if let Err(error) = self.restore_permissions(None) { return Err(tempfile::PathPersistError { error, path: self.path, @@ -1184,6 +1296,20 @@ impl TemporaryExportOutput { } self.path.persist_noclobber(output) } + + pub(crate) fn persist_replace( + self, + output: &std::path::Path, + ) -> Result<(), tempfile::PathPersistError> { + #[cfg(unix)] + if let Err(error) = self.restore_permissions(Some(output)) { + return Err(tempfile::PathPersistError { + error, + path: self.path, + }); + } + self.path.persist(output) + } } pub(crate) fn temporary_export_output( @@ -1606,6 +1732,40 @@ async fn export_render_to_channel( mod tests { use super::*; + #[test] + fn hardware_encoder_errors_restart_on_software() { + let queue = cap_enc_ffmpeg::h264::QueueFrameError::Encode(ffmpeg::Error::External); + let flush = format!("Video encoding failed: {}", ffmpeg::Error::External); + for error in [queue.to_string(), flush] { + let restart = AtomicBool::new(false); + let failure = video_encoder_failure(true, &restart, error.clone()); + assert_eq!(failure.message(), error); + assert!(restart_on_software_encoder( + false, + false, + restart.load(Ordering::Relaxed), + false + )); + } + } + + #[test] + fn software_encoder_errors_do_not_restart() { + let restart = AtomicBool::new(false); + let error = cap_enc_ffmpeg::h264::QueueFrameError::Encode(ffmpeg::Error::External); + video_encoder_failure(false, &restart, error.to_string()); + assert!(!restart_on_software_encoder( + false, + false, + restart.load(Ordering::Relaxed), + false + )); + // Already on libx264, a size-estimate sample, or cancelled. + assert!(!restart_on_software_encoder(true, false, true, false)); + assert!(!restart_on_software_encoder(false, true, true, false)); + assert!(!restart_on_software_encoder(false, false, true, true)); + } + fn sum_samples(sample_rate: u64, fps: u64, frames: u64) -> u64 { (0..frames) .map(|n| { diff --git a/crates/export/src/preview.rs b/crates/export/src/preview.rs index c092ec0fdf4..09e1172af4f 100644 --- a/crates/export/src/preview.rs +++ b/crates/export/src/preview.rs @@ -100,6 +100,7 @@ struct PreviewSource<'a> { render_constants: &'a RenderVideoConstants, segments: Vec>, total_duration: f64, + layers_cache: Option<&'a std::sync::Mutex>>, } struct PreviewSegment<'a> { @@ -130,6 +131,7 @@ async fn render_preview_with_base( }) .collect(), total_duration, + layers_cache: None, }; render_preview_frame(source, frame_time, settings).await } @@ -184,6 +186,7 @@ pub async fn render_preview_with_editor( }) .collect(), total_duration, + layers_cache: Some(&editor.preview_layers), }; render_preview_frame(source, frame_time, settings).await } @@ -219,6 +222,7 @@ async fn render_preview_frame( render_constants, segments, total_duration, + layers_cache, } = source; let project_config = &Arc::new(project_config.clone()); let transition_mapping = project_config.timeline.as_ref().and_then(|timeline| { @@ -286,11 +290,22 @@ async fn render_preview_frame( ); let mut frame_renderer = FrameRenderer::new(render_constants); - let mut layers = RendererLayers::new_with_options( - &render_constants.device, - &render_constants.queue, - render_constants.is_software_adapter, - ); + let mut layers = layers_cache + .and_then(|cache| { + let mut guard = cache.lock().ok()?; + guard.take() + }) + .map(|mut cached| { + cached.reset_frame_state(); + cached + }) + .unwrap_or_else(|| { + RendererLayers::new_with_options( + &render_constants.device, + &render_constants.queue, + render_constants.is_software_adapter, + ) + }); let frame = if let Some((outgoing, kind, progress)) = transition_mapping { let outgoing_media = segments @@ -392,6 +407,12 @@ async fn render_preview_frame( (total_bitrate * encoder_efficiency * duration_seconds) / (8.0 * 1024.0 * 1024.0) }; + if let Some(cache) = layers_cache + && let Ok(mut guard) = cache.lock() + { + *guard = Some(layers); + } + Ok(ExportPreviewResult { jpeg_base64: STANDARD.encode(&jpeg_buffer), estimated_size_mb, diff --git a/crates/export/tests/mov_cancellation.rs b/crates/export/tests/mov_cancellation.rs new file mode 100644 index 00000000000..e6f8f47c4ae --- /dev/null +++ b/crates/export/tests/mov_cancellation.rs @@ -0,0 +1,205 @@ +use cap_export::{ExporterBase, mov::MovExportSettings}; +use cap_project::{ProjectConfiguration, XY}; +use std::{fs, path::Path, process::Command}; + +fn create_project(path: &Path) { + fs::create_dir_all(path).unwrap(); + let video = Command::new("ffmpeg") + .args([ + "-v", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=64x64:rate=10:duration=1", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-threads", + "1", + ]) + .arg(path.join("display.mp4")) + .output() + .unwrap(); + assert!( + video.status.success(), + "{}", + String::from_utf8_lossy(&video.stderr) + ); + fs::write( + path.join("recording-meta.json"), + serde_json::to_vec(&serde_json::json!({ + "pretty_name": "MOV cancellation", + "display": { "path": "display.mp4", "fps": 10 } + })) + .unwrap(), + ) + .unwrap(); +} + +fn assert_playable_mov(path: &Path) { + let decoded = Command::new("ffmpeg") + .args(["-v", "error", "-i"]) + .arg(path) + .args(["-f", "null", "-"]) + .output() + .unwrap(); + assert!( + decoded.status.success(), + "{}", + String::from_utf8_lossy(&decoded.stderr) + ); +} + +async fn export( + project: &Path, + output: &Path, + on_progress: impl FnMut(u32) -> bool + Send + 'static, +) -> Result { + let base = Box::pin( + ExporterBase::builder(project.to_path_buf()) + .with_config(ProjectConfiguration::default()) + .with_output_path(output.to_path_buf()) + .build_video_only(), + ) + .await + .unwrap(); + Box::pin( + MovExportSettings { + fps: 10, + resolution_base: XY::new(64, 64), + cursor_only: true, + } + .export(base, on_progress), + ) + .await +} + +#[tokio::test] +#[ignore = "requires ffmpeg and a wgpu adapter"] +async fn cancelled_mov_preserves_existing_destination() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project.cap"); + create_project(&project); + let output = directory.path().join("export.mov"); + fs::write(&output, b"existing export").unwrap(); + + assert!(export(&project, &output, |frame| frame < 2).await.is_err()); + + assert_eq!(fs::read(&output).unwrap(), b"existing export"); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 2); +} + +#[tokio::test] +#[ignore = "requires ffmpeg and a wgpu adapter"] +async fn dropped_export_keeps_temporary_output_owned_by_encoder() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project.cap"); + create_project(&project); + let output = directory.path().join("export.mov"); + fs::write(&output, b"existing export").unwrap(); + + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let task = tokio::spawn(async move { + let mut started_tx = Some(started_tx); + export(&project, &output, move |_| { + if let Some(started_tx) = started_tx.take() { + let _ = started_tx.send(()); + } + release_rx.recv().unwrap(); + false + }) + .await + }); + + started_rx.await.unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + let temporary_exists = fs::read_dir(directory.path()).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".cap-export-") + }); + release_tx.send(()).unwrap(); + + assert!(temporary_exists); + assert_eq!( + fs::read(directory.path().join("export.mov")).unwrap(), + b"existing export" + ); +} + +#[tokio::test] +#[ignore = "requires ffmpeg and a wgpu adapter"] +async fn finished_mov_replaces_existing_destination() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project.cap"); + create_project(&project); + let output = directory.path().join("export.mov"); + fs::write(&output, b"existing export").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&output, fs::Permissions::from_mode(0o600)).unwrap(); + } + let destination = output.clone(); + + let result = export(&project, &output, move |_| { + assert_eq!(fs::read(&destination).unwrap(), b"existing export"); + true + }) + .await + .unwrap(); + + assert_eq!(result, output); + assert_playable_mov(&output); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&output).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 2); +} + +#[tokio::test] +#[ignore = "requires ffmpeg and a wgpu adapter"] +async fn mov_reports_destination_replacement_failure() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project.cap"); + create_project(&project); + let output = directory.path().join("export.mov"); + fs::create_dir(&output).unwrap(); + + assert!(export(&project, &output, |_| true).await.is_err()); + + assert!(output.is_dir()); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 2); +} + +#[tokio::test] +#[ignore = "requires ffmpeg and a wgpu adapter"] +async fn finished_mov_creates_new_destination() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project.cap"); + create_project(&project); + let output = directory.path().join("export.mov"); + let destination = output.clone(); + + let result = export(&project, &output, move |_| { + assert!(!destination.exists()); + true + }) + .await + .unwrap(); + + assert_eq!(result, output); + assert_playable_mov(&output); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 2); +} diff --git a/crates/ffmpeg-hw-device/src/lib.rs b/crates/ffmpeg-hw-device/src/lib.rs index 8691bebe316..0bfd452cb96 100644 --- a/crates/ffmpeg-hw-device/src/lib.rs +++ b/crates/ffmpeg-hw-device/src/lib.rs @@ -1,6 +1,10 @@ use std::{ cell::Cell, ptr::{null, null_mut}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; use ffmpeg::{ @@ -9,16 +13,18 @@ use ffmpeg::{ sys::{ AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX, AVBufferRef, AVCodecContext, AVCodecHWConfig, AVHWDeviceType, AVPixelFormat, av_buffer_ref, av_buffer_unref, av_dict_free, av_dict_set, - av_hwdevice_ctx_create, av_hwframe_transfer_data, avcodec_get_hw_config, + av_hwdevice_ctx_create, av_hwframe_transfer_data, avcodec_default_get_format, + avcodec_get_hw_config, }, }; +use tracing::warn; thread_local! { static HW_PIX_FMT: Cell = const { Cell::new(AVPixelFormat::AV_PIX_FMT_NONE) }; } unsafe extern "C" fn get_format( - _: *mut AVCodecContext, + ctx: *mut AVCodecContext, pix_fmts: *const AVPixelFormat, ) -> AVPixelFormat { unsafe { @@ -30,19 +36,50 @@ unsafe extern "C" fn get_format( } if *fmt == HW_PIX_FMT.get() { + set_software_fallback(ctx, false); return *fmt; } fmt = fmt.offset(1); } - AVPixelFormat::AV_PIX_FMT_NONE + // When the hardware format's setup fails (a busy or reset media + // engine), FFmpeg drops it from the list and asks again. Choosing + // nothing here would fail every packet, so take FFmpeg's default + // software format and keep decoding on the CPU. + warn!( + hw_pix_fmt = ?HW_PIX_FMT.get(), + "Hardware decoder setup failed, falling back to software decode" + ); + set_software_fallback(ctx, true); + avcodec_default_get_format(ctx, pix_fmts) + } +} + +/// Records `get_format`'s latest choice in the [`HwDevice`] flag that +/// [`install_get_format`] put in the codec context's `opaque`. +unsafe fn set_software_fallback(ctx: *mut AVCodecContext, software: bool) { + unsafe { + if let Some(flag) = (*ctx).opaque.cast::().cast_const().as_ref() { + flag.store(software, Ordering::Relaxed); + } + } +} + +/// Points the codec context at [`get_format`] and at the flag it records the +/// chosen format in. The flag outlives the context as long as the context is +/// freed before the [`HwDevice`] holding the flag. +unsafe fn install_get_format(context: *mut AVCodecContext, software_fallback: &Arc) { + unsafe { + (*context).get_format = Some(get_format); + (*context).opaque = Arc::as_ptr(software_fallback).cast_mut().cast(); } } pub struct HwDevice { pub device_type: AVHWDeviceType, ctx: *mut AVBufferRef, + software_fallback: Arc, } fn linux_gpu_frames() -> bool { @@ -51,6 +88,12 @@ fn linux_gpu_frames() -> bool { } impl HwDevice { + /// Whether FFmpeg took a software format because the hardware setup + /// failed, so the decoder is running on the CPU despite this device. + pub fn is_software_fallback(&self) -> bool { + self.software_fallback.load(Ordering::Relaxed) + } + pub fn get_hwframe(&self, src: &Video) -> Option

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 literal 0 HcmV?d00001 diff --git a/apps/web/public/blog/gpui/settings-061.webp b/apps/web/public/blog/gpui/settings-061.webp new file mode 100644 index 0000000000000000000000000000000000000000..3298a4fd28fc5e61eec01cd221833d18f8f4293a GIT binary patch literal 80440 zcmbrl19WBEx-FbkY}>Y7v2EK<#kO6sZ95ew6&n@XM#biO*?Zr8&OYzn*8caux76BP z7;B7=z4tMvlBAdz<5yrHbx|P&H3d!$XdoaU#Ls6fC{Ph7kf4l$1PB-q5OAKzx3z1D zms5VLHS4tv?r62;AnD;;O;uVYAIu zGO+327N&c%nn9X9YSO}z`W`g#4mO#pjk@`;vmj1~eVp2d=kn%7WnDd~jo1EBI5*@z z=opH7V_b?>q__~@pH=YGm@}oRnBixsrvM7*OhPNEWVna9k&mrnmCeOK-PT9dcF5?W zUZw{`bJogLbrCMZAN?MIo}U<;YA#vQifDIzUm1BD_-tzW`64Elqtt< z9*jR%D|yH}&o})x#*Hxj$=ObV34M^51 ze-o`PvYMBAr*4|ntX11ZZ!)?j_~`e0LICXWzl8y={{J6XX8ga7@b7=^20YIO|NHR& zaz_8h1L$Kpll*@zo+yU=|IRM|$FumS$;a|C|L?6SZ`3rB{1B~WDOs6p_Mek}+ybWi zfAH~NYXN9gtJ7Tn5jllwr+$hw<+bxt2VPbOuj&h9rSj#;Ef?xEzy1t8dfBLC968y6 zv1f?#h#;D&1?p7wG@l=1fGY9+X4&0n{vl5cK$s?$%Juuacx8fA}u zOXQo-yEdD?vFvv8wGlF|o#~@iCjM3pSozq#lgKRk1tXLH;X!5E@S?PZdhyHiYpK)? z!z7FhQ#Mi9+j6-?K?O+|SINjl+1^&=8Zm>&qM!3B=C7n_9LC3KX+8bKB9`>u1a=n; z7C+Yu&-*K5wOFS)$Xq)O?utC5AHN=Uo?UqE4!ZFJ>f3Ab8*Aqc_4S`#aYGF6j#xjM z$lDyFS{r}Xyp%F*dY#OyF>pWo;RuPV82d) z{`=ad-;w?!-*$IV_g420fIA@n{qgfd`Krrny+!^O--%by50>}2_pRs4O}}*hNB&K~ z^6QWHP=M%%`ZmmS?SsBn?u*`#e)C5U!LROCzq$J+!1zbdBj5z*!3iM$!oLN`CpaQl z;Xn2D1dO}@0h&&M0mjwcWBk>Ax&VatrPrli{F{%`_m;QIN4wwrFMhMH;!m}s-HIP` zAKb5754mr-zkMnBV*yC7iATnBetrOkkJ^XJ$JqVYU4Rx}+k4|%>NCMPL36G%AmHQd zgX4k!&HRGD8360&`Bno!dCh--d0M(*IO|^Nw(x8A-F?jmxUHL)0U7|tA5IVbkAP#0 zdB1c(?8o_s{0GJp!un;RzV+t;!o$`Z1_1B`kn**DcYEV_)W0J*_iOY60K8s6-t3;; z9=Fc<5BxU$yguzEeSvUKaLYgO@$};Q{`dxXN4ViX=yv(&=}z_20C>H>z8wMhZ@K}1 zw+|XW27vNg?u-5h{}Dd`aQSrkeDu`)+$(!eh!vq(`*P-yO1i^nLEUtT_oq zvr&MQ;2k!|3cX_e>)8cm)uv-dR0BK(dG0_84~o>{A@DZwdJoTk8>Y5;!>9QC|@m&;{pva=6+V>!7p?19?qvwTQ6(hydu0hhGL z_P!GT%kDvF4~1~8JT+`jY7xyRwb=tNs3b{+>R~0xYbCK0rZ*7mf!N!xGo4Gp^eWmm zM0yt)8e+Y8Gbu7>@=l)EJ#(#5G=0W{N(-G~#FRZaki|E5)%dDkufH$0y3Jc@4csIq z_c9O2VS)Yvaj1jJ7e%Dvf|EjWC@!RO&ik*KxI?yxvL3Nrm;Dm?miFG$8GH|@u8{0| z(>iO?xBzp#ytIGVb{_COJ;ti<;m96&Lw+c~PhUka*q@|T?bzXwB5T+s$Q70s49?uRd;g8{MUUA;bbaL2Zz9);nX5ZR%+v{}d&q{NDb>qxHmkwDg)RR*^wZ8oxlV zGlf^A%Y)W4!t+Ms9{GN|Vzj0JF4mJ49}pF0C|h|JfrVJKo7n-tvM$tdwBXYt@1-a_)gcu0{qbI`c zdH=~PP?nDtt4J^IeoDbHI~dH<`g-gG)noDV16hr?snnF5j_~_RAV04b(Qq`Quxe@Q z!JP+?BGO|=kVBZ?YOZ%;>}(|;|>9Sb&-+B37t-&XQ$ z@EW>V@GX0}xR3<)84@Y@oCT<00=`Xc3zF;UMhZUyoU!c)vs@X^grdrj83=FG+`SGa zj<*4YLaNVJlRuLBgV!d0LlTXf>qSR*QT%DzNDnjeeGD;5gP<0Y6_EOUlCv^%vG7Su z#D5{88+pK6&XtGHwc$-aLu-4LiQTct?FYR+#Mfr3OfVXCI^MDU%HtV&S_0Qx)9m?L zCy%U5zxf(YM|E5-ni$;VVR)&%Ff!kb3ux{W(IIUm2zF=j?Y8hT^)LJlZ4lz%r^$KW z#3AyrDCUp3ADMNnQaWN2gHi|zmyru@8>;*f*Uc^X1X<~SeQke?R!+LP{8rFlCtDc7 zt?qzUexsa!4tdXz)E>S`Y!iQTpJIu5!vwzz&*Qdu2ji>=%Oh+m&|_41Ftlp43zRRY z_)r!5g;W7$JfPGp2}Z`q0sg}}7e^sf-)?oi976Rh~rh6}Y zIih~cb2Y4%Mc=5((G0TPd80>HB1qvZ9N{v{m)NMThZXa^K(#YoiB)e~?qZVlD=!tP zwsT(TEm_mkMs&90%Ys{2F2by|I?H)=rnfB(Svix4(~y)tc&C~ggBj})--m!@gGQ?K zZ&S`*^SpxQ(sC7>cSm@V^|kQ~<=h0+@fna}C~m@tkL<~KUl0!PV9fL@tDPu{FQlvv zz$YtP5+?E#eo9b|p3)jq)^qPa$X;)a_fA6MIia&TH$65ImkGU_ zLw7>kpRK@%zeG;|e&DJIuz)z4lwF{9a-$3o%N8VtUU`tmqaVOec{2Pl?4jwQ$Q!Xy zzF;jx==ku8E^iB=a7&O40M(al!%pKz+Wwo_HmRw_TH6CzPkR&jT*gi~#3FKik z3w-@WHGG8)3F|9ID7%JdEq^~dis|JHrzGZaQxZkV(@&ZcC43^ai95R6b_rWI=T2F| z7O-IygP(gV%>=2WT{*@O^ka<)@h5-p>n*#$YliKhW$xKkrP++cBHK?WUk%IgUH{EX zH04NE6^XUlbYoQ#SaWuLee~;+X{A?IvRlTD&1K`@u%6In8zOd%*;$YHr~p`mEx1!x zQPD**WX!qTG`TjqHgNn68JJICfU>Ad2~N##I&NszOS~4Vg9I1D76$yIuaViY*)s!P z(uFEb6#f^OX?Dp;m=f~!$s1G>djpd`wG5Eomt(w0>iaD6)U1M%8q(>LW3^n5qLRh} z8WB!zO8jQ!4hPQ>6JN^6<*~9#J&QX_7gCG{M@ADfS+dO@In`yQ!~X0?bSl+FpbZ1W zY_dz93Sj$10Sc)t5N}Lb+pBg-NNSy)xD0kaMC0}uUJxK?Dqu8`HUai6t)t-f`^Z_0 zQaa15j~I=<$Uk6%dzL4xEKd+2^~T*p4DEKg^gwe)nHp z>ffz*zy6f$Y}D}gLA-a@F7WyEU;T0x^M8bcpQti`(&$K|3nE|Mh!J3cu=V{C45O{r zH^m9nwQKqk?l-?W=c_-LGe(%wKrUFhK9-so)Llm?;T4htb?+ZD`JaYW$+DQ{8soI# zF%mMKq^#Y|xJ|hgbth<9V0~L#|3$2sBgcW$@`*sc+gpdZZidXd?6cB7eVX&{*8UgP z{nG*`w26P93$st3U?9EoI~SU_YlTM}L2GfpKKteV&p_eS3b|6#p|q^n`iOGTlV=SM zl;&J-6;o#~3unc|@NWD15LK^9s!=WTKv*;xU|>=`jEi$q2HC<$Jt4*=;Jv&$;|cC) zY?UJ~?j<63aANW@y$MhUT~J$>jVWs+WX|z&|4r$Ns6a;PRtLWC_)R>ZvA8ZVMm2ur zAMR@vx)-pZX0l`g4vR~uQ9`d5CO?Hw#z7E%Q}e|x%^OeppmVz0;nwlHp8Ei9{WPL* zka5h(oFhM8O|yFKv*~y2?B6a2>9?w}i5p{7R#=il^oJ#VvO1wNLj1t2Y)7sUBo>PU z?}PFr^BNYH52pgl?XxRN@Y8x>f>1ic;6jVF*DpnmxJ$YvlRvqu!okNaw#K7VjJvOb z!3)SLuFUBpdXp%-lxf@Y()5DX^8V$;=%2@s?YilIz;cxk<*yi-bMg~^df-3t-5)_V zbz38hi8(1#Vz)B={pkHdm&(yIwuV1iT^HEY!x~DIjjGcxY>GUA!`-?icJ^I;bv`iV zlOou`(z~u-{`}vG!(V`JEFTMkJ68gnpJO7_{o)q$mtUusa>f<%&u>}0vEw@gMZGrN=OGND zH<#JqBQ&KSi2tFwCxwF1qn)?|>nM1CG~%j)>qE@R%q6hhozt(aIzIqcUrRGW8X3@M zOdh_fax0Z5)oQ9aNmS6}$AOe=2V6K;l7ldyl@5Lm?bpVZzx-z^9s<#h5>(51ArDi6 zC-S0dYg`1)g1dvoEFscxrJMh1xX3_^4oY07BGzg$SApAWAAA!MoH7*8$z2%}Lyd5; zeq{VIVoO6~%;$;_GE4c)`c+QSKJpr4E8QI|fenV>ot3UY89qnUjcT7|UsblwBPak{ zJw4UR$sndAAH@?0T0k$n^IbG~`0-nFOBL<{g~cZ0W?`@&I{kf@-r;fuZ!vG?gez;jMaB0*)v34{9@mLU7(F+S5Nm&E}=Ug~_DdGr*UG@!=S9 z{6)lJvjeM(q|1XQ73xn8bC)FhM$+xecwErk|cciNrZ?1lGVT!~` z>5t}2f^1(S2$5mBj)v9&31KX=F_pfx8*~|eknn%dr=(2^>MwMwvej=fbY$6_Hhn~4cGEDo@^s^nNG=xceN1eJ+ZloTJ z0y>c0n(;)5!8x$@Nz%3E?r!qLTrKtg*##`szajt57Q-u7g~kK<{cPn7BhJx2?nxt@ zr5mL)BvRPoW7;2!7d>}Jk1(7#-slXc5dWjd@_D$YBfA@Ti}2*;RtwhzuE9N9SDtGT z{$LhCPGSG6H#Orv?{9BAup6tddCQ5S#_-(*%smeY-UvhKJNWXco7PyTG;WI!{4H#J z_uix@F&T8!1;c9cBYc>Z(Klk^-pNvI#WJ!}@RaTi)I+50&_lv;-;tX67c7+}I=v$x zSSwwf4FDGn-ANdF3_tf2s)zzZdz`1dcz9q+hu3v%sfjBo01u3K;ELVMp~Lz?*M+8^ z`b&VIn_3jp`ApW9%aWIQ-_y87757-0CJ>`$*(2WH$V$y+t z`&I>JdM&6GW|&eC&Wgvd1ZaD+XAge0uK}uJP}5izV*;fXy~6EWPv>w0P@5rHTBulv z=3^@EAW+h3$W`Z;2KS5iB`I(-lSnyDDvY3s5!0UB_(kp;T`0wF)rC<&yqQRsx1@YP z1;aENAa_t6u&W7%hw$|Fz4qX}0p+^fV*0Jz^rN9)rk9Ke$PiJVUqAyMeQrMZ{cfM} zI)iRxKJ}lGjZdmhT{PdYGP)l@^?`}ptXz-m-uBTn4To_}m)EJ|o4X&D7hXB}_%oW+ z)9ryq)mozYV_3a5SB#*~ZqRWsz%Si20>Kt-sst2=gNh*v6+GNAZp=r&cRBBotc{Q3 zvXPd^NQ7C~fCXctFC~d3(M6RL>*b!Pi0k7*8otjKu-OU$;X;bl0~<7L%pkvQ-mLUQ zL$eLp!ZPxS;ecAs|*^IQlnLYz8j(>+%GQ=1ecANA{GMg! z!mtQf9Jt&f@9LG_G5yx}HNcA51iXy7KbU$s|E`3QiC4jrCzRy$k0kax9iwrFd*ErD zJo$&{lJJkFtkow88t*p3^tWngZ1$gd`EMx^6vrfr!xd{Idp*cXJjb0Q*K6&CE4-eT zAQ-sJANCI+F6&=#;J+!5GjZ>8$EJwh$?3F)zL>nVN$z@eo$&gcRq))5F5=WN8YZU% z+cOH*ND3zHwh@J_WPhlZ+Ya9Fn{aVpm_Lr+70BZXH;9-|VY-geV9QfpO<@qlm!sX!L)4)JmS(_O#U-_UI<~ z)h3Iz%r6_V1 z(SdH4e?Uet{0{=#zw_WP_525gt!h)}CKDOBqA495PKp|wG*-%43UOj?7BR#ApK$k& z2~{KtH{IE9ZhImsGaU{}rJ?;OhSsST{abx+5qhr&H&O1luWU5)*E9$?Wse z1Bz8^MzBIUivv#94qdCbPQd539rPGWkdi`q4lB@o_bm|TqdiEdg`}X>^3r4 zvrQDhljldQfLuct>S4cNTUwdlMe2`$j5V4V*Y>f-ovxLewSsdR56!-18T(`_15@qalyMdHU;7g{ ze0CkK7j>|FV}&R_woFtAIv?xNlxIO8c&4!27z`lUQIySUCOC4%?g&B3CSJA7V1ZEw}wI#5=ECKdJP&`&W*>U zAq0T?f>Dy0?jbsSsm}zX_+|=JHg489RCS;YVGt$)j@2rATfXhi$=IDcL!+W)(}y^C zrcHA!V*{f@ekxFgZd)gr*mf%px5!^Us?63q zc{D+hEaA8@imH%v+ri?PKZLvXMzl2eHtq=w!#OSEC6x3|wpmn8;5#LN?0aKK=l@|v zt7bpf;UpX74ROz5B=_TS=7Dsj)-V#Qaa=O)Zd;Khdns=$_c?HUj-+AD2SoOV{=vs& z6|G<{oaT-x?6w*b@t15cGoYBLWY18225s?#TSIi!PE()Gp4Xk7G6RWC8{GU`CZ8*t zyq~^Tmd_t#S6(QDx+~hXJ~rjeS579iwX;7IAbg8~i*LKi1)-0%6!@UvcFuE%=2Djs0&!2#sLgSi zbd?dsh@>hZ+=9%Lo@~sBN)eJQ3$j9SgGxP#AxuL&Q>d@zrr+xo#UvK z@qxs-WaNfGgY|9*jyPC$-u#A|f%$uTg%JTr|G-Q#M1WaH)7d%4nfI@f2HZRT=Eg&` zEVQUiPt@u-gu&~_c-@zAr|>nbAgcmfkTiHCi7km$HrfM!(PuPS z68dN8rxx;$-i}s{Z}A;879m~;i5?c$XbM^bx3{uFQTb|?@xQWL-22^t>;&03g_}E` z95|lOItcbT67-ByJ$p`KF6kal@;F9EWyG0R}GWxLr)B=;J^o5_gW*P#OMj?sp z);+Wp%X$IlVLAdrj#Iy1-xqxOrkORchiWE*yUcHy&klaQ#}lHzHElrV8@|)K33Ge1 zK%mfu18#O0sTuVA@1otQbZ8q|`0rt~G#t)8Oc;eb`hi2gLHWCos+659A0Ouh*QWxh z!u}Ap{!k(r;nCc_Gc89RWfsJx1U&?y+e*OM%e?r$F+LnPrRZjaMCxxbTcc#`xS1lQ ziKMM*B-idRFUnlvwMmL`0L1VDo3CVb@jX}Yxq=mpj_b|u=?@S&a!8e+hw)h5eYrFd z@A8vy5Syu|8ziu66^*A9UvS8cRF?Um?k*8tH+~hVu_E(Uicy_Is!HE?p`_Cq4?A>LkBGo7aXs+7NaH zz)vEGlb0etql;aCV8hVZe=g6(DaB$PW9utyiK}GY@!6C|JPVM;Roj5as`U|9_v%%z zOF4MAPDx8KyiGzd%o#c<+4q^!$h46*pNIp?tRf9my8%?m%}>Yh<-3=}SJMV#Yvw zxwrQhjOyv`w{nfTl+=m!uln1r`CX-tUamC5D5pQ4mTxYJyrA*h^~GuEY2jjWKeza_ zr`~?J6xqX2h3}wqCmrEik~Po8+K!}I(@?l_-D1SrhE7r^D&C@mVRIaZ(@*Zn`>$Qe z%uABU84X%YRF6`LjVP!>e)pI~2pGCthOW9DT{z*DodyY)2KqB~HZ}cBTyW>rOGITx zbr>DaC@L~)zOURrfDN)vVauk^kh&!(OR&QvZfUlWhNWvJ$tWF-K0R9BWTNve5%-2Li7MtMidE-@()O{X z!8hzmj8~hzqH^_uwGenB;DvV0Nw?X zNGg?iU!UGlQ%O!1KibFFogWrmd1+xX5xUHw{we1D&x-L5emsezNXO{$idcS{zFM~~ zo?L$%kqbo-&lOhs&zi|!+?JZ4rmnh~{}u}Wt1!TRSD~CdE-}6$MNN{#pWaA2T?KwK zsQW{`hd<;3tI0G9uSVRV#_kF#)zA_13cy}{_IrddTI#?v@~=OPO7vquQiAry?@hgo zJvazjUo2{>rS_VF^Mycn?{#n3^FybLOO02~!_X*ye!ZB~5HqF79Ku%vaW}+7I50Y|dml&UNs(CZ zy(+)1ey*UYsME9!c)SS!TeD#&}Um{G{ z3ebb}yL>7S3N~$%+o6t{KhVc}Vd$8r(D}|tH2dQR5m4$;cZOGm+JaiB=4Wq_w%_xaU4bS9|KNhf$1ofh$UZGj+86}8M(ei zAgYexydFg}564X3lD~cIVd~l|ll%BOBbmN~!Hd&nK{V}jHP%1o5_e?^@W~9={n%rv zM1Y2!t-j&Vf7iIa)obiq4LfHdB#dJO!te%{3m^M}26M+yw>pdp3*nM>W(uV>_-v2$ z@Z-uGx~VZMY1Lu??JJ)DWKqVid^V)ao3fwYB0$Ux;k9-8z?=N*%8{)_*WM$%VASD- z*g5WPud`P!Q_XuU9RqPmd#+RJmSMzSa8?p161bpdIN2VU%r0ZT?V%rDwGh{K{g< zWcKcfJLC?2R3OIyitk9f$0Jb2b8>$JF6l`04DW$V)Mu@wWw=6&4^GvMU5L5e1ASi|R6>W0x~H$lnr+_Pu%IYUWZ3WH1%vm?Zgm z%GuyP(^P0}wZFA0F0A@&!erqWw!5+$zYR?{_4Pqa?~VYqjIq0J18CvOr0GBoCIxp3 z@Js$U&Ttj^YY_(H1X|j;h|hM7j^qtr@dd53f6vH<*$FRy=?D|Nhw4BL8Qxd z6I8xSqlV7rR@d|d^biy_GbpoSp&zTWC1R;t*54N$o4F~|5$rC2oNP*s-5T8-BQunr z<8oC>u7KqZ9tn|TPcAQU8MH#UO`PV0YJYTm|>D8%uJfG1Dvm2_S}4HaI`fN8qvsHWbDAVDc5B9pjyz z-29$>>EI6apq$HHgaKxP%nW)0eJ3EWDXQu@`VYo_`(w=^Z}uWGRgZ~ zcbb+L{Wb~?dZc=Ij&0D`v8t6Hnucg0@PQ4|2uAJca{HJ5+3i#_%ZP=e+5U9AHB@=T z0^NEGj395^!)y-M%I=PrxLH(aFvERLL@BW^mhPr>9|m*k#5K+Vr&E4(6I?vf-{$ds zytom}^-^jB+9lw8n(e-*cEee$A)F<_SjWLWGCZJrwsQjYOquK*4o$vXkypVd(Nz}A zggY_OBmXcd2woYn$0Tl>Q4Fot-61kBi`u^-nBv%S;gQP$A!2wv;j4yi#^`60DVCs} zvLCCpji#hlJ6*0=npYe6&V`=ePuDIkQ569UrpvXFE(4kkem^rJvDHji#j~2xR05?f zCych4F;0h}>I-aF_k+M2mS05Xxq&1tGKKu~lm6gD^G*Vo$&H(@tI#c8XkD#OhHod_ z$qsP+f=C_PN)T*kuzoGcp9+HQ-%JbYdL)1EnQ}M}aXcic+)vKAZKB|i;bZC~I;u;Q z2nEA^XrbJogptoGO>QG}Sxj;&BIZr5Q08xO zW&S|kWy0H-8?&7-VOe3T0FGw&x*xNH1|{KER<_qq;9dweYllneoV=DG{oqJ(RM^bw zHP0?S4vYU8p{!y&WP~K#Rg*QIzxwK1M|0vaN0kXKB7uM3 zF1Fm7F`OOls2Eu;_?2!{Zw$bDi@~ZPJz<=)f}1)2PKP2^pypY^z^ZWVSu*25_Kv@s z>kMgBOZWDR6$=buhhXmk;auXVYL{+dM1v%t)yt6Ha z!{e*7t`%bDm%JQV!r%dp17>Q1>F#DeW4eaWULb{{%>lyyZ!J2;K{6$*E?*oZ>Bxo`)u?1RhMc~U7M>icj4e{4JQQ46t zWXM8%c_QsSBZ(hkX8-Pn8VuB z#qmrnEGM;-TpDLEb9s|wc|$hUSVp~+-sNFog#(^&^^0Ps@xxh1*(ik}D z`bNJ!KQ628+8t+mYvNOa9p;BP-=V;eJ-ca$BqfOO@oYsRs@_gqU}VMN+ZVxi6;(r# z;>5`w@(=IfU=#a+ekaw}_=?mt6O>`99KjcI+I#&hH_`a>UsHsmswq z7k%5`!;L2;1*_O~_A`ScU1KJ(k$X24H={nvIC&e>3+_ShC@+%D?UG}QbQDjmvw&=N zxaEMDrO{?-Guqe`rOgApgIbT$ePm#rr>C0PQ;L()Hn{wJB&GZHin%~X-s0@Vaymjj z&_66Bs)oRC-pl0%Ge)LAKYl>6#Kj!6wPLrf zEAuj_dk${?d9eTRQU>&U*fr&yd&b^>xx%jPIFO)b35~1~^(ZwB98`C}@B)X>zN&wR z-K9{G5`ngq8SRDxeV)hG!{&v4UQ}dl#!uB4s|w6u@7}hyi2gQ)RxL6(#aT_?uB}@2 zT<>cRPh}`O&5|TCqPbk)ycz{(wjs4MD6m%qrvFkx-4E1^S4#qG+fx7tw7*Yd@xe4E zlTG&7JS1pmJ|v%F8{;#c0u@1x1sf)+xs|{o{wSKm_fS4+n6(nd`UJhrv@)~TFBs^_ zrOcC&(#Z5Nsm48;abcwWcRG}2&7im@zk-KB{7-7%(?c0lVpYkeGNMGC<39M-L*iJN z;M-Kl_WWk}F%UvE#p8G2xY)7h9Fv#tAMbqcmcf%!ErQd$SO_c5^E_czXe_u5LEWiN z;o~upp|U@*-eT82?Ua5FjAG80QwCd<1gxhX$U*#i7DVfCHHTNfOp6qxF{BUL{8w}J zG0iQyXHdTNr=ZFaIX6P2JRIy!gj45OMtR^RDv(ga#?B1p-af=Y_AaoB*#k+-QT>O&fZm2O@ z(-)Znimi)9ExhuaWups3#Y20z(__>uoe}4Wd6bK&d3bd0Tt_gZzvov4j7GD?AFG}S zX@(s(TLB(Ii}V8l(j@8*iTz1hq9_&k>{hL$Ah2Jl zov@$^k`Tf7>xXg|WQ8M{IUAyn=1TiRbzF1i`B&QHnoegcS|!o(nn@qf=ErKOcJ)}Z zRCK$G!xg`jG?JK?`o6Ey6xS|6ec$1&0L2hdjh)Y3^u{(3@~+=Y7n6dT&^Jx?0l}kH z=l988@Y=ToC47tN59}S$Alf0{Wg054)EN>8KhH+QbE7%Nqr7pdw)@HMROyngoPive z=n7TW|Fx%S59K`?7X4_2zw0QLF6%+I(ghv zq1%2=fVQ1`z85D>x9zU zsuWI5(Ax;R16)a_j#?L_{ud(ejgXSQ+DEtsFk#x;!FIW-`wDh;YOT&3^k6cz*yy9gS_HdtvUsi-K(=qzZVS7mBoORj1=RzbJ$%$~rrEY@&J%qlyiuX!- zrqg{V@p>Q}Vf_XX#0R&}$A}eU7%;_mC7RK~zOZ#`lx3o1xwCC&?oth36+0RVLEuOG zgc3ANfA-}j!A??du%}F27-`?V!mPl|4N}g)6QMj}K2|FD?Rdn?8MdWOuyWQ5=N2Le zD}%th5UxmPK-lc1v9=}>X2n|KCEpF}^9B)Vm^RY#k(4u{166w-BYKclQAs>_!42*#lKE5U3=^RmdJ5 z+5LjOfPoq&CQm6psMK+(PZihG3R5l&Qku_CAV>#f-Jh2eTQ!K{FLU6?h+Q~eo&CrK~kNEVIz&cv_2AwMEM7SqgbDlNT0@-7e3p27u9n5Cx$-~vEW z2H;1aF$Tf$1&GwZAESD2!{UK7$!>(do9E~7Wx}NBu!;&q!y3A4eS;(*rOtm*2FF&u zm&H(!v8}}2V{KJcaRxb))FsP>mm+%>e=)vqw$YSiR2-#w4Mu6(G@20Bxf6QZj8(#G zndjyB3MPB0MnVhPRK|vxl}m#|QpCnxLbHc(>TT}^NMDAlsK!v%TBD_wx?@X~1(vtU zirBj}5q@UW=vcT^UnqE|R-?}1jo_dT6hUv#D)uLG1OrZ2_(JiqsArrm4%w4ngXa*| z-_;`QnC*UOoKmQ$Fow-TM|6m5Qtj(;b(|-=f339swl#H>$peAIxW<<4{JJwl%>ieR zwD=~CmPNtkC-BXBM9(EvmZ*~@o8GjbXUn`d!V{*vBTlO7!XPv%Qx|1p==dw)GHCuy z`lLc-AKNN$$kc5`&0ty<*mbWL}?jSuh$mZB`AI8#&RmDOe-6 zL8AhrmFY8#9T349RnKr({iQaO85c#TSJFzbp`TMV44>V8ksohU+Bkd$)U6`}Yx)#Fja9x)$I{R%f@J$?*GnjF*2aM8Wv|VG0x3t9CeUIN zf4aNM#DVJ`hVykF)rn0lB{$`l+eVW6b(VLkH9G^a26<5Sp*I>#IarZZe5-&Yc>iSd zHdfPSty;dAS~Smr51lySkmp%GicYFjTomfObJpvK+fZ#rCp^Px-5asQxc}NBi%mT) z{}+RaZ#}%gpy9QBKW+2L*vpw}3~rB3k9EXG1mNl=p+rv#fl`0}SeY2)wVaHDMgZbf zO$@J2`AQpn7!Fw1VHP=sX>VX=D?J5~Cj=gtJuBe(B^f%G=319ourPi6*g2z{A!eB7 z5vr=Rptr&mcPIFHu6dEZo%NT|(!}Zpb~-;cMq_mz_GKuivC<*k4z+BJevZh#&JmQ10 zt*yQd^!l?IO}XkP7r@iPweEC#V%__K6+*K8rs@y`neZ|Jupd8zP!O75F(HHGS5 z1Th_rE#gjG6o4qC?Lbx;j1&$h+%?u!;lLiZfLYx4yN8h;bGpm+SD43TRZP$DkBuPv zmNd!v3Z&JqqDVF@r&3kKj)rK{2iZ@;;@bdc_7FkQ{6^bvX z)1LMx^-H zZYq-i#kiKqTHZ@%RGsE`8fH`(?*(MtmA?xaIj{nd8CwXg?QBVTLCZNy`=+%!_C>HF zEI{+ffcRHgyow66R?{i9hL%*wsD$=bJlQ^MzL+bLADR7NP%`^|>)+Y`o+5TiwEfNg zX+%usV`@}UiUGroG&QWP)1Q%+NH)m;K65HvpfXd6@|Y)OUX}JI#ieK$qP}0Pq+Xaa zk|L7=eyQ6QdXmt7Gih+M$4#1up}eUygapU48pz^`F8s8ZRkqSY?E~wv^RyGx5yffG zOzH$MAGCEv?{lTLxMFsfP{LvXv+?W#^q7x{I5dfMQ%@IPEiwOF+e;HQn!R0LH|51r zGZjZ_0(p1eJkL`CH88LW1Y8&gye;lJSF4l#;m#CGQp0YU!E7JtGXvczP$xQ=Lr5c? z_jQ$b>(R~lE2vKEU59Imgx>*sus4TR@~TbH;o`g)x&sJWE41f0s`H{741dm|Bt?!G zw0)II?li@wc&7m~N3^iZ&^N+s{B_O)4>e$)4vsTRy&`bkGdN4{G!l#B@%f6 zlEeT-w83YVmi0wD6IqQRW3z%4<^q053NIDKjl}^UoJZ zKQ+;6(8Hoe7i7s9(kX-hMnux2W!qV1o;%Aiu&VtTu@ll>7Iv`vLAj#t<95x;$DG$A~o{4QxCgMWBGUV~fyRRhb? zuWmBCe$(Gvht-6Gc|q_&+NSIq9^n(9%QrL#V?s!95pfvvgc9>HOQ(i;f?c&!SfT>C zq%l0EN~4#bykNZ|R!p0XLI`=$#nc%TiCOJoLMfbgm-$VpD&0QY1`q+CY%F4MC*OEErKe1etw z-eDta`OehWL4&}u5S-0@va8)ACfx}?z!0-uKv*^r@|HAW)CN7!3Y@^WO@&9+7s(6e zeVXjHO>bX43RsF)D?jZ)=W!??qGJoQsu_7EtCRnh$$_rxB?bf_9h@Ju3z_j0W7@15ocD0Wgl%5in z(3l_O4xC8vE8M>pvwbZzp_C9f%g+I183N}}vR6%Qe5?F-k}!Q;PQG`KU?jmpeeJgpbmpcsT@QPTUl+>AGqG*sI)ZpH&G3Dylpb zu`sN{FE;+Z-@tzX?T{gLj~XrSgfXZ^$3HRZqtF#Ypp?p)s=uojsPtb4C1^FQ3f0&) zV*I>dlE4yEVIcy7;m+gd$;--WQ2M5fhPur+5LK{ws*nwBYLos85Zz03k7Rg6%#zC5 zE3Cmcd8q^$gE#5O8XVXB0SaJ(9Q#3oX?kMO^V1O=#ikI-=4L9v<|c2iUj|J6wq57a z3RtRelbP;WS-fUi-imdoYR7on%QL@-dvt~lY0_?FtM1kb(kTf37XU3l(!V|u?HL1J z!c8fkEOju<6jvm5IY!a7OV~RY@QQeEU+d#lVDYl5kbWkh%Bzz-QvI8$z-lZZ=^MntHR?!Ut|RF9CyyU{8; zZ=?Q@KA(Ht=!la$vT_zUamNy{WfpN~ok=K|A5;n$c7P|fM3I6b&QZkGW1;kkI>e`s z`SO#Gq-Jcs%>#cze5gJ$ zxwZvJOMWVDxo}Tz!(muM3wGuE1iBcbNgHizX5a>HZnF=2zGcZa46cg^Ka_6oA`&L- z?_jYPR&kvWiCt0blIYHtw|kx=@53fybg^a7rV1X z1pR0?FwM)tyyJg$V~z?BwH(GqbNw+jlV%5y&LzU z%N}Z>N=Cl*QmNBUf^>O0*ot?l$@OU}b1;~pqTuQgX@uIS&Dnq}!>V36tjeH>mbtiW z@~Lj5RXP8{SCljEf!fKnMeX4#zGYMD(9>aeQIjl1XWiIAvm)Hrwx$li~mD505^ zjHD#vwmCjd1Jni&Z;bML_{~n_^y%_#Ol8-|9saSCxGPCkc2eaffvy4J3|a4(BoqKq7I1T3ANG8QMq<6+fTz#v00WBU-TM|Ep&Xne>hCTG&` zMIg|=;Bs{ZYxi7%3wLREYDz2mBh(2f&UKbwBuD%UZmX@)^>k6m*WFAWgwZET#R-2n zb~MI1j;3B+%c279?#SQsYp3;i*f7)5;jZ0>*}>#Y$_gGX!~@kw_Ad{8Feh1dSZEcQ z>u86DKWZr&>?3&>Y}kDA>t~VJ3K%j;{t#?w1M+wI8cx=MKsKj5q)cHMq%%3#+gR?f z#5)DA_u0+v9uZ1*eBUU64`oBXZ;ivq=wHDu4n`}jsLMkm*y}@t_dNnG!d?~9M=|o5 zyXd~Y;WgQK>X1S;+<(bXXLGo46owgP%$T&`qq^!Wp!29Km?x9O5leDq;=xm;F$f;m z(5VGO_8?tpymGGjHj4mK^37@dPdiZ`cu+iqbrP25k=#(el5BLq-pRq47)c@>xfQ;) zkD3OC^a)63UCzW)xo(1yVrYcsqYS}m?huS~5w?*f4VF#l1RAPQFVyuz9~ay9<<%Ah zqT{p6V$%Rf#cm?0YHZ&M_M6Jqt;i!;kcsnO$F*C!huaw7k6@>W zEvj>*2a{@$Fsd(b)nA!LZ(17b9Egxw2FvseAyv>~S=81I7qX)Y-7y*_%`9*KIpd1G zZcH{b&B4L{u%yX5`m55VV4Mi%&F1I!7Cx5K_4di%B)R&E+Y zvH*gDt*@MtVodJI$^Tqcc>4pU$w!%cIQQEy46YeSK$CS=6Z)pX_-91~0A%0UH)<{- zGwV+sF-K*4+VBm7vJ5{63Dzu!(@Aa&;0A?aL&B)r@cNkYO~T6K5b`|1*qCu38B#-c4Ls&*#YtaHOgf2F^5GsKACEk=7v8{zluxy5^;nykya=B27D(3`Q-A8jQoRHoTK0}iSoX;lxMxjhF9?f3)uAm}u2W9Ar;s5{u{vNuh zYh!yt{%3u)$GY*lUn6O<_uqLDF;7c9@H9lBK`&iePJ z$2GBg!_ny;@Qb3gvmqXgXV1>xvm}I<5u1;Dkm*d7tqy(-QOa+}LUwU{yyW+omXg}^ z7-2utmSX=WZEhtdpjjy$p@v1y3TsN=koC5XG$R+TxbLg69XYn+dC)_FinZ{uyLiNC z8M0fi|Dh&#WaR&@>vRl^By)D0dZ%dC5Sqktdplv1y#wiX$OjINkj)D)cmyu>`nY#B zOa$(eU&tXcS4<9vILkRhFbvXw>ko=C$o1p=F*JwaeKN8Pe^VrnT`gPdbcQk>$g?}G zPQA*Ka>FW}V;$~K47Q?5%Tkfr-!-MWv<2Ufoki6m%oRpOxUt0V!QSeKiGkYL z8Sx4G4%9y76EB$t*1;=fJXgA6s&tOP90K<*El=5>>Cm0X-oR+! zK@g^;7fQDq9{B3Z0a9u*(gZSNb%A(jv(d;F#nycCqE;Jll5d={clBf7+B;m@BLf|{ zMW8CRjT9NO0p;@T#9rwJsWjbFu1MIfu-e>J(-$xc0CodNgZnKjM{Z)~c#EJ*!zVyG zPDZ{Q|l=an{ zA(-dRZ;1-AG?<9$m1X?BX$ns|+PKHUro5crPJX zv!2jb=^V7l2lBs_%t<*(s^Ubx5Kk3!z=660>vy0_@QU<1?B$gLSy@|xAuYUw=LM(v ztF)$i_{{V;Qj4r0rCzh4NdG-2La(zXIBj=xw0No(C$%#{z4V_AB7Eci%Cj~N~aB#nB=p(5kd^-3pe=@?{w?bK<^qq9guP9A9}KsUaNt~Pi4UATS;kU zH3+m{amH?CFQ?pPYg8IOnAeU(UjzwV#|k#!a+qR)#2%d3d>H~q-p3zIZ+SxPoj!7DUqCMR$ zBYOm0-nx<0mN|h%L^9VhQl5(86Dv|>yImSpxhBD{u)v?06!Bb8%%NHa2b8erC!%ns zsfNY7*RWCS9uzT+MJnVTnE$A_;;CT`tlkavyU$!bLow-r0&SP!H^oz3^q_$Yr;LWd zl$QXNbW!G;$Wwot)DO6faWXDWK5sCgeGf+c@zN>-xaU+)Pj2Tox}t_1Ex1<5?JYD8 z?n^(@2|7cksbs$!4(G4ulX`#qNl$9%J9vCJnpaHydj(Wm=c+|Am}U_~j3YG1f101X zD4T*VCDO?Y4!kQR0#STBAVC%c8pNDqK<&_5u-5ZkPH~!=!)Cp$0hO^ z2cw1T{S|;dQfPS|G3e>LVK8>!z}1J~73$6-ot(>fGzD_DaCX5`jxl)GhaD&-*QnID z>w2KDl!0eYb{0v(|H;_Tj(}-n#cHTKY(R(oPvNn$=Np9gpxx3%VQG+q!^}=5*l{o& z71*bSi3XRbVcFkA4=2(`W+y`LaSEXUU_@h0ep`#NdYO41a1Ro5^SNMUWOEaFN&*c{ z+UfOotGyus(m#=sQ2bYY1mO7(DQ5NPOg~V!b&nz2)+Dcep8vJs?3hU$t1DMZjv)DA z#R+M!kL?Y0$(g@k5{A+rI1HfyI{@OQf2DAH13UPc9%FN1@j6Qq_LVzC^W0^?lF$dr zQuw@;F;(0IfF&vL(fKg?W#5D7v+si}YPq&L2cDsd`U7cU|4Z#$trQC&L0Rfr?J=L) z@p{PIHRmlVd3TK6_^ob=(AwD5#ckX#a?z`F@FP3O)pKofcG&~&$F4^gD;yete*}6F zB~{VPV9ko*=#I-JqDuuIw8h-6O=*Y1?3*GS&2G{J{Qc#Q`Pk#3=aJ9cc#mWS{OwjX^Z_VP^ zOxi4-FE6%-j%3wzl%R@zVo*xN%p3*tNi)d{)3It1CQp z`=G|5s0gQZGztO>sIJGOUjw+&?&>8T68Vz^bZAX5PzDqJk7YXTBI=&?Rk}pLH7!@MR!R-&Og5IiGzhR- zOhchn^cvy2dafq_wzh11dCx-{2qMD(iz_5xUK1KC108U;hI89={jfF- z>S5@Tc7WH$sGoLlp5<-QNxCWT<}BWf7igc9&&yJlAj9S98eBxLOeHG5Z2Qaa$8&X{kRr%)46oOeMCEkoE6vPEE`+oL;@o)Dn^fJ!Dx|LhkRm zs*}iC$i&%58-6k?er~3U4L=v@bR%z*Z zU5S&GJ9(mJm+oYJWA8%kU|Fw49{)rFrG3?+&u7flWu)YSwBGj0tLF~)`;IqT z9|jgi%6g6U`R*;n0Ix*d(}S)+sX>^`V$d&?A^utYV?+7_`!!ur_#<6PuiCakud<|` zHL+>OXRlXwS&*$mZB;m4dlw#Gb%J%{i|%4G1!ua{jjse>pb4KfQ-l0>9m8WjuNo)G zC_Piv!(*8pvlz7A+aOL5KcI}aBE7oXO!os~h*G>M)cQh0g8u7;d1m`&CWan6E_z|Y zv?NrG2KXcH61AC3#>)1V!l?-0lBRE(5!RzhvMpX;RSF=9*T$jFYu&cZkz3iJxxo0ghbkX}q}Q$ABHe4oNN05aDuZiQ z86)ZFMraI}jLx_wfn61}$&pxA3}NG+f-ZP5|Exq1zz+5iK){cYq=^-GszHW8=6_4o zkffQAQ92Q5jsZ&2PD!C-CiYDtq$RQ(m3v&=YBMN{CwA+9} zO@FDfNAH|I1bZdmhu~EcsjBi^A@f``f39hfFMb3^!B*}nhcBl$dJD)im#Z?r{2n5n zH6@)Wp`NiGjaxW(HWjCR-ZTHgIBfl2*Vf7_7#;qM~LqVkBM<%6AAODf^B;+ZH#IcF6wzSj;;-(0ItqbPk zzpbiXX$F&l#vE1`-J~d8i4C;y%@Y?4vwQl7%L^$07qqZ<$4T(zXF~P1mjr5ft@Uvw zw1>I$&g0BKxfPr^;Yf54+3vcR@g^omI*88n?xE2JeWQ#2qAIY9!_q)?0KYBDGyz}ky zB977nWEl>ZFXbdUGp1ooSkxBrb-8&#NbgU-bS7NsmP3-7flfY1q2GLxtK(p%UL#x@ zyZaQ`U~}fM9>5Qrmic94<6y)I01#9uW$Cw!2|qy^Dv7?ClLj;i34;+g^{aH=;^|0z zQ&85FkU=yW3S@?nmRY4ft|wo3n!p_uWi)d(^CCX|W4hme zZ(t!#0uFD+-1>GLo%4^l0@OAFl|w0jni`fe9x)h^_s7aQ38gXn-NFGX_ zKuFBYLN545ZU==D9hb)dsJk9p;9ljYkj8bJOB>#jQ-YDd_%1zH@NT9EJXG zZ91wpxuE*OU7=rwUL=&|UbewOF39etReTLp0;=Q<0YPUuHva2J${dN-$btT%!ge%EZ~i!B0Eve@WkT4eLIYf%FD)-2A(wge*S) zP9Tdkdc>$MA4}7D{72B&88R{``t>oMk8G=l2elb})K* z#%u0Hl2=?=3Hn~DALp&V9G`Yqz6(;Uq%nmMDFp6aT(L;WwfeEtrTqdTTZ878657rOz(_O=4GIcrz(I z7Q?}sr^i-MoMX$O=kLjAQwzomUIt&d%aHMX^m|h`Nj``z_?r?-wETl>sJyUm}1d|K&)=sN3;`NZN znil@gVSnv(4I{0^Aiq*e9AX|(66WmQ~SFdnTE zO=)OV6UVxa1iMAE=;QeYLDyR#fzmxg=Cew#qu@gE&_$hq-^7VcKXP`5@r%DNe7kX| zqEkzxY|H8Zmn-FazE1uzhd(UHn12S_0X2_G(_wfZbRZMH?OL7F?4;oOfiekLSc5=Y zpi#d`O?)X9sI8TvCmR(|mRyXcs~Mhw{u47(VfccKb4AsFkDAV1q1%3}Ah$h1a(?+> zLP5WJ1~dw7a8@Elye#CfQ)pFQJKl$9E?ygx-fdI8d4?W_aRf7TqH=7If-V*R4q{X*31(J&US= zxG)=8<96L{S)K~Jqd~tWbY`tQqU9LP_CWwgj+H$2LLRbv9<@3DsONdFe|v3|l_&K2WNhjaC>iDj$aOoH+DMn38Qt7xWw8yyoc@{cAS(7| z1^D}!YUOeu=6T#}g<@OE|HAyLgOrfvcOFDH_6`aP1r{3Uz@CXez$438hRUe1&De6R6 zh_Q27TUQy*ds8Hmq)}-G$Qul+2;ox>%!J(P{`4D2OVHdAaxKyzhHfL0$w4k72R67Y zt}$Z3u+#gu!!5=)cST1kZ#bPgK_jCqESZGo04-M^UDbB%wIee&DO27@u#)Hxe73FZ#bcL!3t$PI6d85g$#!Dg)fSXa!RH$WF^i)MQW(@ee? zU>f!AOq{&!YrVE~2}vA$f(N!WT!hQ&omNeJ+^Sm? zo7os5F}4Kz#qzUcd*A`vau1%s=I1z`8Qd$kfa!C|fRzN!_UzVLjE{gJ|H|0KkL>+r z*g(rs_CN3+9sY3{&Wh=UB}@QNe7tIFhP*c_uBj6&Nd?fEY#sU)k@Naya6jbpwBn=M zBHNxj8^1&ItRo(n-Wf-+`hj)`USDK{{iB+rbOdAVCFDyBF2)ebPg0*4N?SpcvM|mZ zmcmkLU{r>-q#7MGuE)rVer1mnl$P-Gs8h4$e%Or!^Qhgn1D4uM*@vZg>O<3bJv;pc z2olsFs(zk1gpp3a)m-=yAQ1dY7B&(8k5S_72-zt(5mDv^`v^ymAqA=zD9Wx(_TCa) z$gaJ8=ew9V@%%&W1@{1SC(#t^s5p9`WkBsiB1T;%&c~Au-a^h=IqFFQuiVjS^|@Pu za@<|%#P`1MjtfM1)}y2i+tl<;_tA)2f?j1uv=IU)%UGqAXd5$xH~CI#+2%8I91SB zICe9SdR;%qXh&bKIKAy>xWVCw^HZ`4rxI11 zF>4kLU0VXTNnmlgR91Tt0jG@TpTh@J1A+j4uWgYgQk@`&1;$nr{8 z$lh{Ypx!eAIK`S!PGj?WjqBxYl2dk^53?mG5+ri~@5`?|P{O&t=L^P{BA5ShOAmWZ zuir8t?Ry$Q>VQ)Me=EzsBPOjML{nM=d#OJ^Q1Twg0phFSUY~DP$euhbu1BZb@9R-8dm#iH*>)Q-dp{2x-*Tts02M&VJ42Jt zAA;cx@?6avuzRh5=arB`E()N#zZJr{H+qG5N2o*p*&Yd&3306+5nAxDrPy`{>gRkX z4A^H)mwe$iq)OCSj!ypwDKw zn#q27;X6tA4nbrL(NKkQ8-%5#Tb~SD1cGJxEtnk8)#n+}iJ2*%9mJ+-ub}tHk|5M2 zuRf#uVIWcfM|^(gdQ%>z>qg5kh0t0{c|}AGDr#<@l&@!v!uEGOM5|HBFsFizJI@|1 z$mfxJG%{G}yPjGj0WY>oL+dOnc~k##{}%NnjjTh(Ux34sbx#vW;K?Zvedk8TYpx2% z0;tQ~2h4NvlfB!BPYa+wEH4^;mL;1eo=#}p0=INDUc#ZNJ4<7ib##Zb3$aN~eBCt8 zyS0BLi9`*}@JyF8jf&RZ@9h^@AqV6BNdqfqQSO(7!asUwYP5>yo)~-v z*FOt^i2G15^x^gp5ZDG7G)ukZ6&>)zqe$ap)Ee+O-rUS{P^)iKt}&B~HZwlcW4gIk ztZ+mli76Ym4VE|X?7hVrS_MY%*8T=7neSS^6SQ@0p?I(y@8neV)4y?J!KHurhWeO1 zgAq>y0DTUw#=iCv#2z;5JBadkO?L1c-#sY2!D63O&knF#}=2q{4} zEc;5UIPLYf2R1PNF|VGb7cE|yhSLNCu7%OUnx(7E_5xu3MYEZIiv2x`O?gvz;`Q{w zD#aqyQLT~r6A?AwVR*riZ!9I5Y(vSZp-bbq9X$rs1^b{AC~lOr^LI6Mr=)BVKUuiX-*=#x1YU}Te?t;LM5_LE zA5JDH6D9__=@tg6bN~1=MPQz>(tj+qpYJF3N;=yGD@T{ODaIQfM*@QDV}VaMy)uHieSS~gV8@a6g5Jk)+BA% zEq30~S+{aLJc12+^GsEhaRpsaD;OWKcCyJR4vsdPDOb})Em8nhP=F>T{psFTiqC33 z>hlt@TChWGW4k=WRXl#nE@A0&-dFh3z{-^SQ)hRhGr=E6oq(x>c^PqmlZfNecN({Z zUg{Ts{{CCIXxERH?Pn&;60eY5?B6YNYiV;1d6VKG ztCBUK%7*~KlvV&^%@ypsQWkn!rf*bim%iYN2t*iS?W>AU$`@WLPxO~2XUk0$EqA9km-jKf|@8~p$T&!`l{ zr-1CsSA<52j!zxfKy%>=>!E-EpF80EC*y||X0Oj&53!@Bg_OeY?!pJ zW=A^@lBu_!x7uJ9c8oe0RY#LOcq=T#QCrs4n;QQaXk~R!@Kdc-vEPGCVmR$D%u!V< zR;@kfYEMZ*0Qs^eRXx1y=M7oNsPJvZ&8s3KBev0xnJRN)akV2)UJNG)UpF}UMEM{) zf0BPGo%lte@iYbJ7j4)dyIdR8t(-Y!U`rX;0at$q?K1n23v)kq2Ob`ffAykgVkLj8A7TUB-n({m?`WUYmPB=>rxq^>e~T z*0^OG$}!}Enzr|9#}Fr3lUnjUf-@`c9D5vEkTuvD)e*?~1D9DP`w(*WC z2sDFbpP?0lxmkpmk;>RQ5TY!7=fmAHZC(cjRQ)dptF;C_#+E3IgYlTRQ{Ho!{py;e z3zVFQNzf`C!u(nRE`!G_po0wm&XnO1xt>i2Nd$IKRAuD!{jtUUTZ0Qdjdhf09ZWoBhAV^@|s2CoNg z0%z3@%T=0Mk8?^xFge@*E8k*uPx;ZGKj|}-QglF8yuV7QU z!lhXZ@F=i(UZIr6sfHzoMzoHXI+>={p20x5g2nQCNmu~p%{y!&_Dd9+53pOm9hln% zHkbjai@pT*bn?Jv$BhURX~{CXCs!aQpDD+-myDRSh;Gz)mZk=+Tu9&h48cyO%U!+* z45^vmzmK-*i4-GEma(6ciKNGWx-kez7NU1&45i`ng99F05p_`Z`&%mhEz$J=IUVc) zThi<4s1Noze{qD>cC&xMR=WBA=(D=eTQzRvf(FiuTK^J;28|84`tmPetj%T~W+6SH z?0j=_$B*6oB7eU7(2EJM)h2Vhbc-g}FBKx#9;l2*1UF;V{8%R5obtu5^XzviT&pmG zpX|3GJw{$1pp~+7z)a6{fG^C_b9IZsqSZLlKy5sk^4dnxhA{G-gcPd|lQ_B`HMWGP z|IO7c?B*i@S3fenc?g#KM*RM99(~R?3b#4U%)T9fI~1lR(cl2W%0^yv3MvAQpNqzT z<12|%-35V@dYxy;YLAh=;u+Z;wpO4`10WGlM%j;;nWZQ0mPP$}7zVfB+#~xDoy1h* z@s)!DVTGp4Rdb7Sj1Q;dkHSrwwrtPXmP-3*l-U^X%;e`kl(sUq|V zM5#>milINZQTku$ZOZt&>0JqzMA7`rfZZ zy=}E3g?@pDU}l+0wB6=(k&Nx$c~J4QiL*n-{{Y2Mjh+oFywD^p*b{S(A|q2Z%Imaq zKEm*-n(#C?W-r$%S(L(iYnVsLao`<8a}~>q^L05)@w~I0vF(FF6if0E5pm8$>9)_0 zrKCyqtu|j#+&A5Ey;Ib$!0=Il81`fP6;Uy)@-!6 zH=eto-;A^BHj$SDH_#X0Du9JHND{qyk7q6IdnHQnm{@G$b9K+sex|>4X8I#noIR+Z z5yv(Mpi<_Gu4LnaYyJ`#ou$E+Z6r-O8~{PLN~M;QK&Q!%R?&U^6nD}Wbx&cR$WEI4 zqFe5wT#ghD4I9&AE(ivNqM}i0&^ca)=IAcl4f6^g)bC zmH|!6smI~IcK~Zl1v-u>v#VKj_26uf&q7K7+pr%QJR(v`FPwGc#xzUq+JM=T{u_++ z5)+T3-DgP{PLAma!2BogX@v}4*4q@86w^R=As0#z6pPhURMNu8tFKC^;qI~Q#9X2` zs4O>!DRb|cXpS^*sJhM{<-Uz7(G2x%q0VyH4R)$f!j^DJNX<3(O)H%~i~_|C+Az*t zF70+siyzS4E`AC(K=SXJhMNn;apGzlu+iSG?A7+IGai!r5`OK0!{oFiGiZTj>Og~O z6I%h>3mDBoGPl}9jJSy%o(Z_3H2UeQ2l0IJUvIpkVK;k!hLq!^)`$p`QNcbfpiW0K~7TN zD%!e81nn(C^plY@8!;c?96V?!wj7|NPY_;{TE;GTb0&(0?(5qB>CTF8=|iq@jvV{7 zTGGbSMVQ5rct|C$(WrlaW(x+CR#u$}$YF0FF8p2brmcM`_*suQgdtZsgRMSB zfj{>h6zwX1#HL)qR9mlopn1l0kSjXZq6;Q7GB%jEeJ0qtqj!9r6?M?Fy^PoAfVW{q z@yvb~&T?da0OIKrPU~eUHASaIt7cs42cAgI#Jc4FWU0qg_VbFI;H~$IJk-w2Z(QpD zf_`IU&+DVUV&U=@g_eApo}jb20dq0TUT=6Z}XN?D{{%aZf9rrX*yS2t4fG832T4rs;#rZVEPIOE4D|mCO zsGk}KnO^V&ztBd46V4M&56TlZNosT*ZlL$L-RrP5Yx4B3y2g&nmjLXE0~8k+B2VQj^gdWbNFi8 zswgyqo^9L@PELI;GWz{FvSl$-E}y_+0DAg}etufbj~bE!_Hit(*9-^pt@RL-+_5^i zV@A%n-BQN)oYz+5Bgz^z>SG}IPX1+>sqk0N#~VQFWu1ZP*Y7r?64HR&%_Ze7k zLFaF<(Sb!-;wwah`MU-SQcTZjb1bEx$fa2+roIgYkNV{8%OF$3?attP-8ft>PjO^G z7Hik3!UzrZohRj+^gFE@V1je3?Y{m3P(agr?7}D!;kea zNP9~DlLsuV$XZGTkSN%yQI=xbrdAF<8jNuQ7L7;0V;hfKpbDv z2cq1td(S;`w~-M~k`m{D=L!nH_Fk%&gg!?iJl@i;d+ty@W3?LzCsi zEh&I-kP~`965WM6RiQ;IU!BxOvn8dKxq{=P3Vb_xZ^FeB=qmWW22WNP(8CXGkhZgp z?g$-kaF;VKy~>wXjfjh8`0vwWcP2(kml7c*t=jV+W%Ad)8jH4%5ee1%jtDMW;@(@r zH_?x<)rO>so{+f_osvBU!(6VO2>bK@)V#)%#Wb2}F_ml(fC%n!=YLbR9 zI{W?*m9glIG_E_C7Q}C9L`QG%xH`fBN$cnqzkbt!cq#zx0dI zorhXlY+NYEzZl}M2c>2vvX8&#bVgFj$i+cI|07;4Ze)dWW=Hk~;Ubp-8+U~RAt6v> zdKB;g0VGZ@UZ9Zgs)-72d|iniyyti9WJ}S`(QecNYK%GgdJtD|YU5E%HSTo{th@p?-*rP*cNi*Ta)|gYZz-2mzoe zsC26}nbfIK8@G&|d3S|VY?vg8GDKqtTIxJ)rRSlUYdBrGZjhl)H zLheSml`6G}b&{kSVeH7o?kl5`9vB18UW<8hfj?mIvlP~+##_Odb*Dx`M3+llr72=I zk=tp_r4k0LC=aC7G}DL0I61?|#z;VXan;Y+W|qzsoPK%$DnsXn1*fS47G_E-UWKG% z-d!5U5&*5ALpOvW)QplocK}&5U>Y2+vKs9-+G1m9ZZ28WR5O90Wu) zMdMI`*5QByd$3)K2#yYbY(b5tN7^_^KHf4)Yj^}A7!@+y0=@^*! z0WdPbZ7iv^vc_1AM6m`O)+dol7af`Zq~YTZR$$HZ8Zu1;QCvYRVH>aUQ4j;!vKc2KH_R@WQh?eh2gWeBlV!y)m(1z-TqCborR~!~WeANa9AL~L{M^j@o+oe|m3zr)jMZBAzE#8j;om;Z z=*Wm9{o8Q4r_P<^8&B97diPt#s6g}e%G+$J{2T#JR|$!uZ&4gDNdCcAKDJzoH;O*B zF|8*s;XbV#B7guY002Z}kjeGHNr`Jw0m z%=PkQz0zgw?MX3_z=|kwAvLGKuTN<7QgK1E&?SIFkITfsh7#hp$DYj_{E}3wUg`4} zecaB$z|Io3pgq=Wk)pb60Y@^fyvgvPTjnz(?^S2R_0=KaH7w1#ZYr`;n(7iyI~)-c zzPmZ^equb#Id4((fSkZaW;fM6Er@;Uta>U%h zP`%{>pf$)8^QY9CBG+D?bDn9FFv#6trIiQm6lMGLMgF^~Nw%^>yN{&w%X5UmnrCQ1 z(p3y+#=JN)=AdD=@mcr$Ll>@2G?Sb*zv#{7M)vw(nq7-7{sfmy4wyLdanlbiOGWpC z-o3(=Y~v}YpK>QoVh7L2*kO#he{y+l+Ll!bj5nO0#P_f7DLD97@`74;BTu>K#GZOr z^g*Bl#zkXk+46L~654=TI12VefON*#GEDqaj0kfVW%^kpHOe7Q1!P(!+s*P}b+r1j zn8gZ@>p;C$^o@SMe3E&9T{7Yeo}`e;tHqq~v=a++*ywB1L&WO`w0{{lV_t&8VVr|O zoFb4u%=jL9Lql=jUIweO8y*3a&Bt2Vib?{NrTlhnIfc*^#>pC)l5k}UVlHci8C;$h zyxG(aj}ZHK%YH1LQ(j5n&9>GDjGr0yD(iQ7G9L`)ou37&^?!P=f(6`E+*AsAIS(z- zYSG99a14w4e%LZo9!6Z-OH94~p~Y3cug>`Y>a=l_XHPq9_vH~3ntsHpv1Q20o=N0v zlkaPZ6{LHKb-K^88kaQbVCrE#=)0yZ!*H)JBf$2x*v)4k#x7-wYXhB+@^lES+HN%< zQ+MCx6?K`{uEd||NuJ;nNEExBBC>5%fPuP(QD;o8dZ*5Ohies_JvGH1F*T;t!=Ui| zZ4~GEDFkir)51JV8?Y|bs0u_|J!wh?|7)rU|x|Y_p8710H%z<(e7zU>Lmzh@CY%oHuk{mIF=L0a_;bk$QtzI@iMtn9J9N})Ox^Ly|WJ6{F9)E zphipQ7<3Q6pU7;Eb2jT_p0`8^1GcASk`j2n=~_Bwb>9 zjd$I*%o^4|VB6vZqyoSVhX^QSS4X^>j9K+CC)8@~P`Vx|`f-72{Gm8AisEzPdxY7zjje2)G-$si}4eJNHkA*AP?nQ84IdTfLPep^gxI#nnuxMsmYF1ttZTXt$+IO8$D!Et=rQ#m*lApz6)TxoP5bhOf;` zER^0eq(F|cpt0+<@!QDHCIluO;8&IkJu|MLP?1L6ipqV4K*{p<&Gk->XkVrl@v$K$ zZta%6!Q*MIBlBzH>bLb-10v12Pbs{X)Mor1HfK_Jm#{s(uoCHkpqBdVQFCoO0ap;Z z9R38W{VMu?MX+0lcWInkic_p%1Z*5PNdLDW)m$#7Os3*JBd|w2plM8Ancij|>@jnJ z!HWE)bzyVdwVHRyK4C`(dBtxHf@L5nZ|NT)5o*HyVe$!a7CyI7CF%ScSmECGge9&S zEdaiOz8q#Q_y7!}^yRmo{WQp^R&wU@R*!2&3KI}}?rY>Y+8awa`u#;qX_gD}fHcwC7qn6n z6fO6Nv6Bd1hDM9O*(pjVDy@61H&ldXwqn53;JC53<5L#1PxPQU~5gGo}_K zEF$n7H#v<}?2biN!`BLjiJU_@#I?V4De84Q9_89)c_zHkxrHfI7(kNe21hX5kp9Mg zP3>xbhH?M+)*fx&h2mUndreS}dzswD<(9()zhW|dk=QCsUfttQ7OBdrsRbhESBj7{ z-)XGnwe0k{)%2W#mA&Vh4e!bND^4{l-kh+2TaQr+G$j!_cC8p>miD~gVp5u2 z-f%mV6)#gBKsi49$vuMx`4l#N`YJ#MChOt&gz-~I)`b<9A>gJQiXq)RfUSO!Vbo-> z6|oktM)e|8{e5`TmvMFE^+DN+PUfH4IYaW7d25D_FScvHM;~`*X&+CZmkWn0G-*91 z&zdf%gXu8@@!v^{>kDT3kg?1Su2+f76;4r0n_BPp3g>;H#_A^z5y{ywoJ=#;M6X(!-LssYa5`|9LALXWWF7q=AGLde(8dWv>hh-2Inc|x=$F~S z{kL(whrw+ue+v`@>!|lV@qf9SYRB7$A4{y?X-l*%^G7aa zk$~7S)gYDzh{4XxHtG4Jx8m&Hy+as{dHsb1VwZTbMLjktNei6;=4!6)E?t@;bVzAo7y z(buzN0)(ds@DBbPcbQgBX=f=XLGC(-B(>GTN*pN_RROwgSnF zmI)JS!8*KW-&6+hrxk)B*Q?y|lL@A2SBNaySHu8P#%h!HhhW*GG*TsVRH`Gvcbi2BtNV3YiIxlf`K5sSH-$Tc*#G{rJSj%Scw zkpjZ{lWN)<5d&;eF%$Wu>?omDL^3k(GbrULboZ$_kXw|TDdy%)Lgf;#;=OTR`xMx+ z;QEdRV%l=#uRzb7arasYCV8k;MPBy|nh2XEnNCHQliHkUjClsH=Q*Xo<3|$k zAv`xXo+t^Su+|IpGbt!jA$*cYRn-#}prZ5cOkuCA(Q z{9^ukvEKJ&iGMC>f&tFeG~+It{bKfJZkFS}mUpJ}0F;$@HJi{$o_x(Zr(B9IVQwc& z>zA=IeNvI=8&?$qnE%h283(OHR`GToLv_Si49&Ps)QmFL88LoHX2HAE%oRGry&c`h z?N{UJn(3{Z_$F7YyXx@@gL^>hW{9bON6O4Vi|1Mk2x010nSx07IO@Qe^;faijQ^tD!xk1~RQZ43r;h&7O}-YN zA(=gbv7!;fHdLyG$V-&y#mc54%sF4_n*n21U)pD1m))`VD({QSt@0n)&Xb z*qXnU#h7(5Ua5`7X3u-5Dh^+tVU(Tc){CII8yxieq^`GhcSmJ7Y6M<@N1&>NQ3LLm z;CuZ44H|efi$(0h2pFPjs*~93bCI#o6RMS!3~P(55K^0~ODqPFtrgosm07}#c&w`( zLPl2(3qU$yR$gFLJ~{#>aLwrpqRNT7@t}5uWx7eQ19v_rnuSpXd<-dw$Qa2Kq_M(c z%AGJCTbg?2Oav(W#B|DfcmsDC_}awl{FOc4(4nS3aMumcU`UN!fFGX8_Rd#YvA&=% zpVj|qkOf2iTtIS-_kuYSd@-uG{hlrm;!M=Kq^W4O(V!#{z`e!K5o+dlVg)!R6b>4-@a`5U( zAebT?!2edoqBCX^ZR2?y&Op8dy2q`M?8^}itQ9s&KCg^ha~1yf zjnKlPWZZ_M#8uDsmiwe@RA5sJmere8Z%$jvEb2coFGR)FI4T@7Sa$#g`r8*jjcuq?#vIkC)!O8pTDkAjm< z&j2+Rwj>=y#A~bC4)KD+O67zZX3)BPtUW8OAYkpnfb`9HrVS!eP1RU$^OaS`Y?`zp8KU;sT!`Mjo1EYp-~0f-7E=qX1R@yxHiQsS`!*3 zp;CAh`88sCJ&<5A=(=@n>1fdi=;WlFwjQmr?VV#%9K&Fyy{gD9w0`3K4ysg#!=WN| z!i6Ixbi{CV=>O8DRfUpcvHX6&lf+WYdr(T+153eacO_aQ77q{!DGiBLBx8I5QT_?! zJw$b9d$YBa}>edP_dNNa{55|IzXB6i~m!z z^1f1Kk*BbDGtu(<4fIt+tSJUd4X$Zax6jm?G1C>D&nA0EB+;rAgRC+k{rb6$yK*xO z?RM@&r6xT_-QMZ4(l<@dYL!3gCntersIA+nIy$bp3DI^S(_uY?HhR#<)RUVl{HER& zD;;~*8J1FP)z%KNf?*d1_jou#72^__$%VCcdQ!J|r|#)sAYbaS2-##DbrE`57vHKe zC8Mv65IM8=utaDFnK`PAuM8%WF>gP@m+y<}kz(u9(H#r2Sf2bfYQvs;X`(m57Deg` zuk5a&(wn<-zEHLtShV?e&U^W>^(LP$ z{;W&)c``<;#A)F0IEei;{n43mjS5Md!3(Q&gS~ z<4`m0prS2$bEd2_unE2oV_^Muo~j=u6za=*6os;9OgOc)muZBTd+FUwM%WoK))l-50EA+77_;p$a>?dxW*pCB0OOW#BwKE zQuT5|w>$GRX;2K?O#+--PDEqn%`perRq&y@XAlyfm}VWkK%8(AK0JNjP$aL3dN)W8 zAyXR>Ynw*9&(T&ElZz;QcSA-yfQ5_00^s)d(+OC~1fFia^#rLkA7bOjW`YUyFgAL45w^bR zd=U^uv~e!k#N|X{^sQnV33;g@=Cyq~a)R35!~vlOwJEN(wF={D$D3dbBdowneMZVG zIMw_0EtybJy{=92x!7N~*&ot7)xNRdnzC=YKp{7V*dW9PQDg}iQGks#bnIVs0LV)Z zz!rU7Zl*qT$Th2>0@mZlq~-tsJj~d6-o4DHeSItCjccVU<;uAP0!0tvE%SkGr(nKz z5zG6~8~&pi5^N#qWYl5az6CSA{XJ|mmcp3Go0=W;YXg~^M8Q6cRdQBN#Z!d%-_`fN zopMI3G2<)S+^N!lQglCuhJ=IH>8j6_hprSpBf5lY1p6xpOaiy$1;jB1o#x`zJ%|zd z>ZQZB#8|78urSL;@A4d0XXZPf#sdNJCu*$4v?>Ko)?g}-=ffZyou33vo2;}Rg3(Ue z(v|gK7-=NV+}#ulr{);MP0hdDPAkx*>C|24BVi_(2}esxTgnhh`;=*xx*BF?;|oLf zi^+AtVR#Z}2QSb@lf=I?AmDcC*~jwU*zKS{C-9HT6=u#UWUda6r`sksHGvChs3PTK zvfwo&<~r$wO!_aTa%vKKwM?qonVL?CCu-U5Qy#kbW8O@W|yr+Gtv3V*`VRh0lJD zjD%_s!b=*;scdS}-?a}*LO7JdvTz3vY|CGzWg0$Z1{^Y9$LU*Y#C*_a8?*dGGYBn% z52!B{NU8&^0sxa6Ylp1>YSyq+~l$AcNvA+H6S0wJ(6n=|u_k_Y@7p<(674T;oOj5*n33 zusJ#_DffTY^;hYuSx?A~1>2&}Wgt(MuUl(ARqrOv9`w;$Wpy^9<*Kf@XT)mt@mLD& znS5!U`N-LJC6XAi+fcelhsh!Io1nfNYFp?!h#*t($3g(JUow|>*+>E0Ly-Cn?~*I+ zF+EG8?hF6`K@mxxJKo_2Oq7qH0Q+*4Ho%8)77+noK)IRI1|3YBvUS@oAC8_X>5mUs z(o^5LaX#k{4BrFbLCPZlU0PhPLNj|TimtkIvI`)7Y2gu_M$&WdRIW;6T}R9wHl&fM z?Kpm|)FOZ$y)1}eO%w>@E6(B>L3{Nfl91u4ip3+&*7gp1NX?ry{KW^vxJ9lkGISYqCr$g0k^9`EYI8y5hPZ2eHxh>agJ zl%0xUL>W9Y|D!mdf+P=AY3B2_anEHkvWOUL{yf!UI6H{)en{VI(0O+ZtxX?h0!^VKqd|{$Y7TkQ!)x*F>H_tYy@_9fzfAf|2XOYC#P)J9oGfQ`}=^qL|fp? zd$0JO1XlvL-C|l$^jqBuOvKfgN@Al}@HFrS=slZZ1Mgs6!1Lg&Bd*8cWYhUsoq%Cr zyaG@|Q*goyXgcb=^NkzZSvp~UL5|{KUITEOa=ak$bXDVx%B}b%a-4Tq0265@h@ZFC z0Xe&xdtysYL zc>Gq=tq)i~0h}Lk?ZO$m`Mh_(5hK6kj`Gyw;hY$rm(B}=n1k>BHJ;_G z{)O)J4l(@qB{j-T7Y#!9nsFDi{LPO|-=Ta)?D_4#!O?J}2vqSJ-D%QDo*Xd<^n`(- z@u`!8Rq$TUc)R@^7NPM8ps4#`+e4gV!Iw)?&g+8Oy}fD)J>UH^H zHMD%M4;$CC0TPM%5zJg>X zfw%1TPBLr90$I|21ekNxv%=`gCT9kJA1u9uvC(wGQr;xQWM+cc$we#8ao`xBYPja0 zEaK2>%*@HO%+(M&(QpBw0^0G$1&ypWh6RV``uVAmx!|C8k?gu^`zaiaRV<-41SgVzK`^N#PfX$wqei+)iuGQrzzis1V`y*9oJeT zktYyAV7F82`}mTIk-m1HPM~vn0PJxFu1Qr`o1gB_wtKzA@Qon55iRgBqJx}*SP3mP ztxa4=ud#YX{_w+&o5&a3x!Gw&e2vj?zG3$kk{3(fI%|wEZnD?V+>uEo)9w4{^iy{f zxtmrspK0#EN0XSY2`|l>+O~iS_nAvzG)V<+->(-|f((M#BGzI7{gtw<7qIl(Qq7T| zms2a^Zz%8=7sR%Qr)mct;WRyXXWk@5Rebz=fDO0B&*hUr=;c#zu@>^kB%lE#mz3Ai z{)gF5=9&75TWF*r#oRN!Od4;JBQ}*G1A0i6zlH#WHeR4eEQ67AKWwOGF12rGF_gs= zYsxZH969(y{!O5n_bj=FYt^sF6<^U`lP$BB$Pg;MY_JXqTb5#|<_K8Hp>rv49UEdn zUk$lHy8M#|MrI@K)~4eEs_aT%$cg2|fGLDXnaEmfYuFz>P}^ShxXnQzquFU?kS6#S zZYiG`GbOf1!0==x`AM%b=uTnW!@ypa`_ePi;(t*aqkIslCi53d*{%zwDr#zCL|3J6 zK7M$%0S9TU=IqT(Jvfp(ztG_Lg6?X7c_(Z{Cjf@syLH9H0{H81WgN+lwyf+CnS{O zvdKNkY)9M;1y0blx8bi2i}RO8vMv=6Vdw;gxsCA{_pnV>j~NNETzQ_`1h~(~yl`DM z_C#4_!YEvg&H-=ij)VLy=OQqmATQ2Wc0|xbP2Op9p%L3$)8Yz7LR5Te;}1>t^7(u=+o4x}=^9SPj8BzFn)R-fgADQABF&Ae zQJ}Tj2*o|T5Jho}Ztv>~m%5q8hq8H6{ZZK)B$JP1F|1>|smQW)YemnOH`j(|iFg1V zOQe!6=pkpBnv(}Bm;r+UC>e+i_mcECwOhS*bWp z|Lxcx;C`nZ9IY~);;;`ps22kWpYFkuW$L7s{R?D*kNO@n-2m-TvHkDpUU%n#0CgX5 z*UUR9N?Y(4#C@9x} z3Q1TyFudMVMEn(^;AEUq``@nyZZrw0472hPQ8Aag!{d0fNp*4Hpd>4oKTN!QA?cpZ8k z)^Sy0ttf=gtyCt1S^m0K+b0=j?XEAFTi+&S9PuoW1`WBAnz?Qpk#x^6?-NAU`#rwY zRc*a2j6bO#C2S{qR;X!MjBFU=lbo3BXH<+MR)+^&;>ycm{Jn9Gr%l-oa_;}*Id}iX zNDC-o!zD!{0BwIW>NClXfl}N0;Hp_{Eqn3AN9qV55VryE*k3@%4{!iy%)*=cH_pl~ zcenCI^z&PdImnwn{EHlJ5dl=+xjvX@W*@h!%_F#986YHeT@J^o(_@SUR*XuOk^9Zo7P+8Dx{C%+CH8=X6{509AG$h`T52ql`5`=RbJdE9)~rxMGuF!8%)vE9|%p#UJj6B|w?89$Y9YoB?e0 zp_^OhZVF){91UWv`&#MZb)~VRlP~b^1gEL+Y)f51 z#Bzk0og8sZln*E_z=*fiS*SfwMiKwbJh<9a$Q(PpB`9H;7_|X8X0a)HbLB6*(?z?R zY7Vin>88KGRzw_=Gc0|)%o@-{_9uQ0yss=an2B(>q`r%Tek~bRV4CY z8+k4&7VP2_o_Av-v=4+$g;59$yX{eKlSm7@i*%y44Cyr5QK268S&$^^w#++*;2lzX z(KcNmG}{gV5)A?8|Cb?&z4J9Fh6!UOsF(On{ec;GRzNRt3CJkmq)NrJH;AR7v{oAT zxM9yp&B2J_wS0n#6}2usv#9-Pm;WYOPqH)h6klD2K@a_l<;d;6hdwg>Jt#n&dtT1c zjvR!r!A7GZmY-=%FvheVe4l#=vcoAG?}CRJ)_S1t;BS#JFDM1 zJB*|}+{viOCuZwu2}^are`0j1w>om!x1YTJF{bZzSF<>FIQpl6>JcFKJfFff3-?0n zdNiW5wn;doi-MAwzY==lE^TsJZv#jck}Cj6+@dc9=_%*B4jr@=6VGP*=Xx*)UfOCZ z$R(#CmJvML1QVhWBVBkz&}4X8 zZ~~%g$uVqkOipIf*$byE4c1~?K0L1anO_!k$N%Oub{OHQ+g9m;`z<(+TLL~|6VpM9 zWPhR#d3R+?iRjEd`Qno6gU1o)X4Ie26;6?+HQHIF6r`RXtVFT?P7mpxSTQfmE`1{` ztPu_?WUzBibkZHv>DF56*o@kNgW}jGcK?LqPn7m9=4?PF7MVbl9K6f!j}O1YolY&- z8w~Sk{nlKlS8HG-C))9#_l+^Xvaxx5H^IP5^i4=pMidfQ#RLoL_z0=a~G@fP9qg`Q3$~JrewZ{vI#|^r1`?VQNwGxIeG=z4T@D1I~UH`VPqy~gX zI`sKK+>~M72$Uk^nd?4Q>BDUz2=ObtaPK7oKB>?3B591_6GtJ^n9DdJL=pfyMQP10 zzIEOfpy)`0K}ME!a=i_f6u#4#GF404cCCD6_dbS|BhWG9aC?Ly{uRP;EucKJ5Pa8n zPihtVogp6G8&Ylt2#+?8am_is%yM*ZSP+|OpB_}cQy(Yh(O~n=&e|j=Dw>-^W+lLm zEgrWd5l@e{ZY?MOmzh}c!aURcHpX4Hz^$yvxnja}w(f|42|h}-q{Q3Xf4Xs>L`cnh zos#HGehyajWT`SYHbBTs4+g5Y+uZa?ia_{gDk3p)SI`{zh#=s4_@4l)5RvrwR(6vZ zSP`4d=p!ug==hvIvH~h|BB<%{{h4b*9ySL$?05d>Wr*2I#EZ`07>sRgUQ?zjSlMB< z!Uhl@I#SDKG7tfNPq_d9_AC~uNh;w%4J4r+wGh_|+q z?PQkoIt$rs$N}@VN$EaWdzO-pNh3I?y(ol2vq{-2#5;Cs=f)z9I6py)LD|lQNDySbro)G4;Ah z%?r(`xwaf!OTZdH=K9iB7|ErEys8r0cfqZ)BtYUF*ap{h`J3B)ao%=3lsvp!i#x2N zso{7Ue#5sP9DPE!xxLD~zN=3|8bIPJ{jSfRcCw_SWJ_&64^B|`pT`XfqdjB4S?&aT zfp4;ChW@r!Uh2B+<_j*Rnl}!0R0Y2MqrJ?#xeFQ6SHfD}ENkNZ9ymA;0M-~b!iZ@Csslb|pTP9V5h+_vwC*K!OEOznM)w%cd-|JdX`)S-{sxTY(KN#z z1mWbMGeXCb=9rY2(KtqPrwsfU$RAZJrYq4x6kGNooMF5=jS%}(w%$DE<~CtXQ-hvC zly+#*8VN^%P0{`}!R@G%HQw@;Nh$xfDz)yf_hWckTmap4m{MQ)N)bZotYMUj$Hm&U znCO5MJEpHp6=9aYGRJ`M4h^OD)2ejcDv@k^poTrfHv|vp2FG|Rk;>f+#$tZb(+&{p zQwtLEeB>v(dVA?p{(3|L{)0AS|5YGhN+VpcJ~gCDch3tiQ^M1I4$TUy_HdAMMbc|L z3yOacx>QzT3$C~3Nxe?jyK9g};7FuN4!Kc?`)m=#V1l9KZ^&sMoqrq7On z6Sz)o1_8Pbh;x20xyjHNUfvm_0VKDPP%a^JI22JE>5x(4LWoRXLIs(J7%?R)Ddb#q z*b$?QsYfJuSWHo-n7vH=5`|~&W!DlJl_7Q$(x9xDGh*(nNP{M>1i?2P@bis`%8R6U zjvbTAYgc+BTqLkx7R@slfV$}OMIOYMO@G^*Hbo!V|I!loycK#3jDp1Rzrkek7I#q_ z$_OUluVaPj6WMXJ&|L@gv^7)N%Ss%*kz({oB|eyEn_GGK4;cxj9}@T7hl?iOEQYDr zp$us5o`gB%sT|%}@{g+|V4)-UII7^6Sd8CUzX~~erOg)4oztNLd<9Y4*~{UPE6V*V z7~?{OY+c|ooWUB!P+vCMr3Hy%r!$20ZZhu@@9<-eP**F~YIVnD_nnaZK8=ug2o&Qq z{MUiJt1$CcxoW(X0h|~9;c-ln@PGf#iZb-1Q&R7^15vmJE#1SRf!w&O*)#I+AZqZF zCCH0`x|j`LqM7zuE+*7Z|LQENv)lOU;%_&a*|rGZkVF4zjX+PNU#AF!_Bj6~x+UAL zKGg$HMqWd((Iba?ky^%eLHclGfIe0m&Rh>#FM|t@f%iad{mrh*J?9*3*BYoD<8U=_d-H1A7A&B$>~rcIuVRVIbA@dACKD2+AWMw}6u~hd?D6w^leR zfLkZ-On@qdzLjlzVlo=wOB!UytO*r`SP+iROPB_4zseQu+FerPtIY$_E)05=1vf(a z5=d8twfXl$$xotftsXi2PVr4?w&0>~&K!!uf@2#hYr7_vipO4$Dr{7Md}B+tYqILL zhz^RLddZ+1)u#d~j%#AM5j3np8IBco3bWyNuq9kf+Ir-gA3bYGfAB}qah0Cp9L>O2rb1@y*JiC(7 zZPnN;`RV3q-Va4wSh2q?eMZC)x!}ji1HsSFIB_kgH7AkskzHqa9={5DPd3N&;gyR~ zANy-p(6u4JgAkQIj1)4w5poeDS;5?t$f6OZdAaLj!$D7@hTXNlGHysjbxdX6b;YS}%1vnT{f;k=S8P(Zh@L^=uEQx41?2z|$vM2n}y zBnf+O-{M;=HF&w!vxHi{qdjH`^*7QGC;e*o7k<*cR|qi}{_9Cox=skvh~95Csj>O* z-DN0!F7DOHyE^>A3$=_`A*SkvHO~yjH0Kd;j zK!9yXE3GT05n@QIilRT~+d%#2@|BR%WwnDeeq>0(Q7yr;Ux)(25G90*jGbo~U;p_Y zqL&h-1*Ijj5Jj=)$IjFo6Q<*yrZ0;cd4E059E!q!#ihO$L?kDM1ML}Q0g_&{a(e-Sm=#U@=RRZiC`+Z z+6dvJgdnG1?&joK6svdIx6x+z=Ywf%MOwU#I)mu}2x9}e1~vowX?}HLN434m@abi- zgFM>rwSvV!<#88yWl`AS}SO$RYjbG4r)*4=XC>t|bYMpldn* z%Q~7McwxmGaynHL zO6M}IhnUb7Z2c^&UGff){*LUtufBoW!dSKbz5k>x8A4;mOvs-RLU;{xm~)TIk-fqM zp%xBd_^b%^jo#3S&_i$)xsQ76J^wA;TPEQVrG_C%6py&QOkfr+tw;5mi`Cp5#wBBK zG0+Kj07uRtc)fX zr*g?!B+iA)fWv9p`ZJikW6KZJeW#hwR&rHEAxz!(ga0i)^3J?KcP@`B29R?km5C%B zbLlU&FX)LuF~P}`^IR{u9u`ER>G2Kb>yYSsJ`b#9{(ah{ zYz$M+SxJpvAJ}6l4Av`AI{e_4y)qYCRk6D5cy5`+kM%0O? zc1v+}8m~TD6X(C7d;Oy>h1S%7M zx$fVqRLz3L$1|LS(Qcvb8vy$*a_jqK?r)P8UB$cUbqk)60xHyrMaEdC_%Wat{~xVn zTj%sRZ6Y`8sYs$f2C15u!Ff9zI2s!Se-WUtW&wFf51mf*>iLTFWAJ6`vBBK8IKc&5 zs{$#ZVp}?tYQd}GvEIPUe2_dG{nk=~7p*n80O1`Dzog6<&~XmS1yISbYQBM6m7;oR zCaQ=5|3;lK(GIBDG1tg@<1P)T_3(LQ@&j8tG&Px>`F3A_b&ixl>H^V<^tBf*P*~25 z#P$SjK^i*oO|>cS)Y-nic%M`+;b2v)ZJ52IR68NmYL~e*@p&A$d*QWOYGRoUH#Q!h zXvs#}u+wjtHks7$9GOY#7{xG!HMDitA|3Bd7;f z87qVUbGXTOO~i6Ccl!vtV}nhf$6zLW7|pkNMhD4ZQScv7z9(}zCw$(&jDu)fuyA~# zb@Ufa{x#Qdj$iAH11#%j*drj=?_xm=M?OUM4ZoCZ)bN3QKHAMA1!+ZAu&8!eh_F=7 z74dJne=RPe5lN%^9(R@$hiwga1s5Cx=l&XCbzCzduFxsbONqJnLVcgBow{JyOjY15 z9L!faQi4Baki~D@u!7xKe7%%INf6AbGM}8g5$8VnFCnLo z^J@7?&x1npr|FPhI2Gn%J5HNc29CRPZrf}k15Ks*9pl($fynv+7u599*1rq+WLWL6P zP-ry8t(J=^^eAzVqGK;KA(M^K;(^XEPiLjQfDFL>q%_BM*RMjG_deGO2=#e?>a+5! z0boroaM^AJInJ!FdHiw!?&#mh1A4)q>(+)$vl0~*6N^bXfi>R>!Od+zImPkbPYqFw z7mjgv+f~r7B6Ah-sxWCSrOO(ot)>Y#K$L;-k~0fXY~k-s{rM)lT`isgivoOf7 zwfWN)mFe2<`8$Pwz9=y%6#0K5IS@LMxlr;5J#PMXwWVFp(_|3s>t!16H5!KC%Hr|c z07d6<`8TO8ngMlQnO)O}7UJwg-xnP_^h~+iY3Ze~c0=))(&L$CoVdf0-gIFS6};f~ z6a2Cw@qge?n>#$OOxYWD2lD8eUs_A8YXHK=QhkP`C*k&|WJP?*fPlPLop!OZ7AX1crypl1N+=ka>Ca&bl^W|ifKC(4@ zg2~0Bu}o;Bt>}6D&RQYpw%_pBC2)B(qb>Y*1KhSas%k=(7|m4AuTcCCyLS{&;FP(-5?25P}6iV<;c4 zM7w6vNYX_=(eg%(a*QO_iL&W@K_LlP)H^_LYo%axW3jxYw(ImlYb?fyHq`(HN<32x zWBE{2jIke%=97F;_jx2^T=IBj@V2A4y>~5_SxqF5a|`z(=`#=7{DG1@thiOW;YRXk^@d;j zdjbk}?hxH_brw5_N~ofXw#tpC%I93U-<3!{kCFtssMf>So_!eHG|SiRFrW(#>M6EG z$+R+F(d_TDgipjqXg|VzYVNaZM9a~!XI&ycP z>(_ib(;Beq-3m2;E!zJ&uE~Y!w@xzB@1unASy}@wSPe4rt z_dCA{Sz4h*qMT?TRIDrVYlEO)*crN2!lDCW;2@$1&)lqZW$$yR8i#@dJ@FQ7uW6vh z2)AxaaMp6+anI94}TP;V2BdXxL@K~bGh>xwqg9sS42HVl9{j9S|oGZMe zQ3G_x*uv>Ax~c%IW`reW1!ywRO)=bI?ut;XHJi#xY_-AR28Y#+H9EBjKi9O?_pbSv z43J<=6%Cg^N#7hE6+ZfMY=P}(d_TBvG{P##Lf+7RXE$I}+OvY9Jwg~k9X$iYc8L&+ z&%hC~skcdS%nnm+Us!3A?Xb9jO{cPonooq^VXA+JIop%L^df&bBvlYBnBCI%gm+*)#d zan{pT1+_T$*~za+_GJEU=1}^0np-b>hB#bVqD8hj*@^r3;2bX)y`82_wTl9oeMLW_ zeii*Io{IA48Rwxu@(y$k8Tt$gG7{u0D(2UWIa=lgR2A=tsVU$>=hPiP|DP_@iL7e1Cl?!$5{YP0dkH=SCG`U4GyivqoS)|fg) zIoa1Z45*ky2pmSYFo7=Z+l(<%^0M|HX3BKac*i>2dF%X1x)cc>&b8mw7@JOaYS+Am z(;VXF2|bI5XSF|Ywb_rPr||>Q4%CNpPYXp{2R`g0RFy;25xPtm5Ro~l9hNR`Y@ZXv z`0o@V+DojW{Unb++FL*STt&08(5&Fx?W?AL79}tH!q3Y1${<6_(lIudP(P8PJ(p7W z1)Sg509L3-W2Bq$j)J9SzO>l}2zE}D;$2HWjoNGr{N4>nX`CGExNCDx4ia9!R{9@S zcTI3{ZCDtFBIgnW!7%2<$wnVBLRj7M+V%Qrp~8221Hh%6=1!yUc3EUJ1(d*vvtMj2 zI&Rk*xTaP9fH@C6R**9fyLv34+e9SvqRF9Y(thLE(|`;W9ol{|S?BSC_l z6BzYzGYi3%!yXM3s&uSjM{sFM1g-jRU$Zg_!dwwVDpl0`mHR*m3NXy2b@|YsWpnbY zS$S1tY5k_cjz#Q;j^PijFra~O1_$S}$rPc|gT1@D`lL$K!_N1v9#JRJ^2wP(NU!~` zI`9m4VqY{4O@< zRWhcQ=0W-@dxp>1;3aHR+%$Q$tOY(ukRVxo30b43?LLwQ3)g$H;Q_UMS`$$J(0NaWz{!D#>Fpa6S-=m$!4Cc^hz^-wr zYF2KJdh}yMfGNIP>8mX(0)?-111mTskR(*+&lbY)QCA0K5B0a=V5_z`bz!vNvZ%`9 z>#`cu)@Sd6*7zsRon_aS5msC^RQQzM#`n$IgB~A${-aPbY}m5dM!UJtx-d7c+wq^i>)5)g z=*smT^`AYl*UP1^(K)b7>EUPw`E@#s0m6u#P?9t6dSTr;!GwhR8jVidF(=J{$OORL zilD6Yo~I+BL~?evZmHlsj+%E;;h0m$l2a`P2;arnk3S&d{Y3t4zaqL&F z+`QgR4aJ-_n6Sxp1a#7h_#2g06t^EhGTQ0@QoWypLaprP9al;l@<{T-Rp~Rm%$-Z?>!+a$2%5#XL1+f`boV_4SlIrJtUWH$r?IS89EbRGhLWU zI@aOg0<&x^qxVXX>N1^aEze~CJ}t3T`VBcKn@*o5wif0+F05fOi;Ht@#fP~2=hWGG z-xSw!POJ3DJVES2pGOun&7tsE`~y4PJA<(!{8Ds^pBY%X^vJxs?37ieSn2zvxlt4; zXhK|eH}^>;`IJ{pwDBv|b+64b+Xow0})yI9Z@;tz}9|u>T4c@Bqt=y<}Xt zWn{#h*S%?$amzc`2#OxK6m#G#c$eq`h_UJRN0{DHK1#1TbG}N;w~iWlO<~Q2HuKM0 zo%vm!hER^t1mRPc^9?gTzFkBp92|T)i(B&Ef|aJ4%E`%z~vj0>X=LPEXUz zne?dcnWtCZRO$gJKdgJ5Nah?k-W{R8f0kVZm6_R=;f&IgR@RrQ& zsg(r+%gCVB%bt$I1NAt!=ZBUD?v=@qmiPPS=CHYSI-b@YXoab6wT-Nv4y-*gk2VWe zI0+q4#W{_1s00nd*$XQY&fU}SmQvFzq-vN%&cyr^!eEYt*zT)l9W>P1% zlr2|L-pCgS{{T%uvcI{m`&+b}u4-Q7$^>8_k=uY!f;*6~q`hy0xxSl5uaWm0ww8>? zA@W8BeY6q10{}L55z2K_XJf50vWIVhu3xV4VgC#F#(fGW;H1Ti9fDkv0EG+_ zSz>Z`_B|~CdCwEJE(80yQVuHtIQB`LL1YuJ4kUE)G(~wXcABG4_4P&}cn-AzAKMX` z+i+#|4%$8b8 zj?R4AX+o7{2Go}OKcAj!ZhJjYIN3iGz8?7Ku>@SAT@>C;xRyo2agfvV^?0t(%yzKG zmA5`b`{@?ynUoQJ?zYfB&nMH?wW$Bp=;(3?O_mB>7vEm45bBK zyL4YFT?ez`!LyU=N)$#qtGPR@@cj-DzR<(az^?91OP5A|4e4AVUa449GHIw z?vtK24yWQ%koImOUPw3HIEH3cCD!GKX3cznc+=^ z*5&uCrS*Zwin=Jr-UGpaMQPZc^B#b+1wBSR+KSI2K-t}<RP?@yL(_QZdn1?Bx0> zJBGCzj7oJI3#99!A6tFgW8*E?Xn^!bZe22%St>NG#v&rkZJ$L^yo}jKHnFANX_-HL zb#1m*q0V?r%;xUV~HKI&I)NT2Ou1bf=&gHWTS;AY&f}d#5Tnl+f{~0 zpq#|~8my(~9P8X`_+SjdihAR{GjClKQ;n^F%*N!Ngd>A%S{}LNT#=kix8y~a-SNM=dLhEYKs+|;w(GN1b`bS^{03Zp4uq*0h;X_`@ZtP_9TEXUtnLOX0K{uK zig+%4$2_v@Yxp!B&QA)viY&F2TVo=sgY#4_t8}Y+ilOIN9mM!fdY(TN&v!RB4r`q9Vlk}YoStt{ zXF;z9SrwnQ=t@|Pgvu*rQxQSPD3aC3Sk#r+1vDWZJnI1}z1tAuCEU!&$S0Q95im2% zdDyQ0S3-BO1C{eJCTMuX|7Cdv)?Ma2Gq(5Bh`2H^gqj+zskh}33TG}28N1zmEYoyiG zy;bTl|K~H;;VXPMErS0iARXPj%vHBCh-%|m2bM?q;EoYH@8AKi{?Hs2rM%Po8rmSx zSLOE0$F6f0OQ_!5Q(e%AkT&Vm$aZYK004pz)3bcphXqP8=_|mK&VT`tth)hB__5HJ5P3=0hN-X|MDR`LSXL*DQxPjJj=!W$;S!boC!ziaF&zNpteOhA zCVCWPg77$pp=epw5Ncx)EUv$=H9?7KaoGdm&|$h5?@Xvn2e^cP7iHVov7x;$r504Q zJ4;~m{sU$KEGpqCp`mY3*$LIh8DV53R$Y(oQ>)ccZK%RVZWx%g+h_BU`DW}& znLGr@3QZQ4X0-9embcS6GX7%yeQgG5lLL4B(8-77i7 zeg3dpc1?V2K4chM?!d0D#&*g1_OvzP1ji!xko5CX8J;BOb#P#Fo*c8uz<&=d$#!aU zU$DIbia6>hIC7cS9E1^H_BynkhvZ6I-pCIuhFV|8OHw zib8&<_lSrYV#3q2>c8+n3$)R6Fp5#J9cM<|k;GY=gOf=m(~|JAkzJXfKj<9vmedhY z*l*>iSA#}7*jZ~CtyZ_k9^73cqWB$>3pKJ6!`0#tHY1RuJY8`Lg`>iOyDwQXd>&Es z6j-U(0#ytWzRMc9LOq*s&X8*jXSF5^TU)v}B-MXR{s2}v;zz75_5oImk|>ef>hJTc zn3L0eT!Am5Y9jJ$#_A3`AiVR5RK{7$2BOr;X|;+87i9u^S-h|vig2q)&_Ild2ghTL zO`@c#x@(~?wI@96c~Np&FW9D*{YE0Tt%4#(W>Q?=KL2_e)8BK@i7q@Wz5MefMo?iIBBn?ybZE3|_ zioX~y%k9gheh9M|+N>>ga~0=oJ_!TM9h6uzy+;ty8k*T)clx2PIogy`9@%0hD-+3f z+;?Y+!xk|iDOe}|bg~#17!)Q$)ajsh!%gU9<(>Y-2GTdMGpE`BA^=yjCNjv%Sb?te zY;}~!m3|b7F(()Z>9U?_a}y<(bf^vKWfQmXs~n~a^lBdYvyuE@L+xp^5@C{>${C@Y z<5AE?9V{<%Ec6V_94e5Gv&x0%^Thhqjv;md&StkA+gS}2jZ}kkr@avfBF3L03 zMhxl_nbUGN;5gd*I)vC70`@;0cEQxCrPR=pn5!|H1@KX^@up^Ahbc5RByFjrS(|KW zjmS;eQG1KA4~4nHe+2$e&5#E8J-5@npThNFWp-b}F`g@}H4c!Rw^tO4E@NSBFEmrfLp7P}i&c_m#RjNX9QLkSefy6s~g#p{djx=Q-n!FbCB1Z&uxbl|Hgr|ase39Wze;oGe2sY5xwvh~pwWQg)> zW>Q^8$_nf1ydtT})YV(>QU(MU#*N^Stn&x);Ww%UE|WzeX(4lada&4>G_` z$WcdX-DaJ~>f^vti6tM|CTrgH_M9*K@h|$XyWwBxgF8&99`h%0VYY3D>y8S;OAWg~ zgxf8!Bv6&;HOLFz8_NJ02%fnX9EIY`;s~J&pIN!yxQ?90{$Pix^19#mbP74@rHpLf z{9p4Q5oI)>UFBu+n?h!rrBb}D)PIf2tixy30cSYfE$P_yh#vU3y$3tX2fRgoUG9o7 z0(f7TiVK!#oL;qnhSG7s!|XL;2@T6t3$&zBG0bKaLh9@KviLwRrA$EW^D5;j({p=S zlgtd1WHLZ0oZ&`B1uuAYHWOdayf`e%%l+-RtPa`3hMXSZ#eYp0a*seXT`}EY7b{jM zsd3~R#=e<`H5M%$5#ExrZv2uVH(^k5X5xcKPccCln+!G%>ybp8tsveL@sgjHJ8!H> zN4$6xXo+1KW!9w%aYuOt0>oGkRqIlwcDo6$9v)r0eghb=QnWgY3&kKgQ zm+_)gt2yJPacUr>?!5~h&T$Byc1==1W-U8(q$&QjW;UO%($F91?@f*0f&qtaS&N3F`wSf z2{*t0`-MljVxzxGR2dyBrbuvNxhg2jmG{VV&B7hmAAOw<`G`&}+r1zN!QHU|#gv#5 zKFNS=D8tjm#fEjkP={w$SS=u~i7>Q8dm~A_^5vZ$U|l3r+D`gorZ>)odKMaogCfFI zD~mlpY?A{%?bx7T&StNgQGngNMgIj!eH=hfaPODyP3Gc1zs zgNRum7cqcto_~k_-?RL?zG0?Ugsc#-0rVy#dmEt*%MxZGS=l)nop;_lA#nG6H>{P7 zWv=2Upae2Kho)&)^U_sI3q_d_kR1PYWUk*NIjsw<5d{9 z7UNMJ=?dH}|25PR<1;1ypwpoAD<7U(m2>Ekf! zqi$WCY5TqJe*ru7_hTwPDhU5*GZ3IpvCLG1(fq(faO?JDmvPb`LaqD^*6_FlGd#2J zSLNx2HzaWK7Tv5mOnV{!7{DQ+_V-e2k{+>ckjF4k^mepcrZ?WYK(6LHTPkRM{`jep z6YL$@C;$<}mvgQzuB0mR^ePKPrn6yUz_HS^PT z6`@v{;V7)q`MX7_kzY}i8SOkoRD-VO2>3|#8J(G_OkNR(E-|HaYc90#a2T)!?%zSZ zVhEbrE;%ydTRj?X{|cQr0rh7`IzMRH#^`)0kev*O#K36OOEAAHuWP3=X1-Zi>{vZb z`_^fUziU?|Ni*;MtB0XZwW8oY6v!$Hd zqk?jb{-Gtyg#jSRiQh>`Zi`Lu&qF8hm%=P7!bGSZq4LT0;PiUp;wdN1AyVKYwFrNf ze$^|T1Fmz6BbMKtjExs@5Y_-cQ`5^$^IVKI21gjg{6Zi)V#L>bd78D^KCTu)+K7tQ zm3%WKtB&hKc0eM(pad&(sKY9s{6f^_pIf#`7zka_?b<;7{&q~K@nR#2tB zo=$x6ej?EA0?;B)Og7?1>MNPceGw5g0c(#}sZ9&}7~rzaT@h-@W(00y!-}RxT8R^F za(YL6tTo>RPkbUb)Kj@5g9vzQ1V{_x{ZcZX%-lHCo`fWXDhlt#$_y*jrg}n)6ME+4 zE2%zD>@sAS*A7*hzy(vhLLC*SL&plNN*-L7Pik9tT5Pz*ELDknkXFM>#M^+1t`yUxE4O4xQSNnD zjU+#%LDyrJ+0+$x^+H!YS4)cp+yYTj=DqxEx1+!X+HYqZxOqfh2xY7|#*8r-lGW6L z{`TP-{e0TBPH`D`VkvKI%UtFqH>G=_MNdyXA%tTw{QoPyRiB995`DKVQ%OUt`n+2y zAP6YA92!gE1vt0{p__63`L8bD!QO+6IfL$Fm7Womy30 z0Hz|V1IUBRaVZA9(weA!bOEIvv-_Ku z&G6~qR0P|DMYknVGAyV7U(ijVy1@*!ZEf9w072n|#mxmO*wj#!B~)o=D=PpafLOWd zCne_&6`lg@L(vMT29YZ2+>19d`#0IeS?FAeYJX@tZeVM=`9~b;9dDIFw{z;;tY7=f z?5|7WPbhE@e0{31iMx~IgyX&`ioqys)R5D9=Q9LerD;UB#xn2jDGo74I6<3kGE11U z&X=LiF!mm8Kipf51yrk-)F%Ed->1CB#!exxN6zKXNTsN%y1^M`-x5!XCh0D@DZ-UX z%d)yxl@?f`Ffpd^A=F@zzw`1l?V>e?DOCAnV-%z}U2fRXw)=7KImOe6P7?D%XEIL-K;%?U!XUI3{pKh>3{T_WF}F9W-!BogFt<@0Fo zQQCCi|9F)6tIeZ8Aj_N$2j_ZA-XfP6TBn&!8niCB^$1Tn!w-Tw2#m#-7a&k(QM4sd z$5wiN4FB=#j?aab+IR=xJDeS%lwC4d9pW=b3b=gn;U+Cn7ETtS>>T*oc>WT1qnQU4 zk7An{Sk)RWAUca+hk=tE_xTEEQ}{(^jTv+-;%%a);X}h)aGC3NJ%KMgAz44GQIfCzKt;RyWwHN@3!LJ5xJ%#q` zPhX|Ia~F8KE0C0tg`6z?7qv+=d3e@;Yt`97m&xgjZLD|u8J?&0)$x3aj^Wo>g&pkb zOQ?Winr7?N*?sUsU`7v5Oi)H~Asq>Be_WStnr0fZX9oSR$|)Zk1})Ji1Is=}1&x(R z`KU8&>?n=J(Ej@~Jrs;=^eXzh7?$N;%RMyR!-`Z?i|Ps>2rOJUF|BjoBO)&f1k~_@ zEuk>$uZaA;Q92YXQmM_r&KDxjd1ezpn)T$}O*?RNex=&&si<}o00*4^Y@r{37OwkR z_%%g*tbu%e*gR?#$9%EP5OBp#Z8X2WV+n~y3Jc|rC;^mlaEqptcfSa<58B> z@AQdbelX~a4Rs0|c~&|P?Wy(~*=`tp0U^TG62PTu+;stw}y?iz`ZgKzTMDS2D$mb=%&$ zyGCiHY@!OB4`~x&(5Uw*!behFyF9dIN1GBrIS7V0T{9GP8YpcY@{MdFbzbOpZR6tm ztVpyNqtw{5*vMhak6sF5m8~Kz+i$1F-Ppc4mL*|($(J4?uwqva9E89yoGH*W1ztUf z&j~(aVgICsl6U6GUjz9)YN(8wR|>l>c9(*H!w*VTzqZ<7W8S)oFJ-snB5^9Jjqq+m zB}FLMt_OJ7M)>Qtk>$jigm}&|9re!cZD`pCRomFR;_n8^6Umk8V|p^|w5^jG1UoFj zHANT;gx0D>UER0McWuke@t4Z6x@xd?P87@;cj)r-KS%X(;jz}Pe2Kp zPIlj-R=6HitXkDgFklK=U;=OheBC6rqCswQ0YaSu3p-XVZgKzLS>1~%`g-dM(AbzA zNy@$IFbQ%5zq1;O?S-h z;rw_Vc_QpHzfye)94@^oXL=dCw@(wA4Z&pkx^{FQhHXr&{B9=#Hoa2QY3sU<&87;w z3{cG`3E_8NyY^x3{Nn#jmVVVe1z{0j7!GNUzA`Q|CS>*EC+{~5!7&lUi6uelXwYcC z;`Anoc8VwBp9ribYY=>JtgQ#KN&TT3NaE_NPE{glJfk~qmKa5ay#1JJ))@w>2)a0c ziS51)SL_6EVeOnCDIwNJ_q+g7W-bEYvE4DO&tzdme06zpX79uH;wzBUw)Ct8jEMXSG47kTV}1EuS)Cs~c}{FXTT}Ye{hn zO-ZNO^y%j9-%8>4>x}Vf0bl^8L{EGeXYN~_1mQOIk=P0ts0#|M2;>k7)2t zK2dh0AxU(=)5erFHaqQG*hLe_HT+jAbNiy{_#v#k0Bp((#CZ-p3rn8Hab ze@r+S3pqY~mv66It8@24bCf%Iom0&85KZwyCTs4#CUsomK{mjtOO~<+8svG(iZia} zcyC}xHUtU;N`Kk+U+fkJJ^7M9)F^MBM)K9TTi$-Qw4smj`0K)^M(<26-*wEH?r3ls2Tfq!UAQN z91*8BXQB-gE&}*NlcYEa?vKkyK~uKRR@zE#mNSL2exYY=V3@!pUeFSi6VrSO-Ppip=u z)Ex;#kg6UFSa4h0xz*S~>*CY^t8{HBPOwxZT(KXpuKIJW6~p=v$qrqZ1A&R-4Prem zY(T?m3YHWV7LT$zQ5Nc#$LPX={rbnBuB5U21U(O9Q$f{SPNYXn1Q^ZF$yshtQIR%O z0tXOBdwvwzN4DCSDX|P7&ldzO0tEhaeMIEbv_CncmO{a9ZBnnqcs2XXaiiZ8r-3*V zI^ea>)mQn3#vc>{R4{X>MEf!)DNOkPoWEf4tdmG)T><0?tT~{!l{wu0;C_pYtE}k0 zU98A8Z=6sYTqaoCWmdW8qP*SRcaGBOQRX4?$`rKHJ)`vp@;z%0BwG(F&rlOmJ#rd% zkUGBE4v}~k_(5~FSPsm&^#Q;gtJjOU>iB_cx;q9Q5wrJVmk+w?}lF zrztm%j>}LW(Z4D-SgRsEZxUl^x(W*c9{s237CkjnVSUN}B+*>yJt>Q%z($S)1$50d z%cB$YmH&9yped??Q)>O!A^nB4%mcE7rd!XGPORjqrwkOX*9p7ebr#hLSi0Kp$^579 zrkQ;DSWcAp1~K)K&S-s(!okqvuuX$fBoT1zf}*v=xki7zk!;yuMXL-?Oy}jjVJFzl zpL-9BU-a-6h2NTgfe%guhh-F&k$0u5Uh{SSUA`Fg?EYf8*VZwR=vIvc3Vx6nZ0w=w z9CR||$_X{u+j_mjV;%nX!COr(_muE4_{Z8OaXq1%Q#$J`987#X=uRe-(HPZxrmSe} z3M5!EH=O_j?#iULjP*wVuGETzx?ND?SO0eRj39FCHEC3)_BXY%JkvF%gsoc!v#BKg zRc=T*kr+?1ft}*Rz2qh{PQExDqGgiZTU}FbH7}E1BL?7dv7JPU?s+YQsagT6ffaad zw~2qCWrLK;H~?l$KZ#b}95^d{QKFC>6B3SyV-it`9cfQ;Z>imO%oss3#QDJm6a5id zwW52#m@1k6W<;?UtQv@Xf_+{DOw~@s029GN4o3axm|wayLqkGm%?5eKxjzW&gL~mb z>g;hYx{F2KS^v$ev>ucGt0zg$n8PZi<*KHQ-kNPRD0-ha0@hLWjON4Q!6%^vKf#n3 z9ly|yR|$iKnMNI=UMm0Z;lA1_MZ*%b!1}NiK6<@p@N{Tr*d@rT8jj`S94ND$c9XAB z&wwU>AGiPm#M;|Hh8`!jfO&DC048)!pm~;F*-slJI4L=!_(m;gY?mPOhRJpU4Oy_b z+ZUw0&M~Pn06j)`qvN)&{yK>qugT^cJE0=nL%5ZH-zM5e5AVdY;|w(fA~eI`N*~(~ z^Mg}%>6a^Z#n9*q4{@R#8FX*Ow2M9Kc-3IC9494(Q{3}P&hRTw>9p%mw8@2JY#;;7SRbD zb$M`=uE1c=XS#ocw7F~U7BEMgeS(3Iaj4;F+&OFpxP4`K^FuYVu`?=ox-a} ziP{vH`%nv|RIh9lv*8HwJ!1sQ z^R+Yjm^_|%wDI2jLr_6n_^+u#K)`Yh$r7sDFu3&Jz@`f>_%|FlYtU;mxhKrb4p?NV zpx+!MJcff1$eLhLNvR*13duk*+vl$c(XHG(d3ZX{=13F5wSp!f;OIRM?r-{z-)u=x zBgS(s*@ohG7x|=Ks+vQAXE_Z~ZnE@+C99#W%GMZ5U^o>BNmn%WXk&#Sj8awiG?#qR zsKv?l4xZv-_g0$I|2h9+$LcRPmGu3r3aK)Db&sLZfz3JW7ijqO_PSUpa4>m)RfZ=4 zMzXvgbo|$JGX+$YG!I;gkhDS%N`KhAqooEIt%MkOUw&|hUI}r|3uGQdg;IEl5stBK zD}P0)J6q#nODhR^grW#Wh0Ji~FQOv`G5ZRauXjAbQt`^4*VOW^hGJ&LZ4}$)-Drw{&@63Fwf`IQhPieDl{7Rr z4F|K@R;G;NzVo5d%@DN&EbO^I$x@8ry_{WiLHR#% z;KzCv3=y@zNJHNa5~1E%^$x{B=i!{{9)KcH|NN(F`ch!eFSvXN!_LQNj5*am{$_xs zU%U4FhPRgEz18vd2%+bT04x`YuJU-WK5_M|2_Du(@2#D)(DzONfI9@XQdnpo@NS8< z)AJUKTHh$n027E=zB(beQlfQY7CL+4X@_nee9rzfSLc7Y3`k&D5e3FhO0~d82M6oA zVCRZlnulE&&`M9Ym{9}!`J9kP)g+G*y+^vcpOr*Z%g6g%O zY{#ionrkHp`7`SQjBW8uW)<>LiiQy`)J(r-%XOh;Oem>{)ERJS5w5O9**4n^`ewgh zEIARA;rO>AeT`HuZJAQUuwSGKzVVQh+zc$j>7I`APVq(qom{Rv?pp*-^mO;$C+rVy zsp0=0Dsfw{qv5i}!v^Q-h5P>2z3#_$HCC2a9)%jOC%i0^Q=Bii5MD%hEov0N3&$L&rN89jy3^nViyjVh^7ZU`+wO|umk4ozHf&9P zWa|`;3!ia_g?-Lc%)#vdcU5ojP+zPEQp?>b>LWU{tvLW}F=W+sDJJRt7Z(G`)=V#| z!jQw7QUNRtmbt5vcNP0&7}-y}Ox|^Mo_mw;;SeS>H;^LFW#^m<)Zi6@jeDz~DtM0q zKvjROIzpb6SUS2HCKGe>O!Uu@?W-ZIh=gX9C z@*r@Bpt{2Uoy^lt&K`o)pP4&}dmr^9+tN1Cvdvo&vyw8B&9ZVXtm-EhYX=}M>W8ra@{P^ zC0i^dI^lvdqrLJRyptbs@Z5N-jM5crO8FeU0RX!YigY$rN3zZyEZaGD`Z#GBv}V^T z(0*d^UJG$y{Vm#uuwICFcE>wB4$2B`#~S<|&H=nX(<9%KXm!u#%;S_eSOj*>!Nf42 z^`-Dm4ks0=;ySsC8T+wuX*4HNva=|^F0Lq0-GlraZPg`D0pfLbfXgTgu7<*I8o|n2 z>Y0MAhsXEA2X6JHeXop0tx}IaCFzP-Fs6p^{FUs;d%AFa>z9>Y6aHsjRc5h z{3&$Zvzm`GEvo~bEqusYy%#VX?U;(+1~o2+mAd5AZHq*Pn;T-%SO(3v^t3U@9-`Oh zm&ec5v;a;USXEK*+Q9l!=Gy<|gMK%*4q(9pG=l)EA;&HHri^*JHkNz3$jf`Is-`3V z%y#3Ns|#)3ZY$ebyt$Wv4gH~-o*6CHAu`lINe@L%M;4LP#+x&vgr&xyB|6pB1}mCUtAN(r1{QeE zdgj5|99X}~mDt?6N?jWM<%kg!vMSOjrR7+Glt;DpWyhi2(~ZR2ow2IBMau~mp6w60J?*E^qEs(2f{aotjdwQ%+R(K8eQGsFyWiudXKJGd z>w+tke^#{dX;(-iCfr2QxUB?!N&icJO253XVgAuE%aflg0oj+_kmyL}AH6J>KMAk5 z${g>d@~2X8psdcYI}5gv-OLPAv$HKnCY1oI3p=A^!M&o&*7|QlKYEJ|(XY8wI3|No zPQd8rpf_9scA51<+Q23KiQIFKFltZ2xtHF46AuudC{=Vlli?WuZksOQ{;?G*9(n5fg=W6aJsBm}57p+5gIK}HgnrPuJ6h{#r zHKhq>;(;hbN4)Y3Caz5;m2O;*e_J}FL!kdTM~pBU(k1|02V^|S|IX~}l*PBVT;ZaF zEm-G-&3pZ>Cn&L6xL_jaiK;l7UHNfC6{DaIS~JTvI?ou5lydnsaxcANc&k6Z;Teop z$+LcXez7EJlZ9RSEmlgP`0zOJ)4s2sPf&Xkd^)p@=isP_V?Qyzml>Y0ihNw%pI}Td zKoZMH$LJn^7#Bc#m=>Q&^&(3^VVMA}50#h?5$RXd>?GJRS90XA)LcqOfnWdGEl+mX zp;$sNG#C32OYbW7^TQMaggkQ0{5pd!`~4!TkZ_Q9^TmeZFbSLf0PTfnQp2q>nsY(G zu4cdXeXvJ61SL!>9l%^1q3*g9i0C;G9b|><*0Au-haj$(+q;xyR=Oe+qb5bvTt0il z#cvq9&UGQI;|SpbUr~Syn(ylqMq#w5YzR4YRzo#6!i|44(!DRcM+Gu4dnVL;z~?0X zl3UgBM0(;YKdFy>bZp&dOOe(-+;*o5casZdJoTjzoBD|+JqJe)ai}`T0o;1#DKO zkAYpwv>L7)8w&0Axxu#(Yari2UBDHjR}{w3G6NKCM@rH#FXyUX35bn7e|T*U??P0n zMNk8+mx4qeCsGxf1yR=cyvJ6>t}1;*&xh_XXLPHRFR_5BQkkaC5EEig-jN*@7fNIf zIXusw;RS#D;~CRu`Mp3J0(3feKjR!~EeZR; z*%|e}(Ms*}JqR4ZSnnF-{A?=eY&RY(GdYyT5 zo6&`9&8Pr?y5~(GbT6CVjxO~sJhG*k@uv6nZ@5~Ap5~YK#60nnjoGh>C-HlUk*b@r zxSxdkkAiZlmhKNeVQU}~Us&9guWMjO$?EkX4mf1!XZO4Cu$Os7s6wY>sBtqwxSxl= z#;?`gYt*yBAVr-5L_y%}C^QhGM2xXS{z})LsOCa5S>wH>*IElFLCIu%@qKhe4FI4J z_d|fFjA?MRuTXwZwgFV@f)N}MWzW8j6$V{n5+&rQABhep5~w9ET_$O1{SF`k(QZed*kvo^&4H&5S-}T6|8$oAS|7B8(a~c{Ja87HBV2ekx=vue4xSp_7D2Pa zE%}8AyxfFjmG5$`XMu@!e zSM8lmz}R)P+&Dv+HJRR#gT&esAKX^;P#I;JVpFuA3LSQ&;F>w{fiMQlWb3I?!U=v9?FL|{k`9`Jc^>$sLoufyoG5Oq=`KS2x2%V%fPL7PZ=f& z>rf!Wo+|stiqwF-a04}}H{CCptvgO1KTySr{j$0;yFm8|^<9%qHOfD;jW&ftz}a&t zCeMD$Q1x_+l~3~7@UA2w`c$`7Pxbq{fJtmhvA%%YFuyz{A@IS;lcD}~P1u;D^i2K^ zk99$wPhgzDK%qf$@VRsGwE@RhEtacR9eeRdzP#MfH!3R%w6db5;jfIYJ`=sRvEE`r zB#@<6)0gZq(En1`?0`qe`co#8ti}>!f9*R-Jj;sZRzSJ4gvc~d5HCjP(|@i?9(?yr zHdq<2QNhU!U{~9tYJhM)ii5j=nLc~cD!N49Mc(xmH57cZjU?^FbUD}ZYV=Ev)%||v zui8I_?aR61H8N<@Gpz&tVB++e_;olt&mC&R2-ws)-AQ34iXXVo+Ct%~5Tqlp+MO{~h>6c>0=7%Auf z)~XUH=Bz9&0rmoy)b^Ou{DR%QxhhL>b3>Ai=Ad}0!SG+oV#V`s#6xCRKI?l}X;>ZQFCCE1kq zqo)>iDnN8m_m?rR#x_pi_V$PvE-=}GGDZFfDyqt>zeFCyf44U`ARLbjPaN`mxLmuR zHAp?VMC5v5F+Lbyv2hyb3YzjaXTkYuFgje@wyDoCHTEADes2q6lDbZik_rosd4SiR z;vYJ&O^7YV?lF+u*TSYM-1Vr*7F(p;|Irb%l3y-#m!hnnU1Lw)p3a6gv-u=z+0KJhgp74a5c-lYAkoMcqdvCINh3lE#@F^@9L zs0dM^T74b#i&K_YzTeYVO~Z;7*XZR;czG_w01Ximo0GR_yTG=^3mQz70acKkh!wqr z5#mj!gc`Z}W+`Ain*@>m&LpxX?F`Z?nVXoRE9e&|5Kk7kK+DtKX?Um4ynK1Bqkh)f znuHZE?8yweIP3Tya2M^tGS5GhDa5wLSZ38-wB)0jY-1AFnXhi34)&5$oilKURTz<(kXqXLUui{7!` zJTK5>nY6|w77zBSgH6jg@nj+miqK2rqy8(Qlg-TYUKi-u1BtL6fxwtib$keT#X-_5NS&$76-T@yTq51sI6qWIjrZ4N%&xtD9 zO+B{85)9K|@O_-=`F#uItiK@+PI6l2N#4|d_OR7OI4xOTOqq~=XiFto?-;Pcm|ez- z{z~u`T13|4j;|RaM(>y?`W}OaCeEOFq2yG|H$8CXzGYMpZM}5D6V4iwndY+xM=+0Ru94(Co%~v&dgN0=|y! zt98VrRktlTBnw!6<~pO}v&83I)?^oBkFH5dC+r(yMR2NZSqTa|GWsh?V&Q z4VM7?VpJoP-@^7Kh0G6?HW27&KM=l}?@#!NE|5-gQ$e+ch4-^m5XB(ii6r&}E*80! zEQxD6t+xy95zDmt_fj36HBn1qj={VmUp5o*0=#o+MmQm=xAEH1?>aYwx5dD%-~ZIL zdb6>;CC$X|&*(zMOO2i0zrmXiuwRk?Th?Wb$p9${mcjk?G%t9ljH-%RUw4n5B4g~sVJx|JKhETxg;=I+; z(&`M+x(fXX>wHrxxU(i!irpr2(SyQh)ibcLF+`y@Br@kQOlyqnu-~n0G5`$98m1~{g~Z~D(pM}aDH6S` z%)ZM4{qWbBuRrj-RQ45K0X$tXHp$7R)|SgZ08Y}x?Qa{EYB8v zUUoi)XGoaDK$h@DB=@DHE%m&0QvM4T2xWZ=o!T_{ic?S}{u}f9(tGjh9GUW(l>r4j z)+}`kOG}9p5Jhl1?*^fhKAt@kFqR?{%t!h00H@AuY0OrYFTfhWtk7-dm7aQ3!Ajo#A{c`@og54L=QSj(X3%ad#8 z`QnmP6X&IG;)T!sATxC$;(Ag>62k6z1fYn}hB!_gL?GL5&wH^!x^K;3!;M6b1a!Xk|e}H2MN3w;ez#!a|P&n*=(DrQto0 z=8tA(^YS@W1yg6Q^87xHkFt;&70?6F-h0S-jSj45vJNihkgs! zI@>$QK)opWZdae`u%CQ9Kx>BXA>^8hf(m_t5`N!<&;#0ShTzGF`IbF4Y8V8TqfCG~ zHS#v4LeZulpO|UC2fPP-jfD0QHncs8j8D(Zs(opoQ1(2YV(C+(&zMG^D<(0p0vCig zK+RBEmO=fG({pybAJ#0ZPvL|Dt^EiVbCkY{>F!lLfMm4CzwC7vNMQg3^scJik4mwK zJ5a&70k`*vA&wa1H38m<`A|>jr~aIoP?cU}XHMV2!EV**dI!VJGzZ<(lMG!VBg+S1 zqkQ`u2;4PH8_pt*uvG&kUCj#@&YnzdaeyIvcxyT{-+O9MF+|VJu-AGmkSN1A+O{~4U-s&G*|0dK^(*AglXnnZe=8eqvi|-ga;Qohskh?QPAXbO z?&K<=-6ChP0Y?jG2!idndWMPfEr{MFUE@)`G&wczQZ zG(*z$yC1Tb0^*5&&F?K!6?99Pc0WVwP91=5=_-jY8;}CK^CWy?O~XzUijZR@jiof_rfa9PjaSFS>oO%#Xf9GL|VxUzZ>1j zo542q{ChA>)f^`6o4zCzZ?RgQtsYYC%n>5*$T)HziFp6jz+4E|X1Hg6QagWqC^o4# zgc216hnXjfQii9oo?Ye|Sm`u-`a-t^79?w$@GsZ^tDHw4*Rm;H&1WJ3T%L~3%qHD< znJ^Q2JnzwZP7O%3*$f54a0+~@Zib>h%D6R1F`iCL*rqFJxbmUfdnbiYtSd}+wvui< z?ht!;mm%4obH-%!Y#V{F@%3?08m4?~uvW-cyB)1_$MVh-Nkq?m{mkVj&PRK-tgRUn@HJh7Xy#V)C)T~3ei)M1&|$O5 zoB5F}s%YAa0uV(0#=$=bN;yyM!&jyv3Gsnct&+U)LO#71cBB~|Hj7qA(cGdvxccGl zKym6M*$M&OO3T!T>a9NFlp`4)03;Om**OnxjotuUs&6o_lEZPJ*q7wur0M13h>%yE zeoAHal+kY3@kc|lls*(H^~gR;cI}K@@?ZeQ>sxdnTnzir=mi&c)YOg?JoygC9HSvoq|Elo+oy2zUI++6}-CR?XYm)d$4KDp;xPDej53KS80%K)_36jyP{Dpl_?+QyqxVTz;C9;sVNJ1lN zZqMKJ)MN%l9-k(-FL=GFsgGwf=Btljab|!$G-N|hRUh*TwfK|iT&X-!XIOOU`RaH&DrG5$!wV;^tPrmK zN|$wycW+K_Ziq>Rhwaqc70gizXc+HCTvT>IO~wJ1 z6;TdJE9&Ay2O5RAtHeiUi+x>dJd%B;Zn`mwNRC5U4FeA>u~D}sR79y^?W)0NvJJtLcY0SyDgI`1vn| zLBlqs?hS%U_ivyv!)yl*?i}&^oi@65O=`%eFP`FklwivMmWH-Xqo|F0i!y(%iME%f zt)ng4{ck;kKOT!ToTEEz#ZDpm3JNJ%+p(+Sw*vqW|DHZvK<4JFlkeh4t6WAbb~n9m z6&tS#A_>Ga9#Afiixao#Bs7dWn$%r=w}s$6A?!4<>x5^o{zKjG%ZFpD*0~$ujOv+` z4bRv>?O7wt1TvGxQ!-Cgv&bu)*fAwD91 za%x0J{TRVxap*zH6VwrFD@Xpn-_&R!r{w@3UwKx6qNPGkXWl|^5da9%8R`f9T4Cy+#ZTesFCanSnE7s?hO%PsMF9IU z*)H9yvC}V=>*+7oVpw&H3tE%LJ{tF8dV+F~N*-@vw?rQZwo3apjR9v=jLz0jJLCk( zBGSD=hxsHoknrg6%D6_H`S`7*QJZ{R@!j?5CrF9fs(`wgGH{Z4x?TqBmX^ZHpRaOD zpe&%H^}lKDKp19cjmgjpkh}JNAp+;3b(y`qG<9;_5}ODuzHf8#-%G>H#8a=F405P&iWRT4vvgY>bJ;M@O-mQ8-Or&B@+5|pM3$1y&@SR4(7Uj$-KWe>#_dPxS?b+JpR--d<((7?5w`kY5`yr18uH{45tfQ}o_TpN;Y zcIO^2J6(~&1ziY<4_1n3wG(Oo;9Zts3(~J*UvsWmyB_Uc`({4GI;sVv9x77>7R#0a zX%7;q4xUE!9UG)~nad_TnJt?Z`w|)4{{zn}V~oZ35wy@e`M{%}mR?|lQY7NVs*)lU zuO;g(_m!0XBK!i(>}Tt|IR^RtAL;lRyA(WDRd$poIB1WH*jo~}WJrC7psY69c{4^k zhgPB-)XqMKcT||ABQnNVNc@@%I(P`h>#(71&ZS$(8&Ah!oN4ww4RbgODI82D5EHG zqMdorYIlyG3)Ys2A^<>10qPsqo{A~B+nK~_&q&<8UndhZA4yqE+ zbTgyw^v{my{8*QjOct2_6p-BQ#m{f5=y`Vlk4Vxd$cj)2drG-Iwm~-9Hk_wAHh}`a zd;T%8u@!3ORykz>E15}sI8rXpbZR+=(b&k71cHBLcMT=zpurVue~13D!X&Y7F8d({ zzc#F>w8cgqHDg$2*^$MgvOnVp*TNqa@|q1MQ0KpopJ&H|4@>5s5AHr^QQ9caxTQ0q zA&C_$^7b+0^1I=a_cBzksIYVBB}wb04ya_uE8eA&6S?4E^u#J)VZErM|tSv>?bI{N>g(ml8ZKDO>s5Xa%i9E zXc+*p`1O!tY&}D#{-(yqA1qP*fYUmzpFA1$Ez^yZlY4Nppm|U4SmI`bZHrX>LG54= zbzMPskAQkeWj&8o_k-@Xy^j7mRM+&%9I)^s3<@!9rXUK>v6Bp!tA4ms{;{|sp`LK(3^3Hg&6TXJQRxa1@ z)M=^55^i&uqf++v_W>wQ8UZQ23J4hc6n#?Mc{6&M41>=?kRwG_w#*ZgEsRW7kr{s zEOlQ^v8`Ju9}5@`+pQ-&`cNli?k{S3$XxR~dTBh98|O3VW4MLSQQie>vpDZ3Z_4=w zGvFrcbpXe4J)cn+4g-aGLK&W6YRF`xTT0>ec)ZWRqUUdrT3lW=-BEbwSe@BjiZ&%r z))7dloAOInr~FgbT-9u=TPPV3+UGXgELQ;tN|&mq#N1EUhBm|l)~Gh?ctW3Hr(>&o z+fMq>*#UO-T z$$4|b)D>oWYJNDxSCuQKZjW15q3>&jcUA$CD1U$AGfQx@p!R8Zn?c_Y-1 zJu|Q8au5YEa-AaSUw6M3DD8}3z#X(G{h?!Y74H~2e$&jim<4qrbEAs6r1O5!ub>6t zL^<=5OfncHu8DZ;z4yu?rrCaHS?x59D9cjCu|?wkJk+>#Ud`|v+LHcZq^O!R z@tMlY4#4a+;)IFu&nnn8rTqd_L4<&O(Jtm^0}&-#*N;+IohJKoF?b7WNMn9$l6g>!PJ?XO?xwcNAy!gL)I-p=X)XDXMyJUGbo8+y(I08uMeo6iV%SWpE66_QA;S_^|SS-TQ9DIpX|Ty;R1T_K5d4AQYhQBC2pW$MeF@l8v3y;_J!z+>hW8D7*fPL)t>mp z6T{On;azguDM+>bmqmGZ*STj;a;~EN!p2YxW=;=O2r@&Gx|Pzl_Qv#mTZ(=q&WO8Q zp{_`dhaz$FCY>m&NBgoRc1K|j)Rq~6O!^dMv+a#!pMMr5l8Wv`GKhS_DYrIT2y;8e z5DJ}fBYS-zkIW8wWpn%TIXV7vKJHi*qn?l!tq{W)~wt)xu>gNYPr^M&kpfFP4WldG61@<(@LmEIM z(-B@RU#SRb8_ng{>XE5p+X?A$pC-SomMz6%k!ZRT;M?wu}J->10IZ|wXI z)amc)iV851A_37@4$n7HGq(UHJ;6#Do6fip#!1An#mJ1Yb{}ZQW>dDZ$~?HAlMO+u z;v34>iMP%5`gvi$rF6oSPNM!v@Z!n8Wn6Vt<~A|T0iA?Eg?jv1IyIl6uFf{~2eVIs3ohbUA<1!#&0-P%=uO_ruDp6=jp7A%3H( zHN<@-&Wd5d9eZl((qCoo(n$+OBoe_Y2sLlHx+*KeZ>^4W+17G<(aN37t=ZW;j1{zI z$!6X1*+ZN+)Q+!x>|`no^*u3j`hJgLvtLaMo+5%q;LVsKMA)LdT!u8Uyj5@|EZyyAYv(Y+nn6|gEh6otG%Tptp zxD55fN=NYZMD*>Ag1MzB{Zyr@8i#ues%@fUBmbyc!fs{_-P0O?$fPiWY_irs;Gr=c zHKPj^g1(2SSX!Tlyim~c&jxMV@7sWqmd=8H=J(5qodRHfquu@JfRrIX;!Tk{C)AyZh z^=TuqnHMWMye@LwA0E9nlxh@ihA;NvN9fDi(PI>1giEDh9ckXDHECkG_=L??*01o} zk$QZKfaM70Y8dkd6O>bd-6`D$cE5xq+?am60O!x2eu2QV06$N8RjMHIx)y4xQ$JoR zj+RBabl}ykc+gpS%VYY7TwUOCDNm4}#Y27-S$AeYHisEd_irm`$+p_8M!P%@FCu|C|!nDP`*dsiEj_QDapW&hn4q&oWnjA=T)-pCs;k7);>arwPhnV1_cV|AY z!Q`=Tms0{GRqeizlSA;knAE1k%IBrLpF3;CSvfjs1UjoWoXLi&*}WX|`IgOho9#Qx zhUJQ2Q}e^cSwlgyKB>@)1%@0?k}X#rLJF87V2|A>za7!|w<%Qs7*}rmu~}xu1-{m& z|D0x{^aT$JP*6jz0CnxKtZE6{M6pPWC~8U8MpEvf&_aJBQKzYpi-ak3#QF~lPQLBy zcPVtq@^mMW{ZZ2*mT3gu{B9-&b69FopokTWVh9E=;DX=7D?#9IxRT2)iZV{FuHJ{U zf*%OGIqvB~@Gp2Re-7VbvTfv>C8+F_N;?U+^im;Q;{>7CM$drU2D+u8@3LU|&NF2I24O=K8 zmw509cf$NBg$}AZjmGqIwTnx2Ii18L!?uK`;X^$r3K$U_hr%7J8*U0g7k{Yd4l^^r zij@l0T0@yW&zWmJML-M9X%W=jbRw>(5HcUGw1Eu+}{I(EeEdxn9A6 zvD1fVEcrZ=a`5pp3AaG-XZSp;bi5pq^{;h_o zf(t651&3O~6K3+ynB~>MP`p6D%$7x#KUTfv}2V*G%X~==w5N0J_XoJDV_^tYIdd)TR(P5s2TpCA$ z`h)8Ti^P|5;hkHp!-l)3^#Z>|l*(C`IyJp!u6_T^5-zLs0sbm~mz+N^oE{7mE;7wo zt|7;CL6DJ8%JHn%<6Dtjj91%DJ4o_~ zYjK_3QX_S|(nXT^%P%!wg%}wxD74dxmhB`4E%Gxqx=gO0!w)Amkg`Esx647WqD{lD z>*uNmzt}dt8s10)bzT)tQhO@kMSoYK!Hh!&V9#)@#{USYuqGJA3yPS*GceuwmY25a z)&+EH?^QK2CCGKwmMZ> z{9t_1YCUorKl)wQ9lOwpIV?D6JGR%@tM3B3tXe=XuWOAkK#yMLbrpXyMxYR|eh?&w zgEj+iZ|qPnS=dAnR}jBzZ#XZ?j}K`f-#&->A_I(at62~!X7zh{_Mbr+z>;lcKz@`6 z&Px!cl<27NND@>8=7ZO?PG+J^XLQ$FZp~|b><^!5d{V)!ho|9&atBh>{=?iPE{k~y zOj+qz%8;>NQQWCclV#KtX0bcZSYN132Cm31!#4*LXo8rP>6dhd5pAvs`nP$KugO1m z_|mc4r!HoF-rPQod?>Kwp$HZI{tzQQIJ7>s2PdJfpXO^4D8|ClyI~QN+Q>uwQj5Mp z5BAVVusFm{e7NAvU}UnPIi2&1WhA8%l+P+f&p#-Nzcq4Fs#4$Ti2@HxVW(I_BYg!>=MRCfSlCGZTw97r zg5Y2)+YSYzq(#Y<*e^i?cjo(}NK(kcwm+hra>NV6EhYAjI9fe;*@2KDoc(1H`?kmT zj_&f*u-AFN#sBQysQDNmd94x0=FIv*&-G+jBSsKNCABc|&*rEAR6Msp+`iqvX2;|T z729ZXOe{sjVhd=zQ1+9EdX?KsG7li7(7_>QhiUdgYmkt>=TnE43t9+cex2sIKp_^u;XbNOvH8n=8wyZG0 zPj7(c!vu2yx61yG4&zKMDwD~c-m8-D!yDMaM-u5=4k4CT&*~U}Zb8=14;vD!FgtxY zs^y=*QPgVJ4jSlI9}MFT<%<(`!eeVT8>;y`1x{}nJ^8|%X3~0U;@g|DG*;{tL2&1D zyLt`7=A=em0_|6(Y^~6LBLZK7gt~`IIJZjanxJKQq%iWYd}ndcuW8h{N}t(V1AXxM zvN_tYt(xD=VY!F$#YeTmR7ScYr{j!#B)#awBG+>HWt zqxD|=oY<>!KyZHxrt zf2!a44=3~qoRx>(-#PTDosNZfH@bQOg@_Y~1W?gp-Uwbq_k85(p@*jVR{U@T`R65C zQZJ2;v%Km(GuyF86!PjuqN_fgY9T%SC!tLKR!9-3TfOE-3#yW{CrY@Aizvddarl`! zSt)R|8VV>zbsK?wipxDo-+53oa);~$SH60(gREs+HQ`@R9H+|GaS(Em7M|3e@Ni80 z2oJLV;jucE_LJ;No=`Gz#dkuS;M+-hzG?U0%1>5scDy?-b_HD2E`nD*kDdtnR!omxN&OU;U5fR1fi1|T=|{rsnKL_J zmy&GbI_A%To}h+R>73gDT2)2$*)!-xgV|(RbPu3(9Jn8r(0;uCkt{8jmSkLc%wlNN;m=1MK7i2%6 zzSUZtu6gN_pBoY+-@|>t<=q8U>#y+9*JI@tU{TUnp&$=jM?;uc(O>6rg8IZ~TYesz z8+~)f(=6XvP;?Eit)AQ-dzV=N)iVx!==SrvjG;?K!lZ2xOxucJ_0{p=^g_I?A9?)4 zSl5SbQw`3hdSVPPAW+rfMj@+rjLtep^uTK-B|{N_u1Sih!cs0>!b3kD(*KA*d*3({ zUy8bzDLL5u1tpP0*%dO%`7{>L=bHtEd&ve1nu%TyY9zf_IGh)Q6w#O2r>;Bx=S7DVW>|q_kBd=`}megjF4Q_7%IZ`hkK^fMrtg(UbqjK7BdU z4;GL+`0&TwmRc;i5VV3muaH_%3cm~Z&p%|4nO>^tQJ)!Su~IYSGdH~Vj^Ty?YxEBR z-E)XJEPKlFM}vmiPLNtu@8cS&&}hAbzK5R{v@a#J;*Z_kOMY3#P;zb{nsPyaem$}j zo>zp?XxZ!|XZ$#Fib39}`IizNoglNFkd`tD`(l2kF6x-Sh7@?j0L+x)0cofp|3^x@ zuNvC}^~x=Tu)U!_chl&3y!4u>d`el>yRLT&ZY)K~i@tAE-tWojV)ror)-%SX+|b%u z0vWsMdwGot)q0Ta@V34p%`i3%o5;K1z3Uazy^#o!hVH#}$%D4(asp~H1R!4oq{oCd zhZmxs^o=g8Sk)0hiFvSGy2=Q&TOdke1O5l@q1z{BVBUv8;wCn{mG#iJ<)t^Ilc}c; z0Nt0W-^~y{qIXsI867d8kx>6*N=(Hf`#ms%zM1R_!7xBQh44p!0L-AC=1An6ul35T zN1(+eg`-UZx3HSqj;}y&OTlG{4KHPYx(MddZ_!*TgzhVNmYi7OHYu2j47U#0J${DmXp_s4+H zUiE|SJnrj0Fdgj2;L3gDe$Rfl&^L2GOg20vK=6JvJLGN-#vj6o`|#E1Xxk=sXxJoI z>A#VOZW@J+6EHR4alvjad!-38SQR=ZZbvJER&KnR5Lt@Wzf*~@8DP=b)dkIrcI`Pe z6)4pz9c`v+`b5Qi>p3iP#bA!(f8Jjv)?k!>Mx*oR!XIaQm0FeGV)eiNhSJ z1(gaOHYM;SH;AwUTGdM^Lfg7%!@i9P@)UliEXV=CYTlH^_}tDw=#=*L%%}n=Er=b} z9e_Xq>a5?GB9^t{8-%A7psOiEBRQ}tRt*Qd1kD-j5R80^n=mLUZK#P|(D~y71wosY z2a-ku%v)PvDW(;=zg9-AX&a`=s;J6G)|^;5aQ+&`jmfxsa2h)Fg$Ac(1bTdg?>J2I z>%{rux^oR`++~-QI$^2zdd{CgU<1VTpwajy6EBjNS#l$;m*+%ZthcLGJ-h)2IL=*C zgA4c8Krl}+Xy}oOaDc*7sx%b1g*l-@mRwjSrPDb?dzH4q;!{Rp5Aa-(9qPW{B3zFL zl-{g8s}skggI7w$;;r6A@GRr$P)Et2M#zNG4A%Lby=yB&y0`6hDf9dWiH9DU*74_h z#{nda$kpkHR70x`I!7ZUAlH^?6$AFG0jo-P3>xxtT~jsg z8F8no}{95LcPU;O%ns;7;=%%5TzZu#NiG(o^!Ou19S@ldD4XaZLm9%eCb&h0)9o&$x6r{;S0`X#U=I z_3gLAy3)#t(L9u8xtPLSJvQN z5X44|kV;4_s8k(X4KD&~2aoGzCCPKd{yZ2`rVL{NWR!Z3fu;ZuNbOkR^29r?B+bMl zcafmvryOit1Al2=mJO8(OO80ciD(sh*Rf8tUq7?3Yrx}B<6Dcl&wxq;68~Em6Ox?y z#4tH%rDu+Tc0o(2aSEFf$!hNwMeKj$VTaMGceHD6#yfWSUdK?@a9obg7G^3fEW2w zDydhgUSVT7k0jP_?^8Y#{U6{D<_2rmWK0$A?xImdIg0opxII6tD58>x-k~tcG89e7 zymsc$UL$j|Zt+CpaZn)c8sC36y}AVD-6}Tkje44fAB!T8=u|_r$6m4;U<%ufctP;1 zeabZNl;#0mT2?0nv(_l^yu6(ccSy6&>s%vc%1Gs^W+nBp%40^M=6c2*sJIwqfW87vp>>^7?BnC)pXSdUJuWqsY@+LI@L-_0~* z0#iTa(FMCuJtgTC(cwLm@T;Zkh;*^Pj7jr1G;3;MSj09m{xFeu z$4n}uz7ty4B0T zJyWDT!GAun7~ltgrHcoQy=Z!Vn*a_Or){?k?WjV^I|+6?+XcNe;TFwxc5 zSbKTyErk>_Fp`r?SSW3#P2`GQ(_fRX+xnC7r&W9c((t+kK?=#A{a;jBLbMXdP!Rp2 z=Sank);IsPIG0S)Z^?>!*a{Jok0QW3MyjA7x$nKf&k`}@SrXTzQbcKscm~pEPn8n+-fv+bys=81{?uA}%D%?X~ZVyOhy;iLL>>j=ah3k;Nj9*;qZD#xS;`W3q%-uo#2A zaWGw`x|3Q=5KBh}_6VMVS3qn9vdMYmvinGUtbSuF%hl=_6-Ny;9R8=b$sh|H{@+~tCN1SGAH_tI#iV|n&OnS&UHwFP z&~hpCNMyyP?jUUxBxC|j^OK|@S(ewub&Uvda(LU{Ogl$P0-qQj_4Jk$nhTDPT(9HPg|W? zL_c*fe~e~T`8*_;u`f9ScF&&8Nf1q}R7yO!$h_)8K* zC65NdGeJz%eXY~1Jd_kVyWwPCVJC6vshaMr09j%J68c;SZa_5wbN>viklr7q_A`rR zcLx%831g6nn>GznT=4B-YnQJTQpNkl7UR^0+~CjvBDYGel{4#}(4VNd`0%@z+Op<< z`ts2zm5c^fBtqMOuw#kAyqp0QCb0);ucnSRh3NVi3X~jZ=6uR1=q>mjbF_G0I;Zi| z5jV^%_NVtfViYt-u2Fc^wgXW_hKfCdYcXUb=YnIYSPH|8suC0$p0@9>#)>=l_>tX_ zU=VfdBPHR7VUR@z8^JWg(7eOW$b0IQn&Z#VUT)z8cDB-O_>-YdmqJj^ca$kn_CX3| zi>GdOi7PW)_W1zcS&%?LpFhyP@<>RnJRKG=bn`MdjLJ(RbyZ<>MeDkCM;!b+1TI~w z@k9PCzCuSazb@X0V%Svq_f73QAHa&_TRmlt<(VmQx99oXk(g1EsA|j;~H`4e^@iDYNvyns3ZY?*S zLN>MMk8#p5Fi#H7{h1Ce#^N~AW59t|;Va^+Djg5CWfdxGlMAlRjUP5(_(Rvb*b?)$ zeKVA87Wb_-9MG!@NC$tG4dXdpwG0GqUv5M5oBQUuH$_oz5rqECzQ>3ux5aw*aJFaS zs+J!rbfs=Bu5zw^D+g(MYj2k<`+rH;UE2BWeq@)`f`3%D=cMFLLcMiEN4m7y@;o@t zW!tS5O#S6nd9Qh$TyCWTTkFFOVw9%hTM?Yd_f8o{zPKN>DBz<}>z@YDBn_*KHzLx; z;E5ETavBo}O_@a7j!)qtH9UK7NpB!$0uOj`Pv(@vp`!iiiRww8M^oLaI+&z!l@W}qGuTks0or)(A{gYvud}YR=)dzSG?#BhA2vK^bghM5X2n6_H-3a31BbbPd|5bmvrq=s{+Lbm@1p3&p7NT*D9DX zQ3iOL*SA_hnLaoYSU4VU(VNiWC)jNOBaM-&{$anv0r&ghH3&nm~Y&BvxiA+;XU z#OrEO?Xx0X-`UZ$U$FvG^YTwdEg zQXazZP^JXE|Lzh5(9B(hzqr6j**&cuW)aIW8QtNkx<`xM>nA;|2oAQVq?4<{#~%4_ zj1XJ%e4kLm@SRM(ak;ND;i#ZtJ54wT)_T5BxOQ8Te<}?Wo+gO?gka}SSgPHJl?dtQ z4yy`OoMzAKt54C-0cLnY!X~KU%l;}>b}~@K$3dg1X4|HBTU)hk?-_V_ep6?IeJ-<4 zNY5ju2wRe5MPQ7A^T16PT{2FC!M;}5SN%4LUFOyAKcR5yT>Y-AbEhzbW72I?zEy2s2o+DCkw=?PmFQCf;Ja&k%&WvQv9C*^p3)LXpmLm7 z>HS!8=%KO5v##)h%)ua4SAUutBn1)~T2lO!fU#JnNwaUv7|Mq1F~S);kS*?2E(9q% z2SKUh{#vh!l^cE0|Nr-SIt_rNiCI=R^gmadVvQa*d{6-~uaH^M-1(+@q0b=`9f|Qh z>o+vD@HC-q!-y&C2m$)Z(7?jJHTkeT^ybiXFUUca2Qb!ttSs1);ebcs!V)OT)+R>> zPpoJe*fFM*bh0z%R`8UJM;1waGdcm*)q;J@%w`(A{$h9kdSog^)M2ONV9_FLW+(U)%Va$hGOu}8(mtYg~`P(sJ ziiqm$l;^gsl@30%h*4oSPw6Y*ysZO^1t-ibBdGw1Ko9JYNm`FOTG5vr)U`G`j@&`q zVqHxu+xy3o?1rpXy$9BdyPb6L9cDS>yR{h6jSb<`=LmP);G@f&M)2Gv=<{Glt-c@z ztPVfeH8MfSOHJ|vhW9=PfpGdfub#?FLfoIb${hGEdh8|8gg}F9S3?<_W7i=A8Mj*V zMXI#iJW*T0pFveL{X!~0-)6p83vsmPUjT0%6-zXCkvWi9*U~ges49_s%LV19!X5o0 zJ*9XYHybPc);8*L$LF1xqy!@=iT7crJB^J2Jyqt0RU;r3k+~i@Vp?68vy5U=aJW8l z0en_HHZm1rs-t!Zf2Oz8E5yp^UoZbBNB-F3FsVluCIK*r< z#b(dhL^i?z2y3VcmQgT|F?Eggd1U=6Aau;$`5!Z6N(IX%9sb_5hO=LI=p~7UmCgwj z)CdwGaY1Ei?uk?p`b?FfU2TLvV z%vnNvxydT3ZamyJ)AE-n``cx`4ka`E8BruwA?%S5#tbf_o4RZQ6lTrS80V<_F|D`N zB2wEd!@_t6=7t7kR0amis_ZF(vFl13*e!1WQymhmw+5u9RKTF&+`D}aO?eF0AI6F0 iRhGwW=wC7chkG%Sjx{qh81qpmMQ$xHTYvhfAOHXmlixf5 literal 0 HcmV?d00001 diff --git a/apps/web/public/blog/rebuilding-cap-in-gpui-poster.jpg b/apps/web/public/blog/rebuilding-cap-in-gpui-poster.jpg new file mode 100644 index 0000000000000000000000000000000000000000..628f5e5f5ea2b22a5d8459f47791136d00b93388 GIT binary patch literal 124375 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 z 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 z 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

) : ( -

+

)} diff --git a/apps/web/app/s/[videoId]/_components/tabs/Summary.tsx b/apps/web/app/s/[videoId]/_components/tabs/Summary.tsx index a5cd915f47b..d276a9481ec 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Summary.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Summary.tsx @@ -10,6 +10,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Edit3 } from "lucide-react"; import { useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; +import { formatPlayerTime } from "@/lib/player-time"; import { type SummaryEditingState, SummaryEditor } from "./SummaryEditor"; type AiGenerationStatus = @@ -42,14 +43,6 @@ interface SummaryProps { onEditingStateChange?: (state: SummaryEditingState) => void; } -const formatTime = (time: number) => { - const minutes = Math.floor(time / 60); - const seconds = Math.floor(time % 60); - return `${minutes.toString().padStart(2, "0")}:${seconds - .toString() - .padStart(2, "0")}`; -}; - const SkeletonLoader = () => (
@@ -318,7 +311,7 @@ export const Summary: React.FC = ({ onClick={() => handleSeek(chapter.start)} > - {formatTime(chapter.start)} + {formatPlayerTime(chapter.start)} {chapter.title} diff --git a/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx b/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx index 2915c4dd2d5..5bfb575bc18 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx @@ -9,12 +9,11 @@ import { ChevronDown, Copy, Download, - Edit3, Globe, LoaderCircle, MessageSquare, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { editTranscriptEntry } from "@/actions/videos/edit-transcript"; import { type LanguageCode, @@ -27,6 +26,7 @@ import type { VideoData } from "../../types"; import { type CaptionLanguage, useCaptionContext } from "../CaptionContext"; import type { TranscriptEntry } from "../utils/parse-transcript-vtt"; import { parseTranscriptVTT } from "../utils/parse-transcript-vtt"; +import { TranscriptEditor, TranscriptRow } from "./transcript-row"; interface TranscriptProps { data: VideoData; @@ -49,7 +49,6 @@ export const Transcript: React.FC = ({ data, onSeek }) => { const [selectedEntry, setSelectedEntry] = useState(null); const [retryTriggered, setRetryTriggered] = useState(false); const [editingEntry, setEditingEntry] = useState(null); - const [editText, setEditText] = useState(""); const [isSaving, setIsSaving] = useState(false); const [isCopying, setIsCopying] = useState(false); const [copyPressed, setCopyPressed] = useState(false); @@ -249,37 +248,33 @@ export const Transcript: React.FC = ({ data, onSeek }) => { ); }, [data.createdAt, data.transcriptionStatus, retryTriggered]); - const handleTranscriptClick = (entry: TranscriptEntry) => { - if (editingEntry === entry.id) { - return; - } - - setSelectedEntry(entry.id); + const handleTranscriptClick = useCallback( + (entry: TranscriptEntry) => { + if (editingEntry === entry.id) { + return; + } - onSeek?.(entry.startTime); - }; + setSelectedEntry(entry.id); + onSeek?.(entry.startTime); + }, + [editingEntry, onSeek], + ); - const startEditing = (entry: TranscriptEntry) => { + const startEditing = useCallback((entry: TranscriptEntry) => { setEditingEntry(entry.id); - setEditText(entry.text); - }; + }, []); const cancelEditing = () => { setEditingEntry(null); - setEditText(""); }; - const saveEdit = async () => { + const saveEdit = async (editText: string) => { const normalizedEditText = normalizeTranscriptCueText(editText); if (!editingEntry || !normalizedEditText) { return; } - const _originalEntry = transcriptData.find( - (entry) => entry.id === editingEntry, - ); - setIsSaving(true); try { const result = await editTranscriptEntry( @@ -297,7 +292,6 @@ export const Transcript: React.FC = ({ data, onSeek }) => { ), ); setEditingEntry(null); - setEditText(""); invalidateTranscript(data.id); } else { console.error("[Transcript] Failed to save transcript edit:", { @@ -796,87 +790,30 @@ export const Transcript: React.FC = ({ data, onSeek }) => {
)}
- {transcriptData.map((entry) => ( -
- {editingEntry === entry.id ? ( -
-