Files
Sylpheed/crates/sylpheed-export/src/video.rs
Fabian Hamm ed54f95d54 style: rustfmt sweep -- 774 hunks across 154 files -> 0
`cargo fmt --all -- --check` has failed on every run in this repository's
history, identically on `main` and on every branch. This is #12.

Mechanical: `cargo fmt --all`, nothing else. 154 files, all `.rs`, no other
extension touched. `cargo check --workspace` exits 0 afterwards, so nothing
changed semantically.

ON THE ORDERING, WHICH WAS THE REAL QUESTION.

HANDOFF-2026-09-06 section 7 warns this is the expensive fix: a whole-tree
reformat before #7 and #8 return "would put a conflict in every file of 861
commits and make the reviews those items exist to enable unreadable".

That is measurably too pessimistic, and it had been reasoned rather than
tested. Measured here by three-way merging a rustfmt'd `main` against both
unmerged branches, file by file:

  file/branch pairs tested   32
  merges CLEAN               28
  merges CONFLICTING          4   (8 conflict hunks total)

    sylpheed-cli/src/main.rs      1 hunk
    sylpheed-export/src/check.rs  1
    sylpheed-export/src/screen.rs 4
    sylpheed-export/src/video.rs  2

All four are against `auto/frame-blend-draw-path` only;
`auto/port-p6-audio` does not conflict anywhere. The earlier framing --
154 dirty files, 133 that cannot collide, 21 that can, the collision set
carrying 147 of 774 hunks (19%) -- reproduces exactly. What it did not say
is that most of the 21 still merge cleanly, because rustfmt's edits and the
branches' edits rarely land on the same lines.

So the cost of sweeping now is 4 files and 8 hunks for one branch, against
a check that is otherwise red forever. Deliberately NOT folded into the
WASM PR: 154 reformatted files would make that one unreviewable.

Closes #12
2026-09-08 20:07:01 +02:00

307 lines
13 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.
///
/// # 🔴 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 8890 % 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,
}))
}