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:
406
Cargo.lock
generated
406
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user