Give it a path; it reads the source, picks the recipe, encodes, and verifies the result before accepting it. The problem it exists to remove: a file can drop ~23% of its frames in a browser while r_frame_rate == avg_frame_rate reads EXACTLY equal, so every fps-based check calls it healthy and it never enters a repair queue. The cause is deterministic and header-readable -- the MP4 composition-offset table is unusable, either a single constant ctts entry (PTS == DTS + const) or no ctts box at all (PTS == DTS) -- so the frame timestamps are in decode order and the decoder's reordered output frames carry the wrong times. - three recipes chosen from the source FILE, never the filename: lossless -c copy when the timeline allows it, AAC-only fix when just the audio is wrong, full HEVC (or x264 for reach) when the timeline cannot be copied - quality policy: never upscale; HEVC -cq 27 measured at SSIM 0.9937 ~= source size; audio copied when already AAC, else AAC 256k - the two flags that must never be dropped: -fps_mode cfr and -pix_fmt yuv420p - verified before acceptance: duration, resolution, codec, hvc1 tag, 8-bit, uniform cadence, no flat composition offsets, full decode pass, and a read-back after the write - built for cron: lock file (two concurrent NVENC jobs starve one encoder and DROP FRAMES), idempotent, leaves the input untouched on failure, aborts below 5 GB free - scripts/selftest.py builds both defect shapes and asserts the detector, including a Matroska case that must NOT be flagged Measured on the file that prompted this: 23.0% of frames dropped before, 0.0% after.
9.2 KiB
name, description, version, author, license, platforms, metadata
| name | description | version | author | license | platforms | metadata | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| universal-video-transcode | Use when transcoding video for iPad+Android playback. | 1.0.0 | Hermes Agent | MIT |
|
|
Universal video transcode — anything in, one MP4 that plays on iPad AND Android
One tool: scripts/transcode.py. Hand it a path; a file converts, a folder converts
everything in it. It reads the source, picks the recipe itself, encodes, and verifies the
result before accepting it.
python scripts/transcode.py "D:/video/clip.mkv"
python scripts/transcode.py "D:/video" --recursive --dry-run # plan only
python scripts/transcode.py "D:/video" --out D:/converted
What "playable everywhere" actually means
The intersection that both platforms decode without a third-party player:
| iPad / iOS | Android | |
|---|---|---|
| Container | MP4/MOV only — no Matroska | MP4 and MKV both fine |
| Video | H.264 (all), HEVC hvc1 (A9+) |
H.264 Baseline mandatory; HEVC 5.0+ |
| Audio | AAC — never Opus | AAC and Opus both fine |
Apple is the constraint, not Android. iOS has no Matroska demuxer and cannot play Opus
at all, so MP4 + AAC is what forces the conversion. Android would have accepted the .mkv.
Two facts that decide the video codec:
- HEVC must be tagged
hvc1. ffmpeg defaults tohev1, which Apple refuses.-tag:v hvc1is not optional. - Android only guarantees HEVC Main profile Level 3 (~SD). 1080p output is Level 4.0
and 4K is 5.0 — fine on modern phones, but a low-end/old device may only reach Level 3.
When the audience includes cheap Android hardware, prefer H.264 (
--codec x264), which is mandatory from Android 3.0 and universal on iOS.
Full matrix, per-platform citations, and the HDR/10-bit caveats: references/compatibility-matrix.md.
The three recipes it picks automatically
Decided from the source file, never from its filename or extension:
| recipe | when | video | audio |
|---|---|---|---|
copy |
H.264/HEVC with a usable timeline | -c copy — bit-identical |
copy if AAC |
fix-audio |
same, audio is not AAC | -c copy |
AAC 256k |
encode |
VP9/AV1, or H.264/HEVC whose timeline is unusable | HEVC (or x264) | copy if AAC, else AAC 256k |
copy is used whenever a copy is possible, because a copy is lossless by definition —
that is the strongest possible answer to "keep the quality the same".
Quality policy ("as close to the original as possible")
- Never upscale. The source resolution is preserved; there is no
scalein the pipeline. - HEVC targets
-cq 27, measured on this hardware at SSIM 0.9937 (visually transparent) while landing at ~source size.-cq 28-30buys 14-32% smaller at SSIM still >=0.992. - H.264 (when chosen for reach) targets
-crf 20. - Audio: copied when already AAC (bit-identical); otherwise AAC 256k, where the residual error is the source's own lossy error — 320k buys nothing measurable.
- Bitrate cap:
-maxrate= 1.5x the source bitrate,-bufsize= 2.5x that cap. Never setbufsizeequal tomaxrate(a 1-second VBV buffer starves the encoder and collapses quality on complex scenes); never cap at exactly the source bitrate (that pushes quality below the original — a second-generation loss).
The two flags that must never be dropped
-fps_mode cfr # without it the encoder silently drops 0.5-1.3% of frames,
# leaving doubled frame intervals that play as judder
-pix_fmt yuv420p # 8-bit 4:2:0; 10-bit / 4:2:2 / 4:4:4 break device compatibility
-fps_mode cfr makes ffmpeg duplicate a frame it cannot place instead of dropping it.
Duplicated frames cost almost nothing; a gap is visible. The tool adds both automatically and
fails the item if the output comes back non-uniform.
Verifying "no dropped frames" — and whether a browser is needed
Short answer: the browser is the arbiter, but it is NOT the gate. Automate the causal check; use the browser only to spot-check.
The user-visible symptom (choppy playback) comes from a deterministic property of the container: whether the frame timestamps are in presentation order or decode order. That is readable from the header, so it needs no playback at all.
The defect this matters for: a B-frame stream whose MP4 composition-offset table is
unusable — either a single constant ctts entry (PTS == DTS + const) or no ctts box
at all (PTS == DTS). The decoder still reorders by POC, so the timestamps attached to the
output frames are wrong and the compositor drops ~23% of them — while
r_frame_rate == avg_frame_rate stays EXACTLY equal, so every fps-based test calls the
file healthy.
| gate | cost | covers HEVC? | role |
|---|---|---|---|
r_frame_rate vs avg_frame_rate |
~50 ms | yes | catches dropped/irregular cadence only |
ctts / PTS-inversion structural check |
~50 ms | yes | the automatable gate — causal |
full decode (ffmpeg -f null -) |
minutes | yes | decode integrity |
real Chrome droppedVideoFrames |
~30 s/file | NO | arbiter for H.264 spot-checks |
Three traps that make the browser check unsuitable as an automated gate:
- Chrome has no HEVC decoder.
canPlayType('video/mp4; codecs="hvc1…"')returns'', so an HEVC file reports 0 decoded / 0 dropped and looks perfect while nothing played. A 0-frame result is not a pass. HEVC can only be confirmed on the Apple device itself. - It needs a real Chrome and a real page; ~30 s per file is far too slow for a library.
- The measurement is statistical (a 1-frame drop is noise), so it needs a threshold, not equality.
The tool therefore runs the structural gate on every file automatically, plus a full decode
pass, and refuses the output otherwise. Measurements, the exact thresholds, and the
2x2 experiment that proved causality: references/verification-and-automation.md.
Running it unattended (cron)
Safe to schedule, because it is idempotent and self-locking:
- A lock file (default
~/.transcode.lock) refuses to start a second instance. This is not optional hygiene: two concurrent NVENC jobs share one encoder unit, starve each other, and drop frames — the exact defect the tool exists to prevent. - Idempotent: a file that is already a playable MP4 is skipped, so a re-run costs a probe
and nothing else.
--dry-runprints the plan without encoding. - A failed item leaves the input untouched, so the next run simply retries it.
- Disk guard: aborts below 5 GB free rather than filling the work drive (an ENOSPC mid-encode is reported as a misleading codec error and costs every remaining item).
# hourly; exits non-zero if anything failed
python /path/to/scripts/transcode.py "Z:/docker/tubesync/download" --recursive \
--out "Z:/docker/tubesync/converted" >> /var/log/transcode.log 2>&1
For a TubeSync library specifically, prefer its own DB-driven pipeline (tubesync-selfhosted)
— it also updates the database rows. Use this tool for anything outside that library.
Pitfalls
- Never write the encode output directly to a network share. A streaming
.partwrite over SMB/SFTP/Dokan leaves a corrupt stub when the connection blips. Encode to local disk, verify, then copy the complete file across. The tool writesdst + ".part.mp4"and renames only after verification — keep it on a local path if the destination is remote. os.path.getsize()lies on RaiDrive/Dokan — it returns cached metadata from the previous file handle. Verify a network copy by sequential chunked read, never by size.- A
.mkvextension on a converted file is cosmetic. TubeSync's rename pass rewrites finished.mp4files back to.mkvwhiledownloaded_containerstill readsmp4, andContent-Typefollows the container. Do not "fix" it and do not report it as a playback bug. - Do not re-download to fix a timing defect. The mkv that yt-dlp writes is itself flat, so a re-download reproduces the defect exactly.
- A
-c copyremux cannot repair timing. ffmpeg will not rewritestts/cttsunder stream copy, and retiming only the SPS (-bsf:v h264_metadata=tick_rate=…) changes nothing either. Repairing a copy-tier file means re-encoding its video — quote the size cost (1.28x at crf 23, 1.74x at crf 20) before applying it to a whole library. - Unicode paths: on Windows, drive ffmpeg from Python
subprocesswith an argv list. MSYS bash cannot hand a Chinese filename to nativeffmpeg.exeeven whenlsshows it. -hwaccel cudaalone may still software-decode VP9;-c:v vp9_cuvidforces NVDEC.
Support files
scripts/transcode.py— the tool.--dry-run,--codec,--cq/--crf,--audio,--channels,--cpu,--keep,--overwrite,--no-decode-check,--lock.references/compatibility-matrix.md— per-platform codec/container facts with citations, the HEVC level caveat, and what breaks on each device.references/verification-and-automation.md— the defect classes, the structural gate, the browser arbiter and its blind spot, and the 2x2 causality proof.