From 3619adaf7c5f76d5964de624c28b0a412ffe0e09 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 4 Oct 2026 13:16:50 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20the=20benefit=20case=20=E2=80=94=20ten?= =?UTF-8?q?=20real=20failures=20and=20the=20guard=20each=20produced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/WHY.md records the headline case and the guards, all traceable to failures this pipeline actually hit rather than hypotheticals: - the file that dropped 23.0% of frames while r_frame_rate == avg_frame_rate read exactly equal, and which a full library scan did not flag - 27 invisible stutterers (1.2% of the library) with the same signature - a detector that missed one of the two defect shapes (no ctts box) entirely - a "lossless" remux that faithfully carried the stutter through -c copy - a re-download that reproduced the defect, because the source mkv is already flat - a video that played only its first 10 seconds after a short network copy, where getsize() returned stale metadata and size-based verification passed - a 115-file batch lost to a full disk - two concurrent encoders starving each other and dropping frames - an iPad refusing an otherwise perfect file over hev1 vs hvc1 - Opus copied into an MP4 Also carries the reproduction (scripts/selftest.py), why a browser cannot be the automated gate (Chrome decodes no HEVC, so an HEVC file reports 0/0 and looks perfect), the 2x2 that proves the cause is container timing and not the bitstream, and cron / Task Scheduler recipes with the reason each is safe to schedule. --- AGENTS.md | 1 + README.md | 5 +++ docs/WHY.md | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 docs/WHY.md diff --git a/AGENTS.md b/AGENTS.md index ffb91f0..02aa211 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ There are **no credentials** in this project, so there is no `SECRETS.md`. ``` SKILL.md the Hermes skill (installed per §4) README.md human-facing overview +docs/WHY.md the benefit case: ten real failures + the guard each produced scripts/transcode.py the tool: plan -> encode -> verify -> keep input on failure scripts/selftest.py proves the detector; both defect shapes, no false positives references/compatibility-matrix.md per-platform codec/container facts with citations diff --git a/README.md b/README.md index 9333fb5..839c0dd 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,11 @@ flat_noctts.mp4 True True ok MP4 with NO ctts box flat_ctts1.mp4 True True ok MP4 with a single constant ctts entry ``` +**Measured on the file that prompted this:** 23.0% of frames dropped before, **0.0% after**. + +The full benefit case — the ten real failures this pipeline hit and the guard each one +produced — is in [`docs/WHY.md`](docs/WHY.md). + ## Compatibility | | iPad / iOS | Android | diff --git a/docs/WHY.md b/docs/WHY.md new file mode 100644 index 0000000..73ab0ac --- /dev/null +++ b/docs/WHY.md @@ -0,0 +1,125 @@ +# Why this exists — the benefit, measured + +Every item below is a failure this pipeline **actually hit**, not a hypothetical. The tool +exists because each one was either invisible to the checks in place, or caused by an +operation that looked safe. + +## The headline case + +One video in a 4,435-file library "played choppy" on an iPad. Every check said it was fine: + +| check | result | +|---|---| +| `Content-Type`, `Accept-Ranges`, Range `206` | correct | +| `moov` before `mdat` (faststart) | correct | +| file downloaded and played locally | stutters **identically** — so not the network | +| decode integrity (`ffmpeg -f null -`) | exit 0 | +| decode speed | 57x realtime — *faster* than a clean 1080p control | +| `r_frame_rate` vs `avg_frame_rate` | **25.000 / 25.000 — exactly equal** | +| full library scan | **not flagged** | + +And yet, measured in real Chrome: **23.0% of frames dropped**, while a clean control dropped +**0.0%**. + +The cause was decidable from the header all along. The video track had B-frames, but its MP4 +composition-offset table was a single constant entry — so `PTS == DTS + const`, and the frame +timestamps were in *decode* order rather than presentation order. The decoder still reorders +by POC, so the timestamps attached to the output frames are wrong, and the compositor throws +away about a quarter of them. + +**`r == avg` exactly equal is the tell.** It is not a clean bill of health; it means the +timeline is uniform *and* flat, which is precisely the broken shape. + +| | before | after `transcode.py` | +|---|---|---| +| dropped frames (real Chrome) | **23.0%** | **0.0%** | +| `ctts` entries | 1 | 29,468 | +| PTS inversions | 0 | 35 | + +## What it prevents — and what each guard came from + +| past failure | what happened | the guard | +|---|---|---| +| **27 invisible stutterers** | 1.2% of the library dropped ~23% of frames while scanning clean, because the fps test is blind to flat composition offsets | `unusable_timeline()` gates on the header — and tests **both** shapes: a single constant `ctts` entry *and* a missing `ctts` box | +| **A quarter of the defect shape missed** | a detector written as `ctts == 1` skipped files with **no** `ctts` box at all — measured 22.8–23.5% dropped, reading as healthy | three return states: entry count / `-1` (MP4, no ctts → flat) / `None` (not an MP4 sample table → **not** flat, or every `.mkv` gets flagged) | +| **A "lossless" remux that kept the stutter** | `-c copy` cannot retime: ffmpeg will not rewrite `stts`/`ctts` under stream copy, so a flat input produces a flat output | `plan_for()` refuses to copy a defective timeline and forces a real re-encode | +| **Re-download made it worse, not better** | the `.mkv` yt-dlp writes is *already* flat, so a fresh download reproduces the defect | the fix is an encode, never a re-download | +| **A video that played only its first 10 seconds** | a copy to a network share came up truncated; `os.path.getsize()` returned **stale metadata from the previous file handle**, so size-based verification passed | verify by reading the file back (duration + resolution via probe), never by size | +| **A 115-file batch lost to a full disk** | ENOSPC mid-encode left a truncated `.part.mp4`; every remaining item then died with a misleading codec error | abort below 5 GB free, before the encode, not after | +| **Two encoders starving each other** | two concurrent NVENC jobs share one encoder unit, drop frames, and *cause* the judder the pipeline exists to remove | a lock file refuses a second instance | +| **An iPad refused a perfect file** | ffmpeg defaults to `hev1`; Apple requires `hvc1` | `-tag:v hvc1` on every HEVC output, asserted in verify | +| **Opus copied into an MP4** | Opus is not valid in MP4 and iOS cannot play it; the file was written but unplayable | copy audio only when it is already AAC, else AAC 256k | +| **Judder from the encoder itself** | without `-fps_mode cfr`, NVENC silently drops 0.5–1.3% of frames, leaving doubled intervals | `-fps_mode cfr` on every re-encode, asserted in verify | + +## Reproduce it + +The detector is the whole claim, so it is tested against files built on the spot: + +```sh +python scripts/selftest.py +``` + +``` +fixture expect got verdict +src.mp4 False False ok healthy MP4, proper ctts +src.mkv False False ok Matroska - no ctts by design +flat_noctts.mp4 True True ok MP4 with NO ctts box (PTS == DTS) +flat_ctts1.mp4 True True ok MP4 with a single constant ctts entry +``` + +`flat_ctts1.mp4` is made by folding a real 75-entry `ctts` table into one constant entry and +padding the difference with a `free` box, so the `moov` keeps its exact size — the same shape +YouTube/yt-dlp produced in the field. No fixture is checked in; both are generated per run. + +## Why a browser is not the gate + +The browser is the *arbiter* of the visible symptom, but it is the wrong tool for automation: + +- **Chrome cannot decode HEVC.** An HEVC file reports **0 decoded / 0 dropped** and looks + perfect while nothing played. A 0-frame result is not a pass. +- It needs a real Chrome and a real page — ~30 s per file, against ~50 ms for the header check. +- It is statistical: a 1-frame drop is noise, so it needs a threshold rather than equality. + +The structural gate is **causal**, which is what licenses automating it. That was not assumed — +it was proven by crossing container timing against bitstream health: + +| | flat container | correct offsets | +|---|---|---| +| **healthy bitstream** | **23.5% dropped** (stutter) | 0.0% | +| **defective bitstream** | 22.8% dropped | **0.0%** (fixed) | + +Writing the same flat container around a *healthy* bitstream reproduces the stutter; writing +proper offsets around the *broken* bitstream fixes it. The bitstream is irrelevant — the +container timing is the cause. + +## Automate it + +The tool is built to run unattended, so "did anyone remember to convert this?" stops being a +question: + +```sh +# every 15 minutes; the lock makes overlap impossible, so a long encode is never doubled +*/15 * * * * /usr/bin/python3 /opt/universal-video-transcode/scripts/transcode.py \ + /srv/media/incoming --recursive --out /srv/media/ready \ + >> /var/log/transcode.log 2>&1 +``` + +```bat +:: Windows Task Scheduler, every 15 minutes +python D:\dev\universal-video-transcode\scripts\transcode.py "Z:\media\incoming" ^ + --recursive --out "D:\media\ready" >> D:\logs\transcode.log 2>&1 +``` + +Why this is safe to schedule: + +- **idempotent** — an already-playable file is skipped, so a re-run costs one probe +- **self-locking** — overlap is refused, which also removes the two-encoders-drop-frames failure +- **failure-isolated** — a failed item leaves its input untouched and is retried next run +- **non-zero exit** when anything failed, so the scheduler's own alerting works +- **disk-guarded** — refuses to fill the work drive + +Before pointing it at a live library, `--dry-run` prints the plan and encodes nothing. + +> **TubeSync specifically:** use that project's own DB-driven pipeline +> (`hoelee-tubesync-management`: `tsconv.py` / `tsfix.py`) — it also updates the database +> rows. This tool is for everything outside it.