Serveur web local sans dépendance nouvelle (http.server, Jinja2, htmx et Leaflet fournis). Écrans : Mes voyages, nouveau voyage, un voyage (journées avec carte, rattachement des traces sans heures à un jour avec suggestion d'après les photos, sources avec choix de dossier, infos et récit, génération avec avancement et arrêt), réglages du site et vérification des outils. Chaîne : jours imposés ([dates_traces] dans voyage.toml), arrêt propre de la compression, suivi des étapes, état structuré (site.tableau_etat), titre du voyage repris de voyage.toml sur l'accueil. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
251 lines
11 KiB
Python
251 lines
11 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 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")
|
||
|
||
|
||
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)
|
||
|
||
|
||
def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||
"""Compresse les rushs du voyage. `progression(nom, etat, pourcentage, rang)`
|
||
est appelée à chaque changement (pour l'interface) ; rang = (i, total).
|
||
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, 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
|
||
if not force and lisible(hd) and lisible(sd):
|
||
with verrou:
|
||
bilan["sautes"] += 1
|
||
dire(f"⏭️ {rang} {nom} — déjà faite")
|
||
signaler(nom, "deja_faite", rang=(i, total))
|
||
return True
|
||
h, duree = _hauteur(src), _duree(src)
|
||
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}
|
||
procs, lecteurs = [], []
|
||
for palier, tmp in (("hd", tmp_hd), ("sd", tmp_sd)):
|
||
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((avancement["hd"] + avancement["sd"]) / 2)
|
||
if en_direct:
|
||
print(f"\r 1080p {avancement['hd']:3.0f} % · 720p {avancement['sd']:3.0f} % ",
|
||
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 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)
|
||
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)
|