diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index dd887126..58c5a8d3 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -77,6 +77,11 @@ struct ManifestVideo { /// dislikes the quality re-runs one line rather than reverse-engineering it. command: String, why: &'static str, + /// What the runtime should have played, so it can report what it did. + /// See `video::Transcoded::duration_s` — the port measured its player + /// presenting 28–47 % of a stream's frames, and seconds alone hide that. + duration_s: f64, + fps: f64, } /// One exported audio file. Carries the same provenance a video does, plus the @@ -366,6 +371,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { file: t.file, command: t.command, why: t.why, + duration_s: t.duration_s, + fps: t.fps, }); } None => println!(" video {} not on this disc -- skipped", m.src), diff --git a/crates/sylpheed-export/src/video.rs b/crates/sylpheed-export/src/video.rs index 17eeeac3..6a6905f3 100644 --- a/crates/sylpheed-export/src/video.rs +++ b/crates/sylpheed-export/src/video.rs @@ -106,6 +106,34 @@ fn channels(src: &Path) -> Result { 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", @@ -134,6 +162,23 @@ pub struct Transcoded { 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. Godot's video + /// player drops frames to hold its schedule, and it drops a lot of them here + /// — measured at **28 % of `S00A`'s frames presented and 47 % of `ADV`'s** on + /// a box with no GPU. 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 @@ -219,10 +264,13 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result -255 sections. Search this before re-deriving anything. +256 sections. Search this before re-deriving anything. * [P0 — the exporter, 2026-08-28](#p0--the-exporter-2026-08-28) * [P1 — Godot draws the screen, 2026-08-28](#p1--godot-draws-the-screen-2026-08-28) @@ -266,6 +266,7 @@ dies, which is what this file is for. * [Reported: a live-reading HANDOFF section that two later ones have overtaken](#reported-a-live-reading-handoff-section-that-two-later-ones-have-overtaken) * [Their rule applied backwards: my video result is stronger than my withdrawal said](#their-rule-applied-backwards-my-video-result-is-stronger-than-my-withdrawal-said) * [🔴 I measured my own claim and it is wrong: the player skips, heavily](#i-measured-my-own-claim-and-it-is-wrong-the-player-skips-heavily) +* [🔴 Correcting the correction: the frame probe is an UPPER BOUND, and my contrast was contention](#correcting-the-correction-the-frame-probe-is-an-upper-bound-and-my-contrast-was-contention) ## P0 — the exporter, 2026-08-28 @@ -13152,3 +13153,54 @@ candidates, 0 real, because in that corpus 🔴 marks a correction being deliver far more often than a section overtaken. **Neither of us should build that.** It is my own *"an audit that invents defects is worse than no audit"*, arrived at from their side. + +## 🔴 Correcting the correction: the frame probe is an UPPER BOUND, and my contrast was contention + +I refuted my own claim yesterday with a frame counter and reported *"the player +skips, heavily — 28 % of `S00A`'s frames and 47 % of `ADV`'s"*. **Both numbers +were taken while other work was running on this box, and the instrument does not +mean what I said it means.** + +Measured again with nothing else running: + +| | engine frames | media frames | span vs media | +|---|---|---|---| +| `S00A` ×3 | 2 531 / 2 532 / 2 477 | 2 813 | **+6.8 %, +6.8 %, +6.7 %** | +| `ADV` ×1 | **6 480** | 4 123 | **+6.9 %** | + +**`ADV` drew 6 480 frames across a 4 123-frame video — 157 %.** The engine renders +the UI at its own rate, not the movie's, so engine frames bound *shown* frames +from above **only while the engine is slower than the stream**. Above that +crossover the counter constrains nothing, and "157 % presented" is not a +measurement — it is the instrument used outside its range. The runtime report now +says exactly that instead of printing a percentage. + +### Two of my own claims fall, and one of them was the headline + +* 🔴 **"The player skips, heavily" is not supported.** What the contended run + showed is that at 8.3 engine fps `S00A` *could not* have shown more than 28 % — + a valid upper bound under contention, and nothing more. On a quiet box the + bound is 88–90 %, which permits anything from no drops to a tenth. +* 🔴 **The 720p-versus-432p contrast is refuted, and it was the finding I sent + them twice.** I reported `ADV` +6.7 % against `S00A` −0.5 % and built + "heavy decode falls behind, light decode keeps up" on it. **Quiet, both run + +6.7 … +6.9 %.** The −0.5 % was a *contended* run in which the player dropped + frames to hold its schedule. **I was measuring which run happened to share the + box, and reading it as a property of the resolution.** + +✅ **What survives, and it is now the sturdiest number here:** playback runs +**+6.7 % … +6.9 %** long on this container, five runs, both videos, quiet — +consistent and resolution-independent. That is a real deficit and it is *not* the +mechanism I described. + +📌 **Three corrections in three iterations, all mine, all the same shape.** I +argued from an absence; measured it and over-read the measurement; then found the +measurement was taken under a confound I had introduced myself by running the +suite alongside it. **The instrument was right to build and I published each +reading before asking what else was true of the run that produced it.** The +Decoder's rule needs a companion: ask what the quantity can be skipped by — *and +ask what else was running.* + +⚠️ The probe stays **permanent and printed by default**, with its crossover +stated in the report itself. Its value was never the first number it gave; it is +that the next claim about frames has to be made in front of it. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 49fa8255..63d0b3b9 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -623,6 +623,9 @@ func _play_video(name: String, skippable: bool) -> void: await get_tree().process_frame _player.finished.connect(_video_finished) _player.play() + _video_meta = v + _video_started_at = _elapsed + _frames_at_video_start = Engine.get_frames_drawn() # The dialogue is a SECOND stream, started with the picture. `ADV.wmv` and # `S00A.wmv` carry music and effects only; the voice is a separate asset the # exporter resolves off the movie manifest. Started after `play()` and in the @@ -650,7 +653,61 @@ func _play_video(name: String, skippable: bool) -> void: var _skippable := false +## What the last movie actually presented, printed on every run. +## +## 🔴 PERMANENT ON PURPOSE. This port asserted that a player running longer than +## its media must have presented every frame -- argued from the absence of a +## visible drop rather than measured. The measurement is the two lines below and +## it refuted the claim outright: **28 % of `S00A`'s frames and 47 % of `ADV`'s** +## reached the screen, and `S00A` held real time precisely BY dropping three in +## four. +## +## So the count is not a diagnostic to reach for, it is printed by default. An +## instrument that has to be added before the question can be asked is one that +## will not be there the next time somebody reasons instead. +## +## 🔴 AND IT IS AN UPPER BOUND, NOT A COUNT -- corrected the same day it was +## added, because the first reading of it was wrong. It counts ENGINE frames. A +## player cannot show more frames than the engine draws, so this bounds shown +## frames from above -- but the engine renders the UI at its OWN rate, and on a +## quiet box it drew **6 480 frames across a 4 123-frame `ADV`**, 44 fps against +## the media's 30. Above that crossover the bound constrains nothing, and the +## report says so rather than printing "157 % presented". +## +## ⚠️ It says nothing about how many frames were DECODED either; Theora is +## inter-frame predicted, so a decoder may decode frames it never displays. +var _video_meta: Dictionary = {} +var _video_started_at := 0.0 +var _frames_at_video_start := 0 + + +func _video_report() -> void: + var dur := float(_video_meta.get("duration_s", 0.0)) + var fps := float(_video_meta.get("fps", 0.0)) + var span := _elapsed - _video_started_at + var drawn := Engine.get_frames_drawn() - _frames_at_video_start + if dur <= 0.0 or fps <= 0.0: + print(" presented %d engine frame(s) in %.2f s -- the manifest carries no" + % [drawn, span] + " duration, so nothing to compare against") + return + var expected := dur * fps + var timing := "%.2f s for %.2f s of media (%+.1f%%)" % [span, dur, 100.0 * (span - dur) / dur] + if drawn >= expected: + # 🔴 THE BOUND IS VACUOUS HERE AND MUST SAY SO. The engine renders the UI + # at its own rate, not the movie's: a quiet box drew 6 480 frames across + # a 4 123-frame `ADV`, 44 fps against the media's 30. "157 % presented" + # is not a measurement, it is the counter being used outside the range + # where it constrains anything. + print(" %d engine frame(s) across %d in the media -- engine faster than" + % [drawn, int(round(expected))] + + " the stream, so this bounds NOTHING about frames shown; %s" % timing) + return + print(" at most %d of %d frame(s) shown (%.0f%% upper bound) in %s" + % [drawn, int(round(expected)), 100.0 * drawn / expected, timing]) + + func _video_finished() -> void: + _video_report() print(" video ended at %.2f s" % _elapsed) # Before anything else: a voice that outlived a skipped intro would play on # over the title screen, which is the sort of bug that sounds like a feature. diff --git a/port/scripts/export_tree.gd b/port/scripts/export_tree.gd index 7312d17d..1a2d17b3 100644 --- a/port/scripts/export_tree.gd +++ b/port/scripts/export_tree.gd @@ -142,7 +142,16 @@ func video(name: String) -> Dictionary: if not FileAccess.file_exists(path): error = "manifest lists %s but %s is not there" % [name, path] return {} - return {"path": path, "command": entry.get("command", "")} + # `duration_s` and `fps` come with it so a run can report what it + # PRESENTED, not just how long it took. Godot's player drops frames + # to hold its schedule and drops most of them on this hardware, and + # elapsed seconds stay plausible while that happens. + return { + "path": path, + "command": entry.get("command", ""), + "duration_s": float(entry.get("duration_s", 0.0)), + "fps": float(entry.get("fps", 0.0)), + } error = "no video named %s in manifest.json" % name return {}