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:
1 parent
b54ae69b19
commit
ada3d76f50
38 files changed
+1067
-264
No files matched your search
+142
-15
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user