Files
Syplheed-Reborn/crates/sylpheed-viewer/src/ui.rs
MechaCat02 91bd2543f4 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>
2026-07-11 14:31:52 +02:00

645 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! egui-based debug UI for the asset viewer.
//!
//! Provides panels for:
//! - File browser (list ISO / extracted game files, filter by name)
//! - Texture inspector (preview + format info)
//! - File info (size, detected format for non-texture files)
//! - RE notes (track discoveries during reverse engineering)
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, SkyboxPreview,
TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
};
use crate::ViewerState;
pub struct ViewerUiPlugin;
impl Plugin for ViewerUiPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(FileBrowserState::default());
// Run after the iso_loader chain so we see the frame's final state.
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
}
}
/// State for the file browser panel.
#[derive(Resource, Default)]
pub struct FileBrowserState {
pub files: Vec<String>,
pub selected: Option<usize>,
pub filter: String,
/// True from the moment a file is clicked until its content is applied —
/// drives the "Loading…" indicator so the click registers immediately.
pub loading: bool,
}
// ── Directory tree ────────────────────────────────────────────────────────────
/// One directory node in the ephemeral file tree. Rebuilt from the flat path
/// list each frame; leaves carry their original index into `browser.files` so
/// selection stays stable regardless of tree shape or filtering.
#[derive(Default)]
struct TreeDir {
/// Child directories, keyed by name (`BTreeMap` = stable alphabetical order).
dirs: std::collections::BTreeMap<String, TreeDir>,
/// Leaf files: (display name, original index into `browser.files`).
files: Vec<(String, usize)>,
}
/// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's
/// data body, not standalone files — the `.pak` index is what the user opens, so
/// they're hidden from the tree (their content loads when the `.pak` is opened).
fn is_pak_segment(leaf: &str) -> bool {
matches!(leaf.rsplit_once('.'), Some((_, ext))
if ext.len() == 3
&& ext.as_bytes()[0] == b'p'
&& ext.as_bytes()[1].is_ascii_digit()
&& ext.as_bytes()[2].is_ascii_digit())
}
/// Split a full path into (directory components, leaf name).
fn split_for_tree(full: &str) -> (Vec<String>, String) {
let mut comps: Vec<String> = full.split('/').map(str::to_string).collect();
let leaf = comps.pop().unwrap_or_default();
(comps, leaf)
}
/// Build the directory tree, keeping only leaves whose full path contains
/// `filter` (case-insensitive; empty filter keeps everything). `.pNN` data
/// segments are skipped — only their `.pak` index is shown.
fn build_tree(files: &[String], filter: &str) -> TreeDir {
let filter = filter.to_lowercase();
let mut root = TreeDir::default();
for (idx, full) in files.iter().enumerate() {
if !filter.is_empty() && !full.to_lowercase().contains(&filter) {
continue;
}
let (dirs, leaf) = split_for_tree(full);
if is_pak_segment(&leaf) {
continue;
}
let mut node = &mut root;
for comp in dirs {
node = node.dirs.entry(comp).or_default();
}
node.files.push((leaf, idx));
}
root
}
/// Render a directory node recursively: sub-directories (collapsible) first,
/// then leaf files as selectable labels.
#[allow(clippy::too_many_arguments)]
fn render_dir(
ui: &mut egui::Ui,
node: &TreeDir,
force_open: bool,
selected: &mut Option<usize>,
loading: &mut bool,
events: &mut EventWriter<FileSelected>,
files: &[String],
) {
for (name, child) in &node.dirs {
egui::CollapsingHeader::new(format!("📁 {name}"))
.id_salt(name)
.default_open(force_open)
.show(ui, |ui| {
render_dir(ui, child, force_open, selected, loading, events, files);
});
}
for (leaf, idx) in &node.files {
let is_selected = *selected == Some(*idx);
if ui
.add(egui::SelectableLabel::new(is_selected, leaf.as_str()))
.clicked()
{
*selected = Some(*idx);
*loading = true; // show the spinner until the content is applied
events.send(FileSelected(files[*idx].clone()));
}
}
}
fn draw_viewer_ui(
mut contexts: EguiContexts,
mut viewer: ResMut<ViewerState>,
mut browser: ResMut<FileBrowserState>,
iso_state: Res<IsoState>,
preview: Res<TexturePreview>,
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>,
mut file_selected_events: EventWriter<FileSelected>,
) {
let ctx = contexts.ctx_mut();
// ── Menu bar ──────────────────────────────────────────────────────────
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
#[cfg(not(target_arch = "wasm32"))]
{
if ui.button("Open ISO disc image…").clicked() {
open_iso_events.send_default();
ui.close_menu();
}
if ui.button("Open extracted folder…").clicked() {
open_dir_events.send_default();
ui.close_menu();
}
ui.separator();
if ui.button("Quit").clicked() {
std::process::exit(0);
}
}
#[cfg(target_arch = "wasm32")]
ui.colored_label(
egui::Color32::GRAY,
"File loading not available in browser",
);
});
ui.menu_button("Help", |ui| {
if ui.button("Controls…").clicked() {
// TODO: controls popup
}
if ui.button("About").clicked() {
// TODO: about dialog
}
});
});
});
// ── Left panel: file browser ──────────────────────────────────────────
egui::SidePanel::left("file_browser")
.resizable(true)
.default_width(280.0)
.show(ctx, |ui| {
ui.heading("Game Files");
ui.separator();
// Filter input
ui.horizontal(|ui| {
ui.label("Filter:");
ui.text_edit_singleline(&mut browser.filter);
});
ui.separator();
// File list
egui::ScrollArea::vertical().show(ui, |ui| {
if iso_state.loading {
ui.spinner();
ui.label("Loading…");
} else if let Some(err) = &iso_state.error {
ui.colored_label(
egui::Color32::RED,
format!("⚠ Error:\n{}", err),
);
} else if browser.files.is_empty() {
ui.colored_label(
egui::Color32::YELLOW,
"No files loaded.\nUse File → Open ISO disc image\n\
or File → Open extracted folder.",
);
} else {
// Build a directory tree from the flat path list. Cheap to
// rebuild every frame (a few hundred short paths); cloning
// `files` first sidesteps borrowing `browser` immutably and
// mutably (selected) at the same time.
let files = browser.files.clone();
let tree = build_tree(&files, &browser.filter);
let force_open = !browser.filter.is_empty();
let FileBrowserState {
selected, loading, ..
} = &mut *browser;
render_dir(
ui,
&tree,
force_open,
selected,
loading,
&mut file_selected_events,
&files,
);
}
});
});
// ── Bottom panel: status bar ──────────────────────────────────────────
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
// `horizontal_wrapped` so the bar wraps instead of overflowing the
// window on narrow widths; the controls hint is right-aligned and only
// the source's final path component is shown (full path on hover).
ui.horizontal_wrapped(|ui| {
if let Some(label) = &iso_state.source_label {
let short = label.rsplit(['/', '\\']).next().unwrap_or(label);
ui.label(short).on_hover_text(label);
ui.separator();
}
ui.label(format!("{} files", browser.files.len()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label("LMB orbit · RMB pan · scroll zoom · R reset");
});
});
});
// ── Central area ──────────────────────────────────────────────────────
if browser.selected.is_some() {
egui::CentralPanel::default().show(ctx, |ui| {
if browser.loading {
// A file was just clicked — show immediate feedback while the
// background read + decode runs, instead of the previous item.
let name = browser
.selected
.and_then(|i| browser.files.get(i))
.map(|p| p.rsplit('/').next().unwrap_or(p))
.unwrap_or("");
ui.centered_and_justified(|ui| {
ui.horizontal(|ui| {
ui.spinner();
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 {
draw_pak_browser(ui, &mut pak_view);
} else if let Some(text) = &text_preview.content {
// ── Plain-text viewer ──
ui.horizontal(|ui| {
ui.heading(&file_info.name);
ui.separator();
ui.label(&text_preview.encoding);
ui.separator();
ui.checkbox(&mut viewer.text_wrap, "Wrap");
if text_preview.truncated {
ui.separator();
ui.colored_label(egui::Color32::YELLOW, "(truncated)");
}
});
ui.separator();
egui::ScrollArea::both().show(ui, |ui| {
// Read-only: `&str` is a non-editable TextBuffer, and
// `.interactive(false)` keeps it selectable/copyable.
let mut buf = text.as_str();
let mut edit = egui::TextEdit::multiline(&mut buf)
.font(egui::TextStyle::Monospace)
.code_editor()
.desired_width(f32::INFINITY)
.interactive(false);
if !viewer.text_wrap {
// Don't wrap: let ScrollArea scroll horizontally.
edit = edit.clip_text(false);
}
ui.add(edit);
});
} else if let Some(egui_id) = preview.egui_id {
// Texture preview
ui.heading(&file_info.name);
ui.label(&preview.format_info);
if !preview.format_info.is_empty() && preview.format_info.contains("failed") {
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
}
ui.separator();
// Scale to fit, preserving aspect ratio, never upscale
let avail = ui.available_size();
let scale = if preview.width == 0 || preview.height == 0 {
1.0_f32
} else {
(avail.x / preview.width as f32)
.min(avail.y / preview.height as f32)
.min(1.0)
};
let display_w = preview.width as f32 * scale;
let display_h = preview.height as f32 * scale;
ui.add(egui::Image::new(egui::load::SizedTexture::new(
egui_id,
[display_w, display_h],
)));
} else {
// Non-texture file info
ui.heading(&file_info.name);
if !preview.format_info.is_empty() {
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
}
if file_info.size_bytes > 0 {
ui.separator();
ui.label(format!("Size: {} bytes", file_info.size_bytes));
if let Some(fmt) = file_info.detected_format {
ui.label(format!("Detected format: {:?}", fmt));
}
} else if iso_state.loading {
ui.spinner();
ui.label("Loading file…");
}
}
});
} else {
// No file selected — show welcome / RE notes
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Project Sylpheed: Arc of Deception");
ui.label("Asset Viewer — Milestone 1");
ui.separator();
ui.collapsing("Getting Started", |ui| {
ui.label("Option A — open an ISO directly:");
ui.code(" File → Open ISO disc image…");
ui.separator();
ui.label("Option B — extract first, then open folder:");
ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
ui.code(" File → Open extracted folder…");
ui.separator();
ui.label("Click a .xpr file in the left panel to preview the texture.");
});
ui.collapsing("Reverse Engineering Notes", |ui| {
ui.label("Document your findings here as you RE the formats.");
ui.separator();
egui::Grid::new("re_notes").striped(true).show(ui, |ui| {
ui.strong("Extension");
ui.strong("Status");
ui.strong("Notes");
ui.end_row();
ui.label(".xpr");
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Partial");
ui.label("XPR2 container + DXT1/3/5 textures");
ui.end_row();
ui.label("?");
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
ui.label("Mesh format — need to identify extension");
ui.end_row();
ui.label("?");
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
ui.label("Audio format — likely XWB wave banks");
ui.end_row();
ui.label("?");
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
ui.label("Mission/level data — format unknown");
ui.end_row();
});
});
});
}
}
// ── Data pack (IPFB / IDXD) browser ───────────────────────────────────────────
/// Master-detail view for an open `.pak`: entry list on the left, the selected
/// entry's IDXD schema + property table on the right.
fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
ui.horizontal(|ui| {
ui.heading(&pak.name);
ui.separator();
ui.label(format!("{} entries", pak.rows.len()));
});
if let Some(err) = &pak.error {
ui.separator();
ui.colored_label(egui::Color32::RED, format!("{err}"));
return;
}
ui.separator();
// Clone rows so the two column closures can read them while we record the
// click into a local (avoids borrowing `pak` mutably inside the closures).
let rows = pak.rows.clone();
let selected = pak.selected;
let mut new_selection = None;
ui.columns(2, |cols| {
// Left — entry list.
egui::ScrollArea::vertical()
.id_salt("pak_list")
.show(&mut cols[0], |ui| {
for (i, row) in rows.iter().enumerate() {
let text = egui::RichText::new(format!(
"{:08x} {:<7} {}",
row.hash, row.format, row.identity
))
.monospace();
if ui
.add(egui::SelectableLabel::new(selected == Some(i), text))
.clicked()
{
new_selection = Some(i);
}
}
});
// Right — detail of the selected entry.
egui::ScrollArea::vertical()
.id_salt("pak_detail")
.show(&mut cols[1], |ui| match selected.and_then(|i| rows.get(i)) {
None => {
ui.label("Select an entry to inspect.");
}
Some(row) => match &row.detail {
Some(d) => {
let title = if row.identity.is_empty() {
row.format.as_str()
} else {
row.identity.as_str()
};
ui.heading(title);
ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count));
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
ui.separator();
if d.fields.is_empty() {
ui.label("(no explicit-value fields)");
} else {
egui::Grid::new("pak_fields")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
for (k, v) in &d.fields {
ui.label(k);
ui.monospace(v);
ui.end_row();
}
});
}
}
None => {
ui.heading(&row.format);
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
if !row.identity.is_empty() {
ui.label(&row.identity);
}
ui.separator();
ui.label("Not an IDXD object — no field table.");
}
},
});
});
if new_selection.is_some() {
pak.selected = new_selection;
}
}
// ── 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
/// interactive skybox is a follow-up — the face data + order are validated, but
/// the wgpu cube-sampling handedness needs visual confirmation first.
fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
ui.horizontal(|ui| {
ui.heading("World cubemap");
ui.separator();
ui.label(&skybox.info);
});
ui.label("6 cube faces, D3D9 order (+X X +Y Y +Z Z).");
ui.separator();
egui::ScrollArea::both().show(ui, |ui| {
const CELL: f32 = 240.0;
egui::Grid::new("cube_faces")
.num_columns(3)
.spacing([10.0, 10.0])
.show(ui, |ui| {
for (i, face) in skybox.faces.iter().enumerate() {
ui.vertical(|ui| {
ui.strong(face.label);
let aspect = face.height as f32 / face.width.max(1) as f32;
ui.add(egui::Image::new(egui::load::SizedTexture::new(
face.egui_id,
[CELL, CELL * aspect],
)));
});
if (i + 1) % 3 == 0 {
ui.end_row();
}
}
});
});
}