#!/usr/bin/env python3 """Prépare les médias d'un voyage pour le web, sans toucher aux originaux. photos_web/ photos réduites (WEB_PX px de grand côté), affichées dans les diaporamas ; le clic ouvre toujours l'original apercus/.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()