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
+2042
-829
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
+59
-97
@@ -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)
|
||||
# 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 !)
|
||||
# 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 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.
|
||||
-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
|
||||
)
|
||||
# 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)
|
||||
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
|
||||
|
||||
|
||||
+448
-401
@@ -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;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
video.load();
|
||||
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)
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
btnElement.style.opacity = "1";
|
||||
if (btnElement.innerText.includes("720p")) {
|
||||
btnElement.classList.add('active-720');
|
||||
} else {
|
||||
btnElement.classList.add('active-1080');
|
||||
}
|
||||
}
|
||||
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)")
|
||||
|
||||
// --- 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// On lance l'espion dès que la page est chargée
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
// ----------------------------------------
|
||||
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]
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
class Journal:
|
||||
"""Récit voyage.md converti en HTML, avec deux raccourcis résolus ici :
|
||||
|
||||
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; }
|
||||
[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
|
||||
|
||||
.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)
|
||||
|
||||
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 __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
|
||||
|
||||
# --- 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 _signaler(self, message):
|
||||
if message not in self._signale:
|
||||
self._signale.add(message)
|
||||
print(f" ⚠️ Journal : {message}")
|
||||
|
||||
m_mini = folium.Map(tiles='OpenStreetMap', zoom_control=False, control_scale=False, attribution_control=False)
|
||||
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>')
|
||||
|
||||
all_global_coords, sidebar_links_html = [], ""
|
||||
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>'
|
||||
|
||||
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>'
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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'):
|
||||
def nouvelle_carte(entete):
|
||||
m = folium.Map(tiles=None)
|
||||
folium.TileLayer(TUILES_SATELLITE, attr='Esri', name='Satellite').add_to(m)
|
||||
folium.TileLayer('OpenStreetMap', name='Plan').add_to(m)
|
||||
m.get_root().header.add_child(folium.Element(str(entete)))
|
||||
return m
|
||||
|
||||
|
||||
def ajouter(carte, fragment):
|
||||
carte.get_root().html.add_child(folium.Element(str(fragment)))
|
||||
|
||||
|
||||
def epingle(couleur, icone):
|
||||
return folium.DivIcon(html=str(F.epingle(couleur, icone)), icon_anchor=(12, 40))
|
||||
|
||||
|
||||
def fichiers_gpx(routes_dir):
|
||||
if not os.path.isdir(routes_dir):
|
||||
return []
|
||||
return sorted(f for f in os.listdir(routes_dir)
|
||||
if not est_sidecar(f) and f.lower().endswith('.gpx'))
|
||||
|
||||
|
||||
def groupes_photos(day_p, base_dir, pre):
|
||||
"""Regroupe les photos proches ; renvoie [(position, html, nb_photos)]."""
|
||||
groupes = []
|
||||
while not day_p.empty:
|
||||
ref = day_p.iloc[0]
|
||||
masque = ((np.abs(day_p['Latitude'] - ref['Latitude']) < TOLERANCE_GROUPE_PHOTOS)
|
||||
& (np.abs(day_p['Longitude'] - ref['Longitude']) < TOLERANCE_GROUPE_PHOTOS))
|
||||
photos = []
|
||||
for nom in day_p[masque]['Fichier']:
|
||||
nom = str(nom).strip()
|
||||
# Version allégée si prepare_web.py l'a produite ; le clic ouvre l'original.
|
||||
web = os.path.exists(os.path.join(base_dir, 'photos_web', nom))
|
||||
photos.append({'nom': nom,
|
||||
'src': f"{pre}{'photos_web' if web else 'photos'}/{url(nom)}",
|
||||
'href': f"{pre}photos/{url(nom)}"})
|
||||
groupes.append(([ref['Latitude'], ref['Longitude']], F.popup_photos(photos), len(photos)))
|
||||
day_p = day_p[~masque]
|
||||
return groupes
|
||||
|
||||
|
||||
def popup_video(v_name, base_dir, pre):
|
||||
nom = str(v_name).strip()
|
||||
base = os.path.splitext(nom)[0]
|
||||
apercu = None
|
||||
if os.path.exists(os.path.join(base_dir, 'apercus', base + '.jpg')):
|
||||
apercu = f"{pre}apercus/{url(base)}.jpg"
|
||||
return F.popup_video(nom, f"{pre}videos/{url(base)}_mobile.mp4", f"{pre}videos/{url(base)}.mp4", apercu)
|
||||
|
||||
|
||||
def exporter_trace_globale(base_dir, pistes):
|
||||
"""assets/trace.json : les journées du voyage, séparées et simplifiées à
|
||||
30 m, pour la carte globale de l'accueil (genere_carte_globale.py)."""
|
||||
lignes = []
|
||||
for piste in pistes:
|
||||
for segment in piste.segments:
|
||||
morceau = copy.deepcopy(segment)
|
||||
morceau.simplify(max_distance=30)
|
||||
if len(morceau.points) > 1:
|
||||
lignes.append([[round(p.latitude, 5), round(p.longitude, 5)] for p in morceau.points])
|
||||
with open(os.path.join(base_dir, 'assets', 'trace.json'), 'w', encoding='utf-8') as f:
|
||||
json.dump(lignes, f, separators=(',', ':'))
|
||||
|
||||
|
||||
class Voyage:
|
||||
"""Données communes à toutes les pages d'un voyage."""
|
||||
|
||||
def __init__(self, base_dir):
|
||||
self.base_dir = base_dir
|
||||
self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage"
|
||||
self.routes_dir = os.path.join(base_dir, "routes")
|
||||
self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"))
|
||||
self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"))
|
||||
jours_gpx = set()
|
||||
for f in fichiers_gpx(self.routes_dir):
|
||||
m = re.search(r'(\d{4}-\d{2}-\d{2})', f)
|
||||
if m:
|
||||
jours_gpx.add(pd.to_datetime(m.group(1)).date())
|
||||
self.jours = sorted(set(self.df_v['Jour']) | set(self.df_p['Jour']) | jours_gpx)
|
||||
self.traces = {} # jour → [(piste, points)]
|
||||
self.gpx_du_jour = {} # jour → nom du fichier GPX
|
||||
|
||||
def lire_traces(self):
|
||||
for jour in self.jours:
|
||||
day_str = jour.strftime('%Y-%m-%d')
|
||||
self.traces[jour] = []
|
||||
# Date n'importe où dans le nom du fichier.
|
||||
for gpx_file in fichiers_gpx(self.routes_dir):
|
||||
if day_str not in gpx_file:
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(routes_dir, gpx_file), 'r') as f:
|
||||
with open(os.path.join(self.routes_dir, gpx_file), 'r', encoding='utf-8') 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}")
|
||||
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))
|
||||
|
||||
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>'))
|
||||
|
||||
# REGROUPEMENT PHOTOS
|
||||
day_p = df_p[df_p['Jour'] == jour].copy()
|
||||
if not day_p.empty:
|
||||
tol = 0.0002
|
||||
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)
|
||||
day_p = day_p[~masque]
|
||||
def carte_du_jour(v, jour, version, journal, pre, lien_accueil, bouton_telechargement):
|
||||
"""Carte d'une journée avec photos et vidéos.
|
||||
|
||||
# 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]
|
||||
|
||||
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])
|
||||
|
||||
# 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>'
|
||||
|
||||
# 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>'
|
||||
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 = [], []
|
||||
|
||||
# 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>'
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
# 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)
|
||||
for pos, popup, nb in groupes_photos(v.df_p[v.df_p['Jour'] == jour].copy(), v.base_dir, pre):
|
||||
media_coords.append(pos)
|
||||
folium.Marker(location=pos, popup=folium.Popup(str(popup), max_width="100%"),
|
||||
icon=epingle("#FF3B30", "camera" if nb == 1 else "images")).add_to(cluster)
|
||||
|
||||
# Vidéos : marqueur au premier point GPS du rush.
|
||||
for v_name, group in v.df_v[v.df_v['Jour'] == jour].groupby('Fichier'):
|
||||
pos = group[['Latitude', 'Longitude']].values.tolist()[0]
|
||||
media_coords.append(pos)
|
||||
folium.Marker(location=pos, popup=folium.Popup(str(popup_video(v_name, v.base_dir, pre)), max_width="100%"),
|
||||
icon=epingle("#007AFF", "play")).add_to(cluster)
|
||||
|
||||
# Cadrage : la trace GPX en priorité, sinon la position des médias
|
||||
# (sans quoi un jour sans GPX s'ouvre sur une mappemonde).
|
||||
b, rejetes = bornes(day_coords or media_coords, robuste=not day_coords)
|
||||
if b:
|
||||
m_day.fit_bounds(b, padding=(50, 50))
|
||||
if rejetes and pre:
|
||||
print(f" ℹ️ {day_str} : {rejetes} point(s) GPS aberrant(s) exclu(s) du cadrage")
|
||||
|
||||
folium.LayerControl(position='topright').add_to(m_day)
|
||||
plugins.Fullscreen(position='topright').add_to(m_day)
|
||||
m_day.save(os.path.join(html_output_dir, file_name))
|
||||
|
||||
# --- FINALISATION ---
|
||||
if all_global_coords:
|
||||
m_global.fit_bounds(all_global_coords, padding=(50, 50))
|
||||
m_mini.fit_bounds(all_global_coords, padding=(20, 20))
|
||||
|
||||
sidebar_html = f'<div style="position: fixed; top: 15px; left: 15px; width: 160px; max-height: 85vh; overflow-y: auto; background: rgba(255,255,255,0.9); z-index: 9999; padding: 12px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.2);"><a href="../" target="_top" style="display:block; margin-bottom:15px; padding:12px; background:#444; color:white; text-decoration:none; border-radius:8px; font-weight:bold; text-align:center; border: 2px solid #666; font-family:sans-serif;">⬅ Retour</a><div style="border-top: 1px solid #ccc; margin-bottom: 10px; padding-top: 10px; font-family:sans-serif; font-size:12px; font-weight:bold; color:#666; text-align:center;">PAR JOURS</div>{sidebar_links_html}</div>'
|
||||
m_global.get_root().html.add_child(folium.Element(sidebar_html + get_journal_ui()))
|
||||
folium.LayerControl().add_to(m_global)
|
||||
|
||||
m_global.save(os.path.join(base_dir, "index.html"))
|
||||
m_mini.save(os.path.join(base_dir, "mini.html"))
|
||||
return m_day, media_coords
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else ".")
|
||||
html_dir = os.path.join(base_dir, "html")
|
||||
os.makedirs(html_dir, exist_ok=True)
|
||||
v = Voyage(base_dir)
|
||||
if not v.jours:
|
||||
print(f"❌ {v.nom} : aucune date trouvée (ni GPX daté, ni média géolocalisé).")
|
||||
sys.exit(1)
|
||||
v.lire_traces()
|
||||
|
||||
version = installer_assets(base_dir)
|
||||
journal = Journal(base_dir, v.jours)
|
||||
racine = lien_racine_site(base_dir)
|
||||
un_seul_jour = len(v.jours) == 1
|
||||
|
||||
# Trace complète du voyage. Écrite à la racine du voyage et non dans
|
||||
# routes/, qui est relu à chaque génération.
|
||||
gpx_complet = gpxpy.gpx.GPX()
|
||||
gpx_complet.name = f"{v.nom} - trace complete"
|
||||
gpx_complet.creator = "genere_carte.py"
|
||||
for jour in v.jours:
|
||||
gpx_complet.tracks.extend(t for t, _ in v.traces[jour])
|
||||
|
||||
bouton_complet, km = None, 0
|
||||
if gpx_complet.tracks:
|
||||
nom_fichier = f"{v.nom}_complet.gpx"
|
||||
with open(os.path.join(base_dir, nom_fichier), 'w', encoding='utf-8') as f:
|
||||
f.write(gpx_complet.to_xml())
|
||||
nb_pts = sum(len(sg.points) for t in gpx_complet.tracks for sg in t.segments)
|
||||
try:
|
||||
km = gpx_complet.length_2d() / 1000.0
|
||||
except Exception:
|
||||
km = 0
|
||||
print(f"🧭 Trace complète : {nom_fichier} ({len(gpx_complet.tracks)} étape(s), {nb_pts} points"
|
||||
+ (f", {km:.0f} km" if km else "") + ")")
|
||||
bouton_complet = lambda pre: F.bouton_gpx( # noqa: E731
|
||||
f"{pre}{url(nom_fichier)}", "📥 Trace complète" + (f" ({km:.0f} km)" if km else ""))
|
||||
exporter_kml(base_dir, v.nom, gpx_complet.tracks, km)
|
||||
exporter_trace_globale(base_dir, gpx_complet.tracks)
|
||||
|
||||
# --- Cartes du jour (html/carte_JJ.html)
|
||||
liens_jours, coords_medias = [], []
|
||||
for i, jour in enumerate(v.jours):
|
||||
day_str = jour.strftime('%Y-%m-%d')
|
||||
file_name = f'carte_{day_str}.html'
|
||||
liens_jours.append({'libelle': day_str, 'lien': f'html/{file_name}',
|
||||
'couleur': COULEURS_JOURS[i % len(COULEURS_JOURS)]})
|
||||
if un_seul_jour:
|
||||
# La carte du jour est aussi la page du voyage : son bouton mène à
|
||||
# l'accueil du site, et on y télécharge la trace complète.
|
||||
accueil, bouton = "../" + racine, bouton_complet("../") if bouton_complet else None
|
||||
else:
|
||||
gpx = v.gpx_du_jour.get(jour)
|
||||
accueil = "../index.html"
|
||||
bouton = F.bouton_gpx(f"../routes/{url(gpx)}", "📥 GPX") if gpx else None
|
||||
m_day, medias = carte_du_jour(v, jour, version, journal, "../", accueil, bouton)
|
||||
coords_medias.extend(medias)
|
||||
m_day.save(os.path.join(html_dir, file_name))
|
||||
|
||||
# --- Miniature pour l'accueil du site
|
||||
coords_traces = [p for j in v.jours for _, pts in v.traces[j] for p in pts]
|
||||
cadrage = coords_traces or coords_medias
|
||||
b, rejetes = bornes(cadrage, robuste=not coords_traces)
|
||||
m_mini = folium.Map(tiles='OpenStreetMap', zoom_control=False,
|
||||
control_scale=False, attribution_control=False)
|
||||
for i, jour in enumerate(v.jours):
|
||||
for _, pts in v.traces[jour]:
|
||||
folium.PolyLine(pts, color=COULEURS_JOURS[i % len(COULEURS_JOURS)],
|
||||
weight=3, opacity=0.8).add_to(m_mini)
|
||||
if b:
|
||||
m_mini.fit_bounds(b, padding=(20, 20))
|
||||
m_mini.save(os.path.join(base_dir, "mini.html"))
|
||||
|
||||
# --- Page du voyage (index.html)
|
||||
if un_seul_jour:
|
||||
# Une seule journée : la page du voyage est directement la carte du
|
||||
# jour, avec photos et vidéos (pas d'étape intermédiaire inutile).
|
||||
m_index, _ = carte_du_jour(v, v.jours[0], version, journal, "", racine,
|
||||
bouton_complet("") if bouton_complet else None)
|
||||
else:
|
||||
m_index = nouvelle_carte(F.entete("", version))
|
||||
for i, jour in enumerate(v.jours):
|
||||
day_str = jour.strftime('%Y-%m-%d')
|
||||
couleur = COULEURS_JOURS[i % len(COULEURS_JOURS)]
|
||||
popup = F.popup_trace(day_str, couleur, f'html/carte_{day_str}.html')
|
||||
for _, pts in v.traces[jour]:
|
||||
folium.PolyLine(pts, color=couleur, weight=7, opacity=0.8,
|
||||
popup=folium.Popup(str(popup), max_width=200)).add_to(m_index)
|
||||
if b:
|
||||
source = "traces GPX" if coords_traces else "positions des médias"
|
||||
print(f"🗺️ Cadrage général d'après {len(cadrage)} point(s) ({source})"
|
||||
+ (f", {rejetes} aberrant(s) exclu(s)" if rejetes else ""))
|
||||
m_index.fit_bounds(b, padding=(50, 50))
|
||||
if bouton_complet:
|
||||
ajouter(m_index, bouton_complet(""))
|
||||
ajouter(m_index, F.barre_jours(racine, liens_jours))
|
||||
ajouter(m_index, journal.rendu("html/", ""))
|
||||
folium.LayerControl().add_to(m_index)
|
||||
m_index.save(os.path.join(base_dir, "index.html"))
|
||||
print(f"✅ {v.nom} : {len(v.jours)} jour(s) → index.html"
|
||||
+ (" (carte du jour directement)" if un_seul_jour else "") + ", mini.html, html/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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])
|
||||
+154
-113
@@ -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."""
|
||||
m = re.search(r'(19|20)\d{2}', nom)
|
||||
annee = int(m.group(0)) if m else 0
|
||||
return (-annee, nom.lower())
|
||||
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 lien(trip, suffixe=""):
|
||||
"""URL sûre : les espaces et les & des noms de dossier sont encodés."""
|
||||
return html.escape(quote(trip) + suffixe)
|
||||
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)
|
||||
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 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 ".")
|
||||
+85
-79
@@ -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))
|
||||
fi
|
||||
done
|
||||
else
|
||||
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