Refonte : templates, balades, photos web, aperçus, carte globale

- Mise en page sortie du Python : Scripts/templates/ (Jinja2 + CSS/JS
  communs copiés dans assets/ de chaque voyage)
- Voyages rangés dans un sous-dossier (Balades/…) : rubriques sur
  l'accueil, 2 plus récents par rubrique, pages voyages.html et
  balades.html
- prepare_web.py : photos allégées (photos_web/) et images d'aperçu
  des vidéos (apercus/)
- Voyage d'une seule journée : la page du voyage est la carte du jour
- Carte globale de l'accueil (OpenStreetMap) : voyages du site + cartes
  Google My Maps récupérées (mymaps.txt, _mymaps/), moto par voyage
  (moto.txt)
- KML par voyage pour My Maps
- compress.sh : réencode une sortie illisible (encodage interrompu) ;
  repli automatique sur l'autre qualité dans la page
- Corrections : CSV vide (voyage sans photo), racine de genere_site.sh,
  fichiers ._ de macOS
- README réécrit, commentaires allégés

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:08:01 +00:00
1 parent 5a04f159ca
commit 97b936fd3e
17 files changed
+2042 -829

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Prépare les médias d'un voyage pour le web, sans toucher aux originaux.
photos_web/<nom> photos réduites (WEB_PX px de grand côté), 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
Usage : ./prepare_web.py [dossier_voyage]
Variables :
WEB_PX=1600 grand côté des photos web (un popup fait au plus 800 px
de large, ×2 pour les écrans Retina)
WEB_QUALITE=80 qualité JPEG des photos web
FORCE=1 refait tout, même ce qui est à jour
Un fichier déjà produit et plus récent que sa source n'est pas refait.
Photos : Pillow (pip install pillow), sinon `sips` (fourni avec macOS).
Aperçus : ffmpeg.
"""
import os
import shutil
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
try:
from PIL import Image, ImageOps
except ImportError:
Image = None
WEB_PX = int(os.environ.get("WEB_PX", 1600))
QUALITE = int(os.environ.get("WEB_QUALITE", 80))
FORCE = os.environ.get("FORCE") == "1"
TRAVAILLEURS = os.cpu_count() or 4
def a_jour(sortie, source):
return (not 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((WEB_PX, WEB_PX), Image.LANCZOS)
im.save(dst, "JPEG", quality=QUALITE, optimize=True, progressive=True)
def reduire_sips(src, dst):
subprocess.run(["sips", "-Z", str(WEB_PX), "-s", "format", "jpeg",
"-s", "formatOptions", str(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 main():
voyage = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else ".")
print(f"--- PRÉPARATION WEB : {os.path.basename(voyage)} ---")
ok = preparer_photos(voyage)
ok = preparer_apercus(voyage) and ok
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()