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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 14:31:52 +02:00
parent 589659bec6
commit 91bd2543f4
5 changed files with 1304 additions and 22 deletions

406
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"] }

View File

@@ -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 }

View File

@@ -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<Handle<Image>>,
pub egui_id: Option<egui::TextureId>,
/// 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<f32>,
/// 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<f32>,
/// 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<PakRow>,
error: Option<String>,
},
/// 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<PreparedVideo>),
/// 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<PathBuf>,
width: u32,
height: u32,
fps: f32,
duration: f32,
}
/// Channel resource. `Receiver<T>` 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<String>,
}
/// 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<Box<PreparedVideo>>,
}
/// 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<VideoEngineInner>,
}
#[cfg(not(target_arch = "wasm32"))]
struct VideoEngineInner {
/// Decoded RGBA frames `(pts_seconds, bytes)` from the ffmpeg reader thread.
frames: mpsc::Receiver<(f32, Vec<u8>)>,
/// The running `ffmpeg … -f rawvideo` child (killed on seek/teardown).
child: Child,
width: u32,
height: u32,
fps: f32,
temp_video: PathBuf,
temp_wav: Option<PathBuf>,
/// 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<u8>)>,
/// Desired scrub positions → one-shot grabber thread (coalesced to latest).
scrub_tx: mpsc::Sender<f32>,
/// Grabbed scrub frames back from the grabber thread.
scrub_frame_rx: mpsc::Receiver<Vec<u8>>,
/// 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<rodio::OutputStream>,
stream_handle: Option<rodio::OutputStreamHandle>,
sink: Option<rodio::Sink>,
/// 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::<TextPreview>()
.init_resource::<PakView>()
.init_resource::<SkyboxPreview>();
.init_resource::<SkyboxPreview>()
.init_resource::<VideoPreview>();
#[cfg(not(target_arch = "wasm32"))]
{
app.init_resource::<IsoChannels>()
.init_resource::<PendingFileBytes>()
.init_resource::<PendingPak>()
.init_resource::<PendingVideo>()
.init_non_send_resource::<VideoEngine>()
.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<u8>) -> Result<Vec<PakRow>, 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<u8>) -> Result<PreparedVideo, String> {
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<std::process::Output, String> {
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::<f32>().unwrap_or(0.0), d.parse::<f32>().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<u8>)>), 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<u8>)>(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<rodio::Sink> {
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<Vec<u8>> {
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<f32>,
tx: mpsc::Sender<Vec<u8>>,
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<FileBrowserState>,
mut pending: ResMut<PendingFileBytes>,
mut pending_pak: ResMut<PendingPak>,
mut pending_video: ResMut<PendingVideo>,
) {
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<Image>,
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<TextPreview>,
mut pak_view: ResMut<PakView>,
mut skybox: ResMut<SkyboxPreview>,
mut video: ResMut<VideoPreview>,
mut engine: NonSendMut<VideoEngine>,
mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>,
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<TexturePreview>,
mut text_preview: ResMut<TextPreview>,
mut skybox: ResMut<SkyboxPreview>,
mut video: ResMut<VideoPreview>,
mut engine: NonSendMut<VideoEngine>,
mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>,
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<PendingVideo>,
mut video: ResMut<VideoPreview>,
mut engine: NonSendMut<VideoEngine>,
mut preview: ResMut<TexturePreview>,
mut text_preview: ResMut<TextPreview>,
mut pak_view: ResMut<PakView>,
mut skybox: ResMut<SkyboxPreview>,
mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>,
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::<f32>();
let (scrub_frame_tx, scrub_frame_rx) = mpsc::channel::<Vec<u8>>();
{
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<VideoPreview>,
mut engine: NonSendMut<VideoEngine>,
time: Res<Time>,
mut images: ResMut<Assets<Image>>,
) {
if !video.active {
return;
}
let Some(inner) = engine.inner.as_mut() else {
return;
};
// ── Commit seek: restart the decoder (and audio) at the requested position.
// The last grabbed scrub frame stays on screen (via `awaiting_seek_frame`)
// until the restarted stream produces its first frame — no black flash. ──
if let Some(target) = video.seek_request.take() {
let target = target.clamp(0.0, video.duration);
match spawn_video_decoder(&inner.temp_video, inner.width, inner.height, inner.fps, target) {
Ok((new_child, new_rx)) => {
let _ = inner.child.kill();
let _ = inner.child.wait();
inner.child = new_child;
inner.frames = new_rx; // old receiver dropped → old reader exits
inner.held = None;
inner.awaiting_seek_frame = true;
if let (Some(handle), Some(wav)) = (&inner.stream_handle, &inner.temp_wav) {
// Rebuild the sink at the new offset (old sink dropped = silenced).
inner.sink = build_audio_sink(handle, wav, target, video.volume, video.playing);
}
inner.applied_volume = video.volume;
inner.was_playing = video.playing;
video.position = target;
}
Err(e) => error!("seek failed: {e}"),
}
}
// ── Live scrub: ask the grabber for the frame under the knob. ──
if let Some(target) = video.scrub_request.take() {
let _ = inner.scrub_tx.send(target.clamp(0.0, video.duration));
}
// Audio + clock are suspended while scrubbing so they don't fight the knob.
let effective_playing = video.playing && !video.scrubbing;
// ── Mirror volume + play/pause to the sink. ──
if let Some(sink) = &inner.sink {
if (video.volume - inner.applied_volume).abs() > 0.001 {
sink.set_volume(video.volume);
inner.applied_volume = video.volume;
}
}
if effective_playing != inner.was_playing {
if let Some(sink) = &inner.sink {
if effective_playing {
sink.play();
} else {
sink.pause();
}
}
inner.was_playing = effective_playing;
}
// ── Advance the wall-clock position while playing (not while scrubbing). ──
if effective_playing {
video.position += time.delta_secs();
if video.duration > 0.0 && video.position >= video.duration {
video.position = video.duration;
video.playing = false;
if let Some(sink) = &inner.sink {
sink.pause();
}
inner.was_playing = false;
}
}
// ── Frame display. ──
// Always drain the grabber, keeping only the latest frame it produced.
let mut scrub_frame: Option<Vec<u8>> = None;
while let Ok(f) = inner.scrub_frame_rx.try_recv() {
scrub_frame = Some(f);
}
// While dragging, the grabbed frame under the knob is what we show.
if video.scrubbing {
if let (Some(handle), Some(frame)) = (&video.handle, scrub_frame) {
if let Some(img) = images.get_mut(handle) {
img.data = frame;
}
}
return;
}
// Otherwise show the newest streamed frame due at `position` (never drop or
// reorder). If the stream hasn't caught up after a seek, bridge with the
// last grabbed frame so the target position is visible immediately.
let pos = video.position;
let mut current: Option<Vec<u8>> = None;
loop {
if let Some((pts, _)) = &inner.held {
if *pts > pos {
break; // still in the future — keep holding it
}
current = inner.held.take().map(|(_, buf)| buf);
continue;
}
match inner.frames.try_recv() {
Ok((pts, buf)) => {
if pts > pos {
inner.held = Some((pts, buf));
break;
}
current = Some(buf);
}
Err(_) => break, // empty (or decoder finished)
}
}
let frame = if let Some(f) = current {
inner.awaiting_seek_frame = false; // stream caught up
Some(f)
} else if inner.awaiting_seek_frame {
scrub_frame // bridge with the grabbed target frame
} else {
None
};
if let (Some(handle), Some(frame)) = (&video.handle, frame) {
if let Some(img) = images.get_mut(handle) {
img.data = frame;
}
}
}

View File

@@ -11,7 +11,7 @@ use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, SkyboxPreview,
TextPreview, TexturePreview, IsoLoaderSystemSet,
TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
};
use crate::ViewerState;
@@ -132,6 +132,7 @@ fn draw_viewer_ui(
text_preview: Res<TextPreview>,
mut pak_view: ResMut<PakView>,
skybox: Res<SkyboxPreview>,
mut video: ResMut<VideoPreview>,
file_info: Res<FileInfo>,
mut open_iso_events: EventWriter<RequestOpenIso>,
mut open_dir_events: EventWriter<RequestOpenDir>,
@@ -266,6 +267,8 @@ fn draw_viewer_ui(
ui.label(format!("Loading {name}"));
});
});
} else if video.active {
draw_video_player(ui, &mut video);
} else if !skybox.faces.is_empty() {
draw_skybox_grid(ui, &skybox);
} else if pak_view.loaded {
@@ -489,6 +492,120 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
}
}
// ── Video player (WMV cutscenes) ──────────────────────────────────────────────
/// Format seconds as `mm:ss`.
fn fmt_time(secs: f32) -> String {
let s = secs.max(0.0) as u32;
format!("{:02}:{:02}", s / 60, s % 60)
}
/// Central-panel video player: the current frame plus a transport bar
/// (play/pause, seekable timeline, volume). Also supports click-to-toggle and
/// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state
/// lives in `VideoPreview`; the decode/audio engine reacts to it in
/// `advance_video_playback`.
fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
// ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
let (mut toggle_play, mut skip) = (false, 0.0_f32);
ui.input_mut(|i| {
if i.consume_key(egui::Modifiers::NONE, egui::Key::Space) {
toggle_play = true;
}
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) {
skip += 10.0;
}
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) {
skip -= 10.0;
}
});
if toggle_play {
video.playing = !video.playing;
}
if skip != 0.0 {
let t = (video.position + skip).clamp(0.0, video.duration);
video.position = t;
video.scrub_request = Some(t); // instant preview via the grabber…
video.seek_request = Some(t); // …then the stream restarts here
}
ui.horizontal(|ui| {
ui.heading(&video.name);
ui.separator();
ui.label(format!("{}×{} · wmv3 / wmapro", video.width, video.height));
if !video.has_audio {
ui.separator();
ui.colored_label(egui::Color32::YELLOW, "no audio");
}
});
ui.separator();
// Reserve room for the transport row so the frame never pushes it off-screen.
const CONTROLS_H: f32 = 34.0;
let avail = ui.available_size();
let frame_h = (avail.y - CONTROLS_H).max(0.0);
let (w, h) = (video.width.max(1) as f32, video.height.max(1) as f32);
let scale = (avail.x / w).min(frame_h / h);
let (disp_w, disp_h) = (w * scale, h * scale);
if let Some(egui_id) = video.egui_id {
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
ui.vertical_centered(|ui| {
// Click the frame to toggle play/pause.
let resp = ui.add(
egui::Image::new(egui::load::SizedTexture::new(egui_id, [disp_w, disp_h]))
.sense(egui::Sense::click()),
);
if resp.clicked() {
video.playing = !video.playing;
}
});
});
}
// ── Transport bar ──
ui.horizontal(|ui| {
if ui.button(if video.playing { "" } else { "" }).clicked() {
video.playing = !video.playing;
}
ui.label(fmt_time(video.position));
// Timeline fills the space left after reserving room for the total-time
// label and the volume control on the right. Dragging scrubs live (the
// grabber shows the frame under the knob); release commits the seek.
let reserved = 170.0;
let tl_width = (ui.available_width() - reserved).max(80.0);
ui.spacing_mut().slider_width = tl_width;
let mut pos = video.position;
let resp =
ui.add(egui::Slider::new(&mut pos, 0.0..=video.duration.max(0.1)).show_value(false));
if resp.changed() {
video.position = pos; // knob + label follow immediately
video.scrub_request = Some(pos);
if resp.dragged() {
video.scrubbing = true;
} else {
// A click on the track (no drag) → commit right away.
video.seek_request = Some(pos);
video.scrubbing = false;
}
}
if resp.drag_stopped() {
video.seek_request = Some(pos);
video.scrubbing = false;
}
ui.label(fmt_time(video.duration));
ui.separator();
ui.label("🔊");
ui.spacing_mut().slider_width = 90.0;
ui.add_enabled(
video.has_audio,
egui::Slider::new(&mut video.volume, 0.0..=1.0).show_value(false),
);
});
}
// ── World cubemap (skybox) viewer ─────────────────────────────────────────────
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true