Refonte : templates, balades, photos web, aperçus, carte globale

- Mise en page sortie du Python : Scripts/templates/ (Jinja2 + CSS/JS
  communs copiés dans assets/ de chaque voyage)
- Voyages rangés dans un sous-dossier (Balades/…) : rubriques sur
  l'accueil, 2 plus récents par rubrique, pages voyages.html et
  balades.html
- prepare_web.py : photos allégées (photos_web/) et images d'aperçu
  des vidéos (apercus/)
- Voyage d'une seule journée : la page du voyage est la carte du jour
- Carte globale de l'accueil (OpenStreetMap) : voyages du site + cartes
  Google My Maps récupérées (mymaps.txt, _mymaps/), moto par voyage
  (moto.txt)
- KML par voyage pour My Maps
- compress.sh : réencode une sortie illisible (encodage interrompu) ;
  repli automatique sur l'autre qualité dans la page
- Corrections : CSV vide (voyage sans photo), racine de genere_site.sh,
  fichiers ._ de macOS
- README réécrit, commentaires allégés

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:08:01 +00:00
1 parent 5a04f159ca
commit 97b936fd3e
17 files changed
+2028 -815

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
__pycache__/
.DS_Store
+86 -91
View File
@@ -1,145 +1,140 @@
Ce repo *RoadTripsGenerator* est un ensemble de scripts (bash et python) permettant **de** générer automatiquement des sites web complets avec cartes interactives à partir de vos photos, vidéos, trace GPS et récits de voyages.
*RoadTripsGenerator* est un ensemble de scripts (bash et Python) qui génèrent un site web statique de road-trips : une carte interactive par voyage et par jour, avec les photos, les vidéos, les traces GPS et le récit du voyage.
C'est sans prétention et il faudrait probablement faire des développements bien plus sérieux pour obtenir un logiciel "pousse boutons" agréable à utiliser, mais si cela peut donner des idées...
C'est sans prétention et il faudrait probablement des développements bien plus sérieux pour obtenir un logiciel « presse-bouton », mais si cela peut donner des idées…
Je me suis en effet rendu compte que je passais des heures à faire des montages vidéo de mes road trips pour faire des films que finalement je ne regardais pas moi-**même** : trop longs **mais** pas assez d'infos, pas interactif, etc.
Alors je me suis demandé sous quelle forme il faudrait mettre en forme mes road trips, pour pouvoir me les remémorer en famille et les faire partager à d'autres.
C'est comme **cela** que ce petit projet a vu le jour.
Je me suis rendu compte que je passais des heures à monter des films de mes road-trips que finalement je ne regardais pas moi-même : trop longs mais pas assez d'infos, pas interactifs, etc. Je me suis donc demandé sous quelle forme présenter mes voyages pour pouvoir me les remémorer en famille et les partager. C'est comme cela que ce petit projet a vu le jour.
## Le principe
Le principe est très simple : je possède des photos et vidéos avec informations GPS de position et de temps (dans mon cas : photos iPhone et films GOPRO), des traces ou parcours GPS quotidiens et un récit écrit en Markdown.
Pour chaque voyage, je dispose de photos et de vidéos géolocalisées (photos iPhone, films GoPro), des traces GPS de chaque journée et d'un récit écrit en Markdown.
Les scripts vont alors générer automatiquement **une** page html avec une carte interactive **permettant** de visualiser les photos, les vidéos **et** les traces GPS du road trip. Voir par exemple mon [road trip de l'été 2025](https://roadtrip.ouaibe.fr/Ete-2025/).
Les scripts lisent les positions GPS des photos et des vidéos, les placent sur une carte avec la trace du parcours découpée par jour, et construisent des pages web où l'on peut, en parallèle, lire le récit du voyage. Voir par exemple mon [road-trip de l'été 2025](https://roadtrip.ouaibe.fr/Ete-2025/).
Les scripts récupèrent les coordonnées GPS des photos, **vidéos** et les placent sur une carte avec la trace du parcours, assurant un découpage quotidien pour marquer les étapes**,** et construisent des pages web interactives **où** l'on peut, en parallèle, consulter le récit du voyage.
Ils préparent aussi les médias pour le web :
Les scripts s'occupent également éventuellement de compresser les films pour alourdir le moins possible le chargement des pages. Actuellement la compression produit **du** 1080p en 60 images par seconde de haute qualité (10Mbps) pour une exécution locale optimale mais peut être trop lourde pour **une** diffusion sur le web. Les vidéos ainsi compressées occupent environ 25% de leur place initiale.
* **vidéos** : un 720p H.264 lisible partout, chargé par défaut, et un 1080p HEVC proposé à la demande (environ 40 % plus léger que du H.264 à qualité égale) ;
* **photos** : une version allégée (1600 px, quelques centaines de Ko) affichée dans les diaporamas, le clic ouvrant l'original ;
* **aperçus** : une image tirée de chaque vidéo, affichée avant la lecture.
Les sites produits étant statiques, une fois générés ils sont facilement **transportables** par simple copie.
Les originaux ne sont jamais modifiés. Le site produit est statique : une simple copie suffit pour le publier ou le transporter.
## Les prérequis
Normalement les scripts doivent fonctionner très facilement sous OSX et Linux, probablement facilement dans un Windows PowerShell ou dans un environnement Cygwin, mais je n'ai pas testé.
Il faudra installer toutefois :
Les scripts fonctionnent sous macOS et Linux.
* obligatoirement :
* python3 et ses packages pandas, folium, **gpxpy**, markdown2 et **numpy**
* exiftool
* optionnellement
* ffmpeg (pour la compression des MP4)
## Préparation des données
Je crée un **répertoire** pour l'ensemble de mes roadtrips à l'**intérieur** duquel je place le sous-**répertoire** `Scripts` de ce dépôt.
Puis, pour chaque road-trip je crée un sous-**répertoire**, par exemple Ete-2025, Ete-2024, etc.
Dans le sous-**répertoire** de chaque road-trip, je crée :
* un sous-**répertoire** `photos` avec mes photos
* un sous-**répertoire** `rushs` avec mes vidéos
* un sous-**répertoire** `routes` avec mes traces GPS **quotidiennes** dans des fichiers au format gpx que je nomme AAAA-MM-JJ.gpx (par exemple 2025-08_02.gpx)
* un fichier `voyage.md` avec le récit du voyage au format markdown
Sur mon disque, je dois voir une structure qui ressemble à :
* Python 3 et les paquets `pandas folium gpxpy markdown2 numpy` (Jinja2, utilisé pour la mise en page, est installé avec folium)
* `exiftool`
* conseillé :
* `ffmpeg` (compression des vidéos et images d'aperçu)
* `pillow` pour les photos allégées (sous macOS, l'outil `sips` du système prend le relais s'il est absent)
```
MesVoyages
|-Scripts
|-Ete-2025
|-photos
|-rushs
|-routes
voyage.md
|-Ete-2024
...
brew install exiftool ffmpeg
pip3 install pandas folium gpxpy markdown2 numpy pillow
```
## Organisation des dossiers
Le dépôt et le site sont deux dossiers voisins. Un voyage est un dossier qui contient au moins un des sous-dossiers `photos`, `rushs` ou `routes`. Les voyages rangés à la racine du site forment la rubrique « Voyages » de la page d'accueil. Ceux rangés un niveau plus bas, dans un dossier de regroupement (ici `Balades`), forment une rubrique du nom de ce dossier. L'accueil montre les 2 plus récents de chaque rubrique (`ACCUEIL_NB=3` pour en montrer plus), avec un lien vers la liste complète.
```
CLEROADTRIPS
|-RoadTripsGenerator ce dépôt
| |-Scripts
| |-templates mise en page (HTML, CSS, JavaScript)
|-Road-Trips le site
|-Ete-2025
| |-photos photos (JPEG)
| |-rushs vidéos d'origine (peut être un lien vers un disque externe)
| |-routes une trace GPX par jour, date dans le nom : 2025-08-02.gpx
| |-voyage.md le récit
|-Balades
|-Aisne-06-2026
|-...
```
Dans le récit `voyage.md`, deux raccourcis évitent les liens écrits en dur (qui ne marcheraient pas à la fois en local et en ligne) :
```
[Carte du jour](jour:2025-08-02)
[Le col](media:photos/IMG_4831.jpeg)
[La descente](media:videos/GH011272.mp4)
```
## Mode d'emploi
Une fois les données préparées, tout est simple, mais un site peut être long à générer à cause de l'étape de compression.
### Tout le site, ou quelques voyages
### 1 road trip (3 ou 4 commandes selon que l'on compresse ou non les vidéos)
Par exemple [https://roadtrip.ouaibe.fr/Ete-2025](https://roadtrip.ouaibe.fr/Ete-2025)
On se place dans le **répertoire** du road-trip que l'on veut générer (celui qui contient les **répertoires** photos, rushs, etc.) et on lance :
Depuis le dossier du site :
```
../Scripts/extraction_infos.sh
cd Road-Trips
../RoadTripsGenerator/Scripts/genere_site.sh # tous les voyages
../RoadTripsGenerator/Scripts/genere_site.sh Balades/Vexin-10-2026 # un seul
SKIP_COMPRESS=1 ../RoadTripsGenerator/Scripts/genere_site.sh # sans réencoder les vidéos
```
Cela produit deux fichiers : `export_photos.csv` et `export_videos.csv` qui contiennent les extractions d'infos GPS des photos et **vidéos**.
Pour chaque voyage, `genere_site.sh` enchaîne les étapes ci-dessous, puis régénère la page d'accueil et la carte globale. Un voyage d'une seule journée (une balade, souvent) n'a pas de carte générale intermédiaire : sa page est directement la carte du jour, avec photos et vidéos. Chaque étape ne refait que ce qui a changé : relancer le script est rapide, sauf quand de nouveaux rushs sont à encoder.
Vient ensuite la compression optionnelle des **vidéos** qui peut être assez longue mais que je conseille vivement, même pour une utilisation locale.
### Étape par étape
Depuis le dossier d'un voyage (ici `Road-Trips/Balades/Vexin-10-2026`, d'où les `../../../`) :
| Commande | Rôle | Produit |
|---|---|---|
| `../../../RoadTripsGenerator/Scripts/extraction_infos.sh` | positions GPS des photos et vidéos | `export_photos.csv`, `export_videos.csv` |
| `../../../RoadTripsGenerator/Scripts/compress.sh` | vidéos web (long) | `videos/` |
| `../../../RoadTripsGenerator/Scripts/prepare_web.py` | photos allégées, aperçus | `photos_web/`, `apercus/` |
| `../../../RoadTripsGenerator/Scripts/genere_carte.py` | pages du voyage | `index.html`, `mini.html`, `html/`, `assets/`, `<voyage>.kml` |
Puis, depuis le dossier du site, la page d'accueil :
```
../Scripts/compress.sh
../RoadTripsGenerator/Scripts/genere_index_general.py
```
Les **vidéos** compressées se trouveront alors dans le répertoire `videos` au **même** niveau que le **répertoire** `rushs`.
Sans compression, il faut faire de `videos` un lien vers `rushs` : `ln -s rushs videos`.
Si votre choix est de ne pas compresser, alors il faut faire un lien symbolique `videos` sur le sous-**répertoire** `rushs` :
### Réglages utiles
```
ln -s rushs videos
* `compress.sh` : `ENCODER=vt` (encodeur matériel du Mac, bien plus rapide, un peu moins bon à taille égale), `JOBS=N`, `CRF_HEVC`, `CRF_H264`, `FORCE=1` pour tout réencoder. Une vidéo déjà encodée mais illisible (encodage interrompu) est refaite automatiquement.
* `prepare_web.py` : `WEB_PX=1600` (taille des photos), `WEB_QUALITE=80`, `FORCE=1`.
* `bench_crf.sh` : mesure la qualité (VMAF ou SSIM) d'un extrait de rush à plusieurs CRF, pour choisir `CRF_HEVC` / `CRF_H264` sur ses propres images.
```
### Carte globale de l'accueil
Vient alors la génération de la carte :
Sous les rubriques, l'accueil affiche une grande carte (`carte_globale.html`, fond OpenStreetMap, sans Google) qui réunit le contenu récupéré de nos cartes Google My Maps et les voyages du site (rubrique « Voyages »), un calque et une couleur par voyage ; un clic sur une trace mène au voyage. Un fichier `moto.txt` (une ligne) dans un voyage ajoute la moto à son nom dans la légende, par exemple « Ete 2026 (BMW R 1300 GS Adventure) ».
```
../Scripts/genere_carte.py
On peut y ajouter des cartes Google My Maps : mettre leurs liens, un par ligne, dans `mymaps.txt` à la racine du site (les cartes doivent être partagées en « Tous les utilisateurs disposant du lien »). Le KML de chaque carte est récupéré une seule fois dans `_mymaps/` ; ensuite la carte ne dépend plus de Google (`MYMAPS_MAJ=1` pour le reprendre après avoir modifié la carte Google). On peut aussi déposer à la main des exports `.kml` ou `.kmz` dans `_mymaps/`. Chaque calque My Maps devient un calque de la carte, avec ses couleurs d'origine.
Chaque voyage produit aussi un `<voyage>.kml` (tout le voyage en une seule trace, avec sa distance et un lien vers sa page, sous les 5 Mo acceptés par My Maps), pour qui voudrait l'importer dans une carte My Maps. Régler l'adresse publique du site avec `SITE_URL` (par défaut `https://roadtrip.ouaibe.fr/`) pour des liens corrects.
```
### Modifier l'apparence
### **Générer** un index de road-trips
Toute la mise en page est dans `Scripts/templates/` :
Par exemple [https://roadtrip.ouaibe.fr](https://roadtrip.ouaibe.fr)
* `carte.css`, `carte.js` : style et comportement des pages de voyage (copiés dans `assets/` de chaque voyage à la génération) ;
* `fragments.html` : les morceaux de HTML (popups photo et vidéo, journal, boutons, barre des jours) ;
* `fragments_globale.html` : les popups et la légende de la carte globale ;
* `base_accueil.html`, `accueil.html`, `liste.html` : la page d'accueil (rubriques Voyages et Balades, les 2 plus récents de chacune) et les listes complètes `voyages.html`, `balades.html`.
On se place dans le **répertoire** dont les sous-**répertoires** sont les road-trips et on lance :
Après une modification, `SKIP_COMPRESS=1 ../RoadTripsGenerator/Scripts/genere_site.sh` régénère tout en quelques dizaines de secondes.
```
./Scripts/genere_index_general.py
## Publier
```
Il suffit de copier le dossier du site. Les sous-dossiers `rushs` (originaux, très lourds) et les fichiers `export_*.csv` sont inutiles en ligne ; `photos` reste nécessaire pour l'ouverture des originaux.
### Tout automatique
En fait si on a bien respecté l'arborescence des **répertoires**, on peut automatiser la **génération** de tous les road-trips présents en lançant :
```
./Scripts/genere_site.sh
```
Il conviendra de désactiver dans le script le lancement du script "compress.sh" si on ne désire pas compresser (attention par contre à bien mettre un lien dynamique sur les répertoires `rushs`, comme expliqué plus haut).
## Contribuer :
## Contribuer
### Pour pouvoir faire des push :
```
git remote set-url origin https://<nom d'utilisateur>n@git.ouaibe.fr/Korrigan/RoadTripsGenerator.git
git remote set-url origin https://<nom d'utilisateur>@git.ouaibe.fr/Korrigan/RoadTripsGenerator.git
```
Pour **éviter** de re-taper le mot de passe pendant une heure pour les
autres push :
Pour éviter de retaper le mot de passe pendant une heure pour les autres push :
```
git config --global credential.helper 'cache --timeout=3600'
```
+1 -2
View File
@@ -4,7 +4,7 @@
#
# Encode un même extrait de rush à plusieurs CRF et mesure la qualité
# objective (VMAF si dispo, sinon SSIM) + la taille obtenue.
# Objectif : choisir CRF_HD / CRF_SD sur TES images plutôt qu'au doigt mouillé.
# Objectif : choisir CRF_HEVC / CRF_H264 de compress.sh sur TES images.
#
# Usage :
# ./bench_crf.sh rushs/GH011182.MP4 [start_sec] [duree_sec]
@@ -95,7 +95,6 @@ echo "🎯 Palier testé : ${HEIGHT}p en $(echo "$CODEC" | tr a-z A-Z)"
printf "%-6s %10s %12s %10s %10s\n" "CRF" "Taille" "Débit" "$(echo "$METRIC" | tr a-z A-Z)" "Encodage"
printf "%s\n" "--------------------------------------------------------"
BEST_LINE=""
for crf in $CRFS; do
OUT="$WORK/test_crf${crf}.mp4"
+57 -95
View File
@@ -1,51 +1,37 @@
#!/bin/bash
# =============================================================================
# COMPRESSION ROAD-TRIP — STRATÉGIE HYBRIDE + ENCODAGE PARALLÈLE
# Source : GoPro Hero 9, rushs en HEVC 1080p
# COMPRESSION DES RUSHS POUR LE WEB
#
# Sorties (noms de fichiers INCHANGÉS par rapport à l'ancien script) :
# <base>.mp4 1080p natif, HEVC/H.265, tag hvc1
# → gros fichier, là où les 40 % d'économie comptent
# <base>_mobile.mp4 720p, H.264, tag avc1, profil High
# → fichier chargé par défaut, se lit sur TOUS
# les navigateurs sans exception
# Pour chaque rush de <voyage>/rushs, deux fichiers dans <voyage>/videos :
# <base>.mp4 1080p HEVC (tag hvc1) — qualité, ~40 % plus léger que H.264
# <base>_mobile.mp4 720p H.264 (tag avc1) — chargé par défaut, lisible partout
#
# PARALLÉLISME
# Deux niveaux, tous deux actifs par défaut :
# 1. Les deux paliers d'un même rush sont indépendants → lancés ensemble.
# 2. JOBS rushs traités simultanément (pool géré par xargs -P).
# Le nombre de threads par encodeur est borné pour ne pas surcharger :
# x265 sature vite (scaling sublinéaire au-delà de ~6-8 threads), donc
# plusieurs encodages courts valent mieux qu'un seul très parallélisé.
# Un rush dont les deux sorties existent et sont lisibles est sauté
# (FORCE=1 pour refaire).
#
# Variables d'environnement :
# JOBS=N nombre de rushs en parallèle
# (défaut : cœurs performance / 4, minimum 1)
# Variables :
# JOBS=N rushs encodés en parallèle (défaut : cœurs performance / 4)
# THREADS=N threads par encodeur (défaut : cœurs / JOBS)
# ENCODER=cpu (défaut) libx265 + libx264 — meilleure qualité/octet
# ENCODER=vt VideoToolbox — ~10x plus rapide sur Apple Silicon
# (⚠️ le parallélisme n'y sert à rien : le bloc média
# de la puce est une ressource fixe → JOBS=1 forcé)
# PRESET=medium preset CPU : ultrafast…placebo
# CRF_HEVC=26 qualité du 1080p HEVC (échelle x265)
# CRF_H264=25 qualité du 720p H.264 (échelle x264 — PAS la même !)
# ENCODER=cpu libx265 + libx264, meilleure qualité par octet (défaut)
# ENCODER=vt VideoToolbox, ~10× plus rapide sur Apple Silicon, un peu
# moins bon à taille égale ; JOBS=1 (un seul bloc matériel)
# PRESET=medium preset x264/x265
# CRF_HEVC=26 qualité du 1080p (échelle x265)
# CRF_H264=25 qualité du 720p (échelle x264, PAS comparable à x265)
# VTQ_HD=60 VTQ_SD=58 qualité VideoToolbox (1-100, plus haut = meilleur)
# FPS_MAX=60 plafonne le framerate (un rush 30p reste en 30p,
# un rush 120p/240p est ramené à 60)
# FORCE=1 réencode même si le fichier de sortie existe
# FPS_MAX=60 plafond de fréquence d'images (un rush 120p est ramené à 60)
# FORCE=1 réencode même si les sorties existent
#
# Exemples :
# ./compress.sh mon-voyage
# JOBS=4 ./compress.sh mon-voyage
# JOBS=1 ./compress.sh mon-voyage # séquentiel + barre de progression
# ENCODER=vt ./compress.sh mon-voyage
# Exemples (depuis le dossier du voyage) :
# ../../RoadTripsGenerator/Scripts/compress.sh
# JOBS=1 ../../RoadTripsGenerator/Scripts/compress.sh # avec barre de progression
# ENCODER=vt ../../RoadTripsGenerator/Scripts/compress.sh
# =============================================================================
set -uo pipefail
shopt -s nocaseglob nullglob
# --- 0. MODE INTERNE ---------------------------------------------------------
# Quand le script est rappelé par xargs pour traiter UN rush, il arrive ici.
# --- Mode interne : rappelé par xargs pour traiter UN rush ---------------------
if [ "${1:-}" = "--encode-one" ]; then
INTERNAL=1
BASE_DIR="${RT_BASE_DIR:-.}"
@@ -58,7 +44,7 @@ fi
SOURCE_DIR="$BASE_DIR/rushs"
DEST_DIR="$BASE_DIR/videos"
# --- 1. CONFIGURATION --------------------------------------------------------
# --- Configuration -----------------------------------------------------------
ENCODER="${ENCODER:-cpu}"
PRESET="${PRESET:-medium}"
CRF_HEVC="${CRF_HEVC:-26}"
@@ -68,9 +54,8 @@ VTQ_SD="${VTQ_SD:-58}"
FPS_MAX="${FPS_MAX:-60}"
FORCE="${FORCE:-0}"
# Détection des cœurs. Sur Apple Silicon on ne compte que les cœurs
# PERFORMANCE : les cœurs d'efficience sont si lents qu'ils deviennent des
# traînards dans un pool x265 et pénalisent l'encodage entier.
# Sur Apple Silicon on ne compte que les cœurs performance : les cœurs
# d'efficience ralentiraient tout le pool de threads x265.
detect_cores() {
local c=""
c=$(sysctl -n hw.perflevel0.logicalcpu 2>/dev/null); [ -n "$c" ] && { echo "$c"; return; }
@@ -80,8 +65,6 @@ detect_cores() {
}
CORES="${CORES:-$(detect_cores)}"
# En VideoToolbox, le parallélisme ne sert à rien (un seul moteur d'encodage
# matériel sur les puces M de base) — sauf si l'utilisateur force JOBS.
JOBS_EXPLICIT=0
[ -n "${JOBS+x}" ] && JOBS_EXPLICIT=1
if [ "$ENCODER" = "vt" ] && [ "$JOBS_EXPLICIT" = "0" ]; then
@@ -89,64 +72,49 @@ if [ "$ENCODER" = "vt" ] && [ "$JOBS_EXPLICIT" = "0" ]; then
else
JOBS="${JOBS:-$(( CORES / 4 ))}"
fi
[ "$JOBS" -lt 1 ] 2>/dev/null && JOBS=1
[ -z "$JOBS" ] && JOBS=1
[ "$JOBS" -lt 1 ] 2>/dev/null && JOBS=1
THREADS="${THREADS:-$(( CORES / JOBS ))}"
[ "$THREADS" -lt 2 ] 2>/dev/null && THREADS=2
# Le palier 720p H.264 est 3 à 5× plus rapide que le 1080p HEVC : on lui donne
# moitié moins de threads. Il finit largement avant, et la fenêtre pendant
# laquelle les deux paliers tournent ensemble ne surcharge pas la machine.
# Le 720p H.264 est 3 à 5× plus rapide que le 1080p HEVC : moitié moins de threads.
THREADS_SD=$(( THREADS / 2 ))
[ "$THREADS_SD" -lt 2 ] && THREADS_SD=2
# --- 2. FONCTION D'ENCODAGE --------------------------------------------------
# --- Encodage d'un palier ----------------------------------------------------
# encode <entrée> <sortie> <filtre_vf|none> <hevc|h264> <crf> <vtq> <maxrate> <bufsize> <audio>
encode() {
local in="$1" out="$2" vf="$3" codec="$4" crf="$5" vtq="$6" maxrate="$7" bufsize="$8" abr="$9"
local -a vopts vf_args statopt
if [ "$vf" = "none" ]; then vf_args=(); else vf_args=(-vf "$vf"); fi
# Barre de progression uniquement en séquentiel : à plusieurs jobs, les
# sorties de progression s'entremêlent et deviennent illisibles.
# Progression seulement en séquentiel : à plusieurs jobs, les lignes s'entremêlent.
if [ "$JOBS" = "1" ]; then statopt=(-stats); else statopt=(); fi
# ⚠️ Les tableaux VIDES sont développés avec l'idiome ${a[@]+"${a[@]}"}.
# Le /bin/bash d'Apple est en 3.2, où `"${vide[@]}"` sous `set -u` provoque
# une erreur "unbound variable" (corrigé seulement en bash 4.4).
if [ "$codec" = "hevc" ]; then
if [ "$ENCODER" = "vt" ]; then
vopts=(-c:v hevc_videotoolbox -profile:v main -q:v "$vtq" -allow_sw 1 -realtime 0 -tag:v hvc1)
else
vopts=(
-c:v libx265 -preset "$PRESET" -profile:v main -crf "$crf"
# keyint=120 -> 1 image-clé toutes les 2 s à 60 fps.
# scenecut laissé ACTIF (défaut) : un GOP strictement fixe n'est
# utile qu'en streaming segmenté, pas pour du MP4 progressif.
# pools=N borne le pool WPP pour cohabiter avec les autres jobs.
# keyint=120 : une image-clé toutes les 2 s à 60 fps.
# pools=N borne le pool de threads x265 pour cohabiter avec les autres jobs.
# -tag:v hvc1 : sans lui ffmpeg écrit « hev1 », que Safari refuse.
vopts=(-c:v libx265 -preset "$PRESET" -profile:v main -crf "$crf"
-x265-params "keyint=120:min-keyint=24:aq-mode=3:pools=$THREADS"
-maxrate "$maxrate" -bufsize "$bufsize"
# -tag:v hvc1 est INDISPENSABLE : sans lui ffmpeg écrit 'hev1',
# que Safari et QuickTime refusent de lire.
-tag:v hvc1
)
-maxrate "$maxrate" -bufsize "$bufsize" -tag:v hvc1)
fi
else
if [ "$ENCODER" = "vt" ]; then
vopts=(-c:v h264_videotoolbox -profile:v high -q:v "$vtq" -allow_sw 1 -realtime 0)
else
vopts=(
-c:v libx264 -preset "$PRESET" -profile:v high -level 4.0 -crf "$crf"
-g 120 -keyint_min 24
-threads "$THREADS_SD"
-maxrate "$maxrate" -bufsize "$bufsize"
)
vopts=(-c:v libx264 -preset "$PRESET" -profile:v high -level 4.0 -crf "$crf"
-g 120 -keyint_min 24 -threads "$THREADS_SD"
-maxrate "$maxrate" -bufsize "$bufsize")
fi
fi
# ${a[@]+"${a[@]}"} : un tableau vide sous `set -u` fait planter le bash 3.2
# livré avec macOS.
ffmpeg -nostdin -hide_banner -loglevel error ${statopt[@]+"${statopt[@]}"} \
-i "$in" \
-map 0:v:0 -map 0:a:0\? \
@@ -163,7 +131,14 @@ encode() {
fsize() { stat -f%z "$1" 2>/dev/null || stat -c%s "$1"; }
# --- 3. TRAITEMENT D'UN RUSH -------------------------------------------------
# Une sortie existante n'est gardée que si ffprobe sait la lire : un encodage
# interrompu laisse un gros fichier sans index (« moov atom not found »).
# Lecture rapide, l'index est en tête de fichier grâce à +faststart.
lisible() {
[ -s "$1" ] && ffprobe -v error -show_entries format=duration -of csv=p=0 "$1" >/dev/null 2>&1
}
# --- Traitement d'un rush : les deux paliers en parallèle --------------------
process_one() {
local f="$1"
local filename base desktop_out mobile_out src_h vf_hd
@@ -172,23 +147,18 @@ process_one() {
desktop_out="$DEST_DIR/${base}.mp4"
mobile_out="$DEST_DIR/${base}_mobile.mp4"
if [ "$FORCE" != "1" ] && [ -s "$desktop_out" ] && [ -s "$mobile_out" ]; then
if [ "$FORCE" != "1" ] && lisible "$desktop_out" && lisible "$mobile_out"; then
echo "⏭️ $filename — déjà encodé (FORCE=1 pour refaire)"
return 0
fi
src_h=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$f" 2>/dev/null | head -1)
src_h=${src_h:-1080}
# Rushs déjà en 1080p : pas de rééchelonnage, on garde le pixel natif.
# Si un rush est plus grand (2.7K/4K), on redescend proprement à 1080p.
# 1080p natif conservé tel quel ; une source 2.7K/4K est ramenée à 1080p.
if [ "$src_h" -gt 1080 ]; then vf_hd="scale=-2:1080:flags=lanczos"; else vf_hd="none"; fi
echo "🎬 $filename (${src_h}p) — encodage des 2 paliers en parallèle…"
# Les deux paliers sont indépendants : même source, sorties distinctes.
# On les lance ensemble — le 720p H.264, bien plus léger, se glisse dans
# les trous laissés par le 1080p HEVC.
local pid_hd pid_sd rc_hd rc_sd
encode "$f" "$desktop_out" "$vf_hd" hevc "$CRF_HEVC" "$VTQ_HD" 8M 16M 96k &
pid_hd=$!
@@ -210,7 +180,6 @@ process_one() {
fi
}
# Point de sortie du mode interne (un seul rush, appelé par xargs)
if [ "$INTERNAL" = "1" ]; then
process_one "$TARGET"
exit $?
@@ -225,9 +194,8 @@ if [ ! -d "$SOURCE_DIR" ]; then
fi
mkdir -p "$DEST_DIR"
# Vérification des encodeurs disponibles.
# (on stocke la liste : un `| grep -q` déclencherait un SIGPIPE que pipefail
# transformerait en faux négatif)
# Liste stockée plutôt que `ffmpeg … | grep -q` : le SIGPIPE de grep -q,
# combiné à pipefail, donnerait un faux « encodeur absent ».
ENCODERS_LIST="$(ffmpeg -hide_banner -encoders 2>/dev/null)"
has_enc() { printf '%s' "$ENCODERS_LIST" | grep -q "$1"; }
@@ -239,10 +207,12 @@ else
has_enc "libx264" || { echo "❌ libx264 absent de ton ffmpeg (brew install ffmpeg)."; exit 1; }
fi
# Liste des rushs
files=()
for f in "$SOURCE_DIR"/*.mp4 "$SOURCE_DIR"/*.mov; do
[ -e "$f" ] && files+=("$f")
[ -e "$f" ] || continue
# « ._nom » : métadonnées que macOS dépose sur les disques non-HFS (exFAT).
case "$(basename "$f")" in ._*) continue ;; esac
files+=("$f")
done
echo "--- COMPRESSION ROAD-TRIP (HYBRIDE HEVC / H.264) ---"
@@ -250,10 +220,6 @@ echo "📂 Source : $SOURCE_DIR"
echo "📤 Destination : $DEST_DIR"
if [ "$ENCODER" = "vt" ]; then
echo "⚙️ Encodeur : VideoToolbox (q=$VTQ_HD / $VTQ_SD)"
[ "$JOBS" = "1" ] && [ "$JOBS_EXPLICIT" = "0" ] && \
echo " ℹ️ JOBS=1 : le bloc média de la puce est une ressource fixe,"
[ "$JOBS" = "1" ] && [ "$JOBS_EXPLICIT" = "0" ] && \
echo " paralléliser ne ferait que mettre les jobs en file d'attente."
else
echo "⚙️ Encodeur : CPU preset=$PRESET (HEVC CRF $CRF_HEVC / H.264 CRF $CRF_H264)"
fi
@@ -272,14 +238,13 @@ RT_STATS="$(mktemp "${TMPDIR:-/tmp}/rtstats.XXXXXX")"
trap 'rm -f "$RT_STATS"' EXIT
SCRIPT_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
export RT_BASE_DIR="$BASE_DIR" RT_STATS
export ENCODER PRESET CRF_HEVC CRF_H264 VTQ_HD VTQ_SD FPS_MAX FORCE JOBS THREADS THREADS_SD CORES
T0=$(date +%s)
# xargs -P gère le pool de jobs : portable (y compris le bash 3.2 d'Apple,
# qui ne connaît pas `wait -n`) et il relance un job dès qu'un autre se termine.
# xargs -P gère le pool de jobs : portable, y compris avec le bash 3.2 de macOS
# qui ne connaît pas `wait -n`.
printf '%s\0' "${files[@]}" | xargs -0 -P "$JOBS" -n1 "$SCRIPT_PATH" --encode-one
rc=$?
@@ -305,8 +270,5 @@ echo
echo "🔍 Contrôle des tags (attendu : hvc1 pour les 1080p, avc1 pour les _mobile) :"
echo " ffprobe -v error -select_streams v:0 -show_entries stream=codec_tag_string \\"
echo " -of csv=p=0 $DEST_DIR/*.mp4"
echo
echo "💡 Pour trouver ton JOBS optimal, chronomètre 3-4 rushs à JOBS=1, 2 puis 4 :"
echo " le gain plafonne vite, et au-delà le cache thrashe."
exit $rc
+27 -45
View File
@@ -1,20 +1,18 @@
#!/bin/bash
# =============================================================================
# EXTRACTION DES MÉTADONNÉES — photos EXIF + traces GPS embarquées GoPro
# EXTRACTION DES POSITIONS GPS — photos (EXIF) et rushs GoPro (GPS embarqué)
#
# Produit deux CSV sans en-tête, consommés par genere_carte.py :
# Produit deux CSV sans en-tête, lus par genere_carte.py :
# export_photos.csv : fichier, date, lat, lon, alt
# export_videos.csv : fichier, date GPS, lat, lon, alt, vitesse
#
# Usage : ./extraction_infos.sh [dossier_voyage]
#
# Variables :
# VIDEO_GPS=first (défaut) un seul point GPS par rush — RAPIDE.
# C'est tout ce que genere_carte.py utilise pour poser
# le marqueur : `folium.Marker(location=pts_v[0])`.
# VIDEO_GPS=full déroule toute la trace GPS de chaque rush.
# Lit l'intégralité de chaque fichier : compter plusieurs
# minutes par rush sur un disque externe.
# VIDEO_GPS=first (défaut) premier point GPS de chaque rush, c'est là que
# genere_carte.py pose le marqueur. Rapide.
# VIDEO_GPS=full toute la trace de chaque rush. Lit les fichiers en entier :
# plusieurs minutes par rush sur un disque externe.
# VARIANTE=D|A|B|C force le mode d'extraction vidéo (voir plus bas)
# =============================================================================
@@ -48,31 +46,27 @@ nlines() { awk 'END{print NR+0}' "$1" 2>/dev/null || echo 0; }
# =============================================================================
# FORMATS
#
# `exiftool -p` supprime la LIGNE ENTIÈRE dès qu'UN SEUL tag du format est
# absent. Sur un rush GoPro, [Main] porte GPSLatitude/GPSLongitude mais PAS
# GPSDateTime, alors que [Doc1] porte tout. Un format à six tags peut donc ne
# rien produire sur un fichier parfaitement géolocalisé.
# `exiftool -p` supprime toute la ligne dès qu'un seul tag du format manque.
# Sur un rush GoPro, le document principal porte la position mais pas
# GPSDateTime (qui est dans [Doc1]) : un format qui réclame la date ne
# produit alors rien. Ne PAS ajouter -f pour forcer : avec -ee, -f fait perdre
# l'itération par document et ne sort plus qu'une ligne vide.
#
# ⚠️ NE JAMAIS ajouter -f pour « forcer » l'affichage. Vérifié en test :
# `-ee -p '$FileName,$SampleRate'` sur un fichier multi-documents donne
# 0 ligne sans -f, mais UNE SEULE ligne « fichier,- » AVEC -f — l'option fait
# perdre à -ee son itération par document. C'est pire que le mal.
#
# La parade : trois modes, essayés sur les premiers rushs ; on garde celui
# qui produit quelque chose.
# D'où quatre modes, essayés sur les premiers rushs ; on garde le premier qui
# produit quelque chose :
# D = lecture des tags un par un, analysée par awk (atteint [Doc1])
# A = format complet (date, lat, lon, alt, vitesse)
# B = format réduit (date, lat, lon) — tolère l'absence d'alt/vitesse
# C = format réduit + -ee3, qui détaille davantage les sous-documents
# B = format réduit (date, lat, lon)
# C = format réduit + -ee3 (sous-documents détaillés)
# =============================================================================
FMT_PHOTOS='"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude,$GPSAltitude'
FMT_PHOTOS_MIN='"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude'
FMT_V_COMPLET='"$FileName",$GPSDateTime,$GPSLatitude,$GPSLongitude,$GPSAltitude,$GPSSpeed'
FMT_V_MINIMAL='"$FileName",$GPSDateTime,$GPSLatitude,$GPSLongitude'
# Reconstruit des lignes CSV depuis la sortie « Tag : valeur » d'exiftool
# (noms longs ou courts, avec ou sans préfixe de groupe [Doc1]).
PARSE_GPS='
# Reconstruit des lignes CSV depuis la sortie « Tag : valeur » de exiftool.
# Robuste au format long ("GPS Date Time") comme court ("GPSDateTime"), et au
# prefixe de groupe [Main] / [Doc1].
{
ligne = $0
sub(/^\[[^]]*\][ \t]*/, "", ligne)
@@ -94,6 +88,8 @@ PARSE_GPS='
else if (tag == "GPSAltitude") { if (mode == "full" && n > 0) palt[n] = val }
else if (tag == "GPSSpeed") { if (mode == "full" && n > 0) pspd[n] = val }
# Mode rapide : on sort dès qu on a une date et une position ; exiftool
# reçoit alors SIGPIPE et arrête de lire le fichier.
if (mode != "full" && flat != "" && dt != "") {
print f "," dt "," flat "," flon ",,"; emis = 1; exit
}
@@ -107,14 +103,6 @@ END {
'
# extrait_video <mode> <fichier> -> lignes CSV sur stdout
#
# Mode D (par defaut) : on demande les tags un par un et on parse la sortie
# « Tag : valeur ». C est la seule methode qui atteint reellement [Doc1] :
# `-p` ne voit que [Main], lequel porte la position mais PAS GPSDateTime, si
# bien qu un format reclamant la date ne produit jamais rien. Verifie sur une
# sortie exiftool reelle de GoPro Hero 9 (GX011569.MP4).
# En mode rapide, awk sort des qu il a une date et une position : exiftool
# recoit SIGPIPE et cesse de lire le fichier.
extrait_video() {
local m="$1" f="$2" nom
nom=$(basename "$f")
@@ -133,17 +121,15 @@ extrait_video() {
: > "$CSV_PHOTOS"
if [ -d "$PHOTOS_DIR" ]; then
echo "📸 Photos : analyse en cours…"
# UN SEUL appel exiftool pour tout le dossier : chaque lancement du binaire
# coûte 150-250 ms (Perl). Mesuré sur 150 photos : 29,3 s → 0,97 s.
# Un seul appel exiftool pour tout le dossier : chaque lancement coûte
# ~200 ms (Perl). Seconde passe au format réduit pour rattraper les photos
# sans altitude, écartées par la première.
exiftool -n -q -ext jpg -ext jpeg -p "$FMT_PHOTOS" "$PHOTOS_DIR" \
2>>"$ERRLOG" > "$TMPD/p1.csv"
# Seconde passe, format réduit : rattrape les photos écartées par la
# première faute d'altitude. Deux appels batch restent sous la seconde.
exiftool -n -q -ext jpg -ext jpeg -p "$FMT_PHOTOS_MIN" "$PHOTOS_DIR" \
2>>"$ERRLOG" | awk '{print $0 ","}' > "$TMPD/p2.csv"
# Fusion : tout p1, plus les fichiers de p2 absents de p1.
# Fusion : tout p1, plus les photos de p2 absentes de p1.
cat "$TMPD/p1.csv" > "$CSV_PHOTOS"
awk -F, 'NR==FNR {vu[$1]=1; next} !($1 in vu)' "$TMPD/p1.csv" "$TMPD/p2.csv" >> "$CSV_PHOTOS"
@@ -163,8 +149,7 @@ else
rushs=()
for f in "$RUSHS_DIR"/*.mp4 "$RUSHS_DIR"/*.mov; do
[ -e "$f" ] || continue
# macOS depose un fichier AppleDouble « ._nom » a cote de chaque
# fichier sur les disques non-HFS (exFAT). Ce ne sont pas des videos.
# « ._nom » : métadonnées que macOS dépose sur les disques non-HFS (exFAT).
case "$(basename "$f")" in ._*) continue ;; esac
rushs+=("$f")
done
@@ -181,9 +166,7 @@ else
echo " (VIDEO_GPS=full pour dérouler la trace entière)"
fi
# --- Choix du mode d'extraction ---
# Testé sur 3 rushs au maximum : inutile de parcourir 552 fichiers
# pour découvrir qu'un mode ne marche pas.
# Choix du mode, testé sur 3 rushs au plus.
if [ -z "$VARIANTE" ]; then
echo " 🔎 recherche du mode d'extraction…"
for essai in D A B C; do
@@ -218,8 +201,7 @@ else
if [ "$VIDEO_GPS" = "full" ]; then
extrait_video "$VARIANTE" "$f" >> "$CSV_VIDEOS"
else
# `head -n 1` ferme le tuyau : exiftool reçoit SIGPIPE et
# s'arrête sans lire la fin du fichier.
# head ferme le tuyau : exiftool s'arrête sans lire la fin du fichier.
extrait_video "$VARIANTE" "$f" | head -n 1 >> "$CSV_VIDEOS"
fi
+440 -393
View File
@@ -1,459 +1,506 @@
#!/usr/bin/env python3
import pandas as pd
import folium
from folium import plugins
from folium.plugins import MarkerCluster
import os
import gpxpy
import re
import sys
import markdown2
import numpy as np
"""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
Usage : ./genere_carte.py [dossier_voyage]
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/.
Variables : RT_RACINE=chemin racine du site, cible du bouton « Retour »
(sinon : premier dossier parent qui contient un index.html)
SITE_URL=https://roadtrip.ouaibe.fr/ adresse publique, pour les liens du KML
"""
import copy
import hashlib
import html
import json
import os
import re
import shutil
import sys
from urllib.parse import quote
# --- 1. CONFIGURATION ---
base_dir = sys.argv[1] if len(sys.argv) > 1 else "./"
base_dir = os.path.abspath(base_dir)
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
routes_dir = os.path.join(base_dir, "routes")
html_output_dir = os.path.join(base_dir, "html")
csv_videos = os.path.join(base_dir, "export_videos.csv")
csv_photos = os.path.join(base_dir, "export_photos.csv")
file_notes = os.path.join(base_dir, "voyage.md")
if not os.path.exists(html_output_dir):
os.makedirs(html_output_dir)
DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
ASSETS = ("carte.css", "carte.js")
COULEURS_JOURS = ['#FF5733', '#2ECC71', '#3498DB', '#9B59B6', '#F1C40F', '#E67E22', '#1ABC9C', '#34495E']
noms_colonnes = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse']
COLONNES = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse']
TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
# --- ÉCHAPPEMENT ---
# Sans ça, un fichier nommé « col & vue.jpg » produit un lien cassé et du HTML
# invalide. quote() encode l'espace et l'esperluette pour les URL, escape()
# protège le texte affiché.
def est_sidecar(nom):
"""Fichiers parasites macOS.
SITE_URL = os.environ.get("SITE_URL", "https://roadtrip.ouaibe.fr/").rstrip('/') + '/'
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)
Sur un disque non-HFS (exFAT d'un disque externe, cle USB, partage reseau),
macOS depose un fichier AppleDouble « ._nom » a cote de chaque fichier pour
y stocker les metadonnees. « ._Ete_2026-08-01.gpx » n'est donc pas un GPX
mais un blob binaire, et gpxpy echoue dessus avec une erreur de decodage.
On ecarte aussi .DS_Store et les fichiers caches.
"""
return nom.startswith('._') or nom.startswith('.')
# 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 sûr pour un attribut src/href."""
return html.escape(quote(chemin))
"""Chemin encodé pour un src/href (espaces, &, #…)."""
return quote(str(chemin))
def txt(valeur):
"""Texte sûr pour l'affichage."""
return html.escape(str(valeur))
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('.')
# --- FONCTIONS UTILES ---
def nettoyer_et_trier(df):
if df is None or df.empty:
return pd.DataFrame(columns=noms_colonnes + ['DT', 'Jour'])
df = df[(df['Latitude'].notnull()) & (df['Longitude'].notnull())]
df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)]
df['DT'] = pd.to_datetime(df['Date_Heure'].str.split('.').str[0], format='%Y:%m:%d %H:%M:%S', errors='coerce')
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 create_pin(color, icon_name):
html_code = f"""<div style="position: relative; height: 40px; width: 24px;">
<div style="background-color: {color}; width: 24px; height: 24px; border-radius: 50%; border: 2px solid white;
display: flex; align-items: center; justify-content: center; color: white; font-size: 12px;
position: absolute; top: 0; z-index: 2; box-shadow: 0 2px 4px rgba(0,0,0,0.4);">
<i class="fa fa-{icon_name}"></i>
</div>
<div style="width: 3px; height: 18px; background-color: {color}; position: absolute; top: 22px; left: 10.5px; z-index: 1;"></div>
</div>"""
return folium.DivIcon(html=html_code, icon_anchor=(12, 40))
def inject_common_assets(m):
custom_js = """
<script>
// --- DÉTECTION HEVC ---
// Stratégie hybride : le 720p (H.264) est chargé par défaut et se lit
// partout. Seul le 1080p est en HEVC. Si le navigateur ne sait pas décoder
// le HEVC (Firefox principalement), on neutralise le bouton 1080p —
// la vidéo reste lisible, seule l'option haute qualité disparaît.
var HEVC_OK = (function () {
try {
var v = document.createElement('video');
var t = v.canPlayType('video/mp4; codecs="hvc1.1.6.L120.B0"');
return t === 'probably' || t === 'maybe';
} catch (e) { return false; }
})();
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 = 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)
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';
}
// --- GESTION DES BOUTONS 720p / 1080p ---
function changeQuality(videoId, newSrc, btnElement) {
if (btnElement.classList.contains('qbtn-disabled')) return;
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, '/')
var video = document.getElementById(videoId);
// On compare les NOMS DE FICHIER, pas les chemins : video.src est une
// URL absolue résolue par le navigateur, alors que newSrc est relatif
// ("../videos/X.mp4"). L'ancien test `video.src.includes(newSrc)` ne
// pouvait donc jamais être vrai, et recliquer sur la qualité déjà
// active rechargeait la vidéo en repartant du début.
var nomActuel = decodeURIComponent(video.src).split('/').pop();
var nomVoulu = decodeURIComponent(newSrc).split('/').pop();
if (nomActuel === nomVoulu) return;
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 = SITE_URL + quote(os.path.relpath(base_dir, racine_site(base_dir)).replace(os.sep, '/')) + '/'
titre = nom_voyage.replace('-', ' ').replace('_', ' ')
var savedTime = video.currentTime;
var wasPlaying = !video.paused;
# 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
video.src = newSrc;
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)
video.onloadedmetadata = function() {
this.currentTime = savedTime;
if (wasPlaying) {
var playPromise = this.play();
if (playPromise !== undefined) {
playPromise.catch(error => { console.log("Lecture bloquée"); });
}
}
this.onloadedmetadata = null;
};
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)")
video.load();
var container = btnElement.parentElement;
var buttons = container.getElementsByClassName('quality-btn');
for (var i = 0; i < buttons.length; i++) {
buttons[i].classList.remove('active-720', 'active-1080');
buttons[i].style.opacity = "0.6";
}
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]
btnElement.style.opacity = "1";
if (btnElement.innerText.includes("720p")) {
btnElement.classList.add('active-720');
} else {
btnElement.classList.add('active-1080');
}
}
// --- AUTOPLAY INTELLIGENT (DÉTECTION D'OUVERTURE POPUP) ---
// Cet observateur surveille l'apparition des popups.
// Dès qu'une popup s'ouvre, il trouve la vidéo et lance la lecture.
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
// Si l'élément ajouté est une popup Leaflet
if (node.nodeType === 1 && node.classList && node.classList.contains('leaflet-popup')) {
applyCodecSupport(node);
var video = node.querySelector('video');
if (video) {
// Filet de sécurité : si le 1080p HEVC échoue malgré la
// détection, on retombe automatiquement sur le 720p H.264.
video.addEventListener('error', function () {
var fb = this.getAttribute('data-fallback');
if (fb && this.src.indexOf(fb) === -1) { this.src = fb; this.load(); }
});
console.log("🎬 Popup ouverte : Lancement de la vidéo...");
var playPromise = video.play();
if (playPromise !== undefined) {
playPromise.catch(error => {
console.log("Autoplay bloqué par le navigateur (interaction requise ?)", error);
});
}
}
}
});
});
});
class Journal:
"""Récit voyage.md converti en HTML, avec deux raccourcis résolus ici :
// On lance l'espion dès que la page est chargée
document.addEventListener("DOMContentLoaded", function() {
observer.observe(document.body, { childList: true, subtree: true });
});
// ----------------------------------------
[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
function toggleJournal(open) {
var panel = document.getElementById('journal-panel');
if (open) {
panel.classList.add('open');
localStorage.setItem('journalOpen', 'true');
} else {
panel.classList.remove('open');
localStorage.setItem('journalOpen', 'false');
}
}
window.addEventListener('DOMContentLoaded', function() {
if (localStorage.getItem('journalOpen') === 'true') {
var panel = document.getElementById('journal-panel');
if (panel) panel.classList.add('open');
}
});
function moveSlide(btn, step) {
var container = btn.parentElement.querySelector('.slides');
var slides = container.querySelectorAll('.slide');
var activeIndex = Array.from(slides).findIndex(s => s.style.display !== 'none');
if (activeIndex === -1) activeIndex = 0;
slides[activeIndex].style.display = 'none';
var nextIndex = (activeIndex + step + slides.length) % slides.length;
slides[nextIndex].style.display = 'block';
btn.parentElement.querySelector('.slide-counter').innerText = (nextIndex + 1) + '/' + slides.length;
}
</script>
"""
custom_css = """
<style>
.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-size: 11px; font-family: monospace; font-weight: bold; color: #333;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;
}
.quality-controls { display: flex; gap: 6px; }
.quality-btn {
padding: 3px 8px; border-radius: 12px; font-size: 10px; font-weight: bold; cursor: pointer;
border: 1px solid #ccc; background: #fff; opacity: 0.6; transition: 0.2s; font-family: sans-serif;
text-transform: uppercase;
}
.quality-btn:hover { opacity: 1; background: #eee; }
.active-720 { background-color: #2ECC71 !important; color: white; border-color: #27ae60 !important; opacity: 1 !important; }
.active-1080 { background-color: #007AFF !important; color: white; border-color: #0056b3 !important; opacity: 1 !important; }
.qbtn-disabled {
opacity: 0.35 !important; cursor: not-allowed !important;
text-decoration: line-through; background: #eee !important;
}
.qbtn-disabled:hover { opacity: 0.35 !important; background: #eee !important; }
.hevc-note {
display: none; margin-top: 6px; font-family: sans-serif; font-size: 11px;
color: #8a6d3b; background: #fcf8e3; border-radius: 6px; padding: 6px 10px;
text-align: left;
}
.leaflet-top.leaflet-left { top: auto !important; bottom: 20px !important; left: 15px !important; }
.leaflet-popup-content-wrapper { border-radius: 12px; max-width: 95vw !important; }
#journal-panel {
position: fixed; top: 0; right: -100%; width: 400px; height: 100%;
background: white; z-index: 10005; transition: 0.4s;
box-shadow: -5px 0 15px rgba(0,0,0,0.2); padding: 25px;
overflow-y: auto; font-family: sans-serif;
}
#journal-panel.open { right: 0; }
@media (max-width: 600px) { #journal-panel { width: 85%; } }
.close-btn { position: absolute; top: 15px; right: 15px; font-size: 24px; cursor: pointer; border: none; background: none; }
.slider-container { position: relative; width: calc(85vw - 60px); max-width: 800px; text-align: center; min-height: 200px; }
.slide img { max-width: 100%; max-height: 65vh; border-radius: 8px; display: block; margin: auto; }
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,0.6); color: white; border: 2px solid white; padding: 12px; cursor: pointer; border-radius: 50%; z-index: 10; font-size: 18px; }
.prev { left: -10px; } .next { right: -10px; }
.slide-counter { margin-top: 8px; font-size: 13px; font-weight: bold; color: #333; font-family: sans-serif; }
</style>
"""
m.get_root().header.add_child(folium.Element(custom_css + custom_js))
def get_journal_ui():
journal_html = ""
if os.path.exists(file_notes):
with open(file_notes, 'r', encoding='utf-8') as f:
journal_html = markdown2.markdown(f.read())
return f"""
<div id="journal-panel"><button class="close-btn" onclick="toggleJournal(false)">&times;</button><div class="journal-content">{journal_html}</div></div>
<div style="position: fixed; bottom: 25px; right: 15px; z-index: 10000; display: flex; flex-direction: column; align-items: flex-end; gap: 10px;">
<button onclick="toggleJournal(true)" style="background: #FFD700; border: 2px solid white; padding: 12px 20px; border-radius: 50px; font-weight: bold; font-size: 14px; cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.3); font-family:sans-serif;">📖 Journal</button>
</div>
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.
"""
# --- 2. CHARGEMENT ---
try:
df_v = pd.read_csv(csv_videos, names=noms_colonnes, header=None) if os.path.exists(csv_videos) else pd.DataFrame()
df_p = pd.read_csv(csv_photos, names=noms_colonnes, header=None) if os.path.exists(csv_photos) else pd.DataFrame()
df_v, df_p = nettoyer_et_trier(df_v), nettoyer_et_trier(df_p)
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
jours_gpx = set()
if os.path.exists(routes_dir):
for f in os.listdir(routes_dir):
if est_sidecar(f):
continue
match = re.search(r'(\d{4}-\d{2}-\d{2})', f)
if match: jours_gpx.add(pd.to_datetime(match.group(1)).date())
tous_les_jours = sorted(list(set(df_v['Jour'].unique()) | set(df_p['Jour'].unique()) | jours_gpx))
except Exception as e:
print(f"❌ Erreur : {e}"); sys.exit()
def _signaler(self, message):
if message not in self._signale:
self._signale.add(message)
print(f" ⚠️ Journal : {message}")
# --- 3. GÉNÉRATION ---
m_global = folium.Map(tiles=None)
folium.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attr='Esri', name='Satellite').add_to(m_global)
folium.TileLayer('OpenStreetMap', name='Plan').add_to(m_global)
inject_common_assets(m_global)
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>')
m_mini = folium.Map(tiles='OpenStreetMap', zoom_control=False, control_scale=False, attribution_control=False)
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>'
all_global_coords, sidebar_links_html = [], ""
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)
for i, jour in enumerate(tous_les_jours):
day_str = jour.strftime('%Y-%m-%d')
file_name = f'carte_{day_str}.html'
color_day = COULEURS_JOURS[i % len(COULEURS_JOURS)]
sidebar_links_html += f'<a href="html/{file_name}" target="_top" style="display:block; margin-bottom:8px; padding:12px; background:{color_day}; color:white; text-decoration:none; border-radius:8px; font-weight:bold; text-align:center; font-family:sans-serif;">{day_str}</a>'
m_day = folium.Map(tiles=None)
folium.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attr='Esri', name='Satellite').add_to(m_day)
folium.TileLayer('OpenStreetMap', name='Plan').add_to(m_day)
inject_common_assets(m_day)
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
marker_cluster = MarkerCluster(name="Médias").add_to(m_day)
day_coords = []
media_coords = [] # position des photos/vidéos, filet de cadrage
current_gpx_filename = None
if os.path.exists(routes_dir):
for gpx_file in os.listdir(routes_dir):
# `day_str in gpx_file` et non `.startswith(day_str)` : la liste des
# jours est bâtie avec re.search (date N'IMPORTE OÙ dans le nom),
# donc une trace « trace_2026-07-15_col.gpx » créait bien un jour
# mais n'était jamais retrouvée ici — jour vide, carte non cadrée.
if est_sidecar(gpx_file):
continue
if day_str in gpx_file and gpx_file.lower().endswith('.gpx'):
try:
with open(os.path.join(routes_dir, gpx_file), 'r') as f:
gpx = gpxpy.parse(f)
for track in gpx.tracks:
pts = []
for segment in track.segments:
pts.extend([[p.latitude, p.longitude] for p in segment.points])
if pts:
day_coords.extend(pts); all_global_coords.extend(pts)
popup_html = f"<div style='text-align:center; font-family:sans-serif;'><b>📅 {day_str}</b><br><br><a href='html/{file_name}' target='_top' style='background:{color_day}; color:white; padding:8px 12px; border-radius:4px; text-decoration:none; font-weight:bold;'>Détails</a></div>"
folium.PolyLine(pts, color=color_day, weight=7, opacity=0.8, popup=folium.Popup(popup_html, max_width=200)).add_to(m_global)
folium.PolyLine(pts, color=color_day, weight=3, opacity=0.8).add_to(m_mini)
folium.PolyLine(pts, color="#FF0000", weight=4, opacity=0.8).add_to(m_day)
# Le nom n'est retenu qu'APRES analyse reussie : sinon le
# bouton de telechargement pouvait pointer sur un fichier
# illisible traite apres la vraie trace.
current_gpx_filename = gpx_file
except Exception as e:
# `except: pass` avalait TOUT, y compris un Ctrl-C, et
# masquait les GPX corrompus : la trace disparaissait sans
# le moindre message.
print(f" ⚠️ GPX illisible ({gpx_file}) : {e}")
def ajouter(carte, fragment):
carte.get_root().html.add_child(folium.Element(str(fragment)))
m_day.get_root().html.add_child(folium.Element('<div style="position: fixed; top: 15px; left: 15px; z-index: 10001;"><a href="../index.html" target="_top" style="text-decoration: none; background: #333; color: white; padding: 12px 20px; border-radius: 10px; font-family: sans-serif; font-weight: bold; box-shadow: 0 4px 10px rgba(0,0,0,0.5); border: 2px solid white; display: block;">🏠 Accueil</a></div>' + get_journal_ui()))
if current_gpx_filename:
m_day.get_root().html.add_child(folium.Element(f'<div style="position: fixed; bottom: 85px; right: 15px; z-index: 10001;"><a href="../routes/{url(current_gpx_filename)}" download style="text-decoration: none; background: #28a745; color: white; padding: 12px 20px; border-radius: 50px; font-family: sans-serif; font-weight: bold; box-shadow: 0 4px 15px rgba(0,0,0,0.4); border: 2px solid white; display: block;">📥 GPX</a></div>'))
def epingle(couleur, icone):
return folium.DivIcon(html=str(F.epingle(couleur, icone)), icon_anchor=(12, 40))
# REGROUPEMENT PHOTOS
day_p = df_p[df_p['Jour'] == jour].copy()
if not day_p.empty:
tol = 0.0002
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']) < tol) & (np.abs(day_p['Longitude'] - ref['Longitude']) < tol)
groupe = day_p[masque]
fichiers = groupe['Fichier'].tolist()
nb = len(fichiers)
slides = "".join([
f'<div class="slide" style="display:{"block" if idx == 0 else "none"};">'
f'<div class="file-header">{txt(p.strip())}</div>'
f'<a href="../photos/{url(p.strip())}" target="_blank">'
f'<img src="../photos/{url(p.strip())}" alt="{txt(p.strip())}" loading="lazy"></a></div>'
for idx, p in enumerate(fichiers)])
btns = f'<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/{nb}</div>' if nb > 1 else ""
media_coords.append([ref['Latitude'], ref['Longitude']])
folium.Marker(location=[ref['Latitude'], ref['Longitude']], popup=folium.Popup(f'<div class="slider-container"><div class="slides">{slides}</div>{btns}</div>', max_width="100%"), icon=create_pin("#FF3B30", "camera" if nb==1 else "images")).add_to(marker_cluster)
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
# Vidéos
day_v = df_v[df_v['Jour'] == jour]
for v_name, group in day_v.groupby('Fichier'):
pts_v = group[['Latitude', 'Longitude']].values.tolist()
base_name = os.path.splitext(v_name.strip())[0]
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)
clean_id = re.sub(r'[^a-zA-Z0-9]', '', base_name)
video_id = f"vid_{clean_id}"
desktop_file = f"../videos/{url(base_name)}.mp4"
mobile_file = f"../videos/{url(base_name)}_mobile.mp4"
media_coords.append(pts_v[0])
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=(',', ':'))
# Ajout 'autoplay muted' pour le lancement au clic
# Ajout 'preload=none' pour ne pas charger les autres
# --- BLOC VIDÉO FIABLE (Défaut = Léger, Upgrade manuel) ---
v_pop = (
f'<div style="width:calc(85vw - 40px); max-width:1000px; text-align:center;">'
f'<div class="header-row">'
f'<span class="file-name" title="{txt(v_name.strip())}">{txt(v_name.strip())}</span>'
f'<div class="quality-controls">'
# Le bouton 720p est actif par défaut (cohérent avec le fichier chargé)
f'<span class="quality-btn active-720" onclick="changeQuality(\'{video_id}\', \'{mobile_file}\', this)">720p</span>'
# data-hevc : ce palier est en H.265, il sera grisé si le
# navigateur ne sait pas le décoder.
f'<span class="quality-btn" data-hevc="1" onclick="changeQuality(\'{video_id}\', \'{desktop_file}\', this)">1080p</span>'
f'</div>'
f'</div>'
class Voyage:
"""Données communes à toutes les pages d'un voyage."""
# 1. On charge par défaut le fichier MOBILE (léger) pour garantir la lecture partout.
# 2. preload="none" : Indispensable pour que la page charge vite.
# 3. Pas d'autoplay.
f'<video id="{video_id}" style="width:100%; max-height:70vh; border-radius:8px; background:black;" '
f'controls playsinline webkit-playsinline preload="none" '
f'src="{mobile_file}" data-fallback="{mobile_file}">' # <-- 720p H.264 : lisible partout
f'</video>'
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
# Note discrète affichée uniquement si le 1080p est indisponible
f'<div class="hevc-note">'
f'Le 1080p est encodé en H.265/HEVC, que ce navigateur ne sait pas décoder. '
f'La lecture en 720p fonctionne normalement ; pour la haute définition, '
f'utilise Safari, Chrome ou Edge.'
f'</div>'
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))
f'</div>'
)
folium.Marker(location=pts_v[0], popup=folium.Popup(v_pop, max_width="100%"), icon=create_pin("#007AFF", "play")).add_to(marker_cluster)
# Cadrage : la trace GPX en priorité, sinon la position des médias.
# Sans ce repli, un jour sans GPX (mais avec photos) s'ouvrait sur une
# mappemonde centrée au large de l'Afrique.
cadrage = day_coords if day_coords else media_coords
if cadrage:
m_day.fit_bounds(cadrage, padding=(50, 50))
all_global_coords.extend(media_coords)
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)
m_day.save(os.path.join(html_output_dir, file_name))
# --- FINALISATION ---
if all_global_coords:
m_global.fit_bounds(all_global_coords, padding=(50, 50))
m_mini.fit_bounds(all_global_coords, padding=(20, 20))
sidebar_html = f'<div style="position: fixed; top: 15px; left: 15px; width: 160px; max-height: 85vh; overflow-y: auto; background: rgba(255,255,255,0.9); z-index: 9999; padding: 12px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.2);"><a href="../" target="_top" style="display:block; margin-bottom:15px; padding:12px; background:#444; color:white; text-decoration:none; border-radius:8px; font-weight:bold; text-align:center; border: 2px solid #666; font-family:sans-serif;">⬅ Retour</a><div style="border-top: 1px solid #ccc; margin-bottom: 10px; padding-top: 10px; font-family:sans-serif; font-size:12px; font-weight:bold; color:#666; text-align:center;">PAR JOURS</div>{sidebar_links_html}</div>'
m_global.get_root().html.add_child(folium.Element(sidebar_html + get_journal_ui()))
folium.LayerControl().add_to(m_global)
m_global.save(os.path.join(base_dir, "index.html"))
m_mini.save(os.path.join(base_dir, "mini.html"))
return m_day, media_coords
def main():
base_dir = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else ".")
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é).")
sys.exit(1)
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/")
if __name__ == "__main__":
main()
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""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 :
- mymaps.txt à la racine du site : un lien de carte My Maps par ligne
(# pour un commentaire), carte partagée « 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 genere_index_general.py ; utilisable seul :
./genere_carte_globale.py [racine_du_site]
"""
import json
import math
import os
import re
import shutil
import subprocess
import sys
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):
"""Récupère le KML des cartes listées dans mymaps.txt vers _mymaps/<mid>.kml
(une fois ; MYMAPS_MAJ=1 pour forcer)."""
liste = os.path.join(racine, "mymaps.txt")
if not os.path.exists(liste):
return
dossier = os.path.join(racine, DOSSIER_MYMAPS)
os.makedirs(dossier, exist_ok=True)
with open(liste, encoding='utf-8') as f:
liens = [l.strip() for l in f if l.strip() and not l.strip().startswith('#')]
for lien in liens:
mid = mid_de(lien)
if not mid:
print(f" ⚠️ mymaps.txt : pas un lien My Maps (mid=… manquant) : {lien}")
continue
dest = os.path.join(dossier, f"{mid}.kml")
if os.path.exists(dest) 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):
"""rubriques : liste de {'titre', 'voyages': [{'titre', 'chemin', 'periode', 'dossier'}]}."""
racine = os.path.abspath(racine)
telecharger_mymaps(racine)
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)})")
if __name__ == "__main__":
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from genere_index_general import TITRE_SECTION_RACINE, collecter_rubriques
racine = sys.argv[1] if len(sys.argv) > 1 else "."
generer_carte_globale(racine, [r for r in collecter_rubriques(os.path.abspath(racine))
if r['titre'] == TITRE_SECTION_RACINE])
+153 -112
View File
@@ -1,138 +1,179 @@
#!/usr/bin/env python3
"""Page d'accueil listant tous les voyages du site.
"""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)
Usage : ./genere_index_general.py [racine_du_site]
Variable : ACCUEIL_NB=2
Un voyage est reconnu à la présence de son fichier mini.html (la carte
miniature produite par genere_carte.py).
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.
Un fichier moto.txt facultatif dans un voyage indique la moto (une ligne),
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
import sys
import html
from datetime import date
from urllib.parse import quote
EXCLURE = {'html', 'Scripts', '__pycache__', '.git', 'node_modules', 'videos',
'photos', 'rushs', 'routes'}
OUTPUT_FILE = "index.html"
from jinja2 import Environment, FileSystemLoader
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from genere_carte_globale import SORTIE as CARTE_GLOBALE, generer_carte_globale # noqa: E402
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_SITE = "Nos Road-Trips"
TITRE_SECTION_RACINE = "Voyages"
NB_RECENTS = int(os.environ.get("ACCUEIL_NB", 2))
MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet",
"août", "septembre", "octobre", "novembre", "décembre"]
def cle_de_tri(nom):
"""Trie du plus récent au plus ancien quand un millésime est détecté
dans le nom du dossier, alphabétiquement sinon."""
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 lire_moto(chemin):
"""<voyage>/moto.txt (facultatif) : la ou les motos du voyage, sur une ligne."""
f = os.path.join(chemin, 'moto.txt')
if os.path.exists(f):
with open(f, encoding='utf-8') as fh:
return fh.readline().strip()
return ""
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)
annee = int(m.group(0)) if m else 0
return (-annee, nom.lower())
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': lire_moto(chemin),
'quand': quand,
}
def lien(trip, suffixe=""):
"""URL sûre : les espaces et les & des noms de dossier sont encodés."""
return html.escape(quote(trip) + suffixe)
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_index_general(racine="."):
road_trips = []
for d in sorted(os.listdir(racine)):
if d in EXCLURE or d.startswith('.'):
continue
chemin = os.path.join(racine, d)
if os.path.isdir(chemin) and os.path.exists(os.path.join(chemin, 'mini.html')):
road_trips.append(d)
racine = os.path.abspath(racine)
rubriques = collecter_rubriques(racine)
total = sum(len(r['voyages']) for r in rubriques)
road_trips.sort(key=cle_de_tri)
env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True,
trim_blocks=True, lstrip_blocks=True)
cartes = []
for trip in road_trips:
# Le nom est échappé : un dossier « rando & vercors » produisait
# sinon du HTML invalide (& non échappé) et un lien cassé.
titre = html.escape(trip.replace('-', ' ').replace('_', ' '))
cartes.append(f""" <article class="card">
<iframe class="map-container" src="{lien(trip, '/mini.html')}"
loading="lazy" title="Carte de {titre}"></iframe>
<div class="card-content">
<h2>{titre}</h2>
<a href="{lien(trip, '/index.html')}" class="btn">Voir le voyage</a>
</div>
</article>""")
def ecrire(nom, contenu):
with open(os.path.join(racine, nom), "w", encoding="utf-8") as f:
f.write(contenu)
if road_trips:
corps = '<div class="grid">\n' + "\n".join(cartes) + '\n </div>'
sous_titre = f"{len(road_trips)} voyage" + ("s" if len(road_trips) > 1 else "")
else:
corps = ('<p class="vide">Aucun voyage pour l\'instant.<br>'
'Lance <code>genere_site.sh</code> pour en générer.</p>')
sous_titre = ""
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))
# Pas d'espace avant <!DOCTYPE html> : la version précédente commençait par
# un saut de ligne et de l'indentation, ce qui bascule certains navigateurs
# en mode « quirks » et fausse la mise en page.
html_content = f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nos Road-Trips</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; }}
/* min() évite le débordement horizontal sur téléphone : l'ancienne règle
minmax(400px, 1fr) forçait 400 px de large sur un écran de 375 px. */
.grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(400px, 100%), 1fr));
gap: 30px; max-width: 1200px; margin: 0 auto;
}}
.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); }}
/* loading="lazy" : sans ça, ouvrir la page chargeait une carte Leaflet
complète par voyage, d'un coup. */
.map-container {{ width: 100%; height: 300px; border: none; pointer-events: none; display: block; }}
.card-content {{ padding: 20px; text-align: center; }}
h2 {{ text-transform: capitalize; margin: 0 0 14px; font-size: 1.25rem; }}
.btn {{
display: inline-block; padding: 10px 25px; background: #3498db; color: #fff;
text-decoration: none; border-radius: 25px; font-weight: bold;
}}
.btn:hover {{ background: #2980b9; }}
.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 {{ color: #9a9aa2; }}
code {{ background: #2e2e36; }}
}}
</style>
</head>
<body>
<header>
<h1>🌍 Nos Road-Trips</h1>
<div class="sous-titre">{sous_titre}</div>
</header>
<main>
{corps}
</main>
</body>
</html>
"""
# 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)
sortie = os.path.join(racine, OUTPUT_FILE)
with open(sortie, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"🌍 Index général : {len(road_trips)} voyage(s) → {sortie}")
detail = ", ".join(f"{r['titre']} : {len(r['voyages'])} → {r['page']}" for r in rubriques)
print(f"🌍 Accueil : {total} voyage(s) ({detail})")
if __name__ == "__main__":
racine = sys.argv[1] if len(sys.argv) > 1 else "."
generer_index_general(racine)
generer_index_general(sys.argv[1] if len(sys.argv) > 1 else ".")
+83 -77
View File
@@ -1,41 +1,41 @@
#!/bin/bash
# =============================================================================
# GÉNÉRATION DU SITE — orchestre la chaîne complète
# GÉNÉRATION DU SITE — enchaîne toutes les étapes
#
# Pour chaque dossier de voyage :
# 1. extraction_infos.sh → CSV des métadonnées EXIF/GPS
# 2. compress.sh → encodage web des rushs
# 3. genere_carte.py → cartes du voyage
# Puis, UNE SEULE FOIS à la fin :
# 4. genere_index_general.py → page d'accueil listant tous les voyages
# Pour chaque voyage :
# 1. extraction_infos.sh → CSV des positions GPS des photos et vidéos
# 2. compress.sh → vidéos web (1080p HEVC + 720p H.264)
# 3. prepare_web.py → photos allégées et images d'aperçu des vidéos
# 4. genere_carte.py → pages et cartes du voyage
# Puis une fois : genere_index_general.py → page d'accueil du site.
#
# Un dossier est considéré comme un voyage s'il contient AU MOINS un des
# sous-dossiers rushs/, routes/ ou photos/. Les manquants sont signalés et
# l'étape correspondante est simplement sautée (un voyage sans vidéo ou sans
# trace GPX reste parfaitement valable).
# Un voyage est un dossier qui contient au moins un de rushs/, routes/,
# photos/. Il peut être rangé un niveau plus bas, dans un dossier de
# regroupement (Balades/Aisne-06-2026). Une étape sans matière est sautée.
#
# Usage :
# ./genere_site.sh # tous les voyages
# ./genere_site.sh Ete-2026 # un seul voyage
# ./genere_site.sh Ete-2026 corse # plusieurs
# À lancer depuis la racine du site (le dossier qui contient les voyages) :
# ../RoadTripsGenerator/Scripts/genere_site.sh # tous
# ../RoadTripsGenerator/Scripts/genere_site.sh Ete-2026 # un seul
# ../RoadTripsGenerator/Scripts/genere_site.sh Balades/Vexin-10-2026
#
# Variables : celles de compress.sh (JOBS, ENCODER, CRF_HEVC…) sont
# transmises telles quelles.
# SKIP_COMPRESS=1 régénère les cartes sans réencoder les vidéos
# Variables :
# ROOT=chemin racine du site (défaut : dossier courant)
# SKIP_COMPRESS=1 ne lance pas compress.sh
# + celles de compress.sh (JOBS, ENCODER…) et de prepare_web.py (WEB_PX…)
# =============================================================================
set -uo pipefail
shopt -s nullglob
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# La racine du site est le dossier PARENT de Scripts/. On ne dépend donc pas
# du répertoire courant : le script marche depuis n'importe où.
ROOT="${ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
ROOT="${ROOT:-$PWD}"
SKIP_COMPRESS="${SKIP_COMPRESS:-0}"
cd "$ROOT" || { echo "❌ Racine introuvable : $ROOT"; exit 1; }
ROOT="$PWD"
export RT_RACINE="$ROOT" # cible du bouton « Retour » des voyages
for s in extraction_infos.sh compress.sh genere_carte.py genere_index_general.py; do
for s in extraction_infos.sh compress.sh prepare_web.py genere_carte.py genere_index_general.py; do
if [ ! -f "$SCRIPT_DIR/$s" ]; then
echo "❌ Script manquant : $SCRIPT_DIR/$s"
exit 1
@@ -43,10 +43,17 @@ for s in extraction_infos.sh compress.sh genere_carte.py genere_index_general.py
[ -x "$SCRIPT_DIR/$s" ] || chmod +x "$SCRIPT_DIR/$s" 2>/dev/null
done
# --- Diagnostic d'un sous-dossier -------------------------------------------
# `ls -l` affiche un lien symbolique CASSÉ exactement comme un lien valide :
# rien ne le distingue à l'œil. `test -d` renvoie faux dessus, d'où des
# « dossier manquant » incompréhensibles. On sépare donc les deux cas.
# Dossiers qui ne sont jamais des voyages ni des regroupements.
ignore() {
case "$1" in
Scripts|RoadTripsGenerator|html|assets|videos|photos|photos_web|apercus|rushs|routes) return 0 ;;
_*|.*) return 0 ;;
esac
return 1
}
# ok | lien_casse | absent. Un lien symbolique cassé ressemble à un lien valide
# dans `ls -l`, mais `test -d` renvoie faux : on le signale à part.
etat_dossier() {
if [ -d "$1" ]; then echo ok
elif [ -L "$1" ]; then echo lien_casse
@@ -54,13 +61,31 @@ etat_dossier() {
fi
}
# Résout la cible d'un lien en chemin absolu, pour pouvoir la montrer.
resoudre_lien() {
python3 -c 'import os,sys
p = sys.argv[1]
print(os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(p)), os.readlink(p))))' "$1" 2>/dev/null \
|| readlink "$1" 2>/dev/null \
|| echo "?"
|| readlink "$1" 2>/dev/null || echo "?"
}
# Vrai si le dossier a au moins un sous-dossier exploitable. Signale au passage
# les liens cassés et les sous-dossiers manquants (dans $DETAILS).
DETAILS=""
est_voyage() {
local dir="$1" sub presents=0
DETAILS=""
for sub in rushs routes photos; do
case "$(etat_dossier "$dir/$sub")" in
ok) presents=$((presents + 1)) ;;
lien_casse)
echo "🔗 $dir/$sub : LIEN SYMBOLIQUE CASSÉ"
echo " pointe vers : $(resoudre_lien "$dir/$sub")"
echo " (disque externe non branché, ou dossier déplacé ?)"
DETAILS="$DETAILS $sub(lien cassé)" ;;
absent) DETAILS="$DETAILS $sub(absent)" ;;
esac
done
[ "$presents" -gt 0 ]
}
echo "=================================================="
@@ -70,52 +95,35 @@ echo "=================================================="
echo
# --- Sélection des voyages ---------------------------------------------------
candidats=()
voyages=()
ajoute() {
voyages+=("$1")
[ -n "$DETAILS" ] && echo "⚠️ $1 : sous-dossier(s) indisponible(s) :$DETAILS — étape(s) sautée(s)"
}
if [ "$#" -gt 0 ]; then
EXPLICITE=1
for d in "$@"; do candidats+=("${d%/}"); done
else
EXPLICITE=0
for d in */; do candidats+=("${d%/}"); done
fi
voyages=(); ignores=0
for dir in "${candidats[@]+"${candidats[@]}"}"; do
[ "$dir" = "Scripts" ] && continue
if [ ! -d "$dir" ]; then
[ "$EXPLICITE" = "1" ] && echo "❌ $dir : ce dossier n'existe pas."
ignores=$((ignores + 1)); continue
fi
presents=0; details=""
for sub in rushs routes photos; do
case "$(etat_dossier "$dir/$sub")" in
ok)
presents=$((presents + 1)) ;;
lien_casse)
# Toujours signalé, même en mode automatique : un lien cassé
# est une erreur à corriger, pas un dossier qu'on ignore.
echo "🔗 $dir/$sub : LIEN SYMBOLIQUE CASSÉ"
echo " pointe vers : $(resoudre_lien "$dir/$sub")"
echo " → cette cible n'existe pas. Le lien a probablement été"
echo " créé en relatif puis le dossier a été déplacé."
echo " Correction (avec un chemin absolu, plus robuste) :"
echo " rm '$dir/$sub' && ln -s /chemin/absolu/vers/$sub '$dir/$sub'"
details="$details $sub(lien cassé)" ;;
absent)
details="$details $sub(absent)" ;;
esac
for d in "$@"; do
d="${d%/}"
if [ ! -d "$d" ]; then echo "❌ $d : ce dossier n'existe pas."; continue; fi
if est_voyage "$d"; then ajoute "$d"
else echo "❌ $d : aucun de rushs/ routes/ photos/ n'est utilisable :$DETAILS"; fi
done
if [ "$presents" -gt 0 ]; then
voyages+=("$dir")
[ -n "$details" ] && echo "⚠️ $dir : sous-dossier(s) indisponible(s) :$details — étape(s) sautée(s)"
else
for d in */; do
d="${d%/}"
ignore "$d" && continue
if est_voyage "$d"; then
ajoute "$d"
else
[ "$EXPLICITE" = "1" ] && echo "❌ $dir : aucun de rushs/ routes/ photos/ n'est utilisable :$details"
ignores=$((ignores + 1))
# Pas un voyage : peut-être un regroupement (Balades/…).
for s in "$d"/*/; do
s="${s%/}"
ignore "$(basename "$s")" && continue
est_voyage "$s" && ajoute "$s"
done
fi
done
done
fi
if [ "${#voyages[@]}" -eq 0 ]; then
echo
@@ -126,7 +134,6 @@ fi
echo
echo "📁 ${#voyages[@]} voyage(s) à traiter : ${voyages[*]}"
[ "$ignores" -gt 0 ] && [ "$EXPLICITE" = "0" ] && echo " ($ignores dossier(s) ignoré(s), ce ne sont pas des voyages)"
echo
# --- Traitement --------------------------------------------------------------
@@ -148,10 +155,12 @@ for dir in "${voyages[@]}"; do
elif [ "$(etat_dossier "$dir/rushs")" != "ok" ]; then
echo "⏭️ Compression sautée : pas de dossier rushs/ utilisable."
elif ! "$SCRIPT_DIR/compress.sh" "$dir"; then
echo "⚠️ Compression incomplète sur $dir — on continue,"
echo " les cartes seront générées avec les vidéos disponibles."
echo "⚠️ Compression incomplète sur $dir — on continue avec les vidéos disponibles."
fi
# Non bloquant : sans photos web ni aperçus, les pages utilisent les originaux.
"$SCRIPT_DIR/prepare_web.py" "$dir" || echo "⚠️ Préparation web incomplète sur $dir — on continue."
if ! "$SCRIPT_DIR/genere_carte.py" "$dir"; then
echo "❌ Génération des cartes échouée sur $dir."
ko=$((ko + 1)); echo; continue
@@ -162,9 +171,6 @@ for dir in "${voyages[@]}"; do
echo
done
# --- Index général : UNE SEULE FOIS, après la boucle -------------------------
# (l'ancienne version l'appelait à chaque itération, y compris pour les
# dossiers non conformes — soit N fois le même travail.)
echo "=================================================="
echo "▶ Index général"
echo "=================================================="
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Prépare les médias d'un voyage pour le web, sans toucher aux originaux.
photos_web/<nom> photos réduites (WEB_PX px de grand côté), 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
Usage : ./prepare_web.py [dossier_voyage]
Variables :
WEB_PX=1600 grand côté des photos web (un popup fait au plus 800 px
de large, ×2 pour les écrans Retina)
WEB_QUALITE=80 qualité JPEG des photos web
FORCE=1 refait tout, même ce qui est à jour
Un fichier déjà produit et plus récent que sa source n'est pas refait.
Photos : Pillow (pip install pillow), sinon `sips` (fourni avec macOS).
Aperçus : ffmpeg.
"""
import os
import shutil
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
try:
from PIL import Image, ImageOps
except ImportError:
Image = None
WEB_PX = int(os.environ.get("WEB_PX", 1600))
QUALITE = int(os.environ.get("WEB_QUALITE", 80))
FORCE = os.environ.get("FORCE") == "1"
TRAVAILLEURS = os.cpu_count() or 4
def a_jour(sortie, source):
return (not FORCE and os.path.exists(sortie) and os.path.getsize(sortie) > 0
and os.path.getmtime(sortie) >= os.path.getmtime(source))
def lister(dossier, extensions):
"""Fichiers du dossier ayant l'une des extensions, hors fichiers cachés et « ._ » de macOS."""
if not os.path.isdir(dossier):
return []
return sorted(f for f in os.listdir(dossier)
if not f.startswith('.') and f.lower().endswith(extensions))
def nettoyer_temporaires(dossier):
"""Restes d'une exécution interrompue (Ctrl-C) : fichiers .tmp-* cachés."""
for f in os.listdir(dossier):
if f.startswith(".tmp-"):
os.remove(os.path.join(dossier, f))
# --- Photos ------------------------------------------------------------------
def reduire_pillow(src, dst):
with Image.open(src) as im:
im = ImageOps.exif_transpose(im) # applique la rotation EXIF
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im.thumbnail((WEB_PX, WEB_PX), Image.LANCZOS)
im.save(dst, "JPEG", quality=QUALITE, optimize=True, progressive=True)
def reduire_sips(src, dst):
subprocess.run(["sips", "-Z", str(WEB_PX), "-s", "format", "jpeg",
"-s", "formatOptions", str(QUALITE), src, "--out", dst],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def preparer_photos(voyage):
src_dir = os.path.join(voyage, "photos")
dst_dir = os.path.join(voyage, "photos_web")
photos = lister(src_dir, (".jpg", ".jpeg"))
if not photos:
print("📸 Photos web : aucune photo.")
return True
if Image is not None:
reduire = reduire_pillow
elif shutil.which("sips"):
reduire = reduire_sips
else:
print("⚠️ Photos web : ni Pillow (pip install pillow) ni sips disponible,"
" les pages afficheront les originaux.")
return False
os.makedirs(dst_dir, exist_ok=True)
nettoyer_temporaires(dst_dir)
a_faire = [n for n in photos if not a_jour(os.path.join(dst_dir, n), os.path.join(src_dir, n))]
def traiter(nom):
src, dst = os.path.join(src_dir, nom), os.path.join(dst_dir, nom)
# Écrit sous un nom caché puis renommé : jamais de fichier à moitié écrit
# sous le vrai nom. L'extension est gardée, sips s'en sert.
tmp = os.path.join(dst_dir, ".tmp-" + nom)
try:
reduire(src, tmp)
os.replace(tmp, dst)
return os.path.getsize(src), os.path.getsize(dst)
except Exception as e:
print(f" ❌ {nom} : {e}")
if os.path.exists(tmp):
os.remove(tmp)
return None
with ThreadPoolExecutor(TRAVAILLEURS) as pool:
resultats = [r for r in pool.map(traiter, a_faire) if r]
msg = f"📸 Photos web : {len(resultats)} réduite(s), {len(photos) - len(a_faire)} déjà à jour"
if resultats:
avant = sum(r[0] for r in resultats) / 1048576
apres = sum(r[1] for r in resultats) / 1048576
msg += f" ({avant:.0f} Mo → {apres:.0f} Mo)"
print(msg)
return len(resultats) == len(a_faire)
# --- Aperçus vidéo -----------------------------------------------------------
def extraire_apercu(sources, dst):
"""Une image à 3 s du début (après les secousses du démarrage), à 0 s si la
vidéo est plus courte. Sources essayées dans l'ordre : le 720p se décode
bien plus vite, le 1080p sert de secours s'il est abîmé.
Renvoie la source utilisée, ou None."""
tmp = os.path.join(os.path.dirname(dst), ".tmp-" + os.path.basename(dst))
for src in sources:
for instant in ("3", "0"):
subprocess.run(["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
"-ss", instant, "-i", src, "-frames:v", "1",
"-vf", "scale=-2:540", "-q:v", "5", "-f", "mjpeg", tmp],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if os.path.exists(tmp) and os.path.getsize(tmp) > 0:
os.replace(tmp, dst)
return src
if os.path.exists(tmp):
os.remove(tmp)
return None
def preparer_apercus(voyage):
src_dir = os.path.join(voyage, "videos")
dst_dir = os.path.join(voyage, "apercus")
fichiers = lister(src_dir, (".mp4", ".mov"))
if not fichiers:
print("🎞️ Aperçus : aucune vidéo.")
return True
if not shutil.which("ffmpeg"):
print("⚠️ Aperçus : ffmpeg introuvable, vidéos sans image d'aperçu.")
return False
# Une vidéo = un nom de base, avec jusqu'à deux fichiers ; le 720p d'abord.
sources = {}
for f in fichiers:
base = os.path.splitext(f)[0]
mobile = base.endswith("_mobile")
if mobile:
base = base[:-len("_mobile")]
liste = sources.setdefault(base, [])
liste.insert(0 if mobile else len(liste), os.path.join(src_dir, f))
os.makedirs(dst_dir, exist_ok=True)
nettoyer_temporaires(dst_dir)
a_faire = [(srcs, os.path.join(dst_dir, b + ".jpg")) for b, srcs in sorted(sources.items())
if not a_jour(os.path.join(dst_dir, b + ".jpg"), srcs[0])]
with ThreadPoolExecutor(TRAVAILLEURS) as pool:
utilisees = list(pool.map(lambda t: extraire_apercu(*t), a_faire))
for (srcs, _), src in zip(a_faire, utilisees):
if src is None:
print(f" ❌ aperçu impossible, vidéo illisible : {os.path.basename(srcs[0])}")
elif src != srcs[0]:
print(f" ⚠️ {os.path.basename(srcs[0])} illisible (encodage interrompu ?),"
f" aperçu tiré de {os.path.basename(src)}. Relance compress.sh pour le refaire.")
ok = [u for u in utilisees if u]
print(f"🎞️ Aperçus : {len(ok)} créé(s), {len(sources) - len(a_faire)} déjà à jour")
return len(ok) == len(a_faire)
def main():
voyage = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else ".")
print(f"--- PRÉPARATION WEB : {os.path.basename(voyage)} ---")
ok = preparer_photos(voyage)
ok = preparer_apercus(voyage) and ok
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+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 %}