Phase 1 : moteur Python et commande rtg
- Paquet roadtripsgenerator (pyproject.toml), commande rtg : generer, etat, analyser, compresser, accueil, migrer - Réglages site.toml / voyage.toml (anciens mymaps.txt et moto.txt encore lus) ; qualité vidéo en trois crans - Sources laissées en place : traces découpées par journée d'après leurs horodatages (coupure à 4 h) ou la date du nom de fichier, photos copiées, HEIC converties en JPEG, position déduite de la trace pour les photos sans GPS - extraction_infos.sh et compress.sh portés en Python (plus de bash) ; sorties vidéo écrites sous un nom temporaire ; positions des rushs inchangés reprises du dernier export ; export vidéo conservé quand le disque des rushs est débranché - Pages, accueil et carte globale repris dans le paquet : sortie identique aux scripts (123 fichiers comparés sur 7 voyages) - Tests pytest sur un voyage d'exemple fabriqué à la volée Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
1 parent
97b936fd3e
commit
f0e1c2dfce
23 files changed
+3083
-1
No files matched your search
@@ -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)
|
[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
|
### Tout le site, ou quelques voyages
|
||||||
|
|
||||||
|
|||||||
@@ -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/*"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""RoadTripsGenerator (RTG) : site web statique de road-trips."""
|
||||||
|
|
||||||
|
__version__ = "0.9.0"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from rtg.cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
+164
@@ -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})")
|
||||||
@@ -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/<id>.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'<a href="\1" target="_blank" rel="noopener">\1</a>', h)
|
||||||
|
return Markup(h.replace('\n', '<br>'))
|
||||||
|
|
||||||
|
|
||||||
|
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 <voyage>.kml (une seule ligne : les journées
|
||||||
|
y sont reliées en ligne droite) ou <voyage>_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<br>', 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<br>', texte)
|
||||||
|
km = float(m.group(1)) if m else None
|
||||||
|
for bloc in re.findall(r'<coordinates>(.*?)</coordinates>', 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/<mid>.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"<kml" not in contenu[:3000]:
|
||||||
|
contenu, erreur = None, ("Google a renvoyé une page au lieu de la carte : est-elle partagée "
|
||||||
|
"« Tous les utilisateurs disposant du lien » ?")
|
||||||
|
if contenu is None:
|
||||||
|
etat = "copie précédente utilisée" if os.path.exists(dest) else "ignorée"
|
||||||
|
print(f" ⚠️ My Maps {mid[:10]}… non récupérée — {erreur} ({etat})")
|
||||||
|
continue
|
||||||
|
with open(dest, 'wb') as f:
|
||||||
|
f.write(contenu)
|
||||||
|
print(f" 🌐 My Maps {mid[:10]}… récupérée dans {DOSSIER_MYMAPS}/ ({len(contenu) // 1024} Ko)")
|
||||||
|
|
||||||
|
|
||||||
|
def telecharger(url):
|
||||||
|
"""Renvoie (contenu, None) ou (None, message d'erreur).
|
||||||
|
|
||||||
|
Python d'abord, puis curl : le Python de python.org sous macOS n'a souvent
|
||||||
|
pas les certificats racine (CERTIFICATE_VERIFY_FAILED), curl utilise ceux
|
||||||
|
du système.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as r:
|
||||||
|
return r.read(), None
|
||||||
|
except Exception as e:
|
||||||
|
erreur = str(e)
|
||||||
|
if shutil.which("curl"):
|
||||||
|
r = subprocess.run(["curl", "-sSfL", "--max-time", "60", url], capture_output=True)
|
||||||
|
if r.returncode == 0 and r.stdout:
|
||||||
|
return r.stdout, None
|
||||||
|
erreur += f" ; curl : {r.stderr.decode(errors='replace').strip() or 'code ' + str(r.returncode)}"
|
||||||
|
return None, erreur
|
||||||
|
|
||||||
|
|
||||||
|
def lire_kml(chemin):
|
||||||
|
if chemin.lower().endswith('.kmz'):
|
||||||
|
with zipfile.ZipFile(chemin) as z:
|
||||||
|
nom = next((n for n in z.namelist() if n.lower().endswith('.kml')), None)
|
||||||
|
return z.read(nom) if nom else None
|
||||||
|
with open(chemin, 'rb') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def couleur_kml(aabbggrr):
|
||||||
|
"""aabbggrr (KML) → #RRGGBB."""
|
||||||
|
c = aabbggrr.strip().lstrip('#')
|
||||||
|
return f"#{c[6:8]}{c[4:6]}{c[2:4]}".upper() if len(c) == 8 else None
|
||||||
|
|
||||||
|
|
||||||
|
def texte_simple(description):
|
||||||
|
"""Description My Maps → texte brut (balises retirées) ; le gabarit
|
||||||
|
l'échappe et rend les liens cliquables."""
|
||||||
|
if not description:
|
||||||
|
return ""
|
||||||
|
t = re.sub(r'<(script|style)\b.*?</\1>', '', description, flags=re.I | re.S)
|
||||||
|
t = re.sub(r'<br\s*/?>', '\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)})")
|
||||||
+71
@@ -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)
|
||||||
+147
@@ -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()
|
||||||
@@ -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 ""))
|
||||||
@@ -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
|
||||||
+504
@@ -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
|
||||||
|
<voyage>_complet.gpx toutes les traces du voyage réunies
|
||||||
|
<voyage>.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):
|
||||||
|
"""<voyage>.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<br>" if km else "") + f'<a href="{site}">Voir le voyage sur le site</a>'
|
||||||
|
kml = ('<?xml version="1.0" encoding="UTF-8"?>\n<kml xmlns="http://www.opengis.net/kml/2.2"><Document>'
|
||||||
|
f"<name>{html.escape(titre)}</name>"
|
||||||
|
f"<Placemark><name>{html.escape(titre)}</name>"
|
||||||
|
f"<description><![CDATA[{desc}]]></description>"
|
||||||
|
f"<Style><LineStyle><color>{KML_COULEUR}</color><width>4</width></LineStyle></Style>"
|
||||||
|
f"<LineString><tessellate>1</tessellate><coordinates>{coords}</coordinates></LineString>"
|
||||||
|
"</Placemark></Document></kml>\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 <voyage>/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'<a id="ancre-{date}" href="{prefixe_cartes}carte_{date}.html#jour-{date}" '
|
||||||
|
f'target="_top" class="lien-jour">{texte}</a>')
|
||||||
|
|
||||||
|
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'<a href="{prefixe_media}{url(chemin)}" target="_blank" class="lien-media">{texte}</a>'
|
||||||
|
|
||||||
|
h = re.sub(r'<a href="jour:(\d{4}-\d{2}-\d{2})"[^>]*>(.*?)</a>', jour, self.html, flags=re.S)
|
||||||
|
h = re.sub(r'<a href="media:([^"]+)"[^>]*>(.*?)</a>', 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'<script>window.JOUR_COURANT="{day_str}";</script>')
|
||||||
|
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
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
"""Médias pour le web, à côté des originaux (jamais modifiés) :
|
||||||
|
|
||||||
|
photos_web/<nom> photos réduites (grand côté réglable), affichées dans
|
||||||
|
les diaporamas ; le clic ouvre toujours l'original
|
||||||
|
apercus/<base>.jpg image d'aperçu de chaque vidéo, affichée avant lecture
|
||||||
|
|
||||||
|
Un fichier déjà produit et plus récent que sa source n'est pas refait.
|
||||||
|
Photos : Pillow, sinon `sips` (fourni avec macOS). Aperçus : ffmpeg.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
except ImportError:
|
||||||
|
Image = None
|
||||||
|
|
||||||
|
TRAVAILLEURS = os.cpu_count() or 4
|
||||||
|
REGLAGES = {"px": 1600, "qualite": 80, "force": False}
|
||||||
|
|
||||||
|
|
||||||
|
def a_jour(sortie, source):
|
||||||
|
return (not REGLAGES["force"] and os.path.exists(sortie) and os.path.getsize(sortie) > 0
|
||||||
|
and os.path.getmtime(sortie) >= os.path.getmtime(source))
|
||||||
|
|
||||||
|
|
||||||
|
def lister(dossier, extensions):
|
||||||
|
"""Fichiers du dossier ayant l'une des extensions, hors fichiers cachés et « ._ » de macOS."""
|
||||||
|
if not os.path.isdir(dossier):
|
||||||
|
return []
|
||||||
|
return sorted(f for f in os.listdir(dossier)
|
||||||
|
if not f.startswith('.') and f.lower().endswith(extensions))
|
||||||
|
|
||||||
|
|
||||||
|
def nettoyer_temporaires(dossier):
|
||||||
|
"""Restes d'une exécution interrompue (Ctrl-C) : fichiers .tmp-* cachés."""
|
||||||
|
for f in os.listdir(dossier):
|
||||||
|
if f.startswith(".tmp-"):
|
||||||
|
os.remove(os.path.join(dossier, f))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Photos ------------------------------------------------------------------
|
||||||
|
def reduire_pillow(src, dst):
|
||||||
|
with Image.open(src) as im:
|
||||||
|
im = ImageOps.exif_transpose(im) # applique la rotation EXIF
|
||||||
|
if im.mode not in ("RGB", "L"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
im.thumbnail((REGLAGES["px"], REGLAGES["px"]), Image.LANCZOS)
|
||||||
|
im.save(dst, "JPEG", quality=REGLAGES["qualite"], optimize=True, progressive=True)
|
||||||
|
|
||||||
|
|
||||||
|
def reduire_sips(src, dst):
|
||||||
|
subprocess.run(["sips", "-Z", str(REGLAGES["px"]), "-s", "format", "jpeg",
|
||||||
|
"-s", "formatOptions", str(REGLAGES["qualite"]), src, "--out", dst],
|
||||||
|
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
def preparer_photos(voyage):
|
||||||
|
src_dir = os.path.join(voyage, "photos")
|
||||||
|
dst_dir = os.path.join(voyage, "photos_web")
|
||||||
|
photos = lister(src_dir, (".jpg", ".jpeg"))
|
||||||
|
if not photos:
|
||||||
|
print("📸 Photos web : aucune photo.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if Image is not None:
|
||||||
|
reduire = reduire_pillow
|
||||||
|
elif shutil.which("sips"):
|
||||||
|
reduire = reduire_sips
|
||||||
|
else:
|
||||||
|
print("⚠️ Photos web : ni Pillow (pip install pillow) ni sips disponible,"
|
||||||
|
" les pages afficheront les originaux.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
nettoyer_temporaires(dst_dir)
|
||||||
|
a_faire = [n for n in photos if not a_jour(os.path.join(dst_dir, n), os.path.join(src_dir, n))]
|
||||||
|
|
||||||
|
def traiter(nom):
|
||||||
|
src, dst = os.path.join(src_dir, nom), os.path.join(dst_dir, nom)
|
||||||
|
# Écrit sous un nom caché puis renommé : jamais de fichier à moitié écrit
|
||||||
|
# sous le vrai nom. L'extension est gardée, sips s'en sert.
|
||||||
|
tmp = os.path.join(dst_dir, ".tmp-" + nom)
|
||||||
|
try:
|
||||||
|
reduire(src, tmp)
|
||||||
|
os.replace(tmp, dst)
|
||||||
|
return os.path.getsize(src), os.path.getsize(dst)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ {nom} : {e}")
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
return None
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(TRAVAILLEURS) as pool:
|
||||||
|
resultats = [r for r in pool.map(traiter, a_faire) if r]
|
||||||
|
|
||||||
|
msg = f"📸 Photos web : {len(resultats)} réduite(s), {len(photos) - len(a_faire)} déjà à jour"
|
||||||
|
if resultats:
|
||||||
|
avant = sum(r[0] for r in resultats) / 1048576
|
||||||
|
apres = sum(r[1] for r in resultats) / 1048576
|
||||||
|
msg += f" ({avant:.0f} Mo → {apres:.0f} Mo)"
|
||||||
|
print(msg)
|
||||||
|
return len(resultats) == len(a_faire)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aperçus vidéo -----------------------------------------------------------
|
||||||
|
def extraire_apercu(sources, dst):
|
||||||
|
"""Une image à 3 s du début (après les secousses du démarrage), à 0 s si la
|
||||||
|
vidéo est plus courte. Sources essayées dans l'ordre : le 720p se décode
|
||||||
|
bien plus vite, le 1080p sert de secours s'il est abîmé.
|
||||||
|
Renvoie la source utilisée, ou None."""
|
||||||
|
tmp = os.path.join(os.path.dirname(dst), ".tmp-" + os.path.basename(dst))
|
||||||
|
for src in sources:
|
||||||
|
for instant in ("3", "0"):
|
||||||
|
subprocess.run(["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
||||||
|
"-ss", instant, "-i", src, "-frames:v", "1",
|
||||||
|
"-vf", "scale=-2:540", "-q:v", "5", "-f", "mjpeg", tmp],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
if os.path.exists(tmp) and os.path.getsize(tmp) > 0:
|
||||||
|
os.replace(tmp, dst)
|
||||||
|
return src
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def preparer_apercus(voyage):
|
||||||
|
src_dir = os.path.join(voyage, "videos")
|
||||||
|
dst_dir = os.path.join(voyage, "apercus")
|
||||||
|
fichiers = lister(src_dir, (".mp4", ".mov"))
|
||||||
|
if not fichiers:
|
||||||
|
print("🎞️ Aperçus : aucune vidéo.")
|
||||||
|
return True
|
||||||
|
if not shutil.which("ffmpeg"):
|
||||||
|
print("⚠️ Aperçus : ffmpeg introuvable, vidéos sans image d'aperçu.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Une vidéo = un nom de base, avec jusqu'à deux fichiers ; le 720p d'abord.
|
||||||
|
sources = {}
|
||||||
|
for f in fichiers:
|
||||||
|
base = os.path.splitext(f)[0]
|
||||||
|
mobile = base.endswith("_mobile")
|
||||||
|
if mobile:
|
||||||
|
base = base[:-len("_mobile")]
|
||||||
|
liste = sources.setdefault(base, [])
|
||||||
|
liste.insert(0 if mobile else len(liste), os.path.join(src_dir, f))
|
||||||
|
|
||||||
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
nettoyer_temporaires(dst_dir)
|
||||||
|
a_faire = [(srcs, os.path.join(dst_dir, b + ".jpg")) for b, srcs in sorted(sources.items())
|
||||||
|
if not a_jour(os.path.join(dst_dir, b + ".jpg"), srcs[0])]
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(TRAVAILLEURS) as pool:
|
||||||
|
utilisees = list(pool.map(lambda t: extraire_apercu(*t), a_faire))
|
||||||
|
|
||||||
|
for (srcs, _), src in zip(a_faire, utilisees):
|
||||||
|
if src is None:
|
||||||
|
print(f" ❌ aperçu impossible, vidéo illisible : {os.path.basename(srcs[0])}")
|
||||||
|
elif src != srcs[0]:
|
||||||
|
print(f" ⚠️ {os.path.basename(srcs[0])} illisible (encodage interrompu ?),"
|
||||||
|
f" aperçu tiré de {os.path.basename(src)}. Relance compress.sh pour le refaire.")
|
||||||
|
|
||||||
|
ok = [u for u in utilisees if u]
|
||||||
|
print(f"🎞️ Aperçus : {len(ok)} créé(s), {len(sources) - len(a_faire)} déjà à jour")
|
||||||
|
return len(ok) == len(a_faire)
|
||||||
|
|
||||||
|
|
||||||
|
def preparer(voyage, cfg, force=False):
|
||||||
|
"""Photos web et aperçus d'un voyage ; renvoie True si tout a réussi."""
|
||||||
|
REGLAGES.update(px=int(cfg["photos"]["grand_cote"]), qualite=int(cfg["photos"]["qualite"]), force=force)
|
||||||
|
ok = preparer_photos(voyage)
|
||||||
|
return preparer_apercus(voyage) and ok
|
||||||
+175
@@ -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)))
|
||||||
+221
@@ -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)
|
||||||
@@ -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 %}
|
||||||
|
<p class="vide">Aucun voyage pour l'instant.<br>Lance <code>genere_site.sh</code> pour en générer.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="rubriques">
|
||||||
|
{% for r in rubriques %}
|
||||||
|
<section>
|
||||||
|
<div class="rubrique-titre">
|
||||||
|
<h2>{{ r.titre }}</h2>
|
||||||
|
<a href="{{ r.page }}">Tout voir ({{ r.voyages|length }}) →</a>
|
||||||
|
</div>
|
||||||
|
<div class="grid compact">
|
||||||
|
{% for v in r.voyages[:nb_recents] %}
|
||||||
|
{{ carte(v) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if carte_globale %}
|
||||||
|
<section class="carte-externe" id="carte">
|
||||||
|
<div class="rubrique-titre">
|
||||||
|
<h2>La carte de nos principaux voyages</h2>
|
||||||
|
<a href="{{ carte_globale }}">Ouvrir en grand ↗</a>
|
||||||
|
</div>
|
||||||
|
<iframe src="{{ carte_globale }}" loading="lazy" title="Carte de nos principaux voyages"></iframe>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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. -#}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block titre %}{{ titre_site }}{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f4f4f9; margin: 0; padding: 20px; color: #222;
|
||||||
|
}
|
||||||
|
header { text-align: center; margin-bottom: 30px; }
|
||||||
|
h1 { margin: 0 0 6px; font-size: clamp(1.6rem, 4vw, 2.4rem); }
|
||||||
|
.sous-titre { color: #777; font-size: 0.9rem; }
|
||||||
|
main { max-width: 1200px; margin: 0 auto; }
|
||||||
|
a.retour { display: inline-block; margin-bottom: 10px; color: #3498db; text-decoration: none; font-weight: bold; }
|
||||||
|
|
||||||
|
/* Rubriques de l'accueil, côte à côte ; empilées quand la place manque. */
|
||||||
|
.rubriques {
|
||||||
|
display: grid; gap: 40px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(480px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
.rubrique-titre {
|
||||||
|
display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
|
||||||
|
margin: 0 0 16px; padding-bottom: 6px; border-bottom: 2px solid #ddd;
|
||||||
|
}
|
||||||
|
.rubrique-titre h2 { margin: 0; font-size: 1.3rem; }
|
||||||
|
.rubrique-titre a, a.tout-voir { color: #3498db; text-decoration: none; font-weight: bold; font-size: 0.9rem; white-space: nowrap; }
|
||||||
|
a.tout-voir { display: block; margin-top: 14px; text-align: right; }
|
||||||
|
|
||||||
|
/* min() : sur un écran étroit, une colonne fixe déborderait. */
|
||||||
|
.grid { display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(min(var(--carte, 400px), 100%), 1fr)); }
|
||||||
|
.compact { --carte: 220px; }
|
||||||
|
.card {
|
||||||
|
background: #fff; border-radius: 15px; overflow: hidden;
|
||||||
|
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||||
|
transition: transform .2s, box-shadow .2s;
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-4px); box-shadow: 0 14px 28px rgba(0,0,0,0.15); }
|
||||||
|
/* Toute la carte est cliquable ; l'iframe ne capte pas les clics. */
|
||||||
|
.card > a { display: block; color: inherit; text-decoration: none; }
|
||||||
|
.map-container { width: 100%; height: 300px; border: none; pointer-events: none; display: block; }
|
||||||
|
.compact .map-container { height: 190px; }
|
||||||
|
.card-content { padding: 16px 20px 20px; text-align: center; }
|
||||||
|
.card h3 { text-transform: capitalize; margin: 0 0 4px; font-size: 1.2rem; }
|
||||||
|
.compact .card h3 { font-size: 1.05rem; }
|
||||||
|
.periode { color: #888; font-size: 0.85rem; }
|
||||||
|
.carte-externe { margin-top: 50px; scroll-margin-top: 20px; }
|
||||||
|
.carte-externe iframe {
|
||||||
|
display: block; width: 100%; height: min(600px, 75vh); border: none;
|
||||||
|
border-radius: 15px; box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.vide { text-align: center; color: #777; margin-top: 60px; line-height: 1.7; }
|
||||||
|
code { background: #e8e8ef; padding: 2px 6px; border-radius: 4px; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: #16161a; color: #e8e8ea; }
|
||||||
|
.card { background: #232329; box-shadow: 0 10px 20px rgba(0,0,0,0.4); }
|
||||||
|
.sous-titre, .vide, .periode { color: #9a9aa2; }
|
||||||
|
.rubrique-titre { border-color: #333; }
|
||||||
|
code { background: #2e2e36; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>{% block entete %}🌍 {{ titre_site }}{% endblock %}</h1>
|
||||||
|
<div class="sous-titre">{% block sous_titre %}{% endblock %}</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% block contenu %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
{% macro carte(v) -%}
|
||||||
|
<article class="card">
|
||||||
|
<a href="{{ v.chemin }}/index.html">
|
||||||
|
{# loading="lazy" : sinon chaque miniature charge une carte Leaflet complète dès l'ouverture #}
|
||||||
|
<iframe class="map-container" src="{{ v.chemin }}/mini.html" loading="lazy" title="Carte de {{ v.titre }}" tabindex="-1"></iframe>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>{{ v.titre }}</h3>
|
||||||
|
{% if v.periode %}<div class="periode">{{ v.periode }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
Pages d'un voyage : carte générale (index.html) et cartes du jour (html/).
|
||||||
|
Copié dans <voyage>/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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
Pages d'un voyage : vidéos, diaporamas photo et carnet de route.
|
||||||
|
Copié dans <voyage>/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); });
|
||||||
@@ -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) -%}
|
||||||
|
<link rel="stylesheet" href="{{ prefixe }}assets/carte.css?v={{ version }}">
|
||||||
|
<script src="{{ prefixe }}assets/carte.js?v={{ version }}"></script>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro epingle(couleur, icone) -%}
|
||||||
|
<div class="pin" style="--c:{{ couleur }}"><div class="pin-tete"><i class="fa fa-{{ icone }}"></i></div><div class="pin-tige"></div></div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# Popup affichée au clic sur la trace d'une journée (carte générale). #}
|
||||||
|
{% macro popup_trace(jour, couleur, lien) -%}
|
||||||
|
<div class="popup-trace"><b>📅 {{ jour }}</b><br><a class="btn-jour" style="--c:{{ couleur }}" href="{{ lien }}" target="_top">Détails</a></div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# photos : liste de {nom, src (version web), href (original)} #}
|
||||||
|
{% macro popup_photos(photos) -%}
|
||||||
|
<div class="slider-container">
|
||||||
|
<div class="slides">
|
||||||
|
{%- for p in photos %}
|
||||||
|
<div class="slide"{% if not loop.first %} style="display:none"{% endif %}>
|
||||||
|
<div class="file-header">{{ p.nom }}</div>
|
||||||
|
<a href="{{ p.href }}" target="_blank"><img src="{{ p.src }}" alt="{{ p.nom }}" loading="lazy"></a>
|
||||||
|
</div>
|
||||||
|
{%- endfor %}
|
||||||
|
</div>
|
||||||
|
{%- if photos|length > 1 %}
|
||||||
|
<button class="nav-btn prev" onclick="moveSlide(this, -1)">❮</button>
|
||||||
|
<button class="nav-btn next" onclick="moveSlide(this, 1)">❯</button>
|
||||||
|
<div class="slide-counter">1/{{ photos|length }}</div>
|
||||||
|
{%- endif %}
|
||||||
|
</div>
|
||||||
|
{%- 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) -%}
|
||||||
|
<div class="popup-video">
|
||||||
|
<div class="header-row">
|
||||||
|
<span class="file-name" title="{{ nom }}">{{ nom }}</span>
|
||||||
|
<div class="quality-controls">
|
||||||
|
<span class="quality-btn active-720" data-src="{{ mobile }}" onclick="changeQuality(this)">720p</span>
|
||||||
|
<span class="quality-btn" data-hevc="1" data-src="{{ hd }}" onclick="changeQuality(this)">1080p</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<video controls playsinline webkit-playsinline preload="none" src="{{ mobile }}" data-fallback="{{ mobile }}"
|
||||||
|
{%- if apercu %} data-poster="{{ apercu }}"{% endif %}></video>
|
||||||
|
<div class="hevc-note">Le 1080p est encodé en H.265/HEVC, que ce navigateur ne sait pas décoder.
|
||||||
|
La lecture en 720p fonctionne normalement ; pour la haute définition, utilise Safari, Chrome ou Edge.</div>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# contenu : HTML du récit (voyage.md converti), déjà prêt. #}
|
||||||
|
{% macro journal(contenu) -%}
|
||||||
|
<div id="journal-panel">
|
||||||
|
<button class="close-btn" onclick="toggleJournal(false)">×</button>
|
||||||
|
<div class="journal-content">{{ contenu|safe }}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-flottant btn-journal" onclick="toggleJournal(true)">📖 Journal</button>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro bouton_accueil(lien) -%}
|
||||||
|
<a class="btn-flottant btn-accueil" href="{{ lien }}" target="_top">🏠 Accueil</a>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro bouton_gpx(lien, libelle) -%}
|
||||||
|
<a class="btn-flottant btn-gpx" href="{{ lien }}" download>{{ libelle }}</a>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# jours : liste de {libelle, lien, couleur} #}
|
||||||
|
{% macro barre_jours(retour, jours) -%}
|
||||||
|
<nav class="sidebar">
|
||||||
|
<a class="btn-retour" href="{{ retour }}" target="_top">⬅ Retour</a>
|
||||||
|
<div class="sidebar-titre">PAR JOURS</div>
|
||||||
|
{%- for j in jours %}
|
||||||
|
<a class="jour" style="--c:{{ j.couleur }}" href="{{ j.lien }}" target="_top">{{ j.libelle }}</a>
|
||||||
|
{%- endfor %}
|
||||||
|
</nav>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -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) -%}
|
||||||
|
<div style="font-family:sans-serif; text-align:center; min-width:160px">
|
||||||
|
<b style="font-size:14px; text-transform:capitalize">{{ titre }}</b><br>
|
||||||
|
<span style="color:#777; font-size:12px">{{ periode }}{% if km %} · {{ "%.0f"|format(km) }} km{% endif %}</span><br>
|
||||||
|
<a href="{{ lien }}" target="_top" style="display:inline-block; margin-top:10px; padding:7px 14px; border-radius:16px; background:#3498db; color:#fff; text-decoration:none; font-weight:bold">Voir le voyage</a>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro popup_mymaps(nom, description) -%}
|
||||||
|
<div style="font-family:sans-serif; max-height:240px; overflow:auto">
|
||||||
|
<b style="font-size:14px">{{ nom }}</b>
|
||||||
|
{%- if description %}<div style="margin-top:6px; font-size:12px; color:#444">{{ description|liens_et_lignes }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# Visible seulement quand la carte est ouverte seule (voir script_page). #}
|
||||||
|
{% macro bouton_accueil() -%}
|
||||||
|
<a id="retour-accueil" href="index.html" style="display:none; position:fixed; bottom:25px; left:15px; z-index:1000; padding:10px 16px; border-radius:10px; background:#333; color:#fff; font:bold 14px sans-serif; text-decoration:none; box-shadow:0 4px 10px rgba(0,0,0,.4)">🏠 Accueil</a>
|
||||||
|
{%- 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) -%}
|
||||||
|
<span style="display:inline-block; width:18px; height:4px; margin:0 6px 3px 0; border-radius:2px; vertical-align:middle; background:{{ couleur }}"></span><span style="text-transform:capitalize">{{ titre }}</span>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -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 %}
|
||||||
|
<a class="retour" href="index.html">← {{ titre_site }}</a>
|
||||||
|
<div class="grid">
|
||||||
|
{% for v in r.voyages %}
|
||||||
|
{{ carte(v) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
"""Compression des rushs pour le web.
|
||||||
|
|
||||||
|
Pour chaque rush, deux fichiers dans <voyage>/videos :
|
||||||
|
<base>.mp4 1080p HEVC (tag hvc1), environ 40 % plus léger que H.264
|
||||||
|
<base>_mobile.mp4 720p H.264 (tag avc1), chargé par défaut, lisible partout
|
||||||
|
|
||||||
|
Un rush dont les deux sorties existent et sont lisibles est sauté. Les
|
||||||
|
sorties sont écrites sous un nom caché puis renommées : un encodage
|
||||||
|
interrompu ne laisse jamais un fichier abîmé sous le vrai nom.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from rtg.config import parametres_video
|
||||||
|
from rtg.outils import coeurs_performance, dire, exiger, lisible, taille_lisible
|
||||||
|
|
||||||
|
|
||||||
|
def _encodeurs_disponibles():
|
||||||
|
r = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True)
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def verifier_encodeurs(p):
|
||||||
|
liste = _encodeurs_disponibles()
|
||||||
|
besoins = (["hevc_videotoolbox", "h264_videotoolbox"] if p["encodeur"] == "vt"
|
||||||
|
else ["libx265", "libx264"])
|
||||||
|
manquants = [e for e in besoins if e not in liste]
|
||||||
|
if manquants:
|
||||||
|
conseil = ("Choisis l'encodeur « processeur »." if p["encodeur"] == "vt"
|
||||||
|
else "Installe un ffmpeg complet (brew install ffmpeg sous macOS).")
|
||||||
|
raise SystemExit(f"❌ Encodeur(s) absent(s) de ffmpeg : {', '.join(manquants)}. {conseil}")
|
||||||
|
|
||||||
|
|
||||||
|
def repartition(p):
|
||||||
|
"""(jobs, threads HD, threads SD) — même logique que l'ancien compress.sh."""
|
||||||
|
coeurs = coeurs_performance()
|
||||||
|
jobs = p["jobs"] or (1 if p["encodeur"] == "vt" else max(1, coeurs // 4))
|
||||||
|
threads = p["threads"] or max(2, coeurs // jobs)
|
||||||
|
return jobs, threads, max(2, threads // 2), coeurs
|
||||||
|
|
||||||
|
|
||||||
|
def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src, stats):
|
||||||
|
hd = palier == "hd"
|
||||||
|
if hd:
|
||||||
|
vf = "scale=-2:1080:flags=lanczos" if hauteur_src > 1080 else None
|
||||||
|
if p["encodeur"] == "vt":
|
||||||
|
vopts = ["-c:v", "hevc_videotoolbox", "-profile:v", "main", "-q:v", str(p["vtq_hd"]),
|
||||||
|
"-allow_sw", "1", "-realtime", "0", "-tag:v", "hvc1"]
|
||||||
|
else:
|
||||||
|
# keyint=120 : une image-clé toutes les 2 s à 60 fps. pools borne le
|
||||||
|
# pool de threads x265. hvc1 : sans lui, Safari refuse la vidéo.
|
||||||
|
vopts = ["-c:v", "libx265", "-preset", p["preset"], "-profile:v", "main",
|
||||||
|
"-crf", str(p["crf_hevc"]),
|
||||||
|
"-x265-params", f"keyint=120:min-keyint=24:aq-mode=3:pools={threads}",
|
||||||
|
"-maxrate", "8M", "-bufsize", "16M", "-tag:v", "hvc1"]
|
||||||
|
audio = "96k"
|
||||||
|
else:
|
||||||
|
vf = "scale=-2:720:flags=lanczos"
|
||||||
|
if p["encodeur"] == "vt":
|
||||||
|
vopts = ["-c:v", "h264_videotoolbox", "-profile:v", "high", "-q:v", str(p["vtq_sd"]),
|
||||||
|
"-allow_sw", "1", "-realtime", "0"]
|
||||||
|
else:
|
||||||
|
vopts = ["-c:v", "libx264", "-preset", p["preset"], "-profile:v", "high", "-level", "4.0",
|
||||||
|
"-crf", str(p["crf_h264"]), "-g", "120", "-keyint_min", "24",
|
||||||
|
"-threads", str(threads_sd), "-maxrate", "5M", "-bufsize", "10M"]
|
||||||
|
audio = "64k"
|
||||||
|
cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error"]
|
||||||
|
if stats:
|
||||||
|
cmd.append("-stats")
|
||||||
|
cmd += ["-i", src, "-map", "0:v:0", "-map", "0:a:0?"] + vopts
|
||||||
|
if vf:
|
||||||
|
cmd += ["-vf", vf]
|
||||||
|
cmd += ["-pix_fmt", "yuv420p", "-fpsmax", str(p["fps_max"]),
|
||||||
|
"-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
|
||||||
|
"-c:a", "aac", "-b:a", audio, "-ac", "1", "-ar", "48000",
|
||||||
|
"-map_metadata", "-1", "-movflags", "+faststart", "-y", dest]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _hauteur(src):
|
||||||
|
r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||||||
|
"stream=height", "-of", "csv=p=0", src], capture_output=True, text=True)
|
||||||
|
try:
|
||||||
|
return int(r.stdout.strip().splitlines()[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return 1080
|
||||||
|
|
||||||
|
|
||||||
|
def sorties(voyage, src):
|
||||||
|
base = os.path.splitext(os.path.basename(src))[0]
|
||||||
|
dossier = os.path.join(voyage, "videos")
|
||||||
|
return os.path.join(dossier, base + ".mp4"), os.path.join(dossier, base + "_mobile.mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def deja_faite(voyage, src):
|
||||||
|
hd, sd = sorties(voyage, src)
|
||||||
|
return lisible(hd) and lisible(sd)
|
||||||
|
|
||||||
|
|
||||||
|
def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||||||
|
"""Compresse les rushs du voyage. `progression(nom, etat)` est appelée à
|
||||||
|
chaque changement (pour l'interface). Renvoie True si tout a réussi."""
|
||||||
|
videos = analyse.videos
|
||||||
|
os.makedirs(os.path.join(voyage, "videos"), exist_ok=True)
|
||||||
|
p = parametres_video(cfg)
|
||||||
|
exiger("ffmpeg", "Installe-le : https://ffmpeg.org (brew install ffmpeg sous macOS).")
|
||||||
|
exiger("ffprobe", "Il est fourni avec ffmpeg.")
|
||||||
|
verifier_encodeurs(p)
|
||||||
|
jobs, threads, threads_sd, coeurs = repartition(p)
|
||||||
|
|
||||||
|
dire("--- COMPRESSION (HEVC 1080p / H.264 720p) ---")
|
||||||
|
if p["encodeur"] == "vt":
|
||||||
|
dire(f"⚙️ Encodeur : matériel VideoToolbox (q={p['vtq_hd']} / {p['vtq_sd']})")
|
||||||
|
else:
|
||||||
|
dire(f"⚙️ Encodeur : processeur, preset={p['preset']} (HEVC CRF {p['crf_hevc']} / H.264 CRF {p['crf_h264']})")
|
||||||
|
dire(f"🧵 Parallélisme : {jobs} rush(s) à la fois × 2 paliers ({threads} threads en 1080p, "
|
||||||
|
f"{threads_sd} en 720p ; {coeurs} cœur(s) performance)")
|
||||||
|
dire(f"📼 {len(videos)} rush(s)")
|
||||||
|
|
||||||
|
verrou = threading.Lock()
|
||||||
|
bilan = {"faits": 0, "sautes": 0, "echecs": 0, "octets_src": 0, "octets_web": 0}
|
||||||
|
|
||||||
|
def signaler(nom, etat):
|
||||||
|
if progression:
|
||||||
|
progression(nom, etat)
|
||||||
|
|
||||||
|
def un_rush(src):
|
||||||
|
nom = os.path.basename(src)
|
||||||
|
hd, sd = sorties(voyage, src)
|
||||||
|
if not force and lisible(hd) and lisible(sd):
|
||||||
|
with verrou:
|
||||||
|
bilan["sautes"] += 1
|
||||||
|
signaler(nom, "deja_faite")
|
||||||
|
return True
|
||||||
|
h = _hauteur(src)
|
||||||
|
dire(f"🎬 {nom} ({h}p) — encodage des 2 paliers en parallèle…")
|
||||||
|
signaler(nom, "en_cours")
|
||||||
|
tmp_hd = os.path.join(os.path.dirname(hd), ".tmp-" + os.path.basename(hd))
|
||||||
|
tmp_sd = os.path.join(os.path.dirname(sd), ".tmp-" + os.path.basename(sd))
|
||||||
|
stats = jobs == 1
|
||||||
|
procs = [subprocess.Popen(_commande(src, tmp_hd, "hd", p, threads, threads_sd, h, stats)),
|
||||||
|
subprocess.Popen(_commande(src, tmp_sd, "sd", p, threads, threads_sd, h, stats))]
|
||||||
|
codes = [pr.wait() for pr in procs]
|
||||||
|
if codes == [0, 0] and lisible(tmp_hd) and lisible(tmp_sd):
|
||||||
|
os.replace(tmp_hd, hd)
|
||||||
|
os.replace(tmp_sd, sd)
|
||||||
|
with verrou:
|
||||||
|
bilan["faits"] += 1
|
||||||
|
bilan["octets_src"] += os.path.getsize(src)
|
||||||
|
bilan["octets_web"] += os.path.getsize(hd) + os.path.getsize(sd)
|
||||||
|
dire(f" ✅ {nom:<24} {taille_lisible(os.path.getsize(src))} → 1080p "
|
||||||
|
f"{taille_lisible(os.path.getsize(hd))} + 720p {taille_lisible(os.path.getsize(sd))}")
|
||||||
|
signaler(nom, "faite")
|
||||||
|
return True
|
||||||
|
for t in (tmp_hd, tmp_sd):
|
||||||
|
if os.path.exists(t):
|
||||||
|
os.remove(t)
|
||||||
|
with verrou:
|
||||||
|
bilan["echecs"] += 1
|
||||||
|
dire(f" ❌ Échec sur {nom}")
|
||||||
|
signaler(nom, "echec")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Restes d'une exécution interrompue
|
||||||
|
for f in os.listdir(os.path.join(voyage, "videos")):
|
||||||
|
if f.startswith(".tmp-"):
|
||||||
|
os.remove(os.path.join(voyage, "videos", f))
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
with ThreadPoolExecutor(jobs) as pool:
|
||||||
|
resultats = list(pool.map(un_rush, videos))
|
||||||
|
duree = int(time.time() - t0)
|
||||||
|
dire()
|
||||||
|
if bilan["faits"]:
|
||||||
|
dire(f"✅ {bilan['faits']} vidéo(s) encodée(s) en {duree // 60} min {duree % 60:02d} s")
|
||||||
|
dire(f"📊 Rushs : {bilan['octets_src'] // 1048576} Mo → Web : {bilan['octets_web'] // 1048576} Mo "
|
||||||
|
f"({bilan['octets_web'] * 100 / max(bilan['octets_src'], 1):.0f} % du volume source)")
|
||||||
|
else:
|
||||||
|
dire(f"ℹ️ Aucun nouvel encodage ({bilan['sautes']} déjà faite(s))")
|
||||||
|
if bilan["echecs"]:
|
||||||
|
dire(f"⚠️ {bilan['echecs']} échec(s)")
|
||||||
|
return all(resultats)
|
||||||
@@ -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'<trkpt lat="{la}" lon="{lo}"><time>{t.strftime("%Y-%m-%dT%H:%M:%SZ")}</time></trkpt>'
|
||||||
|
for la, lo, t in pts)
|
||||||
|
with open(chemin, "w") as f:
|
||||||
|
f.write(f'<?xml version="1.0"?><gpx version="1.1" creator="test" '
|
||||||
|
f'xmlns="http://www.topografix.com/GPX/1/1"><trk><name>test</name><trkseg>{seg}'
|
||||||
|
f'</trkseg></trk></gpx>')
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Reference in new issue
Block a user