Phase 1 : moteur Python et commande rtg
- Paquet roadtripsgenerator (pyproject.toml), commande rtg : generer, etat, analyser, compresser, accueil, migrer - Réglages site.toml / voyage.toml (anciens mymaps.txt et moto.txt encore lus) ; qualité vidéo en trois crans - Sources laissées en place : traces découpées par journée d'après leurs horodatages (coupure à 4 h) ou la date du nom de fichier, photos copiées, HEIC converties en JPEG, position déduite de la trace pour les photos sans GPS - extraction_infos.sh et compress.sh portés en Python (plus de bash) ; sorties vidéo écrites sous un nom temporaire ; positions des rushs inchangés reprises du dernier export ; export vidéo conservé quand le disque des rushs est débranché - Pages, accueil et carte globale repris dans le paquet : sortie identique aux scripts (123 fichiers comparés sur 7 voyages) - Tests pytest sur un voyage d'exemple fabriqué à la volée Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
1 parent
97b936fd3e
commit
f0e1c2dfce
23 files changed
+3083
-1
No files matched your search
+221
@@ -0,0 +1,221 @@
|
||||
"""Analyse des sources d'un voyage et mise en forme dans le dossier du site.
|
||||
|
||||
Les sources (traces, photos, vidéos) peuvent être n'importe où et nommées
|
||||
n'importe comment. Cette étape les range dans l'organisation que le reste
|
||||
de la chaîne attend, sans jamais modifier les originaux :
|
||||
|
||||
routes/AAAA-MM-JJ.gpx une trace par journée (créée par RTG)
|
||||
photos/ copies JPEG des photos (HEIC converties)
|
||||
|
||||
Les vidéos ne sont pas copiées : la compression lit directement les sources.
|
||||
Un voyage dont les sources sont déjà ses propres dossiers routes/ et photos/
|
||||
(organisation historique) n'est pas touché.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import gpxpy
|
||||
import gpxpy.gpx
|
||||
|
||||
from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers
|
||||
|
||||
CREATEUR = "RoadTripsGenerator"
|
||||
DATE_DANS_NOM = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
|
||||
|
||||
|
||||
class Analyse:
|
||||
def __init__(self):
|
||||
self.journees = {} # date → [GPXTrack]
|
||||
self.traces = [] # fichiers GPX lus
|
||||
self.photos = [] # chemins des photos sources
|
||||
self.videos = [] # chemins des vidéos sources
|
||||
self.a_verifier = [] # messages pour l'utilisateur
|
||||
|
||||
def km(self, jour):
|
||||
total = 0.0
|
||||
for t in self.journees.get(jour, []):
|
||||
try:
|
||||
total += t.length_2d() or 0
|
||||
except Exception:
|
||||
pass
|
||||
return total / 1000.0
|
||||
|
||||
|
||||
def _sources(voyage, cle, cfg):
|
||||
return cfg["sources"].get(cle, [])
|
||||
|
||||
|
||||
def _est_dossier_du_voyage(voyage, sources, nom):
|
||||
"""Vrai si les sources se résument au sous-dossier `nom` du voyage."""
|
||||
cible = os.path.realpath(os.path.join(voyage, nom))
|
||||
res = [os.path.realpath(os.path.join(voyage, os.path.expanduser(s))) for s in sources]
|
||||
return res == [cible]
|
||||
|
||||
|
||||
def date_du_nom(nom):
|
||||
m = DATE_DANS_NOM.search(nom)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return datetime(*map(int, m.groups())).date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def decouper_par_journee(fichier, tz, coupure_h):
|
||||
"""Pistes d'un GPX réparties par journée locale.
|
||||
|
||||
Renvoie ({date: [GPXTrack]}, None) ou ({}, raison) si aucune date n'est
|
||||
trouvée. Avec horodatages : date locale dans le fuseau du voyage, avec une
|
||||
coupure à `coupure_h` heures (une étape finie à 1 h reste sur la veille).
|
||||
Sans horodatage : la date du nom de fichier vaut pour tout le fichier.
|
||||
"""
|
||||
with open(fichier, encoding="utf-8") as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
a_des_heures = any(p.time for t in gpx.tracks for s in t.segments for p in s.points)
|
||||
res = {}
|
||||
if not a_des_heures:
|
||||
jour = date_du_nom(os.path.basename(fichier))
|
||||
if jour is None:
|
||||
return {}, "trace sans heures et sans date dans son nom"
|
||||
for t in gpx.tracks:
|
||||
res.setdefault(jour, []).append(t)
|
||||
return res, None
|
||||
|
||||
decalage = timedelta(hours=coupure_h)
|
||||
for t in gpx.tracks:
|
||||
par_jour = {}
|
||||
for s in t.segments:
|
||||
courant, morceau = None, None
|
||||
for p in s.points:
|
||||
if p.time is not None:
|
||||
heure = p.time if p.time.tzinfo else p.time.replace(tzinfo=timezone.utc)
|
||||
jour = (heure.astimezone(tz) - decalage).date()
|
||||
else:
|
||||
jour = courant
|
||||
if jour is None:
|
||||
continue
|
||||
if jour != courant:
|
||||
morceau = gpxpy.gpx.GPXTrackSegment()
|
||||
par_jour.setdefault(jour, []).append(morceau)
|
||||
courant = jour
|
||||
morceau.points.append(p)
|
||||
for jour, segments in par_jour.items():
|
||||
piste = gpxpy.gpx.GPXTrack(name=t.name)
|
||||
piste.segments = segments
|
||||
res.setdefault(jour, []).append(piste)
|
||||
return res, None
|
||||
|
||||
|
||||
def analyser(voyage, cfg):
|
||||
"""Inventaire des sources d'un voyage (sans rien écrire)."""
|
||||
a = Analyse()
|
||||
exclus = set(cfg.get("exclusions", {}).get("fichiers", []))
|
||||
tz = ZoneInfo(cfg["journees"]["fuseau"])
|
||||
coupure = int(cfg["journees"]["coupure_heure"])
|
||||
|
||||
a.traces = [f for f in lister_fichiers(_sources(voyage, "traces", cfg), (".gpx",), voyage)
|
||||
if os.path.basename(f) not in exclus]
|
||||
for f in a.traces:
|
||||
try:
|
||||
parts, raison = decouper_par_journee(f, tz, coupure)
|
||||
except Exception as e:
|
||||
a.a_verifier.append(f"Trace illisible : {os.path.basename(f)} ({e})")
|
||||
continue
|
||||
if raison:
|
||||
a.a_verifier.append(f"Trace à rattacher à un jour : {os.path.basename(f)} ({raison})")
|
||||
for jour, pistes in parts.items():
|
||||
a.journees.setdefault(jour, []).extend(pistes)
|
||||
|
||||
a.photos = [f for f in lister_fichiers(_sources(voyage, "photos", cfg), EXT_PHOTOS, voyage)
|
||||
if os.path.basename(f) not in exclus]
|
||||
a.videos = [f for f in lister_fichiers(_sources(voyage, "videos", cfg), EXT_VIDEOS, voyage)
|
||||
if os.path.basename(f) not in exclus]
|
||||
|
||||
if _est_dossier_du_voyage(voyage, _sources(voyage, "photos", cfg), "photos"):
|
||||
heic = [p for p in a.photos if p.lower().endswith((".heic", ".heif"))]
|
||||
if heic:
|
||||
a.a_verifier.append(f"{len(heic)} photo(s) HEIC dans photos/ : indiquez ce dossier comme "
|
||||
f"source d'un autre emplacement pour qu'elles soient converties")
|
||||
for s in _sources(voyage, "videos", cfg) + _sources(voyage, "photos", cfg) + _sources(voyage, "traces", cfg):
|
||||
p = os.path.join(voyage, os.path.expanduser(s))
|
||||
if os.path.islink(p) and not os.path.exists(p):
|
||||
a.a_verifier.append(f"Source introuvable (disque débranché ?) : {s} → {os.readlink(p)}")
|
||||
elif not os.path.lexists(p) and s not in ("routes", "rushs", "photos"):
|
||||
a.a_verifier.append(f"Source introuvable : {s}")
|
||||
return a
|
||||
|
||||
|
||||
def ranger(voyage, cfg, a):
|
||||
"""Range les sources dans le dossier du voyage (routes/, photos/).
|
||||
Renvoie un résumé des écritures."""
|
||||
resume = []
|
||||
sources_traces = _sources(voyage, "traces", cfg)
|
||||
if a.traces and not _est_dossier_du_voyage(voyage, sources_traces, "routes"):
|
||||
resume.append(_ecrire_routes(voyage, a))
|
||||
if a.photos and not _est_dossier_du_voyage(voyage, _sources(voyage, "photos", cfg), "photos"):
|
||||
resume.append(_copier_photos(voyage, a))
|
||||
return [r for r in resume if r]
|
||||
|
||||
|
||||
def _ecrire_routes(voyage, a):
|
||||
dossier = os.path.join(voyage, "routes")
|
||||
os.makedirs(dossier, exist_ok=True)
|
||||
attendus = set()
|
||||
for jour, pistes in sorted(a.journees.items()):
|
||||
nom = f"{jour.isoformat()}.gpx"
|
||||
attendus.add(nom)
|
||||
gpx = gpxpy.gpx.GPX()
|
||||
gpx.creator = CREATEUR
|
||||
for p in pistes:
|
||||
gpx.tracks.append(p)
|
||||
with open(os.path.join(dossier, nom), "w", encoding="utf-8") as f:
|
||||
f.write(gpx.to_xml())
|
||||
# Retire les journées que RTG avait écrites et qui n'existent plus ;
|
||||
# un GPX déposé à la main n'est jamais touché.
|
||||
retires = 0
|
||||
for nom in os.listdir(dossier):
|
||||
chemin = os.path.join(dossier, nom)
|
||||
if nom in attendus or est_cache(nom) or not nom.lower().endswith(".gpx"):
|
||||
continue
|
||||
with open(chemin, encoding="utf-8", errors="replace") as f:
|
||||
debut = f.read(600)
|
||||
if f'creator="{CREATEUR}"' in debut:
|
||||
os.remove(chemin)
|
||||
retires += 1
|
||||
return f"🧭 routes/ : {len(attendus)} journée(s) écrite(s)" + (f", {retires} retirée(s)" if retires else "")
|
||||
|
||||
|
||||
def _copier_photos(voyage, a):
|
||||
dossier = os.path.join(voyage, "photos")
|
||||
os.makedirs(dossier, exist_ok=True)
|
||||
copiees = converties = 0
|
||||
for src in a.photos:
|
||||
base, ext = os.path.splitext(os.path.basename(src))
|
||||
heic = ext.lower() in (".heic", ".heif")
|
||||
dest = os.path.join(dossier, base + (".jpeg" if heic else ext))
|
||||
if os.path.exists(dest) and os.path.getmtime(dest) >= os.path.getmtime(src):
|
||||
continue
|
||||
if heic:
|
||||
convertir_heic(src, dest)
|
||||
converties += 1
|
||||
else:
|
||||
shutil.copy2(src, dest)
|
||||
copiees += 1
|
||||
if not (copiees or converties):
|
||||
return None
|
||||
return f"📸 photos/ : {copiees} copiée(s), {converties} HEIC convertie(s) en JPEG"
|
||||
|
||||
|
||||
def convertir_heic(src, dest):
|
||||
"""HEIC → JPEG, métadonnées EXIF (date, GPS) conservées."""
|
||||
from PIL import Image
|
||||
import pillow_heif
|
||||
pillow_heif.register_heif_opener()
|
||||
with Image.open(src) as im:
|
||||
exif = im.info.get("exif")
|
||||
im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"")
|
||||
os.replace(dest + ".tmp", dest)
|
||||
Reference in new issue
Block a user