Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
189 lines
8.2 KiB
Python
189 lines
8.2 KiB
Python
"""Compression des rushs pour le web.
|
||
|
||
Pour chaque rush, deux fichiers dans <voyage>/videos :
|
||
<base>.mp4 1080p HEVC (tag hvc1), environ 40 % plus léger que H.264
|
||
<base>_mobile.mp4 720p H.264 (tag avc1), chargé par défaut, lisible partout
|
||
|
||
Un rush dont les deux sorties existent et sont lisibles est sauté. Les
|
||
sorties sont écrites sous un nom caché puis renommées : un encodage
|
||
interrompu ne laisse jamais un fichier abîmé sous le vrai nom.
|
||
"""
|
||
import os
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
|
||
from rtg.config import parametres_video
|
||
from rtg.outils import coeurs_performance, dire, exiger, lisible, taille_lisible
|
||
|
||
|
||
def _encodeurs_disponibles():
|
||
r = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True)
|
||
return r.stdout
|
||
|
||
|
||
def verifier_encodeurs(p):
|
||
liste = _encodeurs_disponibles()
|
||
besoins = (["hevc_videotoolbox", "h264_videotoolbox"] if p["encodeur"] == "vt"
|
||
else ["libx265", "libx264"])
|
||
manquants = [e for e in besoins if e not in liste]
|
||
if manquants:
|
||
conseil = ("Choisis l'encodeur « processeur »." if p["encodeur"] == "vt"
|
||
else "Installe un ffmpeg complet (brew install ffmpeg sous macOS).")
|
||
raise SystemExit(f"❌ Encodeur(s) absent(s) de ffmpeg : {', '.join(manquants)}. {conseil}")
|
||
|
||
|
||
def repartition(p):
|
||
"""(jobs, threads HD, threads SD) — même logique que l'ancien compress.sh."""
|
||
coeurs = coeurs_performance()
|
||
jobs = p["jobs"] or (1 if p["encodeur"] == "vt" else max(1, coeurs // 4))
|
||
threads = p["threads"] or max(2, coeurs // jobs)
|
||
return jobs, threads, max(2, threads // 2), coeurs
|
||
|
||
|
||
def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src, stats):
|
||
hd = palier == "hd"
|
||
if hd:
|
||
vf = "scale=-2:1080:flags=lanczos" if hauteur_src > 1080 else None
|
||
if p["encodeur"] == "vt":
|
||
vopts = ["-c:v", "hevc_videotoolbox", "-profile:v", "main", "-q:v", str(p["vtq_hd"]),
|
||
"-allow_sw", "1", "-realtime", "0", "-tag:v", "hvc1"]
|
||
else:
|
||
# keyint=120 : une image-clé toutes les 2 s à 60 fps. pools borne le
|
||
# pool de threads x265. hvc1 : sans lui, Safari refuse la vidéo.
|
||
vopts = ["-c:v", "libx265", "-preset", p["preset"], "-profile:v", "main",
|
||
"-crf", str(p["crf_hevc"]),
|
||
"-x265-params", f"keyint=120:min-keyint=24:aq-mode=3:pools={threads}",
|
||
"-maxrate", "8M", "-bufsize", "16M", "-tag:v", "hvc1"]
|
||
audio = "96k"
|
||
else:
|
||
vf = "scale=-2:720:flags=lanczos"
|
||
if p["encodeur"] == "vt":
|
||
vopts = ["-c:v", "h264_videotoolbox", "-profile:v", "high", "-q:v", str(p["vtq_sd"]),
|
||
"-allow_sw", "1", "-realtime", "0"]
|
||
else:
|
||
vopts = ["-c:v", "libx264", "-preset", p["preset"], "-profile:v", "high", "-level", "4.0",
|
||
"-crf", str(p["crf_h264"]), "-g", "120", "-keyint_min", "24",
|
||
"-threads", str(threads_sd), "-maxrate", "5M", "-bufsize", "10M"]
|
||
audio = "64k"
|
||
cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error"]
|
||
if stats:
|
||
cmd.append("-stats")
|
||
cmd += ["-i", src, "-map", "0:v:0", "-map", "0:a:0?"] + vopts
|
||
if vf:
|
||
cmd += ["-vf", vf]
|
||
cmd += ["-pix_fmt", "yuv420p", "-fpsmax", str(p["fps_max"]),
|
||
"-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
|
||
"-c:a", "aac", "-b:a", audio, "-ac", "1", "-ar", "48000",
|
||
"-map_metadata", "-1", "-movflags", "+faststart", "-y", dest]
|
||
return cmd
|
||
|
||
|
||
def _hauteur(src):
|
||
r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||
"stream=height", "-of", "csv=p=0", src], capture_output=True, text=True)
|
||
try:
|
||
return int(r.stdout.strip().splitlines()[0])
|
||
except (ValueError, IndexError):
|
||
return 1080
|
||
|
||
|
||
def sorties(voyage, src):
|
||
base = os.path.splitext(os.path.basename(src))[0]
|
||
dossier = os.path.join(voyage, "videos")
|
||
return os.path.join(dossier, base + ".mp4"), os.path.join(dossier, base + "_mobile.mp4")
|
||
|
||
|
||
def deja_faite(voyage, src, rapide=False):
|
||
"""Les deux sorties existent (rapide=True) ou sont lisibles par ffprobe."""
|
||
hd, sd = sorties(voyage, src)
|
||
if rapide:
|
||
return all(os.path.exists(f) and os.path.getsize(f) > 0 for f in (hd, sd))
|
||
return lisible(hd) and lisible(sd)
|
||
|
||
|
||
def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||
"""Compresse les rushs du voyage. `progression(nom, etat)` est appelée à
|
||
chaque changement (pour l'interface). Renvoie True si tout a réussi."""
|
||
videos = analyse.videos
|
||
os.makedirs(os.path.join(voyage, "videos"), exist_ok=True)
|
||
p = parametres_video(cfg)
|
||
exiger("ffmpeg", "Installe-le : https://ffmpeg.org (brew install ffmpeg sous macOS).")
|
||
exiger("ffprobe", "Il est fourni avec ffmpeg.")
|
||
verifier_encodeurs(p)
|
||
jobs, threads, threads_sd, coeurs = repartition(p)
|
||
|
||
dire("--- COMPRESSION (HEVC 1080p / H.264 720p) ---")
|
||
if p["encodeur"] == "vt":
|
||
dire(f"⚙️ Encodeur : matériel VideoToolbox (q={p['vtq_hd']} / {p['vtq_sd']})")
|
||
else:
|
||
dire(f"⚙️ Encodeur : processeur, preset={p['preset']} (HEVC CRF {p['crf_hevc']} / H.264 CRF {p['crf_h264']})")
|
||
dire(f"🧵 Parallélisme : {jobs} rush(s) à la fois × 2 paliers ({threads} threads en 1080p, "
|
||
f"{threads_sd} en 720p ; {coeurs} cœur(s) performance)")
|
||
dire(f"📼 {len(videos)} rush(s)")
|
||
|
||
verrou = threading.Lock()
|
||
bilan = {"faits": 0, "sautes": 0, "echecs": 0, "octets_src": 0, "octets_web": 0}
|
||
|
||
def signaler(nom, etat):
|
||
if progression:
|
||
progression(nom, etat)
|
||
|
||
def un_rush(src):
|
||
nom = os.path.basename(src)
|
||
hd, sd = sorties(voyage, src)
|
||
if not force and lisible(hd) and lisible(sd):
|
||
with verrou:
|
||
bilan["sautes"] += 1
|
||
signaler(nom, "deja_faite")
|
||
return True
|
||
h = _hauteur(src)
|
||
dire(f"🎬 {nom} ({h}p) — encodage des 2 paliers en parallèle…")
|
||
signaler(nom, "en_cours")
|
||
tmp_hd = os.path.join(os.path.dirname(hd), ".tmp-" + os.path.basename(hd))
|
||
tmp_sd = os.path.join(os.path.dirname(sd), ".tmp-" + os.path.basename(sd))
|
||
stats = jobs == 1
|
||
procs = [subprocess.Popen(_commande(src, tmp_hd, "hd", p, threads, threads_sd, h, stats)),
|
||
subprocess.Popen(_commande(src, tmp_sd, "sd", p, threads, threads_sd, h, stats))]
|
||
codes = [pr.wait() for pr in procs]
|
||
if codes == [0, 0] and lisible(tmp_hd) and lisible(tmp_sd):
|
||
os.replace(tmp_hd, hd)
|
||
os.replace(tmp_sd, sd)
|
||
with verrou:
|
||
bilan["faits"] += 1
|
||
bilan["octets_src"] += os.path.getsize(src)
|
||
bilan["octets_web"] += os.path.getsize(hd) + os.path.getsize(sd)
|
||
dire(f" ✅ {nom:<24} {taille_lisible(os.path.getsize(src))} → 1080p "
|
||
f"{taille_lisible(os.path.getsize(hd))} + 720p {taille_lisible(os.path.getsize(sd))}")
|
||
signaler(nom, "faite")
|
||
return True
|
||
for t in (tmp_hd, tmp_sd):
|
||
if os.path.exists(t):
|
||
os.remove(t)
|
||
with verrou:
|
||
bilan["echecs"] += 1
|
||
dire(f" ❌ Échec sur {nom}")
|
||
signaler(nom, "echec")
|
||
return False
|
||
|
||
# Restes d'une exécution interrompue
|
||
for f in os.listdir(os.path.join(voyage, "videos")):
|
||
if f.startswith(".tmp-"):
|
||
os.remove(os.path.join(voyage, "videos", f))
|
||
|
||
t0 = time.time()
|
||
with ThreadPoolExecutor(jobs) as pool:
|
||
resultats = list(pool.map(un_rush, videos))
|
||
duree = int(time.time() - t0)
|
||
dire()
|
||
if bilan["faits"]:
|
||
dire(f"✅ {bilan['faits']} vidéo(s) encodée(s) en {duree // 60} min {duree % 60:02d} s")
|
||
dire(f"📊 Rushs : {bilan['octets_src'] // 1048576} Mo → Web : {bilan['octets_web'] // 1048576} Mo "
|
||
f"({bilan['octets_web'] * 100 / max(bilan['octets_src'], 1):.0f} % du volume source)")
|
||
else:
|
||
dire(f"ℹ️ Aucun nouvel encodage ({bilan['sautes']} déjà faite(s))")
|
||
if bilan["echecs"]:
|
||
dire(f"⚠️ {bilan['echecs']} échec(s)")
|
||
return all(resultats)
|