From f0e1c2dfce4f4eec4bca76b0a3d1964d66ec26fe Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 9 Oct 2026 11:31:31 +0000 Subject: [PATCH] Phase 1 : moteur Python et commande rtg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim --- README.md | 37 +- pyproject.toml | 36 ++ rtg/__init__.py | 3 + rtg/__main__.py | 3 + rtg/accueil.py | 164 +++++++++ rtg/carte_globale.py | 379 ++++++++++++++++++++ rtg/cli.py | 71 ++++ rtg/config.py | 147 ++++++++ rtg/extraction.py | 183 ++++++++++ rtg/outils.py | 86 +++++ rtg/pages.py | 504 +++++++++++++++++++++++++++ rtg/photos.py | 174 +++++++++ rtg/site.py | 175 ++++++++++ rtg/sources.py | 221 ++++++++++++ rtg/templates/accueil.html | 37 ++ rtg/templates/base_accueil.html | 90 +++++ rtg/templates/carte.css | 131 +++++++ rtg/templates/carte.js | 183 ++++++++++ rtg/templates/fragments.html | 95 +++++ rtg/templates/fragments_globale.html | 41 +++ rtg/templates/liste.html | 16 + rtg/video.py | 185 ++++++++++ tests/test_chaine.py | 123 +++++++ 23 files changed, 3083 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml create mode 100644 rtg/__init__.py create mode 100644 rtg/__main__.py create mode 100644 rtg/accueil.py create mode 100644 rtg/carte_globale.py create mode 100644 rtg/cli.py create mode 100644 rtg/config.py create mode 100644 rtg/extraction.py create mode 100644 rtg/outils.py create mode 100644 rtg/pages.py create mode 100644 rtg/photos.py create mode 100644 rtg/site.py create mode 100644 rtg/sources.py create mode 100644 rtg/templates/accueil.html create mode 100644 rtg/templates/base_accueil.html create mode 100644 rtg/templates/carte.css create mode 100644 rtg/templates/carte.js create mode 100644 rtg/templates/fragments.html create mode 100644 rtg/templates/fragments_globale.html create mode 100644 rtg/templates/liste.html create mode 100644 rtg/video.py create mode 100644 tests/test_chaine.py diff --git a/README.md b/README.md index 28b584e..c8bbcd9 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,42 @@ Dans le récit `voyage.md`, deux raccourcis évitent les liens écrits en dur (q [La descente](media:videos/GH011272.mp4) ``` -## Mode d'emploi +## Installation et utilisation : la commande `rtg` + +RTG s'installe comme un logiciel, avec une seule commande `rtg` utilisable depuis n'importe quel dossier : + +``` +brew install exiftool ffmpeg # ou l'équivalent sous Linux / Windows +pipx install ./RoadTripsGenerator # depuis le dossier du dépôt +``` + +Depuis le dossier du site (`Road-Trips`) : + +``` +rtg migrer # une fois : écrit site.toml et un voyage.toml par voyage +rtg etat # avancement de chaque voyage +rtg generer Balades/Vexin-10-2026 # un voyage, puis l'accueil +rtg generer --sans-compression # tous les voyages, sans encoder de vidéo +rtg analyser Balades/Vexin-10-2026 # ce que RTG trouve dans les sources +``` + +Les sources d'un voyage peuvent rester où elles sont, nommées n'importe comment : on les indique dans son `voyage.toml`. + +```toml +titre = "Balade dans le Vexin" +moto = "BMW R 1300 GS Adventure" + +[sources] +traces = ["/Volumes/GPS/2026/vexin.gpx"] +videos = ["/Volumes/LaCie/Perso/Gopro/Gopro-2026/Vexin"] +photos = ["~/Pictures/Export/Vexin"] +``` + +RTG range alors lui-même les traces par journée (`routes/AAAA-MM-JJ.gpx`, d'après leurs horodatages ou, à défaut, la date du nom de fichier) et copie les photos dans `photos/` (les HEIC de l'iPhone sont converties en JPEG). Une photo sans GPS reçoit la position de la trace à son heure, quand la trace est horodatée. Les réglages communs (qualité vidéo, taille des photos, fuseau horaire, cartes My Maps…) sont dans `site.toml`. + +Les scripts de `Scripts/` restent disponibles pendant la transition ; ils produisent le même site. + +## Mode d'emploi (anciens scripts) ### Tout le site, ou quelques voyages diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6974ee1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "roadtripsgenerator" +version = "0.9.0" +description = "Génère un site web statique de road-trips : cartes interactives, photos, vidéos, traces GPS et récits." +readme = "README.md" +license = { text = "LGPL-3.0-or-later" } +requires-python = ">=3.10" +authors = [{ name = "Fabrice" }] +dependencies = [ + "folium>=0.15", + "gpxpy>=1.5", + "jinja2>=3.0", + "markdown2>=2.4", + "numpy", + "pandas>=1.5", + "pillow>=9", + "pillow-heif>=0.13", + "tomli>=2; python_version < '3.11'", + "tzdata; platform_system == 'Windows'", +] + +[project.optional-dependencies] +dev = ["pytest>=7"] + +[project.scripts] +rtg = "rtg.cli:main" + +[tool.setuptools] +packages = ["rtg"] + +[tool.setuptools.package-data] +rtg = ["templates/*"] diff --git a/rtg/__init__.py b/rtg/__init__.py new file mode 100644 index 0000000..d62d0ef --- /dev/null +++ b/rtg/__init__.py @@ -0,0 +1,3 @@ +"""RoadTripsGenerator (RTG) : site web statique de road-trips.""" + +__version__ = "0.9.0" diff --git a/rtg/__main__.py b/rtg/__main__.py new file mode 100644 index 0000000..25331d6 --- /dev/null +++ b/rtg/__main__.py @@ -0,0 +1,3 @@ +from rtg.cli import main + +main() diff --git a/rtg/accueil.py b/rtg/accueil.py new file mode 100644 index 0000000..3d7a4e6 --- /dev/null +++ b/rtg/accueil.py @@ -0,0 +1,164 @@ +"""Page d'accueil du site et listes complètes par rubrique. + + index.html une rubrique par section, avec les ACCUEIL_NB voyages les + plus récents (2 par défaut) et un lien « Tout voir » + voyages.html tous les voyages rangés à la racine du site + balades.html tous ceux du dossier Balades/ (une page par regroupement) + carte_globale.html les voyages (pas les balades) et les cartes My Maps + sur une même carte, affichée en grand sous les rubriques + de l'accueil (voir genere_carte_globale.py et mymaps.txt) + +Appelé par `rtg generer` / `rtg accueil`. Réglages (site.toml) : titre, +accueil_nb_recents, carte_globale.mymaps. + +Un voyage est un dossier qui contient mini.html (produit par genere_carte.py). +Les voyages rangés à la racine forment la rubrique « Voyages » ; ceux rangés +un niveau plus bas, dans un dossier de regroupement (Balades/Aisne-06-2026), +forment une rubrique du nom de ce dossier. + +La moto d'un voyage (voyage.toml, ou ancien moto.txt) est affichée dans la +légende de la carte globale. + +Les voyages sont triés du plus récent au plus ancien d'après les dates des +traces GPX (routes/AAAA-MM-JJ.gpx), sinon d'après l'année trouvée dans le nom. +""" +import os +import unicodedata +import re +from datetime import date +from urllib.parse import quote + +from jinja2 import Environment, FileSystemLoader + +from rtg.carte_globale import SORTIE as CARTE_GLOBALE, generer_carte_globale +from rtg.config import reglages_site, reglages_voyage + +DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") +EXCLURE = {'html', 'Scripts', 'RoadTripsGenerator', '__pycache__', 'node_modules', + 'assets', 'videos', 'photos', 'photos_web', 'apercus', 'rushs', 'routes'} +TITRE_SECTION_RACINE = "Voyages" +MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", + "août", "septembre", "octobre", "novembre", "décembre"] + + +def sous_dossiers(dossier): + for d in sorted(os.listdir(dossier)): + chemin = os.path.join(dossier, d) + if d in EXCLURE or d.startswith(('.', '_')) or not os.path.isdir(chemin): + continue + yield d, chemin + + +def est_voyage(chemin): + return os.path.exists(os.path.join(chemin, 'mini.html')) + + +def dates_du_voyage(chemin): + routes = os.path.join(chemin, 'routes') + if not os.path.isdir(routes): + return [] + dates = set() + for f in os.listdir(routes): + m = re.search(r'(\d{4})-(\d{2})-(\d{2})', f) + if m and not f.startswith('.'): + try: + dates.add(date(*map(int, m.groups()))) + except ValueError: + pass + return sorted(dates) + + +def periode(dates): + """« juin 2026 · 1 jour », « juillet – août 2025 · 15 jours »…""" + if not dates: + return "" + d1, d2 = dates[0], dates[-1] + if (d1.year, d1.month) == (d2.year, d2.month): + texte = f"{MOIS[d1.month - 1]} {d1.year}" + elif d1.year == d2.year: + texte = f"{MOIS[d1.month - 1]} – {MOIS[d2.month - 1]} {d2.year}" + else: + texte = f"{MOIS[d1.month - 1]} {d1.year} – {MOIS[d2.month - 1]} {d2.year}" + n = len(dates) + return f"{texte} · {n} jour{'s' if n > 1 else ''}" + + +def fiche(racine, chemin): + nom = os.path.basename(chemin) + dates = dates_du_voyage(chemin) + if dates: + quand = dates[0] + else: + m = re.search(r'(19|20)\d{2}', nom) + quand = date(int(m.group(0)), 1, 1) if m else date.min + return { + 'dossier': chemin, + 'titre': nom.replace('-', ' ').replace('_', ' '), + 'chemin': quote(os.path.relpath(chemin, racine).replace(os.sep, '/')), + 'periode': periode(dates), + 'moto': reglages_voyage(chemin).get('moto', ''), + 'quand': quand, + } + + +def trier(voyages): + # Plus récent d'abord ; à date égale, ordre alphabétique. + voyages.sort(key=lambda v: v['titre'].lower()) + voyages.sort(key=lambda v: v['quand'], reverse=True) + return voyages + + +def nom_de_page(titre): + """« Balades » → « balades.html » (minuscules, sans accents ni espaces).""" + t = unicodedata.normalize('NFKD', titre).encode('ascii', 'ignore').decode().lower() + return re.sub(r'[^a-z0-9]+', '-', t).strip('-') + '.html' + + +def rubrique(titre, voyages): + n = len(voyages) + unite = titre.lower() if n > 1 else titre.lower().rstrip('s') + return {'titre': titre, 'voyages': trier(voyages), 'page': nom_de_page(titre), 'unite': unite} + + +def collecter_rubriques(racine): + principaux, rubriques = [], [] + for nom, chemin in sous_dossiers(racine): + if est_voyage(chemin): + principaux.append(fiche(racine, chemin)) + else: + voyages = [fiche(racine, c) for _, c in sous_dossiers(chemin) if est_voyage(c)] + if voyages: + rubriques.append(rubrique(nom.replace('-', ' ').replace('_', ' '), voyages)) + if principaux: + rubriques.insert(0, rubrique(TITRE_SECTION_RACINE, principaux)) + return rubriques + + +def generer(racine=".", cfg=None, mettre_a_jour_mymaps=False): + racine = os.path.abspath(racine) + cfg = cfg or reglages_site(racine) + TITRE_SITE, NB_RECENTS = cfg["titre"], int(cfg["accueil_nb_recents"]) + rubriques = collecter_rubriques(racine) + total = sum(len(r['voyages']) for r in rubriques) + + env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True, + trim_blocks=True, lstrip_blocks=True) + + def ecrire(nom, contenu): + with open(os.path.join(racine, nom), "w", encoding="utf-8") as f: + f.write(contenu) + + ecrire("index.html", env.get_template("accueil.html").render( + titre_site=TITRE_SITE, rubriques=rubriques, nb_recents=NB_RECENTS, + carte_globale=CARTE_GLOBALE if any(r['titre'] == TITRE_SECTION_RACINE for r in rubriques) else None, + sous_titre=f"{total} voyage{'s' if total > 1 else ''}" if total else "")) + for r in rubriques: + ecrire(r['page'], env.get_template("liste.html").render(titre_site=TITRE_SITE, r=r)) + + # Carte globale : la rubrique Voyages seulement, sans les balades. + voyages = [r for r in rubriques if r['titre'] == TITRE_SECTION_RACINE] + if voyages: + generer_carte_globale(racine, voyages, cfg["carte_globale"]["mymaps"], mettre_a_jour_mymaps) + + detail = ", ".join(f"{r['titre']} : {len(r['voyages'])} → {r['page']}" for r in rubriques) + print(f"🌍 Accueil : {total} voyage(s) ({detail})") diff --git a/rtg/carte_globale.py b/rtg/carte_globale.py new file mode 100644 index 0000000..2a5d796 --- /dev/null +++ b/rtg/carte_globale.py @@ -0,0 +1,379 @@ +"""Carte globale du site (carte_globale.html), affichée en grand sur l'accueil. + +Fond OpenStreetMap (ou satellite Esri), sans Google. Elle réunit, en calques +que l'on peut afficher ou masquer : + - le contenu récupéré de cartes Google My Maps (anciennes balades et + voyages), avec leurs couleurs d'origine, un calque par calque My Maps ; + - les voyages du site (rubrique Voyages, pas les balades du site), chaque + trace menant à la page du voyage. + +Récupération des cartes My Maps : + - réglage carte_globale.mymaps de site.toml (ou ancien mymaps.txt) : liens de + cartes My Maps partagées « Tous les utilisateurs disposant du lien ». Le KML est téléchargé UNE fois dans _mymaps/.kml ; ensuite + la carte ne dépend plus de Google. MYMAPS_MAJ=1 pour le récupérer à nouveau + après avoir modifié la carte Google. + - ou tout export .kml / .kmz déposé à la main dans _mymaps/. + +Appelé par rtg.accueil. +""" +import json +import math +import os +import re +import shutil +import subprocess +import urllib.request +import zipfile +import xml.etree.ElementTree as ET +from urllib.parse import parse_qs, urlparse + +import folium +import gpxpy +from folium import plugins +from jinja2 import Environment, FileSystemLoader +from markupsafe import Markup, escape + +DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") +TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' +DOSSIER_MYMAPS = "_mymaps" +SORTIE = "carte_globale.html" + +# Une couleur par voyage, du plus récent au plus ancien. +COULEURS_VOYAGES = ['#E74C3C', '#2980B9', '#27AE60', '#8E44AD', '#F39C12', + '#16A085', '#D35400', '#2C3E50', '#C0392B', '#7F8C8D'] +COULEUR_MYMAPS = '#8E44AD' # si le KML ne précise pas de couleur +# Écart toléré (m) en simplifiant les traces : à l'échelle de la France, +# 30 m sont invisibles et allègent beaucoup la page. +TOLERANCE_M = 30 + +env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True, + trim_blocks=True, lstrip_blocks=True) + + +def liens_et_lignes(texte): + """Texte brut → HTML sûr : échappé, adresses http(s) cliquables, retours à la ligne.""" + h = str(escape(texte)) + h = re.sub(r'(https?://[^\s<]+)', r'\1', h) + return Markup(h.replace('\n', '
')) + + +env.filters['liens_et_lignes'] = liens_et_lignes +F = env.get_template("fragments_globale.html").module + + +# --- Traces des voyages du site --------------------------------------------- +def eclaircir(points, ecart_m): + """Ne garde que les points distants d'au moins ecart_m du précédent gardé + (calcul approché, rapide). Divise d'autant le travail de simplify(), lent + en Python sur les 100 000 points d'un grand voyage.""" + if len(points) < 3: + return points + garde = [points[0]] + seuil = (ecart_m / 111_320.0) ** 2 # degrés², à peu près + for p in points[1:-1]: + q = garde[-1] + dlat = p.latitude - q.latitude + dlon = (p.longitude - q.longitude) * math.cos(math.radians(p.latitude)) + if dlat * dlat + dlon * dlon >= seuil: + garde.append(p) + garde.append(points[-1]) + return garde + + +def longueur_km(lignes): + total = 0.0 + for l in lignes: + for (la1, lo1), (la2, lo2) in zip(l, l[1:]): + dlat = math.radians(la2 - la1) + dlon = math.radians(lo2 - lo1) * math.cos(math.radians((la1 + la2) / 2)) + total += 6371.0 * math.hypot(dlat, dlon) + return total + + +def trace_voyage(dossier): + """Lignes simplifiées [[lat, lon], …] d'un voyage et sa longueur en km. + + Source : assets/trace.json (journées séparées, déjà simplifiées par + genere_carte.py) ; à défaut .kml (une seule ligne : les journées + y sont reliées en ligne droite) ou _complet.gpx (lent). + """ + nom = os.path.basename(dossier) + json_trace = os.path.join(dossier, 'assets', 'trace.json') + if os.path.exists(json_trace): + with open(json_trace, encoding='utf-8') as f: + lignes = json.load(f) + km = None + kml = os.path.join(dossier, f"{nom}.kml") + if os.path.exists(kml): + with open(kml, encoding='utf-8') as f: + m = re.search(r'(\d+) km
', f.read()) + km = float(m.group(1)) if m else None + return lignes, km if km is not None else longueur_km(lignes) + kml = os.path.join(dossier, f"{nom}.kml") + gpx_complet = os.path.join(dossier, f"{nom}_complet.gpx") + brutes, km = [], None + if os.path.exists(kml): + with open(kml, encoding='utf-8') as f: + texte = f.read() + # Distance exacte calculée par genere_carte.py sur la trace complète + # (recalculée ici, elle compterait les sauts entre deux journées). + m = re.search(r'(\d+) km
', texte) + km = float(m.group(1)) if m else None + for bloc in re.findall(r'(.*?)', texte, flags=re.S): + pts = [] + for c in bloc.split(): + v = c.split(',') + pts.append(gpxpy.gpx.GPXTrackPoint(float(v[1]), float(v[0]))) + brutes.append(pts) + elif os.path.exists(gpx_complet): + with open(gpx_complet, encoding='utf-8') as f: + gpx = gpxpy.parse(f) + brutes = [seg.points for t in gpx.tracks for seg in t.segments] + km = gpx.length_2d() / 1000.0 + lignes = [] + for pts in brutes: + s = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2)) + s.simplify(max_distance=TOLERANCE_M) + if len(s.points) > 1: + lignes.append([[round(p.latitude, 5), round(p.longitude, 5)] for p in s.points]) + return lignes, km if km is not None else longueur_km(lignes) + + +# --- Cartes My Maps ---------------------------------------------------------- +def mid_de(lien): + mid = parse_qs(urlparse(lien).query).get('mid') + return mid[0] if mid else None + + +def telecharger_mymaps(racine, liens, mettre_a_jour=False): + """Récupère le KML des cartes My Maps vers _mymaps/.kml (une fois ; + mettre_a_jour=True ou MYMAPS_MAJ=1 pour forcer).""" + if not liens: + return + dossier = os.path.join(racine, DOSSIER_MYMAPS) + os.makedirs(dossier, exist_ok=True) + for lien in liens: + mid = mid_de(lien) + if not mid: + print(f" ⚠️ Pas un lien My Maps (mid=… manquant) : {lien}") + continue + dest = os.path.join(dossier, f"{mid}.kml") + if os.path.exists(dest) and not mettre_a_jour and os.environ.get("MYMAPS_MAJ") != "1": + continue + url = f"https://www.google.com/maps/d/kml?forcekml=1&mid={mid}" + contenu, erreur = telecharger(url) + if contenu is not None and b"', '', description, flags=re.I | re.S) + t = re.sub(r'', '\n', t, flags=re.I) + t = re.sub(r'<[^>]+>', '', t) + return t.strip() + + +def nettoyer_nom(nom): + """« Track-Pyrenees 2014 _ aller.kmz » → « Pyrenees 2014 – aller ».""" + n = re.sub(r'\.km[lz]$', '', nom.strip(), flags=re.I) + n = re.sub(r'^(Track|T)\s*-\s*', '', n, flags=re.I) + n = re.sub(r'\s+_\s+|\s*:\s+', ' – ', n) + return re.sub(r'\s{2,}', ' ', n).strip() + + +def resumer_description(texte): + """Les statistiques automatiques de My Maps (« Statistiques calculées… », + altitudes à 0) sont réduites à la distance ; une vraie description est gardée.""" + if texte.startswith("Statistiques calculées"): + m = re.search(r'Distance\s*:\s*([\d\s,.]+)\s*km', texte.replace('\xa0', ' ')) + return f"{m.group(1).strip()} km" if m else "" + return texte + + +def elements_mymaps(contenu): + """KML → (titre du document, {nom de calque: [éléments]}). + + Élément : {'type': 'ligne'|'point', 'nom', 'description', 'couleur', 'coords'}. + """ + ns = {'k': 'http://www.opengis.net/kml/2.2'} + racine = ET.fromstring(contenu) + doc = racine.find('k:Document', ns) + if doc is None: + return None, {} + + # Styles : id → couleur ; les StyleMap renvoient vers le style « normal ». + styles = {} + for st in doc.iter('{http://www.opengis.net/kml/2.2}Style'): + # (pas de « or » : un élément XML sans enfant est évalué comme faux) + c = st.find('k:LineStyle/k:color', ns) + if c is None: + c = st.find('k:IconStyle/k:color', ns) + if st.get('id') and c is not None and c.text: + styles['#' + st.get('id')] = couleur_kml(c.text) + for sm in doc.iter('{http://www.opengis.net/kml/2.2}StyleMap'): + for paire in sm.findall('k:Pair', ns): + if paire.findtext('k:key', '', ns) == 'normal': + styles['#' + (sm.get('id') or '')] = styles.get(paire.findtext('k:styleUrl', '', ns)) + + def coords(el): + pts = [] + for c in (el.findtext('k:coordinates', '', ns) or '').split(): + v = c.split(',') + if len(v) >= 2: + pts.append([round(float(v[1]), 5), round(float(v[0]), 5)]) + return pts + + calques = {} + + def visiter(noeud, calque): + for enfant in noeud: + tag = enfant.tag.split('}')[-1] + if tag == 'Folder': + visiter(enfant, enfant.findtext('k:name', calque, ns)) + elif tag == 'Placemark': + base = {'nom': nettoyer_nom(enfant.findtext('k:name', '', ns)), + 'description': resumer_description(texte_simple(enfant.findtext('k:description', '', ns))), + 'couleur': styles.get(enfant.findtext('k:styleUrl', '', ns)) or COULEUR_MYMAPS} + for ls in enfant.iter('{http://www.opengis.net/kml/2.2}LineString'): + pts = coords(ls) + if len(pts) > 1: + calques.setdefault(calque, []).append(dict(base, type='ligne', coords=pts)) + for pt in enfant.iter('{http://www.opengis.net/kml/2.2}Point'): + pts = coords(pt) + if pts: + calques.setdefault(calque, []).append(dict(base, type='point', coords=pts[0])) + + titre = doc.findtext('k:name', 'My Maps', ns) + visiter(doc, titre) + return titre, calques + + +def simplifier(coords): + pts = [gpxpy.gpx.GPXTrackPoint(la, lo) for la, lo in coords] + seg = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2)) + seg.simplify(max_distance=TOLERANCE_M) + return [[round(p.latitude, 5), round(p.longitude, 5)] for p in seg.points] + + +# --- Carte ------------------------------------------------------------------- +def generer_carte_globale(racine, rubriques, liens_mymaps=(), mettre_a_jour=False): + """rubriques : liste de {'titre', 'voyages': [{'titre', 'chemin', 'periode', 'dossier', 'moto'}]}.""" + racine = os.path.abspath(racine) + telecharger_mymaps(racine, list(liens_mymaps), mettre_a_jour) + + m = folium.Map(tiles=None, scrollWheelZoom=False) + folium.TileLayer('OpenStreetMap', name='Plan').add_to(m) + folium.TileLayer(TUILES_SATELLITE, attr='Esri', name='Satellite', show=False).add_to(m) + tous = [] + resume = [] + + # Voyages du site : un calque par voyage, coloré, que la légende permet + # d'afficher ou de masquer ; le plus récent en tête de légende. + voyages = [v for r in rubriques for v in r['voyages']] + calques = [] + for i, v in enumerate(voyages): + lignes, km = trace_voyage(v['dossier']) + if not lignes: + continue + couleur = COULEURS_VOYAGES[i % len(COULEURS_VOYAGES)] + libelle = f"{v['titre']} ({v['moto']})" if v.get('moto') else v['titre'] + groupe = folium.FeatureGroup(name=str(F.legende(libelle, couleur))) + popup = str(F.popup_voyage(libelle, v['periode'], km, f"{v['chemin']}/index.html")) + for l in lignes: + folium.PolyLine(l, color=couleur, weight=4, opacity=0.85, tooltip=libelle, + popup=folium.Popup(popup, max_width=260)).add_to(groupe) + tous.extend(l) + calques.append(groupe) + for groupe in calques: + groupe.add_to(m) + resume.append(f"{len(calques)} voyage(s)") + + # Cartes My Maps + dossier = os.path.join(racine, DOSSIER_MYMAPS) + fichiers = sorted(f for f in os.listdir(dossier) if f.lower().endswith(('.kml', '.kmz'))) \ + if os.path.isdir(dossier) else [] + for f in fichiers: + try: + titre, calques = elements_mymaps(lire_kml(os.path.join(dossier, f))) + except Exception as e: + print(f" ⚠️ {DOSSIER_MYMAPS}/{f} illisible : {e}") + continue + # Calques du plus récent au plus ancien (leurs noms commencent par l'année). + for nom_calque, elements in sorted(calques.items(), key=lambda kv: kv[0], reverse=True): + couleur_calque = elements[0]['couleur'] if elements else COULEUR_MYMAPS + groupe = folium.FeatureGroup(name=str(F.legende(nom_calque, couleur_calque))) + for e in elements: + popup = folium.Popup(str(F.popup_mymaps(e['nom'], e['description'])), max_width=300) + if e['type'] == 'ligne': + pts = simplifier(e['coords']) + folium.PolyLine(pts, color=e['couleur'], weight=4, opacity=0.85, + tooltip=e['nom'] or None, popup=popup).add_to(groupe) + tous.extend(pts) + else: + folium.CircleMarker(e['coords'], radius=6, color='#fff', weight=2, + fill=True, fill_color=e['couleur'], fill_opacity=1, + tooltip=e['nom'] or None, popup=popup).add_to(groupe) + tous.append(e['coords']) + groupe.add_to(m) + resume.append(f"{nom_calque} : {len(elements)}") + + if tous: + lats = [p[0] for p in tous] + lons = [p[1] for p in tous] + m.fit_bounds([[min(lats), min(lons)], [max(lats), max(lons)]], padding=(20, 20)) + + folium.LayerControl(collapsed=False).add_to(m) + plugins.Fullscreen(position='topleft').add_to(m) + m.get_root().html.add_child(folium.Element(str(F.bouton_accueil()))) + # Molette active seulement quand la carte est ouverte seule : intégrée dans + # l'accueil, elle capterait le défilement de la page. + m.get_root().script.add_child(folium.Element(str(F.script_page(m.get_name())))) + m.save(os.path.join(racine, SORTIE)) + print(f"🗺️ Carte globale : {SORTIE} ({', '.join(resume)})") diff --git a/rtg/cli.py b/rtg/cli.py new file mode 100644 index 0000000..96c4578 --- /dev/null +++ b/rtg/cli.py @@ -0,0 +1,71 @@ +"""Commande `rtg`. + + rtg generer [VOYAGE…] tout faire (sources, positions, vidéos, photos, pages, accueil) + rtg etat avancement de chaque voyage + rtg analyser VOYAGE ce que RTG trouve dans les sources d'un voyage + rtg compresser VOYAGE seulement les vidéos + rtg accueil seulement la page d'accueil et la carte globale + rtg migrer écrire site.toml et les voyage.toml depuis l'existant + +La racine du site est le premier dossier, en remontant depuis le dossier +courant, qui contient site.toml (sinon le dossier courant) ; --racine la force. +""" +import argparse +import os +import sys + +from rtg import __version__ + + +def main(argv=None): + p = argparse.ArgumentParser(prog="rtg", description="RoadTripsGenerator : site web de road-trips.") + p.add_argument("--version", action="version", version=f"rtg {__version__}") + p.add_argument("--racine", help="racine du site (défaut : recherche de site.toml)") + sp = p.add_subparsers(dest="commande", required=True) + + g = sp.add_parser("generer", help="générer un, plusieurs ou tous les voyages, puis l'accueil") + g.add_argument("voyages", nargs="*", help="dossiers des voyages (défaut : tous)") + g.add_argument("--sans-compression", action="store_true", help="ne pas encoder les vidéos") + g.add_argument("--force", action="store_true", help="tout refaire, même ce qui est à jour") + g.add_argument("--mymaps-maj", action="store_true", help="récupérer à nouveau les cartes My Maps") + + sp.add_parser("etat", help="avancement de chaque voyage") + + a = sp.add_parser("analyser", help="inventaire des sources d'un voyage") + a.add_argument("voyage") + + c = sp.add_parser("compresser", help="encoder les vidéos d'un voyage") + c.add_argument("voyage") + c.add_argument("--force", action="store_true") + + ac = sp.add_parser("accueil", help="régénérer l'accueil et la carte globale") + ac.add_argument("--mymaps-maj", action="store_true") + + sp.add_parser("migrer", help="écrire site.toml et voyage.toml depuis l'organisation actuelle") + + args = p.parse_args(argv) + + from rtg import accueil, site, sources, video + from rtg.config import reglages_site, reglages_voyage, trouver_racine + racine = os.path.abspath(args.racine) if args.racine else trouver_racine(".") + + try: + if args.commande == "generer": + ok = site.generer(racine, args.voyages, compression=not args.sans_compression, + force=args.force, mymaps_maj=args.mymaps_maj) + sys.exit(0 if ok else 1) + elif args.commande == "etat": + site.etat(racine) + elif args.commande == "analyser": + site.analyser(args.voyage, racine) + elif args.commande == "compresser": + v = os.path.abspath(args.voyage) + cfg = reglages_voyage(v, reglages_site(racine)) + sys.exit(0 if video.compresser(v, cfg, sources.analyser(v, cfg), force=args.force) else 1) + elif args.commande == "accueil": + accueil.generer(racine, reglages_site(racine), args.mymaps_maj) + elif args.commande == "migrer": + site.migrer(racine) + except KeyboardInterrupt: + print("\nInterrompu.") + sys.exit(130) diff --git a/rtg/config.py b/rtg/config.py new file mode 100644 index 0000000..7c53dc9 --- /dev/null +++ b/rtg/config.py @@ -0,0 +1,147 @@ +"""Réglages du site (site.toml) et des voyages (voyage.toml). + +Un réglage absent d'un voyage prend la valeur du site, puis la valeur par +défaut ci-dessous. Les anciens fichiers (mymaps.txt, moto.txt) sont encore lus +quand les nouveaux n'existent pas ; `rtg migrer` écrit les nouveaux. +""" +import copy +import os +import re + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib + +SITE_TOML = "site.toml" +VOYAGE_TOML = "voyage.toml" + +DEFAUTS = { + "titre": "Nos Road-Trips", + "adresse_publique": "https://roadtrip.ouaibe.fr/", + "accueil_nb_recents": 2, + "publication": {"mode": "sur_place", "destination": "", "adresse_videos": ""}, + "video": { + "qualite": "equilibree", # econome | equilibree | haute + "encodeur": "processeur", # processeur | materiel + "preset": "medium", + "fps_max": 60, + "jobs": 0, # 0 = automatique + "threads": 0, + }, + "photos": {"grand_cote": 1600, "qualite": 80, "positions_deduites": True}, + "journees": {"fuseau": "Europe/Paris", "coupure_heure": 4}, + "carte_globale": {"mymaps": []}, + # Propres à un voyage + "rubrique": "", + "moto": "", + "sources": {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]}, + "exclusions": {"fichiers": []}, +} + +# Qualité « humaine » → paramètres d'encodeur. « equilibree » = réglages +# historiques de compress.sh (CRF 26 HEVC / 25 H.264). +QUALITES = { + "econome": {"crf_hevc": 28, "crf_h264": 27, "vtq_hd": 55, "vtq_sd": 53}, + "equilibree": {"crf_hevc": 26, "crf_h264": 25, "vtq_hd": 60, "vtq_sd": 58}, + "haute": {"crf_hevc": 23, "crf_h264": 22, "vtq_hd": 68, "vtq_sd": 65}, +} + + +def fusion(base, ajout): + """Fusion récursive : les valeurs de `ajout` l'emportent.""" + r = copy.deepcopy(base) + for k, v in (ajout or {}).items(): + if isinstance(v, dict) and isinstance(r.get(k), dict): + r[k] = fusion(r[k], v) + else: + r[k] = copy.deepcopy(v) + return r + + +def lire_toml(chemin): + if not os.path.exists(chemin): + return {} + with open(chemin, "rb") as f: + return tomllib.load(f) + + +def trouver_racine(depart="."): + """Premier dossier, en remontant depuis `depart`, qui contient site.toml ; + à défaut `depart` lui-même.""" + d = os.path.abspath(depart) + while True: + if os.path.exists(os.path.join(d, SITE_TOML)): + return d + parent = os.path.dirname(d) + if parent == d: + return os.path.abspath(depart) + d = parent + + +def _mymaps_ancien(racine): + f = os.path.join(racine, "mymaps.txt") + if not os.path.exists(f): + return [] + with open(f, encoding="utf-8") as fh: + return [l.strip() for l in fh if l.strip() and not l.strip().startswith("#")] + + +def reglages_site(racine): + brut = lire_toml(os.path.join(racine, SITE_TOML)) + cfg = fusion(DEFAUTS, brut) + if not brut.get("carte_globale", {}).get("mymaps"): + cfg["carte_globale"]["mymaps"] = _mymaps_ancien(racine) + return cfg + + +def reglages_voyage(dossier, site=None): + """Réglages effectifs d'un voyage : défauts ← site ← voyage.toml (← moto.txt).""" + brut = lire_toml(os.path.join(dossier, VOYAGE_TOML)) + cfg = fusion(site if site is not None else DEFAUTS, brut) + if not brut.get("moto"): + f = os.path.join(dossier, "moto.txt") + if os.path.exists(f): + with open(f, encoding="utf-8") as fh: + cfg["moto"] = fh.readline().strip() + if not cfg.get("titre") or "titre" not in brut: + cfg["titre"] = os.path.basename(dossier.rstrip("/")).replace("-", " ").replace("_", " ") + return cfg + + +def parametres_video(cfg): + """Paramètres d'encodage effectifs (dictionnaire plat).""" + v = cfg["video"] + q = QUALITES.get(v.get("qualite", "equilibree"), QUALITES["equilibree"]) + return dict(q, encodeur="vt" if v.get("encodeur") == "materiel" else "cpu", + preset=v.get("preset", "medium"), fps_max=int(v.get("fps_max", 60)), + jobs=int(v.get("jobs", 0)), threads=int(v.get("threads", 0))) + + +# --- Écriture (rtg migrer, interface) ----------------------------------------- +def _toml_valeur(v): + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, list): + return "[" + ", ".join(_toml_valeur(x) for x in v) + "]" + s = str(v).replace("\\", "\\\\").replace('"', '\\"') + return f'"{s}"' + + +def ecrire_toml(chemin, donnees, entete=""): + """Écrit un TOML simple (valeurs, listes, une profondeur de tables).""" + lignes = [f"# {l}" for l in entete.splitlines()] + ([""] if entete else []) + for k, v in donnees.items(): + if not isinstance(v, dict): + lignes.append(f"{k} = {_toml_valeur(v)}") + for k, v in donnees.items(): + if isinstance(v, dict): + lignes += ["", f"[{k}]"] + [f"{kk} = {_toml_valeur(vv)}" for kk, vv in v.items()] + with open(chemin, "w", encoding="utf-8") as f: + f.write("\n".join(lignes) + "\n") + + +def nom_lisible(nom): + return re.sub(r"[-_]+", " ", nom).strip() diff --git a/rtg/extraction.py b/rtg/extraction.py new file mode 100644 index 0000000..887d3bd --- /dev/null +++ b/rtg/extraction.py @@ -0,0 +1,183 @@ +"""Positions GPS des photos (EXIF) et des rushs GoPro (GPS embarqué). + +Produit, comme l'ancien extraction_infos.sh, deux CSV sans en-tête : + export_photos.csv : fichier, date, lat, lon, alt + export_videos.csv : fichier, date GPS, lat, lon, alt, vitesse + +Une photo sans GPS mais datée peut recevoir la position de la trace à son +heure (réglage photos.positions_deduites), si la trace est horodatée. +""" +import bisect +import os +import subprocess +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +from rtg.outils import dire, exiger + +FMT_PHOTOS = '"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude,$GPSAltitude' +FMT_PHOTOS_MIN = '"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude' +FMT_PHOTOS_DATE = '"$FileName",$DateTimeOriginal' +TOLERANCE_POSITION = timedelta(minutes=5) + + +def _exiftool(args): + r = subprocess.run(["exiftool"] + args, capture_output=True, text=True, errors="replace") + return [l for l in r.stdout.splitlines() if l.strip()] + + +# --- Photos ------------------------------------------------------------------ +def extraire_photos(voyage, cfg, analyse): + dossier = os.path.join(voyage, "photos") + csv = os.path.join(voyage, "export_photos.csv") + if not os.path.isdir(dossier): + open(csv, "w").close() + dire("⚠️ Dossier photos non trouvé, étape ignorée.") + return + exiger("exiftool", "Installe-le : https://exiftool.org (brew install exiftool sous macOS).") + # Un seul appel par passe pour tout le dossier : chaque lancement d'exiftool + # coûte ~200 ms. Seconde passe au format réduit pour les photos sans altitude. + commun = ["-n", "-q", "-ext", "jpg", "-ext", "jpeg"] + p1 = _exiftool(commun + ["-p", FMT_PHOTOS, dossier]) + p2 = [l + "," for l in _exiftool(commun + ["-p", FMT_PHOTOS_MIN, dossier])] + vus = {l.split(",")[0] for l in p1} + lignes = p1 + [l for l in p2 if l.split(",")[0] not in vus] + + deduites = 0 + if cfg["photos"].get("positions_deduites", True): + vus = {l.split(",")[0] for l in lignes} + sans_gps = [l for l in _exiftool(commun + ["-p", FMT_PHOTOS_DATE, dossier]) + if l.split(",")[0] not in vus] + if sans_gps: + index = IndexTrace(analyse, cfg) + for l in sans_gps: + nom, date = l.split(",", 1) + pos = index.position(date) + if pos: + lignes.append(f"{nom},{date},{pos[0]},{pos[1]},") + deduites += 1 + + with open(csv, "w", encoding="utf-8") as f: + f.write("".join(l + "\n" for l in lignes)) + msg = f"📸 Photos : {len(lignes)} ligne(s)" + if len(lignes) > len(p1) + deduites: + msg += f" (dont {len(lignes) - len(p1) - deduites} sans altitude)" + if deduites: + msg += f", {deduites} position(s) déduite(s) de la trace" + dire(msg) + + +class IndexTrace: + """Points horodatés des traces du voyage, pour retrouver une position à + une heure donnée.""" + + def __init__(self, analyse, cfg): + self.tz = ZoneInfo(cfg["journees"]["fuseau"]) + pts = [] + for pistes in analyse.journees.values(): + for t in pistes: + for s in t.segments: + for p in s.points: + if p.time: + h = p.time if p.time.tzinfo else p.time.replace(tzinfo=timezone.utc) + pts.append((h.timestamp(), p.latitude, p.longitude)) + pts.sort() + self.temps = [p[0] for p in pts] + self.pts = pts + + def position(self, date_exif): + """Date EXIF « AAAA:MM:JJ HH:MM:SS » (heure locale du voyage) → (lat, lon).""" + if not self.pts: + return None + try: + locale = datetime.strptime(date_exif.split(".")[0][:19], "%Y:%m:%d %H:%M:%S") + except ValueError: + return None + t = locale.replace(tzinfo=self.tz).timestamp() + i = bisect.bisect_left(self.temps, t) + voisins = [j for j in (i - 1, i) if 0 <= j < len(self.pts)] + j = min(voisins, key=lambda k: abs(self.temps[k] - t)) + if abs(self.temps[j] - t) > TOLERANCE_POSITION.total_seconds(): + return None + return self.pts[j][1], self.pts[j][2] + + +# --- Vidéos ------------------------------------------------------------------ +def _premier_point(video): + """(date, lat, lon) du premier point GPS d'un rush GoPro, ou None. + + Lit les tags un par un : `exiftool -p` ne voit que le document principal, + qui porte la position mais pas GPSDateTime (rangé dans [Doc1]). On arrête + exiftool dès qu'on a une date et une position, sans lire tout le fichier. + """ + cmd = ["exiftool", "-ee", "-n", "-q", "-s", "-api", "largefilesupport=1", + "-GPSDateTime", "-GPSLatitude", "-GPSLongitude", "-CreateDate", video] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, errors="replace") + dt = cd = lat = None + premier = None + try: + for ligne in proc.stdout: + if ":" not in ligne: + continue + ligne = ligne.split("]", 1)[-1] if ligne.startswith("[") else ligne + tag, _, val = ligne.partition(":") + tag, val = tag.strip().replace(" ", ""), val.strip() + if tag == "GPSDateTime" and dt is None: + dt = val + elif tag == "CreateDate" and cd is None: + cd = val + elif tag == "GPSLatitude": + lat = val + elif tag == "GPSLongitude" and lat is not None: + if premier is None: + premier = (lat, val) + lat = None + if premier and dt: + break + finally: + proc.kill() + proc.wait() + date = dt or cd + if premier and date: + return date, premier[0], premier[1] + return None + + +def extraire_videos(voyage, analyse): + csv = os.path.join(voyage, "export_videos.csv") + if not analyse.videos: + open(csv, "w").close() + dire("⚠️ Aucune vidéo source trouvée.") + return + exiger("exiftool", "Installe-le : https://exiftool.org (brew install exiftool sous macOS).") + # Lignes déjà connues : un rush non modifié depuis le dernier export n'est + # pas relu (gain important sur un disque externe). + connues, date_csv = {}, 0 + if os.path.exists(csv) and os.path.getsize(csv) > 0: + date_csv = os.path.getmtime(csv) + with open(csv, encoding="utf-8") as f: + for l in f: + if l.strip(): + connues[l.split(",")[0].strip('"')] = l.rstrip("\n") + dire(f"🎬 Vidéos : {len(analyse.videos)} rush(s), premier point GPS") + lignes, sans, reprises = [], 0, 0 + for i, v in enumerate(analyse.videos, 1): + nom = os.path.basename(v) + if nom in connues and os.path.getmtime(v) <= date_csv: + lignes.append(connues[nom]) + reprises += 1 + continue + r = _premier_point(v) + if r: + lignes.append(f'"{nom}",{r[0]},{r[1]},{r[2]},,') + etat = "✓" + else: + sans += 1 + etat = "— pas de GPS" + dire(f" [{i:3d}/{len(analyse.videos):3d}] {nom:<28} {etat}") + if reprises: + dire(f" {reprises} rush(s) inchangé(s), positions reprises du dernier export") + with open(csv, "w", encoding="utf-8") as f: + f.write("".join(l + "\n" for l in lignes)) + dire(f"🎬 Vidéos : {len(lignes)} rush(s) localisé(s)" + (f", {sans} sans GPS" if sans else "")) diff --git a/rtg/outils.py b/rtg/outils.py new file mode 100644 index 0000000..09a7222 --- /dev/null +++ b/rtg/outils.py @@ -0,0 +1,86 @@ +"""Petits outils communs : programmes externes, fichiers, affichage.""" +import os +import shutil +import subprocess +import sys + +EXT_PHOTOS = (".jpg", ".jpeg", ".heic", ".heif") +EXT_VIDEOS = (".mp4", ".mov") + + +def dire(message=""): + print(message, flush=True) + + +def programme(nom): + """Chemin d'un programme externe (ffmpeg, ffprobe, exiftool), ou None.""" + return shutil.which(nom) + + +def exiger(nom, conseil): + chemin = programme(nom) + if not chemin: + raise SystemExit(f"❌ {nom} introuvable. {conseil}") + return chemin + + +def est_cache(nom): + """Fichiers cachés et « ._nom » AppleDouble que macOS dépose sur les + disques non-HFS : jamais des médias.""" + return nom.startswith(".") + + +def lister_fichiers(sources, extensions, base="."): + """Fichiers (chemins absolus, triés, sans doublon) trouvés dans une liste + de fichiers et de dossiers ; chemins relatifs résolus depuis `base`. + Les dossiers sont parcourus sur un niveau, comme les anciens scripts.""" + vus, res = set(), [] + for s in sources: + p = os.path.abspath(os.path.join(base, os.path.expanduser(s))) + if os.path.isdir(p): + noms = sorted(os.listdir(p)) + candidats = [os.path.join(p, n) for n in noms if not est_cache(n)] + elif os.path.isfile(p): + candidats = [p] + else: + continue + for c in candidats: + if os.path.isfile(c) and c.lower().endswith(extensions): + cle = os.path.realpath(c) + if cle not in vus: + vus.add(cle) + res.append(c) + return res + + +def lisible(video): + """Vrai si ffprobe sait lire la vidéo (un encodage interrompu laisse un + gros fichier sans index « moov »).""" + if not os.path.exists(video) or os.path.getsize(video) == 0: + return False + r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", video], capture_output=True) + return r.returncode == 0 and r.stdout.strip() != b"" + + +def a_jour(sortie, source, force=False): + return (not force and os.path.exists(sortie) and os.path.getsize(sortie) > 0 + and os.path.getmtime(sortie) >= os.path.getmtime(source)) + + +def coeurs_performance(): + """Cœurs « performance » sur Apple Silicon (les cœurs d'efficience + ralentiraient tout le pool de threads x265), sinon tous les cœurs.""" + if sys.platform == "darwin": + for cle in ("hw.perflevel0.logicalcpu", "hw.logicalcpu"): + r = subprocess.run(["sysctl", "-n", cle], capture_output=True, text=True) + if r.returncode == 0 and r.stdout.strip().isdigit(): + return int(r.stdout.strip()) + return os.cpu_count() or 4 + + +def taille_lisible(octets): + for unite in ("o", "Ko", "Mo", "Go"): + if octets < 1024 or unite == "Go": + return f"{octets:.0f} {unite}" if unite == "o" else f"{octets:.1f} {unite}" + octets /= 1024 diff --git a/rtg/pages.py b/rtg/pages.py new file mode 100644 index 0000000..8289284 --- /dev/null +++ b/rtg/pages.py @@ -0,0 +1,504 @@ +"""Génère les pages d'un voyage. + + index.html carte générale : traces du voyage, liens par jour + mini.html miniature affichée sur la page d'accueil du site + html/carte_AAAA-MM-JJ.html une carte par jour, avec photos et vidéos + _complet.gpx toutes les traces du voyage réunies + .kml le voyage en une seule trace simplifiée (< 5 Mo), + à importer dans Google My Maps + assets/ feuille de style et script communs + +Appelé par `rtg generer` (rtg.site). + +Entrées : export_photos.csv et export_videos.csv (extraction_infos.sh), +routes/*.gpx (un fichier par jour, date AAAA-MM-JJ dans le nom), voyage.md, +et si présents photos_web/ et apercus/ (prepare_web.py). +La mise en page est dans Scripts/templates/. + +Réglages (site.toml) : adresse_publique, pour les liens du KML. +""" +import copy +import hashlib +import html +import json +import os +import re +import shutil +from urllib.parse import quote + +import folium +import gpxpy +import markdown2 +import numpy as np +import pandas as pd +from folium import plugins +from folium.plugins import MarkerCluster +from jinja2 import Environment, FileSystemLoader + +DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") +# Fixés par generer() : racine du site et adresse publique. +CONTEXTE = {"racine": None, "site_url": "https://roadtrip.ouaibe.fr/"} +ASSETS = ("carte.css", "carte.js") + +COULEURS_JOURS = ['#FF5733', '#2ECC71', '#3498DB', '#9B59B6', '#F1C40F', '#E67E22', '#1ABC9C', '#34495E'] +COLONNES = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse'] +TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' + +KML_MAX_OCTETS = 4_500_000 # My Maps refuse les fichiers de plus de 5 Mo +KML_TOLERANCE_M = 10 # simplification de départ (écart toléré, en mètres) +KML_COULEUR = "ff3357ff" # #FF5733 au format KML (aabbggrr) + +# Photos prises à moins de ~20 m l'une de l'autre : un seul marqueur, en diaporama. +TOLERANCE_GROUPE_PHOTOS = 0.0002 + +env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True, + trim_blocks=True, lstrip_blocks=True) +F = env.get_template("fragments.html").module + + +def url(chemin): + """Chemin encodé pour un src/href (espaces, &, #…).""" + return quote(str(chemin)) + + +def est_sidecar(nom): + """Fichiers cachés et « ._nom » AppleDouble que macOS dépose sur les + disques non-HFS : ce ne sont pas des médias.""" + return nom.startswith('.') + + +def bornes(points, marge=0.02, robuste=True): + """Rectangle de cadrage [[sud, ouest], [nord, est]] et nombre de points écartés. + + Avec robuste=True, on écarte les points hors de [Q1 - 3·IQR, Q3 + 3·IQR] : + le premier échantillon GPS d'une GoPro est parfois la position de la session + précédente, et un seul point aberrant suffit à dézoomer sur toute l'Europe. + À désactiver pour les traces GPX, continues par construction : le filtre y + rognerait de vrais morceaux d'itinéraire. + """ + if not points: + return None, 0 + + def quartiles(v): + v = sorted(v) + n = len(v) + return (v[0], v[-1]) if n < 4 else (v[n // 4], v[(3 * n) // 4]) + + gardes = points + if robuste: + la1, la3 = quartiles([p[0] for p in points]) + lo1, lo3 = quartiles([p[1] for p in points]) + # Plancher de tolérance : sur une balade très locale l'IQR est minuscule. + ela, elo = max((la3 - la1) * 3, 0.05), max((lo3 - lo1) * 3, 0.05) + gardes = [p for p in points + if la1 - ela <= p[0] <= la3 + ela and lo1 - elo <= p[1] <= lo3 + elo] or points + + lats = [p[0] for p in gardes] + lons = [p[1] for p in gardes] + dla = max((max(lats) - min(lats)) * marge, 0.005) + dlo = max((max(lons) - min(lons)) * marge, 0.005) + return ([[min(lats) - dla, min(lons) - dlo], [max(lats) + dla, max(lons) + dlo]], + len(points) - len(gardes)) + + +def lire_csv(chemin): + """CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour.""" + vide = pd.DataFrame(columns=COLONNES + ['DT', 'Jour']) + # Un voyage sans photo produit un CSV de 0 octet, que pandas refuse de lire. + if not os.path.exists(chemin) or os.path.getsize(chemin) == 0: + return vide + df = pd.read_csv(chemin, names=COLONNES, header=None) + df = df[df['Latitude'].notnull() & df['Longitude'].notnull()] + df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)].copy() + if df.empty: + return vide + df['DT'] = pd.to_datetime(df['Date_Heure'].astype(str).str.split('.').str[0], + format='%Y:%m:%d %H:%M:%S', errors='coerce') + df = df.dropna(subset=['DT']) + df['Jour'] = df['DT'].dt.date + return df.sort_values(by='DT') + + +def racine_site(base_dir): + """Dossier racine du site : RT_RACINE, sinon le premier parent qui contient + un index.html. Un voyage peut être rangé un niveau plus bas + (Balades/Aisne-06-2026) : on ne peut pas supposer le parent direct.""" + racine = CONTEXTE["racine"] or os.environ.get('RT_RACINE') + if racine: + return os.path.abspath(racine) + parent = os.path.dirname(base_dir) + for _ in range(3): + if os.path.exists(os.path.join(parent, 'index.html')): + return parent + parent = os.path.dirname(parent) + return os.path.dirname(base_dir) + + +def lien_racine_site(base_dir): + """Chemin relatif vers l'index.html de la racine du site.""" + return os.path.relpath(os.path.join(racine_site(base_dir), 'index.html'), base_dir).replace(os.sep, '/') + + +def exporter_kml(base_dir, nom_voyage, pistes, km): + """.kml pour Google My Maps : UNE ligne pour tout le voyage (les + journées mises bout à bout), avec un lien vers la page du voyage. On + ne simplifie davantage que si le fichier dépasse la taille acceptée par + My Maps (5 Mo ; pas de limite de points par ligne).""" + site = CONTEXTE["site_url"] + quote(os.path.relpath(base_dir, racine_site(base_dir)).replace(os.sep, '/')) + '/' + titre = nom_voyage.replace('-', ' ').replace('_', ' ') + + # Simplification journée par journée (rapide sur des lignes courtes), puis + # sur la ligne entière, de plus en plus fort, seulement si elle est trop lourde. + seg = gpxpy.gpx.GPXTrackSegment() + for piste in pistes: + for segment in piste.segments: + morceau = copy.deepcopy(segment) + morceau.simplify(max_distance=KML_TOLERANCE_M) + seg.points.extend(morceau.points) + if not seg.points: + return + + tolerance = KML_TOLERANCE_M + while True: + coords = " ".join(f"{p.longitude:.6f},{p.latitude:.6f}" for p in seg.points) + desc = (f"{km:.0f} km
" if km else "") + f'Voir le voyage sur le site' + kml = ('\n' + f"{html.escape(titre)}" + f"{html.escape(titre)}" + f"" + f"" + f"1{coords}" + "\n").encode('utf-8') + if len(kml) <= KML_MAX_OCTETS or tolerance > 5000: + break + tolerance *= 1.5 + seg.simplify(max_distance=tolerance) + + nom = f"{nom_voyage}.kml" + with open(os.path.join(base_dir, nom), 'wb') as f: + f.write(kml) + print(f"🌐 KML My Maps : {nom} (1 trace, {len(seg.points)} points, {len(kml) // 1024} Ko, " + f"simplifiée à {tolerance:.0f} m)") + + +def installer_assets(base_dir): + """Copie carte.css / carte.js dans /assets/ et renvoie une version + courte (empreinte du contenu) pour forcer le rechargement après mise à jour.""" + dest = os.path.join(base_dir, 'assets') + os.makedirs(dest, exist_ok=True) + empreinte = hashlib.sha1() + for nom in ASSETS: + src = os.path.join(DOSSIER_TEMPLATES, nom) + shutil.copyfile(src, os.path.join(dest, nom)) + with open(src, 'rb') as f: + empreinte.update(f.read()) + return empreinte.hexdigest()[:8] + + +class Journal: + """Récit voyage.md converti en HTML, avec deux raccourcis résolus ici : + + [Carte du jour](jour:2026-08-01) → lien vers la carte de ce jour + [Le col](media:photos/IMG_4831.jpeg) → lien vers un média du voyage + + Le panneau est injecté à deux profondeurs (index.html à la racine du voyage, + cartes du jour dans html/) : un lien écrit en dur serait faux sur l'une des + deux, d'où ces marqueurs. Un jour sans carte devient du texte simple. + """ + + def __init__(self, base_dir, jours_valides): + self.base_dir = base_dir + self.valides = {j.strftime('%Y-%m-%d') for j in jours_valides} + self.html = "" + chemin = os.path.join(base_dir, 'voyage.md') + if os.path.exists(chemin): + with open(chemin, 'r', encoding='utf-8') as f: + self.html = markdown2.markdown(f.read()) + self._signale = set() # chaque problème n'est signalé qu'une fois + + def _signaler(self, message): + if message not in self._signale: + self._signale.add(message) + print(f" ⚠️ Journal : {message}") + + def rendu(self, prefixe_cartes, prefixe_media): + def jour(m): + date, texte = m.group(1), m.group(2) + if date not in self.valides: + self._signaler(f"lien vers {date}, mais aucune carte pour ce jour") + return texte + # Le fragment #jour-… ne correspond à aucun id : c'est carte.js qui + # le lit pour faire défiler le panneau jusqu'à l'ancre. + return (f'{texte}') + + def media(m): + chemin, texte = m.group(1).lstrip('/'), m.group(2) + if not os.path.exists(os.path.join(self.base_dir, chemin)): + self._signaler(f"média introuvable -> {chemin}") + return f'{texte}' + + h = re.sub(r']*>(.*?)', jour, self.html, flags=re.S) + h = re.sub(r']*>(.*?)', media, h, flags=re.S) + return F.journal(h) + + +def nouvelle_carte(entete): + m = folium.Map(tiles=None) + folium.TileLayer(TUILES_SATELLITE, attr='Esri', name='Satellite').add_to(m) + folium.TileLayer('OpenStreetMap', name='Plan').add_to(m) + m.get_root().header.add_child(folium.Element(str(entete))) + return m + + +def ajouter(carte, fragment): + carte.get_root().html.add_child(folium.Element(str(fragment))) + + +def epingle(couleur, icone): + return folium.DivIcon(html=str(F.epingle(couleur, icone)), icon_anchor=(12, 40)) + + +def fichiers_gpx(routes_dir): + if not os.path.isdir(routes_dir): + return [] + return sorted(f for f in os.listdir(routes_dir) + if not est_sidecar(f) and f.lower().endswith('.gpx')) + + +def groupes_photos(day_p, base_dir, pre): + """Regroupe les photos proches ; renvoie [(position, html, nb_photos)].""" + groupes = [] + while not day_p.empty: + ref = day_p.iloc[0] + masque = ((np.abs(day_p['Latitude'] - ref['Latitude']) < TOLERANCE_GROUPE_PHOTOS) + & (np.abs(day_p['Longitude'] - ref['Longitude']) < TOLERANCE_GROUPE_PHOTOS)) + photos = [] + for nom in day_p[masque]['Fichier']: + nom = str(nom).strip() + # Version allégée si prepare_web.py l'a produite ; le clic ouvre l'original. + web = os.path.exists(os.path.join(base_dir, 'photos_web', nom)) + photos.append({'nom': nom, + 'src': f"{pre}{'photos_web' if web else 'photos'}/{url(nom)}", + 'href': f"{pre}photos/{url(nom)}"}) + groupes.append(([ref['Latitude'], ref['Longitude']], F.popup_photos(photos), len(photos))) + day_p = day_p[~masque] + return groupes + + +def popup_video(v_name, base_dir, pre): + nom = str(v_name).strip() + base = os.path.splitext(nom)[0] + apercu = None + if os.path.exists(os.path.join(base_dir, 'apercus', base + '.jpg')): + apercu = f"{pre}apercus/{url(base)}.jpg" + return F.popup_video(nom, f"{pre}videos/{url(base)}_mobile.mp4", f"{pre}videos/{url(base)}.mp4", apercu) + + +def exporter_trace_globale(base_dir, pistes): + """assets/trace.json : les journées du voyage, séparées et simplifiées à + 30 m, pour la carte globale de l'accueil (genere_carte_globale.py).""" + lignes = [] + for piste in pistes: + for segment in piste.segments: + morceau = copy.deepcopy(segment) + morceau.simplify(max_distance=30) + if len(morceau.points) > 1: + lignes.append([[round(p.latitude, 5), round(p.longitude, 5)] for p in morceau.points]) + with open(os.path.join(base_dir, 'assets', 'trace.json'), 'w', encoding='utf-8') as f: + json.dump(lignes, f, separators=(',', ':')) + + +class Voyage: + """Données communes à toutes les pages d'un voyage.""" + + def __init__(self, base_dir): + self.base_dir = base_dir + self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage" + self.routes_dir = os.path.join(base_dir, "routes") + self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv")) + self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv")) + jours_gpx = set() + for f in fichiers_gpx(self.routes_dir): + m = re.search(r'(\d{4}-\d{2}-\d{2})', f) + if m: + jours_gpx.add(pd.to_datetime(m.group(1)).date()) + self.jours = sorted(set(self.df_v['Jour']) | set(self.df_p['Jour']) | jours_gpx) + self.traces = {} # jour → [(piste, points)] + self.gpx_du_jour = {} # jour → nom du fichier GPX + + def lire_traces(self): + for jour in self.jours: + day_str = jour.strftime('%Y-%m-%d') + self.traces[jour] = [] + # Date n'importe où dans le nom du fichier. + for gpx_file in fichiers_gpx(self.routes_dir): + if day_str not in gpx_file: + continue + try: + with open(os.path.join(self.routes_dir, gpx_file), 'r', encoding='utf-8') as f: + gpx = gpxpy.parse(f) + except Exception as e: + print(f" ⚠️ GPX illisible ({gpx_file}) : {e}") + continue + self.gpx_du_jour[jour] = gpx_file + for track in gpx.tracks: + pts = [[p.latitude, p.longitude] for seg in track.segments for p in seg.points] + if pts: + if not track.name: + track.name = day_str + self.traces[jour].append((track, pts)) + + +def carte_du_jour(v, jour, version, journal, pre, lien_accueil, bouton_telechargement): + """Carte d'une journée avec photos et vidéos. + + pre : chemin vers la racine du voyage depuis la page (« ../ » pour + html/carte_JJ.html, « » quand la carte sert de page du voyage). + """ + day_str = jour.strftime('%Y-%m-%d') + m_day = nouvelle_carte(str(F.entete(pre, version)) + + f'') + cluster = MarkerCluster(name="Médias").add_to(m_day) + day_coords, media_coords = [], [] + + for _, pts in v.traces.get(jour, []): + day_coords.extend(pts) + folium.PolyLine(pts, color="#FF0000", weight=4, opacity=0.8).add_to(m_day) + + ajouter(m_day, F.bouton_accueil(lien_accueil)) + ajouter(m_day, journal.rendu("" if pre else "html/", pre)) + if bouton_telechargement: + ajouter(m_day, bouton_telechargement) + + for pos, popup, nb in groupes_photos(v.df_p[v.df_p['Jour'] == jour].copy(), v.base_dir, pre): + media_coords.append(pos) + folium.Marker(location=pos, popup=folium.Popup(str(popup), max_width="100%"), + icon=epingle("#FF3B30", "camera" if nb == 1 else "images")).add_to(cluster) + + # Vidéos : marqueur au premier point GPS du rush. + for v_name, group in v.df_v[v.df_v['Jour'] == jour].groupby('Fichier'): + pos = group[['Latitude', 'Longitude']].values.tolist()[0] + media_coords.append(pos) + folium.Marker(location=pos, popup=folium.Popup(str(popup_video(v_name, v.base_dir, pre)), max_width="100%"), + icon=epingle("#007AFF", "play")).add_to(cluster) + + # Cadrage : la trace GPX en priorité, sinon la position des médias + # (sans quoi un jour sans GPX s'ouvre sur une mappemonde). + b, rejetes = bornes(day_coords or media_coords, robuste=not day_coords) + if b: + m_day.fit_bounds(b, padding=(50, 50)) + if rejetes and pre: + print(f" ℹ️ {day_str} : {rejetes} point(s) GPS aberrant(s) exclu(s) du cadrage") + + folium.LayerControl(position='topright').add_to(m_day) + plugins.Fullscreen(position='topright').add_to(m_day) + return m_day, media_coords + + +def generer(base_dir, racine=None, site_url=None): + """Pages d'un voyage. `racine` : racine du site (bouton « Retour »).""" + CONTEXTE["racine"] = racine + if site_url: + CONTEXTE["site_url"] = site_url.rstrip('/') + '/' + base_dir = os.path.abspath(base_dir) + html_dir = os.path.join(base_dir, "html") + os.makedirs(html_dir, exist_ok=True) + v = Voyage(base_dir) + if not v.jours: + print(f"❌ {v.nom} : aucune date trouvée (ni GPX daté, ni média géolocalisé).") + return False + v.lire_traces() + + version = installer_assets(base_dir) + journal = Journal(base_dir, v.jours) + racine = lien_racine_site(base_dir) + un_seul_jour = len(v.jours) == 1 + + # Trace complète du voyage. Écrite à la racine du voyage et non dans + # routes/, qui est relu à chaque génération. + gpx_complet = gpxpy.gpx.GPX() + gpx_complet.name = f"{v.nom} - trace complete" + gpx_complet.creator = "genere_carte.py" + for jour in v.jours: + gpx_complet.tracks.extend(t for t, _ in v.traces[jour]) + + bouton_complet, km = None, 0 + if gpx_complet.tracks: + nom_fichier = f"{v.nom}_complet.gpx" + with open(os.path.join(base_dir, nom_fichier), 'w', encoding='utf-8') as f: + f.write(gpx_complet.to_xml()) + nb_pts = sum(len(sg.points) for t in gpx_complet.tracks for sg in t.segments) + try: + km = gpx_complet.length_2d() / 1000.0 + except Exception: + km = 0 + print(f"🧭 Trace complète : {nom_fichier} ({len(gpx_complet.tracks)} étape(s), {nb_pts} points" + + (f", {km:.0f} km" if km else "") + ")") + bouton_complet = lambda pre: F.bouton_gpx( # noqa: E731 + f"{pre}{url(nom_fichier)}", "📥 Trace complète" + (f" ({km:.0f} km)" if km else "")) + exporter_kml(base_dir, v.nom, gpx_complet.tracks, km) + exporter_trace_globale(base_dir, gpx_complet.tracks) + + # --- Cartes du jour (html/carte_JJ.html) + liens_jours, coords_medias = [], [] + for i, jour in enumerate(v.jours): + day_str = jour.strftime('%Y-%m-%d') + file_name = f'carte_{day_str}.html' + liens_jours.append({'libelle': day_str, 'lien': f'html/{file_name}', + 'couleur': COULEURS_JOURS[i % len(COULEURS_JOURS)]}) + if un_seul_jour: + # La carte du jour est aussi la page du voyage : son bouton mène à + # l'accueil du site, et on y télécharge la trace complète. + accueil, bouton = "../" + racine, bouton_complet("../") if bouton_complet else None + else: + gpx = v.gpx_du_jour.get(jour) + accueil = "../index.html" + bouton = F.bouton_gpx(f"../routes/{url(gpx)}", "📥 GPX") if gpx else None + m_day, medias = carte_du_jour(v, jour, version, journal, "../", accueil, bouton) + coords_medias.extend(medias) + m_day.save(os.path.join(html_dir, file_name)) + + # --- Miniature pour l'accueil du site + coords_traces = [p for j in v.jours for _, pts in v.traces[j] for p in pts] + cadrage = coords_traces or coords_medias + b, rejetes = bornes(cadrage, robuste=not coords_traces) + m_mini = folium.Map(tiles='OpenStreetMap', zoom_control=False, + control_scale=False, attribution_control=False) + for i, jour in enumerate(v.jours): + for _, pts in v.traces[jour]: + folium.PolyLine(pts, color=COULEURS_JOURS[i % len(COULEURS_JOURS)], + weight=3, opacity=0.8).add_to(m_mini) + if b: + m_mini.fit_bounds(b, padding=(20, 20)) + m_mini.save(os.path.join(base_dir, "mini.html")) + + # --- Page du voyage (index.html) + if un_seul_jour: + # Une seule journée : la page du voyage est directement la carte du + # jour, avec photos et vidéos (pas d'étape intermédiaire inutile). + m_index, _ = carte_du_jour(v, v.jours[0], version, journal, "", racine, + bouton_complet("") if bouton_complet else None) + else: + m_index = nouvelle_carte(F.entete("", version)) + for i, jour in enumerate(v.jours): + day_str = jour.strftime('%Y-%m-%d') + couleur = COULEURS_JOURS[i % len(COULEURS_JOURS)] + popup = F.popup_trace(day_str, couleur, f'html/carte_{day_str}.html') + for _, pts in v.traces[jour]: + folium.PolyLine(pts, color=couleur, weight=7, opacity=0.8, + popup=folium.Popup(str(popup), max_width=200)).add_to(m_index) + if b: + source = "traces GPX" if coords_traces else "positions des médias" + print(f"🗺️ Cadrage général d'après {len(cadrage)} point(s) ({source})" + + (f", {rejetes} aberrant(s) exclu(s)" if rejetes else "")) + m_index.fit_bounds(b, padding=(50, 50)) + if bouton_complet: + ajouter(m_index, bouton_complet("")) + ajouter(m_index, F.barre_jours(racine, liens_jours)) + ajouter(m_index, journal.rendu("html/", "")) + folium.LayerControl().add_to(m_index) + m_index.save(os.path.join(base_dir, "index.html")) + print(f"✅ {v.nom} : {len(v.jours)} jour(s) → index.html" + + (" (carte du jour directement)" if un_seul_jour else "") + ", mini.html, html/") + return True diff --git a/rtg/photos.py b/rtg/photos.py new file mode 100644 index 0000000..564a5b8 --- /dev/null +++ b/rtg/photos.py @@ -0,0 +1,174 @@ +"""Médias pour le web, à côté des originaux (jamais modifiés) : + + photos_web/ photos réduites (grand côté réglable), 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 + +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 diff --git a/rtg/site.py b/rtg/site.py new file mode 100644 index 0000000..13518c5 --- /dev/null +++ b/rtg/site.py @@ -0,0 +1,175 @@ +"""Orchestration : trouver les voyages, les générer, dire où ils en sont.""" +import os + +from rtg import accueil, extraction, pages, photos, sources, video +from rtg.config import (SITE_TOML, VOYAGE_TOML, ecrire_toml, nom_lisible, + reglages_site, reglages_voyage) +from rtg.outils import dire, est_cache + +IGNORES = {"Scripts", "RoadTripsGenerator", "html", "assets", "videos", "photos", "photos_web", + "apercus", "rushs", "routes"} +MARQUEURS = ("rushs", "routes", "photos", VOYAGE_TOML) + + +def _ignore(nom): + return nom in IGNORES or nom.startswith(("_", ".")) + + +def est_voyage(chemin): + return any(os.path.lexists(os.path.join(chemin, m)) for m in MARQUEURS) + + +def decouvrir(racine): + """Voyages du site : dossiers de la racine, ou un niveau plus bas dans un + dossier de regroupement (Balades/…).""" + res = [] + for d in sorted(os.listdir(racine)): + chemin = os.path.join(racine, d) + if _ignore(d) or not os.path.isdir(chemin): + continue + if est_voyage(chemin): + res.append(chemin) + continue + for s in sorted(os.listdir(chemin)): + sous = os.path.join(chemin, s) + if not _ignore(s) and os.path.isdir(sous) and est_voyage(sous): + res.append(sous) + return res + + +def _sources_disponibles(voyage, liste): + """Faux si une source est un lien cassé ou un chemin absent (disque débranché).""" + for s in liste: + p = os.path.join(voyage, os.path.expanduser(s)) + if not os.path.exists(p) and (os.path.islink(p) or s not in ("rushs", "routes", "photos")): + return False + return True + + +def generer_voyage(voyage, racine, site_cfg, compression=True, force=False): + cfg = reglages_voyage(voyage, site_cfg) + nom = os.path.relpath(voyage, racine) + dire("=" * 50) + dire(f"▶ {nom}") + dire("=" * 50) + + a = sources.analyser(voyage, cfg) + for m in a.a_verifier: + dire(f"⚠️ {m}") + for r in sources.ranger(voyage, cfg, a): + dire(r) + + extraction.extraire_photos(voyage, cfg, a) + if _sources_disponibles(voyage, cfg["sources"]["videos"]): + extraction.extraire_videos(voyage, a) + else: + dire("⏭️ Vidéos sources inaccessibles : positions reprises du dernier export.") + + if compression and a.videos: + video.compresser(voyage, cfg, a, force=force) + elif not compression: + dire("⏭️ Compression sautée.") + + photos.preparer(voyage, cfg, force=force) + ok = pages.generer(voyage, racine, cfg["adresse_publique"]) + dire() + return ok + + +def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=False): + racine = os.path.abspath(racine) + cfg = reglages_site(racine) + liste = [os.path.abspath(v) for v in voyages] if voyages else decouvrir(racine) + if not liste: + dire("❌ Aucun voyage trouvé (dossiers avec routes/, photos/, rushs/ ou voyage.toml).") + return False + dire(f"📁 {len(liste)} voyage(s) : {', '.join(os.path.relpath(v, racine) for v in liste)}\n") + echecs = [v for v in liste if not generer_voyage(v, racine, cfg, compression, force)] + dire("=" * 50) + dire("▶ Accueil") + dire("=" * 50) + accueil.generer(racine, cfg, mymaps_maj) + dire(f"\n✅ {len(liste) - len(echecs)} voyage(s) généré(s)" + + (f", ❌ {len(echecs)} en échec" if echecs else "")) + return not echecs + + +def etat(racine): + """Tableau de l'avancement de chaque voyage.""" + racine = os.path.abspath(racine) + site_cfg = reglages_site(racine) + lignes = [("Voyage", "Jours", "Vidéos web", "Photos web", "Pages", "À vérifier")] + for v in decouvrir(racine): + cfg = reglages_voyage(v, site_cfg) + a = sources.analyser(v, cfg) + if a.videos: + faites = sum(1 for s in a.videos if video.deja_faite(v, s)) + vid = f"{faites}/{len(a.videos)}" + else: + dossier = os.path.join(v, "videos") + n = len([f for f in os.listdir(dossier) if f.endswith("_mobile.mp4")]) if os.path.isdir(dossier) else 0 + vid = f"{n} (sources absentes)" if n else "—" + ph = os.path.join(v, "photos") + nb_ph = len([f for f in os.listdir(ph) if f.lower().endswith((".jpg", ".jpeg"))]) if os.path.isdir(ph) else 0 + pw = os.path.join(v, "photos_web") + nb_pw = len([f for f in os.listdir(pw) if not est_cache(f)]) if os.path.isdir(pw) else 0 + index = os.path.join(v, "index.html") + pages_ok = "à jour" if os.path.exists(index) and all( + os.path.getmtime(index) >= os.path.getmtime(os.path.join(v, c)) + for c in ("export_photos.csv", "export_videos.csv", "voyage.md") + if os.path.exists(os.path.join(v, c))) else "à refaire" + lignes.append((os.path.relpath(v, racine), str(len(a.journees)), vid, + f"{nb_pw}/{nb_ph}" if nb_ph else "—", pages_ok, str(len(a.a_verifier)) or "0")) + largeurs = [max(len(l[i]) for l in lignes) for i in range(len(lignes[0]))] + for k, l in enumerate(lignes): + dire(" ".join(c.ljust(w) for c, w in zip(l, largeurs))) + if k == 0: + dire(" ".join("-" * w for w in largeurs)) + + +def analyser(voyage, racine): + cfg = reglages_voyage(os.path.abspath(voyage), reglages_site(racine)) + a = sources.analyser(os.path.abspath(voyage), cfg) + dire(f"Voyage : {cfg['titre']}" + (f" ({cfg['moto']})" if cfg.get("moto") else "")) + dire(f"Traces : {len(a.traces)} fichier(s), {len(a.journees)} journée(s)") + for jour in sorted(a.journees): + dire(f" {jour.isoformat()} {a.km(jour):6.0f} km") + taille = sum(os.path.getsize(v) for v in a.videos) / 1e9 + dire(f"Photos : {len(a.photos)}") + dire(f"Vidéos : {len(a.videos)} ({taille:.1f} Go)") + if a.a_verifier: + dire("À vérifier :") + for m in a.a_verifier: + dire(f" ⚠️ {m}") + + +def migrer(racine): + """Écrit site.toml et un voyage.toml par voyage à partir de l'existant + (mymaps.txt, moto.txt). Ne remplace jamais un fichier déjà présent.""" + racine = os.path.abspath(racine) + cfg = reglages_site(racine) + ecrits = [] + if not os.path.exists(os.path.join(racine, SITE_TOML)): + ecrire_toml(os.path.join(racine, SITE_TOML), { + "titre": cfg["titre"], "adresse_publique": cfg["adresse_publique"], + "accueil_nb_recents": cfg["accueil_nb_recents"], + "video": {"qualite": cfg["video"]["qualite"], "encodeur": cfg["video"]["encodeur"]}, + "photos": {"grand_cote": cfg["photos"]["grand_cote"], "qualite": cfg["photos"]["qualite"]}, + "journees": dict(cfg["journees"]), + "carte_globale": {"mymaps": cfg["carte_globale"]["mymaps"]}, + }, entete="Réglages du site RoadTripsGenerator (rtg).") + ecrits.append(SITE_TOML) + for v in decouvrir(racine): + chemin = os.path.join(v, VOYAGE_TOML) + if os.path.exists(chemin): + continue + vc = reglages_voyage(v, cfg) + parent = os.path.dirname(v) + donnees = {"titre": nom_lisible(os.path.basename(v)), + "rubrique": "Voyages" if parent == racine else nom_lisible(os.path.basename(parent))} + if vc.get("moto"): + donnees["moto"] = vc["moto"] + donnees["sources"] = {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]} + ecrire_toml(chemin, donnees, entete="Voyage RoadTripsGenerator : sources, infos et réglages propres.") + ecrits.append(os.path.relpath(chemin, racine)) + dire(f"✅ {len(ecrits)} fichier(s) écrit(s)" + ("".join(f"\n {e}" for e in ecrits))) diff --git a/rtg/sources.py b/rtg/sources.py new file mode 100644 index 0000000..5602556 --- /dev/null +++ b/rtg/sources.py @@ -0,0 +1,221 @@ +"""Analyse des sources d'un voyage et mise en forme dans le dossier du site. + +Les sources (traces, photos, vidéos) peuvent être n'importe où et nommées +n'importe comment. Cette étape les range dans l'organisation que le reste +de la chaîne attend, sans jamais modifier les originaux : + + routes/AAAA-MM-JJ.gpx une trace par journée (créée par RTG) + photos/ copies JPEG des photos (HEIC converties) + +Les vidéos ne sont pas copiées : la compression lit directement les sources. +Un voyage dont les sources sont déjà ses propres dossiers routes/ et photos/ +(organisation historique) n'est pas touché. +""" +import os +import re +import shutil +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import gpxpy +import gpxpy.gpx + +from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers + +CREATEUR = "RoadTripsGenerator" +DATE_DANS_NOM = re.compile(r"(\d{4})-(\d{2})-(\d{2})") + + +class Analyse: + def __init__(self): + self.journees = {} # date → [GPXTrack] + self.traces = [] # fichiers GPX lus + self.photos = [] # chemins des photos sources + self.videos = [] # chemins des vidéos sources + self.a_verifier = [] # messages pour l'utilisateur + + def km(self, jour): + total = 0.0 + for t in self.journees.get(jour, []): + try: + total += t.length_2d() or 0 + except Exception: + pass + return total / 1000.0 + + +def _sources(voyage, cle, cfg): + return cfg["sources"].get(cle, []) + + +def _est_dossier_du_voyage(voyage, sources, nom): + """Vrai si les sources se résument au sous-dossier `nom` du voyage.""" + cible = os.path.realpath(os.path.join(voyage, nom)) + res = [os.path.realpath(os.path.join(voyage, os.path.expanduser(s))) for s in sources] + return res == [cible] + + +def date_du_nom(nom): + m = DATE_DANS_NOM.search(nom) + if not m: + return None + try: + return datetime(*map(int, m.groups())).date() + except ValueError: + return None + + +def decouper_par_journee(fichier, tz, coupure_h): + """Pistes d'un GPX réparties par journée locale. + + Renvoie ({date: [GPXTrack]}, None) ou ({}, raison) si aucune date n'est + trouvée. Avec horodatages : date locale dans le fuseau du voyage, avec une + coupure à `coupure_h` heures (une étape finie à 1 h reste sur la veille). + Sans horodatage : la date du nom de fichier vaut pour tout le fichier. + """ + with open(fichier, encoding="utf-8") as f: + gpx = gpxpy.parse(f) + a_des_heures = any(p.time for t in gpx.tracks for s in t.segments for p in s.points) + res = {} + if not a_des_heures: + jour = date_du_nom(os.path.basename(fichier)) + if jour is None: + return {}, "trace sans heures et sans date dans son nom" + for t in gpx.tracks: + res.setdefault(jour, []).append(t) + return res, None + + decalage = timedelta(hours=coupure_h) + for t in gpx.tracks: + par_jour = {} + for s in t.segments: + courant, morceau = None, None + for p in s.points: + if p.time is not None: + heure = p.time if p.time.tzinfo else p.time.replace(tzinfo=timezone.utc) + jour = (heure.astimezone(tz) - decalage).date() + else: + jour = courant + if jour is None: + continue + if jour != courant: + morceau = gpxpy.gpx.GPXTrackSegment() + par_jour.setdefault(jour, []).append(morceau) + courant = jour + morceau.points.append(p) + for jour, segments in par_jour.items(): + piste = gpxpy.gpx.GPXTrack(name=t.name) + piste.segments = segments + res.setdefault(jour, []).append(piste) + return res, None + + +def analyser(voyage, cfg): + """Inventaire des sources d'un voyage (sans rien écrire).""" + a = Analyse() + exclus = set(cfg.get("exclusions", {}).get("fichiers", [])) + tz = ZoneInfo(cfg["journees"]["fuseau"]) + coupure = int(cfg["journees"]["coupure_heure"]) + + a.traces = [f for f in lister_fichiers(_sources(voyage, "traces", cfg), (".gpx",), voyage) + if os.path.basename(f) not in exclus] + for f in a.traces: + try: + parts, raison = decouper_par_journee(f, tz, coupure) + except Exception as e: + a.a_verifier.append(f"Trace illisible : {os.path.basename(f)} ({e})") + continue + if raison: + a.a_verifier.append(f"Trace à rattacher à un jour : {os.path.basename(f)} ({raison})") + for jour, pistes in parts.items(): + a.journees.setdefault(jour, []).extend(pistes) + + a.photos = [f for f in lister_fichiers(_sources(voyage, "photos", cfg), EXT_PHOTOS, voyage) + if os.path.basename(f) not in exclus] + a.videos = [f for f in lister_fichiers(_sources(voyage, "videos", cfg), EXT_VIDEOS, voyage) + if os.path.basename(f) not in exclus] + + if _est_dossier_du_voyage(voyage, _sources(voyage, "photos", cfg), "photos"): + heic = [p for p in a.photos if p.lower().endswith((".heic", ".heif"))] + if heic: + a.a_verifier.append(f"{len(heic)} photo(s) HEIC dans photos/ : indiquez ce dossier comme " + f"source d'un autre emplacement pour qu'elles soient converties") + for s in _sources(voyage, "videos", cfg) + _sources(voyage, "photos", cfg) + _sources(voyage, "traces", cfg): + p = os.path.join(voyage, os.path.expanduser(s)) + if os.path.islink(p) and not os.path.exists(p): + a.a_verifier.append(f"Source introuvable (disque débranché ?) : {s} → {os.readlink(p)}") + elif not os.path.lexists(p) and s not in ("routes", "rushs", "photos"): + a.a_verifier.append(f"Source introuvable : {s}") + return a + + +def ranger(voyage, cfg, a): + """Range les sources dans le dossier du voyage (routes/, photos/). + Renvoie un résumé des écritures.""" + resume = [] + sources_traces = _sources(voyage, "traces", cfg) + if a.traces and not _est_dossier_du_voyage(voyage, sources_traces, "routes"): + resume.append(_ecrire_routes(voyage, a)) + if a.photos and not _est_dossier_du_voyage(voyage, _sources(voyage, "photos", cfg), "photos"): + resume.append(_copier_photos(voyage, a)) + return [r for r in resume if r] + + +def _ecrire_routes(voyage, a): + dossier = os.path.join(voyage, "routes") + os.makedirs(dossier, exist_ok=True) + attendus = set() + for jour, pistes in sorted(a.journees.items()): + nom = f"{jour.isoformat()}.gpx" + attendus.add(nom) + gpx = gpxpy.gpx.GPX() + gpx.creator = CREATEUR + for p in pistes: + gpx.tracks.append(p) + with open(os.path.join(dossier, nom), "w", encoding="utf-8") as f: + f.write(gpx.to_xml()) + # Retire les journées que RTG avait écrites et qui n'existent plus ; + # un GPX déposé à la main n'est jamais touché. + retires = 0 + for nom in os.listdir(dossier): + chemin = os.path.join(dossier, nom) + if nom in attendus or est_cache(nom) or not nom.lower().endswith(".gpx"): + continue + with open(chemin, encoding="utf-8", errors="replace") as f: + debut = f.read(600) + if f'creator="{CREATEUR}"' in debut: + os.remove(chemin) + retires += 1 + return f"🧭 routes/ : {len(attendus)} journée(s) écrite(s)" + (f", {retires} retirée(s)" if retires else "") + + +def _copier_photos(voyage, a): + dossier = os.path.join(voyage, "photos") + os.makedirs(dossier, exist_ok=True) + copiees = converties = 0 + for src in a.photos: + base, ext = os.path.splitext(os.path.basename(src)) + heic = ext.lower() in (".heic", ".heif") + dest = os.path.join(dossier, base + (".jpeg" if heic else ext)) + if os.path.exists(dest) and os.path.getmtime(dest) >= os.path.getmtime(src): + continue + if heic: + convertir_heic(src, dest) + converties += 1 + else: + shutil.copy2(src, dest) + copiees += 1 + if not (copiees or converties): + return None + return f"📸 photos/ : {copiees} copiée(s), {converties} HEIC convertie(s) en JPEG" + + +def convertir_heic(src, dest): + """HEIC → JPEG, métadonnées EXIF (date, GPS) conservées.""" + from PIL import Image + import pillow_heif + pillow_heif.register_heif_opener() + with Image.open(src) as im: + exif = im.info.get("exif") + im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"") + os.replace(dest + ".tmp", dest) diff --git a/rtg/templates/accueil.html b/rtg/templates/accueil.html new file mode 100644 index 0000000..8abf426 --- /dev/null +++ b/rtg/templates/accueil.html @@ -0,0 +1,37 @@ +{#- Page d'accueil : une rubrique par section (Voyages, Balades…), avec les + voyages les plus récents et un lien vers la liste complète. -#} +{% extends "base_accueil.html" %} +{% from "base_accueil.html" import carte %} + +{% block sous_titre %}{{ sous_titre }}{% endblock %} + +{% block contenu %} +{% if not rubriques %} +

Aucun voyage pour l'instant.
Lance genere_site.sh pour en générer.

+{% else %} +
+{% for r in rubriques %} +
+ +
+{% for v in r.voyages[:nb_recents] %} + {{ carte(v) }} +{% endfor %} +
+
+{% endfor %} +
+{% endif %} +{% if carte_globale %} +
+
+

La carte de nos principaux voyages

+ Ouvrir en grand ↗ +
+ +
+{% endif %} +{% endblock %} diff --git a/rtg/templates/base_accueil.html b/rtg/templates/base_accueil.html new file mode 100644 index 0000000..db66a91 --- /dev/null +++ b/rtg/templates/base_accueil.html @@ -0,0 +1,90 @@ +{#- Gabarit commun à la page d'accueil (accueil.html) et aux listes complètes + (liste.html). Les pages filles remplissent les blocs titre et contenu. -#} + + + + + + {% block titre %}{{ titre_site }}{% endblock %} + + + +
+

{% block entete %}🌍 {{ titre_site }}{% endblock %}

+
{% block sous_titre %}{% endblock %}
+
+
+{% block contenu %}{% endblock %} +
+ + + +{% macro carte(v) -%} + +{%- endmacro %} diff --git a/rtg/templates/carte.css b/rtg/templates/carte.css new file mode 100644 index 0000000..c413073 --- /dev/null +++ b/rtg/templates/carte.css @@ -0,0 +1,131 @@ +/* ============================================================================= + Pages d'un voyage : carte générale (index.html) et cartes du jour (html/). + Copié dans /assets/ à chaque génération. + ============================================================================= */ + +/* --- Contrôles Leaflet : zoom en bas à gauche, la barre latérale occupe le haut */ +.leaflet-top.leaflet-left { top: auto !important; bottom: 20px !important; left: 15px !important; } +.leaflet-popup-content-wrapper { border-radius: 12px; max-width: 95vw !important; } + +/* --- Épingles des médias --------------------------------------------------- */ +.pin { position: relative; height: 40px; width: 24px; } +.pin-tete { + position: absolute; top: 0; z-index: 2; + width: 24px; height: 24px; border-radius: 50%; + background: var(--c); border: 2px solid #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.4); + display: flex; align-items: center; justify-content: center; + color: #fff; font-size: 12px; +} +.pin-tige { + position: absolute; top: 22px; left: 10.5px; z-index: 1; + width: 3px; height: 18px; background: var(--c); +} + +/* --- Boutons flottants ----------------------------------------------------- */ +.btn-flottant { + position: fixed; right: 15px; z-index: 10001; display: block; + padding: 12px 20px; border: 2px solid #fff; border-radius: 50px; + color: #fff; text-decoration: none; font: bold 14px sans-serif; + box-shadow: 0 4px 15px rgba(0,0,0,0.4); cursor: pointer; +} +.btn-journal { bottom: 25px; z-index: 10000; background: #FFD700; color: #000; } +.btn-gpx { bottom: 85px; background: #28a745; } +.btn-accueil { + top: 15px; left: 15px; right: auto; border-radius: 10px; + background: #333; box-shadow: 0 4px 10px rgba(0,0,0,0.5); +} + +/* --- Barre latérale de la carte générale ------------------------------------ */ +.sidebar { + position: fixed; top: 15px; left: 15px; z-index: 9999; + width: 160px; max-height: 85vh; overflow-y: auto; padding: 12px; + background: rgba(255,255,255,0.9); border-radius: 12px; + box-shadow: 0 4px 20px rgba(0,0,0,0.2); font-family: sans-serif; +} +.sidebar a { + display: block; margin-bottom: 8px; padding: 12px; border-radius: 8px; + color: #fff; text-decoration: none; font-weight: bold; text-align: center; +} +.sidebar .btn-retour { margin-bottom: 15px; background: #444; border: 2px solid #666; } +.sidebar .jour { background: var(--c); } +.sidebar-titre { + border-top: 1px solid #ccc; margin-bottom: 10px; padding-top: 10px; + font-size: 12px; font-weight: bold; color: #666; text-align: center; +} + +/* --- Popup d'une étape (clic sur la trace) ---------------------------------- */ +.popup-trace { text-align: center; font-family: sans-serif; } +.popup-trace .btn-jour { + display: inline-block; margin-top: 14px; padding: 8px 12px; border-radius: 4px; + background: var(--c); color: #fff; text-decoration: none; font-weight: bold; +} + +/* --- Popup vidéo ------------------------------------------------------------ */ +.popup-video { width: calc(85vw - 40px); max-width: 1000px; text-align: center; } +.popup-video video { + display: block; width: 100%; max-height: 70vh; + border-radius: 8px; background: #000; +} +.header-row { + display: flex; justify-content: space-between; align-items: center; + background: #f0f0f0; padding: 5px 10px; border-radius: 4px; margin-bottom: 5px; +} +.file-name { + font: bold 11px monospace; color: #333; max-width: 60%; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.quality-controls { display: flex; gap: 6px; } +.quality-btn { + padding: 3px 8px; border: 1px solid #ccc; border-radius: 12px; background: #fff; + font: bold 10px sans-serif; text-transform: uppercase; + cursor: pointer; opacity: 0.6; transition: 0.2s; +} +.quality-btn:hover { opacity: 1; background: #eee; } +.active-720 { background: #2ECC71 !important; color: #fff; border-color: #27ae60 !important; opacity: 1 !important; } +.active-1080 { background: #007AFF !important; color: #fff; border-color: #0056b3 !important; opacity: 1 !important; } +.qbtn-disabled, .qbtn-disabled:hover { + opacity: 0.35 !important; background: #eee !important; + cursor: not-allowed !important; text-decoration: line-through; +} +.hevc-note { + display: none; margin-top: 6px; padding: 6px 10px; border-radius: 6px; + font: 11px sans-serif; color: #8a6d3b; background: #fcf8e3; text-align: left; +} + +/* --- Popup photos (diaporama) ----------------------------------------------- */ +.slider-container { position: relative; width: calc(85vw - 60px); max-width: 800px; min-height: 200px; text-align: center; } +.file-header { font: bold 11px monospace; color: #555; margin-bottom: 4px; } +.slide img { display: block; margin: auto; max-width: 100%; max-height: 65vh; border-radius: 8px; } +.nav-btn { + position: absolute; top: 50%; transform: translateY(-50%); z-index: 10; + padding: 12px; border: 2px solid #fff; border-radius: 50%; + background: rgba(0,0,0,0.6); color: #fff; font-size: 18px; cursor: pointer; +} +.prev { left: -10px; } +.next { right: -10px; } +.slide-counter { margin-top: 8px; font: bold 13px sans-serif; color: #333; } + +/* --- Carnet de route -------------------------------------------------------- */ +#journal-panel { + position: fixed; top: 0; right: -100%; z-index: 10005; + width: 400px; height: 100%; padding: 25px; overflow-y: auto; + background: #fff; box-shadow: -5px 0 15px rgba(0,0,0,0.2); + font-family: sans-serif; transition: 0.4s; +} +#journal-panel.open { right: 0; } +@media (max-width: 600px) { #journal-panel { width: 85%; } } +.close-btn { + position: absolute; top: 15px; right: 15px; + border: none; background: none; font-size: 24px; cursor: pointer; +} +.journal-content a.lien-jour { + display: inline-block; margin: 4px 0 10px; padding: 5px 12px; border-radius: 14px; + background: #007AFF; color: #fff !important; text-decoration: none; + font-size: 12px; font-weight: bold; +} +.journal-content a.lien-jour:hover { background: #0056b3; } +.journal-content a.lien-jour.jour-actif { background: #2ECC71; box-shadow: 0 0 0 3px rgba(46,204,113,0.3); } +.journal-content a.lien-media { + color: #007AFF; text-decoration: underline; + text-decoration-style: dotted; text-underline-offset: 2px; +} diff --git a/rtg/templates/carte.js b/rtg/templates/carte.js new file mode 100644 index 0000000..7fa00b8 --- /dev/null +++ b/rtg/templates/carte.js @@ -0,0 +1,183 @@ +/* ============================================================================= + Pages d'un voyage : vidéos, diaporamas photo et carnet de route. + Copié dans /assets/ à chaque génération. + + Les gestionnaires sont appelés par des onclick="" dans les popups : Leaflet + bloque la propagation des clics hors des popups, une délégation au niveau + du document ne les verrait pas. + ============================================================================= */ + +// --- Vidéos ------------------------------------------------------------------ +// Le 720p (H.264) est chargé par défaut et se lit partout. Le 1080p est en +// HEVC : si le navigateur ne le décode pas (Firefox surtout), on grise le bouton. +var HEVC_OK = (function () { + try { + var t = document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L120.B0"'); + return t === 'probably' || t === 'maybe'; + } catch (e) { return false; } +})(); + +function applyCodecSupport(popup) { + if (HEVC_OK) return; + var btns = popup.querySelectorAll('.quality-btn[data-hevc="1"]'); + for (var i = 0; i < btns.length; i++) { + btns[i].classList.add('qbtn-disabled'); + btns[i].title = "Le 1080p est encodé en HEVC, que ce navigateur ne sait pas décoder. Essaie Safari, Chrome ou Edge."; + } + var note = popup.querySelector('.hevc-note'); + if (note) note.style.display = 'block'; +} + +function changeQuality(btn) { + if (btn.classList.contains('qbtn-disabled')) return; + var box = btn.closest('.popup-video'); + var video = box.querySelector('video'); + var newSrc = btn.getAttribute('data-src'); + + // video.src est une URL absolue, data-src est relatif : on compare les noms. + var nomActuel = decodeURIComponent(video.src).split('/').pop(); + var nomVoulu = decodeURIComponent(newSrc).split('/').pop(); + if (nomActuel === nomVoulu) return; + + var savedTime = video.currentTime; + var wasPlaying = !video.paused; + video.src = newSrc; + video.onloadedmetadata = function () { + this.currentTime = savedTime; + if (wasPlaying) { + var p = this.play(); + if (p !== undefined) p.catch(function () { console.log("Lecture bloquée"); }); + } + this.onloadedmetadata = null; + }; + video.load(); + + var buttons = box.querySelectorAll('.quality-btn'); + for (var i = 0; i < buttons.length; i++) buttons[i].classList.remove('active-720', 'active-1080'); + btn.classList.add(btn.getAttribute('data-hevc') === '1' ? 'active-1080' : 'active-720'); +} + +// À l'ouverture d'une popup vidéo : codec, image d'aperçu, puis lecture. +// L'aperçu n'est posé qu'à ce moment-là : en attribut poster="" direct, le +// navigateur chargerait d'un coup les aperçus de toutes les vidéos du jour. +function nomFichier(u) { return decodeURIComponent(u || '').split('/').pop(); } + +// Filet de sécurité : si une version ne se lit pas (1080p HEVC malgré la +// détection, fichier abîmé ou absent), on essaie l'autre. +function surErreurVideo() { + var video = this, box = video.closest('.popup-video'); + video._essais = video._essais || {}; + video._essais[nomFichier(video.src)] = true; + var candidats = [video.getAttribute('data-fallback')]; + var hd = box.querySelector('.quality-btn[data-hevc="1"]'); + if (hd && HEVC_OK) candidats.push(hd.getAttribute('data-src')); + for (var i = 0; i < candidats.length; i++) { + var c = candidats[i]; + if (c && !video._essais[nomFichier(c)]) { + var btn = box.querySelector('.quality-btn[data-src="' + c + '"]'); + if (btn) changeQuality(btn); else { video.src = c; video.load(); } + var p = video.play(); + if (p !== undefined) p.catch(function () {}); + return; + } + } +} + +function preparePopup(node) { + applyCodecSupport(node); + var video = node.querySelector('video'); + if (!video) return; + // Leaflet réinsère la même popup à chaque ouverture : une seule initialisation. + if (!video._pret) { + video._pret = true; + var apercu = video.getAttribute('data-poster'); + if (apercu) video.setAttribute('poster', apercu); + video.addEventListener('error', surErreurVideo); + } + var p = video.play(); + if (p !== undefined) p.catch(function (e) { console.log("Autoplay bloqué par le navigateur", e); }); +} + +var observer = new MutationObserver(function (mutations) { + mutations.forEach(function (m) { + m.addedNodes.forEach(function (node) { + if (node.nodeType === 1 && node.classList && node.classList.contains('leaflet-popup')) { + preparePopup(node); + } + }); + }); +}); +document.addEventListener('DOMContentLoaded', function () { + observer.observe(document.body, { childList: true, subtree: true }); +}); + +// --- Diaporama photos ---------------------------------------------------------- +function moveSlide(btn, step) { + var slides = btn.parentElement.querySelectorAll('.slides .slide'); + var actif = Array.prototype.findIndex.call(slides, function (s) { return s.style.display !== 'none'; }); + if (actif === -1) actif = 0; + slides[actif].style.display = 'none'; + var suivant = (actif + step + slides.length) % slides.length; + slides[suivant].style.display = 'block'; + btn.parentElement.querySelector('.slide-counter').innerText = (suivant + 1) + '/' + slides.length; +} + +// --- Carnet de route ----------------------------------------------------------- +// localStorage lève une exception sur file:// dans certains navigateurs +// (Safari, navigation privée) : on l'enveloppe. +function memLire(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } +function memEcrire(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } + +// Journée à afficher : celle de l'URL (#jour-AAAA-MM-JJ) ou celle de la page. +function journalCible() { + var m = /#jour-(\d{4}-\d{2}-\d{2})/.exec(window.location.hash || ''); + return m ? m[1] : (window.JOUR_COURANT || null); +} + +function journalDefile(jour) { + var panel = document.getElementById('journal-panel'); + if (!panel || !jour) return false; + var el = document.getElementById('ancre-' + jour); + if (!el) return false; + var r = el.getBoundingClientRect(), pr = panel.getBoundingClientRect(); + panel.scrollTop += (r.top - pr.top) - 70; // laisse voir le titre du jour + var actif = panel.querySelector('.jour-actif'); + if (actif) actif.classList.remove('jour-actif'); + el.classList.add('jour-actif'); + return true; +} + +// Rejoué plusieurs fois : les CSS distantes (Leaflet, Font Awesome) arrivent +// après DOMContentLoaded et décalent les hauteurs. +function journalRecale() { + var j = journalCible(); + journalDefile(j); + [150, 500, 1200].forEach(function (d) { setTimeout(function () { journalDefile(j); }, d); }); +} + +function toggleJournal(open) { + var panel = document.getElementById('journal-panel'); + if (!panel) return; + panel.classList.toggle('open', open); + memEcrire('journalOpen', open ? 'true' : 'false'); + if (open) journalRecale(); +} + +function journalInit() { + var panel = document.getElementById('journal-panel'); + if (!panel) return; + var vientDuCarnet = /#jour-\d{4}-\d{2}-\d{2}/.test(window.location.hash || ''); + if (memLire('journalOpen') === 'true' || vientDuCarnet) { + panel.classList.add('open'); + if (vientDuCarnet) memEcrire('journalOpen', 'true'); + } + journalRecale(); +} + +if (document.readyState === 'loading') { + window.addEventListener('DOMContentLoaded', journalInit); +} else { + journalInit(); +} +window.addEventListener('load', journalRecale); +window.addEventListener('hashchange', function () { toggleJournal(true); }); diff --git a/rtg/templates/fragments.html b/rtg/templates/fragments.html new file mode 100644 index 0000000..588a1f9 --- /dev/null +++ b/rtg/templates/fragments.html @@ -0,0 +1,95 @@ +{#- ============================================================================= + Morceaux de HTML insérés par genere_carte.py dans les pages folium. + Échappement automatique : seules les variables marquées |safe passent telles + quelles. Les chemins arrivent déjà encodés pour les URL (quote()). + Les styles sont dans carte.css, le comportement dans carte.js. + ============================================================================= -#} + +{# Feuille de style et script communs, avec un numéro de version pour que les + navigateurs ne gardent pas une ancienne copie en cache. #} +{% macro entete(prefixe, version) -%} + + +{%- endmacro %} + + +{% macro epingle(couleur, icone) -%} +
+{%- endmacro %} + + +{# Popup affichée au clic sur la trace d'une journée (carte générale). #} +{% macro popup_trace(jour, couleur, lien) -%} + +{%- endmacro %} + + +{# photos : liste de {nom, src (version web), href (original)} #} +{% macro popup_photos(photos) -%} +
+
+ {%- for p in photos %} + + {%- endfor %} +
+ {%- if photos|length > 1 %} + + +
1/{{ photos|length }}
+ {%- endif %} +
+{%- endmacro %} + + +{# 720p H.264 chargé par défaut (lisible partout), 1080p HEVC à la demande. + preload="none" : rien n'est téléchargé avant l'ouverture de la popup. #} +{% macro popup_video(nom, mobile, hd, apercu) -%} + +{%- endmacro %} + + +{# contenu : HTML du récit (voyage.md converti), déjà prêt. #} +{% macro journal(contenu) -%} +
+ +
{{ contenu|safe }}
+
+ +{%- endmacro %} + + +{% macro bouton_accueil(lien) -%} +🏠 Accueil +{%- endmacro %} + + +{% macro bouton_gpx(lien, libelle) -%} +{{ libelle }} +{%- endmacro %} + + +{# jours : liste de {libelle, lien, couleur} #} +{% macro barre_jours(retour, jours) -%} + +{%- endmacro %} diff --git a/rtg/templates/fragments_globale.html b/rtg/templates/fragments_globale.html new file mode 100644 index 0000000..c74176d --- /dev/null +++ b/rtg/templates/fragments_globale.html @@ -0,0 +1,41 @@ +{#- Morceaux de HTML de la carte globale (genere_carte_globale.py). + Les liens visent target="_top" : la carte est affichée dans un cadre + de la page d'accueil, c'est toute la page qui doit changer. -#} + +{% macro popup_voyage(titre, periode, km, lien) -%} +
+ {{ titre }}
+ {{ periode }}{% if km %} · {{ "%.0f"|format(km) }} km{% endif %}
+ Voir le voyage +
+{%- endmacro %} + + +{% macro popup_mymaps(nom, description) -%} +
+ {{ nom }} + {%- if description %}
{{ description|liens_et_lignes }}
{% endif %} +
+{%- endmacro %} + + +{# Visible seulement quand la carte est ouverte seule (voir script_page). #} +{% macro bouton_accueil() -%} + +{%- endmacro %} + + +{# Exécuté au chargement complet : folium crée la carte plus bas dans la page. #} +{% macro script_page(carte) -%} +window.addEventListener('load', function () { + if (window.self !== window.top) return; + {{ carte }}.scrollWheelZoom.enable(); + document.getElementById('retour-accueil').style.display = 'block'; +}); +{%- endmacro %} + + +{# Libellé d'un voyage dans la légende (sélecteur de calques). #} +{% macro legende(titre, couleur) -%} +{{ titre }} +{%- endmacro %} diff --git a/rtg/templates/liste.html b/rtg/templates/liste.html new file mode 100644 index 0000000..e5530a9 --- /dev/null +++ b/rtg/templates/liste.html @@ -0,0 +1,16 @@ +{#- Liste complète d'une rubrique (voyages.html, balades.html…). -#} +{% extends "base_accueil.html" %} +{% from "base_accueil.html" import carte %} + +{% block titre %}{{ r.titre }} — {{ titre_site }}{% endblock %} +{% block entete %}{{ r.titre }}{% endblock %} +{% block sous_titre %}{{ r.voyages|length }} {{ r.unite }}{% endblock %} + +{% block contenu %} + ← {{ titre_site }} +
+{% for v in r.voyages %} + {{ carte(v) }} +{% endfor %} +
+{% endblock %} diff --git a/rtg/video.py b/rtg/video.py new file mode 100644 index 0000000..3f5a990 --- /dev/null +++ b/rtg/video.py @@ -0,0 +1,185 @@ +"""Compression des rushs pour le web. + +Pour chaque rush, deux fichiers dans /videos : + .mp4 1080p HEVC (tag hvc1), environ 40 % plus léger que H.264 + _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) diff --git a/tests/test_chaine.py b/tests/test_chaine.py new file mode 100644 index 0000000..eb3a598 --- /dev/null +++ b/tests/test_chaine.py @@ -0,0 +1,123 @@ +"""Chaîne complète sur un petit voyage fabriqué à la volée. + +Sources « mal rangées » exprès : une trace de deux jours au nom quelconque, +des photos dans un autre dossier (dont une HEIC et une sans GPS), une vidéo. +""" +import os +import shutil +import subprocess +from datetime import datetime, timedelta, timezone + +import pytest + +from rtg import config, site, sources + +pytestmark = pytest.mark.skipif(not (shutil.which("exiftool") and shutil.which("ffmpeg")), + reason="exiftool et ffmpeg nécessaires") + +UTC = timezone.utc + + +def _gpx(chemin): + """Deux journées (Paris, UTC+2 en mai) ; un point à 00 h 30 locale doit + rester sur la veille (coupure à 4 h).""" + pts = [] + t0 = datetime(2026, 5, 1, 6, 0, tzinfo=UTC) # 8 h locale + for i in range(60): + pts.append((48.80 + i * 0.002, 2.30 + i * 0.003, t0 + timedelta(minutes=2 * i))) + pts.append((48.95, 2.50, datetime(2026, 5, 1, 22, 30, tzinfo=UTC))) # 00 h 30 le 2 mai + t1 = datetime(2026, 5, 2, 7, 0, tzinfo=UTC) + for i in range(40): + pts.append((49.00 + i * 0.002, 2.60 + i * 0.002, t1 + timedelta(minutes=3 * i))) + seg = "\n".join(f'' + for la, lo, t in pts) + with open(chemin, "w") as f: + f.write(f'test{seg}' + f'') + + +def _photo(chemin, date_locale, gps=None): + from PIL import Image + im = Image.new("RGB", (3000, 2000), (90, 140, 200)) + im.save(chemin, "JPEG") + args = ["exiftool", "-q", "-overwrite_original", f"-DateTimeOriginal={date_locale}"] + if gps: + args += [f"-GPSLatitude={gps[0]}", "-GPSLatitudeRef=N", f"-GPSLongitude={gps[1]}", "-GPSLongitudeRef=E"] + subprocess.run(args + [chemin], check=True) + + +def _heic(chemin, jpeg): + from PIL import Image + import pillow_heif + pillow_heif.register_heif_opener() + with Image.open(jpeg) as im: + im.save(chemin, format="HEIF", exif=im.info.get("exif")) + + +@pytest.fixture +def site_test(tmp_path): + racine = tmp_path / "site" + voyage = racine / "Balades" / "Test-05-2026" + sources_dir = tmp_path / "carte_sd" + for d in (voyage, sources_dir / "photos", sources_dir / "gopro"): + d.mkdir(parents=True) + _gpx(sources_dir / "export_68degres_quelconque.gpx") + _photo(str(sources_dir / "photos" / "IMG_0001.jpg"), "2026:05:01 09:30:00", gps=(48.85, 2.35)) + # Sans GPS, prise à 9 h 00 locale le 1er mai = 7 h 00 UTC : 30e point de la trace + _photo(str(sources_dir / "photos" / "IMG_0002.jpg"), "2026:05:01 09:00:00") + _photo(str(tmp_path / "base.jpg"), "2026:05:02 10:00:00", gps=(49.01, 2.61)) + _heic(str(sources_dir / "photos" / "IMG_0003.HEIC"), str(tmp_path / "base.jpg")) + subprocess.run(["ffmpeg", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=1280x720:rate=30", + "-f", "lavfi", "-i", "sine=frequency=440", "-t", "2", "-c:v", "libx264", "-c:a", "aac", + "-shortest", str(sources_dir / "gopro" / "GX010001.MP4")], check=True) + config.ecrire_toml(str(racine / "site.toml"), {"titre": "Site de test", + "video": {"preset": "ultrafast"}}) + config.ecrire_toml(str(voyage / "voyage.toml"), { + "titre": "Test", "moto": "Moto d'essai", + "sources": {"traces": [str(sources_dir / "export_68degres_quelconque.gpx")], + "videos": [str(sources_dir / "gopro")], + "photos": [str(sources_dir / "photos")]}}) + return racine, voyage + + +def test_decoupage_et_coupure(site_test): + racine, voyage = site_test + cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine))) + a = sources.analyser(str(voyage), cfg) + assert sorted(j.isoformat() for j in a.journees) == ["2026-05-01", "2026-05-02"] + # Le point de 00 h 30 (heure de Paris) appartient au 1er mai + derniers = [p for t in a.journees[min(a.journees)] for s in t.segments for p in s.points] + assert derniers[-1].latitude == 48.95 + + +def test_chaine_complete(site_test): + racine, voyage = site_test + assert site.generer(str(racine)) + # Traces rangées par jour + assert sorted(os.listdir(voyage / "routes")) == ["2026-05-01.gpx", "2026-05-02.gpx"] + # Photos copiées, HEIC convertie en JPEG + assert sorted(os.listdir(voyage / "photos")) == ["IMG_0001.jpg", "IMG_0002.jpg", "IMG_0003.jpeg"] + csv = (voyage / "export_photos.csv").read_text() + assert "IMG_0001.jpg" in csv and "IMG_0003.jpeg" in csv + ligne2 = [l for l in csv.splitlines() if "IMG_0002" in l][0] + lat = float(ligne2.split(",")[2]) + assert abs(lat - (48.80 + 30 * 0.002)) < 0.003 # position déduite de la trace + # Vidéo compressée en deux paliers lisibles + assert (voyage / "videos" / "GX010001.mp4").exists() + assert (voyage / "videos" / "GX010001_mobile.mp4").exists() + # Pages + assert sorted(os.listdir(voyage / "html")) == ["carte_2026-05-01.html", "carte_2026-05-02.html"] + assert (voyage / "index.html").exists() and (voyage / "photos_web" / "IMG_0001.jpg").exists() + accueil = (racine / "index.html").read_text() + assert "Site de test" in accueil and "Balades/Test-05-2026/index.html" in accueil + + +def test_deuxieme_passage_ne_refait_rien(site_test, capsys): + racine, voyage = site_test + site.generer(str(racine)) + capsys.readouterr() + site.generer(str(racine)) + sortie = capsys.readouterr().out + assert "Aucun nouvel encodage" in sortie + assert "0 réduite(s)" in sortie