- admin.sh : RTG lancé directement sur le Mac (plus de conteneur), nginx joint l'interface par host.docker.internal:8765 ; variante conteneur conservée (admin-docker.sh, docker-compose-conteneur.yml) - Compression : réglages lus dans les fichiers (CRF, codec), refaire seulement le 1080p ou le 720p, seulement si les réglages ont changé ; réglages vidéo propres à chaque voyage ; « Compression actuelle » affichée - Plus d'arrêt automatique après 2 h d'inactivité (--arret-inactif, 0 par défaut) ; arrêt propre qui attend la fin des encodages en cours - Aide contextuelle dans l'interface (rtg/interface/aide) ; README centré sur l'installation et la mise en place - Cœurs attribués au conteneur détectés (cgroup) et signalés Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
378 lines
16 KiB
Python
378 lines
16 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 re
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
|
||
from rtg.config import parametres_video
|
||
from rtg.outils import ARRET, 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):
|
||
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}:log-level=error",
|
||
"-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"
|
||
# -progress pipe:1 : avancement lisible par RTG (out_time_us=…) au lieu de
|
||
# la ligne -stats, illisible quand deux encodages tournent ensemble.
|
||
cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-nostats", "-progress", "pipe:1"]
|
||
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 _duree(src):
|
||
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||
"-of", "csv=p=0", src], capture_output=True, text=True)
|
||
try:
|
||
return float(r.stdout.strip())
|
||
except ValueError:
|
||
return 0.0
|
||
|
||
|
||
def _suivre(proc, palier, duree, avancement):
|
||
"""Lit la sortie -progress de ffmpeg et met à jour avancement[palier] (0-100)."""
|
||
for ligne in proc.stdout:
|
||
if ligne.startswith(("out_time_us=", "out_time_ms=")) and duree > 0:
|
||
try:
|
||
avancement[palier] = min(100.0, int(ligne.split("=")[1]) / 1e6 / duree * 100)
|
||
except ValueError:
|
||
pass
|
||
elif ligne.startswith("progress=end"):
|
||
avancement[palier] = 100.0
|
||
|
||
|
||
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")
|
||
|
||
|
||
# --- Compression des vidéos déjà faites ------------------------------------------
|
||
# x265 et x264 inscrivent leurs réglages dans la vidéo elle-même (« options:
|
||
# … crf=26.0 … ») : on les relit au début des données, sans tout parcourir.
|
||
_MARQUES = ((b"x265 (build", "HEVC"), (b"x264 - core", "H.264"))
|
||
_cache_reglages = {}
|
||
|
||
|
||
def _debut_des_donnees(chemin, octets=128 * 1024):
|
||
taille = os.path.getsize(chemin)
|
||
with open(chemin, "rb") as f:
|
||
pos = 0
|
||
while pos + 8 <= taille:
|
||
f.seek(pos)
|
||
h = f.read(16)
|
||
n, typ, entete = int.from_bytes(h[:4], "big"), h[4:8], 8
|
||
if n == 1:
|
||
n, entete = int.from_bytes(h[8:16], "big"), 16
|
||
elif n == 0:
|
||
n = taille - pos
|
||
if typ == b"mdat":
|
||
f.seek(pos + entete)
|
||
return f.read(octets)
|
||
if n < 8:
|
||
break
|
||
pos += n
|
||
return b""
|
||
|
||
|
||
def reglages_fichier(chemin):
|
||
"""(codec, crf) d'une vidéo encodée par RTG ; crf vaut None pour un encodage
|
||
matériel ou inconnu. Résultat gardé tant que le fichier ne change pas."""
|
||
try:
|
||
st = os.stat(chemin)
|
||
except OSError:
|
||
return None
|
||
cle = (chemin, st.st_mtime, st.st_size)
|
||
if cle in _cache_reglages:
|
||
return _cache_reglages[cle]
|
||
res = ("autre", None)
|
||
try:
|
||
# HEVC (hvc1) : les réglages sont dans la description de la piste, au
|
||
# début du fichier ; H.264 : dans la première image, au début des données.
|
||
with open(chemin, "rb") as f:
|
||
donnees = f.read(64 * 1024)
|
||
donnees += _debut_des_donnees(chemin)
|
||
for marque, codec in _MARQUES:
|
||
i = donnees.find(marque)
|
||
if i >= 0:
|
||
texte = donnees[i:i + 6000].split(b"\x00")[0].decode("ascii", "replace")
|
||
m = re.search(r"crf=([\d.]+)", texte)
|
||
res = (codec, float(m.group(1)) if m else None)
|
||
break
|
||
except OSError:
|
||
pass
|
||
if res[0] == "autre":
|
||
# Pas d'inscription (encodeur matériel VideoToolbox, ou autre) : le codec seul.
|
||
r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||
"stream=codec_name", "-of", "csv=p=0", chemin], capture_output=True, text=True)
|
||
codec = {"hevc": "HEVC", "h264": "H.264"}.get(r.stdout.strip(), r.stdout.strip() or "inconnu")
|
||
res = (codec, None)
|
||
_cache_reglages[cle] = res
|
||
return res
|
||
|
||
|
||
def bilan_compression(voyage, maxi=40):
|
||
"""Compression des vidéos web du voyage, par version :
|
||
{"1080p": {"total": n, "lues": k, "reglages": Counter({(codec, crf): nb})}, "720p": …}.
|
||
Au-delà de `maxi` vidéos, un échantillon réparti sur tout le voyage est lu."""
|
||
from collections import Counter
|
||
res = {}
|
||
dossier = os.path.join(voyage, "videos")
|
||
noms = sorted(n for n in os.listdir(dossier) if n.endswith(".mp4") and not n.startswith(".")) \
|
||
if os.path.isdir(dossier) else []
|
||
for version, liste in (("1080p", [n for n in noms if not n.endswith("_mobile.mp4")]),
|
||
("720p", [n for n in noms if n.endswith("_mobile.mp4")])):
|
||
pas = max(1, -(-len(liste) // maxi))
|
||
lues = liste[::pas]
|
||
compte = Counter(r for r in (reglages_fichier(os.path.join(dossier, n)) for n in lues) if r)
|
||
res[version] = {"total": len(liste), "lues": len(lues), "reglages": compte}
|
||
return res
|
||
|
||
|
||
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)
|
||
|
||
|
||
REFAIRE = ("manquantes", "differentes", "toutes")
|
||
|
||
|
||
def cible(palier, p):
|
||
"""(codec, crf) que les réglages `p` donnent pour une version ; crf None
|
||
pour l'encodeur matériel, qui n'inscrit pas ses réglages."""
|
||
codec = "HEVC" if palier == "hd" else "H.264"
|
||
if p["encodeur"] == "vt":
|
||
return codec, None
|
||
return codec, float(p["crf_hevc"] if palier == "hd" else p["crf_h264"])
|
||
|
||
|
||
def compresser(voyage, cfg, analyse, force=False, progression=None, paliers=("hd", "sd"),
|
||
refaire="manquantes"):
|
||
"""Compresse les rushs du voyage. `progression(nom, etat, pourcentage, rang)`
|
||
est appelée à chaque changement (pour l'interface) ; rang = (i, total).
|
||
|
||
refaire : « manquantes » (défaut) n'encode que les versions absentes ou
|
||
illisibles ; « differentes » réencode aussi celles dont les réglages lus
|
||
dans le fichier diffèrent des réglages actuels ; « toutes » réencode tout
|
||
(= force). `paliers` limite la reprise au 1080p ("hd",) ou au 720p ("sd",) ;
|
||
une version absente est toujours produite.
|
||
Renvoie True si tout a réussi."""
|
||
paliers = tuple(x for x in ("hd", "sd") if x in paliers) or ("hd", "sd")
|
||
if force:
|
||
refaire = "toutes"
|
||
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']})")
|
||
dans_docker = os.path.exists("/.dockerenv")
|
||
dire(f"🧵 Parallélisme : {jobs} rush(s) à la fois × 2 paliers ({threads} threads en 1080p, "
|
||
f"{threads_sd} en 720p ; {coeurs} cœur(s) "
|
||
+ ("attribués au conteneur Docker)" if dans_docker else "performance)"))
|
||
if dans_docker and coeurs < (os.cpu_count() or coeurs):
|
||
dire(" (le conteneur est limité : voir Docker Desktop → Réglages → Resources → CPU)")
|
||
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, detail=None, rang=None):
|
||
if progression:
|
||
progression(nom, etat, detail, rang)
|
||
|
||
total = len(videos)
|
||
en_direct = jobs == 1 and sys.stdout.isatty()
|
||
|
||
def un_rush(arg):
|
||
i, src = arg
|
||
nom = os.path.basename(src)
|
||
rang = f"[{i:3d}/{total}]"
|
||
hd, sd = sorties(voyage, src)
|
||
if ARRET.is_set():
|
||
return False
|
||
finals = {"hd": hd, "sd": sd}
|
||
manquants = [x for x in ("hd", "sd") if not lisible(finals[x])]
|
||
if refaire == "toutes":
|
||
a_faire = list(paliers)
|
||
elif refaire == "differentes":
|
||
a_faire = [x for x in paliers if x in manquants or reglages_fichier(finals[x]) != cible(x, p)]
|
||
else:
|
||
a_faire = []
|
||
a_faire = [x for x in ("hd", "sd") if x in a_faire or x in manquants]
|
||
if not a_faire:
|
||
with verrou:
|
||
bilan["sautes"] += 1
|
||
dire(f"⏭️ {rang} {nom} — déjà faite" + (" avec ces réglages" if refaire == "differentes" else ""))
|
||
signaler(nom, "deja_faite", rang=(i, total))
|
||
return True
|
||
h, duree = _hauteur(src), _duree(src)
|
||
debut = time.time()
|
||
dire(f"🎬 {rang} {nom} ({h}p, {int(duree) // 60} min {int(duree) % 60:02d} s) — encodage…")
|
||
signaler(nom, "en_cours", 0, rang=(i, total))
|
||
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))
|
||
tmps = {"hd": tmp_hd, "sd": tmp_sd}
|
||
avancement = {x: 0.0 for x in a_faire}
|
||
procs, lecteurs = [], []
|
||
for palier in a_faire:
|
||
tmp = tmps[palier]
|
||
pr = subprocess.Popen(_commande(src, tmp, palier, p, threads, threads_sd, h),
|
||
stdout=subprocess.PIPE, text=True)
|
||
th = threading.Thread(target=_suivre, args=(pr, palier, duree, avancement), daemon=True)
|
||
th.start()
|
||
procs.append(pr)
|
||
lecteurs.append(th)
|
||
dernier = -1
|
||
while any(pr.poll() is None for pr in procs):
|
||
time.sleep(0.5)
|
||
if ARRET.is_set():
|
||
for pr in procs:
|
||
pr.terminate()
|
||
moyenne = int(sum(avancement.values()) / len(avancement))
|
||
if en_direct:
|
||
print("\r " + " · ".join(f"{'1080p' if x == 'hd' else '720p'} {avancement[x]:3.0f} %"
|
||
for x in a_faire) + " ", end="", flush=True)
|
||
elif 0 < moyenne < 100 and moyenne // 25 > max(dernier, 0) // 25:
|
||
dire(f" {nom} : {moyenne} %")
|
||
if moyenne != dernier:
|
||
signaler(nom, "en_cours", moyenne)
|
||
dernier = moyenne
|
||
if en_direct:
|
||
print()
|
||
for th in lecteurs:
|
||
th.join(timeout=5)
|
||
codes = [pr.returncode for pr in procs]
|
||
if all(c == 0 for c in codes) and all(lisible(tmps[x]) for x in a_faire):
|
||
avant = {x: os.path.getsize(finals[x]) for x in a_faire if os.path.exists(finals[x])}
|
||
for x in a_faire:
|
||
os.replace(tmps[x], finals[x])
|
||
with verrou:
|
||
bilan["faits"] += 1
|
||
bilan["octets_src"] += os.path.getsize(src)
|
||
bilan["octets_web"] += sum(os.path.getsize(finals[x]) for x in a_faire)
|
||
details = " + ".join(
|
||
f"{'1080p' if x == 'hd' else '720p'} {taille_lisible(os.path.getsize(finals[x]))}"
|
||
+ (f" (avant {taille_lisible(avant[x])})" if x in avant else "") for x in a_faire)
|
||
ecoule = max(time.time() - debut, 0.001)
|
||
dire(f" ✅ {nom:<24} {taille_lisible(os.path.getsize(src))} → {details}"
|
||
f" · {int(ecoule) // 60} min {int(ecoule) % 60:02d} s"
|
||
+ (f" (×{duree / ecoule:.2f} temps réel)" if duree else ""))
|
||
signaler(nom, "faite")
|
||
return True
|
||
for t in (tmp_hd, tmp_sd):
|
||
if os.path.exists(t):
|
||
os.remove(t)
|
||
if ARRET.is_set():
|
||
dire(f" ⏹ {nom} : arrêtée")
|
||
signaler(nom, "arretee")
|
||
return False
|
||
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, enumerate(videos, 1)))
|
||
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)")
|
||
if ARRET.is_set():
|
||
dire("⏹ Compression arrêtée (les vidéos finies sont gardées).")
|
||
return all(resultats)
|