port: settle times measured -- the pacing was already right, and my own red flag was half wrong
THE TRANSCODE CACHE HAD NEVER HIT. `video::transcode` has carried one since P4 -- a `.cmd` sidecar with the command, the source size and the channel count -- and `main.rs` clears the output tree wholesale immediately before the check, deleting the sidecar and the file it stamps. Six exports in this session paid the full Theora encode and produced five byte-identical files, roughly 48 minutes. Nothing reported it, and nothing could: a cache is silent either way and the only symptom is a wall clock that looks like the job being slow. The wipe now spares `video/` and `prune_videos` deletes anything in it this run did not claim, so the wholesale guarantee is kept rather than traded. A re-export is 20 s. SETTLE TIMES, MEASURED, and they refute more of my row than they confirm. The principle holds -- the title's rest.t is 251 units = 4.183 s where its art finishes at ~2 s -- but "everything the sequencer paces off that landmark is therefore late" does not. Measured the port the way the game was measured, by VISIBLE SPAN rather than arrival-to-arrival: publisher wordmark port 4.25 s game 4.297 / 4.604 / 4.370 developer logos port 3.50 s game 3.508 / 3.503 / 3.366 black hold port ~0.25 s game 0.2 - 0.3 title -> plate port 2.000 s (declared 120 units) game 2.247 s Dead on. My earlier reading compared the port's transition timestamps against the game's visible spans, which differ by the exit ramp plus the black hold -- the whole of the discrepancy I was about to chase, and the same definitional trap that cost this corpus 0.48 s on the plate delay. Nothing in the sequencer is changed. `dwell_seconds` stays null, now for a measured reason rather than an absent one: `timing.json` said "if a capture ever times the real boot, this is where that number goes", and the answer is that nothing goes there. Not authored, deliberately: an (A)->menu dwell, which measures 3.763 s and contains a 1.53 s guest load stall on a cold cache; and the menu build-in and (B)->title, which rest on one run where the port is already within ~0.1 s. THE VOICE PRESENTATION MOVED TO authored/, because the recommendation behind it was withdrawn as self-contradictory and the choice is now unambiguously mine. `voice.presentation` = `loudest`, and the reason to switch is a measurement, not a preference: ADV chunk 1 is MONO-IN-STEREO and chunk 2 is DUAL-MONO, so chunk 2's extra bytes encode a duplicate channel rather than fidelity. That explains the byte-rate difference and removes the only argument for `highest_rate`. ADV's dialogue now exports at +0.3 dBFS instead of -8.7, which is the SE bound's documented decode overshoot on a wave mastered at full scale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
@@ -83,10 +83,28 @@ pub struct BgmSpec {
|
||||
pub stems_why: Option<String>,
|
||||
}
|
||||
|
||||
/// Which of a voice region's full-length presentations to export.
|
||||
///
|
||||
/// A region carries three presentations of one take and **nothing on the disc
|
||||
/// ranks them** — `wEncodeOptions`, channel count and channel mask are
|
||||
/// byte-identical across them. So this is a CHOICE, it lives in
|
||||
/// `authored/audio.json` with its `why`, and it is deleted the day a capture
|
||||
/// says which one the game plays.
|
||||
#[derive(Deserialize, Default, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Presentation {
|
||||
/// Peak nearest full scale.
|
||||
#[default]
|
||||
Loudest,
|
||||
/// Most bytes per second.
|
||||
HighestRate,
|
||||
}
|
||||
|
||||
/// `authored/audio.json`, with the documentation keys dropped.
|
||||
pub struct Config {
|
||||
pub se: Vec<(String, CueSpec)>,
|
||||
pub bgm: Vec<(String, BgmSpec)>,
|
||||
pub voice: Presentation,
|
||||
}
|
||||
|
||||
/// Read `authored/audio.json`, or `None` when there is no such file.
|
||||
@@ -104,6 +122,8 @@ pub fn load(authored: &Path) -> Result<Option<Config>> {
|
||||
se: BTreeMap<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
bgm: BTreeMap<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
voice: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
@@ -128,9 +148,18 @@ pub fn load(authored: &Path) -> Result<Option<Config>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Absent means `loudest`, which is what the file says today. A default here
|
||||
// is safe in a way a default matrix is not: the manifest records which
|
||||
// presentation was taken and why, on every entry.
|
||||
let voice = match file.voice.get("presentation") {
|
||||
Some(v) => serde_json::from_value(v.clone())
|
||||
.with_context(|| format!("authored/audio.json: voice.presentation {v}"))?,
|
||||
None => Presentation::default(),
|
||||
};
|
||||
Ok(Some(Config {
|
||||
se: entries(file.se, "se")?,
|
||||
bgm: entries(file.bgm, "bgm")?,
|
||||
voice,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -535,6 +564,7 @@ pub fn export_voice<S: DiscSource + ?Sized>(
|
||||
out: &Path,
|
||||
movie: &str,
|
||||
video_duration_s: Option<f32>,
|
||||
presentation: Presentation,
|
||||
) -> Result<Option<Exported>> {
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
|
||||
@@ -590,20 +620,25 @@ pub fn export_voice<S: DiscSource + ?Sized>(
|
||||
// region carries **three presentations of one take**, not a mix. Summing a
|
||||
// take with a scaled copy of itself adds ~4 dB and colours it.
|
||||
//
|
||||
// The selector is the **highest byte rate** among the equal-duration
|
||||
// survivors, on the Decoder's recommendation. 🟡 That is a recommendation and
|
||||
// not a decoded field: no flag on the disc says which presentation the game
|
||||
// plays, and on `ADV` it picks the quieter of the two (-8.3 dBFS against
|
||||
// 0.0). Recorded in the manifest so the choice is visible and reversible.
|
||||
// WHICH of the equal-duration survivors is a CHOICE, and it lives in
|
||||
// `authored/audio.json` rather than here -- see [`Presentation`]. It was
|
||||
// `highest_rate` on the Decoder's recommendation until that was withdrawn as
|
||||
// self-contradictory, and the reason it is now `loudest` is a measurement:
|
||||
// `ADV`'s louder presentation is mono-in-stereo while its higher-rate one is
|
||||
// DUAL-MONO, so the extra bytes encode a duplicate channel rather than
|
||||
// fidelity, and the rate difference is explained without appealing to
|
||||
// quality at all.
|
||||
let tied: Vec<usize> = (0..all.len())
|
||||
.filter(|&i| !silent.contains(&i) && (longest - lengths[i]).abs() < 0.001)
|
||||
.collect();
|
||||
let keep: Vec<usize> = tied
|
||||
.iter()
|
||||
.copied()
|
||||
.max_by_key(|&i| riffs[i].len())
|
||||
.into_iter()
|
||||
.collect();
|
||||
let chosen = match presentation {
|
||||
Presentation::HighestRate => tied.iter().copied().max_by_key(|&i| riffs[i].len()),
|
||||
Presentation::Loudest => tied
|
||||
.iter()
|
||||
.copied()
|
||||
.max_by(|&a, &b| probed[a].1.total_cmp(&probed[b].1)),
|
||||
};
|
||||
let keep: Vec<usize> = chosen.into_iter().collect();
|
||||
let dropped: Vec<String> = (0..all.len())
|
||||
.filter(|i| !keep.contains(i))
|
||||
.map(|i| {
|
||||
@@ -708,14 +743,19 @@ pub fn export_voice<S: DiscSource + ?Sized>(
|
||||
summing them as HANDOFF Q10's two stems, which its own measurements refuted: \
|
||||
S00A's second full-length chunk is DIGITAL SILENCE and ADV's is 0.60x the first \
|
||||
with 26.8 dB of residual. Summing a take with a scaled copy of itself adds ~4 dB \
|
||||
and colours it. 🟡 The kept stream is the HIGHEST BYTE RATE among the \
|
||||
equal-duration survivors, which is a recommendation and NOT a decoded field: \
|
||||
nothing on the disc says which presentation the game plays, and on ADV this \
|
||||
picks the quieter of two.{} Folded to mono from the {} of {channels} declared \
|
||||
and colours it. 🟡 WHICH of the equal-duration survivors is kept is a CHOICE, \
|
||||
not a decoded field -- authored/audio.json voice.presentation = {:?}, with its \
|
||||
why. Nothing on the disc ranks the presentations: wEncodeOptions, channel count \
|
||||
and channel mask are byte-identical across them. One capture of the movie with \
|
||||
dialogue audible deletes that entry.{} Folded to mono from the {} of {channels} declared \
|
||||
channel(s) that carry signal -- channel 2 of both voice streams is digitally \
|
||||
silent, and averaging it in cost 5.94 dB until this was measured rather than \
|
||||
read off the declared count.{against}",
|
||||
riffs.len(),
|
||||
match presentation {
|
||||
Presentation::Loudest => "loudest",
|
||||
Presentation::HighestRate => "highest_rate",
|
||||
},
|
||||
if dropped.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
|
||||
@@ -241,8 +241,35 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
// Derived output is regenerated wholesale: clear it, so a screen that stops
|
||||
// being exported stops existing rather than lingering as a stale file that
|
||||
// still validates.
|
||||
//
|
||||
// 🔴 EXCEPT `video/`, and leaving it out was a bug that hid in plain sight.
|
||||
// `video::transcode` has always carried a cache -- it writes a `.cmd`
|
||||
// sidecar with the exact command, the source size and the channel count, and
|
||||
// skips the encode when all three still match. Its own doc comment says
|
||||
// "without it every re-export pays ~4 minutes to produce a byte-identical
|
||||
// file". **This wipe deleted the sidecar and the output immediately before
|
||||
// the check, so the cache had never hit once.** Six exports in one session
|
||||
// paid ~48 minutes of Theora to produce five byte-identical files, and
|
||||
// nothing reported it: the cache is silent when it works and silent when it
|
||||
// does not.
|
||||
//
|
||||
// The wholesale guarantee is kept rather than weakened -- everything else is
|
||||
// still cleared outright, and `prune_videos` below deletes any file in
|
||||
// `video/` that this run did not claim, so a movie that stops being exported
|
||||
// still stops existing.
|
||||
if out.exists() {
|
||||
std::fs::remove_dir_all(&out).context("clear the output tree")?;
|
||||
for entry in std::fs::read_dir(&out).context("clear the output tree")? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == "video" {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type()?.is_dir() {
|
||||
std::fs::remove_dir_all(entry.path())
|
||||
} else {
|
||||
std::fs::remove_file(entry.path())
|
||||
}
|
||||
.with_context(|| format!("clear {}", entry.path().display()))?;
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(&out)?;
|
||||
|
||||
@@ -332,13 +359,15 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
None => println!(" video {} not on this disc -- skipped", m.src),
|
||||
}
|
||||
}
|
||||
prune_videos(out, &videos)?;
|
||||
|
||||
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
|
||||
// because they were measured off the running game and are on the disc in no
|
||||
// findable form, the BGM choice because HANDOFF Q10 is a negative and
|
||||
// nothing states which track a menu plays. See `authored/audio.json`.
|
||||
let mut audio = Vec::new();
|
||||
match audio::load(authored_dir)? {
|
||||
let audio_cfg = audio::load(authored_dir)?;
|
||||
match &audio_cfg {
|
||||
None => println!(" no authored/audio.json -- no audio exported"),
|
||||
Some(cfg) => {
|
||||
let source = media::DirectorySource::new(disc);
|
||||
@@ -406,7 +435,11 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
{
|
||||
let source = media::DirectorySource::new(disc);
|
||||
for (stem, len) in &movie_lengths {
|
||||
match audio::export_voice(&source, out, stem, *len)? {
|
||||
// The presentation choice is AUTHORED and this block runs even when
|
||||
// there is no `authored/audio.json` -- the voice binding is decoded,
|
||||
// so the dialogue exports either way and only the choice defaults.
|
||||
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
|
||||
match audio::export_voice(&source, out, stem, *len, want)? {
|
||||
Some(a) => {
|
||||
println!(
|
||||
" voice {:<8} -> {} ({}, {} region chunk(s))",
|
||||
@@ -475,3 +508,38 @@ fn describe(a: &audio::Exported) -> String {
|
||||
None => peak,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Delete anything in `video/` this run did not produce.
|
||||
///
|
||||
/// `video/` is the one directory the wholesale wipe spares, so that the
|
||||
/// transcode cache survives to be consulted. This restores the guarantee the
|
||||
/// wipe exists for: a movie that stops being exported stops existing, rather
|
||||
/// than lingering as a file the manifest no longer lists.
|
||||
fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> {
|
||||
let dir = out.join("video");
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut keep: Vec<String> = Vec::new();
|
||||
for v in kept {
|
||||
if let Some(name) = Path::new(&v.file).file_name() {
|
||||
let name = name.to_string_lossy().into_owned();
|
||||
keep.push(name.clone());
|
||||
// The cache sidecar goes with the file it stamps.
|
||||
if let Some(stem) = Path::new(&name).file_stem() {
|
||||
keep.push(format!("{}.cmd", stem.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if keep.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
println!(" video {name} is no longer exported -- removed");
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user