Phase 1 : moteur Python et commande rtg
- Paquet roadtripsgenerator (pyproject.toml), commande rtg : generer, etat, analyser, compresser, accueil, migrer - Réglages site.toml / voyage.toml (anciens mymaps.txt et moto.txt encore lus) ; qualité vidéo en trois crans - Sources laissées en place : traces découpées par journée d'après leurs horodatages (coupure à 4 h) ou la date du nom de fichier, photos copiées, HEIC converties en JPEG, position déduite de la trace pour les photos sans GPS - extraction_infos.sh et compress.sh portés en Python (plus de bash) ; sorties vidéo écrites sous un nom temporaire ; positions des rushs inchangés reprises du dernier export ; export vidéo conservé quand le disque des rushs est débranché - Pages, accueil et carte globale repris dans le paquet : sortie identique aux scripts (123 fichiers comparés sur 7 voyages) - Tests pytest sur un voyage d'exemple fabriqué à la volée 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
97b936fd3e
commit
f0e1c2dfce
23 files changed
+3083
-1
No files matched your search
+185
@@ -0,0 +1,185 @@
|
||||
"""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 threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from rtg.config import parametres_video
|
||||
from rtg.outils import 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, stats):
|
||||
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}",
|
||||
"-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"
|
||||
cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error"]
|
||||
if stats:
|
||||
cmd.append("-stats")
|
||||
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 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):
|
||||
hd, sd = sorties(voyage, src)
|
||||
return lisible(hd) and lisible(sd)
|
||||
|
||||
|
||||
def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||||
"""Compresse les rushs du voyage. `progression(nom, etat)` est appelée à
|
||||
chaque changement (pour l'interface). 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):
|
||||
if progression:
|
||||
progression(nom, etat)
|
||||
|
||||
def un_rush(src):
|
||||
nom = os.path.basename(src)
|
||||
hd, sd = sorties(voyage, src)
|
||||
if not force and lisible(hd) and lisible(sd):
|
||||
with verrou:
|
||||
bilan["sautes"] += 1
|
||||
signaler(nom, "deja_faite")
|
||||
return True
|
||||
h = _hauteur(src)
|
||||
dire(f"🎬 {nom} ({h}p) — encodage des 2 paliers en parallèle…")
|
||||
signaler(nom, "en_cours")
|
||||
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))
|
||||
stats = jobs == 1
|
||||
procs = [subprocess.Popen(_commande(src, tmp_hd, "hd", p, threads, threads_sd, h, stats)),
|
||||
subprocess.Popen(_commande(src, tmp_sd, "sd", p, threads, threads_sd, h, stats))]
|
||||
codes = [pr.wait() 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)
|
||||
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, videos))
|
||||
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)")
|
||||
return all(resultats)
|
||||
Reference in new issue
Block a user