mise à jours HEVC pour 1080p

This commit is contained in:
2026-08-17 23:50:11 +02:00
parent aed1467284
commit 5a04f159ca
6 changed files with 1139 additions and 167 deletions
+177
View File
@@ -0,0 +1,177 @@
#!/bin/bash
# =============================================================================
# CALIBRATION CRF — HEVC
#
# 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é.
#
# Usage :
# ./bench_crf.sh rushs/GH011182.MP4 [start_sec] [duree_sec]
#
# Variables :
# CODEC=hevc palier 1080p HEVC (défaut)
# CODEC=h264 palier 720p H.264
# HEIGHT=1080 hauteur cible du test (1080 ou 720)
# CRFS="22 24 26 28 30 32"
# PRESET=medium
# ENCODER=cpu|vt (en mode vt, CRFS est interprété comme des valeurs -q:v)
# KEEP=1 conserve les fichiers encodés pour comparaison visuelle
#
# Les deux paliers du site se calibrent séparément — les échelles de CRF
# de x264 et x265 ne sont PAS comparables :
# ./bench_crf.sh rushs/GH011182.MP4 30 20 # 1080p HEVC
# CODEC=h264 HEIGHT=720 CRFS="20 22 24 26" ./bench_crf.sh rushs/GH011182.MP4 30 20
#
# Choisis un extrait REPRÉSENTATIF : du mouvement, de la végétation, du
# ciel dégradé. Un plan fixe sur un mur donnera un CRF trop optimiste.
# =============================================================================
set -uo pipefail
SRC="${1:?Usage: ./bench_crf.sh <fichier_rush> [start_sec] [duree_sec]}"
START="${2:-30}"
DUR="${3:-20}"
CODEC="${CODEC:-hevc}"
HEIGHT="${HEIGHT:-1080}"
if [ "$CODEC" = "h264" ]; then
CRFS="${CRFS:-18 20 22 24 26 28}"
else
CRFS="${CRFS:-22 24 26 28 30 32}"
fi
PRESET="${PRESET:-medium}"
ENCODER="${ENCODER:-cpu}"
KEEP="${KEEP:-0}"
[ -f "$SRC" ] || { echo "❌ Fichier introuvable : $SRC"; exit 1; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/bench_crf.XXXXXX")"
cleanup() { [ "$KEEP" = "1" ] || rm -rf "$WORK"; }
trap cleanup EXIT
# --- Détection de la métrique disponible -------------------------------------
FILTERS_LIST="$(ffmpeg -hide_banner -filters 2>/dev/null)"
if printf '%s' "$FILTERS_LIST" | grep -q "libvmaf"; then
METRIC="vmaf"
else
METRIC="ssim"
echo "️ libvmaf absent de ton ffmpeg → repli sur SSIM."
echo " (VMAF est bien plus parlant : brew install ffmpeg l'inclut normalement)"
fi
# --- Référence : extrait copié sans réencodage -------------------------------
REF="$WORK/ref.mp4"
echo "✂️ Extraction de la référence (${DUR}s à partir de ${START}s)…"
ffmpeg -nostdin -hide_banner -loglevel error -y \
-ss "$START" -i "$SRC" -t "$DUR" \
-map 0:v:0 -c copy -tag:v hvc1 "$REF" 2>/dev/null
if [ ! -s "$REF" ]; then
echo "⚠️ Copie directe impossible, réencodage sans perte visible de la référence…"
ffmpeg -nostdin -hide_banner -loglevel error -y \
-ss "$START" -i "$SRC" -t "$DUR" \
-map 0:v:0 -c:v libx265 -preset ultrafast -x265-params lossless=1 \
-tag:v hvc1 "$REF"
fi
# Si on teste une hauteur inférieure à la source, la référence de comparaison
# doit être à la MÊME définition que le test : on fabrique une référence scalée.
SRC_H=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$REF" | head -1)
REF_CMP="$REF"
if [ "$SRC_H" != "$HEIGHT" ]; then
REF_CMP="$WORK/ref_${HEIGHT}.mp4"
echo "📐 Référence de comparaison rescalée en ${HEIGHT}p…"
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$REF" \
-vf "scale=-2:${HEIGHT}:flags=lanczos" \
-c:v libx265 -preset ultrafast -x265-params lossless=1 -tag:v hvc1 "$REF_CMP"
fi
REF_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$REF" | cut -d. -f1)
REF_DUR=${REF_DUR:-$DUR}
echo
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"
if [ "$CODEC" = "h264" ]; then
if [ "$ENCODER" = "vt" ]; then
VOPTS=(-c:v h264_videotoolbox -profile:v high -q:v "$crf" -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)
fi
else
if [ "$ENCODER" = "vt" ]; then
VOPTS=(-c:v hevc_videotoolbox -profile:v main -q:v "$crf" -allow_sw 1 -realtime 0 -tag:v hvc1)
else
VOPTS=(-c:v libx265 -preset "$PRESET" -profile:v main -crf "$crf"
-x265-params "keyint=120:min-keyint=24:aq-mode=3" -tag:v hvc1)
fi
fi
T0=$(date +%s)
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$REF" \
-map 0:v:0 "${VOPTS[@]}" \
-vf "scale=-2:${HEIGHT}:flags=lanczos" \
-pix_fmt yuv420p \
-color_primaries bt709 -color_trc bt709 -colorspace bt709 \
-an "$OUT" 2>/dev/null
T1=$(date +%s)
ENC_TIME=$((T1 - T0))
[ -s "$OUT" ] || { printf "%-6s %10s\n" "$crf" "ÉCHEC"; continue; }
BYTES=$(stat -f%z "$OUT" 2>/dev/null || stat -c%s "$OUT")
MB=$(awk -v b="$BYTES" 'BEGIN{printf "%.1f", b/1048576}')
KBPS=$(awk -v b="$BYTES" -v d="$REF_DUR" 'BEGIN{printf "%.0f", b*8/1000/d}')
# --- Mesure de qualité ---
if [ "$METRIC" = "vmaf" ]; then
SCORE=$(ffmpeg -nostdin -hide_banner -loglevel info -i "$OUT" -i "$REF_CMP" \
-lavfi "[0:v]setpts=PTS-STARTPTS[d];[1:v]setpts=PTS-STARTPTS[r];[d][r]libvmaf" \
-f null - 2>&1 | grep -oE "VMAF score: [0-9.]+" | tail -1 | grep -oE "[0-9.]+")
SCORE=$(awk -v s="${SCORE:-0}" 'BEGIN{printf "%.2f", s}')
else
SCORE=$(ffmpeg -nostdin -hide_banner -loglevel info -i "$OUT" -i "$REF_CMP" \
-lavfi "[0:v]setpts=PTS-STARTPTS[d];[1:v]setpts=PTS-STARTPTS[r];[d][r]ssim" \
-f null - 2>&1 | grep -oE "All:[0-9.]+" | tail -1 | cut -d: -f2)
SCORE=$(awk -v s="${SCORE:-0}" 'BEGIN{printf "%.4f", s}')
fi
printf "%-6s %9s M %9s k %10s %8s s\n" "$crf" "$MB" "$KBPS" "$SCORE" "$ENC_TIME"
done
echo
echo "📖 Comment lire ce tableau"
if [ "$METRIC" = "vmaf" ]; then
cat <<'EOF'
VMAF ≥ 95 : indiscernable de la source à distance normale
VMAF 90-95 : excellent, cible raisonnable pour un site de voyage
VMAF 85-90 : bon, quelques artefacts visibles en plein écran
VMAF < 85 : dégradation nette sur les scènes chargées
→ Prends le CRF le plus ÉLEVÉ qui reste au-dessus de ton seuil.
Le gain de taille entre deux CRF consécutifs s'écrase vite ;
dès que la courbe de qualité plonge, tu es allé trop loin.
EOF
else
cat <<'EOF'
SSIM ≥ 0.98 : très bon
SSIM 0.96-0.98 : correct pour du web
SSIM < 0.95 : dégradation visible
→ Prends le CRF le plus ÉLEVÉ qui reste au-dessus de ton seuil.
⚠️ SSIM est moins fiable que VMAF sur des images très texturées
(feuillage, eau, gravier) : vérifie AUSSI à l'œil avec KEEP=1.
EOF
fi
if [ "$KEEP" = "1" ]; then
echo
echo "📁 Fichiers conservés dans : $WORK"
echo " Compare à l'œil, en plein écran, sur les plans les plus chargés."
fi
Regular → Executable
+297 -68
View File
@@ -1,83 +1,312 @@
#!/bin/bash #!/bin/bash
shopt -s nocaseglob # =============================================================================
# COMPRESSION ROAD-TRIP — STRATÉGIE HYBRIDE + ENCODAGE PARALLÈLE
# Source : GoPro Hero 9, rushs en HEVC 1080p
#
# 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
#
# 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é.
#
# 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 !)
# 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
#
# 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
# =============================================================================
set -uo pipefail
shopt -s nocaseglob nullglob
# --- 0. MODE INTERNE ---------------------------------------------------------
# Quand le script est rappelé par xargs pour traiter UN rush, il arrive ici.
if [ "${1:-}" = "--encode-one" ]; then
INTERNAL=1
BASE_DIR="${RT_BASE_DIR:-.}"
TARGET="${2:?--encode-one attend un fichier}"
else
INTERNAL=0
BASE_DIR="${1:-.}"
fi
# --- 1. CONFIGURATION ---
# Utilise l'argument 1 comme dossier racine, ou le dossier courant par défaut
BASE_DIR="${1:-.}"
SOURCE_DIR="$BASE_DIR/rushs" SOURCE_DIR="$BASE_DIR/rushs"
DEST_DIR="$BASE_DIR/videos" DEST_DIR="$BASE_DIR/videos"
# Vérification du dossier source # --- 1. CONFIGURATION --------------------------------------------------------
ENCODER="${ENCODER:-cpu}"
PRESET="${PRESET:-medium}"
CRF_HEVC="${CRF_HEVC:-26}"
CRF_H264="${CRF_H264:-25}"
VTQ_HD="${VTQ_HD:-60}"
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.
detect_cores() {
local c=""
c=$(sysctl -n hw.perflevel0.logicalcpu 2>/dev/null); [ -n "$c" ] && { echo "$c"; return; }
c=$(sysctl -n hw.logicalcpu 2>/dev/null); [ -n "$c" ] && { echo "$c"; return; }
c=$(nproc 2>/dev/null); [ -n "$c" ] && { echo "$c"; return; }
echo 4
}
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
JOBS=1
else
JOBS="${JOBS:-$(( CORES / 4 ))}"
fi
[ "$JOBS" -lt 1 ] 2>/dev/null && JOBS=1
[ -z "$JOBS" ] && 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.
THREADS_SD=$(( THREADS / 2 ))
[ "$THREADS_SD" -lt 2 ] && THREADS_SD=2
# --- 2. FONCTION D'ENCODAGE --------------------------------------------------
# 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.
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
)
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"
)
fi
fi
ffmpeg -nostdin -hide_banner -loglevel error ${statopt[@]+"${statopt[@]}"} \
-i "$in" \
-map 0:v:0 -map 0:a:0\? \
"${vopts[@]}" \
${vf_args[@]+"${vf_args[@]}"} \
-pix_fmt yuv420p \
-fpsmax "$FPS_MAX" \
-color_primaries bt709 -color_trc bt709 -colorspace bt709 \
-c:a aac -b:a "$abr" -ac 1 -ar 48000 \
-map_metadata -1 \
-movflags +faststart \
-y "$out"
}
fsize() { stat -f%z "$1" 2>/dev/null || stat -c%s "$1"; }
# --- 3. TRAITEMENT D'UN RUSH -------------------------------------------------
process_one() {
local f="$1"
local filename base desktop_out mobile_out src_h vf_hd
filename=$(basename "$f")
base="${filename%.*}"
desktop_out="$DEST_DIR/${base}.mp4"
mobile_out="$DEST_DIR/${base}_mobile.mp4"
if [ "$FORCE" != "1" ] && [ -s "$desktop_out" ] && [ -s "$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.
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=$!
encode "$f" "$mobile_out" "scale=-2:720:flags=lanczos" h264 "$CRF_H264" "$VTQ_SD" 5M 10M 64k &
pid_sd=$!
wait "$pid_hd"; rc_hd=$?
wait "$pid_sd"; rc_sd=$?
if [ "$rc_hd" = "0" ] && [ "$rc_sd" = "0" ] && [ -s "$desktop_out" ] && [ -s "$mobile_out" ]; then
printf " ✅ %-24s %s → 1080p %s (HEVC) + 720p %s (H.264)\n" \
"$filename" "$(du -h "$f" | cut -f1)" \
"$(du -h "$desktop_out" | cut -f1)" "$(du -h "$mobile_out" | cut -f1)"
[ -n "${RT_STATS:-}" ] && \
echo "$(fsize "$f") $(( $(fsize "$desktop_out") + $(fsize "$mobile_out") ))" >> "$RT_STATS"
return 0
else
echo " ❌ Échec sur $filename"
return 1
fi
}
# Point de sortie du mode interne (un seul rush, appelé par xargs)
if [ "$INTERNAL" = "1" ]; then
process_one "$TARGET"
exit $?
fi
# =============================================================================
# MODE PRINCIPAL
# =============================================================================
if [ ! -d "$SOURCE_DIR" ]; then if [ ! -d "$SOURCE_DIR" ]; then
echo "❌ Erreur : Dossier source $SOURCE_DIR introuvable." echo "❌ Erreur : Dossier source $SOURCE_DIR introuvable."
exit 1 exit 1
fi fi
# Création du dossier de destination
mkdir -p "$DEST_DIR" mkdir -p "$DEST_DIR"
echo "--- COMPRESSION ROAD-TRIP (CPU OPTIMISÉ) ---" # Vérification des encodeurs disponibles.
echo "📂 Source : $SOURCE_DIR" # (on stocke la liste : un `| grep -q` déclencherait un SIGPIPE que pipefail
echo "📤 Destination : $DEST_DIR" # transformerait en faux négatif)
ENCODERS_LIST="$(ffmpeg -hide_banner -encoders 2>/dev/null)"
has_enc() { printf '%s' "$ENCODERS_LIST" | grep -q "$1"; }
# 2. BOUCLE DE TRAITEMENT if [ "$ENCODER" = "vt" ]; then
for f in "$SOURCE_DIR"/*.{mp4,mov}; do has_enc "hevc_videotoolbox" || { echo "❌ hevc_videotoolbox indisponible. Utilise ENCODER=cpu."; exit 1; }
[ -e "$f" ] || continue has_enc "h264_videotoolbox" || { echo "❌ h264_videotoolbox indisponible. Utilise ENCODER=cpu."; exit 1; }
else
# Extraction du nom de base (ex: GH011182.mp4 -> GH011182) has_enc "libx265" || { echo "❌ libx265 absent de ton ffmpeg (brew install ffmpeg)."; exit 1; }
filename=$(basename "$f") has_enc "libx264" || { echo "❌ libx264 absent de ton ffmpeg (brew install ffmpeg)."; exit 1; }
base="${filename%.*}" fi
# Conventions de nommage validées
desktop_out="$DEST_DIR/${base}.mp4"
mobile_out="$DEST_DIR/${base}_mobile.mp4"
echo "🎬 Traitement de : $filename ..." # Liste des rushs
files=()
# --- A. VERSION DESKTOP (1080p60 - Validé 233 Mo) --- for f in "$SOURCE_DIR"/*.mp4 "$SOURCE_DIR"/*.mov; do
# Réglages : CRF 28, Maxrate 12M, GOP 120, FPS source exact [ -e "$f" ] && files+=("$f")
ffmpeg -i "$f" \
-c:v libx264 -preset slow -profile:v high \
-crf 28 \
-tune film \
-g 120 -keyint_min 120 -sc_threshold 0 \
-maxrate 15M -bufsize 30M \
-pix_fmt yuv420p \
-r 60000/1001 \
-vf "scale=1920:-2:flags=lanczos" \
-c:a aac -b:a 96k -ac 1 \
-map_metadata -1 \
-movflags +faststart \
-y -loglevel error \
"$desktop_out"
# --- B. VERSION MOBILE (720p60 - Optimisé 4G/Smartphone) ---
# Réglages : On baisse le maxrate à 6M pour la fluidité mobile
ffmpeg -i "$f" \
-c:v libx264 -preset slow -profile:v high \
-crf 28 \
-tune film \
-g 120 -keyint_min 120 -sc_threshold 0 \
-maxrate 6M -bufsize 12M \
-pix_fmt yuv420p \
-r 60000/1001 \
-vf "scale=1280:-2:flags=lanczos" \
-c:a aac -b:a 64k -ac 1 \
-map_metadata -1 \
-movflags +faststart \
-y -loglevel error \
"$mobile_out"
# Petit bilan de taille pour confirmer l'économie
if [ -f "$desktop_out" ]; then
d_size=$(du -h "$desktop_out" | cut -f1)
m_size=$(du -h "$mobile_out" | cut -f1)
echo " ✅ Desktop : $d_size (${base}.mp4)"
echo " ✅ Mobile : $m_size (${base}_mobile.mp4)"
fi
echo "---------------------------------------------------"
done done
shopt -u nocaseglob echo "--- COMPRESSION ROAD-TRIP (HYBRIDE HEVC / H.264) ---"
echo "✅ TOUTES LES VIDÉOS SONT PRÊTES." 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
echo "🧵 Parallélisme : $JOBS rush(s) à la fois × 2 paliers ($THREADS threads en 1080p, $THREADS_SD en 720p)"
echo " ($CORES cœur(s) performance détecté(s))"
echo " 1080p → HEVC (hvc1) | 720p → H.264 (avc1, lisible partout)"
echo "📼 ${#files[@]} rush(s) à traiter"
echo
if [ "${#files[@]}" -eq 0 ]; then
echo "️ Rien à faire."
exit 0
fi
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.
printf '%s\0' "${files[@]}" | xargs -0 -P "$JOBS" -n1 "$SCRIPT_PATH" --encode-one
rc=$?
T1=$(date +%s)
ELAPSED=$((T1 - T0))
echo
if [ -s "$RT_STATS" ]; then
awk -v el="$ELAPSED" '
{ n++; s += $1; o += $2 }
END {
if (n > 0) {
printf "✅ %d vidéo(s) traitée(s) en %d min %02d s\n", n, el/60, el%60
printf "📊 Rushs : %d Mo → Web : %d Mo (%.0f%% du volume source)\n", \
s/1048576, o/1048576, o*100/s
}
}' "$RT_STATS"
else
printf "️ Aucun nouvel encodage (durée : %d min %02d s)\n" $((ELAPSED/60)) $((ELAPSED%60))
fi
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
Regular → Executable
+224 -34
View File
@@ -1,52 +1,242 @@
#!/bin/bash #!/bin/bash
# =============================================================================
# EXTRACTION DES MÉTADONNÉES — photos EXIF + traces GPS embarquées GoPro
#
# Produit deux CSV sans en-tête, consommés 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.
# VARIANTE=D|A|B|C force le mode d'extraction vidéo (voir plus bas)
# =============================================================================
set -uo pipefail
shopt -s nocaseglob nullglob
# --- 1. CONFIGURATION DU RÉPERTOIRE ---
# On récupère l'argument 1 s'il existe, sinon on reste sur le répertoire courant
BASE_DIR="${1:-.}" BASE_DIR="${1:-.}"
# Définition des dossiers et fichiers par rapport à la base
PHOTOS_DIR="$BASE_DIR/photos" PHOTOS_DIR="$BASE_DIR/photos"
RUSHS_DIR="$BASE_DIR/rushs" RUSHS_DIR="$BASE_DIR/rushs"
CSV_PHOTOS="$BASE_DIR/export_photos.csv" CSV_PHOTOS="$BASE_DIR/export_photos.csv"
CSV_VIDEOS="$BASE_DIR/export_videos.csv" CSV_VIDEOS="$BASE_DIR/export_videos.csv"
# On active l'insensibilité à la casse pour les jokers (*.mp4, etc.) VIDEO_GPS="${VIDEO_GPS:-first}"
shopt -s nocaseglob VARIANTE="${VARIANTE:-}"
echo "--- DÉBUT DE L'EXTRACTION BRUTE ---" if ! command -v exiftool >/dev/null 2>&1; then
echo "📂 Répertoire racine : $BASE_DIR" echo "❌ exiftool introuvable. Installe-le : brew install exiftool"
exit 1
fi
# 2. RÉINITIALISATION DES FICHIERS CSV (Optionnel mais recommandé) echo "--- EXTRACTION DES MÉTADONNÉES ---"
# Si tu veux cumuler sans effacer, commente les deux lignes suivantes : echo "📂 Répertoire : $BASE_DIR"
> "$CSV_PHOTOS"
> "$CSV_VIDEOS"
# 3. PHOTOS (Fichiers .jpg, .jpeg) TMPD="$(mktemp -d "${TMPDIR:-/tmp}/extr.XXXXXX")"
echo "📸 Traitement des photos dans : $PHOTOS_DIR" ERRLOG="$TMPD/err.log"
: > "$ERRLOG"
trap 'rm -rf "$TMPD"' EXIT
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é.
#
# ⚠️ 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.
# 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
# =============================================================================
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'
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)
if (index(ligne, ":") == 0) next
tag = ligne; sub(/[ \t]*:.*/, "", tag); gsub(/[ \t]/, "", tag)
val = ligne; sub(/^[^:]*:[ \t]*/, "", val); gsub(/[ \t]+$/, "", val)
if (tag == "GPSDateTime" && dt == "") dt = val
else if (tag == "CreateDate" && cd == "") cd = val
else if (tag == "GPSLatitude") lat = val
else if (tag == "GPSLongitude") {
if (lat != "") {
n++
if (mode == "full") { plat[n] = lat; plon[n] = val }
else if (flat == "") { flat = lat; flon = val }
lat = ""
}
}
else if (tag == "GPSAltitude") { if (mode == "full" && n > 0) palt[n] = val }
else if (tag == "GPSSpeed") { if (mode == "full" && n > 0) pspd[n] = val }
if (mode != "full" && flat != "" && dt != "") {
print f "," dt "," flat "," flon ",,"; emis = 1; exit
}
}
END {
d = (dt != "" ? dt : cd)
if (d == "") exit
if (mode == "full") { for (i = 1; i <= n; i++) print f "," d "," plat[i] "," plon[i] "," palt[i] "," pspd[i] }
else if (!emis && flat != "") print f "," d "," flat "," flon ",,"
}
'
# 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")
case "$m" in
D) exiftool -ee -n -q -s -api largefilesupport=1 \
-GPSDateTime -GPSLatitude -GPSLongitude -GPSAltitude -GPSSpeed -CreateDate \
"$f" 2>>"$ERRLOG" \
| awk -v f="\"$nom\"" -v mode="$VIDEO_GPS" "$PARSE_GPS" ;;
A) exiftool -ee -n -q -api largefilesupport=1 -p "$FMT_V_COMPLET" "$f" 2>>"$ERRLOG" ;;
B) exiftool -ee -n -q -api largefilesupport=1 -p "$FMT_V_MINIMAL" "$f" 2>>"$ERRLOG" | awk '{print $0 ",,"}' ;;
C) exiftool -ee3 -n -q -api largefilesupport=1 -p "$FMT_V_MINIMAL" "$f" 2>>"$ERRLOG" | awk '{print $0 ",,"}' ;;
esac
}
# --- 1. PHOTOS ---------------------------------------------------------------
: > "$CSV_PHOTOS"
if [ -d "$PHOTOS_DIR" ]; then if [ -d "$PHOTOS_DIR" ]; then
for f in "$PHOTOS_DIR"/*.jp*g; do echo "📸 Photos : analyse en cours…"
[ -e "$f" ] || continue # UN SEUL appel exiftool pour tout le dossier : chaque lancement du binaire
# Utilisation du chemin complet pour exiftool mais basename pour le CSV # coûte 150-250 ms (Perl). Mesuré sur 150 photos : 29,3 s → 0,97 s.
exiftool -n -p "$(basename "$f")"',$DateTimeOriginal,$GPSLatitude,$GPSLongitude,$GPSAltitude' "$f" >> "$CSV_PHOTOS" exiftool -n -q -ext jpg -ext jpeg -p "$FMT_PHOTOS" "$PHOTOS_DIR" \
done 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.
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"
n1=$(nlines "$TMPD/p1.csv"); nt=$(nlines "$CSV_PHOTOS")
printf "📸 Photos : %d ligne(s)" "$nt"
[ "$nt" -gt "$n1" ] && printf " (dont %d récupérée(s) sans altitude)" "$((nt - n1))"
echo
else else
echo "⚠️ Dossier photos non trouvé, étape ignorée." echo "⚠️ Dossier photos non trouvé, étape ignorée."
fi fi
# 4. VIDÉOS (Fichiers .mp4, .mov avec flux GoPro) # --- 2. VIDÉOS ---------------------------------------------------------------
echo "🎬 Traitement des vidéos dans : $RUSHS_DIR" : > "$CSV_VIDEOS"
if [ -d "$RUSHS_DIR" ]; then if [ ! -d "$RUSHS_DIR" ]; then
for f in "$RUSHS_DIR"/*.{mp4,mov}; do echo "⚠️ Dossier rushs non trouvé, étape ignorée."
[ -e "$f" ] || continue
# Ta commande exiftool exacte pour le flux GoPro
exiftool -ee -n -p "$(basename "$f")"',$GPSDateTime,$GPSLatitude,$GPSLongitude,$GPSAltitude,$GPSSpeed' "$f" >> "$CSV_VIDEOS"
done
else else
echo "⚠️ Dossier rushs non trouvé, étape ignorée." 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.
case "$(basename "$f")" in ._*) continue ;; esac
rushs+=("$f")
done
total="${#rushs[@]}"
if [ "$total" -eq 0 ]; then
echo "⚠️ Aucun rush trouvé dans $RUSHS_DIR"
else
if [ "$VIDEO_GPS" = "full" ]; then
echo "🎬 Vidéos : $total rush(s), trace GPS COMPLÈTE"
echo " ⏳ Chaque fichier est lu intégralement."
else
echo "🎬 Vidéos : $total rush(s), premier point GPS seulement"
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.
if [ -z "$VARIANTE" ]; then
echo " 🔎 recherche du mode d'extraction…"
for essai in D A B C; do
k=0
for f in "${rushs[@]}"; do
k=$((k + 1)); [ "$k" -gt 3 ] && break
if [ -n "$(extrait_video "$essai" "$f" | head -n 1)" ]; then
VARIANTE="$essai"; break
fi
done
[ -n "$VARIANTE" ] && break
done
fi
case "${VARIANTE:-}" in
D) echo " ✔ mode D : lecture directe des tags (atteint [Doc1])" ;;
A) echo " ✔ mode A : format complet (date, lat, lon, alt, vitesse)" ;;
B) echo " ✔ mode B : format réduit (date, lat, lon)" ;;
C) echo " ✔ mode C : format réduit + -ee3 (sous-documents détaillés)" ;;
*) echo " ⚠️ Aucun mode ne donne de résultat sur les 3 premiers rushs."
echo " Diagnostic : exiftool -ee -G3 -n <un_rush> | grep -i gps"
VARIANTE=A ;;
esac
i=0; avec=0; sans=0
T0=$(date +%s)
for f in "${rushs[@]}"; do
i=$((i + 1))
printf " [%3d/%3d] %-28s " "$i" "$total" "$(basename "$f")"
avant=$(nlines "$CSV_VIDEOS")
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.
extrait_video "$VARIANTE" "$f" | head -n 1 >> "$CSV_VIDEOS"
fi
ajoutes=$(( $(nlines "$CSV_VIDEOS") - avant ))
if [ "$ajoutes" -gt 0 ]; then
if [ "$VIDEO_GPS" = "full" ]; then printf "✓ %d points\n" "$ajoutes"; else echo "✓"; fi
avec=$((avec + 1))
else
echo "— pas de GPS"; sans=$((sans + 1))
fi
done
T1=$(date +%s); EL=$((T1 - T0))
printf "🎬 Vidéos : %d point(s) GPS, %d rush(s) localisé(s)" "$(nlines "$CSV_VIDEOS")" "$avec"
[ "$sans" -gt 0 ] && printf ", %d sans GPS" "$sans"
printf " — %d min %02d s\n" $((EL / 60)) $((EL % 60))
fi
fi fi
# On désactive l'option nocaseglob echo "✅ Générés : $CSV_PHOTOS et $CSV_VIDEOS"
shopt -u nocaseglob
echo "--- TERMINÉ ---"
echo "✅ Fichiers générés : $CSV_PHOTOS et $CSV_VIDEOS"
Regular → Executable
+133 -14
View File
@@ -10,6 +10,7 @@ import sys
import markdown2 import markdown2
import numpy as np import numpy as np
import html import html
from urllib.parse import quote
# --- 1. CONFIGURATION --- # --- 1. CONFIGURATION ---
base_dir = sys.argv[1] if len(sys.argv) > 1 else "./" base_dir = sys.argv[1] if len(sys.argv) > 1 else "./"
@@ -27,6 +28,32 @@ if not os.path.exists(html_output_dir):
COULEURS_JOURS = ['#FF5733', '#2ECC71', '#3498DB', '#9B59B6', '#F1C40F', '#E67E22', '#1ABC9C', '#34495E'] COULEURS_JOURS = ['#FF5733', '#2ECC71', '#3498DB', '#9B59B6', '#F1C40F', '#E67E22', '#1ABC9C', '#34495E']
noms_colonnes = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse'] noms_colonnes = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse']
# --- É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.
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('.')
def url(chemin):
"""Chemin sûr pour un attribut src/href."""
return html.escape(quote(chemin))
def txt(valeur):
"""Texte sûr pour l'affichage."""
return html.escape(str(valeur))
# --- FONCTIONS UTILES --- # --- FONCTIONS UTILES ---
def nettoyer_et_trier(df): def nettoyer_et_trier(df):
if df is None or df.empty: if df is None or df.empty:
@@ -52,11 +79,44 @@ def create_pin(color, icon_name):
def inject_common_assets(m): def inject_common_assets(m):
custom_js = """ custom_js = """
<script> <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; }
})();
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 --- // --- GESTION DES BOUTONS 720p / 1080p ---
function changeQuality(videoId, newSrc, btnElement) { function changeQuality(videoId, newSrc, btnElement) {
if (btnElement.classList.contains('qbtn-disabled')) return;
var video = document.getElementById(videoId); var video = document.getElementById(videoId);
if (video.src.includes(newSrc)) return; // 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;
var savedTime = video.currentTime; var savedTime = video.currentTime;
var wasPlaying = !video.paused; var wasPlaying = !video.paused;
@@ -99,8 +159,15 @@ def inject_common_assets(m):
mutation.addedNodes.forEach(function(node) { mutation.addedNodes.forEach(function(node) {
// Si l'élément ajouté est une popup Leaflet // Si l'élément ajouté est une popup Leaflet
if (node.nodeType === 1 && node.classList && node.classList.contains('leaflet-popup')) { if (node.nodeType === 1 && node.classList && node.classList.contains('leaflet-popup')) {
applyCodecSupport(node);
var video = node.querySelector('video'); var video = node.querySelector('video');
if (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..."); console.log("🎬 Popup ouverte : Lancement de la vidéo...");
var playPromise = video.play(); var playPromise = video.play();
if (playPromise !== undefined) { if (playPromise !== undefined) {
@@ -170,6 +237,17 @@ def inject_common_assets(m):
.active-720 { background-color: #2ECC71 !important; color: white; border-color: #27ae60 !important; opacity: 1 !important; } .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; } .active-1080 { background-color: #007AFF !important; color: white; border-color: #0056b3 !important; opacity: 1 !important; }
.qbtn-disabled {
opacity: 0.35 !important; cursor: not-allowed !important;
text-decoration: line-through; background: #eee !important;
}
.qbtn-disabled:hover { opacity: 0.35 !important; background: #eee !important; }
.hevc-note {
display: none; margin-top: 6px; font-family: sans-serif; font-size: 11px;
color: #8a6d3b; background: #fcf8e3; border-radius: 6px; padding: 6px 10px;
text-align: left;
}
.leaflet-top.leaflet-left { top: auto !important; bottom: 20px !important; left: 15px !important; } .leaflet-top.leaflet-left { top: auto !important; bottom: 20px !important; left: 15px !important; }
.leaflet-popup-content-wrapper { border-radius: 12px; max-width: 95vw !important; } .leaflet-popup-content-wrapper { border-radius: 12px; max-width: 95vw !important; }
#journal-panel { #journal-panel {
@@ -211,6 +289,8 @@ try:
jours_gpx = set() jours_gpx = set()
if os.path.exists(routes_dir): if os.path.exists(routes_dir):
for f in os.listdir(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) match = re.search(r'(\d{4}-\d{2}-\d{2})', f)
if match: jours_gpx.add(pd.to_datetime(match.group(1)).date()) 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)) tous_les_jours = sorted(list(set(df_v['Jour'].unique()) | set(df_p['Jour'].unique()) | jours_gpx))
@@ -240,12 +320,18 @@ for i, jour in enumerate(tous_les_jours):
marker_cluster = MarkerCluster(name="Médias").add_to(m_day) marker_cluster = MarkerCluster(name="Médias").add_to(m_day)
day_coords = [] day_coords = []
media_coords = [] # position des photos/vidéos, filet de cadrage
current_gpx_filename = None current_gpx_filename = None
if os.path.exists(routes_dir): if os.path.exists(routes_dir):
for gpx_file in os.listdir(routes_dir): for gpx_file in os.listdir(routes_dir):
if gpx_file.startswith(day_str) and gpx_file.endswith('.gpx'): # `day_str in gpx_file` et non `.startswith(day_str)` : la liste des
current_gpx_filename = gpx_file # jours est bâtie avec re.search (date N'IMPORTE OÙ dans le nom),
# donc une trace « trace_2026-07-15_col.gpx » créait bien un jour
# mais n'était jamais retrouvée ici — jour vide, carte non cadrée.
if est_sidecar(gpx_file):
continue
if day_str in gpx_file and gpx_file.lower().endswith('.gpx'):
try: try:
with open(os.path.join(routes_dir, gpx_file), 'r') as f: with open(os.path.join(routes_dir, gpx_file), 'r') as f:
gpx = gpxpy.parse(f) gpx = gpxpy.parse(f)
@@ -259,12 +345,20 @@ for i, jour in enumerate(tous_les_jours):
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=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=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) folium.PolyLine(pts, color="#FF0000", weight=4, opacity=0.8).add_to(m_day)
except: pass # 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}")
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())) 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: 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/{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>')) 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 # REGROUPEMENT PHOTOS
day_p = df_p[df_p['Jour'] == jour].copy() day_p = df_p[df_p['Jour'] == jour].copy()
@@ -276,8 +370,14 @@ for i, jour in enumerate(tous_les_jours):
groupe = day_p[masque] groupe = day_p[masque]
fichiers = groupe['Fichier'].tolist() fichiers = groupe['Fichier'].tolist()
nb = len(fichiers) nb = len(fichiers)
slides = "".join([f'<div class="slide" style="display:{"block" if idx == 0 else "none"};"><div class="file-header">{p.strip()}</div><a href="../photos/{p.strip()}" target="_blank"><img src="../photos/{p.strip()}" loading="lazy"></a></div>' for idx, p in enumerate(fichiers)]) slides = "".join([
f'<div class="slide" style="display:{"block" if idx == 0 else "none"};">'
f'<div class="file-header">{txt(p.strip())}</div>'
f'<a href="../photos/{url(p.strip())}" target="_blank">'
f'<img src="../photos/{url(p.strip())}" alt="{txt(p.strip())}" loading="lazy"></a></div>'
for idx, p in enumerate(fichiers)])
btns = f'<button class="nav-btn prev" onclick="moveSlide(this, -1)">&#10094;</button><button class="nav-btn next" onclick="moveSlide(this, 1)">&#10095;</button><div class="slide-counter">1/{nb}</div>' if nb > 1 else "" btns = f'<button class="nav-btn prev" onclick="moveSlide(this, -1)">&#10094;</button><button class="nav-btn next" onclick="moveSlide(this, 1)">&#10095;</button><div class="slide-counter">1/{nb}</div>' if nb > 1 else ""
media_coords.append([ref['Latitude'], ref['Longitude']])
folium.Marker(location=[ref['Latitude'], ref['Longitude']], popup=folium.Popup(f'<div class="slider-container"><div class="slides">{slides}</div>{btns}</div>', max_width="100%"), icon=create_pin("#FF3B30", "camera" if nb==1 else "images")).add_to(marker_cluster) 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] day_p = day_p[~masque]
@@ -291,8 +391,9 @@ for i, jour in enumerate(tous_les_jours):
clean_id = re.sub(r'[^a-zA-Z0-9]', '', base_name) clean_id = re.sub(r'[^a-zA-Z0-9]', '', base_name)
video_id = f"vid_{clean_id}" video_id = f"vid_{clean_id}"
desktop_file = f"../videos/{base_name}.mp4" desktop_file = f"../videos/{url(base_name)}.mp4"
mobile_file = f"../videos/{base_name}_mobile.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 'autoplay muted' pour le lancement au clic
# Ajout 'preload=none' pour ne pas charger les autres # Ajout 'preload=none' pour ne pas charger les autres
@@ -301,11 +402,13 @@ for i, jour in enumerate(tous_les_jours):
f'<div style="width:calc(85vw - 40px); max-width:1000px; text-align:center;">' f'<div style="width:calc(85vw - 40px); max-width:1000px; text-align:center;">'
f'<div class="header-row">' f'<div class="header-row">'
f'<span class="file-name" title="{v_name.strip()}">{v_name.strip()}</span>' f'<span class="file-name" title="{txt(v_name.strip())}">{txt(v_name.strip())}</span>'
f'<div class="quality-controls">' f'<div class="quality-controls">'
# Le bouton 720p est actif par défaut (cohérent avec le fichier chargé) # 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>' f'<span class="quality-btn active-720" onclick="changeQuality(\'{video_id}\', \'{mobile_file}\', this)">720p</span>'
f'<span class="quality-btn" onclick="changeQuality(\'{video_id}\', \'{desktop_file}\', this)">1080p</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>'
f'</div>' f'</div>'
@@ -314,12 +417,28 @@ for i, jour in enumerate(tous_les_jours):
# 3. Pas d'autoplay. # 3. Pas d'autoplay.
f'<video id="{video_id}" style="width:100%; max-height:70vh; border-radius:8px; background:black;" ' f'<video id="{video_id}" style="width:100%; max-height:70vh; border-radius:8px; background:black;" '
f'controls playsinline webkit-playsinline preload="none" ' f'controls playsinline webkit-playsinline preload="none" '
f'src="{mobile_file}">' # <-- On pointe directement sur le fichier léger f'src="{mobile_file}" data-fallback="{mobile_file}">' # <-- 720p H.264 : lisible partout
f'</video></div>' f'</video>'
# 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>'
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) folium.Marker(location=pts_v[0], popup=folium.Popup(v_pop, max_width="100%"), icon=create_pin("#007AFF", "play")).add_to(marker_cluster)
if day_coords: m_day.fit_bounds(day_coords, padding=(50, 50)) # 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)
folium.LayerControl(position='topright').add_to(m_day) folium.LayerControl(position='topright').add_to(m_day)
plugins.Fullscreen(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)) m_day.save(os.path.join(html_output_dir, file_name))
Regular → Executable
+130 -40
View File
@@ -1,48 +1,138 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os """Page d'accueil listant tous les voyages du site.
EXCLURE = ['html', 'Scripts', '__pycache__', '.git'] Usage : ./genere_index_general.py [racine_du_site]
Un voyage est reconnu à la présence de son fichier mini.html (la carte
miniature produite par genere_carte.py).
"""
import os
import re
import sys
import html
from urllib.parse import quote
EXCLURE = {'html', 'Scripts', '__pycache__', '.git', 'node_modules', 'videos',
'photos', 'rushs', 'routes'}
OUTPUT_FILE = "index.html" OUTPUT_FILE = "index.html"
def generer_index_general():
road_trips = []
for d in sorted(os.listdir('.')):
if os.path.isdir(d) and d not in EXCLURE:
# On cherche mini.html en priorité
if os.path.exists(os.path.join(d, 'mini.html')):
road_trips.append(d)
html_content = f""" def cle_de_tri(nom):
<!DOCTYPE html> """Trie du plus récent au plus ancien quand un millésime est détecté
<html lang="fr"> dans le nom du dossier, alphabétiquement sinon."""
<head> m = re.search(r'(19|20)\d{2}', nom)
<meta charset="UTF-8"> annee = int(m.group(0)) if m else 0
<title>Nos Road-Trips</title> return (-annee, nom.lower())
<style>
body {{ font-family: 'Segoe UI', sans-serif; background: #f4f4f9; margin: 0; padding: 20px; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 30px; max-width: 1200px; margin: 0 auto; }} def lien(trip, suffixe=""):
.card {{ background: white; border-radius: 15px; overflow: hidden; box-shadow: 0 10px 20px rgba(0,0,0,0.1); }} """URL sûre : les espaces et les & des noms de dossier sont encodés."""
.map-container {{ width: 100%; height: 300px; border: none; pointer-events: none; }} return html.escape(quote(trip) + suffixe)
.card-content {{ padding: 20px; text-align: center; }}
.btn {{ display: inline-block; padding: 10px 25px; background: #3498db; color: white; text-decoration: none; border-radius: 25px; font-weight: bold; }}
</style> def generer_index_general(racine="."):
</head> road_trips = []
<body> for d in sorted(os.listdir(racine)):
<h1 style="text-align:center;">🌍 Nos Road-Trips</h1> if d in EXCLURE or d.startswith('.'):
<div class="grid"> 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)
road_trips.sort(key=cle_de_tri)
cartes = []
for trip in road_trips: for trip in road_trips:
html_content += f""" # Le nom est échappé : un dossier « rando & vercors » produisait
<div class="card"> # sinon du HTML invalide (& non échappé) et un lien cassé.
<iframe class="map-container" src="{trip}/mini.html"></iframe> titre = html.escape(trip.replace('-', ' ').replace('_', ' '))
<div class="card-content"> cartes.append(f""" <article class="card">
<h2 style="text-transform: capitalize;">{trip.replace('-', ' ')}</h2> <iframe class="map-container" src="{lien(trip, '/mini.html')}"
<a href="{trip}/index.html" class="btn">Voir le voyage</a> loading="lazy" title="Carte de {titre}"></iframe>
</div> <div class="card-content">
</div> <h2>{titre}</h2>
""" <a href="{lien(trip, '/index.html')}" class="btn">Voir le voyage</a>
html_content += "</div></body></html>" </div>
with open(OUTPUT_FILE, "w", encoding="utf-8") as f: f.write(html_content) </article>""")
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 = ""
# 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>
"""
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}")
if __name__ == "__main__": if __name__ == "__main__":
generer_index_general() racine = sys.argv[1] if len(sys.argv) > 1 else "."
generer_index_general(racine)
+178 -11
View File
@@ -1,16 +1,183 @@
#!/bin/bash #!/bin/bash
# =============================================================================
# GÉNÉRATION DU SITE — orchestre la chaîne complète
#
# 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
#
# 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).
#
# Usage :
# ./genere_site.sh # tous les voyages
# ./genere_site.sh Ete-2026 # un seul voyage
# ./genere_site.sh Ete-2026 corse # plusieurs
#
# 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
# =============================================================================
# Parcourir tous les éléments du répertoire courant set -uo pipefail
for dir in */; do shopt -s nullglob
# Supprimer le slash final pour l'affichage
dir=${dir%/}
# Vérifier si les trois sous-répertoires existent à l'intérieur SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ -d "$dir/rushs" && -d "$dir/routes" && -d "$dir/photos" ]]; then # La racine du site est le dossier PARENT de Scripts/. On ne dépend donc pas
echo "✅ Dossier conforme trouvé : $dir" # du répertoire courant : le script marche depuis n'importe où.
Scripts/extraction_infos.sh $dir ROOT="${ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
Scripts/compress.sh $dir SKIP_COMPRESS="${SKIP_COMPRESS:-0}"
Scripts/genere_carte.py $dir
cd "$ROOT" || { echo "❌ Racine introuvable : $ROOT"; exit 1; }
for s in extraction_infos.sh compress.sh genere_carte.py genere_index_general.py; do
if [ ! -f "$SCRIPT_DIR/$s" ]; then
echo "❌ Script manquant : $SCRIPT_DIR/$s"
exit 1
fi fi
Scripts/genere_index_general.py [ -x "$SCRIPT_DIR/$s" ] || chmod +x "$SCRIPT_DIR/$s" 2>/dev/null
done 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.
etat_dossier() {
if [ -d "$1" ]; then echo ok
elif [ -L "$1" ]; then echo lien_casse
else echo absent
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 "?"
}
echo "=================================================="
echo " GÉNÉRATION DU SITE"
echo " Racine : $ROOT"
echo "=================================================="
echo
# --- Sélection des voyages ---------------------------------------------------
candidats=()
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
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
if [ "${#voyages[@]}" -eq 0 ]; then
echo
echo "❌ Aucun voyage à traiter."
echo " Un voyage doit contenir au moins un de : rushs/ routes/ photos/"
exit 1
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 --------------------------------------------------------------
ok=0; ko=0
T0=$(date +%s)
for dir in "${voyages[@]}"; do
echo "=================================================="
echo "$dir"
echo "=================================================="
if ! "$SCRIPT_DIR/extraction_infos.sh" "$dir"; then
echo "❌ Extraction échouée sur $dir — voyage ignoré."
ko=$((ko + 1)); echo; continue
fi
if [ "$SKIP_COMPRESS" = "1" ]; then
echo "⏭️ Compression sautée (SKIP_COMPRESS=1)"
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."
fi
if ! "$SCRIPT_DIR/genere_carte.py" "$dir"; then
echo "❌ Génération des cartes échouée sur $dir."
ko=$((ko + 1)); echo; continue
fi
ok=$((ok + 1))
echo "$dir terminé"
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 "=================================================="
"$SCRIPT_DIR/genere_index_general.py" "$ROOT"
T1=$(date +%s); EL=$((T1 - T0))
echo
echo "=================================================="
printf " ✅ %d voyage(s) généré(s)" "$ok"
[ "$ko" -gt 0 ] && printf ", ❌ %d en échec" "$ko"
printf " — %d min %02d s\n" $((EL / 60)) $((EL % 60))
echo " 🌍 Ouvre index.html à la racine du site."
echo "=================================================="
[ "$ko" -gt 0 ] && exit 1
exit 0