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