Merge remote-tracking branch 'origin/main' into auto/no-disc-and-menu-captures
This commit is contained in:
@@ -843,6 +843,37 @@ pub struct CutsceneBrowser {
|
||||
pub cues_generation: u64,
|
||||
/// Set by the window to ask for the selected row's transcript.
|
||||
pub want_cues: bool,
|
||||
/// Where this cutscene's voice actually lives, versus where its name says.
|
||||
pub voice: Option<VoiceLocation>,
|
||||
}
|
||||
|
||||
/// A cutscene's voice, located two ways — because they disagree.
|
||||
///
|
||||
/// The movie voices are one continuous XMA stream chunked into `VOICE_*.slb`
|
||||
/// TOC entries whose boundaries do **not** match the cutscene cues. So the bank
|
||||
/// named after a movie need not hold that movie's audio: `RT01A`'s voice sits
|
||||
/// inside the byte range of the entry named `VOICE_ADV.slb`.
|
||||
///
|
||||
/// Showing both is the point. A viewer that played the name-matched bank would
|
||||
/// be confidently wrong for exactly the cutscenes where it matters.
|
||||
#[derive(Clone)]
|
||||
pub struct VoiceLocation {
|
||||
/// The bank the naming convention points at, e.g. `eng\Movie\VOICE_ADV.slb`.
|
||||
pub named_bank: String,
|
||||
/// That bank's byte range in the sound stream, if it is in the TOC at all.
|
||||
pub named_range: Option<(u64, u64)>,
|
||||
/// Where the audio for this movie actually is.
|
||||
pub region: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
impl VoiceLocation {
|
||||
/// True when the resolved audio lies inside the bank named after the movie.
|
||||
pub fn name_is_honest(&self) -> bool {
|
||||
match (self.named_range, self.region) {
|
||||
(Some((a, b)), Some((s, e))) => s >= a && e <= b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the loader to read the cutscene manifest from `tables.pak`.
|
||||
@@ -1018,6 +1049,7 @@ enum IsoLoaderMsg {
|
||||
CutsceneCuesLoaded {
|
||||
generation: u64,
|
||||
cues: Vec<sylpheed_formats::SubCue>,
|
||||
voice: Option<VoiceLocation>,
|
||||
},
|
||||
/// The sound-bank library enumerated from `<lang>\sounds.tbl`.
|
||||
AudioLibraryLoaded {
|
||||
@@ -2278,10 +2310,15 @@ fn poll_loader_channel(
|
||||
cutscenes.loaded = true;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::CutsceneCuesLoaded { generation, cues }) => {
|
||||
Ok(IsoLoaderMsg::CutsceneCuesLoaded {
|
||||
generation,
|
||||
cues,
|
||||
voice,
|
||||
}) => {
|
||||
if generation == cutscenes.cues_generation {
|
||||
cutscenes.cues_loading = false;
|
||||
cutscenes.cues = cues;
|
||||
cutscenes.voice = voice;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::AudioLibraryLoaded { entries }) => {
|
||||
@@ -4851,6 +4888,7 @@ fn handle_cutscene_cues_request(
|
||||
cut.cues_generation = cut.cues_generation.wrapping_add(1);
|
||||
cut.cues_loading = true;
|
||||
cut.cues.clear();
|
||||
cut.voice = None;
|
||||
let generation = cut.cues_generation;
|
||||
let lang = cut.lang;
|
||||
let source = iso_state.source_kind.clone();
|
||||
@@ -4868,7 +4906,41 @@ fn handle_cutscene_cues_request(
|
||||
))
|
||||
})()
|
||||
.unwrap_or_default();
|
||||
let _ = sender.send(IsoLoaderMsg::CutsceneCuesLoaded { generation, cues });
|
||||
|
||||
// Locate the voice BOTH ways, so the window can show that they differ.
|
||||
// The naming convention is the obvious route and it is wrong often
|
||||
// enough to matter -- resolving the region is the only reading that
|
||||
// yields the right audio.
|
||||
let voice = (|| {
|
||||
use sylpheed_formats::{hash::name_hash, media, PakArchive};
|
||||
let vlang = match lang.pak_code() {
|
||||
"jpn" => sylpheed_formats::slb::VoiceLang::Japanese,
|
||||
_ => sylpheed_formats::slb::VoiceLang::English,
|
||||
};
|
||||
let named_bank = sylpheed_formats::slb::movie_voice_name(&movie, vlang);
|
||||
let named_range = read_source_file(&source, "dat/sound.pak")
|
||||
.ok()
|
||||
.and_then(|toc| PakArchive::parse_toc(&toc).ok())
|
||||
.and_then(|entries| {
|
||||
let h = name_hash(&named_bank);
|
||||
entries
|
||||
.iter()
|
||||
.find(|e| e.name_hash == h)
|
||||
.map(|e| (e.offset as u64, e.offset as u64 + e.comp_size as u64))
|
||||
});
|
||||
let region = media::resolve_movie_voice_region(&source, &movie, vlang);
|
||||
(named_range.is_some() || region.is_some()).then_some(VoiceLocation {
|
||||
named_bank,
|
||||
named_range,
|
||||
region,
|
||||
})
|
||||
})();
|
||||
|
||||
let _ = sender.send(IsoLoaderMsg::CutsceneCuesLoaded {
|
||||
generation,
|
||||
cues,
|
||||
voice,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2416,6 +2416,8 @@ fn draw_cutscenes_ui(
|
||||
mut requests: EventReader<RequestCutscenes>,
|
||||
mut file_selected: EventWriter<FileSelected>,
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
mut audio: ResMut<AudioPreview>,
|
||||
mut compose_audio: EventWriter<RequestAudio>,
|
||||
) {
|
||||
if requests.read().next().is_some() {
|
||||
cut.open = true;
|
||||
@@ -2427,6 +2429,7 @@ fn draw_cutscenes_ui(
|
||||
let mut open = true;
|
||||
let mut pick: Option<usize> = None;
|
||||
let mut play: Option<String> = None;
|
||||
let mut play_voice: Option<String> = None;
|
||||
|
||||
egui::Window::new("🎬 Cutscenes")
|
||||
.default_width(880.0)
|
||||
@@ -2581,6 +2584,79 @@ fn draw_cutscenes_ui(
|
||||
);
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// ── Voice ───────────────────────────────────────────────────
|
||||
// Two locations, shown together because they disagree. The bank
|
||||
// named after a movie is the obvious place to look and is often
|
||||
// not where the audio is: RT01A's voice sits inside the byte
|
||||
// range of the entry named VOICE_ADV.slb. Playing the
|
||||
// name-matched bank would be confidently wrong for exactly the
|
||||
// cutscenes where it matters.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Voice").strong());
|
||||
if let Some(v) = &cut.voice {
|
||||
if v.region.is_some() && ui.button("▶ Play").clicked() {
|
||||
play_voice = Some(row.movie.clone());
|
||||
}
|
||||
if v.region.is_none() {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(224, 168, 86),
|
||||
"no region resolves — this cutscene may be unvoiced",
|
||||
);
|
||||
}
|
||||
} else if cut.cues_loading {
|
||||
ui.spinner();
|
||||
}
|
||||
});
|
||||
if let Some(v) = &cut.voice {
|
||||
egui::Grid::new("cutscene_voice")
|
||||
.num_columns(2)
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
let mut kv2 = |k: &str, val: String, warn: bool| {
|
||||
ui.label(egui::RichText::new(k).weak().small());
|
||||
let t = egui::RichText::new(val).monospace();
|
||||
ui.label(if warn {
|
||||
t.color(egui::Color32::from_rgb(224, 168, 86))
|
||||
} else {
|
||||
t
|
||||
});
|
||||
ui.end_row();
|
||||
};
|
||||
kv2("named bank", v.named_bank.clone(), false);
|
||||
kv2(
|
||||
"its byte range",
|
||||
match v.named_range {
|
||||
Some((a, b)) => format!("{a} .. {b} ({} B)", b - a),
|
||||
None => "not in the TOC".into(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let honest = v.name_is_honest();
|
||||
kv2(
|
||||
"resolved region",
|
||||
match v.region {
|
||||
Some((a, b)) => format!("{a} .. {b} ({} B)", b - a),
|
||||
None => "—".into(),
|
||||
},
|
||||
!honest && v.region.is_some(),
|
||||
);
|
||||
if v.region.is_some() {
|
||||
kv2(
|
||||
"name honest?",
|
||||
if honest {
|
||||
"yes — the audio is inside its own bank".into()
|
||||
} else {
|
||||
"NO — the audio is outside the bank named after this movie"
|
||||
.into()
|
||||
},
|
||||
!honest,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Transcript").strong());
|
||||
if cut.cues_loading {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -2623,6 +2699,26 @@ fn draw_cutscenes_ui(
|
||||
cut.selected = Some(i);
|
||||
cut.want_cues = true;
|
||||
}
|
||||
if let Some(movie) = play_voice {
|
||||
// Routed through the movie form of RequestAudio, which resolves the
|
||||
// continuous region rather than reading the name-matched bank.
|
||||
audio.generation = audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true;
|
||||
audio.error = None;
|
||||
audio.name = format!("VOICE_{movie}");
|
||||
let vlang = match cut.lang.pak_code() {
|
||||
"jpn" => sylpheed_formats::slb::VoiceLang::Japanese,
|
||||
_ => sylpheed_formats::slb::VoiceLang::English,
|
||||
};
|
||||
compose_audio.send(RequestAudio {
|
||||
clip: String::new(),
|
||||
display: format!("VOICE_{movie}"),
|
||||
movie: Some((movie, vlang)),
|
||||
mono: true,
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -133,13 +133,23 @@ mkdir -p /exchange/files 2>/dev/null || true
|
||||
# Seeded rather than shared because credentials live in .credentials.json and a
|
||||
# token refresh must be able to write. Copying once means each agent refreshes
|
||||
# its own token and neither can corrupt the host's.
|
||||
if [ -d "$HOME/.claude.seed" ] && [ ! -s "$HOME/.claude/.credentials.json" ]; then
|
||||
# Re-seed whenever the HOST's credentials are newer than ours, not only when
|
||||
# ours are missing. The missing-only guard meant an expired token could never be
|
||||
# replaced: the file existed, so the copy was skipped, and restarting the
|
||||
# container changed nothing. A human re-logging in on the host is exactly the
|
||||
# recovery path, and it has to reach here.
|
||||
#
|
||||
# Newer-wins rather than always-copy, because the container refreshes its own
|
||||
# token during a run and that copy may legitimately be the fresher one.
|
||||
if [ -d "$HOME/.claude.seed" ] && \
|
||||
{ [ ! -s "$HOME/.claude/.credentials.json" ] || \
|
||||
[ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then
|
||||
mkdir -p "$HOME/.claude"
|
||||
cp -a "$HOME/.claude.seed/.credentials.json" "$HOME/.claude/" 2>/dev/null || true
|
||||
for f in settings.json CLAUDE.md; do
|
||||
[ -e "$HOME/.claude.seed/$f" ] && cp -a "$HOME/.claude.seed/$f" "$HOME/.claude/" 2>/dev/null || true
|
||||
done
|
||||
echo "[entrypoint] seeded ~/.claude from the host (credentials only)"
|
||||
echo "[entrypoint] refreshed ~/.claude credentials from the host"
|
||||
fi
|
||||
|
||||
# Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as
|
||||
|
||||
@@ -30,13 +30,23 @@ echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)"
|
||||
# Seeded rather than shared because credentials live in .credentials.json and a
|
||||
# token refresh must be able to write. Copying once means each agent refreshes
|
||||
# its own token and neither can corrupt the host's.
|
||||
if [ -d "$HOME/.claude.seed" ] && [ ! -s "$HOME/.claude/.credentials.json" ]; then
|
||||
# Re-seed whenever the HOST's credentials are newer than ours, not only when
|
||||
# ours are missing. The missing-only guard meant an expired token could never be
|
||||
# replaced: the file existed, so the copy was skipped, and restarting the
|
||||
# container changed nothing. A human re-logging in on the host is exactly the
|
||||
# recovery path, and it has to reach here.
|
||||
#
|
||||
# Newer-wins rather than always-copy, because the container refreshes its own
|
||||
# token during a run and that copy may legitimately be the fresher one.
|
||||
if [ -d "$HOME/.claude.seed" ] && \
|
||||
{ [ ! -s "$HOME/.claude/.credentials.json" ] || \
|
||||
[ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then
|
||||
mkdir -p "$HOME/.claude"
|
||||
cp -a "$HOME/.claude.seed/.credentials.json" "$HOME/.claude/" 2>/dev/null || true
|
||||
for f in settings.json CLAUDE.md; do
|
||||
[ -e "$HOME/.claude.seed/$f" ] && cp -a "$HOME/.claude.seed/$f" "$HOME/.claude/" 2>/dev/null || true
|
||||
done
|
||||
echo "[entrypoint] seeded ~/.claude from the host (credentials only)"
|
||||
echo "[entrypoint] refreshed ~/.claude credentials from the host"
|
||||
fi
|
||||
|
||||
# Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as
|
||||
|
||||
Reference in New Issue
Block a user