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>
2369 lines
86 KiB
Rust
2369 lines
86 KiB
Rust
//! 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");
|
||
}
|
||
}
|