Files
Syplheed-Reborn/crates/sylpheed-viewer/src/iso_loader.rs
MechaCat02 8ed8c85f18 [WIP] Audio codec probe/detection + CLI audio-info + viewer audio panel
Snapshot of local WIP before rebasing onto the movie/voice remote work.
- audio.rs: AudioCodec enum, AudioInfo::probe (RIFF/WAVE parse, raw-XMA2
  Shannon-entropy heuristic looks_like_raw_xma2), from_wav, riff_data_span.
- cli/main.rs: `audio-info` subcommand (cmd_audio_info).
- viewer ui.rs/iso_loader.rs: draw_audio_detail panel wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:42:48 +02:00

2369 lines
86 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.
//! ISO / extracted-directory loading pipeline for the viewer.
//!
//! Bridges the async `XisoReader` API (and synchronous `GameAssets`) with
//! Bevy's ECS via background threads and `std::sync::mpsc` channels polled
//! each frame — no blocking on the main thread.
//!
//! ## Data flow (native)
//!
//! ```text
//! UI click → Event → handle_open_iso / handle_open_dir / handle_file_selected
//! └─ std::thread ─→ mpsc::Sender<IsoLoaderMsg>
//! ↓ (next frame)
//! poll_loader_channel → FileBrowserState / PendingFileBytes
//! ↓
//! apply_loaded_texture → TexturePreview (Image + egui TextureId)
//! ↓
//! draw_viewer_ui → renders preview panel
//! ```
#[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::*;
use bevy_egui::egui;
use sylpheed_formats::vfs::FileFormat;
use crate::ui::FileBrowserState;
// ── Events ────────────────────────────────────────────────────────────────────
/// Fired by the "Open ISO disc image…" menu item.
#[derive(Event, Default)]
pub struct RequestOpenIso;
/// Fired by the "Open extracted folder…" menu item.
#[derive(Event, Default)]
pub struct RequestOpenDir;
/// Fired when the user clicks a file in the browser panel.
#[derive(Event)]
pub struct FileSelected(pub String);
// ── Resources (platform-agnostic) ────────────────────────────────────────────
/// Which kind of source is currently open.
#[derive(Default, Clone)]
pub enum SourceKind {
#[default]
None,
#[cfg(not(target_arch = "wasm32"))]
Iso(PathBuf),
#[cfg(not(target_arch = "wasm32"))]
Directory(PathBuf),
}
/// State of the currently-open file source (ISO or extracted folder).
#[derive(Resource, Default)]
pub struct IsoState {
/// Short description shown in the status bar (filename or directory path).
pub source_label: Option<String>,
/// True while a background thread is loading.
pub loading: bool,
/// The most recent error message (cleared when a new operation starts).
pub error: Option<String>,
/// Which backend to use when reading individual files.
pub source_kind: SourceKind,
}
/// The texture currently shown in the central panel.
#[derive(Resource, Default)]
pub struct TexturePreview {
/// Bevy handle — kept alive so the GPU texture stays allocated.
pub handle: Option<Handle<Image>>,
/// egui texture ID registered via `EguiContexts::add_image`.
pub egui_id: Option<egui::TextureId>,
pub width: u32,
pub height: u32,
/// Human-readable format / dimension summary (or error message).
pub format_info: String,
}
/// Info shown for any selected file (texture or not).
#[derive(Resource, Default)]
pub struct FileInfo {
pub name: String,
pub size_bytes: usize,
pub detected_format: Option<FileFormat>,
}
/// Decoded contents of the currently-selected text file, shown in the central
/// panel. `content` is `None` whenever the selection isn't text.
#[derive(Resource, Default)]
pub struct TextPreview {
/// Decoded UTF-8 (any BOM / UTF-16 already resolved), or `None`.
pub content: Option<String>,
/// Encoding label for the header, e.g. "UTF-16LE (BOM)".
pub encoding: String,
/// True when the file was larger than `MAX_TEXT_BYTES` and got clipped.
pub truncated: bool,
}
/// Cap on the decoded text we hand to egui — its text layout cost grows
/// super-linearly, and disc config files are only a few KiB anyway.
const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024;
/// One row in the pack browser's entry list. Owned/plain so it crosses the
/// loader channel and lives in a resource without borrowing a `PakArchive`.
#[derive(Clone)]
pub struct PakRow {
pub hash: u32,
pub comp_size: u32,
/// Decompressed payload length, or `comp_size` when the entry wasn't decoded.
pub size: usize,
/// Inner-format label (`IDXD`, a 4-char tag, `(not decoded)`, …).
pub format: String,
/// Short identity (`ID=…` / `Name=…`), empty for non-IDXD entries.
pub identity: String,
/// Recovered original TOC path (e.g. `unit\rou_f001.tbl`) via the name-hash,
/// when a known scheme reproduces `hash`. `None` = unresolved (~70% of entries).
pub name: Option<String>,
/// Parsed IDXD detail for the property table, when the entry is an object.
pub detail: Option<PakDetail>,
/// Decoded presentable payload for the detail pane (subtitle / font / image
/// / text). `None` for IDXD (uses `detail`) and un-presentable formats.
pub content: PakContent,
}
/// Presentable, off-thread-decoded content behind a pack entry, shown in the
/// browser's detail pane without touching the Bevy texture pipeline.
#[derive(Clone, Default)]
pub enum PakContent {
#[default]
None,
/// IXUD subtitle / localized-string track.
Subtitle(sylpheed_formats::ixud::Subtitle),
/// Embedded font: metadata + a pre-rasterized sample line (RGBA8), if the
/// font could be rendered off-thread (`None` for collections / CFF that our
/// rasterizer declines — metadata still shows).
Font {
info: sylpheed_formats::FontInfo,
sample: Option<ImageRgba>,
},
/// Decoded PNG image (RGBA8), shown via an egui texture.
Png(ImageRgba),
/// A decoded T8aD 2D texture.
T8ad(ImageRgba),
/// An LSTA sprite list — inline T8aD frames.
Lsta(Vec<ImageRgba>),
/// A RATC bundle — its listed children (T8aD children carry a decoded image).
Ratc(Vec<RatcEntry>),
/// Plain-text / XML payload, with its encoding label.
Text { text: String, encoding: String },
/// An audio stream (WAV / XMA / raw XMA2) — metadata only; XMA isn't decoded.
Audio(sylpheed_formats::AudioInfo),
}
/// One child in a presented RATC bundle.
#[derive(Clone)]
pub struct RatcEntry {
pub name: String,
pub kind: String,
pub size: usize,
/// Decoded image when the child is a (decodable) T8aD within the pixel budget.
pub image: Option<ImageRgba>,
}
/// A plain RGBA8 image handed to the UI for an egui texture.
#[derive(Clone)]
pub struct ImageRgba {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
impl ImageRgba {
fn from_t8ad(img: sylpheed_formats::T8adImage) -> Self {
Self {
width: img.width,
height: img.height,
rgba: img.rgba,
}
}
fn pixels(&self) -> usize {
(self.width as usize) * (self.height as usize)
}
}
/// The parsed IDXD detail behind one entry, shown in the master-detail pane.
#[derive(Clone)]
pub struct PakDetail {
pub schema_hash: u32,
pub count: u32,
/// Explicit (key, value) fields (defaulted fields omitted), owned.
pub fields: Vec<(String, String)>,
}
/// One decoded cubemap face, registered as an egui image.
pub struct FaceTex {
pub label: &'static str,
pub handle: Handle<Image>,
pub egui_id: egui::TextureId,
pub width: u32,
pub height: u32,
}
/// The currently-open world cubemap (`TXCM`), shown as a labelled 6-face grid.
#[derive(Resource, Default)]
pub struct SkyboxPreview {
pub faces: Vec<FaceTex>,
/// Header summary (dimensions / format).
pub info: String,
}
/// Marker for entities spawned to preview an XBG7 3D model, so they can all be
/// despawned when a new file is selected.
#[derive(Component)]
pub struct PreviewModel;
/// The currently-previewed XBG7 3D model. When `active`, the central panel is
/// drawn transparent so the real Bevy scene (mesh + orbit camera) shows through.
#[derive(Resource, Default)]
pub struct ModelPreview {
pub active: bool,
pub name: String,
/// Summary line: sub-meshes / vertices / triangles / texture.
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 {
pub loaded: bool,
pub name: String,
pub rows: Vec<PakRow>,
pub selected: Option<usize>,
pub error: Option<String>,
/// egui-side cache: (entry hash, textures) for the shown entry's image(s) —
/// one for PNG/T8aD/font-sample, many for LSTA/RATC.
pub img_tex: Option<(u32, Vec<egui::TextureHandle>)>,
}
/// Total decompressed bytes we're willing to decode when labelling a pack's
/// entries — bounds work on a huge `sound.pak`; entries past it show as
/// `(not decoded)`.
const PAK_DECODE_BUDGET: usize = 64 * 1024 * 1024;
/// Skip decoding any single entry whose stored size exceeds this (cheap guard
/// 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
/// final state before rendering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct IsoLoaderSystemSet;
// ── Native-only types ─────────────────────────────────────────────────────────
/// Messages sent from background threads back to the Bevy main thread.
#[cfg(not(target_arch = "wasm32"))]
enum IsoLoaderMsg {
FilesListed {
source: SourceKind,
label: String,
files: Vec<String>,
},
FileLoaded {
path: String,
bytes: Vec<u8>,
},
/// A `.pak` archive assembled + labelled off-thread (`error` set on failure).
PakLoaded {
name: String,
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"))]
#[derive(Resource)]
struct IsoChannels {
sender: mpsc::Sender<IsoLoaderMsg>,
receiver: Mutex<mpsc::Receiver<IsoLoaderMsg>>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for IsoChannels {
fn default() -> Self {
let (sender, receiver) = mpsc::channel();
Self {
sender,
receiver: Mutex::new(receiver),
}
}
}
/// One-frame staging buffer: `poll_loader_channel` deposits raw bytes here;
/// `apply_loaded_texture` (chained after) consumes them.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Default)]
struct PendingFileBytes {
ready: bool,
path: String,
bytes: Vec<u8>,
}
/// One-frame staging buffer for a loaded pack; `apply_pak` (chained after)
/// consumes it, freeing the previous preview and populating `PakView`.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Default)]
struct PendingPak {
ready: bool,
name: String,
rows: Vec<PakRow>,
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;
impl Plugin for IsoLoaderPlugin {
fn build(&self, app: &mut App) {
app.add_event::<RequestOpenIso>()
.add_event::<RequestOpenDir>()
.add_event::<FileSelected>()
.init_resource::<IsoState>()
.init_resource::<TexturePreview>()
.init_resource::<FileInfo>()
// Registered unconditionally (even on wasm, where the load systems
// below are cfg'd out) so the UI can always read them.
.init_resource::<TextPreview>()
.init_resource::<PakView>()
.init_resource::<SkyboxPreview>()
.init_resource::<ModelPreview>()
.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,
(
handle_open_iso,
handle_open_dir,
handle_file_selected,
poll_loader_channel,
apply_loaded_texture,
apply_pak,
apply_loaded_video,
advance_video_playback,
)
.chain()
.in_set(IsoLoaderSystemSet),
);
}
}
}
// ── Systems (native-only) ─────────────────────────────────────────────────────
/// Listens for `RequestOpenIso`, opens a native file-picker in a background
/// thread, then lists all files in the selected ISO.
#[cfg(not(target_arch = "wasm32"))]
fn handle_open_iso(
mut events: EventReader<RequestOpenIso>,
mut iso_state: ResMut<IsoState>,
channels: Res<IsoChannels>,
) {
let count = events.read().count();
if count == 0 || iso_state.loading {
return;
}
iso_state.loading = true;
iso_state.error = None;
let sender = channels.sender.clone();
std::thread::spawn(move || {
let path = rfd::FileDialog::new()
.set_title("Open Xbox 360 Disc Image")
.add_filter("Xbox 360 ISO", &["iso", "xiso"])
.pick_file();
let Some(path) = path else {
let _ = sender.send(IsoLoaderMsg::Cancelled);
return;
};
let label = path.display().to_string();
let iso_path = path.clone();
let result = futures::executor::block_on(async move {
let mut reader = sylpheed_formats::xiso::open_iso(&path)
.await
.map_err(|e| e.to_string())?;
let files = reader
.list_all_files()
.await
.map_err(|e| e.to_string())?;
Ok::<_, String>((iso_path, label, files))
});
match result {
Ok((iso_path, label, files)) => {
let _ = sender.send(IsoLoaderMsg::FilesListed {
source: SourceKind::Iso(iso_path),
label,
files,
});
}
Err(e) => {
let _ = sender.send(IsoLoaderMsg::Error(e));
}
}
});
}
/// Listens for `RequestOpenDir`, opens a folder picker, lists files via
/// `GameAssets` (synchronous).
#[cfg(not(target_arch = "wasm32"))]
fn handle_open_dir(
mut events: EventReader<RequestOpenDir>,
mut iso_state: ResMut<IsoState>,
channels: Res<IsoChannels>,
) {
let count = events.read().count();
if count == 0 || iso_state.loading {
return;
}
iso_state.loading = true;
iso_state.error = None;
let sender = channels.sender.clone();
std::thread::spawn(move || {
let path = rfd::FileDialog::new()
.set_title("Open Extracted Game Directory")
.pick_folder();
let Some(path) = path else {
let _ = sender.send(IsoLoaderMsg::Cancelled);
return;
};
let label = path.display().to_string();
let assets = sylpheed_formats::vfs::GameAssets::from_directory(&path);
match assets.list("") {
Ok(files) => {
let _ = sender.send(IsoLoaderMsg::FilesListed {
source: SourceKind::Directory(path),
label,
files,
});
}
Err(e) => {
let _ = sender.send(IsoLoaderMsg::Error(e.to_string()));
}
}
});
}
/// Listens for `FileSelected`, reads the file from the active source,
/// sends bytes back via the channel.
#[cfg(not(target_arch = "wasm32"))]
fn handle_file_selected(
mut events: EventReader<FileSelected>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
for FileSelected(file_path) in events.read() {
let file_path = file_path.clone();
let sender = channels.sender.clone();
// `.pak` archives need their index plus sibling `.pNN` segments, and the
// decode is heavy — assemble + label off-thread, then hand the UI plain
// rows. Everything else falls through to the single-file read below.
if file_path.to_ascii_lowercase().ends_with(".pak") {
let name = file_path.rsplit('/').next().unwrap_or(&file_path).to_string();
let base = file_path[..file_path.len() - 4].to_string(); // strip ".pak"
match &iso_state.source_kind {
SourceKind::Iso(iso_path) => {
let iso_path = iso_path.clone();
let pak_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())?;
let index = reader
.read_file(&pak_path)
.await
.map_err(|e| e.to_string())?;
let mut data = Vec::new();
for i in 0..100u32 {
match reader.read_file(&format!("{base}.p{i:02}")).await {
Ok(mut b) => data.append(&mut b),
Err(_) => break, // first gap = end of segments
}
}
if data.is_empty() {
return Err("no .pNN data segments found".to_string());
}
build_pak_rows(&index, data)
});
let (rows, error) = match result {
Ok(rows) => (rows, None),
Err(e) => (Vec::new(), Some(e)),
};
let _ = sender.send(IsoLoaderMsg::PakLoaded { name, rows, error });
});
}
SourceKind::Directory(root) => {
let root = root.clone();
let pak_path = file_path.clone();
std::thread::spawn(move || {
let assets = sylpheed_formats::vfs::GameAssets::from_directory(&root);
let result = (|| {
let index = assets.read(&pak_path).map_err(|e| e.to_string())?;
let mut data = Vec::new();
for i in 0..100u32 {
match assets.read(&format!("{base}.p{i:02}")) {
Ok(mut b) => data.append(&mut b),
Err(_) => break,
}
}
if data.is_empty() {
return Err("no .pNN data segments found".to_string());
}
build_pak_rows(&index, data)
})();
let (rows, error) = match result {
Ok(rows) => (rows, None),
Err(e) => (Vec::new(), Some(e)),
};
let _ = sender.send(IsoLoaderMsg::PakLoaded { name, rows, error });
});
}
SourceKind::None => {}
}
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();
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())?;
let bytes = reader
.read_file(&file_path)
.await
.map_err(|e| e.to_string())?;
Ok::<_, String>((file_path, bytes))
});
match result {
Ok((path, bytes)) => {
let _ = sender
.send(IsoLoaderMsg::FileLoaded { path, bytes });
}
Err(e) => {
let _ = sender.send(IsoLoaderMsg::Error(e));
}
}
});
}
SourceKind::Directory(root) => {
let assets =
sylpheed_formats::vfs::GameAssets::from_directory(root);
match assets.read(&file_path) {
Ok(bytes) => {
let _ = sender.send(IsoLoaderMsg::FileLoaded {
path: file_path,
bytes,
});
}
Err(e) => {
let _ = sender.send(IsoLoaderMsg::Error(e.to_string()));
}
}
}
SourceKind::None => {}
}
}
}
/// Assemble a `PakArchive` from its index + concatenated segment data, then
/// build one `PakRow` per entry (decompressing + IDXD-parsing under the decode
/// budget). Runs on the loader thread; returns owned rows for the UI.
#[cfg(not(target_arch = "wasm32"))]
fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
use sylpheed_formats::pak::inner_format_label;
use sylpheed_formats::{IdxdObject, PakArchive};
let arc = PakArchive::from_parts(index, data).map_err(|e| e.to_string())?;
let mut rows = Vec::with_capacity(arc.entries().len());
let mut decoded_budget: usize = 0;
for e in arc.entries() {
// Skip decoding oversized or over-budget entries — bounds a huge pack.
if e.comp_size > PAK_ENTRY_COMP_CAP || decoded_budget > PAK_DECODE_BUDGET {
rows.push(PakRow {
hash: e.name_hash,
comp_size: e.comp_size,
size: e.comp_size as usize,
format: "(not decoded)".into(),
identity: String::new(),
name: None,
detail: None,
content: PakContent::None,
});
continue;
}
match arc.read(e) {
Ok(payload) => {
decoded_budget += payload.len();
let format = inner_format_label(&payload);
let (identity, name, detail) = if IdxdObject::is_idxd(&payload) {
match IdxdObject::parse(&payload) {
Ok(obj) => {
let fields = obj
.resolved_fields()
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
(
obj.identity(),
obj.recover_toc_path(e.name_hash),
Some(PakDetail {
schema_hash: obj.schema_hash,
count: obj.count,
fields,
}),
)
}
Err(_) => (String::new(), None, None),
}
} else {
(String::new(), None, None)
};
// Presentable content for non-IDXD entries (subtitle/font/png/text).
let content = if detail.is_some() {
PakContent::None
} else {
classify_content(&payload)
};
rows.push(PakRow {
hash: e.name_hash,
comp_size: e.comp_size,
size: payload.len(),
format,
identity,
name,
detail,
content,
});
}
Err(err) => rows.push(PakRow {
hash: e.name_hash,
comp_size: e.comp_size,
size: e.comp_size as usize,
format: "(read error)".into(),
identity: err.to_string(),
name: None,
detail: None,
content: PakContent::None,
}),
}
}
Ok(rows)
}
/// Cap on decoded text we retain per pack entry (xml/text preview).
#[cfg(not(target_arch = "wasm32"))]
const PAK_TEXT_CAP: usize = 512 * 1024;
/// Cap on decoded child-image texels per LSTA/RATC entry (~8 M texels = 32 MB
/// RGBA). Further children past it are listed but not decoded to an image.
#[cfg(not(target_arch = "wasm32"))]
const PAK_IMAGE_TEXEL_BUDGET: usize = 8 * 1024 * 1024;
/// Classify a (non-IDXD) decompressed entry into presentable content: subtitle
/// track, embedded font, PNG image, or plain text/XML. Runs off-thread.
#[cfg(not(target_arch = "wasm32"))]
fn classify_content(payload: &[u8]) -> PakContent {
use sylpheed_formats::{font, ixud, vfs};
if ixud::is_ixud(payload) {
if let Some(sub) = ixud::parse(payload) {
return PakContent::Subtitle(sub);
}
}
if font::is_font(payload) {
if let Some(info) = font::parse_info(payload) {
// Rasterize a sample line here (off-thread) for single-face fonts —
// never touch egui's global font system on the main thread.
let sample = if info.faces <= 1 && info.kind != "TrueType Collection" {
render_font_sample(payload, "The quick brown fox — 0123456789 !?&", 34.0)
} else {
None
};
return PakContent::Font { info, sample };
}
}
if payload.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
if let Ok(img) = image::load_from_memory_with_format(payload, image::ImageFormat::Png) {
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
return PakContent::Png(ImageRgba {
width,
height,
rgba: rgba.into_raw(),
});
}
}
if sylpheed_formats::t8ad::is_t8ad(payload) {
if let Some(img) = sylpheed_formats::t8ad::parse(payload) {
return PakContent::T8ad(ImageRgba::from_t8ad(img));
}
}
if sylpheed_formats::lsta::is_lsta(payload) {
if let Some(frames) = sylpheed_formats::lsta::parse(payload) {
let mut budget = PAK_IMAGE_TEXEL_BUDGET;
let out: Vec<ImageRgba> = frames
.into_iter()
.map(ImageRgba::from_t8ad)
.take_while(|f| {
budget = budget.saturating_sub(f.pixels());
budget > 0
})
.collect();
if !out.is_empty() {
return PakContent::Lsta(out);
}
}
}
if sylpheed_formats::ratc::is_ratc(payload) {
if let Some(children) = sylpheed_formats::ratc::parse(payload) {
let mut budget = PAK_IMAGE_TEXEL_BUDGET;
let entries: Vec<RatcEntry> = children
.into_iter()
.map(|c| {
// Decode T8aD children up to the pixel budget; list the rest.
let mut image = None;
if c.kind == "T8aD" && budget > 0 {
if let Some(img) = payload
.get(c.offset..c.offset + c.size)
.and_then(sylpheed_formats::t8ad::parse)
.map(ImageRgba::from_t8ad)
{
budget = budget.saturating_sub(img.pixels());
image = Some(img);
}
}
RatcEntry {
name: c.name,
kind: c.kind,
size: c.size,
image,
}
})
.collect();
if !entries.is_empty() {
return PakContent::Ratc(entries);
}
}
}
if payload.starts_with(b"<?xml") || vfs::identify_format(payload) == vfs::FileFormat::Text {
let (mut text, encoding) = vfs::decode_text(payload);
if text.len() > PAK_TEXT_CAP {
let end = text
.char_indices()
.take_while(|(i, _)| *i < PAK_TEXT_CAP)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
text.truncate(end);
}
return PakContent::Text {
text,
encoding: encoding.to_string(),
};
}
// Last resort: a self-describing WAV/XMA header, or a header-less high-
// entropy blob (the game's raw XMA2 sound-bank streams).
let audio = sylpheed_formats::AudioInfo::probe(payload);
if audio.codec != sylpheed_formats::AudioCodec::Unknown {
return PakContent::Audio(audio);
}
PakContent::None
}
/// Rasterize a sample line with the embedded font into a white-on-transparent
/// RGBA image. Fully off-thread and panic-free: `ab_glyph` returns `None` for
/// unusable fonts/glyphs rather than unwrapping (unlike egui's global fonts).
#[cfg(not(target_arch = "wasm32"))]
fn render_font_sample(bytes: &[u8], text: &str, px: f32) -> Option<ImageRgba> {
use ab_glyph::{point, Font, FontRef, ScaleFont};
let font = FontRef::try_from_slice(bytes).ok()?;
let scaled = font.as_scaled(px);
let (ascent, descent) = (scaled.ascent(), scaled.descent());
let pad = 2.0;
// Lay glyphs left-to-right on a single baseline.
let mut caret = pad;
let mut glyphs = Vec::new();
for ch in text.chars() {
let id = font.glyph_id(ch);
glyphs.push(id.with_scale_and_position(px, point(caret, pad + ascent)));
caret += scaled.h_advance(id);
}
let width = (caret + pad).ceil().max(1.0) as u32;
let height = (pad * 2.0 + ascent - descent).ceil().max(1.0) as u32;
if width > 8192 || height > 512 {
return None; // sanity bound
}
let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4];
let mut drew_any = false;
for g in glyphs {
if let Some(outline) = font.outline_glyph(g) {
let bb = outline.px_bounds();
outline.draw(|gx, gy, cov| {
let x = bb.min.x as i32 + gx as i32;
let y = bb.min.y as i32 + gy as i32;
if x >= 0 && y >= 0 && (x as u32) < width && (y as u32) < height {
let i = ((y as u32 * width + x as u32) * 4) as usize;
let a = (cov * 255.0) as u8;
rgba[i] = 255;
rgba[i + 1] = 255;
rgba[i + 2] = 255;
rgba[i + 3] = rgba[i + 3].max(a);
drew_any = true;
}
});
}
}
drew_any.then_some(ImageRgba {
width,
height,
rgba,
})
}
// ── 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"))]
fn poll_loader_channel(
channels: Res<IsoChannels>,
mut iso_state: ResMut<IsoState>,
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 {
use std::sync::mpsc::TryRecvError;
match receiver.try_recv() {
Ok(IsoLoaderMsg::FilesListed {
source,
label,
files,
}) => {
iso_state.loading = false;
iso_state.error = None;
iso_state.source_label = Some(label);
iso_state.source_kind = source;
browser.files = files;
browser.selected = None;
browser.filter.clear();
info!("Loaded {} files", browser.files.len());
}
Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => {
pending.path = path;
pending.bytes = bytes;
pending.ready = true;
browser.loading = false;
}
Ok(IsoLoaderMsg::PakLoaded { name, rows, error }) => {
pending_pak.name = name;
pending_pak.rows = rows;
pending_pak.error = error;
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;
}
Ok(IsoLoaderMsg::Error(msg)) => {
error!("ISO loader: {}", msg);
iso_state.loading = false;
iso_state.error = Some(msg);
browser.loading = false;
}
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
}
}
}
/// Free the current texture preview's GPU image + egui slot and reset it.
#[cfg(not(target_arch = "wasm32"))]
fn free_texture(
preview: &mut TexturePreview,
images: &mut Assets<Image>,
contexts: &mut bevy_egui::EguiContexts<'_, '_>,
) {
if let Some(ref handle) = preview.handle {
contexts.remove_image(handle);
images.remove(handle.id());
}
*preview = TexturePreview::default();
}
/// Free the current skybox preview's per-face GPU images + egui slots and reset it.
#[cfg(not(target_arch = "wasm32"))]
fn free_skybox(
skybox: &mut SkyboxPreview,
images: &mut Assets<Image>,
contexts: &mut bevy_egui::EguiContexts<'_, '_>,
) {
for f in &skybox.faces {
contexts.remove_image(&f.handle);
images.remove(f.handle.id());
}
*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();
}
/// Build Bevy meshes from a decoded [`Xbg7Model`], texture them with the model's
/// albedo (`_col`) map, spawn them into the scene tagged [`PreviewModel`], and
/// frame the orbit camera on the combined bounding box.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
fn spawn_model_preview(
model: &sylpheed_formats::mesh::Xbg7Model,
xpr_bytes: &[u8],
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
images: &mut Assets<Image>,
model_preview: &mut ModelPreview,
orbit: &mut Query<&mut crate::camera::OrbitCamera>,
) {
use bevy::render::mesh::{Indices, PrimitiveTopology};
use sylpheed_formats::texture::X360Texture;
// ── Albedo texture: prefer the `_col` map, else the first texture. ──
let names = X360Texture::texture_names(xpr_bytes);
let col_idx = names
.iter()
.position(|n| n.ends_with("_col"))
.or(if names.is_empty() { None } else { Some(0) });
let (base_color_texture, tex_label) = match col_idx {
Some(i) => match X360Texture::from_xpr2_index(xpr_bytes, i)
.ok()
.and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok())
{
Some(img) => (Some(images.add(img)), names[i].clone()),
None => (None, "none".to_string()),
},
None => (None, "none".to_string()),
};
let material = materials.add(StandardMaterial {
base_color: Color::srgb(0.8, 0.8, 0.82),
base_color_texture,
perceptual_roughness: 0.7,
metallic: 0.1,
// Xbox winding / our handedness may disagree — show both sides so a
// model is never invisible from the "back".
cull_mode: None,
double_sided: true,
..default()
});
// ── Combined bounding box for camera framing. ──
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
// Parent entity so the whole model can be despawned / transformed as one.
let root = commands
.spawn((
PreviewModel,
Transform::default(),
Visibility::default(),
Name::new(model.name.clone()),
))
.id();
let mut total_v = 0usize;
let mut total_t = 0usize;
for sub in &model.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
total_v += sub.positions.len();
total_t += sub.indices.len() / 3;
for p in &sub.positions {
lo = lo.min(Vec3::from_array(*p));
hi = hi.max(Vec3::from_array(*p));
}
let positions: Vec<[f32; 3]> = sub.positions.clone();
let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() {
sub.uvs.clone()
} else {
vec![[0.0, 0.0]; sub.positions.len()]
};
// Prefer the model's own normals; fall back to computed smooth normals.
let normals = if sub.normals.len() == sub.positions.len() {
sub.normals.clone()
} else {
compute_smooth_normals(&positions, &sub.indices)
};
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_indices(Indices::U32(sub.indices.clone()));
let child = commands
.spawn((
PreviewModel,
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(material.clone()),
Transform::default(),
))
.id();
commands.entity(root).add_child(child);
}
// ── Frame the orbit camera on the model. ──
let center = if lo.x <= hi.x { (lo + hi) * 0.5 } else { Vec3::ZERO };
let extent = if lo.x <= hi.x {
(hi - lo).length().max(0.001)
} else {
5.0
};
if let Ok(mut cam) = orbit.get_single_mut() {
cam.focus = center;
cam.radius = extent * 1.4;
cam.min_radius = (extent * 0.02).max(0.02);
cam.max_radius = (extent * 20.0).max(50.0);
}
model_preview.active = true;
model_preview.name = model.name.clone();
model_preview.info = format!(
"XBG7 model · {} sub-mesh(es) · {} verts · {} tris · albedo: {}",
model.meshes.len(),
total_v,
total_t,
tex_label,
);
}
/// Spawn every decoded sub-model of a **stage** container as a side-by-side row
/// (a "cast sheet"): the stage's enemy / prop models are separate objects with no
/// recovered scene transforms, so each is recentred on its own origin and laid
/// out along +X, spaced by its width. The orbit camera frames the whole row.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
fn spawn_stage_models(
models: &[sylpheed_formats::mesh::Xbg7Model],
xpr_bytes: &[u8],
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
images: &mut Assets<Image>,
model_preview: &mut ModelPreview,
orbit: &mut Query<&mut crate::camera::OrbitCamera>,
) {
use bevy::render::mesh::{Indices, PrimitiveTopology};
use sylpheed_formats::texture::X360Texture;
// Per-sub-model albedo: match each model's name to its `_col` texture
// (e.g. `e003` → `e003_bdy_col`, `e005` → `e005_01_col`). Decode each once
// and cache by texture index; fall back to a neutral tint when no `_col`
// channel matches. Colours are the same "unverified" item as the 2D textures.
let tex_names = X360Texture::texture_names(xpr_bytes);
let neutral = materials.add(StandardMaterial {
base_color: Color::srgb(0.72, 0.74, 0.78),
perceptual_roughness: 0.65,
metallic: 0.1,
cull_mode: None,
double_sided: true,
..default()
});
let mut tex_cache: std::collections::HashMap<usize, Handle<StandardMaterial>> =
std::collections::HashMap::new();
let material_for = |model_name: &str,
materials: &mut Assets<StandardMaterial>,
images: &mut Assets<Image>,
cache: &mut std::collections::HashMap<usize, Handle<StandardMaterial>>|
-> Handle<StandardMaterial> {
// Core name = strip leading `_`/`rou_` and trailing `_l` LOD suffix.
let core = model_name
.trim_start_matches('_')
.trim_start_matches("rou_")
.trim_end_matches("_l");
let idx = tex_names.iter().position(|n| {
n.ends_with("_col") && (n.starts_with(core) || n.contains(core))
});
let Some(i) = idx else { return neutral.clone() };
if let Some(h) = cache.get(&i) {
return h.clone();
}
let handle = X360Texture::from_xpr2_index(xpr_bytes, i)
.ok()
.and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok())
.map(|img| {
materials.add(StandardMaterial {
base_color: Color::WHITE,
base_color_texture: Some(images.add(img)),
perceptual_roughness: 0.7,
metallic: 0.1,
cull_mode: None,
double_sided: true,
..default()
})
})
.unwrap_or_else(|| neutral.clone());
cache.insert(i, handle.clone());
handle
};
let root = commands
.spawn((
PreviewModel,
Transform::default(),
Visibility::default(),
Name::new("stage"),
))
.id();
// A stage holds up to ~450 sub-models at wildly different true scales (a
// 1-unit prop next to a 3000-unit structure) with no recovered scene
// transforms. Laying them end-to-end made a mile-wide strip the camera
// couldn't escape. Instead present a **thumbnail grid**: each model is
// recentred and uniformly scaled to a fixed cell, so all are equally visible
// and browsable regardless of native size. Grid spans the XY plane.
const CELL: f32 = 10.0; // target on-screen size of each thumbnail
const GAP: f32 = 4.0;
let pitch = CELL + GAP;
// Extent above which a sub-model is a skybox plane (300 k-unit cloud quad) or
// a stray-vertex mis-anchor rather than a real object — dropped from the grid.
const MAX_EXTENT: f32 = 20_000.0;
let mut drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = Vec::new();
for model in models {
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
let mut has_geo = false;
for sub in &model.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
has_geo = true;
for p in &sub.positions {
lo = lo.min(Vec3::from_array(*p));
hi = hi.max(Vec3::from_array(*p));
}
}
if has_geo && (hi - lo).max_element() <= MAX_EXTENT {
drawn.push(model);
}
}
let cols = (drawn.len() as f32).sqrt().ceil().max(1.0) as usize;
let mut total_v = 0usize;
let mut total_t = 0usize;
for (i, model) in drawn.iter().enumerate() {
// Per-model bbox → recentre + uniform scale to the cell.
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
for sub in &model.meshes {
for p in &sub.positions {
lo = lo.min(Vec3::from_array(*p));
hi = hi.max(Vec3::from_array(*p));
}
}
if lo.x > hi.x {
continue;
}
let center = (lo + hi) * 0.5;
let extent = (hi - lo).max_element().max(1e-3);
let scale = CELL / extent;
// Grid cell centre (row-major, growing down in Y).
let col = i % cols;
let row = i / cols;
let cell_pos = Vec3::new(col as f32 * pitch, -(row as f32) * pitch, 0.0);
// One entity per model carries the placement + normalising scale; its
// sub-meshes are recentred to the model's own origin underneath it.
let model_root = commands
.spawn((
PreviewModel,
Transform::from_translation(cell_pos).with_scale(Vec3::splat(scale)),
Visibility::default(),
Name::new(model.name.clone()),
))
.id();
commands.entity(root).add_child(model_root);
let material = material_for(&model.name, materials, images, &mut tex_cache);
for sub in &model.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
total_v += sub.positions.len();
total_t += sub.indices.len() / 3;
let positions: Vec<[f32; 3]> =
sub.positions.iter().map(|p| {
[p[0] - center.x, p[1] - center.y, p[2] - center.z]
}).collect();
let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() {
sub.uvs.clone()
} else {
vec![[0.0, 0.0]; sub.positions.len()]
};
let normals = if sub.normals.len() == sub.positions.len() {
sub.normals.clone()
} else {
compute_smooth_normals(&positions, &sub.indices)
};
let mut mesh =
Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default());
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_indices(Indices::U32(sub.indices.clone()));
let child = commands
.spawn((
PreviewModel,
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(material.clone()),
Transform::default(),
))
.id();
commands.entity(model_root).add_child(child);
}
}
// Frame the whole grid.
let rows = drawn.len().div_ceil(cols);
let grid_w = cols as f32 * pitch;
let grid_h = rows as f32 * pitch;
let center = Vec3::new(grid_w * 0.5 - pitch * 0.5, -(grid_h * 0.5 - pitch * 0.5), 0.0);
let diag = (grid_w * grid_w + grid_h * grid_h).sqrt().max(CELL);
if let Ok(mut cam) = orbit.get_single_mut() {
cam.focus = center;
cam.radius = diag * 0.75;
cam.min_radius = CELL * 0.05;
cam.max_radius = diag * 3.0;
}
model_preview.active = true;
model_preview.name = "stage".to_string();
model_preview.info = format!(
"Stage container · {} sub-models (thumbnail grid, each scaled to fit) · \
{} verts · {} tris (content-anchored; hero bodies with quantized layout \
are skipped)",
drawn.len(),
total_v,
total_t,
);
}
/// Per-vertex smooth normals = normalized sum of incident face normals.
#[cfg(not(target_arch = "wasm32"))]
fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
let mut acc = vec![Vec3::ZERO; positions.len()];
for tri in indices.chunks_exact(3) {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a >= positions.len() || b >= positions.len() || c >= positions.len() {
continue;
}
let pa = Vec3::from_array(positions[a]);
let pb = Vec3::from_array(positions[b]);
let pc = Vec3::from_array(positions[c]);
let n = (pb - pa).cross(pc - pa);
acc[a] += n;
acc[b] += n;
acc[c] += n;
}
acc.into_iter()
.map(|n| n.normalize_or_zero().to_array())
.map(|n| if n == [0.0, 0.0, 0.0] { [0.0, 1.0, 0.0] } else { n })
.collect()
}
/// Converts pending raw bytes into a Bevy `Image`, registers it with egui,
/// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`.
#[cfg(not(target_arch = "wasm32"))]
fn apply_loaded_texture(
mut pending: ResMut<PendingFileBytes>,
mut preview: ResMut<TexturePreview>,
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,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut model_preview: ResMut<ModelPreview>,
old_models: Query<Entity, With<PreviewModel>>,
mut orbit: Query<&mut crate::camera::OrbitCamera>,
) {
if !pending.ready {
return;
}
pending.ready = false;
let bytes = std::mem::take(&mut pending.bytes);
let path = std::mem::take(&mut pending.path);
let fmt = sylpheed_formats::vfs::identify_format(&bytes);
// Always free the previous previews (GPU memory + egui slots) so exactly one
// 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();
// Despawn any previously-previewed 3D model.
for e in old_models.iter() {
commands.entity(e).despawn_recursive();
}
*model_preview = ModelPreview::default();
// Populate FileInfo for the status / info panel.
file_info.name = path
.split('/')
.next_back()
.unwrap_or(&path)
.to_string();
file_info.size_bytes = bytes.len();
file_info.detected_format = Some(fmt);
if fmt == FileFormat::Text {
let (mut text, encoding) = sylpheed_formats::vfs::decode_text(&bytes);
let truncated = text.len() > MAX_TEXT_BYTES;
if truncated {
// Cut on a char boundary so we never split a codepoint.
let end = text
.char_indices()
.take_while(|(i, _)| *i < MAX_TEXT_BYTES)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
text.truncate(end);
}
text_preview.content = Some(text);
text_preview.encoding = encoding.to_string();
text_preview.truncated = truncated;
return; // FileInfo + TextPreview are sufficient for text files.
}
if fmt != FileFormat::Xpr2Texture {
return; // Not a texture — FileInfo is sufficient
}
// 3D model? XPR2 containers that hold XBG7 geometry (ship / weapon / prop
// models under `hidden/resource3d/`) preview as an orbitable mesh, textured
// with their albedo (`_col`) map. Complex multi-stream body meshes decline
// cleanly (Err) and fall through to the 2D texture preview below.
// A container may decode as a single model (weapons / props, via the
// single-block layout) OR as a stage — a collection of many enemy / prop
// sub-models. A stage's *first* XBG7 is often a tiny dummy "root" node that
// the single-model path decodes into a degenerate cube, so we can't just try
// it first and stop. Decode both and keep whichever yields more geometry.
use sylpheed_formats::mesh::Xbg7Model;
let single = Xbg7Model::from_xpr2(&bytes)
.ok()
.filter(|m| !m.meshes.is_empty());
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
let stage = Xbg7Model::stage_models(&bytes);
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
if !stage.is_empty() && stage_verts > single_verts {
spawn_stage_models(
&stage,
&bytes,
&mut commands,
&mut meshes,
&mut materials,
&mut images,
&mut model_preview,
&mut orbit,
);
return;
}
if let Some(model) = single {
spawn_model_preview(
&model,
&bytes,
&mut commands,
&mut meshes,
&mut materials,
&mut images,
&mut model_preview,
&mut orbit,
);
return;
}
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.
// Returns `None` for ordinary 2D textures, which fall through below.
match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) {
Ok(Some(cube)) => {
use sylpheed_formats::texture::{Cubemap, X360Texture};
for (i, face) in cube.faces.iter().enumerate() {
let face_tex = X360Texture {
width: cube.width,
height: cube.height,
format: cube.format,
mip_levels: 1,
is_cubemap: false,
data: face.clone(),
};
if let Ok(img) = crate::asset_loader::x360_texture_to_bevy_image(face_tex) {
let handle = images.add(img);
let egui_id = contexts.add_image(handle.clone_weak());
skybox.faces.push(FaceTex {
label: Cubemap::face_label(i),
handle,
egui_id,
width: cube.width,
height: cube.height,
});
}
}
skybox.info = format!(
"Cubemap {}×{} {:?} · {} faces",
cube.width,
cube.height,
cube.format,
skybox.faces.len()
);
return;
}
Ok(None) => {} // ordinary 2D texture
Err(e) => {
preview.format_info = format!("Cubemap parse failed: {e}");
return;
}
}
let tex = match sylpheed_formats::X360Texture::from_xpr2(&bytes) {
Ok(t) => t,
Err(e) => {
preview.format_info = format!("XPR2 parse failed: {e}");
return;
}
};
let w = tex.width;
let h = tex.height;
let mips = tex.mip_levels;
// Canary-sourced format name + metadata (GPUTEXTUREFORMAT), e.g.
// "k_DXT1 (Dxt1) · 256×256 · 9 mip(s) · 4 bpp, compressed".
let d = tex.format.desc();
let fmt_str = format!(
"{} ({:?}) · {}×{} · {} mip(s) · {} bpp, {}",
tex.format.gpu_name(),
tex.format,
w,
h,
mips,
d.bpp,
if d.compressed { "compressed" } else { "uncompressed" },
);
let image = match crate::asset_loader::x360_texture_to_bevy_image(tex) {
Ok(img) => img,
Err(e) => {
preview.format_info = format!("Bevy image conversion failed: {e}");
return;
}
};
let handle = images.add(image);
let egui_id = contexts.add_image(handle.clone_weak());
preview.handle = Some(handle);
preview.egui_id = Some(egui_id);
preview.width = w;
preview.height = h;
preview.format_info = fmt_str;
}
/// Consumes a staged pack, freeing the previous texture/text previews and
/// populating `PakView` for the master-detail browser.
#[cfg(not(target_arch = "wasm32"))]
fn apply_pak(
mut pending: ResMut<PendingPak>,
mut pak_view: ResMut<PakView>,
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,
) {
if !pending.ready {
return;
}
pending.ready = false;
// 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();
file_info.size_bytes = 0;
file_info.detected_format = None;
*pak_view = PakView {
loaded: true,
name: std::mem::take(&mut pending.name),
rows: std::mem::take(&mut pending.rows),
selected: None,
error: pending.error.take(),
..Default::default()
};
}
/// 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;
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod font_sample_tests {
use super::*;
use std::path::Path;
/// The real English movie font must rasterize a sample off-thread without
/// panicking (regression for the egui `set_fonts` crash).
#[test]
fn real_font_rasterizes() {
let pak = Path::new("/tmp/sylph_extract/dat/movie/eng.pak");
if !pak.exists() {
eprintln!("SKIP: extract not present");
return;
}
let arc = sylpheed_formats::PakArchive::open(pak).unwrap();
let bytes = arc.read_by_hash(0x5cd0_fca6).unwrap().unwrap();
assert!(sylpheed_formats::font::is_font(&bytes));
let img = render_font_sample(&bytes, "The quick brown fox 0123", 34.0)
.expect("real TrueType font should rasterize a sample");
assert!(img.width > 0 && img.height > 0);
assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
assert!(img.rgba.iter().skip(3).step_by(4).any(|&a| a > 0), "some ink");
}
}