Files
FabriceandClaude Opus 5.5 ada3d76f50 Administration native sur le Mac, réencodage sélectif et aide intégrée
- 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
2026-10-19 05:32:05 +00:00

378 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)