"""Compression des rushs pour le web. Pour chaque rush, deux fichiers dans /videos : .mp4 1080p HEVC (tag hvc1), environ 40 % plus léger que H.264 _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): hd, sd = sorties(voyage, src) 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)