viewer: a Cutscenes browser -- the manifest was invisible plumbing
`movie_manifest` has been parsed since the movie-voice work and rendered nowhere: it resolved a voice bank and that was all. So the only way to find a cutscene was to hunt `.wmv` files in the ISO tree, where nothing tells you which mission a file belongs to, whether it has subtitles, or what is said in it. View ▸ Cutscenes lists all 104 manifest slots with mission/phase, kind, movie, subtitle track, voice token and telop, and -- the part that needed no new parsing, only a route -- resolves the captions to a readable TRANSCRIPT with a language selector. Subtitles were previously burned into the video during playback and reachable no other way. Three negatives are shown rather than smoothed over: * 5 manifest-bound movies have no `.wmv` (logo1-4 and an encoder test clip). They are marked and get no Play button instead of one that would fail. * 9 of 101 movies resolve no English transcript. * the `.prt` telop overlay is named by the manifest and we have no parser, so the reference is shown labelled "not decoded" rather than omitted. `cutscene_catalog_binds_movies_and_transcripts` pins all of it against the disc -- 104/101/99/99/22, the exact absent-movie list, 92 transcripts -- because a browser that quietly dropped these would look complete and be wrong. The counts independently reproduce docs/re/movie-subtitle-link.md. Play routes through the normal FileSelected path, so the existing video player handles it exactly as it would from the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,8 +25,10 @@ use crate::pak::PakArchive;
|
||||
|
||||
/// Subtitle language. `pak_code` selects `dat/movie/<code>.pak`; `game_code`
|
||||
/// selects `dat/GP_MAIN_GAME_<code>.pak` (the caption text).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SubLang {
|
||||
/// Default only because the disc's own default is English.
|
||||
#[default]
|
||||
English,
|
||||
Japanese,
|
||||
German,
|
||||
|
||||
@@ -167,3 +167,62 @@ fn manifest_slot_and_movie_counts() {
|
||||
// Every id names a real record, so nothing dangles.
|
||||
assert!(entries.iter().all(|e| !e.slot.is_empty() && !e.movie.is_empty()));
|
||||
}
|
||||
|
||||
/// Everything the Cutscenes browser shows, asserted against the disc.
|
||||
///
|
||||
/// The window's value is that it answers "which cutscenes exist, which mission
|
||||
/// is each one in, and can I read it without playing it" — so the test checks
|
||||
/// exactly those three, including the **negative**: five manifest-bound movies
|
||||
/// have no `.wmv`, and nine more have no English transcript. A browser that
|
||||
/// quietly omitted them would look complete and be wrong, so the counts are
|
||||
/// pinned here rather than left to the eye.
|
||||
#[test]
|
||||
fn cutscene_catalog_binds_movies_and_transcripts() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||||
return;
|
||||
};
|
||||
use sylpheed_formats::movie_subtitle as ms;
|
||||
let (manifest, _) = load_manifest_and_sounds(&root);
|
||||
let rows = movie_manifest::parse(&manifest);
|
||||
assert_eq!(rows.len(), 104, "manifest slots");
|
||||
|
||||
let movies: std::collections::BTreeSet<&str> =
|
||||
rows.iter().map(|r| r.movie.as_str()).collect();
|
||||
assert_eq!(movies.len(), 101, "distinct movies");
|
||||
assert_eq!(rows.iter().filter(|r| r.subtitle.is_some()).count(), 99);
|
||||
assert_eq!(rows.iter().filter(|r| r.voice_token.is_some()).count(), 99);
|
||||
assert_eq!(rows.iter().filter(|r| r.telop.is_some()).count(), 22);
|
||||
|
||||
// Bound-but-absent: the four boot logos and one encoder test clip are named
|
||||
// by the manifest and are not on the disc. The browser marks these in red
|
||||
// rather than offering a Play button that would fail.
|
||||
let on_disc: std::collections::BTreeSet<String> = std::fs::read_dir(root.join("dat/movie"))
|
||||
.unwrap()
|
||||
.filter_map(|e| {
|
||||
let p = e.ok()?.path();
|
||||
(p.extension()?.to_str()? == "wmv")
|
||||
.then(|| p.file_stem()?.to_str().map(|s| s.to_ascii_lowercase()))?
|
||||
})
|
||||
.collect();
|
||||
let mut absent: Vec<&str> = movies
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|m| !on_disc.contains(&m.to_ascii_lowercase()))
|
||||
.collect();
|
||||
absent.sort_unstable();
|
||||
assert_eq!(
|
||||
absent,
|
||||
["SYLPH_HD720p_8M-CBR_2ch", "logo1", "logo2", "logo3", "logo4"],
|
||||
"manifest-bound movies with no .wmv on the disc"
|
||||
);
|
||||
|
||||
// Transcripts, which is what makes a cutscene readable without playback.
|
||||
let lang_pak = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
|
||||
let text_pak = PakArchive::open(root.join("dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||
let resolved = movies
|
||||
.iter()
|
||||
.filter(|m| !ms::load(m, &lang_pak, &text_pak).is_empty())
|
||||
.count();
|
||||
assert_eq!(resolved, 92, "movies with an English transcript");
|
||||
}
|
||||
|
||||
@@ -799,6 +799,56 @@ pub struct RequestScreenCompose {
|
||||
pub hidden: Vec<bool>,
|
||||
}
|
||||
|
||||
// ── Cutscenes (View ▸ Cutscenes) ─────────────────────────────────────────────
|
||||
|
||||
/// One row of the cutscene manifest, flattened for display.
|
||||
///
|
||||
/// The manifest has been parsed since the movie-voice work but was never shown:
|
||||
/// it was plumbing that resolved a voice bank and nothing else, so the only way
|
||||
/// to find a cutscene was to hunt `.wmv` files in the ISO tree — with no way to
|
||||
/// know which mission a file belongs to, or whether it has subtitles at all.
|
||||
#[derive(Clone)]
|
||||
pub struct CutsceneRow {
|
||||
/// Manifest slot name, e.g. `STAGE10_PHASE01`.
|
||||
pub slot: String,
|
||||
/// `System` / `Intro` / `Phase` / `PhaseEnd` / `Supply`.
|
||||
pub kind: &'static str,
|
||||
pub mission: Option<u8>,
|
||||
pub phase: Option<u8>,
|
||||
/// Movie basename, e.g. `S02A`. The file is `dat/movie/<movie>.wmv`.
|
||||
pub movie: String,
|
||||
pub voice_token: Option<String>,
|
||||
pub subtitle: Option<String>,
|
||||
/// On-screen text overlay (`.prt`). Bound by the manifest; we have no `.prt`
|
||||
/// parser, so the reference is shown and the content is not.
|
||||
pub telop: Option<String>,
|
||||
/// Whether `dat/movie/<movie>.wmv` is actually on the disc.
|
||||
pub present: bool,
|
||||
}
|
||||
|
||||
/// State of the cutscene browser.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct CutsceneBrowser {
|
||||
pub open: bool,
|
||||
pub loading: bool,
|
||||
pub loaded: bool,
|
||||
pub filter: String,
|
||||
pub rows: Vec<CutsceneRow>,
|
||||
pub selected: Option<usize>,
|
||||
/// Subtitle language for the transcript pane.
|
||||
pub lang: sylpheed_formats::movie_subtitle::SubLang,
|
||||
/// Resolved captions for `selected`, newest request wins.
|
||||
pub cues: Vec<sylpheed_formats::SubCue>,
|
||||
pub cues_loading: bool,
|
||||
pub cues_generation: u64,
|
||||
/// Set by the window to ask for the selected row's transcript.
|
||||
pub want_cues: bool,
|
||||
}
|
||||
|
||||
/// Ask the loader to read the cutscene manifest from `tables.pak`.
|
||||
#[derive(Event, Default)]
|
||||
pub struct RequestCutscenes;
|
||||
|
||||
// ── Save files (View ▸ Save File) ────────────────────────────────────────────
|
||||
|
||||
/// One GHAD field, flattened for display with its confidence.
|
||||
@@ -962,6 +1012,13 @@ enum IsoLoaderMsg {
|
||||
generation: u64,
|
||||
wav: Option<(PathBuf, f32)>,
|
||||
},
|
||||
/// The cutscene manifest, flattened for the browser.
|
||||
CutscenesLoaded(Vec<CutsceneRow>),
|
||||
/// One cutscene's resolved captions. `generation` drops a stale result.
|
||||
CutsceneCuesLoaded {
|
||||
generation: u64,
|
||||
cues: Vec<sylpheed_formats::SubCue>,
|
||||
},
|
||||
/// The sound-bank library enumerated from `<lang>\sounds.tbl`.
|
||||
AudioLibraryLoaded {
|
||||
entries: Vec<sylpheed_formats::slb::AudioEntry>,
|
||||
@@ -1197,6 +1254,7 @@ impl Plugin for IsoLoaderPlugin {
|
||||
.init_resource::<MovieVoice>()
|
||||
.init_resource::<AudioPreview>()
|
||||
.init_resource::<AudioLibrary>()
|
||||
.init_resource::<CutsceneBrowser>()
|
||||
.init_resource::<GameData>()
|
||||
.init_resource::<ShipBrowser>()
|
||||
.init_resource::<ScreenBrowser>()
|
||||
@@ -1205,6 +1263,7 @@ impl Plugin for IsoLoaderPlugin {
|
||||
.add_event::<RequestVoice>()
|
||||
.add_event::<RequestAudio>()
|
||||
.add_event::<RequestAudioLibrary>()
|
||||
.add_event::<RequestCutscenes>()
|
||||
.add_event::<RequestGameData>()
|
||||
.add_event::<RequestShipCatalog>()
|
||||
.add_event::<RequestShipRender>()
|
||||
@@ -1239,11 +1298,13 @@ impl Plugin for IsoLoaderPlugin {
|
||||
handle_audio_request,
|
||||
advance_audio_playback,
|
||||
handle_audio_library_request,
|
||||
// Nested: Bevy's system tuples cap at 20 elements, and
|
||||
// the chain is one ordered sequence either way.
|
||||
(handle_cutscene_request, handle_cutscene_cues_request).chain(),
|
||||
handle_game_data_request,
|
||||
handle_ship_catalog_request,
|
||||
handle_ship_render_request,
|
||||
handle_screen_catalog_request,
|
||||
handle_screen_compose_request,
|
||||
(handle_screen_catalog_request, handle_screen_compose_request).chain(),
|
||||
handle_save_open_request,
|
||||
)
|
||||
.chain()
|
||||
@@ -2097,6 +2158,7 @@ fn poll_loader_channel(
|
||||
mut mvoice: ResMut<MovieVoice>,
|
||||
mut audio_preview: ResMut<AudioPreview>,
|
||||
mut audio_lib: ResMut<AudioLibrary>,
|
||||
mut cutscenes: ResMut<CutsceneBrowser>,
|
||||
mut game_data: ResMut<GameData>,
|
||||
mut ships: ResMut<ShipBrowser>,
|
||||
mut screens: ResMut<ScreenBrowser>,
|
||||
@@ -2205,6 +2267,23 @@ fn poll_loader_channel(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::CutscenesLoaded(rows)) => {
|
||||
cutscenes.loading = false;
|
||||
// The manifest always names ~104 slots, so an empty result means
|
||||
// the read failed — leave `loaded=false` so re-opening retries.
|
||||
if rows.is_empty() {
|
||||
cutscenes.loaded = false;
|
||||
} else {
|
||||
cutscenes.rows = rows;
|
||||
cutscenes.loaded = true;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::CutsceneCuesLoaded { generation, cues }) => {
|
||||
if generation == cutscenes.cues_generation {
|
||||
cutscenes.cues_loading = false;
|
||||
cutscenes.cues = cues;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::AudioLibraryLoaded { entries }) => {
|
||||
audio_lib.loading = false;
|
||||
// sounds.tbl always names thousands of banks, so an empty result
|
||||
@@ -2280,6 +2359,8 @@ fn poll_loader_channel(
|
||||
game_data.loading = false;
|
||||
ships.loading = false;
|
||||
audio_lib.loading = false;
|
||||
cutscenes.loading = false;
|
||||
cutscenes.cues_loading = false;
|
||||
iso_state.error = Some("the asset loader stopped responding".into());
|
||||
break;
|
||||
}
|
||||
@@ -4820,6 +4901,117 @@ fn build_ship_model(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a [`RequestCutscenes`]: reads the cutscene manifest out of
|
||||
/// `tables.pak` and checks each movie against the disc, off-thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle_cutscene_request(
|
||||
mut events: EventReader<RequestCutscenes>,
|
||||
iso_state: Res<IsoState>,
|
||||
browser: Res<crate::ui::FileBrowserState>,
|
||||
channels: Res<IsoChannels>,
|
||||
mut cut: ResMut<CutsceneBrowser>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if cut.loaded || cut.loading {
|
||||
return;
|
||||
}
|
||||
cut.loading = true;
|
||||
let source = iso_state.source_kind.clone();
|
||||
let files = browser.files.clone();
|
||||
let sender = channels.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rows = build_cutscene_catalog(&source, &files);
|
||||
let _ = sender.send(IsoLoaderMsg::CutscenesLoaded(rows));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_cutscene_catalog(source: &SourceKind, files: &[String]) -> Vec<CutsceneRow> {
|
||||
use sylpheed_formats::movie_manifest::{self, MovieKind};
|
||||
let Ok(tpak) = read_pak_archive_blocking(source, "dat/tables.pak") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(manifest) = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Which `.wmv` are actually on the disc, lowercased for the comparison.
|
||||
let on_disc: std::collections::BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let l = f.to_ascii_lowercase();
|
||||
l.strip_suffix(".wmv")
|
||||
.and_then(|s| s.rsplit('/').next())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect();
|
||||
movie_manifest::parse(&manifest)
|
||||
.into_iter()
|
||||
.map(|e| CutsceneRow {
|
||||
kind: match e.kind {
|
||||
MovieKind::System => "System",
|
||||
MovieKind::Intro => "Intro",
|
||||
MovieKind::Phase => "Phase",
|
||||
MovieKind::PhaseEnd => "Phase end",
|
||||
MovieKind::Supply => "Supply",
|
||||
},
|
||||
present: on_disc.contains(&e.movie.to_ascii_lowercase()),
|
||||
slot: e.slot,
|
||||
mission: e.mission,
|
||||
phase: e.phase,
|
||||
movie: e.movie,
|
||||
voice_token: e.voice_token,
|
||||
subtitle: e.subtitle,
|
||||
telop: e.telop,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolves the selected cutscene's captions to text, so a transcript can be
|
||||
/// read **without playing the video** — which was the only route before.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle_cutscene_cues_request(
|
||||
iso_state: Res<IsoState>,
|
||||
channels: Res<IsoChannels>,
|
||||
mut cut: ResMut<CutsceneBrowser>,
|
||||
) {
|
||||
if !cut.want_cues {
|
||||
return;
|
||||
}
|
||||
cut.want_cues = false;
|
||||
let Some(row) = cut.selected.and_then(|i| cut.rows.get(i)) else {
|
||||
return;
|
||||
};
|
||||
let movie = row.movie.clone();
|
||||
cut.cues_generation = cut.cues_generation.wrapping_add(1);
|
||||
cut.cues_loading = true;
|
||||
cut.cues.clear();
|
||||
let generation = cut.cues_generation;
|
||||
let lang = cut.lang;
|
||||
let source = iso_state.source_kind.clone();
|
||||
let sender = channels.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let cues = (|| -> Result<Vec<sylpheed_formats::SubCue>, String> {
|
||||
let lang_pak =
|
||||
read_pak_archive_blocking(&source, &format!("dat/movie/{}.pak", lang.pak_code()))?;
|
||||
let text_pak = read_pak_archive_blocking(
|
||||
&source,
|
||||
&format!("dat/GP_MAIN_GAME_{}.pak", lang.game_code()),
|
||||
)?;
|
||||
Ok(sylpheed_formats::movie_subtitle::load(
|
||||
&movie, &lang_pak, &text_pak,
|
||||
))
|
||||
})()
|
||||
.unwrap_or_default();
|
||||
let _ = sender.send(IsoLoaderMsg::CutsceneCuesLoaded { generation, cues });
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles a [`RequestAudioLibrary`]: reads `tables.pak`'s `<lang>\sounds.tbl`
|
||||
/// and enumerates every named sound bank off-thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -10,12 +10,12 @@ use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
|
||||
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
|
||||
RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose,
|
||||
RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestAudioLibrary, SaveBrowser,
|
||||
ScreenBrowser, ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
|
||||
VideoPreview, AudioLibrary, IsoLoaderSystemSet,
|
||||
AudioLibrary, AudioPreview, CutsceneBrowser, FileInfo, FileSelected, GameCategory, GameData,
|
||||
ImageRgba, IsoLoaderSystemSet, IsoState, ModelPreview, MovieSubtitles, MovieVoice, PakContent,
|
||||
PakView, RequestAudio, RequestAudioLibrary, RequestCutscenes, RequestGameData, RequestOpenDir,
|
||||
RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose, RequestShipCatalog,
|
||||
RequestShipRender, RequestSubtitles, SaveBrowser, ScreenBrowser, ShipBrowser, SkyboxPreview,
|
||||
TextPreview, TexturePreview, VideoPreview,
|
||||
};
|
||||
use crate::ViewerState;
|
||||
use sylpheed_formats::SubLang;
|
||||
@@ -37,6 +37,7 @@ impl Plugin for ViewerUiPlugin {
|
||||
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
|
||||
app.add_systems(Update, draw_screens_ui.after(IsoLoaderSystemSet));
|
||||
app.add_systems(Update, draw_save_ui.after(IsoLoaderSystemSet));
|
||||
app.add_systems(Update, draw_cutscenes_ui.after(IsoLoaderSystemSet));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +154,7 @@ struct UiEvents<'w> {
|
||||
ships: EventWriter<'w, RequestShipCatalog>,
|
||||
screens: EventWriter<'w, RequestScreenCatalog>,
|
||||
save: EventWriter<'w, RequestSaveOpen>,
|
||||
cutscenes: EventWriter<'w, RequestCutscenes>,
|
||||
}
|
||||
|
||||
fn draw_viewer_ui(
|
||||
@@ -228,6 +230,12 @@ fn draw_viewer_ui(
|
||||
events.screens.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("🎬 Cutscenes…").clicked() {
|
||||
// The manifest binds every cutscene slot to its movie,
|
||||
// subtitle track, voice token and telop overlay.
|
||||
events.cutscenes.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("💾 Save File…").clicked() {
|
||||
// A save is not on the disc — it lives in the emulator's
|
||||
// content tree, so this opens a file dialog.
|
||||
@@ -2391,3 +2399,236 @@ fn draw_save_ui(
|
||||
}
|
||||
saves.open &= open;
|
||||
}
|
||||
|
||||
// ── Cutscene browser (View ▸ Cutscenes) ──────────────────────────────────────
|
||||
|
||||
/// The cutscene catalog: every slot the manifest binds, with its movie,
|
||||
/// subtitle track, voice token and telop overlay — and a transcript pane that
|
||||
/// resolves the captions to text **without playing the video**.
|
||||
///
|
||||
/// Before this the manifest was invisible plumbing: it resolved a voice bank and
|
||||
/// was never rendered, so a cutscene could only be found by hunting `.wmv` in
|
||||
/// the ISO tree, where nothing says which mission a file belongs to.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn draw_cutscenes_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut cut: ResMut<CutsceneBrowser>,
|
||||
mut requests: EventReader<RequestCutscenes>,
|
||||
mut file_selected: EventWriter<FileSelected>,
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
) {
|
||||
if requests.read().next().is_some() {
|
||||
cut.open = true;
|
||||
}
|
||||
if !cut.open {
|
||||
return;
|
||||
}
|
||||
let ctx = contexts.ctx_mut().clone();
|
||||
let mut open = true;
|
||||
let mut pick: Option<usize> = None;
|
||||
let mut play: Option<String> = None;
|
||||
|
||||
egui::Window::new("🎬 Cutscenes")
|
||||
.default_width(880.0)
|
||||
.default_height(560.0)
|
||||
.open(&mut open)
|
||||
.show(&ctx, |ui| {
|
||||
if cut.loading {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Reading the cutscene manifest…");
|
||||
});
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
if !cut.loaded {
|
||||
ui.label("Open a game source first (File ▸ Open…).");
|
||||
return;
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("🔍");
|
||||
ui.text_edit_singleline(&mut cut.filter);
|
||||
if !cut.filter.is_empty() && ui.small_button("✖").clicked() {
|
||||
cut.filter.clear();
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Subtitles:");
|
||||
let before = cut.lang;
|
||||
egui::ComboBox::from_id_salt("cutscene_lang")
|
||||
.selected_text(cut.lang.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for l in sylpheed_formats::movie_subtitle::SubLang::ALL {
|
||||
ui.selectable_value(&mut cut.lang, l, l.label());
|
||||
}
|
||||
});
|
||||
if cut.lang != before && cut.selected.is_some() {
|
||||
cut.want_cues = true;
|
||||
}
|
||||
});
|
||||
let missing = cut.rows.iter().filter(|r| !r.present).count();
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"{} slots · {} distinct movies{}",
|
||||
cut.rows.len(),
|
||||
cut.rows
|
||||
.iter()
|
||||
.map(|r| r.movie.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len(),
|
||||
if missing > 0 {
|
||||
format!(" · ⚠ {missing} bound to a movie not on the disc")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
))
|
||||
.weak()
|
||||
.small(),
|
||||
);
|
||||
ui.separator();
|
||||
|
||||
let filter = cut.filter.to_lowercase();
|
||||
egui::SidePanel::left("cutscene_list")
|
||||
.resizable(true)
|
||||
.default_width(330.0)
|
||||
.show_inside(ui, |ui| {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("cutscene_grid")
|
||||
.striped(true)
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
for (i, r) in cut.rows.iter().enumerate() {
|
||||
let hay = format!("{} {} {}", r.slot, r.movie, r.kind)
|
||||
.to_lowercase();
|
||||
if !filter.is_empty() && !hay.contains(&filter) {
|
||||
continue;
|
||||
}
|
||||
let label = match (r.mission, r.phase) {
|
||||
(Some(m), Some(p)) => format!("S{m:02} ph{p} {}", r.movie),
|
||||
(Some(m), None) => format!("S{m:02} {}", r.movie),
|
||||
_ => format!(" {}", r.movie),
|
||||
};
|
||||
let mut text = egui::RichText::new(label).monospace();
|
||||
if !r.present {
|
||||
text = text.color(egui::Color32::from_rgb(224, 86, 122));
|
||||
}
|
||||
if ui
|
||||
.selectable_label(cut.selected == Some(i), text)
|
||||
.on_hover_text(&r.slot)
|
||||
.clicked()
|
||||
{
|
||||
pick = Some(i);
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(r.kind).weak().small(),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||
let Some(row) = cut.selected.and_then(|i| cut.rows.get(i)) else {
|
||||
ui.label("Pick a cutscene on the left.");
|
||||
return;
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&row.slot);
|
||||
if row.present {
|
||||
if ui.button("▶ Play").clicked() {
|
||||
play = Some(format!("dat/movie/{}.wmv", row.movie));
|
||||
}
|
||||
} else {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(224, 86, 122),
|
||||
"⚠ not on the disc",
|
||||
);
|
||||
}
|
||||
});
|
||||
egui::Grid::new("cutscene_detail")
|
||||
.num_columns(2)
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
let mut kv = |k: &str, v: String| {
|
||||
ui.label(egui::RichText::new(k).weak().small());
|
||||
ui.label(egui::RichText::new(v).monospace());
|
||||
ui.end_row();
|
||||
};
|
||||
kv("kind", row.kind.to_string());
|
||||
kv("movie", format!("dat/movie/{}.wmv", row.movie));
|
||||
if let (Some(m), Some(p)) = (row.mission, row.phase) {
|
||||
kv("mission", format!("S{m:02}, phase {p}"));
|
||||
} else if let Some(m) = row.mission {
|
||||
kv("mission", format!("S{m:02}"));
|
||||
}
|
||||
kv(
|
||||
"subtitle",
|
||||
row.subtitle.clone().unwrap_or_else(|| "—".into()),
|
||||
);
|
||||
kv(
|
||||
"voice track",
|
||||
row.voice_token.clone().unwrap_or_else(|| "—".into()),
|
||||
);
|
||||
// The .prt overlay is bound here but we have no parser,
|
||||
// so name the reference and say the content is not read.
|
||||
kv(
|
||||
"telop (.prt)",
|
||||
match &row.telop {
|
||||
Some(t) => format!("{t} (not decoded)"),
|
||||
None => "—".into(),
|
||||
},
|
||||
);
|
||||
});
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Transcript").strong());
|
||||
if cut.cues_loading {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("resolving captions…");
|
||||
});
|
||||
ctx.request_repaint();
|
||||
} else if cut.cues.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"no captions resolved for this movie in this language",
|
||||
)
|
||||
.weak(),
|
||||
);
|
||||
} else {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("cue_scroll")
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new("cue_grid").num_columns(2).striped(true).show(
|
||||
ui,
|
||||
|ui| {
|
||||
for c in &cut.cues {
|
||||
ui.label(
|
||||
egui::RichText::new(fmt_time(c.start))
|
||||
.monospace()
|
||||
.weak()
|
||||
.small(),
|
||||
);
|
||||
ui.label(&c.text);
|
||||
ui.end_row();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some(i) = pick {
|
||||
cut.selected = Some(i);
|
||||
cut.want_cues = true;
|
||||
}
|
||||
if let Some(path) = play {
|
||||
// Route through the normal file-open path, so the existing video player
|
||||
// handles it exactly as it would from the tree.
|
||||
browser.loading = true;
|
||||
browser.selected = browser.files.iter().position(|f| f.eq_ignore_ascii_case(&path));
|
||||
file_selected.send(FileSelected(path));
|
||||
}
|
||||
cut.open = open;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user