Files
Sylpheed/crates/sylpheed-export/src/video.rs
MechaCat02 9fbb352ef0 monorepo: one repository for the decoders, the port and the corpus
Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.

The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.

Canary stays separate: it is a fork tracking upstream.

New structure for the long term:

  docs/game/     how the game is NAVIGATED -- menus, modals, prompts, alerts,
                 and in-game flight. Written so nobody rediscovers it. Mostly
                 open questions on purpose; the in-game tutorials are the
                 resource for the flight half.
  docs/port/MODDING.md
                 modding as a constraint on the exporter TODAY, not a later
                 feature: one logical asset in one file (the disc splits nearly
                 everything, and resolving that is the exporter's job), names a
                 person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
                 re-exporting is always safe, provenance in every file.
  data/base + data/mods
                 generated tree and drop-in overrides, both gitignored
  exchange/      transient inter-agent files, deliberately outside history
  docs/agents/   the team protocol

Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.

Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
2026-08-29 11:34:46 +02:00

167 lines
6.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
///
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
/// why the normalisation is here rather than the textbook coefficients.
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();
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
let fresh = ogv.exists()
&& std::fs::read_to_string(&stamp).map(|s| s == 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)?;
std::fs::write(&stamp, &want)?;
}
Ok(Some(Transcoded {
name: m.stem.to_string(),
file: format!("video/{}.ogv", m.stem),
command,
why: m.why,
}))
}