docs: the benefit case — ten real failures and the guard each produced
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+125
@@ -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.
|
||||
Reference in New Issue
Block a user