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
+174
@@ -0,0 +1,174 @@
|
||||
"""Médias pour le web, à côté des originaux (jamais modifiés) :
|
||||
|
||||
photos_web/<nom> photos réduites (grand côté réglable), affichées dans
|
||||
les diaporamas ; le clic ouvre toujours l'original
|
||||
apercus/<base>.jpg image d'aperçu de chaque vidéo, affichée avant lecture
|
||||
|
||||
Un fichier déjà produit et plus récent que sa source n'est pas refait.
|
||||
Photos : Pillow, sinon `sips` (fourni avec macOS). Aperçus : ffmpeg.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
except ImportError:
|
||||
Image = None
|
||||
|
||||
TRAVAILLEURS = os.cpu_count() or 4
|
||||
REGLAGES = {"px": 1600, "qualite": 80, "force": False}
|
||||
|
||||
|
||||
def a_jour(sortie, source):
|
||||
return (not REGLAGES["force"] and os.path.exists(sortie) and os.path.getsize(sortie) > 0
|
||||
and os.path.getmtime(sortie) >= os.path.getmtime(source))
|
||||
|
||||
|
||||
def lister(dossier, extensions):
|
||||
"""Fichiers du dossier ayant l'une des extensions, hors fichiers cachés et « ._ » de macOS."""
|
||||
if not os.path.isdir(dossier):
|
||||
return []
|
||||
return sorted(f for f in os.listdir(dossier)
|
||||
if not f.startswith('.') and f.lower().endswith(extensions))
|
||||
|
||||
|
||||
def nettoyer_temporaires(dossier):
|
||||
"""Restes d'une exécution interrompue (Ctrl-C) : fichiers .tmp-* cachés."""
|
||||
for f in os.listdir(dossier):
|
||||
if f.startswith(".tmp-"):
|
||||
os.remove(os.path.join(dossier, f))
|
||||
|
||||
|
||||
# --- Photos ------------------------------------------------------------------
|
||||
def reduire_pillow(src, dst):
|
||||
with Image.open(src) as im:
|
||||
im = ImageOps.exif_transpose(im) # applique la rotation EXIF
|
||||
if im.mode not in ("RGB", "L"):
|
||||
im = im.convert("RGB")
|
||||
im.thumbnail((REGLAGES["px"], REGLAGES["px"]), Image.LANCZOS)
|
||||
im.save(dst, "JPEG", quality=REGLAGES["qualite"], optimize=True, progressive=True)
|
||||
|
||||
|
||||
def reduire_sips(src, dst):
|
||||
subprocess.run(["sips", "-Z", str(REGLAGES["px"]), "-s", "format", "jpeg",
|
||||
"-s", "formatOptions", str(REGLAGES["qualite"]), src, "--out", dst],
|
||||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def preparer_photos(voyage):
|
||||
src_dir = os.path.join(voyage, "photos")
|
||||
dst_dir = os.path.join(voyage, "photos_web")
|
||||
photos = lister(src_dir, (".jpg", ".jpeg"))
|
||||
if not photos:
|
||||
print("📸 Photos web : aucune photo.")
|
||||
return True
|
||||
|
||||
if Image is not None:
|
||||
reduire = reduire_pillow
|
||||
elif shutil.which("sips"):
|
||||
reduire = reduire_sips
|
||||
else:
|
||||
print("⚠️ Photos web : ni Pillow (pip install pillow) ni sips disponible,"
|
||||
" les pages afficheront les originaux.")
|
||||
return False
|
||||
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
nettoyer_temporaires(dst_dir)
|
||||
a_faire = [n for n in photos if not a_jour(os.path.join(dst_dir, n), os.path.join(src_dir, n))]
|
||||
|
||||
def traiter(nom):
|
||||
src, dst = os.path.join(src_dir, nom), os.path.join(dst_dir, nom)
|
||||
# Écrit sous un nom caché puis renommé : jamais de fichier à moitié écrit
|
||||
# sous le vrai nom. L'extension est gardée, sips s'en sert.
|
||||
tmp = os.path.join(dst_dir, ".tmp-" + nom)
|
||||
try:
|
||||
reduire(src, tmp)
|
||||
os.replace(tmp, dst)
|
||||
return os.path.getsize(src), os.path.getsize(dst)
|
||||
except Exception as e:
|
||||
print(f" ❌ {nom} : {e}")
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(TRAVAILLEURS) as pool:
|
||||
resultats = [r for r in pool.map(traiter, a_faire) if r]
|
||||
|
||||
msg = f"📸 Photos web : {len(resultats)} réduite(s), {len(photos) - len(a_faire)} déjà à jour"
|
||||
if resultats:
|
||||
avant = sum(r[0] for r in resultats) / 1048576
|
||||
apres = sum(r[1] for r in resultats) / 1048576
|
||||
msg += f" ({avant:.0f} Mo → {apres:.0f} Mo)"
|
||||
print(msg)
|
||||
return len(resultats) == len(a_faire)
|
||||
|
||||
|
||||
# --- Aperçus vidéo -----------------------------------------------------------
|
||||
def extraire_apercu(sources, dst):
|
||||
"""Une image à 3 s du début (après les secousses du démarrage), à 0 s si la
|
||||
vidéo est plus courte. Sources essayées dans l'ordre : le 720p se décode
|
||||
bien plus vite, le 1080p sert de secours s'il est abîmé.
|
||||
Renvoie la source utilisée, ou None."""
|
||||
tmp = os.path.join(os.path.dirname(dst), ".tmp-" + os.path.basename(dst))
|
||||
for src in sources:
|
||||
for instant in ("3", "0"):
|
||||
subprocess.run(["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-ss", instant, "-i", src, "-frames:v", "1",
|
||||
"-vf", "scale=-2:540", "-q:v", "5", "-f", "mjpeg", tmp],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if os.path.exists(tmp) and os.path.getsize(tmp) > 0:
|
||||
os.replace(tmp, dst)
|
||||
return src
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
return None
|
||||
|
||||
|
||||
def preparer_apercus(voyage):
|
||||
src_dir = os.path.join(voyage, "videos")
|
||||
dst_dir = os.path.join(voyage, "apercus")
|
||||
fichiers = lister(src_dir, (".mp4", ".mov"))
|
||||
if not fichiers:
|
||||
print("🎞️ Aperçus : aucune vidéo.")
|
||||
return True
|
||||
if not shutil.which("ffmpeg"):
|
||||
print("⚠️ Aperçus : ffmpeg introuvable, vidéos sans image d'aperçu.")
|
||||
return False
|
||||
|
||||
# Une vidéo = un nom de base, avec jusqu'à deux fichiers ; le 720p d'abord.
|
||||
sources = {}
|
||||
for f in fichiers:
|
||||
base = os.path.splitext(f)[0]
|
||||
mobile = base.endswith("_mobile")
|
||||
if mobile:
|
||||
base = base[:-len("_mobile")]
|
||||
liste = sources.setdefault(base, [])
|
||||
liste.insert(0 if mobile else len(liste), os.path.join(src_dir, f))
|
||||
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
nettoyer_temporaires(dst_dir)
|
||||
a_faire = [(srcs, os.path.join(dst_dir, b + ".jpg")) for b, srcs in sorted(sources.items())
|
||||
if not a_jour(os.path.join(dst_dir, b + ".jpg"), srcs[0])]
|
||||
|
||||
with ThreadPoolExecutor(TRAVAILLEURS) as pool:
|
||||
utilisees = list(pool.map(lambda t: extraire_apercu(*t), a_faire))
|
||||
|
||||
for (srcs, _), src in zip(a_faire, utilisees):
|
||||
if src is None:
|
||||
print(f" ❌ aperçu impossible, vidéo illisible : {os.path.basename(srcs[0])}")
|
||||
elif src != srcs[0]:
|
||||
print(f" ⚠️ {os.path.basename(srcs[0])} illisible (encodage interrompu ?),"
|
||||
f" aperçu tiré de {os.path.basename(src)}. Relance compress.sh pour le refaire.")
|
||||
|
||||
ok = [u for u in utilisees if u]
|
||||
print(f"🎞️ Aperçus : {len(ok)} créé(s), {len(sources) - len(a_faire)} déjà à jour")
|
||||
return len(ok) == len(a_faire)
|
||||
|
||||
|
||||
def preparer(voyage, cfg, force=False):
|
||||
"""Photos web et aperçus d'un voyage ; renvoie True si tout a réussi."""
|
||||
REGLAGES.update(px=int(cfg["photos"]["grand_cote"]), qualite=int(cfg["photos"]["qualite"]), force=force)
|
||||
ok = preparer_photos(voyage)
|
||||
return preparer_apercus(voyage) and ok
|
||||
Reference in new issue
Block a user