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:
FabriceandClaude Opus 5.5 committed 2026-10-09 11:31:31 +00:00
1 parent 97b936fd3e
commit f0e1c2dfce
23 files changed
+3083 -1

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
"""RoadTripsGenerator (RTG) : site web statique de road-trips."""
__version__ = "0.9.0"
+3
View File
@@ -0,0 +1,3 @@
from rtg.cli import main
main()
+164
View File
@@ -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})")
+379
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+183
View File
@@ -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 ""))
+86
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+37
View File
@@ -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 %}
+90
View File
@@ -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 %}
+131
View File
@@ -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;
}
+183
View File
@@ -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); });
+95
View File
@@ -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)">&#10094;</button>
<button class="nav-btn next" onclick="moveSlide(this, 1)">&#10095;</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)">&times;</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 %}
+41
View File
@@ -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 %}
+16
View File
@@ -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
View File
@@ -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)