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
@@ -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)
|
||||
Reference in new issue
Block a user