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:
FabriceandClaude Opus 5.5 committed 2026-10-09 11:31:31 +00:00
1 parent 97b936fd3e
commit f0e1c2dfce
23 files changed
+3083 -1

No files matched your search

+185
View File
@@ -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)