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
This commit is contained in:
FabriceandClaude Opus 5.5 committed 2026-10-19 05:32:05 +00:00
1 parent b54ae69b19
commit ada3d76f50
38 files changed
+1067 -264

No files matched your search

+142 -15
View File
@@ -9,6 +9,7 @@ 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
@@ -117,6 +118,88 @@ def sorties(voyage, src):
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)
@@ -125,10 +208,32 @@ def deja_faite(voyage, src, rapide=False):
return lisible(hd) and lisible(sd)
def compresser(voyage, cfg, analyse, force=False, progression=None):
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)
@@ -142,8 +247,12 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
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) performance)")
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()
@@ -163,20 +272,32 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
hd, sd = sorties(voyage, src)
if ARRET.is_set():
return False
if not force and lisible(hd) and lisible(sd):
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")
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))
avancement = {"hd": 0.0, "sd": 0.0}
tmps = {"hd": tmp_hd, "sd": tmp_sd}
avancement = {x: 0.0 for x in a_faire}
procs, lecteurs = [], []
for palier, tmp in (("hd", tmp_hd), ("sd", tmp_sd)):
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)
@@ -189,10 +310,10 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
if ARRET.is_set():
for pr in procs:
pr.terminate()
moyenne = int((avancement["hd"] + avancement["sd"]) / 2)
moyenne = int(sum(avancement.values()) / len(avancement))
if en_direct:
print(f"\r 1080p {avancement['hd']:3.0f} % · 720p {avancement['sd']:3.0f} % ",
end="", flush=True)
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:
@@ -203,15 +324,21 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
for th in lecteurs:
th.join(timeout=5)
codes = [pr.returncode 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)
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"] += 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))}")
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):