From 91bd2543f4d7ad46396e661ab870aa040625b7dc Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 11 Jul 2026 14:31:52 +0200 Subject: [PATCH] feat(viewer): WMV cutscene player with scrub, keyboard + audio Add an in-explorer video player for the dat/movie/*.wmv cutscenes. Decode via the ffmpeg/ffprobe CLI (no libav linking): video streams from an `ffmpeg -f rawvideo -pix_fmt rgba` pipe read into fixed w*h*4 frame chunks over a bounded channel (paused/behind => ffmpeg back-pressures on its pipe); one reused Image is overwritten in place each tick. Audio is best-effort: the track is pre-decoded to a temp stereo WAV and played through a rodio Sink (volume / play / pause / seek-via-skip_duration for free). Native-only under cfg(not(wasm32)); VideoPreview registers unconditionally so the UI compiles for wasm. Mirrors the .pak load pipeline (FileSelected -> bg thread -> IsoLoaderMsg::VideoLoaded -> apply_loaded_video -> advance_video_playback), with a single free_video teardown wired into every other viewer's reset. Controls: - play/pause button, click-on-frame toggle, Space / arrow (+-10s) shortcuts - seekable timeline with mm:ss labels; volume slider (greyed when no audio) - live scrubbing: a coalescing one-shot `ffmpeg -ss -frames:v 1` grabber always jumps to the knob's latest position, so dragging shows the frame under the knob in real time; that grabbed frame also bridges the gap after a commit-seek so there's no black flash while the streaming decoder catches up. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 406 +++++++++++- Cargo.toml | 3 + crates/sylpheed-viewer/Cargo.toml | 2 + crates/sylpheed-viewer/src/iso_loader.rs | 796 ++++++++++++++++++++++- crates/sylpheed-viewer/src/ui.rs | 119 +++- 5 files changed, 1304 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3d86fb..c125e70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,8 +60,8 @@ dependencies = [ "hashbrown 0.15.5", "paste", "static_assertions", - "windows", - "windows-core", + "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] @@ -112,6 +112,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -121,10 +143,10 @@ dependencies = [ "android-properties", "bitflags 2.11.0", "cc", - "jni", + "jni 0.22.4", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", "ndk-sys 0.6.0+11769913", "num_enum", @@ -1248,7 +1270,25 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", "shlex", "syn 2.0.117", ] @@ -1420,6 +1460,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -1470,6 +1516,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -1561,6 +1613,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "claxon" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1764,6 +1822,49 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen 0.72.1", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1830,6 +1931,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.10.0" @@ -2453,7 +2560,7 @@ dependencies = [ "log", "presser", "thiserror 1.0.69", - "windows", + "windows 0.58.0", ] [[package]] @@ -2548,6 +2655,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "icu_collections" version = "2.1.1" @@ -2726,6 +2839,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -2845,6 +2974,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + [[package]] name = "libc" version = "0.2.183" @@ -2927,6 +3067,15 @@ dependencies = [ "twox-hash 2.1.2", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3047,7 +3196,7 @@ dependencies = [ "indexmap", "log", "pp-rs", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "termcolor", "thiserror 1.0.69", @@ -3068,12 +3217,26 @@ dependencies = [ "once_cell", "regex", "regex-syntax", - "rustc-hash", + "rustc-hash 1.1.0", "thiserror 1.0.69", "tracing", "unicode-ident", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3169,6 +3332,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3506,6 +3680,29 @@ dependencies = [ "objc", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "offset-allocator" version = "0.2.0" @@ -3516,6 +3713,15 @@ dependencies = [ "nonmax", ] +[[package]] +name = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3986,6 +4192,19 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rodio" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +dependencies = [ + "claxon", + "cpal", + "hound", + "lewton", + "symphonia", +] + [[package]] name = "ron" version = "0.8.1" @@ -4004,6 +4223,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -4378,12 +4603,62 @@ dependencies = [ "bevy_egui", "futures", "rfd", + "rodio", "sylpheed-formats", "thiserror 2.0.18", "tracing", "tracing-subscriber", ] +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + [[package]] name = "syn" version = "1.0.109" @@ -4694,7 +4969,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528bdd1f0e27b5dd9a4ededf154e824b0532731e4af73bb531de46276e0aab1e" dependencies = [ - "bindgen", + "bindgen 0.70.1", "cc", "cfg-if", "once_cell", @@ -5100,7 +5375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16" dependencies = [ "core-foundation 0.10.1", - "jni", + "jni 0.22.4", "log", "ndk-context", "objc2 0.6.4", @@ -5158,7 +5433,7 @@ dependencies = [ "parking_lot", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 1.0.69", "wgpu-hal", @@ -5200,14 +5475,14 @@ dependencies = [ "range-alloc", "raw-window-handle", "renderdoc-sys", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 1.0.69", "wasm-bindgen", "web-sys", "wgpu-types", - "windows", - "windows-core", + "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] @@ -5230,13 +5505,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ - "windows-core", + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", "windows-targets 0.52.6", ] @@ -5248,7 +5543,7 @@ checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ "windows-implement", "windows-interface", - "windows-result", + "windows-result 0.2.0", "windows-strings", "windows-targets 0.52.6", ] @@ -5281,6 +5576,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -5296,10 +5600,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -5345,6 +5658,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -5393,6 +5721,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5411,6 +5745,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5429,6 +5769,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5459,6 +5805,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5477,6 +5829,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5495,6 +5853,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5513,6 +5877,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5553,7 +5923,7 @@ dependencies = [ "js-sys", "libc", "memmap2", - "ndk", + "ndk 0.9.0", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", diff --git a/Cargo.toml b/Cargo.toml index 4e9c614..87d06cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # ── Native file dialog ──────────────────────────────────────────────────────── rfd = "0.14" +# ── Audio playback (native video player) ────────────────────────────────────── +rodio = "0.20" + # ── CLI ─────────────────────────────────────────────────────────────────────── clap = { version = "4", features = ["derive"] } diff --git a/crates/sylpheed-viewer/Cargo.toml b/crates/sylpheed-viewer/Cargo.toml index e97637d..d93db7b 100644 --- a/crates/sylpheed-viewer/Cargo.toml +++ b/crates/sylpheed-viewer/Cargo.toml @@ -50,3 +50,5 @@ dev = ["bevy/dynamic_linking"] [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tracing-subscriber = { workspace = true } rfd = { workspace = true } +# Audio for the WMV video player (pulls cpal → alsa on Linux). +rodio = { workspace = true } diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 4c7a76e..2125230 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -20,6 +20,18 @@ #[cfg(not(target_arch = "wasm32"))] use std::sync::{mpsc, Mutex}; +#[cfg(not(target_arch = "wasm32"))] +use std::io::Read; +#[cfg(not(target_arch = "wasm32"))] +use std::path::Path; +#[cfg(not(target_arch = "wasm32"))] +use std::process::{Child, Command, Stdio}; + +#[cfg(not(target_arch = "wasm32"))] +use bevy::render::render_asset::RenderAssetUsages; +#[cfg(not(target_arch = "wasm32"))] +use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; + use std::path::PathBuf; use bevy::prelude::*; @@ -148,6 +160,59 @@ pub struct SkyboxPreview { pub info: String, } +/// The currently-open `.wmv` cutscene, shown in the central panel with a +/// transport bar. Registered unconditionally (the decode/audio engine below is +/// native-only); on wasm `active` stays false and `.wmv` shows the info panel. +#[derive(Resource)] +pub struct VideoPreview { + /// True while a video is loaded and the player owns the central panel. + pub active: bool, + pub name: String, + pub width: u32, + pub height: u32, + /// Total length in seconds (from `ffprobe`). + pub duration: f32, + /// Current playback position in seconds (drives the frame clock + timeline). + pub position: f32, + pub playing: bool, + /// The frame texture — one `Image`, overwritten in place each tick. + pub handle: Option>, + pub egui_id: Option, + /// 0.0..=1.0 audio level (ignored when `!has_audio`). + pub volume: f32, + pub has_audio: bool, + /// Set on drag-release / click / keyboard skip; restarts the streaming + /// decoder (and audio) at this position. + pub seek_request: Option, + /// Set continuously while dragging the timeline; asks the one-shot grabber + /// for the frame at this position so scrubbing shows a live preview. + pub scrub_request: Option, + /// True while the knob is being dragged — display grabbed scrub frames and + /// hold the streaming decoder until release. + pub scrubbing: bool, +} + +impl Default for VideoPreview { + fn default() -> Self { + Self { + active: false, + name: String::new(), + width: 0, + height: 0, + duration: 0.0, + position: 0.0, + playing: false, + handle: None, + egui_id: None, + volume: 0.8, + has_audio: false, + seek_request: None, + scrub_request: None, + scrubbing: false, + } + } +} + /// The currently-open IPFB data pack, shown as a master-detail browser. #[derive(Resource, Default)] pub struct PakView { @@ -166,6 +231,12 @@ const PAK_DECODE_BUDGET: usize = 64 * 1024 * 1024; /// against a pathological entry, since `decompress_entry` has no output cap). const PAK_ENTRY_COMP_CAP: u32 = 16 * 1024 * 1024; +/// Bounded decoded-frame queue between the ffmpeg reader thread and the main +/// thread. Also the back-pressure valve: when we stop draining (paused/behind), +/// the queue fills, the reader blocks on `send`, and ffmpeg blocks on its pipe. +#[cfg(not(target_arch = "wasm32"))] +const VIDEO_FRAME_QUEUE: usize = 8; + // ── SystemSet label ─────────────────────────────────────────────────────────── /// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's @@ -193,11 +264,29 @@ enum IsoLoaderMsg { rows: Vec, error: Option, }, + /// A `.wmv` materialised to a temp file + probed (+ audio pre-decoded) off + /// the main thread; the frame pipe is opened later in `apply_loaded_video`. + VideoLoaded(Box), /// User dismissed the file dialog — not an error. Cancelled, Error(String), } +/// A probed, temp-materialised video ready for playback. Boxed inside the +/// message so the enum stays small. +#[cfg(not(target_arch = "wasm32"))] +pub struct PreparedVideo { + name: String, + /// Temp `.wmv` on disk (ffmpeg reads it by path). + temp_video: PathBuf, + /// Temp stereo WAV of the audio track, or `None` (no audio / decode failed). + temp_wav: Option, + width: u32, + height: u32, + fps: f32, + duration: f32, +} + /// Channel resource. `Receiver` is `!Sync`, so we wrap it in `Mutex` /// to satisfy Bevy's `Resource: Send + Sync` bound. #[cfg(not(target_arch = "wasm32"))] @@ -239,6 +328,56 @@ struct PendingPak { error: Option, } +/// One-frame staging buffer for a prepared video; `apply_loaded_video` (chained +/// after) consumes it, spawns the frame pipe + audio sink, and fills `VideoPreview`. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Resource, Default)] +struct PendingVideo { + ready: bool, + video: Option>, +} + +/// Native-only playback engine backing the current `VideoPreview`. Held as a +/// `NonSend` resource because `rodio::OutputStream` is `!Send`. `None` when no +/// video is playing. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct VideoEngine { + inner: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +struct VideoEngineInner { + /// Decoded RGBA frames `(pts_seconds, bytes)` from the ffmpeg reader thread. + frames: mpsc::Receiver<(f32, Vec)>, + /// The running `ffmpeg … -f rawvideo` child (killed on seek/teardown). + child: Child, + width: u32, + height: u32, + fps: f32, + temp_video: PathBuf, + temp_wav: Option, + /// A frame pulled from the queue whose pts is still in the future — shown + /// once the clock reaches it (so we never drop or mis-order frames). + held: Option<(f32, Vec)>, + /// Desired scrub positions → one-shot grabber thread (coalesced to latest). + scrub_tx: mpsc::Sender, + /// Grabbed scrub frames back from the grabber thread. + scrub_frame_rx: mpsc::Receiver>, + /// After a commit-seek, keep showing the last grabbed frame until the + /// restarted streaming decoder produces its first frame (no black flash). + awaiting_seek_frame: bool, + // ── audio (present only when a temp WAV was produced) ── + /// Kept alive to keep the audio device open; dropping it stops playback. + _stream: Option, + stream_handle: Option, + sink: Option, + /// Last volume pushed to the sink, to avoid redundant `set_volume` calls. + applied_volume: f32, + /// Last `playing` state mirrored to the sink, to detect transitions. + was_playing: bool, +} + // ── Plugin ──────────────────────────────────────────────────────────────────── pub struct IsoLoaderPlugin; @@ -255,13 +394,16 @@ impl Plugin for IsoLoaderPlugin { // below are cfg'd out) so the UI can always read them. .init_resource::() .init_resource::() - .init_resource::(); + .init_resource::() + .init_resource::(); #[cfg(not(target_arch = "wasm32"))] { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_non_send_resource::() .add_systems( Update, ( @@ -271,6 +413,8 @@ impl Plugin for IsoLoaderPlugin { poll_loader_channel, apply_loaded_texture, apply_pak, + apply_loaded_video, + advance_video_playback, ) .chain() .in_set(IsoLoaderSystemSet), @@ -463,6 +607,50 @@ fn handle_file_selected( continue; } + // `.wmv` cutscenes: read the bytes, spill to a temp file (ffmpeg needs a + // real path), probe dimensions/duration, and pre-decode the audio track + // to a temp WAV — all off-thread. The video frame pipe is opened later. + if file_path.to_ascii_lowercase().ends_with(".wmv") { + let name = file_path.rsplit('/').next().unwrap_or(&file_path).to_string(); + match &iso_state.source_kind { + SourceKind::Iso(iso_path) => { + let iso_path = iso_path.clone(); + let vid_path = file_path.clone(); + std::thread::spawn(move || { + let result = futures::executor::block_on(async { + let mut reader = sylpheed_formats::xiso::open_iso(&iso_path) + .await + .map_err(|e| e.to_string())?; + reader.read_file(&vid_path).await.map_err(|e| e.to_string()) + }); + let msg = match result.and_then(|bytes| prepare_video(&name, bytes)) { + Ok(prepared) => IsoLoaderMsg::VideoLoaded(Box::new(prepared)), + Err(e) => IsoLoaderMsg::Error(e), + }; + let _ = sender.send(msg); + }); + } + SourceKind::Directory(root) => { + let root = root.clone(); + let vid_path = file_path.clone(); + std::thread::spawn(move || { + let assets = sylpheed_formats::vfs::GameAssets::from_directory(&root); + let msg = match assets + .read(&vid_path) + .map_err(|e| e.to_string()) + .and_then(|bytes| prepare_video(&name, bytes)) + { + Ok(prepared) => IsoLoaderMsg::VideoLoaded(Box::new(prepared)), + Err(e) => IsoLoaderMsg::Error(e), + }; + let _ = sender.send(msg); + }); + } + SourceKind::None => {} + } + continue; + } + match &iso_state.source_kind { SourceKind::Iso(iso_path) => { let iso_path = iso_path.clone(); @@ -583,6 +771,298 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { Ok(rows) } +// ── Video preparation (loader thread) ───────────────────────────────────────── + +/// Make a filesystem-safe stem for temp files from a leaf name. +#[cfg(not(target_arch = "wasm32"))] +fn sanitize_stem(name: &str) -> String { + name.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect() +} + +/// Spill the video bytes to a temp file, probe it, and pre-decode its audio. +/// Runs on the loader thread. Maps a missing `ffmpeg`/`ffprobe` to a friendly +/// error so the UI degrades gracefully instead of panicking. +#[cfg(not(target_arch = "wasm32"))] +fn prepare_video(name: &str, bytes: Vec) -> Result { + let dir = std::env::temp_dir(); + let stem = format!("sylph_vid_{}_{}", std::process::id(), sanitize_stem(name)); + let temp_video = dir.join(format!("{stem}.wmv")); + std::fs::write(&temp_video, &bytes) + .map_err(|e| format!("writing temp video: {e}"))?; + + let (width, height, fps, duration) = ffprobe_video(&temp_video)?; + + // Audio is best-effort: decode the track to a temp stereo WAV so rodio can + // play it with volume/seek. Failure (no track / codec issue) → silent video. + let temp_wav = if ffprobe_has_audio(&temp_video) { + let wav = dir.join(format!("{stem}.wav")); + match decode_audio_wav(&temp_video, &wav) { + Ok(()) => Some(wav), + Err(e) => { + warn!("audio pre-decode failed ({name}): {e}"); + None + } + } + } else { + None + }; + + Ok(PreparedVideo { + name: name.to_string(), + temp_video, + temp_wav, + width, + height, + fps, + duration, + }) +} + +/// Run a command to completion, mapping a not-found spawn error to a friendly +/// "install ffmpeg" message and a non-zero exit to its stderr. +#[cfg(not(target_arch = "wasm32"))] +fn run_tool(cmd: &mut Command, tool: &str) -> Result { + let out = cmd.output().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + format!("`{tool}` not found in PATH — install FFmpeg to play videos") + } else { + format!("running `{tool}`: {e}") + } + })?; + if !out.status.success() { + return Err(format!( + "`{tool}` failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(out) +} + +/// Probe width/height/fps/duration of the first video stream via `ffprobe`. +#[cfg(not(target_arch = "wasm32"))] +fn ffprobe_video(path: &Path) -> Result<(u32, u32, f32, f32), String> { + let out = run_tool( + Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-select_streams") + .arg("v:0") + .arg("-show_entries") + .arg("stream=width,height,avg_frame_rate:format=duration") + .arg("-of") + .arg("default=noprint_wrappers=1") + .arg(path), + "ffprobe", + )?; + let text = String::from_utf8_lossy(&out.stdout); + let (mut w, mut h, mut fps, mut dur) = (0u32, 0u32, 30.0f32, 0.0f32); + for line in text.lines() { + let Some((k, v)) = line.split_once('=') else { continue }; + match k.trim() { + "width" => w = v.trim().parse().unwrap_or(0), + "height" => h = v.trim().parse().unwrap_or(0), + "avg_frame_rate" => { + // "30/1" (or "0/0" for VFR/unknown → keep the 30 fps default). + if let Some((n, d)) = v.trim().split_once('/') { + let (n, d) = (n.parse::().unwrap_or(0.0), d.parse::().unwrap_or(0.0)); + if n > 0.0 && d > 0.0 { + fps = n / d; + } + } + } + "duration" => dur = v.trim().parse().unwrap_or(0.0), + _ => {} + } + } + if w == 0 || h == 0 { + return Err("ffprobe: no video stream / zero dimensions".to_string()); + } + Ok((w, h, fps, dur)) +} + +/// True if the file has at least one audio stream. +#[cfg(not(target_arch = "wasm32"))] +fn ffprobe_has_audio(path: &Path) -> bool { + Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-select_streams") + .arg("a") + .arg("-show_entries") + .arg("stream=index") + .arg("-of") + .arg("csv=p=0") + .arg(path) + .output() + .map(|o| o.status.success() && !o.stdout.iter().all(u8::is_ascii_whitespace)) + .unwrap_or(false) +} + +/// Decode the audio track to a temp stereo 48 kHz WAV (downmixing surround). +#[cfg(not(target_arch = "wasm32"))] +fn decode_audio_wav(video: &Path, wav: &Path) -> Result<(), String> { + run_tool( + Command::new("ffmpeg") + .arg("-v") + .arg("error") + .arg("-y") + .arg("-i") + .arg(video) + .arg("-vn") + .arg("-ac") + .arg("2") + .arg("-ar") + .arg("48000") + .arg(wav), + "ffmpeg", + ) + .map(|_| ()) +} + +// ── Video decode + audio (main thread) ──────────────────────────────────────── + +/// Spawn an `ffmpeg` process decoding to raw RGBA on stdout, plus a reader +/// thread that chunks it into frames and forwards `(pts, bytes)` over a bounded +/// channel. Optional `-ss start` seeks the input; output pts re-base to 0, so we +/// add `start` back to each frame's timestamp. +#[cfg(not(target_arch = "wasm32"))] +fn spawn_video_decoder( + path: &Path, + w: u32, + h: u32, + fps: f32, + start: f32, +) -> Result<(Child, mpsc::Receiver<(f32, Vec)>), String> { + let mut cmd = Command::new("ffmpeg"); + cmd.arg("-v").arg("error"); + if start > 0.0 { + cmd.arg("-ss").arg(format!("{start:.3}")); + } + cmd.arg("-i") + .arg(path) + .arg("-f") + .arg("rawvideo") + .arg("-pix_fmt") + .arg("rgba") + .arg("-vsync") + .arg("0") + .arg("pipe:1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + let mut child = cmd.spawn().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + "`ffmpeg` not found in PATH — install FFmpeg to play videos".to_string() + } else { + format!("spawning ffmpeg: {e}") + } + })?; + + let mut stdout = child.stdout.take().expect("piped stdout"); + let frame_len = (w as usize) * (h as usize) * 4; + // Bounded: when the main thread stops draining, `send` blocks and ffmpeg + // back-pressures on its pipe (natural pause / flow control). + let (tx, rx) = mpsc::sync_channel::<(f32, Vec)>(VIDEO_FRAME_QUEUE); + std::thread::spawn(move || { + let mut idx: u64 = 0; + loop { + let mut buf = vec![0u8; frame_len]; + if stdout.read_exact(&mut buf).is_err() { + break; // EOF or the child was killed + } + let pts = start + idx as f32 / fps.max(1.0); + idx += 1; + if tx.send((pts, buf)).is_err() { + break; // receiver dropped (video closed / seeking) + } + } + }); + Ok((child, rx)) +} + +/// Build a rodio `Sink` playing `wav` from `start` seconds, at `volume`, paused +/// unless `playing`. Returns `None` on any audio failure (video still plays). +#[cfg(not(target_arch = "wasm32"))] +fn build_audio_sink( + handle: &rodio::OutputStreamHandle, + wav: &Path, + start: f32, + volume: f32, + playing: bool, +) -> Option { + use rodio::Source; + let file = std::fs::File::open(wav).ok()?; + let decoder = rodio::Decoder::new(std::io::BufReader::new(file)).ok()?; + let sink = rodio::Sink::try_new(handle).ok()?; + if start > 0.0 { + sink.append(decoder.skip_duration(std::time::Duration::from_secs_f32(start))); + } else { + sink.append(decoder); + } + sink.set_volume(volume); + if !playing { + sink.pause(); + } + Some(sink) +} + +/// Extract a single RGBA frame at `t` seconds via a fast one-shot ffmpeg seek. +/// Much cheaper than restarting the streaming decoder — used for live scrubbing. +#[cfg(not(target_arch = "wasm32"))] +fn grab_one_frame(path: &Path, t: f32, frame_len: usize) -> Option> { + let mut child = Command::new("ffmpeg") + .arg("-v") + .arg("error") + .arg("-ss") + .arg(format!("{t:.3}")) + .arg("-i") + .arg(path) + .arg("-frames:v") + .arg("1") + .arg("-f") + .arg("rawvideo") + .arg("-pix_fmt") + .arg("rgba") + .arg("pipe:1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let mut out = child.stdout.take()?; + let mut buf = vec![0u8; frame_len]; + let ok = out.read_exact(&mut buf).is_ok(); + let _ = child.wait(); + ok.then_some(buf) +} + +/// The scrub grabber: waits for requested positions, **coalesces to the most +/// recent** (so fast dragging skips stale targets), grabs that frame, and sends +/// it back. One ffmpeg at a time; exits when the request channel closes. +#[cfg(not(target_arch = "wasm32"))] +fn scrub_worker( + rx: mpsc::Receiver, + tx: mpsc::Sender>, + path: PathBuf, + w: u32, + h: u32, +) { + let frame_len = (w as usize) * (h as usize) * 4; + while let Ok(mut t) = rx.recv() { + while let Ok(newer) = rx.try_recv() { + t = newer; // jump to the latest requested position + } + if let Some(frame) = grab_one_frame(&path, t, frame_len) { + if tx.send(frame).is_err() { + break; // main side gone + } + } + } +} + /// Polls the mpsc channel, updating `IsoState`, `FileBrowserState`, and /// `PendingFileBytes` as messages arrive. #[cfg(not(target_arch = "wasm32"))] @@ -592,6 +1072,7 @@ fn poll_loader_channel( mut browser: ResMut, mut pending: ResMut, mut pending_pak: ResMut, + mut pending_video: ResMut, ) { let receiver = channels.receiver.lock().unwrap(); loop { @@ -624,6 +1105,11 @@ fn poll_loader_channel( pending_pak.ready = true; browser.loading = false; } + Ok(IsoLoaderMsg::VideoLoaded(prepared)) => { + pending_video.video = Some(prepared); + pending_video.ready = true; + browser.loading = false; + } Ok(IsoLoaderMsg::Cancelled) => { iso_state.loading = false; browser.loading = false; @@ -667,6 +1153,36 @@ fn free_skybox( *skybox = SkyboxPreview::default(); } +/// Stop and tear down any playing video: kill ffmpeg, drop the audio sink + +/// output stream, release the frame texture, and delete the temp files. +#[cfg(not(target_arch = "wasm32"))] +fn free_video( + video: &mut VideoPreview, + engine: &mut VideoEngine, + images: &mut Assets, + contexts: &mut bevy_egui::EguiContexts<'_, '_>, +) { + if let Some(ref handle) = video.handle { + contexts.remove_image(handle); + images.remove(handle.id()); + } + if let Some(inner) = engine.inner.take() { + // Drop audio first (stops the device), then kill ffmpeg; dropping the + // receiver unblocks the reader thread's `send` so it exits. + drop(inner.sink); + drop(inner._stream); + let mut child = inner.child; + let _ = child.kill(); + let _ = child.wait(); + drop(inner.frames); + let _ = std::fs::remove_file(&inner.temp_video); + if let Some(w) = &inner.temp_wav { + let _ = std::fs::remove_file(w); + } + } + *video = VideoPreview::default(); +} + /// Converts pending raw bytes into a Bevy `Image`, registers it with egui, /// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`. #[cfg(not(target_arch = "wasm32"))] @@ -676,6 +1192,8 @@ fn apply_loaded_texture( mut text_preview: ResMut, mut pak_view: ResMut, mut skybox: ResMut, + mut video: ResMut, + mut engine: NonSendMut, mut file_info: ResMut, mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, @@ -694,6 +1212,7 @@ fn apply_loaded_texture( // viewer is active per selection. free_texture(&mut preview, &mut images, &mut contexts); free_skybox(&mut skybox, &mut images, &mut contexts); + free_video(&mut video, &mut engine, &mut images, &mut contexts); *text_preview = TextPreview::default(); *pak_view = PakView::default(); @@ -811,6 +1330,8 @@ fn apply_pak( mut preview: ResMut, mut text_preview: ResMut, mut skybox: ResMut, + mut video: ResMut, + mut engine: NonSendMut, mut file_info: ResMut, mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, @@ -820,10 +1341,11 @@ fn apply_pak( } pending.ready = false; - // Free any previous texture/skybox + clear the other previews (mirrors the - // texture path) so exactly one viewer is active. + // Free any previous texture/skybox/video + clear the other previews (mirrors + // the texture path) so exactly one viewer is active. free_texture(&mut preview, &mut images, &mut contexts); free_skybox(&mut skybox, &mut images, &mut contexts); + free_video(&mut video, &mut engine, &mut images, &mut contexts); *text_preview = TextPreview::default(); file_info.name = pending.name.clone(); @@ -838,3 +1360,271 @@ fn apply_pak( error: pending.error.take(), }; } + +/// Consumes a prepared video: allocates the frame texture, starts the ffmpeg +/// frame pipe + (best-effort) audio sink, and populates `VideoPreview`. +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::too_many_arguments)] +fn apply_loaded_video( + mut pending: ResMut, + mut video: ResMut, + mut engine: NonSendMut, + mut preview: ResMut, + mut text_preview: ResMut, + mut pak_view: ResMut, + mut skybox: ResMut, + mut file_info: ResMut, + mut images: ResMut>, + mut contexts: bevy_egui::EguiContexts, +) { + if !pending.ready { + return; + } + pending.ready = false; + let Some(prep) = pending.video.take() else { + return; + }; + let prep = *prep; + + // Free every other preview + any prior video so exactly one viewer is active. + free_texture(&mut preview, &mut images, &mut contexts); + free_skybox(&mut skybox, &mut images, &mut contexts); + free_video(&mut video, &mut engine, &mut images, &mut contexts); + *text_preview = TextPreview::default(); + *pak_view = PakView::default(); + + file_info.name = prep.name.clone(); + file_info.size_bytes = 0; + file_info.detected_format = None; + + // One RGBA frame texture (starts black), overwritten in place each tick. + let image = Image::new_fill( + Extent3d { + width: prep.width, + height: prep.height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &[0, 0, 0, 255], + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + let handle = images.add(image); + let egui_id = contexts.add_image(handle.clone_weak()); + + // Start the video frame pipe (clock is paused; frames flow up to the queue). + let (child, frames) = + match spawn_video_decoder(&prep.temp_video, prep.width, prep.height, prep.fps, 0.0) { + Ok(v) => v, + Err(e) => { + error!("video decode: {e}"); + contexts.remove_image(&handle); + images.remove(handle.id()); + let _ = std::fs::remove_file(&prep.temp_video); + if let Some(w) = &prep.temp_wav { + let _ = std::fs::remove_file(w); + } + preview.format_info = e; // shown in the info panel + return; + } + }; + + // Audio (best-effort): open the device + a sink over the temp WAV, paused. + let volume = 0.8_f32; + let (stream, stream_handle, sink) = match &prep.temp_wav { + Some(wav) => match rodio::OutputStream::try_default() { + Ok((stream, handle_out)) => { + let sink = build_audio_sink(&handle_out, wav, 0.0, volume, false); + (Some(stream), Some(handle_out), sink) + } + Err(e) => { + warn!("no audio output device: {e}"); + (None, None, None) + } + }, + None => (None, None, None), + }; + let has_audio = sink.is_some(); + + // One-shot frame grabber for live timeline scrubbing. + let (scrub_tx, scrub_req_rx) = mpsc::channel::(); + let (scrub_frame_tx, scrub_frame_rx) = mpsc::channel::>(); + { + let path = prep.temp_video.clone(); + let (gw, gh) = (prep.width, prep.height); + std::thread::spawn(move || scrub_worker(scrub_req_rx, scrub_frame_tx, path, gw, gh)); + } + + engine.inner = Some(VideoEngineInner { + frames, + child, + width: prep.width, + height: prep.height, + fps: prep.fps, + temp_video: prep.temp_video, + temp_wav: prep.temp_wav, + held: None, + scrub_tx, + scrub_frame_rx, + awaiting_seek_frame: false, + _stream: stream, + stream_handle, + sink, + applied_volume: volume, + was_playing: false, + }); + + *video = VideoPreview { + active: true, + name: prep.name, + width: prep.width, + height: prep.height, + duration: prep.duration, + position: 0.0, + playing: false, + handle: Some(handle), + egui_id: Some(egui_id), + volume, + has_audio, + seek_request: None, + scrub_request: None, + scrubbing: false, + }; +} + +/// Drives the playing video each frame: honours seek requests, mirrors +/// play/pause + volume to the audio sink, advances the wall-clock position, and +/// uploads the frame due at that position into the shared texture. +#[cfg(not(target_arch = "wasm32"))] +fn advance_video_playback( + mut video: ResMut, + mut engine: NonSendMut, + time: Res