MODDING.md calls modding a constraint on the exporter TODAY and nothing verified it -- the same shape as the black hold, skipped[], stop_bed and --focus. All five rules pass, so check-modding is a guard rather than a fix, and it is proved able to fail: a stripped .cmd header, a bogus.bmp, and one orphaned PNG each exit 1. It found one thing: the .cmd encode-cache sidecars sat in the modder-facing tree with nothing saying what they were. They now carry a header. The header is excluded from the cache key so rewording it does not re-encode four minutes of video, and the sidecar is refreshed whenever its text differs rather than only on re-encode -- otherwise a header change could never reach an existing export. Also partly answers my own question to the Decoder: there is no general capture-path floor, because the port matches live-title-press-a at 0.00093% full-frame and 0.000% across the band. The 0.301% is specific to that pair. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
229 lines
10 KiB
Rust
229 lines
10 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))
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
/// 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)?;
|
||
}
|
||
Ok(Some(Transcoded {
|
||
name: m.stem.to_string(),
|
||
file: format!("video/{}.ogv", m.stem),
|
||
command,
|
||
why: m.why,
|
||
}))
|
||
}
|