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:
1 parent
5a04f159ca
commit
97b936fd3e
17 files changed
+2023
-810
No files matched your search
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
@@ -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'
|
||||
|
||||
```
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
+436
-389
@@ -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)">×</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)">❮</button><button class="nav-btn next" onclick="moveSlide(this, 1)">❯</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))
|
||||
return m_day, media_coords
|
||||
|
||||
# --- 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)
|
||||
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()
|
||||
|
||||
m_global.save(os.path.join(base_dir, "index.html"))
|
||||
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()
|
||||
Executable
+394
@@ -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
@@ -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 ".")
|
||||
+82
-76
@@ -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
|
||||
[ "$EXPLICITE" = "1" ] && echo "❌ $dir : aucun de rushs/ routes/ photos/ n'est utilisable :$details"
|
||||
ignores=$((ignores + 1))
|
||||
for d in */; do
|
||||
d="${d%/}"
|
||||
ignore "$d" && continue
|
||||
if est_voyage "$d"; then
|
||||
ajoute "$d"
|
||||
else
|
||||
# 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
|
||||
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 "=================================================="
|
||||
|
||||
Executable
+192
@@ -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()
|
||||
@@ -0,0 +1,37 @@
|
||||
{#- Page d'accueil : une rubrique par section (Voyages, Balades…), avec les
|
||||
voyages les plus récents et un lien vers la liste complète. -#}
|
||||
{% extends "base_accueil.html" %}
|
||||
{% from "base_accueil.html" import carte %}
|
||||
|
||||
{% block sous_titre %}{{ sous_titre }}{% endblock %}
|
||||
|
||||
{% block contenu %}
|
||||
{% if not rubriques %}
|
||||
<p class="vide">Aucun voyage pour l'instant.<br>Lance <code>genere_site.sh</code> pour en générer.</p>
|
||||
{% else %}
|
||||
<div class="rubriques">
|
||||
{% for r in rubriques %}
|
||||
<section>
|
||||
<div class="rubrique-titre">
|
||||
<h2>{{ r.titre }}</h2>
|
||||
<a href="{{ r.page }}">Tout voir ({{ r.voyages|length }}) →</a>
|
||||
</div>
|
||||
<div class="grid compact">
|
||||
{% for v in r.voyages[:nb_recents] %}
|
||||
{{ carte(v) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if carte_globale %}
|
||||
<section class="carte-externe" id="carte">
|
||||
<div class="rubrique-titre">
|
||||
<h2>La carte de nos principaux voyages</h2>
|
||||
<a href="{{ carte_globale }}">Ouvrir en grand ↗</a>
|
||||
</div>
|
||||
<iframe src="{{ carte_globale }}" loading="lazy" title="Carte de nos principaux voyages"></iframe>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{#- Gabarit commun à la page d'accueil (accueil.html) et aux listes complètes
|
||||
(liste.html). Les pages filles remplissent les blocs titre et contenu. -#}
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block titre %}{{ titre_site }}{% endblock %}</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f4f4f9; margin: 0; padding: 20px; color: #222;
|
||||
}
|
||||
header { text-align: center; margin-bottom: 30px; }
|
||||
h1 { margin: 0 0 6px; font-size: clamp(1.6rem, 4vw, 2.4rem); }
|
||||
.sous-titre { color: #777; font-size: 0.9rem; }
|
||||
main { max-width: 1200px; margin: 0 auto; }
|
||||
a.retour { display: inline-block; margin-bottom: 10px; color: #3498db; text-decoration: none; font-weight: bold; }
|
||||
|
||||
/* Rubriques de l'accueil, côte à côte ; empilées quand la place manque. */
|
||||
.rubriques {
|
||||
display: grid; gap: 40px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(480px, 100%), 1fr));
|
||||
}
|
||||
.rubrique-titre {
|
||||
display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
|
||||
margin: 0 0 16px; padding-bottom: 6px; border-bottom: 2px solid #ddd;
|
||||
}
|
||||
.rubrique-titre h2 { margin: 0; font-size: 1.3rem; }
|
||||
.rubrique-titre a, a.tout-voir { color: #3498db; text-decoration: none; font-weight: bold; font-size: 0.9rem; white-space: nowrap; }
|
||||
a.tout-voir { display: block; margin-top: 14px; text-align: right; }
|
||||
|
||||
/* min() : sur un écran étroit, une colonne fixe déborderait. */
|
||||
.grid { display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(min(var(--carte, 400px), 100%), 1fr)); }
|
||||
.compact { --carte: 220px; }
|
||||
.card {
|
||||
background: #fff; border-radius: 15px; overflow: hidden;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
}
|
||||
.card:hover { transform: translateY(-4px); box-shadow: 0 14px 28px rgba(0,0,0,0.15); }
|
||||
/* Toute la carte est cliquable ; l'iframe ne capte pas les clics. */
|
||||
.card > a { display: block; color: inherit; text-decoration: none; }
|
||||
.map-container { width: 100%; height: 300px; border: none; pointer-events: none; display: block; }
|
||||
.compact .map-container { height: 190px; }
|
||||
.card-content { padding: 16px 20px 20px; text-align: center; }
|
||||
.card h3 { text-transform: capitalize; margin: 0 0 4px; font-size: 1.2rem; }
|
||||
.compact .card h3 { font-size: 1.05rem; }
|
||||
.periode { color: #888; font-size: 0.85rem; }
|
||||
.carte-externe { margin-top: 50px; scroll-margin-top: 20px; }
|
||||
.carte-externe iframe {
|
||||
display: block; width: 100%; height: min(600px, 75vh); border: none;
|
||||
border-radius: 15px; box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
.vide { text-align: center; color: #777; margin-top: 60px; line-height: 1.7; }
|
||||
code { background: #e8e8ef; padding: 2px 6px; border-radius: 4px; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #16161a; color: #e8e8ea; }
|
||||
.card { background: #232329; box-shadow: 0 10px 20px rgba(0,0,0,0.4); }
|
||||
.sous-titre, .vide, .periode { color: #9a9aa2; }
|
||||
.rubrique-titre { border-color: #333; }
|
||||
code { background: #2e2e36; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{% block entete %}🌍 {{ titre_site }}{% endblock %}</h1>
|
||||
<div class="sous-titre">{% block sous_titre %}{% endblock %}</div>
|
||||
</header>
|
||||
<main>
|
||||
{% block contenu %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{% macro carte(v) -%}
|
||||
<article class="card">
|
||||
<a href="{{ v.chemin }}/index.html">
|
||||
{# loading="lazy" : sinon chaque miniature charge une carte Leaflet complète dès l'ouverture #}
|
||||
<iframe class="map-container" src="{{ v.chemin }}/mini.html" loading="lazy" title="Carte de {{ v.titre }}" tabindex="-1"></iframe>
|
||||
<div class="card-content">
|
||||
<h3>{{ v.titre }}</h3>
|
||||
{% if v.periode %}<div class="periode">{{ v.periode }}</div>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* =============================================================================
|
||||
Pages d'un voyage : carte générale (index.html) et cartes du jour (html/).
|
||||
Copié dans <voyage>/assets/ à chaque génération.
|
||||
============================================================================= */
|
||||
|
||||
/* --- Contrôles Leaflet : zoom en bas à gauche, la barre latérale occupe le haut */
|
||||
.leaflet-top.leaflet-left { top: auto !important; bottom: 20px !important; left: 15px !important; }
|
||||
.leaflet-popup-content-wrapper { border-radius: 12px; max-width: 95vw !important; }
|
||||
|
||||
/* --- Épingles des médias --------------------------------------------------- */
|
||||
.pin { position: relative; height: 40px; width: 24px; }
|
||||
.pin-tete {
|
||||
position: absolute; top: 0; z-index: 2;
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
background: var(--c); border: 2px solid #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.4);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 12px;
|
||||
}
|
||||
.pin-tige {
|
||||
position: absolute; top: 22px; left: 10.5px; z-index: 1;
|
||||
width: 3px; height: 18px; background: var(--c);
|
||||
}
|
||||
|
||||
/* --- Boutons flottants ----------------------------------------------------- */
|
||||
.btn-flottant {
|
||||
position: fixed; right: 15px; z-index: 10001; display: block;
|
||||
padding: 12px 20px; border: 2px solid #fff; border-radius: 50px;
|
||||
color: #fff; text-decoration: none; font: bold 14px sans-serif;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.4); cursor: pointer;
|
||||
}
|
||||
.btn-journal { bottom: 25px; z-index: 10000; background: #FFD700; color: #000; }
|
||||
.btn-gpx { bottom: 85px; background: #28a745; }
|
||||
.btn-accueil {
|
||||
top: 15px; left: 15px; right: auto; border-radius: 10px;
|
||||
background: #333; box-shadow: 0 4px 10px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* --- Barre latérale de la carte générale ------------------------------------ */
|
||||
.sidebar {
|
||||
position: fixed; top: 15px; left: 15px; z-index: 9999;
|
||||
width: 160px; max-height: 85vh; overflow-y: auto; padding: 12px;
|
||||
background: rgba(255,255,255,0.9); border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2); font-family: sans-serif;
|
||||
}
|
||||
.sidebar a {
|
||||
display: block; margin-bottom: 8px; padding: 12px; border-radius: 8px;
|
||||
color: #fff; text-decoration: none; font-weight: bold; text-align: center;
|
||||
}
|
||||
.sidebar .btn-retour { margin-bottom: 15px; background: #444; border: 2px solid #666; }
|
||||
.sidebar .jour { background: var(--c); }
|
||||
.sidebar-titre {
|
||||
border-top: 1px solid #ccc; margin-bottom: 10px; padding-top: 10px;
|
||||
font-size: 12px; font-weight: bold; color: #666; text-align: center;
|
||||
}
|
||||
|
||||
/* --- Popup d'une étape (clic sur la trace) ---------------------------------- */
|
||||
.popup-trace { text-align: center; font-family: sans-serif; }
|
||||
.popup-trace .btn-jour {
|
||||
display: inline-block; margin-top: 14px; padding: 8px 12px; border-radius: 4px;
|
||||
background: var(--c); color: #fff; text-decoration: none; font-weight: bold;
|
||||
}
|
||||
|
||||
/* --- Popup vidéo ------------------------------------------------------------ */
|
||||
.popup-video { width: calc(85vw - 40px); max-width: 1000px; text-align: center; }
|
||||
.popup-video video {
|
||||
display: block; width: 100%; max-height: 70vh;
|
||||
border-radius: 8px; background: #000;
|
||||
}
|
||||
.header-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: #f0f0f0; padding: 5px 10px; border-radius: 4px; margin-bottom: 5px;
|
||||
}
|
||||
.file-name {
|
||||
font: bold 11px monospace; color: #333; max-width: 60%;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.quality-controls { display: flex; gap: 6px; }
|
||||
.quality-btn {
|
||||
padding: 3px 8px; border: 1px solid #ccc; border-radius: 12px; background: #fff;
|
||||
font: bold 10px sans-serif; text-transform: uppercase;
|
||||
cursor: pointer; opacity: 0.6; transition: 0.2s;
|
||||
}
|
||||
.quality-btn:hover { opacity: 1; background: #eee; }
|
||||
.active-720 { background: #2ECC71 !important; color: #fff; border-color: #27ae60 !important; opacity: 1 !important; }
|
||||
.active-1080 { background: #007AFF !important; color: #fff; border-color: #0056b3 !important; opacity: 1 !important; }
|
||||
.qbtn-disabled, .qbtn-disabled:hover {
|
||||
opacity: 0.35 !important; background: #eee !important;
|
||||
cursor: not-allowed !important; text-decoration: line-through;
|
||||
}
|
||||
.hevc-note {
|
||||
display: none; margin-top: 6px; padding: 6px 10px; border-radius: 6px;
|
||||
font: 11px sans-serif; color: #8a6d3b; background: #fcf8e3; text-align: left;
|
||||
}
|
||||
|
||||
/* --- Popup photos (diaporama) ----------------------------------------------- */
|
||||
.slider-container { position: relative; width: calc(85vw - 60px); max-width: 800px; min-height: 200px; text-align: center; }
|
||||
.file-header { font: bold 11px monospace; color: #555; margin-bottom: 4px; }
|
||||
.slide img { display: block; margin: auto; max-width: 100%; max-height: 65vh; border-radius: 8px; }
|
||||
.nav-btn {
|
||||
position: absolute; top: 50%; transform: translateY(-50%); z-index: 10;
|
||||
padding: 12px; border: 2px solid #fff; border-radius: 50%;
|
||||
background: rgba(0,0,0,0.6); color: #fff; font-size: 18px; cursor: pointer;
|
||||
}
|
||||
.prev { left: -10px; }
|
||||
.next { right: -10px; }
|
||||
.slide-counter { margin-top: 8px; font: bold 13px sans-serif; color: #333; }
|
||||
|
||||
/* --- Carnet de route -------------------------------------------------------- */
|
||||
#journal-panel {
|
||||
position: fixed; top: 0; right: -100%; z-index: 10005;
|
||||
width: 400px; height: 100%; padding: 25px; overflow-y: auto;
|
||||
background: #fff; box-shadow: -5px 0 15px rgba(0,0,0,0.2);
|
||||
font-family: sans-serif; transition: 0.4s;
|
||||
}
|
||||
#journal-panel.open { right: 0; }
|
||||
@media (max-width: 600px) { #journal-panel { width: 85%; } }
|
||||
.close-btn {
|
||||
position: absolute; top: 15px; right: 15px;
|
||||
border: none; background: none; font-size: 24px; cursor: pointer;
|
||||
}
|
||||
.journal-content a.lien-jour {
|
||||
display: inline-block; margin: 4px 0 10px; padding: 5px 12px; border-radius: 14px;
|
||||
background: #007AFF; color: #fff !important; text-decoration: none;
|
||||
font-size: 12px; font-weight: bold;
|
||||
}
|
||||
.journal-content a.lien-jour:hover { background: #0056b3; }
|
||||
.journal-content a.lien-jour.jour-actif { background: #2ECC71; box-shadow: 0 0 0 3px rgba(46,204,113,0.3); }
|
||||
.journal-content a.lien-media {
|
||||
color: #007AFF; text-decoration: underline;
|
||||
text-decoration-style: dotted; text-underline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* =============================================================================
|
||||
Pages d'un voyage : vidéos, diaporamas photo et carnet de route.
|
||||
Copié dans <voyage>/assets/ à chaque génération.
|
||||
|
||||
Les gestionnaires sont appelés par des onclick="" dans les popups : Leaflet
|
||||
bloque la propagation des clics hors des popups, une délégation au niveau
|
||||
du document ne les verrait pas.
|
||||
============================================================================= */
|
||||
|
||||
// --- Vidéos ------------------------------------------------------------------
|
||||
// Le 720p (H.264) est chargé par défaut et se lit partout. Le 1080p est en
|
||||
// HEVC : si le navigateur ne le décode pas (Firefox surtout), on grise le bouton.
|
||||
var HEVC_OK = (function () {
|
||||
try {
|
||||
var t = document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L120.B0"');
|
||||
return t === 'probably' || t === 'maybe';
|
||||
} catch (e) { return false; }
|
||||
})();
|
||||
|
||||
function applyCodecSupport(popup) {
|
||||
if (HEVC_OK) return;
|
||||
var btns = popup.querySelectorAll('.quality-btn[data-hevc="1"]');
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
btns[i].classList.add('qbtn-disabled');
|
||||
btns[i].title = "Le 1080p est encodé en HEVC, que ce navigateur ne sait pas décoder. Essaie Safari, Chrome ou Edge.";
|
||||
}
|
||||
var note = popup.querySelector('.hevc-note');
|
||||
if (note) note.style.display = 'block';
|
||||
}
|
||||
|
||||
function changeQuality(btn) {
|
||||
if (btn.classList.contains('qbtn-disabled')) return;
|
||||
var box = btn.closest('.popup-video');
|
||||
var video = box.querySelector('video');
|
||||
var newSrc = btn.getAttribute('data-src');
|
||||
|
||||
// video.src est une URL absolue, data-src est relatif : on compare les noms.
|
||||
var nomActuel = decodeURIComponent(video.src).split('/').pop();
|
||||
var nomVoulu = decodeURIComponent(newSrc).split('/').pop();
|
||||
if (nomActuel === nomVoulu) return;
|
||||
|
||||
var savedTime = video.currentTime;
|
||||
var wasPlaying = !video.paused;
|
||||
video.src = newSrc;
|
||||
video.onloadedmetadata = function () {
|
||||
this.currentTime = savedTime;
|
||||
if (wasPlaying) {
|
||||
var p = this.play();
|
||||
if (p !== undefined) p.catch(function () { console.log("Lecture bloquée"); });
|
||||
}
|
||||
this.onloadedmetadata = null;
|
||||
};
|
||||
video.load();
|
||||
|
||||
var buttons = box.querySelectorAll('.quality-btn');
|
||||
for (var i = 0; i < buttons.length; i++) buttons[i].classList.remove('active-720', 'active-1080');
|
||||
btn.classList.add(btn.getAttribute('data-hevc') === '1' ? 'active-1080' : 'active-720');
|
||||
}
|
||||
|
||||
// À l'ouverture d'une popup vidéo : codec, image d'aperçu, puis lecture.
|
||||
// L'aperçu n'est posé qu'à ce moment-là : en attribut poster="" direct, le
|
||||
// navigateur chargerait d'un coup les aperçus de toutes les vidéos du jour.
|
||||
function nomFichier(u) { return decodeURIComponent(u || '').split('/').pop(); }
|
||||
|
||||
// Filet de sécurité : si une version ne se lit pas (1080p HEVC malgré la
|
||||
// détection, fichier abîmé ou absent), on essaie l'autre.
|
||||
function surErreurVideo() {
|
||||
var video = this, box = video.closest('.popup-video');
|
||||
video._essais = video._essais || {};
|
||||
video._essais[nomFichier(video.src)] = true;
|
||||
var candidats = [video.getAttribute('data-fallback')];
|
||||
var hd = box.querySelector('.quality-btn[data-hevc="1"]');
|
||||
if (hd && HEVC_OK) candidats.push(hd.getAttribute('data-src'));
|
||||
for (var i = 0; i < candidats.length; i++) {
|
||||
var c = candidats[i];
|
||||
if (c && !video._essais[nomFichier(c)]) {
|
||||
var btn = box.querySelector('.quality-btn[data-src="' + c + '"]');
|
||||
if (btn) changeQuality(btn); else { video.src = c; video.load(); }
|
||||
var p = video.play();
|
||||
if (p !== undefined) p.catch(function () {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function preparePopup(node) {
|
||||
applyCodecSupport(node);
|
||||
var video = node.querySelector('video');
|
||||
if (!video) return;
|
||||
// Leaflet réinsère la même popup à chaque ouverture : une seule initialisation.
|
||||
if (!video._pret) {
|
||||
video._pret = true;
|
||||
var apercu = video.getAttribute('data-poster');
|
||||
if (apercu) video.setAttribute('poster', apercu);
|
||||
video.addEventListener('error', surErreurVideo);
|
||||
}
|
||||
var p = video.play();
|
||||
if (p !== undefined) p.catch(function (e) { console.log("Autoplay bloqué par le navigateur", e); });
|
||||
}
|
||||
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (m) {
|
||||
m.addedNodes.forEach(function (node) {
|
||||
if (node.nodeType === 1 && node.classList && node.classList.contains('leaflet-popup')) {
|
||||
preparePopup(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
// --- Diaporama photos ----------------------------------------------------------
|
||||
function moveSlide(btn, step) {
|
||||
var slides = btn.parentElement.querySelectorAll('.slides .slide');
|
||||
var actif = Array.prototype.findIndex.call(slides, function (s) { return s.style.display !== 'none'; });
|
||||
if (actif === -1) actif = 0;
|
||||
slides[actif].style.display = 'none';
|
||||
var suivant = (actif + step + slides.length) % slides.length;
|
||||
slides[suivant].style.display = 'block';
|
||||
btn.parentElement.querySelector('.slide-counter').innerText = (suivant + 1) + '/' + slides.length;
|
||||
}
|
||||
|
||||
// --- Carnet de route -----------------------------------------------------------
|
||||
// localStorage lève une exception sur file:// dans certains navigateurs
|
||||
// (Safari, navigation privée) : on l'enveloppe.
|
||||
function memLire(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
||||
function memEcrire(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
||||
|
||||
// Journée à afficher : celle de l'URL (#jour-AAAA-MM-JJ) ou celle de la page.
|
||||
function journalCible() {
|
||||
var m = /#jour-(\d{4}-\d{2}-\d{2})/.exec(window.location.hash || '');
|
||||
return m ? m[1] : (window.JOUR_COURANT || null);
|
||||
}
|
||||
|
||||
function journalDefile(jour) {
|
||||
var panel = document.getElementById('journal-panel');
|
||||
if (!panel || !jour) return false;
|
||||
var el = document.getElementById('ancre-' + jour);
|
||||
if (!el) return false;
|
||||
var r = el.getBoundingClientRect(), pr = panel.getBoundingClientRect();
|
||||
panel.scrollTop += (r.top - pr.top) - 70; // laisse voir le titre du jour
|
||||
var actif = panel.querySelector('.jour-actif');
|
||||
if (actif) actif.classList.remove('jour-actif');
|
||||
el.classList.add('jour-actif');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rejoué plusieurs fois : les CSS distantes (Leaflet, Font Awesome) arrivent
|
||||
// après DOMContentLoaded et décalent les hauteurs.
|
||||
function journalRecale() {
|
||||
var j = journalCible();
|
||||
journalDefile(j);
|
||||
[150, 500, 1200].forEach(function (d) { setTimeout(function () { journalDefile(j); }, d); });
|
||||
}
|
||||
|
||||
function toggleJournal(open) {
|
||||
var panel = document.getElementById('journal-panel');
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('open', open);
|
||||
memEcrire('journalOpen', open ? 'true' : 'false');
|
||||
if (open) journalRecale();
|
||||
}
|
||||
|
||||
function journalInit() {
|
||||
var panel = document.getElementById('journal-panel');
|
||||
if (!panel) return;
|
||||
var vientDuCarnet = /#jour-\d{4}-\d{2}-\d{2}/.test(window.location.hash || '');
|
||||
if (memLire('journalOpen') === 'true' || vientDuCarnet) {
|
||||
panel.classList.add('open');
|
||||
if (vientDuCarnet) memEcrire('journalOpen', 'true');
|
||||
}
|
||||
journalRecale();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
window.addEventListener('DOMContentLoaded', journalInit);
|
||||
} else {
|
||||
journalInit();
|
||||
}
|
||||
window.addEventListener('load', journalRecale);
|
||||
window.addEventListener('hashchange', function () { toggleJournal(true); });
|
||||
@@ -0,0 +1,95 @@
|
||||
{#- =============================================================================
|
||||
Morceaux de HTML insérés par genere_carte.py dans les pages folium.
|
||||
Échappement automatique : seules les variables marquées |safe passent telles
|
||||
quelles. Les chemins arrivent déjà encodés pour les URL (quote()).
|
||||
Les styles sont dans carte.css, le comportement dans carte.js.
|
||||
============================================================================= -#}
|
||||
|
||||
{# Feuille de style et script communs, avec un numéro de version pour que les
|
||||
navigateurs ne gardent pas une ancienne copie en cache. #}
|
||||
{% macro entete(prefixe, version) -%}
|
||||
<link rel="stylesheet" href="{{ prefixe }}assets/carte.css?v={{ version }}">
|
||||
<script src="{{ prefixe }}assets/carte.js?v={{ version }}"></script>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro epingle(couleur, icone) -%}
|
||||
<div class="pin" style="--c:{{ couleur }}"><div class="pin-tete"><i class="fa fa-{{ icone }}"></i></div><div class="pin-tige"></div></div>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{# Popup affichée au clic sur la trace d'une journée (carte générale). #}
|
||||
{% macro popup_trace(jour, couleur, lien) -%}
|
||||
<div class="popup-trace"><b>📅 {{ jour }}</b><br><a class="btn-jour" style="--c:{{ couleur }}" href="{{ lien }}" target="_top">Détails</a></div>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{# photos : liste de {nom, src (version web), href (original)} #}
|
||||
{% macro popup_photos(photos) -%}
|
||||
<div class="slider-container">
|
||||
<div class="slides">
|
||||
{%- for p in photos %}
|
||||
<div class="slide"{% if not loop.first %} style="display:none"{% endif %}>
|
||||
<div class="file-header">{{ p.nom }}</div>
|
||||
<a href="{{ p.href }}" target="_blank"><img src="{{ p.src }}" alt="{{ p.nom }}" loading="lazy"></a>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
{%- if photos|length > 1 %}
|
||||
<button class="nav-btn prev" onclick="moveSlide(this, -1)">❮</button>
|
||||
<button class="nav-btn next" onclick="moveSlide(this, 1)">❯</button>
|
||||
<div class="slide-counter">1/{{ photos|length }}</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{# 720p H.264 chargé par défaut (lisible partout), 1080p HEVC à la demande.
|
||||
preload="none" : rien n'est téléchargé avant l'ouverture de la popup. #}
|
||||
{% macro popup_video(nom, mobile, hd, apercu) -%}
|
||||
<div class="popup-video">
|
||||
<div class="header-row">
|
||||
<span class="file-name" title="{{ nom }}">{{ nom }}</span>
|
||||
<div class="quality-controls">
|
||||
<span class="quality-btn active-720" data-src="{{ mobile }}" onclick="changeQuality(this)">720p</span>
|
||||
<span class="quality-btn" data-hevc="1" data-src="{{ hd }}" onclick="changeQuality(this)">1080p</span>
|
||||
</div>
|
||||
</div>
|
||||
<video controls playsinline webkit-playsinline preload="none" src="{{ mobile }}" data-fallback="{{ mobile }}"
|
||||
{%- if apercu %} data-poster="{{ apercu }}"{% endif %}></video>
|
||||
<div class="hevc-note">Le 1080p est encodé en H.265/HEVC, que ce navigateur ne sait pas décoder.
|
||||
La lecture en 720p fonctionne normalement ; pour la haute définition, utilise Safari, Chrome ou Edge.</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{# contenu : HTML du récit (voyage.md converti), déjà prêt. #}
|
||||
{% macro journal(contenu) -%}
|
||||
<div id="journal-panel">
|
||||
<button class="close-btn" onclick="toggleJournal(false)">×</button>
|
||||
<div class="journal-content">{{ contenu|safe }}</div>
|
||||
</div>
|
||||
<button class="btn-flottant btn-journal" onclick="toggleJournal(true)">📖 Journal</button>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro bouton_accueil(lien) -%}
|
||||
<a class="btn-flottant btn-accueil" href="{{ lien }}" target="_top">🏠 Accueil</a>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro bouton_gpx(lien, libelle) -%}
|
||||
<a class="btn-flottant btn-gpx" href="{{ lien }}" download>{{ libelle }}</a>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{# jours : liste de {libelle, lien, couleur} #}
|
||||
{% macro barre_jours(retour, jours) -%}
|
||||
<nav class="sidebar">
|
||||
<a class="btn-retour" href="{{ retour }}" target="_top">⬅ Retour</a>
|
||||
<div class="sidebar-titre">PAR JOURS</div>
|
||||
{%- for j in jours %}
|
||||
<a class="jour" style="--c:{{ j.couleur }}" href="{{ j.lien }}" target="_top">{{ j.libelle }}</a>
|
||||
{%- endfor %}
|
||||
</nav>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{#- Morceaux de HTML de la carte globale (genere_carte_globale.py).
|
||||
Les liens visent target="_top" : la carte est affichée dans un cadre
|
||||
de la page d'accueil, c'est toute la page qui doit changer. -#}
|
||||
|
||||
{% macro popup_voyage(titre, periode, km, lien) -%}
|
||||
<div style="font-family:sans-serif; text-align:center; min-width:160px">
|
||||
<b style="font-size:14px; text-transform:capitalize">{{ titre }}</b><br>
|
||||
<span style="color:#777; font-size:12px">{{ periode }}{% if km %} · {{ "%.0f"|format(km) }} km{% endif %}</span><br>
|
||||
<a href="{{ lien }}" target="_top" style="display:inline-block; margin-top:10px; padding:7px 14px; border-radius:16px; background:#3498db; color:#fff; text-decoration:none; font-weight:bold">Voir le voyage</a>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro popup_mymaps(nom, description) -%}
|
||||
<div style="font-family:sans-serif; max-height:240px; overflow:auto">
|
||||
<b style="font-size:14px">{{ nom }}</b>
|
||||
{%- if description %}<div style="margin-top:6px; font-size:12px; color:#444">{{ description|liens_et_lignes }}</div>{% endif %}
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{# Visible seulement quand la carte est ouverte seule (voir script_page). #}
|
||||
{% macro bouton_accueil() -%}
|
||||
<a id="retour-accueil" href="index.html" style="display:none; position:fixed; bottom:25px; left:15px; z-index:1000; padding:10px 16px; border-radius:10px; background:#333; color:#fff; font:bold 14px sans-serif; text-decoration:none; box-shadow:0 4px 10px rgba(0,0,0,.4)">🏠 Accueil</a>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{# Exécuté au chargement complet : folium crée la carte plus bas dans la page. #}
|
||||
{% macro script_page(carte) -%}
|
||||
window.addEventListener('load', function () {
|
||||
if (window.self !== window.top) return;
|
||||
{{ carte }}.scrollWheelZoom.enable();
|
||||
document.getElementById('retour-accueil').style.display = 'block';
|
||||
});
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{# Libellé d'un voyage dans la légende (sélecteur de calques). #}
|
||||
{% macro legende(titre, couleur) -%}
|
||||
<span style="display:inline-block; width:18px; height:4px; margin:0 6px 3px 0; border-radius:2px; vertical-align:middle; background:{{ couleur }}"></span><span style="text-transform:capitalize">{{ titre }}</span>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{#- Liste complète d'une rubrique (voyages.html, balades.html…). -#}
|
||||
{% extends "base_accueil.html" %}
|
||||
{% from "base_accueil.html" import carte %}
|
||||
|
||||
{% block titre %}{{ r.titre }} — {{ titre_site }}{% endblock %}
|
||||
{% block entete %}{{ r.titre }}{% endblock %}
|
||||
{% block sous_titre %}{{ r.voyages|length }} {{ r.unite }}{% endblock %}
|
||||
|
||||
{% block contenu %}
|
||||
<a class="retour" href="index.html">← {{ titre_site }}</a>
|
||||
<div class="grid">
|
||||
{% for v in r.voyages %}
|
||||
{{ carte(v) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user