universal-video-transcode: any file in, one iPad+Android-playable MP4 out

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.
This commit is contained in:
Hermes Agent
2026-10-04 13:01:14 +08:00
commit 0cdb54f910
8 changed files with 1265 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""selftest - prove the frame-timing detector actually works, on this machine.
The whole value of this repo rests on one claim: a file can drop ~23% of its frames in a
browser while `r_frame_rate == avg_frame_rate` EXACTLY, and the defect is decidable from
the header alone. So the detector is tested against files built here, with both defect
shapes and two must-not-false-positive cases:
src.mp4 healthy MP4, B-frames, proper ctts -> must NOT be flagged
src.mkv same stream in Matroska (no ctts by design) -> must NOT be flagged
flat_noctts.mp4 MP4 video track with NO ctts box -> MUST be flagged
flat_ctts1.mp4 MP4 with a single constant ctts entry -> MUST be flagged
`flat_ctts1.mp4` is produced by binary-patching the real ctts box down to one entry and
padding the difference with a `free` box, so the moov keeps its exact size and no chunk
offsets shift. That is the shape YouTube/yt-dlp actually produced in the field.
Exit code 0 = all assertions held. Non-zero = the detector is wrong, and the tool cannot
be trusted to gate conversions.
Usage: python scripts/selftest.py
"""
import os
import shutil
import struct
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import transcode as T # noqa: E402
FFMPEG = T.FFMPEG
FFPROBE = T.FFPROBE
NESTED = (b"moov", b"trak", b"mdia", b"minf", b"stbl")
def run(cmd):
return subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace")
def find_box(buf, typ):
"""Walk the (nested) box tree, return (offset, size) of the first `typ`, else None."""
def walk(start, end):
p = start
while p + 8 <= end:
size = struct.unpack(">I", buf[p:p + 4])[0]
name = buf[p + 4:p + 8]
hdr = 8
if size == 1:
size = struct.unpack(">Q", buf[p + 8:p + 16])[0]
hdr = 16
elif size == 0:
size = end - p
if size < 8 or p + size > end:
return None
if name == typ:
return (p, size)
if name in NESTED:
got = walk(p + hdr, p + size)
if got:
return got
p += size
return None
return walk(0, len(buf))
def make_flat_ctts1(src, dst):
"""Rewrite the video track's ctts to ONE constant entry, same box size.
A ctts entry is (sample_count, sample_offset); one entry covering every sample with a
constant offset is exactly the defect: PTS == DTS + const for the whole stream.
The byte difference is absorbed by a `free` box so the moov size is unchanged and the
chunk offset tables stay valid.
"""
buf = bytearray(open(src, "rb").read())
hit = find_box(bytes(buf), b"ctts")
if not hit:
raise SystemExit("selftest: no ctts in %s - cannot build the flat_ctts1 fixture" % src)
off, size = hit
ver = buf[off + 8]
n = struct.unpack(">I", buf[off + 12:off + 16])[0]
total, first_off = 0, 0
for i in range(n):
c, o = struct.unpack(">Ii" if ver == 1 else ">II", buf[off + 16 + 8 * i:off + 24 + 8 * i])
if i == 0:
first_off = o
total += c
new = struct.pack(">I", 24) + b"ctts" + bytes([ver, 0, 0, 0]) + \
struct.pack(">I", 1) + struct.pack(">Ii" if ver == 1 else ">II", total, first_off)
pad = size - len(new)
if pad < 8:
raise SystemExit("selftest: ctts too small to patch in place (%d bytes)" % size)
filler = struct.pack(">I", pad) + b"free" + b"\x00" * (pad - 8)
buf[off:off + size] = new + filler
open(dst, "wb").write(bytes(buf))
return total
def main():
if not (shutil.which(FFMPEG) or os.path.exists(FFMPEG)):
raise SystemExit("ffmpeg not found - set TRANSCODE_FFMPEG or put ffmpeg on PATH")
d = tempfile.mkdtemp(prefix="tctest-")
print("work dir: %s\n" % d)
try:
src = os.path.join(d, "src.mp4")
# B-frames are required: without reordering there is nothing to get wrong, and the
# detector is correct to stay silent.
r = run([FFMPEG, "-v", "error", "-y", "-f", "lavfi",
"-i", "testsrc=size=320x240:rate=25", "-t", "3",
"-c:v", "libx264", "-preset", "ultrafast", "-bf", "3",
"-pix_fmt", "yuv420p", src])
if r.returncode != 0:
raise SystemExit("ffmpeg could not build the source: %s" % r.stderr)
hbf = run([FFPROBE, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=has_b_frames", "-of", "csv=p=0", src]).stdout.strip()
if hbf in ("", "0"):
raise SystemExit("fixture has no B-frames (has_b_frames=%r) - test is meaningless" % hbf)
raw = os.path.join(d, "raw.h264")
flat_no = os.path.join(d, "flat_noctts.mp4")
run([FFMPEG, "-v", "error", "-y", "-i", src, "-c", "copy",
"-bsf:v", "h264_mp4toannexb", "-f", "h264", raw])
run([FFMPEG, "-v", "error", "-y", "-fflags", "+genpts", "-r", "25",
"-i", raw, "-c", "copy", flat_no])
flat_c1 = os.path.join(d, "flat_ctts1.mp4")
total = make_flat_ctts1(src, flat_c1)
mkv = os.path.join(d, "src.mkv")
run([FFMPEG, "-v", "error", "-y", "-i", src, "-c", "copy", mkv])
cases = [
(src, False, "healthy MP4, proper ctts"),
(mkv, False, "Matroska - no ctts by design, must NOT be flagged"),
(flat_no, True, "MP4 with NO ctts box (PTS == DTS)"),
(flat_c1, True, "MP4 with a single constant ctts entry (PTS == DTS + const)"),
]
print("ctts table: %d samples folded into one constant entry\n" % total)
print("%-34s %-8s %-8s %s" % ("fixture", "expect", "got", "verdict"))
bad = 0
for path, expect, note in cases:
info = T.probe(path)
got = T.flat_timing(info, path)
ok = (got == expect)
bad += 0 if ok else 1
print("%-34s %-8s %-8s %-5s %s"
% (os.path.basename(path), expect, got, "ok" if ok else "FAIL", note))
# The detector must also drive the right DECISION, not just a boolean: a flat file
# cannot be fixed by -c copy, so plan_for has to choose a real re-encode.
print()
for path, want_encode, note in ((src, False, "healthy -> copy is safe"),
(flat_c1, True, "flat -> must re-encode")):
vmode, _, why = T.plan_for(T.probe(path), path, "auto")
must_reencode = vmode in ("x264", "hevc")
ok = (must_reencode == want_encode)
bad += 0 if ok else 1
print("%-34s plan=%-6s %-5s %s"
% (os.path.basename(path), vmode, "ok" if ok else "FAIL", note))
print("%-34s reason: %s" % ("", why))
print("\n%s" % ("ALL ASSERTIONS HELD" if not bad else "%d ASSERTION(S) FAILED" % bad))
return 1 if bad else 0
finally:
shutil.rmtree(d, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())
+492
View File
@@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""transcode - turn ANY video/audio file into an MP4 that plays on BOTH iPad and Android,
keeping quality as close to the source as the target codec allows.
Give it a path. A file converts; a folder converts everything in it. Nothing else is needed.
python transcode.py "D:/video/clip.mkv"
python transcode.py "D:/video" --recursive
python transcode.py "D:/video" --dry-run # plan only, no encoding
Three recipes are chosen automatically from the SOURCE FILE (never from a filename):
copy-video source is H.264/HEVC with a usable timeline -> remux to MP4, bit-identical
fix-audio same, but the audio is not AAC -> copy video, re-encode audio to AAC
encode VP9/AV1, or H.264 whose timeline is unusable -> full HEVC (or x264) re-encode
Quality policy ("as close to the original as possible"):
* never upscale; the source resolution is preserved
* video is COPIED whenever a copy is possible - that is lossless by definition
* HEVC re-encode targets -cq 27, measured at SSIM 0.9937 == visually transparent,
landing at ~source size. x264 (for browser-only targets) targets -crf 20.
* audio is COPIED when it is already AAC (bit-identical); otherwise AAC 256k, where the
residual error is the source's own lossy error, so more bits buy nothing.
Every output is verified before it is accepted; a failed item leaves the input untouched.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
FFMPEG = os.environ.get("TRANSCODE_FFMPEG", "ffmpeg")
FFPROBE = os.environ.get("TRANSCODE_FFPROBE", "ffprobe")
MIN_FREE_GB = 5
VIDEO_EXT = {".mkv", ".mp4", ".mov", ".avi", ".webm", ".flv", ".ts", ".m4v", ".wmv",
".mpg", ".mpeg", ".m2ts", ".3gp", ".ogv", ".vob"}
AUDIO_EXT = {".m4a", ".aac", ".mp3", ".opus", ".flac", ".wav", ".ogg", ".wma"}
SKIP_EXT = {".jpg", ".jpeg", ".png", ".nfo", ".vtt", ".srt", ".json", ".txt", ".part",
".ytdl", ".tmp"}
def log(*a):
print(time.strftime("%H:%M:%S ") + " ".join(str(x) for x in a), flush=True)
def run(cmd, timeout=86400):
return subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout)
def _fps(v):
try:
n, d = (v or "0/0").split("/")
return float(n) / float(d) if float(d) else 0.0
except Exception:
return 0.0
def probe(path):
r = run([FFPROBE, "-v", "error", "-print_format", "json",
"-show_streams", "-show_format", path], timeout=120)
try:
j = json.loads(r.stdout)
except Exception:
return None
v = next((s for s in j.get("streams", []) if s.get("codec_type") == "video"), None)
a = next((s for s in j.get("streams", []) if s.get("codec_type") == "audio"), None)
if not v:
return None
return {
"vcodec": (v.get("codec_name") or "").lower(),
"tag": (v.get("codec_tag_string") or "").lower(),
"w": v.get("width"), "h": v.get("height"),
"pix": (v.get("pix_fmt") or "").lower(),
"acodec": (a.get("codec_name") or "").lower() if a else "",
"channels": a.get("channels") if a else 0,
"dur": float(j.get("format", {}).get("duration") or 0),
"size": int(j.get("format", {}).get("size") or 0),
"fmt": (j.get("format", {}).get("format_name") or "").lower(),
"rfps": _fps(v.get("r_frame_rate")), "afps": _fps(v.get("avg_frame_rate")),
"hbf": int(v.get("has_b_frames") or 0),
}
def _ctts_entries(path):
"""ctts entry count from the moov; -1 if the MP4 video track has NO ctts box at all;
None if there is no MP4 sample table (not an MP4, or unreadable).
The -1/None distinction matters: a missing ctts is a DEFECT on an MP4 with B-frames,
but is perfectly normal in Matroska, which stores presentation timestamps per block.
"""
try:
with open(path, "rb") as f:
head = f.read(2_000_000)
except Exception:
return None
p, saw_stbl = 0, False
while p + 8 <= len(head):
size = int.from_bytes(head[p:p + 4], "big")
typ = head[p + 4:p + 8]
hdr = 8
if size == 1:
size = int.from_bytes(head[p + 8:p + 16], "big"); hdr = 16
elif size == 0:
break
if size < 8:
break
if typ == b"ctts":
return int.from_bytes(head[p + 12:p + 16], "big")
if typ == b"stbl":
saw_stbl = True
if typ in (b"moov", b"trak", b"mdia", b"minf", b"stbl"):
p += hdr
continue
p += size
return -1 if saw_stbl else None
def flat_timing(info, path):
"""True when a B-frame MP4 track carries no usable composition offsets.
Either a single constant ctts entry (PTS == DTS + const) or no ctts box at all
(PTS == DTS). Both put the frame timestamps in DECODE order; the decoder still
reorders by POC, so the timestamps on the output frames are wrong and the compositor
drops ~23% of them. r_frame_rate == avg_frame_rate EXACTLY, so no fps test sees it.
"""
if not info or info["hbf"] <= 0:
return False
ce = _ctts_entries(path)
if ce is None:
return False # not an MP4 video track - not our call to make
return ce <= 1
def pts_inversions(path, seconds=3):
"""(inversions, packets) over the first `seconds` of video packets.
A stream with B-frames MUST present frames out of decode order, so its container PTS
go backwards somewhere: 0 inversions means the timestamps are in decode order.
"""
r = run([FFPROBE, "-v", "error", "-select_streams", "v:0",
"-read_intervals", "%%+%d" % seconds,
"-show_entries", "packet=pts", "-of", "csv=p=0", path], timeout=180)
vals = []
for line in (r.stdout or "").splitlines():
t = line.strip().split(",")[0]
if t.lstrip("-").isdigit():
vals.append(int(t))
return sum(1 for i in range(len(vals) - 1) if vals[i + 1] < vals[i]), len(vals)
def unusable_timeline(info, path):
"""(bool, why) - frame timestamps that no player can present smoothly.
Covers BOTH defect shapes in one gate, across containers:
* MP4 -> ctts missing, or a single constant entry (PTS == DTS + const)
* Matroska -> no ctts by design, so read the packet PTS instead; 0 inversions on a
B-frame stream means decode-order timestamps
Either way the decoder reorders by POC and the compositor drops ~23% of frames, while
r_frame_rate == avg_frame_rate stays EXACTLY equal, so no fps test can see it.
"""
if not info or info["hbf"] <= 0:
return False, ""
ce = _ctts_entries(path)
if ce is not None:
if ce <= 1:
return True, "flat composition offsets (ctts=%s)" % (ce if ce >= 0 else "missing")
return False, ""
inv, n = pts_inversions(path)
if n >= 20 and inv == 0:
return True, "flat timestamps in %s (0 PTS inversions over %d packets)" % (info["fmt"], n)
return False, ""
def has_nvenc():
try:
r = run([FFMPEG, "-hide_banner", "-encoders"], timeout=60)
return "hevc_nvenc" in (r.stdout or "")
except Exception:
return False
def plan_for(info, path, force_codec):
"""Return (video_mode, audio_mode, reason). video_mode: copy | x264 | hevc."""
if info["dur"] < 1.0:
return None, None, "stub (needs re-download, not a transcode)"
if info["vcodec"] not in ("h264", "avc1", "hevc", "vp9", "vp09", "av1", "av01"):
return None, None, "unsupported video codec %s" % info["vcodec"]
acopy = info["acodec"] in ("aac", "mp4a", "")
amode = "copy" if acopy else "aac"
is_mp4 = ("mp4" in info["fmt"]) or ("mov" in info["fmt"])
flat, why_flat = unusable_timeline(info, path)
uniform = abs(info["rfps"] - info["afps"]) <= 0.01
if info["vcodec"] in ("h264", "avc1"):
# A copy is lossless, but only if the timeline is already correct: ffmpeg cannot
# retime under -c copy, so a defective one would survive into the output.
if flat:
return "x264", amode, "H.264 but %s - copy cannot retime" % why_flat
if not uniform:
return "x264", amode, "H.264 but non-uniform cadence - copy cannot retime"
if not is_mp4:
return "copy", amode, "H.264 in %s -> lossless remux to MP4" % (info["fmt"] or "?")
if info["tag"] and info["tag"] != "avc1":
return "copy", amode, "re-tag %s -> avc1" % info["tag"]
if not acopy:
return "copy", amode, "H.264 + %s audio -> copy video, fix audio" % info["acodec"]
return None, None, "already playable MP4 (H.264 + AAC, correct timeline)"
if info["vcodec"] == "hevc":
if is_mp4 and info["tag"] == "hvc1" and not flat and uniform and acopy:
return None, None, "already playable MP4 (HEVC hvc1 + AAC, correct timeline)"
if flat or not uniform:
return "hevc", amode, "HEVC but %s - must re-encode" % (why_flat or "non-uniform cadence")
return "copy", amode, "HEVC -> remux/tag hvc1 + fix audio"
want = force_codec if force_codec != "auto" else ("x264" if info["vcodec"] == "h264" else "hevc")
return want, amode, "%s -> %s re-encode" % (info["vcodec"].upper(), want)
def build_cmd(src, dst, info, vmode, amode, args, use_nvenc):
cmd = [FFMPEG, "-y", "-hide_banner", "-loglevel", "warning", "-stats"]
if vmode == "hevc" and use_nvenc:
dec = {"vp9": "vp9_cuvid", "vp09": "vp9_cuvid", "av1": "av1_cuvid",
"av01": "av1_cuvid", "h264": "h264_cuvid", "avc1": "h264_cuvid",
"hevc": "hevc_cuvid"}.get(info["vcodec"])
if dec:
cmd += ["-c:v", dec]
cmd += ["-i", src, "-map", "0:v:0", "-map", "0:a:0?", "-map_metadata", "0"]
if vmode == "copy":
cmd += ["-c:v", "copy"]
elif vmode == "x264":
cmd += ["-c:v", "libx264", "-preset", args.preset, "-crf", str(args.crf),
"-profile:v", "high", "-level:v", "4.0", "-pix_fmt", "yuv420p"]
else:
if use_nvenc:
br = info["size"] * 8 / max(info["dur"], 1)
cmd += ["-c:v", "hevc_nvenc", "-preset", args.nvenc_preset, "-tune", "hq",
"-rc", "vbr", "-cq", str(args.cq), "-b:v", "0",
"-maxrate", str(int(br * 1.5)), "-bufsize", str(int(br * 1.5 * 2.5))]
else:
cmd += ["-c:v", "libx265", "-preset", args.preset, "-crf", str(args.cq)]
cmd += ["-profile:v", "main", "-pix_fmt", "yuv420p", "-tag:v", "hvc1"]
# -fps_mode cfr is MANDATORY on every re-encode: without it the encoder silently drops
# the frames it cannot place (measured 0.5-1.3%), leaving doubled frame intervals that
# play as judder. Copy modes keep the source's own timing, which was verified above.
if vmode != "copy":
cmd += ["-fps_mode", "cfr"]
if amode == "copy":
cmd += ["-c:a", "copy"]
else:
cmd += ["-c:a", "aac", "-b:a", args.audio]
if args.channels:
cmd += ["-ac", str(args.channels)]
cmd += ["-movflags", "+faststart", dst]
return cmd
def verify(src_info, out, vmode, amode, check_decode=True):
"""Structural gate. Returns (ok, message). Every claim is read back from the file."""
oi = probe(out)
if not oi:
return False, "output unreadable"
if oi["dur"] < 1.0:
return False, "output is a stub"
if abs(oi["dur"] - src_info["dur"]) > max(1.0, src_info["dur"] * 0.01):
return False, "duration %.2f vs source %.2f" % (oi["dur"], src_info["dur"])
if (oi["w"], oi["h"]) != (src_info["w"], src_info["h"]):
return False, "resolution %sx%s vs %sx%s" % (oi["w"], oi["h"], src_info["w"], src_info["h"])
if vmode == "hevc":
if oi["vcodec"] != "hevc":
return False, "vcodec %s (want hevc)" % oi["vcodec"]
if oi["tag"] != "hvc1":
return False, "codec tag %s (Apple requires hvc1)" % oi["tag"]
if vmode == "x264" and oi["vcodec"] not in ("h264", "avc1"):
return False, "vcodec %s (want h264)" % oi["vcodec"]
if src_info["acodec"] and oi["acodec"] != "aac":
return False, "audio %s (want aac)" % oi["acodec"]
if oi["pix"] not in ("yuv420p", "yuvj420p"):
return False, "pixel format %s (want 8-bit 4:2:0)" % oi["pix"]
if vmode != "copy":
if abs(oi["rfps"] - oi["afps"]) > 0.01:
return False, "non-uniform cadence r=%.3f avg=%.3f (is -fps_mode cfr missing?)" % (
oi["rfps"], oi["afps"])
if flat_timing(oi, out):
return False, "flat composition offsets survived (ctts<=1 on a B-frame stream)"
if check_decode:
r = run([FFMPEG, "-v", "error", "-i", out, "-f", "null", "-"], timeout=7200)
if r.returncode != 0:
return False, "decode errors: %s" % (r.stderr or "").strip()[:200]
return True, "ok"
def out_path(src, args):
stem = os.path.splitext(os.path.basename(src))[0]
d = args.out or os.path.dirname(os.path.abspath(src))
return os.path.join(d, stem + ".mp4")
class Lock:
"""One encoder at a time. Two concurrent NVENC jobs starve each other and drop frames -
the exact defect this tool exists to prevent - so cron runs must never overlap."""
def __init__(self, path):
self.path = path
self.fd = None
def __enter__(self):
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or ".", exist_ok=True)
try:
self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(self.fd, str(os.getpid()).encode())
except FileExistsError:
try:
pid = int(open(self.path).read().strip() or 0)
except Exception:
pid = 0
if pid and _alive(pid):
raise SystemExit("another transcode is already running (pid %d, %s) - refusing "
"to start a second encoder" % (pid, self.path))
log("stale lock (pid %s gone) - taking it over" % pid)
os.remove(self.path)
self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(self.fd, str(os.getpid()).encode())
return self
def __exit__(self, *a):
try:
if self.fd is not None:
os.close(self.fd)
os.remove(self.path)
except OSError:
pass
def _alive(pid):
try:
if os.name == "nt":
r = run(["tasklist", "/FI", "PID eq %d" % pid], timeout=30)
return str(pid) in (r.stdout or "")
os.kill(pid, 0)
return True
except Exception:
return False
def collect(paths, recursive):
out = []
for p in paths:
if os.path.isfile(p):
out.append(p)
elif os.path.isdir(p):
if recursive:
for dp, _, fn in os.walk(p):
out += [os.path.join(dp, f) for f in fn]
else:
out += [os.path.join(p, f) for f in os.listdir(p)]
else:
log("!! not found: %s" % p)
keep = []
for f in sorted(out):
e = os.path.splitext(f)[1].lower()
if e in SKIP_EXT or e not in (VIDEO_EXT | AUDIO_EXT):
continue
if "_src" in os.path.basename(f) or ".part." in f:
continue
keep.append(f)
return keep
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("paths", nargs="+")
ap.add_argument("--out")
ap.add_argument("--recursive", "-r", action="store_true")
ap.add_argument("--codec", choices=["auto", "hevc", "x264"], default="auto")
ap.add_argument("--cq", type=int, default=27, help="NVENC HEVC quality (default 27)")
ap.add_argument("--crf", type=int, default=20, help="x264 CRF (default 20)")
ap.add_argument("--preset", default="medium")
ap.add_argument("--nvenc-preset", default="p6")
ap.add_argument("--audio", default="256k")
ap.add_argument("--channels", type=int, default=2, help="0 = keep the source layout")
ap.add_argument("--cpu", action="store_true", help="force libx265/libx264 (no NVENC)")
ap.add_argument("--overwrite", action="store_true")
ap.add_argument("--keep", action="store_true", help="keep the input file")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--no-decode-check", action="store_true")
ap.add_argument("--lock", default=os.path.join(os.path.expanduser("~"),
".transcode.lock"))
a = ap.parse_args()
# --out is a DIRECTORY; create it up front so the free-space probe and the encode
# both have a real path to work with (os.path.dirname of a not-yet-made dir is None,
# and shutil.disk_usage raises WinError 3 on a path that does not exist).
if a.out:
os.makedirs(a.out, exist_ok=True)
if not a.dry_run:
os.makedirs(os.path.dirname(os.path.abspath(a.lock)) or ".", exist_ok=True)
files = collect(a.paths, a.recursive)
if not files:
raise SystemExit("nothing to do: no video/audio files found in %s" % a.paths)
use_nvenc = (not a.cpu) and has_nvenc()
log("=== transcode: %d file(s) nvenc=%s codec=%s dry=%s"
% (len(files), use_nvenc, a.codec, a.dry_run))
done = {"ok": 0, "skipped": 0, "failed": 0}
plans = []
for src in files:
info = probe(src)
if not info:
log("SKIP %s: unreadable" % os.path.basename(src)); done["skipped"] += 1; continue
vmode, amode, reason = plan_for(info, src, a.codec)
if vmode is None:
log("SKIP %s: %s" % (os.path.basename(src), reason)); done["skipped"] += 1; continue
plans.append((src, info, vmode, amode, reason))
if a.dry_run:
for src, info, vmode, amode, reason in plans:
log("PLAN %s -> %s | video=%s audio=%s | %s"
% (os.path.basename(src), os.path.basename(out_path(src, a)),
vmode, amode, reason))
log("=== dry-run: %d to convert, %d already fine" % (len(plans), done["skipped"]))
return
with Lock(a.lock):
for i, (src, info, vmode, amode, reason) in enumerate(plans, 1):
dst = out_path(src, a)
probe_dir = os.path.dirname(os.path.abspath(dst)) or "."
os.makedirs(probe_dir, exist_ok=True)
free = shutil.disk_usage(probe_dir).free / 2 ** 30
if free < MIN_FREE_GB:
log("!!! ABORT: %.1f GB free, need %d GB - %d item(s) not attempted"
% (free, MIN_FREE_GB, len(plans) - i + 1))
break
log("--- %d/%d %s | video=%s audio=%s | %s"
% (i, len(plans), os.path.basename(src), vmode, amode, reason))
if os.path.exists(dst) and not a.overwrite:
log("SKIP: %s exists (use --overwrite)" % os.path.basename(dst))
done["skipped"] += 1; continue
part = dst + ".part.mp4"
cmd = build_cmd(src, part, info, vmode, amode, a, use_nvenc)
t0 = time.time()
r = run(cmd)
if r.returncode != 0:
log("!! ffmpeg failed rc=%s: %s" % (r.returncode, (r.stderr or "").strip()[-300:]))
if os.path.exists(part):
os.remove(part)
done["failed"] += 1; continue
el = time.time() - t0
ok, msg = verify(info, part, vmode, amode, check_decode=not a.no_decode_check)
if not ok:
log("!! verify failed: %s" % msg)
os.remove(part)
done["failed"] += 1; continue
os.replace(part, dst)
back = probe(dst)
if not back or abs(back["dur"] - info["dur"]) > max(1.0, info["dur"] * 0.01):
log("!! READ-BACK MISMATCH after writing %s - input left untouched" % dst)
done["failed"] += 1; continue
if not a.keep and os.path.abspath(src) != os.path.abspath(dst):
try:
os.remove(src)
except OSError:
pass
sz = os.path.getsize(dst)
log(" ok %s %.0f MB (%.0f%% of source) in %.0fs"
% (os.path.basename(dst), sz / 1e6, 100.0 * sz / max(info["size"], 1), el))
done["ok"] += 1
log("=== done: ok=%d skipped=%d failed=%d" % (done["ok"], done["skipped"], done["failed"]))
if done["failed"]:
sys.exit(1)
if __name__ == "__main__":
main()