mise à jours HEVC pour 1080p
This commit is contained in:
Executable
+177
@@ -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
+295
-66
@@ -1,83 +1,312 @@
|
||||
#!/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
|
||||
# =============================================================================
|
||||
|
||||
# --- 1. CONFIGURATION ---
|
||||
# Utilise l'argument 1 comme dossier racine, ou le dossier courant par défaut
|
||||
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
|
||||
|
||||
SOURCE_DIR="$BASE_DIR/rushs"
|
||||
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
|
||||
echo "❌ Erreur : Dossier source $SOURCE_DIR introuvable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Création du dossier de destination
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
echo "--- COMPRESSION ROAD-TRIP (CPU OPTIMISÉ) ---"
|
||||
echo "📂 Source : $SOURCE_DIR"
|
||||
echo "📤 Destination : $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)
|
||||
ENCODERS_LIST="$(ffmpeg -hide_banner -encoders 2>/dev/null)"
|
||||
has_enc() { printf '%s' "$ENCODERS_LIST" | grep -q "$1"; }
|
||||
|
||||
# 2. BOUCLE DE TRAITEMENT
|
||||
for f in "$SOURCE_DIR"/*.{mp4,mov}; do
|
||||
[ -e "$f" ] || continue
|
||||
|
||||
# Extraction du nom de base (ex: GH011182.mp4 -> GH011182)
|
||||
filename=$(basename "$f")
|
||||
base="${filename%.*}"
|
||||
|
||||
# Conventions de nommage validées
|
||||
desktop_out="$DEST_DIR/${base}.mp4"
|
||||
mobile_out="$DEST_DIR/${base}_mobile.mp4"
|
||||
|
||||
echo "🎬 Traitement de : $filename ..."
|
||||
|
||||
# --- A. VERSION DESKTOP (1080p60 - Validé 233 Mo) ---
|
||||
# Réglages : CRF 28, Maxrate 12M, GOP 120, FPS source exact
|
||||
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)"
|
||||
if [ "$ENCODER" = "vt" ]; then
|
||||
has_enc "hevc_videotoolbox" || { echo "❌ hevc_videotoolbox indisponible. Utilise ENCODER=cpu."; exit 1; }
|
||||
has_enc "h264_videotoolbox" || { echo "❌ h264_videotoolbox indisponible. Utilise ENCODER=cpu."; exit 1; }
|
||||
else
|
||||
has_enc "libx265" || { echo "❌ libx265 absent de ton ffmpeg (brew install ffmpeg)."; exit 1; }
|
||||
has_enc "libx264" || { echo "❌ libx264 absent de ton ffmpeg (brew install ffmpeg)."; exit 1; }
|
||||
fi
|
||||
|
||||
echo "---------------------------------------------------"
|
||||
# Liste des rushs
|
||||
files=()
|
||||
for f in "$SOURCE_DIR"/*.mp4 "$SOURCE_DIR"/*.mov; do
|
||||
[ -e "$f" ] && files+=("$f")
|
||||
done
|
||||
|
||||
shopt -u nocaseglob
|
||||
echo "✅ TOUTES LES VIDÉOS SONT PRÊTES."
|
||||
echo "--- COMPRESSION ROAD-TRIP (HYBRIDE HEVC / H.264) ---"
|
||||
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
+222
-32
@@ -1,52 +1,242 @@
|
||||
#!/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:-.}"
|
||||
|
||||
# Définition des dossiers et fichiers par rapport à la base
|
||||
PHOTOS_DIR="$BASE_DIR/photos"
|
||||
RUSHS_DIR="$BASE_DIR/rushs"
|
||||
CSV_PHOTOS="$BASE_DIR/export_photos.csv"
|
||||
CSV_VIDEOS="$BASE_DIR/export_videos.csv"
|
||||
|
||||
# On active l'insensibilité à la casse pour les jokers (*.mp4, etc.)
|
||||
shopt -s nocaseglob
|
||||
VIDEO_GPS="${VIDEO_GPS:-first}"
|
||||
VARIANTE="${VARIANTE:-}"
|
||||
|
||||
echo "--- DÉBUT DE L'EXTRACTION BRUTE ---"
|
||||
echo "📂 Répertoire racine : $BASE_DIR"
|
||||
if ! command -v exiftool >/dev/null 2>&1; then
|
||||
echo "❌ exiftool introuvable. Installe-le : brew install exiftool"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. RÉINITIALISATION DES FICHIERS CSV (Optionnel mais recommandé)
|
||||
# Si tu veux cumuler sans effacer, commente les deux lignes suivantes :
|
||||
> "$CSV_PHOTOS"
|
||||
> "$CSV_VIDEOS"
|
||||
echo "--- EXTRACTION DES MÉTADONNÉES ---"
|
||||
echo "📂 Répertoire : $BASE_DIR"
|
||||
|
||||
# 3. PHOTOS (Fichiers .jpg, .jpeg)
|
||||
echo "📸 Traitement des photos dans : $PHOTOS_DIR"
|
||||
TMPD="$(mktemp -d "${TMPDIR:-/tmp}/extr.XXXXXX")"
|
||||
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
|
||||
for f in "$PHOTOS_DIR"/*.jp*g; do
|
||||
[ -e "$f" ] || continue
|
||||
# Utilisation du chemin complet pour exiftool mais basename pour le CSV
|
||||
exiftool -n -p "$(basename "$f")"',$DateTimeOriginal,$GPSLatitude,$GPSLongitude,$GPSAltitude' "$f" >> "$CSV_PHOTOS"
|
||||
done
|
||||
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.
|
||||
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.
|
||||
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
|
||||
echo "⚠️ Dossier photos non trouvé, étape ignorée."
|
||||
fi
|
||||
|
||||
# 4. VIDÉOS (Fichiers .mp4, .mov avec flux GoPro)
|
||||
echo "🎬 Traitement des vidéos dans : $RUSHS_DIR"
|
||||
if [ -d "$RUSHS_DIR" ]; then
|
||||
for f in "$RUSHS_DIR"/*.{mp4,mov}; do
|
||||
[ -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
|
||||
# --- 2. VIDÉOS ---------------------------------------------------------------
|
||||
: > "$CSV_VIDEOS"
|
||||
if [ ! -d "$RUSHS_DIR" ]; then
|
||||
echo "⚠️ Dossier rushs non trouvé, étape ignorée."
|
||||
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.
|
||||
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
|
||||
|
||||
# On désactive l'option nocaseglob
|
||||
shopt -u nocaseglob
|
||||
# --- 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
|
||||
|
||||
echo "--- TERMINÉ ---"
|
||||
echo "✅ Fichiers générés : $CSV_PHOTOS et $CSV_VIDEOS"
|
||||
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
|
||||
|
||||
echo "✅ Générés : $CSV_PHOTOS et $CSV_VIDEOS"
|
||||
|
||||
Regular → Executable
+132
-13
@@ -10,6 +10,7 @@ import sys
|
||||
import markdown2
|
||||
import numpy as np
|
||||
import html
|
||||
from urllib.parse import quote
|
||||
|
||||
# --- 1. CONFIGURATION ---
|
||||
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']
|
||||
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 ---
|
||||
def nettoyer_et_trier(df):
|
||||
if df is None or df.empty:
|
||||
@@ -52,11 +79,44 @@ def create_pin(color, icon_name):
|
||||
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; }
|
||||
})();
|
||||
|
||||
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;
|
||||
|
||||
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 wasPlaying = !video.paused;
|
||||
@@ -99,8 +159,15 @@ def inject_common_assets(m):
|
||||
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) {
|
||||
@@ -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-1080 { background-color: #007AFF !important; color: white; border-color: #0056b3 !important; opacity: 1 !important; }
|
||||
|
||||
.qbtn-disabled {
|
||||
opacity: 0.35 !important; cursor: not-allowed !important;
|
||||
text-decoration: line-through; background: #eee !important;
|
||||
}
|
||||
.qbtn-disabled:hover { opacity: 0.35 !important; background: #eee !important; }
|
||||
.hevc-note {
|
||||
display: none; margin-top: 6px; font-family: sans-serif; font-size: 11px;
|
||||
color: #8a6d3b; background: #fcf8e3; border-radius: 6px; padding: 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.leaflet-top.leaflet-left { top: auto !important; bottom: 20px !important; left: 15px !important; }
|
||||
.leaflet-popup-content-wrapper { border-radius: 12px; max-width: 95vw !important; }
|
||||
#journal-panel {
|
||||
@@ -211,6 +289,8 @@ try:
|
||||
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))
|
||||
@@ -240,12 +320,18 @@ for i, jour in enumerate(tous_les_jours):
|
||||
|
||||
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):
|
||||
if gpx_file.startswith(day_str) and gpx_file.endswith('.gpx'):
|
||||
current_gpx_filename = gpx_file
|
||||
# `day_str in gpx_file` et non `.startswith(day_str)` : la liste des
|
||||
# jours est bâtie avec re.search (date N'IMPORTE OÙ dans le nom),
|
||||
# donc une trace « trace_2026-07-15_col.gpx » créait bien un jour
|
||||
# mais n'était jamais retrouvée ici — jour vide, carte non cadrée.
|
||||
if est_sidecar(gpx_file):
|
||||
continue
|
||||
if day_str in gpx_file and gpx_file.lower().endswith('.gpx'):
|
||||
try:
|
||||
with open(os.path.join(routes_dir, gpx_file), 'r') as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
@@ -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=3, opacity=0.8).add_to(m_mini)
|
||||
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()))
|
||||
|
||||
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
|
||||
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]
|
||||
fichiers = groupe['Fichier'].tolist()
|
||||
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)">❮</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]
|
||||
|
||||
@@ -291,8 +391,9 @@ for i, jour in enumerate(tous_les_jours):
|
||||
clean_id = re.sub(r'[^a-zA-Z0-9]', '', base_name)
|
||||
video_id = f"vid_{clean_id}"
|
||||
|
||||
desktop_file = f"../videos/{base_name}.mp4"
|
||||
mobile_file = f"../videos/{base_name}_mobile.mp4"
|
||||
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
|
||||
@@ -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 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">'
|
||||
# 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" 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>'
|
||||
|
||||
@@ -314,12 +417,28 @@ for i, jour in enumerate(tous_les_jours):
|
||||
# 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}">' # <-- On pointe directement sur le fichier léger
|
||||
f'</video></div>'
|
||||
f'src="{mobile_file}" data-fallback="{mobile_file}">' # <-- 720p H.264 : lisible partout
|
||||
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)
|
||||
|
||||
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)
|
||||
plugins.Fullscreen(position='topright').add_to(m_day)
|
||||
m_day.save(os.path.join(html_output_dir, file_name))
|
||||
|
||||
Regular → Executable
+119
-29
@@ -1,48 +1,138 @@
|
||||
#!/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"
|
||||
|
||||
def generer_index_general():
|
||||
|
||||
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 lien(trip, suffixe=""):
|
||||
"""URL sûre : les espaces et les & des noms de dossier sont encodés."""
|
||||
return html.escape(quote(trip) + suffixe)
|
||||
|
||||
|
||||
def generer_index_general(racine="."):
|
||||
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')):
|
||||
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)
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
road_trips.sort(key=cle_de_tri)
|
||||
|
||||
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>""")
|
||||
|
||||
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>
|
||||
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; }}
|
||||
.card {{ background: white; border-radius: 15px; overflow: hidden; box-shadow: 0 10px 20px rgba(0,0,0,0.1); }}
|
||||
.map-container {{ width: 100%; height: 300px; border: none; pointer-events: none; }}
|
||||
: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; }}
|
||||
.btn {{ display: inline-block; padding: 10px 25px; background: #3498db; color: white; text-decoration: none; border-radius: 25px; font-weight: bold; }}
|
||||
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>
|
||||
<h1 style="text-align:center;">🌍 Nos Road-Trips</h1>
|
||||
<div class="grid">
|
||||
<header>
|
||||
<h1>🌍 Nos Road-Trips</h1>
|
||||
<div class="sous-titre">{sous_titre}</div>
|
||||
</header>
|
||||
<main>
|
||||
{corps}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
for trip in road_trips:
|
||||
html_content += f"""
|
||||
<div class="card">
|
||||
<iframe class="map-container" src="{trip}/mini.html"></iframe>
|
||||
<div class="card-content">
|
||||
<h2 style="text-transform: capitalize;">{trip.replace('-', ' ')}</h2>
|
||||
<a href="{trip}/index.html" class="btn">Voir le voyage</a>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
html_content += "</div></body></html>"
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f: f.write(html_content)
|
||||
|
||||
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__":
|
||||
generer_index_general()
|
||||
racine = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
generer_index_general(racine)
|
||||
|
||||
+178
-11
@@ -1,16 +1,183 @@
|
||||
#!/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
|
||||
for dir in */; do
|
||||
# Supprimer le slash final pour l'affichage
|
||||
dir=${dir%/}
|
||||
set -uo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
# Vérifier si les trois sous-répertoires existent à l'intérieur
|
||||
if [[ -d "$dir/rushs" && -d "$dir/routes" && -d "$dir/photos" ]]; then
|
||||
echo "✅ Dossier conforme trouvé : $dir"
|
||||
Scripts/extraction_infos.sh $dir
|
||||
Scripts/compress.sh $dir
|
||||
Scripts/genere_carte.py $dir
|
||||
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)}"
|
||||
SKIP_COMPRESS="${SKIP_COMPRESS:-0}"
|
||||
|
||||
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
|
||||
Scripts/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.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user