1
0

smart-compress.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. #!/usr/bin/env python3
  2. """Target-size compression: 'make this fit in 25MB' as one verified command.
  3. Computes the video bitrate from the size budget (duration-aware, audio and mux
  4. overhead subtracted), auto-selects an audio bitrate and a downscale rung when
  5. the bits-per-pixel would be hopeless at source resolution, runs a two-pass
  6. encode (predictable size, unlike CRF), and VERIFIES the result actually landed
  7. under the cap — retrying once at -8% if not.
  8. Usage: smart-compress.py --target SIZE [-o OUT] [--codec x264|x265]
  9. [--preset P] [--no-downscale] [--json] <file>
  10. Input: one media file as positional; SIZE like 25MB, 8M, 512KB, 1.5GB
  11. Output: stdout = result line (or --json envelope,
  12. schema claude-mods.ffmpeg-ops.compress/v1)
  13. Stderr: progress, plan explanation, errors
  14. Exit: 0 ok and under target, 2 usage, 3 input missing, 4 encode failure,
  15. 5 ffmpeg missing, 10 best effort still OVER target (kept, caller decides)
  16. Examples:
  17. smart-compress.py --target 25MB video.mp4 # Discord/email cap
  18. smart-compress.py --target 8MB -o clip_small.mp4 clip.mov
  19. smart-compress.py --target 50MB --codec x265 lecture.mp4
  20. smart-compress.py --target 10MB --json in.mp4 | jq '.data.final_bytes'
  21. """
  22. import argparse
  23. import json
  24. import re
  25. import shutil
  26. import subprocess
  27. import sys
  28. import tempfile
  29. from pathlib import Path
  30. from typing import NoReturn, Optional
  31. SCHEMA = "claude-mods.ffmpeg-ops.compress/v1"
  32. EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION = 0, 2, 3, 4
  33. EXIT_MISSING_DEP, EXIT_OVER_TARGET = 5, 10
  34. MUX_OVERHEAD = 0.98 # reserve 2% of the budget for container overhead
  35. DOWNSCALE_LADDER = [1080, 720, 540, 360, 270]
  36. MIN_BPP = 0.045 # below this bits-per-pixel, downscale instead
  37. def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
  38. if json_mode:
  39. print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
  40. print(f"ERROR: {message}", file=sys.stderr)
  41. sys.exit(exit_code)
  42. def parse_size(s: str) -> Optional[int]:
  43. m = re.fullmatch(r"([\d.]+)\s*([KMG]i?B?|B)?", s.strip(), re.IGNORECASE)
  44. if not m:
  45. return None
  46. mult = {"": 1, "B": 1, "K": 1000, "M": 1000**2, "G": 1000**3,
  47. "KI": 1024, "MI": 1024**2, "GI": 1024**3}
  48. unit = (m.group(2) or "").upper().rstrip("B")
  49. try:
  50. return int(float(m.group(1)) * mult[unit])
  51. except (KeyError, ValueError):
  52. return None
  53. def probe(ffprobe: str, path: Path) -> dict:
  54. proc = subprocess.run(
  55. [ffprobe, "-v", "error", "-print_format", "json",
  56. "-show_format", "-show_streams", str(path)],
  57. capture_output=True, text=True)
  58. if proc.returncode != 0:
  59. return {}
  60. raw = json.loads(proc.stdout)
  61. out = {"duration": float(raw.get("format", {}).get("duration", 0) or 0),
  62. "size": int(raw.get("format", {}).get("size", 0) or 0),
  63. "width": 0, "height": 0, "fps": 30.0, "has_audio": False}
  64. for s in raw.get("streams", []):
  65. if s.get("codec_type") == "video" and not out["width"]:
  66. out["width"], out["height"] = s.get("width", 0), s.get("height", 0)
  67. try:
  68. num, den = s.get("avg_frame_rate", "30/1").split("/")
  69. out["fps"] = (int(num) / int(den)) if int(den) else 30.0
  70. except (ValueError, ZeroDivisionError):
  71. pass
  72. elif s.get("codec_type") == "audio":
  73. out["has_audio"] = True
  74. return out
  75. def plan_encode(info: dict, target_bytes: int, allow_downscale: bool) -> dict:
  76. budget_kbps = (target_bytes * 8 / 1000) / info["duration"] * MUX_OVERHEAD
  77. # Audio gets ~12% of the budget, clamped to sane speech/music rates.
  78. audio_kbps = int(min(160, max(48, budget_kbps * 0.12))) if info["has_audio"] else 0
  79. video_kbps = budget_kbps - audio_kbps
  80. w, h, fps = info["width"], info["height"], info["fps"] or 30.0
  81. scaled_h = None
  82. if allow_downscale and w and h:
  83. bpp = video_kbps * 1000 / (w * h * fps)
  84. if bpp < MIN_BPP:
  85. for rung in DOWNSCALE_LADDER:
  86. if rung >= h:
  87. continue
  88. rw = w * rung / h
  89. if video_kbps * 1000 / (rw * rung * fps) >= MIN_BPP:
  90. scaled_h = rung
  91. break
  92. else:
  93. scaled_h = DOWNSCALE_LADDER[-1] if h > DOWNSCALE_LADDER[-1] else None
  94. return {"video_kbps": int(video_kbps), "audio_kbps": audio_kbps,
  95. "scale_height": scaled_h}
  96. def two_pass(ffmpeg: str, path: Path, out: Path, plan: dict, codec: str,
  97. preset: str, json_mode: bool) -> None:
  98. enc = {"x264": "libx264", "x265": "libx265"}[codec]
  99. vf = ["-vf", f"scale=-2:{plan['scale_height']}"] if plan["scale_height"] else []
  100. audio = (["-c:a", "aac", "-b:a", f"{plan['audio_kbps']}k", "-ar", "48000"]
  101. if plan["audio_kbps"] else ["-an"])
  102. tag = ["-tag:v", "hvc1"] if codec == "x265" else []
  103. with tempfile.TemporaryDirectory() as td:
  104. passlog = str(Path(td) / "ffpass")
  105. base = [ffmpeg, "-y", "-v", "error", "-i", str(path),
  106. "-c:v", enc, "-b:v", f"{plan['video_kbps']}k",
  107. "-preset", preset, "-pix_fmt", "yuv420p", *tag, *vf,
  108. "-passlogfile", passlog]
  109. p1 = subprocess.run([*base, "-pass", "1", "-an", "-f", "null",
  110. "NUL" if sys.platform == "win32" else "/dev/null"],
  111. capture_output=True, text=True)
  112. if p1.returncode != 0:
  113. err(json_mode, "VALIDATION",
  114. f"pass 1 failed: {(p1.stderr.strip().splitlines() or ['?'])[-1]}",
  115. EXIT_VALIDATION)
  116. p2 = subprocess.run([*base, "-pass", "2", *audio,
  117. "-movflags", "+faststart", str(out)],
  118. capture_output=True, text=True)
  119. if p2.returncode != 0:
  120. err(json_mode, "VALIDATION",
  121. f"pass 2 failed: {(p2.stderr.strip().splitlines() or ['?'])[-1]}",
  122. EXIT_VALIDATION)
  123. def main() -> int:
  124. ap = argparse.ArgumentParser(
  125. description="Compress a video to fit a size target (two-pass, verified).",
  126. epilog="Examples:\n"
  127. " smart-compress.py --target 25MB video.mp4\n"
  128. " smart-compress.py --target 8MB -o small.mp4 clip.mov\n",
  129. formatter_class=argparse.RawDescriptionHelpFormatter)
  130. ap.add_argument("file", help="input media file")
  131. ap.add_argument("--target", required=True, metavar="SIZE",
  132. help="size cap, e.g. 25MB, 8M, 1.5GB (MiB/GiB also accepted)")
  133. ap.add_argument("-o", "--output", default=None,
  134. help="output path (default <stem>.compressed.mp4)")
  135. ap.add_argument("--codec", default="x264", choices=("x264", "x265"),
  136. help="x264 = universal (default); x265 = ~40%% smaller, modern players")
  137. ap.add_argument("--preset", default="slow",
  138. help="encoder preset (default slow; use medium/fast for speed)")
  139. ap.add_argument("--no-downscale", action="store_true",
  140. help="never lower resolution, even at hopeless bits-per-pixel")
  141. ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
  142. args = ap.parse_args()
  143. target = parse_size(args.target)
  144. if not target or target <= 0:
  145. err(args.json, "USAGE", f"could not parse --target size: {args.target!r}",
  146. EXIT_USAGE)
  147. ffmpeg, ffprobe = shutil.which("ffmpeg"), shutil.which("ffprobe")
  148. if not ffmpeg or not ffprobe:
  149. err(args.json, "MISSING_DEPENDENCY", "ffmpeg/ffprobe not found on PATH",
  150. EXIT_MISSING_DEP)
  151. path = Path(args.file)
  152. if not path.is_file():
  153. err(args.json, "NOT_FOUND", f"file not found: {path}", EXIT_NOT_FOUND)
  154. info = probe(ffprobe, path)
  155. if not info or info["duration"] <= 0:
  156. err(args.json, "VALIDATION", "could not probe input (no duration)",
  157. EXIT_VALIDATION)
  158. if info["size"] and info["size"] <= target:
  159. print(f"input is already {info['size']} bytes <= target {target} — "
  160. f"no encode needed (copy it as-is)", file=sys.stderr)
  161. out = Path(args.output) if args.output else path.with_name(
  162. path.stem + ".compressed.mp4")
  163. plan = plan_encode(info, target, not args.no_downscale)
  164. if plan["video_kbps"] < 50:
  165. err(args.json, "VALIDATION",
  166. f"budget gives only {plan['video_kbps']} kb/s video for "
  167. f"{info['duration']:.0f}s — target too small; trim the video or raise it",
  168. EXIT_VALIDATION)
  169. scale_note = f", downscale to {plan['scale_height']}p" if plan["scale_height"] else ""
  170. print(f"plan: video {plan['video_kbps']}k + audio {plan['audio_kbps']}k "
  171. f"({args.codec}, two-pass, preset {args.preset}{scale_note})", file=sys.stderr)
  172. attempts = []
  173. current = dict(plan)
  174. for attempt in (1, 2):
  175. print(f"encoding (attempt {attempt})...", file=sys.stderr)
  176. two_pass(ffmpeg, path, out, current, args.codec, args.preset, args.json)
  177. size = out.stat().st_size
  178. attempts.append({"video_kbps": current["video_kbps"], "bytes": size})
  179. if size <= target:
  180. break
  181. # Two-pass overshoot is rare but real on short/complex content: -8%.
  182. print(f"over target ({size} > {target}); retrying at -8% bitrate",
  183. file=sys.stderr)
  184. current["video_kbps"] = int(current["video_kbps"] * 0.92)
  185. final = out.stat().st_size
  186. data = {"input": str(path), "output": str(out), "target_bytes": target,
  187. "final_bytes": final, "under_target": final <= target,
  188. "plan": plan, "attempts": attempts}
  189. if args.json:
  190. print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
  191. else:
  192. print(f"{out}\t{final}\t{'OK' if final <= target else 'OVER'}\t{target}")
  193. if final > target:
  194. print(f"best effort is still over target — kept at {final} bytes; "
  195. f"trim duration or accept a lower resolution", file=sys.stderr)
  196. return EXIT_OVER_TARGET
  197. print(f"done: {final} bytes ({100 * final / target:.0f}% of budget)",
  198. file=sys.stderr)
  199. return EXIT_OK
  200. if __name__ == "__main__":
  201. sys.exit(main())