Their sharpening of my harness-note finding: a why in an authored file has a convention demanding a citation; a docstring has nothing, travels with the code, and reads as authoritative. Their instance was ring_row.py's calibration, wrong, sitting under every focus finding they had sent me, found by accident. Swept mine for numbers I had corrected in DECISIONS.md. Three live instances, each contradicting my own log. video.rs asserted '28 % of S00A's frames presented and 47 % of ADV's' as measured; boot.gd asserted that the same numbers 'refuted the claim outright'; dialog_rows.rs said 'by three routes'. All three were retracted days ago in the log and never in the code -- the percentages came from contended runs and the counter is an upper bound that goes vacuous once the engine outruns the stream, and three routes became two, one compound. verify-transcode-fidelity was the only one already correct. Third time this pattern has bitten me, and it is the one audio.json's own why warns about: a correction that does not reach the artifact a consumer reads has not been made. First was loop_why shipping a refuted story into manifest.json, second a BLOCKED row, this is code comments -- the worst of the three because they sit beside the thing they describe. So the class is now checked rather than swept: the retracted numbers are register rows carrying the propositions they asserted, and check-claims immediately failed on my own corrections quoting them unmarked. The next stale number of this kind fails a run instead of waiting for a sweep. What it does not cover is a docstring number that was never corrected anywhere. The register holds only what I have already retracted, so it catches propagation failures rather than wrong numbers -- their ring_row.py case would still have gone undetected here, because nothing had retracted that calibration. Their closing observation is the honest limit: the only thing that has actually caught these is one of us reading the other's sentence for its own sake, which is not a filter and does not scale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
284 lines
13 KiB
Rust
284 lines
13 KiB
Rust
//! Movies: disc WMV → Ogg Theora, because Godot 4 plays Theora natively and
|
||
//! will never be taught to read WMV.
|
||
//!
|
||
//! The transcode command is **recorded in the manifest verbatim**. A modder who
|
||
//! dislikes the quality re-runs one line rather than reverse-engineering what
|
||
//! was done to their video, which is the whole reason this project converts the
|
||
//! disc instead of reading it at runtime.
|
||
|
||
use anyhow::{bail, Context, Result};
|
||
use std::path::Path;
|
||
use std::process::Command;
|
||
|
||
/// A movie in scope for this port.
|
||
pub struct Movie {
|
||
/// Path under the disc root.
|
||
pub src: &'static str,
|
||
/// Output stem under `export/video/`.
|
||
pub stem: &'static str,
|
||
pub why: &'static str,
|
||
}
|
||
|
||
/// MISSION §6: the boot intro and the one new-game intro. The disc holds 3.3 GB
|
||
/// of video and transcoding all of it is not this milestone.
|
||
pub const MOVIES: &[Movie] = &[
|
||
Movie {
|
||
src: "dat/movie/ADV.wmv",
|
||
stem: "ADV",
|
||
why: "HANDOFF Q9: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the \
|
||
attract movie are the SAME asset -- there is no separate boot slot.",
|
||
},
|
||
Movie {
|
||
src: "dat/movie/S00A.wmv",
|
||
stem: "S00A",
|
||
why: "HANDOFF Q9: MS00A -> S00A.wmv is the new-game intro. P7.",
|
||
},
|
||
];
|
||
|
||
/// The encode.
|
||
///
|
||
/// `-q:v 8` was chosen by measurement, not taste: against the decoded source,
|
||
/// SSIM over a 10 s sample is 0.9863 at q6, **0.9896 at q8** and 0.9924 at q10,
|
||
/// and q8 is visually indistinguishable at 200 % zoom on the reel's hardest
|
||
/// case — fine serif text and soft gradients over near-black, which is where
|
||
/// Theora usually breaks first. MISSION §6 anticipated that 720p Theora might
|
||
/// be too poor and asked for the FFmpeg-GDExtension fallback to be *proposed*
|
||
/// if so. It is not: **no runtime dependency is needed, and none is requested.**
|
||
///
|
||
/// The stereo downmix, **stated explicitly rather than inherited**.
|
||
///
|
||
/// The disc ships movies in two audio profiles: 28 files are 5.1 WMA Pro (every
|
||
/// cutscene, including both movies this port needs) and 69 are already stereo.
|
||
/// A bare `-ac 2` therefore does two different things and records neither — the
|
||
/// stereo files pass through, and the 5.1 files are folded by **ffmpeg's default
|
||
/// matrix**. How loudly centre-channel dialogue sits against the music is a
|
||
/// CONTENT decision, and leaving it to a default means it is made by accident
|
||
/// and can move under an ffmpeg upgrade.
|
||
///
|
||
/// So the matrix is written out: **ITU-R BS.775, LFE dropped**, normalised by
|
||
/// `1/(1 + √½ + √½) = 0.4142` so the sum of coefficients cannot clip.
|
||
///
|
||
/// This does not change the audio. Measured against the inherited default over a
|
||
/// 25 s stretch, the residual is **−91 dB** — roughly one LSB at 16-bit, i.e.
|
||
/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's
|
||
/// default *is* this matrix; the point is that the manifest now says so.
|
||
///
|
||
/// # 🔴 This is NOT the matrix MISSION §6 pins, and that was never said out loud
|
||
///
|
||
/// MISSION §6 records a **human decision of 2026-08-29** fixing the fold at
|
||
/// `FL = 1.0·FL + 0.707·FC + 0.707·BL` (plus 7.1 terms a 5.1 source does not
|
||
/// have). This constant is that matrix scaled by 0.4142 — the same relative
|
||
/// weighting, **7.65 dB quieter** — and until now nothing in the code, the
|
||
/// manifest or the docs said so. Recording the command you ran does not disclose
|
||
/// that it is not the command you were given.
|
||
///
|
||
/// The original justification for the deviation was *"the unnormalised form
|
||
/// clips: peak 0.0 dBFS"*, and that is a peak reading — the instrument
|
||
/// `docs/port/BLOCKED.md` records this port declaring unfit for the clipping
|
||
/// question, because one sample at full scale and two seconds of square wave
|
||
/// give the same number. Re-measured properly (float decode, whole file, count
|
||
/// the samples that would clamp):
|
||
///
|
||
/// | | peak | ≥ full scale | > +1 dB over | longest run |
|
||
/// |---|---|---|---|---|
|
||
/// | `ADV`, MISSION §6 | **+4.26 dBFS** | 4 406 / 13 187 900 | 1 874 | 0.333 ms |
|
||
/// | `S00A`, MISSION §6 | −1.34 dBFS | **0** | 0 | — |
|
||
///
|
||
/// So the pin really does overload `ADV` — and this constant is over-broad,
|
||
/// because `S00A` never needed it. The smallest single scalar under which
|
||
/// neither clamps is `1/1.6339 = 0.612`, +3.39 dB on today.
|
||
///
|
||
/// **Not changed here.** The level of a mix is what §6 reserves to a human
|
||
/// (*"adjust it deliberately, as a commit"*), so the export carries a warning
|
||
/// with these numbers instead. See `docs/port/DECISIONS.md`.
|
||
const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR";
|
||
|
||
/// How many audio channels the source declares.
|
||
fn channels(src: &Path) -> Result<u32> {
|
||
let out = Command::new("ffprobe")
|
||
.args([
|
||
"-v", "error", "-select_streams", "a:0",
|
||
"-show_entries", "stream=channels", "-of", "csv=p=0",
|
||
])
|
||
.arg(src)
|
||
.output()
|
||
.context("run ffprobe -- is it on PATH?")?;
|
||
Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2))
|
||
}
|
||
|
||
/// Duration and frame rate of a finished transcode, straight from the file.
|
||
///
|
||
/// Probed from the OUTPUT, not the source: what the runtime will play is this
|
||
/// file, and the two differ — `ADV` is 137.44 s against a 137.71 s source.
|
||
/// Returns zeros rather than failing, because a missing number should make the
|
||
/// runtime say "unknown", not stop an export that otherwise succeeded.
|
||
fn probe_timebase(out: &Path) -> (f64, f64) {
|
||
let probe = |entries: &str, stream: bool| -> String {
|
||
let mut c = Command::new("ffprobe");
|
||
c.args(["-v", "error"]);
|
||
if stream {
|
||
c.args(["-select_streams", "v:0"]);
|
||
}
|
||
c.args(["-show_entries", entries, "-of", "csv=p=0"]).arg(out);
|
||
c.output()
|
||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||
.unwrap_or_default()
|
||
};
|
||
let secs = probe("format=duration", false).parse().unwrap_or(0.0);
|
||
// `r_frame_rate` is a rational, "30/1".
|
||
let rate = probe("stream=r_frame_rate", true);
|
||
let fps = match rate.split_once('/') {
|
||
Some((n, d)) => n.parse::<f64>().unwrap_or(0.0) / d.parse::<f64>().unwrap_or(1.0),
|
||
None => rate.parse().unwrap_or(0.0),
|
||
};
|
||
(secs, fps)
|
||
}
|
||
|
||
fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> {
|
||
let mut v: Vec<String> = [
|
||
"-hide_banner", "-loglevel", "error", "-y",
|
||
"-i", &src.display().to_string(),
|
||
"-c:v", "libtheora", "-q:v", "8",
|
||
"-c:a", "libvorbis", "-q:a", "5",
|
||
]
|
||
.iter()
|
||
.map(|s| s.to_string())
|
||
.collect();
|
||
// Only 5.1 sources are folded. A source that is already stereo is passed
|
||
// through untouched rather than run through a matrix that would silently
|
||
// reference channels it does not have.
|
||
if channels == 6 {
|
||
v.push("-af".into());
|
||
v.push(DOWNMIX_51.into());
|
||
}
|
||
v.push("-ac".into());
|
||
v.push("2".into());
|
||
v.push(out.display().to_string());
|
||
v
|
||
}
|
||
|
||
pub struct Transcoded {
|
||
pub name: String,
|
||
pub file: String,
|
||
pub command: String,
|
||
pub why: &'static str,
|
||
/// The transcode's own duration and frame rate, probed from the file that
|
||
/// was just written.
|
||
///
|
||
/// Recorded so the RUNTIME can say what it actually presented.
|
||
///
|
||
/// 🔴 CORRECTED 2026-09-01. This read: *"Godot's video player drops frames to
|
||
/// hold its schedule, and it drops a lot of them here — measured at 28 % of [refuted]
|
||
/// `S00A`'s frames presented and 47 % of `ADV`'s"*. **Both numbers are
|
||
/// retracted.** They came from CONTENDED runs, and the counter is an upper
|
||
/// bound on ENGINE frames that is vacuous once the engine outruns the stream
|
||
/// — quiet, `ADV` draws 6 480 frames across a 4 123-frame video. On a quiet
|
||
/// box the bound is 88–90 % for `S00A`, and playback runs **+6.7 %…+6.9 %**
|
||
/// long for both films. What survives is that elapsed seconds hide whatever
|
||
/// the player does, which is why the count is in the manifest. Without a frame count in the manifest a run can only
|
||
/// report elapsed seconds, and elapsed seconds are exactly what stays
|
||
/// plausible while three frames in four go missing.
|
||
///
|
||
/// 🔴 This field exists because the port asserted the opposite. The claim was
|
||
/// *"a player that runs long decoded everything"*, argued from the absence of
|
||
/// an overrun rather than measured; the measurement was four lines and
|
||
/// refuted it. **The instrument is now permanent so the argument cannot be
|
||
/// made again from a run that never counted.**
|
||
pub duration_s: f64,
|
||
pub fps: f64,
|
||
}
|
||
|
||
/// Transcode one movie, skipping the encode when the output already exists and
|
||
/// was produced by exactly this command against exactly this source.
|
||
///
|
||
/// `export/` is still regenerated wholesale — this is a cache, not a hand-edit.
|
||
/// The sidecar records the command and the source size, so any change to either
|
||
/// re-encodes. Without it every re-export pays ~4 minutes to produce a
|
||
/// byte-identical file, and an exporter nobody re-runs is worse than a cache.
|
||
pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded>> {
|
||
let src = disc.join(m.src);
|
||
if !src.exists() {
|
||
return Ok(None);
|
||
}
|
||
let dir = out.join("video");
|
||
std::fs::create_dir_all(&dir)?;
|
||
let ogv = dir.join(format!("{}.ogv", m.stem));
|
||
let stamp = dir.join(format!("{}.cmd", m.stem));
|
||
|
||
let ch = channels(&src)?;
|
||
let argv = args(&src, &ogv, ch);
|
||
let command = format!("ffmpeg {}", argv.join(" "));
|
||
let size = std::fs::metadata(&src)?.len();
|
||
// The sidecar SAYS WHAT IT IS. It sits in the modder-facing asset tree next
|
||
// to the `.ogv`, and MODDING rule 2's principle is that a generated file
|
||
// should be tellable from a hand-made one by reading it -- a bare ffmpeg
|
||
// line beside a video looks like something a modder should edit or delete.
|
||
//
|
||
// The header is NOT part of the cache key: `fresh` compares only the lines
|
||
// that describe the encode. Otherwise rewording this comment would re-encode
|
||
// four minutes of video to no purpose, which is a cache that punishes
|
||
// documentation.
|
||
let key = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
|
||
let want = format!(
|
||
"# Generated by sylpheed-export. NOT an asset and not hand-editable: this\n\
|
||
# records how {}.ogv beside it was encoded, so a re-export can skip the\n\
|
||
# encode when the source and the command are both unchanged. Deleting it\n\
|
||
# only forces one re-encode. To change the video, override the .ogv under\n\
|
||
# data/mods/ (MODDING rule 4) -- editing this file changes nothing.\n{key}",
|
||
m.stem
|
||
);
|
||
|
||
let cache_key = |s: &str| -> String {
|
||
s.lines()
|
||
.filter(|l| !l.starts_with('#'))
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
};
|
||
let fresh = ogv.exists()
|
||
&& std::fs::read_to_string(&stamp)
|
||
.map(|s| cache_key(&s) == cache_key(&want))
|
||
.unwrap_or(false);
|
||
if !fresh {
|
||
// Encode to a temp name and rename on success. A reader that catches
|
||
// this mid-write sees no file at all rather than a valid-looking one
|
||
// with a wrong duration -- ffprobe reported 33 s against a 137 s source
|
||
// during one such race, with no error, and it looked exactly like
|
||
// catastrophic truncation. The filesystem is shared with another agent,
|
||
// so this is a race and not an edge case.
|
||
let partial = dir.join(format!(".{}.partial.ogv", m.stem));
|
||
let mut argv = argv.clone();
|
||
let last = argv.len() - 1;
|
||
argv[last] = partial.display().to_string();
|
||
let status = Command::new("ffmpeg")
|
||
.args(&argv)
|
||
.status()
|
||
.context("run ffmpeg -- is it on PATH?")?;
|
||
if !status.success() {
|
||
let _ = std::fs::remove_file(&partial);
|
||
bail!("ffmpeg failed on {}", m.src);
|
||
}
|
||
std::fs::rename(&partial, &ogv)?;
|
||
}
|
||
// Refresh the sidecar whenever its TEXT differs, encode or no encode.
|
||
//
|
||
// It used to be written only inside the `!fresh` branch, which is right for
|
||
// the cache and wrong for the file: a change to the header alone -- the part
|
||
// deliberately excluded from the key -- would then never reach an existing
|
||
// export, because nothing that reads the header can trigger the write that
|
||
// updates it. The explanation would be correct in the source and absent on
|
||
// disc, which is the same shape as every other documented-but-unexercised
|
||
// thing this port has had to find the hard way.
|
||
if std::fs::read_to_string(&stamp).map(|s| s != want).unwrap_or(true) {
|
||
std::fs::write(&stamp, &want)?;
|
||
}
|
||
let (duration_s, fps) = probe_timebase(&ogv);
|
||
Ok(Some(Transcoded {
|
||
name: m.stem.to_string(),
|
||
file: format!("video/{}.ogv", m.stem),
|
||
command,
|
||
why: m.why,
|
||
duration_s,
|
||
fps,
|
||
}))
|
||
}
|