//! 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 { 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::().unwrap_or(0.0) / d.parse::().unwrap_or(1.0), None => rate.parse().unwrap_or(0.0), }; (secs, fps) } fn args(src: &Path, out: &Path, channels: u32) -> Vec { let mut v: Vec = [ "-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> { 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::>() .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, })) }