From 13bc0b02f7764ce251830a44825d854e8198b151 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sun, 30 Aug 2026 23:26:18 +0000 Subject: [PATCH] port: make the frame count permanent, then correct what I read from it twice The Decoder's closing point -- the inference is cheap and the measurement looks expensive right up until someone does it -- is actionable, so the probe I reverted is now permanent. The exporter records each transcode's duration and frame rate in the manifest (probed from the file it wrote, not the source), and every video run prints what it showed against what the media holds. An instrument that has to be added before the question can be asked will not be there the next time somebody reasons instead. Then the instrument corrected me twice more. It is an UPPER BOUND, not a count. It counts engine frames, and the engine renders the UI at its own rate: on a quiet box ADV drew 6480 frames across a 4123-frame video, 44 fps against the media's 30. Above that crossover it constrains nothing, and '157% presented' is the counter used outside its range. The report now says so instead of printing a percentage. So 'the player skips, heavily' is not supported. At 8.3 engine fps under contention S00A could not have shown more than 28% -- a valid bound under contention and nothing more. Quiet, the bound is 88-90%, permitting anything from no drops to a tenth. And the 720p-versus-432p contrast is refuted -- the finding I sent them twice. I reported ADV +6.7% against S00A -0.5% and built 'heavy decode falls behind, light 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 schedule. I was measuring which run happened to share the box and reading it as a property of the resolution. What survives is sturdier than either: playback runs +6.7%...+6.9% long on this container, five runs, both videos, quiet, resolution-independent. Three corrections in three iterations, all mine, all the same shape: argued from an absence; measured and over-read; then found the measurement was taken under a confound I introduced myself by running the suite alongside it. Their rule needs a companion -- ask what the quantity can be skipped by, and ask what else was running. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF --- crates/sylpheed-export/src/main.rs | 7 ++++ crates/sylpheed-export/src/video.rs | 48 ++++++++++++++++++++++++ docs/port/BLOCKED.md | 2 +- docs/port/DECISIONS.md | 54 ++++++++++++++++++++++++++- port/scripts/boot.gd | 57 +++++++++++++++++++++++++++++ port/scripts/export_tree.gd | 11 +++++- 6 files changed, 176 insertions(+), 3 deletions(-) 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 {}