Compare commits
18
Commits
f0e1c2dfce
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f08aa09449 | ||
|
|
ada3d76f50 | ||
|
|
b54ae69b19 | ||
|
|
ff7e13ad28 | ||
|
|
b29a066b36 | ||
|
|
daaee62373 | ||
|
|
02473fafbf | ||
|
|
4852361ea6 | ||
|
|
2c2530a3dc | ||
|
|
d3a8394bc1 | ||
|
|
4366db94ed | ||
|
|
71779f6f53 | ||
|
|
06153a227e | ||
|
|
608334b9d8 | ||
|
|
96b1e53a2f | ||
|
|
d40e963019 | ||
|
|
b9cb4b8b47 | ||
|
|
315246cb39 |
No files matched your search
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
Scripts
|
||||
tests
|
||||
exemples
|
||||
**/__pycache__
|
||||
*.egg-info
|
||||
build
|
||||
dist
|
||||
@@ -1,2 +1,5 @@
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
*.egg-info/
|
||||
rtg-admin.log
|
||||
rtg-admin.pid
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Interface RTG en conteneur, derrière le nginx du site
|
||||
# (README, « L'administration dans Docker »).
|
||||
FROM python:3.12-slim
|
||||
|
||||
# ffmpeg (libx265, libx264), exiftool, fuseaux horaires
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY rtg ./rtg
|
||||
RUN pip install --no-cache-dir . && rtg verifier
|
||||
|
||||
# Mot de passe (acces.toml) : /config/rtg, à monter depuis le Mac.
|
||||
# Le conteneur voit les disques du Mac sous /Volumes, aux mêmes chemins que
|
||||
# le Mac : les sources notées dans les voyage.toml restent valables.
|
||||
ENV XDG_CONFIG_HOME=/config TZ=Europe/Paris PYTHONUNBUFFERED=1 \
|
||||
RTG_RACINE=/Volumes/CLEROADTRIPS/Road-Trips RTG_PREFIXE=/admin \
|
||||
RTG_ARRET_INACTIF=0
|
||||
EXPOSE 8765
|
||||
|
||||
# Aucun port publié : seul le nginx du site (même réseau Docker) le joint.
|
||||
CMD exec rtg --racine "$RTG_RACINE" interface --proxy --prefixe "$RTG_PREFIXE" \
|
||||
--ecoute 0.0.0.0 --sans-navigateur --arret-inactif "$RTG_ARRET_INACTIF"
|
||||
@@ -1,4 +1,4 @@
|
||||
*RoadTripsGenerator* est un ensemble de scripts (bash et Python) qui génèrent un site web statique de road-trips : une carte interactive par voyage et par jour, avec les photos, les vidéos, les traces GPS et le récit du voyage.
|
||||
*RoadTripsGenerator* (RTG) est un logiciel qui génère un site web statique de road-trips : une carte interactive par voyage et par jour, avec les photos, les vidéos, les traces GPS et le récit du voyage.
|
||||
|
||||
C'est sans prétention et il faudrait probablement des développements bien plus sérieux pour obtenir un logiciel « presse-bouton », mais si cela peut donner des idées…
|
||||
|
||||
@@ -8,9 +8,9 @@ Je me suis rendu compte que je passais des heures à monter des films de mes roa
|
||||
|
||||
Pour chaque voyage, je dispose de photos et de vidéos géolocalisées (photos iPhone, films GoPro), des traces GPS de chaque journée et d'un récit écrit en Markdown.
|
||||
|
||||
Les scripts lisent les positions GPS des photos et des vidéos, les placent sur une carte avec la trace du parcours découpée par jour, et construisent des pages web où l'on peut, en parallèle, lire le récit du voyage. Voir par exemple mon [road-trip de l'été 2025](https://roadtrip.ouaibe.fr/Ete-2025/).
|
||||
RTG lit les positions GPS des photos et des vidéos, les placent sur une carte avec la trace du parcours découpée par jour, et construit des pages web où l'on peut, en parallèle, lire le récit du voyage. Voir par exemple mon [road-trip de l'été 2025](https://roadtrips.ouaibe.fr/Ete-2025/).
|
||||
|
||||
Ils préparent aussi les médias pour le web :
|
||||
Il prépare aussi les médias pour le web :
|
||||
|
||||
* **vidéos** : un 720p H.264 lisible partout, chargé par défaut, et un 1080p HEVC proposé à la demande (environ 40 % plus léger que du H.264 à qualité égale) ;
|
||||
* **photos** : une version allégée (1600 px, quelques centaines de Ko) affichée dans les diaporamas, le clic ouvrant l'original ;
|
||||
@@ -18,147 +18,245 @@ Ils préparent aussi les médias pour le web :
|
||||
|
||||
Les originaux ne sont jamais modifiés. Le site produit est statique : une simple copie suffit pour le publier ou le transporter.
|
||||
|
||||
## Les prérequis
|
||||
## Installation
|
||||
|
||||
Les scripts fonctionnent sous macOS et Linux.
|
||||
RTG fonctionne sous macOS et Linux ; Windows devrait fonctionner mais n'a pas encore été testé. Il lui faut :
|
||||
|
||||
* obligatoirement :
|
||||
* Python 3 et les paquets `pandas folium gpxpy markdown2 numpy` (Jinja2, utilisé pour la mise en page, est installé avec folium)
|
||||
* `exiftool`
|
||||
* conseillé :
|
||||
* `ffmpeg` (compression des vidéos et images d'aperçu)
|
||||
* `pillow` pour les photos allégées (sous macOS, l'outil `sips` du système prend le relais s'il est absent)
|
||||
| Élément | Rôle | Version |
|
||||
| --- | --- | --- |
|
||||
| Python | le logiciel lui-même | 3.10 ou plus récent |
|
||||
| pipx | installe RTG et ses modules Python dans un espace à part | — |
|
||||
| exiftool | lit les dates et positions GPS des photos et des vidéos | toute version récente |
|
||||
| ffmpeg (avec libx265 et libx264) | compresse les vidéos, tire les images d'aperçu | 5 ou plus récent |
|
||||
|
||||
Les modules Python (folium, pandas, gpxpy, Pillow, pillow-heif…) sont installés automatiquement avec RTG.
|
||||
|
||||
### macOS
|
||||
|
||||
1. Installer [Homebrew](https://brew.sh) s'il n'est pas déjà là :
|
||||
```
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
2. Installer les outils, puis rendre pipx accessible :
|
||||
```
|
||||
brew install python pipx exiftool ffmpeg
|
||||
pipx ensurepath
|
||||
```
|
||||
3. Ouvrir une nouvelle fenêtre de Terminal (ou taper `source ~/.zshrc`, zsh étant le shell par défaut de macOS) : `pipx ensurepath` ajoute `~/.local/bin` au chemin des commandes, mais une fenêtre déjà ouverte ne le voit pas. Si `rtg` reste introuvable, c'est ce point.
|
||||
|
||||
Le ffmpeg de Homebrew contient libx265, libx264 et l'encodeur matériel VideoToolbox du Mac.
|
||||
|
||||
### Linux (Debian, Ubuntu et dérivées)
|
||||
|
||||
```
|
||||
brew install exiftool ffmpeg
|
||||
pip3 install pandas folium gpxpy markdown2 numpy pillow
|
||||
sudo apt install python3 pipx ffmpeg libimage-exiftool-perl
|
||||
pipx ensurepath
|
||||
```
|
||||
|
||||
Puis fermer et rouvrir le terminal. Sur Fedora : `sudo dnf install pipx perl-Image-ExifTool` et ffmpeg depuis RPM Fusion.
|
||||
|
||||
### Windows (non testé)
|
||||
|
||||
Dans PowerShell :
|
||||
|
||||
```
|
||||
winget install Python.Python.3.12 Gyan.FFmpeg OliverBetz.ExifTool
|
||||
py -m pip install --user pipx
|
||||
py -m pipx ensurepath
|
||||
```
|
||||
|
||||
Puis fermer et rouvrir PowerShell.
|
||||
|
||||
### Installer RTG
|
||||
|
||||
Récupérer le dépôt (ou le copier, par exemple sur un disque externe) :
|
||||
|
||||
```
|
||||
git clone https://git.ouaibe.fr/Korrigan/RoadTripsGenerator.git
|
||||
pipx install --editable ./RoadTripsGenerator
|
||||
```
|
||||
|
||||
Avec `--editable`, la commande `rtg` utilise directement le code du dossier : une mise à jour du dépôt (`git pull`) est prise en compte sans réinstaller. Le dossier doit alors rester accessible (disque branché).
|
||||
|
||||
### Vérifier
|
||||
|
||||
```
|
||||
rtg verifier
|
||||
```
|
||||
|
||||
La commande contrôle Python, les modules, exiftool, ffmpeg et ses encodeurs, et donne pour chaque élément manquant la commande à taper sur votre système.
|
||||
|
||||
## Premiers pas
|
||||
|
||||
```
|
||||
cd /Volumes/CLEROADTRIPS/Road-Trips
|
||||
rtg interface
|
||||
```
|
||||
|
||||
L'interface s'ouvre dans le navigateur. Laisser la fenêtre de Terminal ouverte pendant l'utilisation ; Ctrl+C l'arrête.
|
||||
|
||||
- **Site existant**, organisé à l'ancienne (avant RTG) : `rtg migrer` écrit une fois `site.toml` et un `voyage.toml` par voyage.
|
||||
- **Nouveau site** : lancer `rtg interface` dans un dossier vide ; l'interface propose de créer le site.
|
||||
|
||||
**L'aide d'utilisation est dans l'interface** : un bouton « ? » sur chaque écran et chaque onglet, et la page « Aide » du menu. Elle est aussi lisible ici, dans [`rtg/interface/aide/`](rtg/interface/aide/index.md).
|
||||
|
||||
## Organisation des dossiers
|
||||
|
||||
Le dépôt et le site sont deux dossiers voisins. Un voyage est un dossier qui contient au moins un des sous-dossiers `photos`, `rushs` ou `routes`. Les voyages rangés à la racine du site forment la rubrique « Voyages » de la page d'accueil. Ceux rangés un niveau plus bas, dans un dossier de regroupement (ici `Balades`), forment une rubrique du nom de ce dossier. L'accueil montre les 2 plus récents de chaque rubrique (`ACCUEIL_NB=3` pour en montrer plus), avec un lien vers la liste complète.
|
||||
Le dépôt et le site sont deux dossiers voisins. Un voyage est un dossier qui contient un `voyage.toml` (ou, à l'ancienne, un des sous-dossiers `photos`, `rushs` ou `routes`). Les voyages rangés à la racine du site forment la rubrique « Voyages » de la page d'accueil ; ceux rangés un niveau plus bas, dans un dossier de regroupement (ici `Balades`), forment une rubrique du nom de ce dossier.
|
||||
|
||||
```
|
||||
CLEROADTRIPS
|
||||
|-RoadTripsGenerator ce dépôt
|
||||
| |-Scripts
|
||||
| |-templates mise en page (HTML, CSS, JavaScript)
|
||||
| |-rtg le logiciel (interface, pages, encodage…)
|
||||
| |-exemples docker-compose, nginx, admin.sh
|
||||
| |-Scripts anciens scripts
|
||||
|-Road-Trips le site
|
||||
|-site.toml réglages du site
|
||||
|-Ete-2025
|
||||
| |-photos photos (JPEG)
|
||||
| |-rushs vidéos d'origine (peut être un lien vers un disque externe)
|
||||
| |-routes une trace GPX par jour, date dans le nom : 2025-08-02.gpx
|
||||
| |-voyage.toml sources, infos et réglages du voyage
|
||||
| |-voyage.md le récit
|
||||
| |-routes une trace GPX par jour (écrite par RTG)
|
||||
| |-photos, photos_web photos et versions allégées
|
||||
| |-videos, apercus vidéos encodées et images d'aperçu
|
||||
|-Balades
|
||||
|-Aisne-06-2026
|
||||
|-...
|
||||
```
|
||||
|
||||
Dans le récit `voyage.md`, deux raccourcis évitent les liens écrits en dur (qui ne marcheraient pas à la fois en local et en ligne) :
|
||||
Les sources (rushs, photos, traces d'origine) peuvent rester sur d'autres disques ; leurs chemins sont notés dans `voyage.toml`.
|
||||
|
||||
## Administration depuis le site : mise en place
|
||||
|
||||
Le bouton « Connexion » de l'accueil mène à l'interface, servie sous `/admin/` par le serveur web du site. Exemple avec le site servi par un conteneur nginx derrière Traefik, et l'interface lancée directement sur le Mac :
|
||||
|
||||
```
|
||||
[Carte du jour](jour:2025-08-02)
|
||||
[Le col](media:photos/IMG_4831.jpeg)
|
||||
[La descente](media:videos/GH011272.mp4)
|
||||
Traefik (HTTPS) → nginx (le site, dans Docker) → /admin/ → RTG sur le Mac (host.docker.internal:8765)
|
||||
```
|
||||
|
||||
## Installation et utilisation : la commande `rtg`
|
||||
L'interface ne tourne qu'à la demande (`./admin.sh`, `./admin.sh stop`). Fermée, `/admin/` affiche « Administration fermée » et le site reste en ligne. Sur le Mac, elle voit tous les disques et encode à pleine vitesse. Son utilisation est décrite dans l'aide de l'interface.
|
||||
|
||||
RTG s'installe comme un logiciel, avec une seule commande `rtg` utilisable depuis n'importe quel dossier :
|
||||
1. **Mot de passe**, une fois : `rtg mot-de-passe` (10 caractères au moins).
|
||||
2. **`docker-compose.yml`** du site : le service nginx doit pouvoir joindre le Mac, avec `extra_hosts: ["host.docker.internal:host-gateway"]`. Voir [`exemples/docker-compose.yml`](exemples/docker-compose.yml).
|
||||
3. **`nginx.conf`** : ajouter le bloc `/admin/` et la page « Administration fermée », voir [`exemples/nginx-admin.conf`](exemples/nginx-admin.conf). Le `^~` de `location ^~ /admin/` est nécessaire si `nginx.conf` contient déjà une règle pour les `.js`, `.css` ou images.
|
||||
4. `docker compose up -d --remove-orphans`.
|
||||
5. **`admin.sh`** : copier [`exemples/admin.sh`](exemples/admin.sh) à côté du `docker-compose.yml`, puis `chmod +x admin.sh` et `./admin.sh`. L'interface tourne en arrière-plan ; son journal est dans `rtg-admin.log`.
|
||||
6. Dans l'interface, **Réglages → Administration depuis le site** : `/admin/`, puis « Refaire l'accueil » pour faire apparaître le bouton « Connexion ».
|
||||
|
||||
```
|
||||
brew install exiftool ffmpeg # ou l'équivalent sous Linux / Windows
|
||||
pipx install ./RoadTripsGenerator # depuis le dossier du dépôt
|
||||
```
|
||||
Après une mise à jour de RTG : `./admin.sh stop`, puis `./admin.sh`.
|
||||
|
||||
Depuis le dossier du site (`Road-Trips`) :
|
||||
**Variantes**
|
||||
|
||||
```
|
||||
rtg migrer # une fois : écrit site.toml et un voyage.toml par voyage
|
||||
rtg etat # avancement de chaque voyage
|
||||
rtg generer Balades/Vexin-10-2026 # un voyage, puis l'accueil
|
||||
rtg generer --sans-compression # tous les voyages, sans encoder de vidéo
|
||||
rtg analyser Balades/Vexin-10-2026 # ce que RTG trouve dans les sources
|
||||
```
|
||||
- **Interface dans un conteneur** : [`exemples/docker-compose-conteneur.yml`](exemples/docker-compose-conteneur.yml) et [`exemples/admin-docker.sh`](exemples/admin-docker.sh), avec le `Dockerfile` à la racine du dépôt. L'encodage y est plus lent : il n'a que les processeurs que Docker Desktop attribue, et pas l'encodeur matériel du Mac. Chaque disque des sources doit aussi être déclaré dans Docker Desktop (Réglages → Resources → File sharing). **Ne jamais déclarer `/Volumes` en entier** : avec le partage de la clé du site, Docker Desktop échoue (« mkdir /host_mnt/Volumes/…: file exists ») et nginx ne démarre plus. Le mot de passe se copie à côté du compose : `mkdir -p rtg-acces && cp ~/.config/rtg/acces.toml rtg-acces/`.
|
||||
- **Traefik seul**, sans nginx : [`exemples/traefik-rtg-admin.yml`](exemples/traefik-rtg-admin.yml).
|
||||
- **Autre machine du réseau** : `rtg interface --distant` (mot de passe obligatoire), de préférence en HTTPS (`--certificat`, `--cle`) ou par un tunnel SSH (`ssh -L 8765:127.0.0.1:8765 serveur`, puis l'adresse avec la clé affichée sur le serveur).
|
||||
|
||||
Les sources d'un voyage peuvent rester où elles sont, nommées n'importe comment : on les indique dans son `voyage.toml`.
|
||||
Le dossier du site étant publié tel quel, les `.toml` (chemins de vos disques) sont lisibles en ligne si le serveur web ne les refuse pas : la règle `location ~ \.toml$ { deny all; }` de l'exemple nginx s'en charge (Apache : `<FilesMatch "\.toml$"> Require all denied </FilesMatch>`). De même pour les fichiers cachés, dont `.git` si le site est sous git : règle `location ~ /\. { deny all; }`.
|
||||
|
||||
## Commandes
|
||||
|
||||
Tout ce que fait l'interface existe aussi dans le Terminal, depuis le dossier du site (`Road-Trips`) ou avec `--racine CHEMIN`.
|
||||
|
||||
| Commande | Rôle |
|
||||
| --- | --- |
|
||||
| `rtg interface` | ouvrir l'interface dans le navigateur |
|
||||
| `rtg generer [VOYAGE…]` | générer un, plusieurs ou tous les voyages, puis l'accueil |
|
||||
| `rtg etat` | avancement de chaque voyage |
|
||||
| `rtg analyser VOYAGE` | ce que RTG trouve dans les sources d'un voyage |
|
||||
| `rtg compresser VOYAGE` | encoder les vidéos d'un voyage |
|
||||
| `rtg accueil` | refaire l'accueil et la carte globale |
|
||||
| `rtg migrer` | une fois : écrire `site.toml` et un `voyage.toml` par voyage d'après l'existant |
|
||||
| `rtg verifier` | Python, exiftool et ffmpeg sont-ils bien installés ? |
|
||||
| `rtg mot-de-passe` | définir le mot de passe de l'interface (`--tester`, `--supprimer`) |
|
||||
|
||||
### Options utiles
|
||||
|
||||
- `rtg generer --sans-compression` : sans encoder les vidéos.
|
||||
- `rtg generer --force` : tout refaire, même ce qui est à jour.
|
||||
- `rtg generer --mymaps-maj` : récupérer à nouveau les cartes Google My Maps.
|
||||
- `rtg compresser VOYAGE --reglages-changes` : réencoder aussi les vidéos faites avec d'autres réglages que les actuels.
|
||||
- `rtg compresser VOYAGE --seulement 1080p` (ou `720p`) : ne traiter qu'une version.
|
||||
- `rtg compresser VOYAGE --force` : tout réencoder.
|
||||
- `rtg interface --port 8765 --sans-navigateur` : autre port, sans ouvrir le navigateur.
|
||||
- `rtg interface --proxy --prefixe /admin` : derrière le serveur web du site (mot de passe obligatoire).
|
||||
- `rtg interface --arret-inactif 120` : s'arrêter après 120 minutes sans activité.
|
||||
- `rtg interface --distant [--certificat cert.pem --cle cle.pem]` : accepter les connexions du réseau, mot de passe obligatoire, HTTPS conseillé.
|
||||
|
||||
Exemple : `rtg compresser Balades/Aisne-06-2026 --reglages-changes --seulement 1080p`.
|
||||
|
||||
## Fichiers de réglages
|
||||
|
||||
Tout ce que fait l'interface est noté dans deux fichiers texte au format TOML. On peut aussi les modifier à la main ; une réécriture par l'interface garde les valeurs mais pas les commentaires.
|
||||
|
||||
### site.toml (racine du site)
|
||||
|
||||
```toml
|
||||
titre = "Balade dans le Vexin"
|
||||
titre = "Nos Road-Trips"
|
||||
adresse_publique = "https://roadtrips.ouaibe.fr/"
|
||||
accueil_nb_recents = 2
|
||||
|
||||
[video]
|
||||
qualite = "equilibree" # econome | equilibree | haute
|
||||
encodeur = "processeur" # processeur | materiel
|
||||
preset = "medium"
|
||||
jobs = 0 # 0 = automatique
|
||||
|
||||
[photos]
|
||||
grand_cote = 1600
|
||||
qualite = 80
|
||||
positions_deduites = true
|
||||
|
||||
[journees]
|
||||
fuseau = "Europe/Paris"
|
||||
coupure_heure = 4
|
||||
|
||||
[carte_globale]
|
||||
mymaps = ["https://www.google.com/maps/d/…"]
|
||||
|
||||
[administration]
|
||||
adresse = "/admin/" # bouton Connexion sur l'accueil
|
||||
|
||||
[confidentialite]
|
||||
zones = [[48.80, 2.10, 500]] # zones privées : latitude, longitude, rayon en m
|
||||
```
|
||||
|
||||
### voyage.toml (un par voyage)
|
||||
|
||||
```toml
|
||||
titre = "Été 2026"
|
||||
moto = "BMW R 1300 GS Adventure"
|
||||
|
||||
[sources]
|
||||
traces = ["/Volumes/GPS/2026/vexin.gpx"]
|
||||
videos = ["/Volumes/LaCie/Perso/Gopro/Gopro-2026/Vexin"]
|
||||
photos = ["~/Pictures/Export/Vexin"]
|
||||
traces = ["routes"]
|
||||
videos = ["/Volumes/LaCie/Perso/Gopro/Gopro-2026/Ete-2026"]
|
||||
photos = ["photos"]
|
||||
|
||||
[video] # réglages vidéo propres au voyage (facultatif)
|
||||
qualite = "econome"
|
||||
preset = "slow"
|
||||
crf_hevc = 28
|
||||
crf_h264 = 27
|
||||
|
||||
[dates_traces] # jours imposés aux traces
|
||||
"Etape 3.gpx" = "2026-08-03"
|
||||
|
||||
[positions] # médias placés à la main
|
||||
"IMG_4831.jpeg" = [48.85, 2.35]
|
||||
|
||||
[corrections]
|
||||
masques = [[48.85, 2.35, 300]] # zones retirées des traces de ce voyage
|
||||
```
|
||||
|
||||
RTG range alors lui-même les traces par journée (`routes/AAAA-MM-JJ.gpx`, d'après leurs horodatages ou, à défaut, la date du nom de fichier) et copie les photos dans `photos/` (les HEIC de l'iPhone sont converties en JPEG). Une photo sans GPS reçoit la position de la trace à son heure, quand la trace est horodatée. Les réglages communs (qualité vidéo, taille des photos, fuseau horaire, cartes My Maps…) sont dans `site.toml`.
|
||||
|
||||
Les scripts de `Scripts/` restent disponibles pendant la transition ; ils produisent le même site.
|
||||
|
||||
## Mode d'emploi (anciens scripts)
|
||||
|
||||
### Tout le site, ou quelques voyages
|
||||
|
||||
Depuis le dossier du site :
|
||||
|
||||
```
|
||||
cd Road-Trips
|
||||
../RoadTripsGenerator/Scripts/genere_site.sh # tous les voyages
|
||||
../RoadTripsGenerator/Scripts/genere_site.sh Balades/Vexin-10-2026 # un seul
|
||||
SKIP_COMPRESS=1 ../RoadTripsGenerator/Scripts/genere_site.sh # sans réencoder les vidéos
|
||||
```
|
||||
|
||||
Pour chaque voyage, `genere_site.sh` enchaîne les étapes ci-dessous, puis régénère la page d'accueil et la carte globale. Un voyage d'une seule journée (une balade, souvent) n'a pas de carte générale intermédiaire : sa page est directement la carte du jour, avec photos et vidéos. Chaque étape ne refait que ce qui a changé : relancer le script est rapide, sauf quand de nouveaux rushs sont à encoder.
|
||||
|
||||
### Étape par étape
|
||||
|
||||
Depuis le dossier d'un voyage (ici `Road-Trips/Balades/Vexin-10-2026`, d'où les `../../../`) :
|
||||
|
||||
| Commande | Rôle | Produit |
|
||||
|---|---|---|
|
||||
| `../../../RoadTripsGenerator/Scripts/extraction_infos.sh` | positions GPS des photos et vidéos | `export_photos.csv`, `export_videos.csv` |
|
||||
| `../../../RoadTripsGenerator/Scripts/compress.sh` | vidéos web (long) | `videos/` |
|
||||
| `../../../RoadTripsGenerator/Scripts/prepare_web.py` | photos allégées, aperçus | `photos_web/`, `apercus/` |
|
||||
| `../../../RoadTripsGenerator/Scripts/genere_carte.py` | pages du voyage | `index.html`, `mini.html`, `html/`, `assets/`, `<voyage>.kml` |
|
||||
|
||||
Puis, depuis le dossier du site, la page d'accueil :
|
||||
|
||||
```
|
||||
../RoadTripsGenerator/Scripts/genere_index_general.py
|
||||
```
|
||||
|
||||
Sans compression, il faut faire de `videos` un lien vers `rushs` : `ln -s rushs videos`.
|
||||
|
||||
### Réglages utiles
|
||||
|
||||
* `compress.sh` : `ENCODER=vt` (encodeur matériel du Mac, bien plus rapide, un peu moins bon à taille égale), `JOBS=N`, `CRF_HEVC`, `CRF_H264`, `FORCE=1` pour tout réencoder. Une vidéo déjà encodée mais illisible (encodage interrompu) est refaite automatiquement.
|
||||
* `prepare_web.py` : `WEB_PX=1600` (taille des photos), `WEB_QUALITE=80`, `FORCE=1`.
|
||||
* `bench_crf.sh` : mesure la qualité (VMAF ou SSIM) d'un extrait de rush à plusieurs CRF, pour choisir `CRF_HEVC` / `CRF_H264` sur ses propres images.
|
||||
|
||||
### Carte globale de l'accueil
|
||||
|
||||
Sous les rubriques, l'accueil affiche une grande carte (`carte_globale.html`, fond OpenStreetMap, sans Google) qui réunit le contenu récupéré de nos cartes Google My Maps et les voyages du site (rubrique « Voyages »), un calque et une couleur par voyage ; un clic sur une trace mène au voyage. Un fichier `moto.txt` (une ligne) dans un voyage ajoute la moto à son nom dans la légende, par exemple « Ete 2026 (BMW R 1300 GS Adventure) ».
|
||||
|
||||
On peut y ajouter des cartes Google My Maps : mettre leurs liens, un par ligne, dans `mymaps.txt` à la racine du site (les cartes doivent être partagées en « Tous les utilisateurs disposant du lien »). Le KML de chaque carte est récupéré une seule fois dans `_mymaps/` ; ensuite la carte ne dépend plus de Google (`MYMAPS_MAJ=1` pour le reprendre après avoir modifié la carte Google). On peut aussi déposer à la main des exports `.kml` ou `.kmz` dans `_mymaps/`. Chaque calque My Maps devient un calque de la carte, avec ses couleurs d'origine.
|
||||
|
||||
Chaque voyage produit aussi un `<voyage>.kml` (tout le voyage en une seule trace, avec sa distance et un lien vers sa page, sous les 5 Mo acceptés par My Maps), pour qui voudrait l'importer dans une carte My Maps. Régler l'adresse publique du site avec `SITE_URL` (par défaut `https://roadtrip.ouaibe.fr/`) pour des liens corrects.
|
||||
|
||||
### Modifier l'apparence
|
||||
|
||||
Toute la mise en page est dans `Scripts/templates/` :
|
||||
|
||||
* `carte.css`, `carte.js` : style et comportement des pages de voyage (copiés dans `assets/` de chaque voyage à la génération) ;
|
||||
* `fragments.html` : les morceaux de HTML (popups photo et vidéo, journal, boutons, barre des jours) ;
|
||||
* `fragments_globale.html` : les popups et la légende de la carte globale ;
|
||||
* `base_accueil.html`, `accueil.html`, `liste.html` : la page d'accueil (rubriques Voyages et Balades, les 2 plus récents de chacune) et les listes complètes `voyages.html`, `balades.html`.
|
||||
|
||||
Après une modification, `SKIP_COMPRESS=1 ../RoadTripsGenerator/Scripts/genere_site.sh` régénère tout en quelques dizaines de secondes.
|
||||
Un chemin relatif (comme `routes`) part du dossier du voyage.
|
||||
|
||||
## Publier
|
||||
|
||||
Il suffit de copier le dossier du site. Les sous-dossiers `rushs` (originaux, très lourds) et les fichiers `export_*.csv` sont inutiles en ligne ; `photos` reste nécessaire pour l'ouverture des originaux.
|
||||
Le site est statique. Servi directement depuis son dossier (comme ici par nginx), il est en ligne dès la génération terminée. Sinon, il suffit de le copier : les sous-dossiers `rushs` (originaux, très lourds) et les fichiers `export_*.csv` sont inutiles en ligne ; `photos` reste nécessaire pour l'ouverture des originaux.
|
||||
|
||||
## Anciens scripts
|
||||
|
||||
Les scripts de `Scripts/` (`genere_site.sh`, `compress.sh`, `prepare_web.py`…) produisent le même site que RTG ; ils restent disponibles pendant la transition. Ils demandent d'installer à la main les modules Python : `pip3 install pandas folium gpxpy markdown2 numpy pillow`. `bench_crf.sh` mesure la qualité (VMAF ou SSIM) d'un extrait de rush à plusieurs CRF, pour choisir les réglages sur ses propres images.
|
||||
|
||||
La mise en page de RTG est dans `rtg/templates/` (pages des voyages, accueil, carte globale) et `rtg/interface/` (interface).
|
||||
|
||||
## Contribuer
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
# Variante : ouvre ou ferme l'administration RTG dans un conteneur « rtg »
|
||||
# (docker-compose-conteneur.yml).
|
||||
# ./admin-docker.sh ouvrir (https://roadtrips.ouaibe.fr/admin/)
|
||||
# ./admin-docker.sh stop fermer
|
||||
# RTG_ARRET_INACTIF (minutes, dans le compose) : fermeture automatique sans activité ; 0 = jamais.
|
||||
# À placer à côté du docker-compose.yml du site.
|
||||
|
||||
# Disques ou dossiers des sources (rushs, photos, traces), vus en lecture seule
|
||||
# aux mêmes chemins que sur le Mac. Chacun doit être déclaré dans Docker Desktop
|
||||
# (Réglages → Resources → File sharing). Ceux qui sont débranchés sont ignorés.
|
||||
# Avant d'éjecter l'un de ces disques : ./admin-docker.sh stop (un disque retiré sous un
|
||||
# conteneur qui l'utilise peut dérégler Docker Desktop). Rebranché : ./admin-docker.sh.
|
||||
SOURCES="/Volumes/LaCie /Volumes/EXTFAT"
|
||||
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
if [ "$1" = "stop" ]; then
|
||||
docker compose stop rtg && echo "Administration fermée."
|
||||
exit
|
||||
fi
|
||||
|
||||
# Montages du jour : seulement les sources branchées, sinon Docker refuse de démarrer.
|
||||
MONTAGES=""
|
||||
for s in $SOURCES; do
|
||||
if [ -d "$s" ]; then
|
||||
MONTAGES="$MONTAGES - $s:$s:ro
|
||||
"
|
||||
echo "Source : $s (lecture seule)"
|
||||
else
|
||||
echo "Source débranchée, ignorée : $s"
|
||||
fi
|
||||
done
|
||||
FICHIERS="-f docker-compose.yml"
|
||||
if [ -n "$MONTAGES" ]; then
|
||||
printf '# Écrit par admin.sh à chaque ouverture.\nservices:\n rtg:\n volumes:\n%s' "$MONTAGES" > rtg-sources.yml
|
||||
FICHIERS="$FICHIERS -f rtg-sources.yml"
|
||||
fi
|
||||
|
||||
docker compose $FICHIERS up -d --force-recreate rtg \
|
||||
&& echo "Administration ouverte : https://roadtrips.ouaibe.fr/admin/ (./admin-docker.sh stop pour la fermer)"
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
# Ouvre ou ferme l'administration RTG du site, lancée directement sur le Mac.
|
||||
# ./admin.sh ouvrir (https://roadtrips.ouaibe.fr/admin/)
|
||||
# ./admin.sh stop fermer
|
||||
# Option --arret-inactif MINUTES : fermeture automatique sans activité.
|
||||
# Le nginx du site la joint par host.docker.internal:8765 (exemples/nginx-admin.conf).
|
||||
# Journal : rtg-admin.log, à côté de ce script.
|
||||
RACINE=/Volumes/CLEROADTRIPS/Road-Trips
|
||||
RTG="" # chemin de la commande rtg si elle n'est pas trouvée toute seule
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
PID="$PWD/rtg-admin.pid"
|
||||
JOURNAL="$PWD/rtg-admin.log"
|
||||
|
||||
ouverte() { [ -f "$PID" ] && kill -0 "$(cat "$PID")" 2>/dev/null; }
|
||||
|
||||
if [ "$1" = "stop" ]; then
|
||||
if ouverte; then kill "$(cat "$PID")" && echo "Administration fermée."
|
||||
else echo "L'administration n'était pas ouverte."; fi
|
||||
rm -f "$PID"
|
||||
exit
|
||||
fi
|
||||
|
||||
if ouverte; then
|
||||
echo "Déjà ouverte : https://roadtrips.ouaibe.fr/admin/"
|
||||
exit
|
||||
fi
|
||||
# rtg : celui du chemin des commandes, sinon l'emplacement habituel de pipx.
|
||||
[ -n "$RTG" ] || RTG=$(command -v rtg 2>/dev/null)
|
||||
for c in "$HOME/.local/bin/rtg" /opt/homebrew/bin/rtg /usr/local/bin/rtg; do
|
||||
[ -n "$RTG" ] && break
|
||||
[ -x "$c" ] && RTG="$c"
|
||||
done
|
||||
[ -n "$RTG" ] || { echo "❌ Commande rtg introuvable : tapez « type rtg » dans le Terminal et mettez son chemin dans RTG= en haut de ce script."; exit 1; }
|
||||
nohup "$RTG" --racine "$RACINE" interface --proxy --prefixe /admin > "$JOURNAL" 2>&1 &
|
||||
echo $! > "$PID"
|
||||
# Attendre qu'elle réponde (quelques secondes au premier lancement).
|
||||
i=0
|
||||
while [ $i -lt 15 ] && ouverte && ! curl -s -o /dev/null http://127.0.0.1:8765/admin/connexion; do
|
||||
sleep 1; i=$((i + 1))
|
||||
done
|
||||
if ouverte; then
|
||||
echo "Administration ouverte : https://roadtrips.ouaibe.fr/admin/ (journal : $JOURNAL)"
|
||||
else
|
||||
echo "❌ L'administration n'a pas démarré :"
|
||||
tail -5 "$JOURNAL"
|
||||
rm -f "$PID"
|
||||
fi
|
||||
@@ -0,0 +1,39 @@
|
||||
# Variante : l'administration RTG dans son propre conteneur (admin-docker.sh).
|
||||
# Plus lente pour encoder (processeurs limités par Docker Desktop, pas d'encodeur
|
||||
# matériel), et chaque disque des sources doit être partagé dans Docker Desktop.
|
||||
# Ouvrir : ./admin-docker.sh ; fermer : ./admin-docker.sh stop.
|
||||
# Après une mise à jour de RTG : docker compose build rtg
|
||||
services:
|
||||
roadtrip:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ouaibe_network
|
||||
volumes:
|
||||
- /Volumes/CLEROADTRIPS/Road-Trips:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=ouaibe_network
|
||||
- traefik.http.routers.ouaibe_roadtrip.rule=Host(`roadtrip.ouaibe.fr`) || Host(`roadtrips.ouaibe.fr`)
|
||||
- traefik.http.routers.ouaibe_roadtrip.entrypoints=websecure
|
||||
|
||||
# Ne démarre pas avec le reste (profil « admin »). Fermeture automatique
|
||||
# possible : environment: [RTG_ARRET_INACTIF=120] (minutes ; 0 = jamais, défaut).
|
||||
rtg:
|
||||
build: /Volumes/CLEROADTRIPS/RoadTripsGenerator
|
||||
profiles: ["admin"]
|
||||
restart: "no"
|
||||
networks:
|
||||
- ouaibe_network
|
||||
volumes:
|
||||
# Le site, en lecture et écriture, au même chemin que sur le Mac
|
||||
# (même partage Docker Desktop que pour nginx).
|
||||
- /Volumes/CLEROADTRIPS/Road-Trips:/Volumes/CLEROADTRIPS/Road-Trips
|
||||
# Le mot de passe : mkdir -p rtg-acces && cp ~/.config/rtg/acces.toml rtg-acces/
|
||||
- ./rtg-acces:/config/rtg:ro
|
||||
# Les sources (LaCie…) sont ajoutées par admin.sh, en lecture seule.
|
||||
|
||||
networks:
|
||||
ouaibe_network:
|
||||
external: true
|
||||
@@ -0,0 +1,24 @@
|
||||
# docker-compose.yml du site. L'administration RTG n'y figure pas : admin.sh la
|
||||
# lance directement sur le Mac, à la demande (vitesse maximale, tous les disques
|
||||
# visibles sans réglage) ; nginx la joint par host.docker.internal.
|
||||
# Variante avec l'administration en conteneur : docker-compose-conteneur.yml.
|
||||
services:
|
||||
roadtrip:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- ouaibe_network
|
||||
volumes:
|
||||
- /Volumes/CLEROADTRIPS/Road-Trips:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=ouaibe_network
|
||||
- traefik.http.routers.ouaibe_roadtrip.rule=Host(`roadtrip.ouaibe.fr`) || Host(`roadtrips.ouaibe.fr`)
|
||||
- traefik.http.routers.ouaibe_roadtrip.entrypoints=websecure
|
||||
|
||||
networks:
|
||||
ouaibe_network:
|
||||
external: true
|
||||
@@ -0,0 +1,33 @@
|
||||
# Bloc à ajouter dans nginx.conf, à l'intérieur de « server { … } », quand le
|
||||
# site est servi par un conteneur nginx derrière Traefik (Traefik → nginx → RTG).
|
||||
# Si RTG ne tourne pas, /admin/ affiche « Administration fermée ».
|
||||
|
||||
# « ^~ » : sinon une règle du type « location ~* \.(js|css|png)$ » déjà
|
||||
# présente dans nginx.conf prendrait les fichiers de l'interface pour ceux du site.
|
||||
location ^~ /admin/ {
|
||||
# RTG lancé sur le Mac par admin.sh (extra_hosts
|
||||
# « host.docker.internal:host-gateway » dans le compose).
|
||||
proxy_pass http://host.docker.internal:8765;
|
||||
# Variante, RTG dans un conteneur « rtg » (docker-compose-conteneur.yml) :
|
||||
# resolver 127.0.0.11 valid=30s; set $rtg http://rtg:8765; proxy_pass $rtg;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
# Traefik a déjà noté l'adresse du visiteur et le https : on les transmet tels quels.
|
||||
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
# Administration fermée : une page claire plutôt qu'une erreur 502.
|
||||
error_page 502 504 = @admin_fermee;
|
||||
}
|
||||
|
||||
location @admin_fermee {
|
||||
default_type "text/html; charset=utf-8";
|
||||
add_header Cache-Control no-store always;
|
||||
return 503 "<!doctype html><html lang=fr><meta charset=utf-8><meta name=viewport content='width=device-width'><title>Administration fermée</title><body style='font-family:-apple-system,sans-serif;text-align:center;margin-top:18vh;color:#444'><h1>Administration fermée</h1><p>Elle n'est ouverte que pendant les mises à jour du site.</p><p><a href='/'>Retour au site</a></p></body></html>";
|
||||
}
|
||||
|
||||
# Réglages de RTG : inutile de les publier.
|
||||
location ~ \.toml$ { deny all; }
|
||||
# Fichiers cachés (.git, .gitignore, .DS_Store) : jamais publiés.
|
||||
location ~ /\. { deny all; }
|
||||
@@ -0,0 +1,27 @@
|
||||
# Traefik : envoie https://roadtrips.ouaibe.fr/admin/ vers l'interface RTG.
|
||||
#
|
||||
# 1. Copier ce fichier dans le dossier de configuration « dynamique » de
|
||||
# Traefik (celui de providers.file), à côté de la configuration du site.
|
||||
# 2. Mettre ci-dessous les mêmes entryPoints et certResolver que ceux du site.
|
||||
# 3. Sur le Mac, laisser tourner :
|
||||
# rtg --racine /Volumes/CLEROADTRIPS/Road-Trips interface --proxy --prefixe /admin
|
||||
#
|
||||
# Traefik transmet lui-même l'adresse du visiteur et le « https » à RTG
|
||||
# (X-Forwarded-For, X-Forwarded-Proto) : rien d'autre à régler.
|
||||
http:
|
||||
routers:
|
||||
rtg-admin:
|
||||
rule: "Host(`roadtrips.ouaibe.fr`) && PathPrefix(`/admin`)"
|
||||
entryPoints: [websecure] # même nom que pour le site
|
||||
service: rtg-admin
|
||||
tls:
|
||||
certResolver: letsencrypt # même nom que pour le site
|
||||
services:
|
||||
rtg-admin:
|
||||
loadBalancer:
|
||||
servers:
|
||||
# Traefik dans Docker sur ce Mac :
|
||||
- url: "http://host.docker.internal:8765"
|
||||
# Traefik installé directement sur ce Mac : "http://127.0.0.1:8765"
|
||||
# Traefik sur une autre machine : "http://<adresse du Mac>:8765",
|
||||
# et lancer RTG avec --ecoute 0.0.0.0 en plus.
|
||||
+2
-1
@@ -30,7 +30,8 @@ dev = ["pytest>=7"]
|
||||
rtg = "rtg.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["rtg"]
|
||||
packages = ["rtg", "rtg.interface"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
rtg = ["templates/*"]
|
||||
"rtg.interface" = ["modeles/*", "static/*", "static/images/*", "aide/*"]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Mot de passe de l'interface (`rtg mot-de-passe`).
|
||||
|
||||
Il est rangé hors du site (le dossier du site est souvent servi tel quel par
|
||||
le serveur web) : ~/.config/rtg/acces.toml sous macOS et Linux,
|
||||
%APPDATA%\\rtg\\acces.toml sous Windows. Seule une empreinte scrypt salée y
|
||||
est écrite, jamais le mot de passe.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import unicodedata
|
||||
|
||||
from rtg.config import ecrire_toml, lire_toml
|
||||
|
||||
LONGUEUR_MIN = 10
|
||||
SCRYPT = {"n": 2 ** 15, "r": 8, "p": 1}
|
||||
|
||||
|
||||
def chemin_acces():
|
||||
if os.name == "nt":
|
||||
base = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||
else:
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
||||
return os.path.join(base, "rtg", "acces.toml")
|
||||
|
||||
|
||||
def _empreinte(mdp, sel, n, r, p):
|
||||
# NFC : un « é » tapé dans le Terminal ou dans le navigateur donne les mêmes octets.
|
||||
mdp = unicodedata.normalize("NFC", mdp)
|
||||
return hashlib.scrypt(mdp.encode("utf-8"), salt=sel, n=n, r=r, p=p,
|
||||
maxmem=128 * r * n * 2, dklen=32)
|
||||
|
||||
|
||||
def definir(mdp):
|
||||
if len(mdp) < LONGUEUR_MIN:
|
||||
raise ValueError(f"Le mot de passe doit faire au moins {LONGUEUR_MIN} caractères.")
|
||||
sel = secrets.token_bytes(16)
|
||||
e = _empreinte(mdp, sel, **SCRYPT)
|
||||
chemin = chemin_acces()
|
||||
os.makedirs(os.path.dirname(chemin), exist_ok=True)
|
||||
ecrire_toml(chemin, {"mot_de_passe": "scrypt${n}${r}${p}${sel}${e}".format(
|
||||
**SCRYPT, sel=base64.b64encode(sel).decode(), e=base64.b64encode(e).decode())},
|
||||
entete="Accès à l'interface RTG (empreinte du mot de passe, pas le mot de passe).")
|
||||
try:
|
||||
os.chmod(chemin, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return chemin
|
||||
|
||||
|
||||
def defini():
|
||||
return bool(lire_toml(chemin_acces()).get("mot_de_passe"))
|
||||
|
||||
|
||||
def supprimer():
|
||||
chemin = chemin_acces()
|
||||
if os.path.exists(chemin):
|
||||
os.remove(chemin)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verifier(mdp):
|
||||
"""Vrai si `mdp` est le mot de passe enregistré (comparaison à temps constant)."""
|
||||
valeur = lire_toml(chemin_acces()).get("mot_de_passe", "")
|
||||
try:
|
||||
algo, n, r, p, sel, e = valeur.split("$")
|
||||
if algo != "scrypt":
|
||||
return False
|
||||
attendu = base64.b64decode(e)
|
||||
calcule = _empreinte(mdp, base64.b64decode(sel), int(n), int(r), int(p))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return hmac.compare_digest(attendu, calcule)
|
||||
+2
-1
@@ -93,7 +93,7 @@ def fiche(racine, chemin):
|
||||
quand = date(int(m.group(0)), 1, 1) if m else date.min
|
||||
return {
|
||||
'dossier': chemin,
|
||||
'titre': nom.replace('-', ' ').replace('_', ' '),
|
||||
'titre': reglages_voyage(chemin).get('titre') or nom.replace('-', ' ').replace('_', ' '),
|
||||
'chemin': quote(os.path.relpath(chemin, racine).replace(os.sep, '/')),
|
||||
'periode': periode(dates),
|
||||
'moto': reglages_voyage(chemin).get('moto', ''),
|
||||
@@ -150,6 +150,7 @@ def generer(racine=".", cfg=None, mettre_a_jour_mymaps=False):
|
||||
|
||||
ecrire("index.html", env.get_template("accueil.html").render(
|
||||
titre_site=TITRE_SITE, rubriques=rubriques, nb_recents=NB_RECENTS,
|
||||
administration=cfg["administration"]["adresse"],
|
||||
carte_globale=CARTE_GLOBALE if any(r['titre'] == TITRE_SECTION_RACINE for r in rubriques) else None,
|
||||
sous_titre=f"{total} voyage{'s' if total > 1 else ''}" if total else ""))
|
||||
for r in rubriques:
|
||||
|
||||
+65
-2
@@ -6,6 +6,9 @@
|
||||
rtg compresser VOYAGE seulement les vidéos
|
||||
rtg accueil seulement la page d'accueil et la carte globale
|
||||
rtg migrer écrire site.toml et les voyage.toml depuis l'existant
|
||||
rtg verifier tout ce dont RTG a besoin est-il installé ?
|
||||
rtg interface ouvrir l'interface dans le navigateur
|
||||
rtg mot-de-passe définir le mot de passe de l'interface (accès distant)
|
||||
|
||||
La racine du site est le premier dossier, en remontant depuis le dossier
|
||||
courant, qui contient site.toml (sinon le dossier courant) ; --racine la force.
|
||||
@@ -36,15 +39,65 @@ def main(argv=None):
|
||||
|
||||
c = sp.add_parser("compresser", help="encoder les vidéos d'un voyage")
|
||||
c.add_argument("voyage")
|
||||
c.add_argument("--force", action="store_true")
|
||||
c.add_argument("--force", action="store_true", help="refaire même les vidéos déjà encodées")
|
||||
c.add_argument("--seulement", choices=["1080p", "720p"],
|
||||
help="ne (re)faire qu'une des deux versions")
|
||||
c.add_argument("--reglages-changes", action="store_true",
|
||||
help="réencoder aussi les vidéos faites avec d'autres réglages que les actuels")
|
||||
|
||||
ac = sp.add_parser("accueil", help="régénérer l'accueil et la carte globale")
|
||||
ac.add_argument("--mymaps-maj", action="store_true")
|
||||
|
||||
sp.add_parser("migrer", help="écrire site.toml et voyage.toml depuis l'organisation actuelle")
|
||||
sp.add_parser("verifier", help="contrôler que Python, exiftool et ffmpeg sont bien installés")
|
||||
|
||||
i = sp.add_parser("interface", help="ouvrir l'interface dans le navigateur")
|
||||
i.add_argument("--port", type=int, default=8765)
|
||||
i.add_argument("--sans-navigateur", action="store_true", help="ne pas ouvrir le navigateur")
|
||||
i.add_argument("--distant", action="store_true",
|
||||
help="accepter les connexions depuis le réseau (mot de passe obligatoire)")
|
||||
i.add_argument("--certificat", help="certificat TLS (PEM) pour servir en HTTPS")
|
||||
i.add_argument("--cle", help="clé privée du certificat TLS (PEM)")
|
||||
i.add_argument("--proxy", action="store_true",
|
||||
help="derrière le serveur web du site (mot de passe obligatoire)")
|
||||
i.add_argument("--prefixe", default="", help="chemin de l'interface sur le site, par exemple /admin")
|
||||
i.add_argument("--ecoute", help="adresse d'écoute (défaut 127.0.0.1 ; 0.0.0.0 si le serveur web est ailleurs)")
|
||||
i.add_argument("--arret-inactif", type=float, default=0, metavar="MINUTES",
|
||||
help="s'arrêter après MINUTES sans activité (0 = jamais)")
|
||||
|
||||
mp = sp.add_parser("mot-de-passe", help="définir ou supprimer le mot de passe de l'interface")
|
||||
mp.add_argument("--supprimer", action="store_true")
|
||||
mp.add_argument("--tester", action="store_true", help="vérifier un mot de passe sans le changer")
|
||||
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.commande == "mot-de-passe":
|
||||
from rtg import acces
|
||||
if args.supprimer:
|
||||
print("✅ Mot de passe supprimé." if acces.supprimer() else "ℹ️ Aucun mot de passe défini.")
|
||||
return
|
||||
import getpass
|
||||
if args.tester:
|
||||
if not acces.defini():
|
||||
sys.exit(f"❌ Aucun mot de passe défini ({acces.chemin_acces()}).")
|
||||
ok = acces.verifier(getpass.getpass("Mot de passe à vérifier : "))
|
||||
print(f"✅ C'est bien le mot de passe enregistré ({acces.chemin_acces()})." if ok
|
||||
else f"❌ Ce n'est pas le mot de passe enregistré ({acces.chemin_acces()}).")
|
||||
sys.exit(0 if ok else 1)
|
||||
mdp = getpass.getpass("Nouveau mot de passe : ")
|
||||
if mdp != getpass.getpass("Le même, encore une fois : "):
|
||||
sys.exit("❌ Les deux saisies diffèrent.")
|
||||
try:
|
||||
print(f"✅ Mot de passe enregistré ({acces.definir(mdp)}).")
|
||||
except ValueError as e:
|
||||
sys.exit(f"❌ {e}")
|
||||
return
|
||||
|
||||
if args.commande == "verifier":
|
||||
# Avant tout autre import : doit fonctionner même si un module manque.
|
||||
from rtg.verifier import verifier
|
||||
sys.exit(0 if verifier() else 1)
|
||||
|
||||
from rtg import accueil, site, sources, video
|
||||
from rtg.config import reglages_site, reglages_voyage, trouver_racine
|
||||
racine = os.path.abspath(args.racine) if args.racine else trouver_racine(".")
|
||||
@@ -61,11 +114,21 @@ def main(argv=None):
|
||||
elif args.commande == "compresser":
|
||||
v = os.path.abspath(args.voyage)
|
||||
cfg = reglages_voyage(v, reglages_site(racine))
|
||||
sys.exit(0 if video.compresser(v, cfg, sources.analyser(v, cfg), force=args.force) else 1)
|
||||
paliers = {"1080p": ("hd",), "720p": ("sd",)}.get(args.seulement, ("hd", "sd"))
|
||||
refaire = "differentes" if args.reglages_changes else "manquantes"
|
||||
sys.exit(0 if video.compresser(v, cfg, sources.analyser(v, cfg), force=args.force,
|
||||
paliers=paliers, refaire=refaire) else 1)
|
||||
elif args.commande == "accueil":
|
||||
accueil.generer(racine, reglages_site(racine), args.mymaps_maj)
|
||||
elif args.commande == "migrer":
|
||||
site.migrer(racine)
|
||||
elif args.commande == "interface":
|
||||
from rtg.interface.serveur import lancer
|
||||
if bool(args.certificat) != bool(args.cle):
|
||||
sys.exit("❌ --certificat et --cle vont ensemble.")
|
||||
lancer(racine, args.port, ouvrir=not (args.sans_navigateur or args.proxy), distant=args.distant,
|
||||
certificat=args.certificat, cle=args.cle, proxy=args.proxy, prefixe=args.prefixe,
|
||||
ecoute=args.ecoute, arret_inactif=args.arret_inactif)
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrompu.")
|
||||
sys.exit(130)
|
||||
+49
-2
@@ -32,11 +32,18 @@ DEFAUTS = {
|
||||
"photos": {"grand_cote": 1600, "qualite": 80, "positions_deduites": True},
|
||||
"journees": {"fuseau": "Europe/Paris", "coupure_heure": 4},
|
||||
"carte_globale": {"mymaps": []},
|
||||
# Adresse de l'interface (rtg interface --proxy) : bouton « Connexion »
|
||||
# sur l'accueil du site. Vide = pas de bouton.
|
||||
"administration": {"adresse": ""},
|
||||
# Propres à un voyage
|
||||
"rubrique": "",
|
||||
"moto": "",
|
||||
"sources": {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]},
|
||||
"exclusions": {"fichiers": []},
|
||||
"dates_traces": {}, # trace sans heures → jour imposé (AAAA-MM-JJ)
|
||||
"positions": {}, # média placé à la main → [lat, lon]
|
||||
"corrections": {"masques": []}, # zones retirées des traces : [lat, lon, rayon_m]
|
||||
"confidentialite": {"zones": []}, # (site) zones retirées de tous les voyages
|
||||
}
|
||||
|
||||
# Qualité « humaine » → paramètres d'encodeur. « equilibree » = réglages
|
||||
@@ -113,6 +120,8 @@ def parametres_video(cfg):
|
||||
"""Paramètres d'encodage effectifs (dictionnaire plat)."""
|
||||
v = cfg["video"]
|
||||
q = QUALITES.get(v.get("qualite", "equilibree"), QUALITES["equilibree"])
|
||||
# crf_hevc / crf_h264 dans [video] l'emportent sur la qualité choisie (essais).
|
||||
q = dict(q, **{k: int(v[k]) for k in ("crf_hevc", "crf_h264") if k in v})
|
||||
return dict(q, encodeur="vt" if v.get("encodeur") == "materiel" else "cpu",
|
||||
preset=v.get("preset", "medium"), fps_max=int(v.get("fps_max", 60)),
|
||||
jobs=int(v.get("jobs", 0)), threads=int(v.get("threads", 0)))
|
||||
@@ -130,18 +139,56 @@ def _toml_valeur(v):
|
||||
return f'"{s}"'
|
||||
|
||||
|
||||
def _toml_cle(k):
|
||||
return k if re.fullmatch(r"[A-Za-z0-9_-]+", k) else _toml_valeur(k)
|
||||
|
||||
|
||||
def ecrire_toml(chemin, donnees, entete=""):
|
||||
"""Écrit un TOML simple (valeurs, listes, une profondeur de tables)."""
|
||||
lignes = [f"# {l}" for l in entete.splitlines()] + ([""] if entete else [])
|
||||
for k, v in donnees.items():
|
||||
if not isinstance(v, dict):
|
||||
lignes.append(f"{k} = {_toml_valeur(v)}")
|
||||
lignes.append(f"{_toml_cle(k)} = {_toml_valeur(v)}")
|
||||
for k, v in donnees.items():
|
||||
if isinstance(v, dict):
|
||||
lignes += ["", f"[{k}]"] + [f"{kk} = {_toml_valeur(vv)}" for kk, vv in v.items()]
|
||||
lignes += ["", f"[{_toml_cle(k)}]"] + [f"{_toml_cle(kk)} = {_toml_valeur(vv)}"
|
||||
for kk, vv in v.items()]
|
||||
with open(chemin, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lignes) + "\n")
|
||||
|
||||
|
||||
def modifier_toml(chemin, changements, entete=""):
|
||||
"""Applique `changements` à un fichier TOML existant (ou nouveau) et le
|
||||
réécrit. Une valeur None retire la clé ; une table est fusionnée sur un
|
||||
niveau. Le commentaire d'en-tête est conservé, les autres sont perdus."""
|
||||
donnees = lire_toml(chemin)
|
||||
if os.path.exists(chemin):
|
||||
with open(chemin, encoding="utf-8") as f:
|
||||
tete = []
|
||||
for l in f:
|
||||
if not l.startswith("#"):
|
||||
break
|
||||
tete.append(l[1:].strip())
|
||||
entete = "\n".join(tete) or entete
|
||||
for k, v in changements.items():
|
||||
if v is None:
|
||||
donnees.pop(k, None)
|
||||
elif isinstance(v, dict):
|
||||
table = dict(donnees.get(k, {}))
|
||||
for kk, vv in v.items():
|
||||
if vv is None:
|
||||
table.pop(kk, None)
|
||||
else:
|
||||
table[kk] = vv
|
||||
if table:
|
||||
donnees[k] = table
|
||||
else:
|
||||
donnees.pop(k, None)
|
||||
else:
|
||||
donnees[k] = v
|
||||
ecrire_toml(chemin, donnees, entete)
|
||||
return donnees
|
||||
|
||||
|
||||
def nom_lisible(nom):
|
||||
return re.sub(r"[-_]+", " ", nom).strip()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Corrections faites à la main (interface, onglet « Carte ») et appliquées à
|
||||
la génération des pages ; les fichiers d'origine ne sont jamais modifiés.
|
||||
|
||||
voyage.toml
|
||||
[positions] média déplacé ou placé à la main
|
||||
"IMG_4831.jpeg" = [48.85, 2.35]
|
||||
[corrections]
|
||||
masques = [[48.85, 2.35, 300]] zones (centre, rayon en m) retirées des traces
|
||||
|
||||
site.toml
|
||||
[confidentialite]
|
||||
zones = [[48.80, 2.10, 500]] zones retirées des traces de tous les voyages
|
||||
(autour de chez soi, par exemple)
|
||||
"""
|
||||
import csv
|
||||
import math
|
||||
import os
|
||||
|
||||
import gpxpy.gpx
|
||||
|
||||
|
||||
def masques(cfg):
|
||||
"""[(lat, lon, rayon_m)] : zones du voyage et zones privées du site."""
|
||||
res = []
|
||||
for liste in (cfg.get("corrections", {}).get("masques", []),
|
||||
cfg.get("confidentialite", {}).get("zones", [])):
|
||||
for m in liste:
|
||||
try:
|
||||
res.append((float(m[0]), float(m[1]), float(m[2])))
|
||||
except (TypeError, ValueError, IndexError):
|
||||
pass
|
||||
return res
|
||||
|
||||
|
||||
def positions(cfg):
|
||||
"""{nom de fichier: (lat, lon)} des médias placés à la main."""
|
||||
res = {}
|
||||
for nom, pos in cfg.get("positions", {}).items():
|
||||
try:
|
||||
res[nom] = (float(pos[0]), float(pos[1]))
|
||||
except (TypeError, ValueError, IndexError):
|
||||
pass
|
||||
return res
|
||||
|
||||
|
||||
def distance_m(lat1, lon1, lat2, lon2):
|
||||
x = math.radians(lon2 - lon1) * math.cos(math.radians((lat1 + lat2) / 2))
|
||||
y = math.radians(lat2 - lat1)
|
||||
return 6371000 * math.hypot(x, y)
|
||||
|
||||
|
||||
def dans_un_masque(lat, lon, zones):
|
||||
return any(distance_m(lat, lon, m[0], m[1]) <= m[2] for m in zones)
|
||||
|
||||
|
||||
def filtrer_piste(piste, zones):
|
||||
"""Retire d'une piste GPX les points situés dans les zones ; un segment
|
||||
traversé par une zone est coupé en deux. Renvoie le nombre de points retirés."""
|
||||
if not zones:
|
||||
return 0
|
||||
retires = 0
|
||||
nouveaux = []
|
||||
for seg in piste.segments:
|
||||
courant = gpxpy.gpx.GPXTrackSegment()
|
||||
for p in seg.points:
|
||||
if dans_un_masque(p.latitude, p.longitude, zones):
|
||||
retires += 1
|
||||
if courant.points:
|
||||
nouveaux.append(courant)
|
||||
courant = gpxpy.gpx.GPXTrackSegment()
|
||||
else:
|
||||
courant.points.append(p)
|
||||
if courant.points:
|
||||
nouveaux.append(courant)
|
||||
piste.segments = [s for s in nouveaux if len(s.points) > 1]
|
||||
return retires
|
||||
|
||||
|
||||
def lire_medias(voyage, cfg):
|
||||
"""Photos et vidéos des exports CSV, positions corrigées comprises.
|
||||
|
||||
Renvoie une liste de dicts {fichier, type, date, lat, lon, deplace} ; lat et
|
||||
lon valent None pour un média sans position."""
|
||||
corr = positions(cfg)
|
||||
res = []
|
||||
for nom_csv, type_ in (("export_photos.csv", "photo"), ("export_videos.csv", "video")):
|
||||
chemin = os.path.join(voyage, nom_csv)
|
||||
if not os.path.exists(chemin):
|
||||
continue
|
||||
vus = set()
|
||||
with open(chemin, encoding="utf-8", errors="replace") as f:
|
||||
for ligne in csv.reader(f):
|
||||
if len(ligne) < 4 or ligne[0] in vus:
|
||||
continue
|
||||
vus.add(ligne[0])
|
||||
try:
|
||||
lat, lon = float(ligne[2]), float(ligne[3])
|
||||
if lat == 0 and lon == 0:
|
||||
lat = lon = None
|
||||
except ValueError:
|
||||
lat = lon = None
|
||||
m = {"fichier": ligne[0], "type": type_, "date": ligne[1], "lat": lat, "lon": lon,
|
||||
"deplace": ligne[0] in corr}
|
||||
if m["deplace"]:
|
||||
m["lat"], m["lon"] = corr[ligne[0]]
|
||||
res.append(m)
|
||||
return res
|
||||
+27
-5
@@ -13,6 +13,7 @@ import subprocess
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from rtg import corrections
|
||||
from rtg.outils import dire, exiger
|
||||
|
||||
FMT_PHOTOS = '"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude,$GPSAltitude'
|
||||
@@ -56,14 +57,29 @@ def extraire_photos(voyage, cfg, analyse):
|
||||
if pos:
|
||||
lignes.append(f"{nom},{date},{pos[0]},{pos[1]},")
|
||||
deduites += 1
|
||||
# Photos datées restées sans position : lignes sans coordonnées, ignorées
|
||||
# par les pages mais listées par l'interface pour être placées à la main.
|
||||
vus = {l.split(",")[0] for l in lignes}
|
||||
sans_position = [l.split(",", 1) for l in _exiftool(commun + ["-p", FMT_PHOTOS_DATE, dossier])
|
||||
if l.split(",")[0] not in vus]
|
||||
lignes += [f"{nom},{date},,," for nom, date in sans_position]
|
||||
|
||||
with open(csv, "w", encoding="utf-8") as f:
|
||||
f.write("".join(l + "\n" for l in lignes))
|
||||
msg = f"📸 Photos : {len(lignes)} ligne(s)"
|
||||
if len(lignes) > len(p1) + deduites:
|
||||
msg += f" (dont {len(lignes) - len(p1) - deduites} sans altitude)"
|
||||
localisees = len(lignes) - len(sans_position)
|
||||
msg = f"📸 Photos : {localisees} localisée(s)"
|
||||
if localisees > len(p1) + deduites:
|
||||
msg += f" (dont {localisees - len(p1) - deduites} sans altitude)"
|
||||
if deduites:
|
||||
msg += f", {deduites} position(s) déduite(s) de la trace"
|
||||
if sans_position:
|
||||
placees = corrections.positions(cfg)
|
||||
a_la_main = sum(1 for nom, _ in sans_position if nom.strip('"') in placees)
|
||||
reste = len(sans_position) - a_la_main
|
||||
if a_la_main:
|
||||
msg += f", {a_la_main} placée(s) à la main"
|
||||
if reste:
|
||||
msg += f", {reste} sans position (à placer dans l'interface)"
|
||||
dire(msg)
|
||||
|
||||
|
||||
@@ -141,6 +157,8 @@ def _premier_point(video):
|
||||
date = dt or cd
|
||||
if premier and date:
|
||||
return date, premier[0], premier[1]
|
||||
if date:
|
||||
return date, None, None # datée mais sans GPS : à placer à la main
|
||||
return None
|
||||
|
||||
|
||||
@@ -169,15 +187,19 @@ def extraire_videos(voyage, analyse):
|
||||
reprises += 1
|
||||
continue
|
||||
r = _premier_point(v)
|
||||
if r:
|
||||
if r and r[1] is not None:
|
||||
lignes.append(f'"{nom}",{r[0]},{r[1]},{r[2]},,')
|
||||
etat = "✓"
|
||||
else:
|
||||
sans += 1
|
||||
etat = "— pas de GPS"
|
||||
if r:
|
||||
lignes.append(f'"{nom}",{r[0]},,,,')
|
||||
dire(f" [{i:3d}/{len(analyse.videos):3d}] {nom:<28} {etat}")
|
||||
if reprises:
|
||||
dire(f" {reprises} rush(s) inchangé(s), positions reprises du dernier export")
|
||||
with open(csv, "w", encoding="utf-8") as f:
|
||||
f.write("".join(l + "\n" for l in lignes))
|
||||
dire(f"🎬 Vidéos : {len(lignes)} rush(s) localisé(s)" + (f", {sans} sans GPS" if sans else ""))
|
||||
sans_pos = sum(1 for l in lignes if l.split(",")[2:4] == ["", ""])
|
||||
dire(f"🎬 Vidéos : {len(lignes) - sans_pos} rush(s) localisé(s)"
|
||||
+ (f", {sans_pos} sans GPS (à placer dans l'interface)" if sans_pos else ""))
|
||||
@@ -0,0 +1 @@
|
||||
"""Interface locale de RTG (`rtg interface`)."""
|
||||
@@ -0,0 +1,22 @@
|
||||
# Accès et sécurité
|
||||
|
||||
L'interface permet de modifier le site et de parcourir les disques : elle est protégée.
|
||||
|
||||
## Deux façons d'entrer
|
||||
|
||||
- **La clé d'accès** : à chaque lancement de l'interface, une clé tirée au hasard est ajoutée à l'adresse ouverte dans le navigateur (`…/?jeton=…`) et affichée dans le Terminal ou le journal. Seul celui qui la voit peut entrer. Elle change à chaque lancement.
|
||||
- **Le mot de passe** : défini dans le Terminal avec `rtg mot-de-passe` (10 caractères au moins). Indispensable depuis une autre machine ou depuis le site. `rtg mot-de-passe --tester` vérifie un mot de passe ; `--supprimer` l'efface.
|
||||
|
||||
Le mot de passe n'est enregistré nulle part en clair : seule une empreinte est gardée, hors du site, dans `~/.config/rtg/acces.toml`.
|
||||
|
||||
## Session
|
||||
|
||||
Une fois entré, le navigateur garde une session de 12 heures après la dernière action. Elle est perdue quand l'interface s'arrête. **Se déconnecter** la ferme tout de suite.
|
||||
|
||||
## Protections
|
||||
|
||||
- Après 5 mots de passe faux, l'adresse IP doit attendre une minute, puis deux, quatre… jusqu'à une heure.
|
||||
- Le cookie de session n'est lisible que par l'interface, et n'est envoyé qu'en HTTPS quand le site l'est.
|
||||
- Une autre page ouverte dans le navigateur ne peut rien modifier, et l'interface refuse d'être affichée dans un cadre.
|
||||
|
||||
Pour l'accès depuis le site, voir [Administration depuis le site](administration.md).
|
||||
@@ -0,0 +1,41 @@
|
||||
# Administration depuis le site
|
||||
|
||||
Le bouton **Connexion** de l'accueil mène à `https://roadtrips.ouaibe.fr/admin/`. Le serveur web y fait suivre les demandes vers l'interface RTG, qui tourne directement sur le Mac :
|
||||
|
||||
```
|
||||
Traefik (HTTPS) → nginx (le site, dans Docker) → /admin/ → RTG sur le Mac
|
||||
```
|
||||
|
||||
L'interface ne tourne que quand on en a besoin. Fermée, `/admin/` affiche « Administration fermée » et le site reste en ligne.
|
||||
|
||||
## Au quotidien
|
||||
|
||||
Dans le dossier du `docker-compose.yml` du site :
|
||||
|
||||
```
|
||||
./admin.sh ouvrir l'administration
|
||||
./admin.sh stop la fermer
|
||||
```
|
||||
|
||||
Elle tourne en arrière-plan : inutile de garder le Terminal ouvert. Son journal est dans `rtg-admin.log`, à côté du script. Fermer pendant un encodage l'arrête proprement ; les vidéos finies sont gardées.
|
||||
|
||||
À la maison, devant le Mac, on peut aussi simplement taper `rtg interface` dans le Terminal : ce sont les mêmes écrans, sans passer par le site.
|
||||
|
||||
## Disques et vitesse
|
||||
|
||||
L'interface tourne sur le Mac : elle voit tous les disques branchés, comme n'importe quelle application, et encode à pleine vitesse (tous les cœurs performance, encodeur matériel possible dans les [Réglages du site](reglages.md)).
|
||||
|
||||
Un disque des sources débranché est signalé dans [Sources](sources.md) ; les vidéos déjà encodées restent en ligne.
|
||||
|
||||
## Avant d'éjecter un disque
|
||||
|
||||
- **Disque de sources** : attendre la fin de l'encodage en cours, ou fermer l'administration.
|
||||
- **Clé du site** : nginx s'en sert en permanence ; `docker compose stop` d'abord, `docker compose up -d` une fois rebranchée.
|
||||
|
||||
## Après une mise à jour de RTG
|
||||
|
||||
`./admin.sh stop`, puis `./admin.sh`.
|
||||
|
||||
## Mise en place
|
||||
|
||||
L'installation (nginx, mot de passe, `admin.sh`) est décrite dans le README de RoadTripsGenerator, sur git, section « Administration depuis le site : mise en place ».
|
||||
@@ -0,0 +1,22 @@
|
||||
# Carte et corrections
|
||||
|
||||
Corriger à la souris ce que les fichiers d'origine ont de faux. Les corrections sont enregistrées aussitôt dans `voyage.toml` (ou `site.toml` pour une zone privée) et appliquées **à la prochaine génération** ; les fichiers d'origine ne changent pas.
|
||||
|
||||
## Photos et vidéos
|
||||
|
||||
- **Déplacer** : glisser le marqueur (rouge pour une photo, bleu pour une vidéo). Un marqueur cerclé d'orange a une position corrigée à la main ; sa bulle propose de revenir à la position d'origine.
|
||||
- **Placer un média sans position** : cliquer son nom dans « Médias sans position », puis cliquer sur la carte. Échap annule.
|
||||
- Le sélecteur **Jour** et les cases **Photos** / **Vidéos** filtrent l'affichage. Le fond **Satellite** (bouton des calques) aide à placer précisément.
|
||||
|
||||
## Zones masquées
|
||||
|
||||
Les points de trace situés dans une zone sont retirés des pages : autour de chez soi, ou sur une erreur du GPS.
|
||||
|
||||
1. **+ Masquer une zone**, puis cliquer au centre ; un nouveau clic déplace la zone.
|
||||
2. Régler le **rayon**.
|
||||
3. Cocher **Zone privée** pour la retirer de **tous les voyages** (elle est alors notée dans `site.toml`).
|
||||
4. **Enregistrer la zone**.
|
||||
|
||||
Une zone se supprime depuis la liste, bouton **Supprimer**.
|
||||
|
||||
Après des corrections, régénérer le voyage ([Générer](generer.md)) ; pour une zone privée, régénérer les voyages concernés.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Générer
|
||||
|
||||
Fabrique tout ce que le site affiche pour ce voyage, puis refait l'accueil.
|
||||
|
||||
## Les étapes
|
||||
|
||||
1. **Sources** : les traces sont réparties par jour, les photos copiées.
|
||||
2. **Positions** : dates et positions GPS des photos et des vidéos (exiftool).
|
||||
3. **Vidéos** : encodage des rushs.
|
||||
4. **Photos** : versions allégées pour le web, et une image d'aperçu par vidéo.
|
||||
5. **Pages** : cartes du voyage et de chaque jour.
|
||||
6. **Accueil** : page d'accueil, rubriques, carte globale.
|
||||
|
||||
Ce qui est déjà à jour n'est pas refait. Le journal s'affiche en dessous. **Arrêter** interrompt proprement : les vidéos finies sont gardées.
|
||||
|
||||
## Compression des vidéos
|
||||
|
||||
Chaque rush donne deux vidéos :
|
||||
|
||||
- **1080p en HEVC (H.265)**, environ 40 % plus léger que du H.264 à qualité égale ;
|
||||
- **720p en H.264**, chargée par défaut et lisible partout.
|
||||
|
||||
Le tableau **Compression des vidéos** montre, pour chaque version :
|
||||
|
||||
- **Vidéos actuelles** : les réglages relus dans les fichiers eux-mêmes (les encodeurs x265 et x264 y inscrivent leurs réglages, par exemple « HEVC CRF 26 »). **conforme** ou **différent** les compare aux prochains encodages. Pour un voyage de plus de 40 vidéos, un échantillon est lu.
|
||||
- **Prochains encodages** : ce que donneraient les réglages actuels.
|
||||
|
||||
Le **CRF** règle la qualité : plus il est élevé, plus les fichiers sont légers et moins la qualité est bonne. Les qualités du site correspondent à : Économe 28 / 27, Équilibrée 26 / 25, Haute 23 / 22 (1080p / 720p).
|
||||
|
||||
## Réglages vidéo propres à ce voyage
|
||||
|
||||
Pour tester une compression sur un seul voyage, sans toucher au reste du site : qualité, vitesse de l'encodeur (plus lent = un peu plus léger à qualité égale), ou un **CRF précis** pour le 1080p et le 720p. Laisser vide pour suivre les réglages du site. Ces réglages sont notés dans `voyage.toml`, table `[video]`.
|
||||
|
||||
## Quelles vidéos encoder
|
||||
|
||||
- **Seulement celles qui manquent** (par défaut).
|
||||
- **Aussi celles faites avec d'autres réglages** : RTG relit les réglages de chaque vidéo et ne refait que celles qui diffèrent. Relancé avec les mêmes réglages, il ne refait rien.
|
||||
- **Tout réencoder**.
|
||||
|
||||
**Versions concernées** limite le travail au **1080p** ou au **720p** ; l'autre version est gardée telle quelle. Le journal affiche pour chaque vidéo la nouvelle taille et l'ancienne (« avant … »).
|
||||
|
||||
**Ne pas toucher aux vidéos** saute l'encodage (utile quand le disque des rushs n'est pas branché). **Refaire aussi les photos web et les pages** les refait même si elles sont à jour.
|
||||
|
||||
## Boutons
|
||||
|
||||
- **Générer le voyage** : toutes les étapes.
|
||||
- **Encoder les vidéos seulement** : l'étape Vidéos seule, avec les choix ci-dessus.
|
||||
|
||||
Pour essayer un réglage sur Aisne, par exemple : réglages propres au voyage, puis « aussi celles faites avec d'autres réglages », « 1080p seulement » et « Encoder les vidéos seulement ».
|
||||
@@ -0,0 +1,25 @@
|
||||
# Prise en main
|
||||
|
||||
RTG fabrique un site web de road-trips : pour chaque voyage, une carte par jour avec la trace GPS, les photos, les vidéos et le récit. Le site produit est statique : de simples fichiers, servis tels quels par le serveur web.
|
||||
|
||||
## Les étapes, pour un nouveau voyage
|
||||
|
||||
1. **Mes voyages → Nouveau voyage** : un titre, un nom de dossier, une rubrique. Voir [Mes voyages](voyages.md).
|
||||
2. **Onglet Sources** : indiquer où sont les traces GPS, les vidéos et les photos d'origine. Elles restent où elles sont. Voir [Sources](sources.md).
|
||||
3. **Onglet Journées** : vérifier les jours et rattacher à un jour les traces qui n'ont pas d'heures. Voir [Journées](journees.md).
|
||||
4. **Onglet Générer** : lancer la génération, qui encode les vidéos, prépare les photos et fabrique les pages. Voir [Générer](generer.md).
|
||||
5. **Onglets Infos et récit, Carte et corrections** : écrire le récit, corriger une photo mal placée, masquer une portion de trace, puis régénérer. Voir [Infos et récit](infos.md) et [Carte et corrections](carte.md).
|
||||
|
||||
Les fichiers d'origine ne sont jamais modifiés. Tout ce que fait l'interface est noté dans deux fichiers texte, `site.toml` (réglages du site) et `voyage.toml` (un par voyage). L'installation, les commandes du Terminal et le détail de ces fichiers sont dans le README de RoadTripsGenerator, sur git.
|
||||
|
||||
## Toutes les pages d'aide
|
||||
|
||||
- [Mes voyages](voyages.md) — la liste, les pastilles, créer un voyage
|
||||
- [Sources](sources.md) — où RTG trouve les fichiers d'origine
|
||||
- [Journées](journees.md) — les jours du voyage, les traces sans heures
|
||||
- [Carte et corrections](carte.md) — déplacer une photo, masquer une zone
|
||||
- [Infos et récit](infos.md) — titre, moto, récit en Markdown
|
||||
- [Générer](generer.md) — génération, compression des vidéos
|
||||
- [Réglages du site](reglages.md)
|
||||
- [Accès et sécurité](acces.md) — clé d'accès, mot de passe
|
||||
- [Administration depuis le site](administration.md) — ouvrir, fermer, disques des sources
|
||||
@@ -0,0 +1,27 @@
|
||||
# Infos et récit
|
||||
|
||||
## Titre et moto
|
||||
|
||||
- **Titre** : le nom du voyage sur l'accueil, dans les listes et dans la légende de la carte globale.
|
||||
- **Moto** : facultative ; affichée dans la légende de la carte globale, par exemple « Ete 2026 (BMW R 1300 GS Adventure) ».
|
||||
|
||||
La rubrique vient du dossier où le voyage est rangé (voir [Mes voyages](voyages.md)).
|
||||
|
||||
## Récit
|
||||
|
||||
Le récit est le fichier `voyage.md`, en Markdown, affiché dans un panneau à côté des cartes du voyage.
|
||||
|
||||
Deux liens spéciaux, qui marchent aussi bien en local qu'en ligne :
|
||||
|
||||
```
|
||||
[Carte du jour](jour:2026-08-01)
|
||||
[Le col](media:photos/IMG_4831.jpeg)
|
||||
[La descente](media:videos/GH011272.mp4)
|
||||
```
|
||||
|
||||
- `jour:AAAA-MM-JJ` ouvre la carte de ce jour ;
|
||||
- `media:…` ouvre un média du voyage (chemin depuis le dossier du voyage).
|
||||
|
||||
Un lien vers un jour sans carte ou vers un média introuvable est signalé dans le journal de génération.
|
||||
|
||||
Enregistrer, puis régénérer le voyage pour mettre le site à jour ([Générer](generer.md)).
|
||||
@@ -0,0 +1,19 @@
|
||||
# Journées
|
||||
|
||||
Les jours du voyage d'après les traces GPS, avec leurs kilomètres, et la carte des traces (une couleur par jour).
|
||||
|
||||
## Traces à rattacher à un jour
|
||||
|
||||
Une trace **sans heures** (un itinéraire préparé, par exemple) et sans date AAAA-MM-JJ dans son nom ne peut pas être placée seule. Elle apparaît en pointillé sur la carte, dans un encadré jaune. Pour chacune :
|
||||
|
||||
- choisir la date puis **OK** ;
|
||||
- ou accepter la **suggestion** (« Le 11 juin ? »). RTG propose le jour où des photos ou des vidéos ont été prises le long de la trace ;
|
||||
- ou, pour plusieurs traces d'un coup, **Répartir** : un jour par trace, dans l'ordre des noms, à partir de la date choisie.
|
||||
|
||||
## Imposer un jour
|
||||
|
||||
Le détail « Les N fichiers de traces et leur jour » permet d'imposer un jour à n'importe quelle trace, même datée ; **Annuler** revient au jour déduit. Ce choix est noté dans `voyage.toml`, table `[dates_traces]`.
|
||||
|
||||
Les jours se changent à minuit, sauf avant l'heure réglée dans [Réglages du site](reglages.md) (4 h par défaut) : une étape finie à 1 h du matin reste sur la veille.
|
||||
|
||||
Les modifications sont prises en compte à la prochaine génération ([Générer](generer.md)).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Réglages du site
|
||||
|
||||
Ils valent pour tous les voyages et sont enregistrés dans `site.toml`. Un voyage peut avoir ses propres réglages vidéo (onglet [Générer](generer.md)).
|
||||
|
||||
## Site
|
||||
|
||||
- **Titre** : affiché en haut de l'accueil.
|
||||
- **Adresse publique** : l'adresse du site en ligne (par exemple `https://roadtrips.ouaibe.fr/`), pour les liens des fichiers KML.
|
||||
- **Voyages récents par rubrique** : combien l'accueil en montre avant le lien « Tout voir ».
|
||||
|
||||
## Vidéos
|
||||
|
||||
- **Qualité** : Économe, Équilibrée (le réglage d'origine) ou Haute. Voir les valeurs de CRF dans [Générer](generer.md).
|
||||
- **Encodeur** : **processeur** (x265 et x264, meilleure qualité à taille égale) ou **matériel** (VideoToolbox du Mac : bien plus rapide, fichiers plus gros ; indisponible dans le conteneur Docker).
|
||||
- **Vitesse** : `medium` par défaut ; plus lent = un peu plus léger à qualité égale, mais plus long.
|
||||
- **Vidéos encodées en parallèle** : 0 = automatique selon le nombre de cœurs.
|
||||
|
||||
## Photos
|
||||
|
||||
- **Plus grand côté** (1600 px par défaut) et **qualité JPEG** (80) des versions web. Le clic sur une photo ouvre l'original.
|
||||
- **Placer les photos sans GPS d'après l'heure et la trace** : à 5 minutes près, quand la trace a des heures.
|
||||
|
||||
## Journées
|
||||
|
||||
- **Fuseau horaire** du voyage (Europe/Paris par défaut).
|
||||
- **Heure de changement de jour** (4 h) : une étape finie avant cette heure reste sur la veille.
|
||||
|
||||
## Carte globale
|
||||
|
||||
Liens de cartes Google My Maps à reprendre, un par ligne. Leurs tracés sont récupérés une fois et ajoutés à la carte de l'accueil, sur fond OpenStreetMap.
|
||||
|
||||
## Administration depuis le site
|
||||
|
||||
Adresse de l'interface (par exemple `/admin/`). Si elle est remplie, l'accueil affiche un bouton « Connexion » vers cette adresse, après « Refaire l'accueil ». Voir [Administration depuis le site](administration.md).
|
||||
|
||||
## Système
|
||||
|
||||
Vérifie que Python, exiftool et ffmpeg (avec libx265 et libx264) sont installés, et l'espace libre sur le disque du site.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Sources
|
||||
|
||||
Où se trouvent les fichiers d'origine du voyage : traces GPS, vidéos (rushs) et photos. Ils peuvent être sur n'importe quel disque, nommés n'importe comment, et ne sont **jamais modifiés**.
|
||||
|
||||
## Ajouter ou retirer une source
|
||||
|
||||
- **Choisir un dossier…** ouvre un explorateur : naviguer, puis **Choisir ce dossier**. Le nombre de fichiers reconnus s'affiche en bas.
|
||||
- Ou coller un chemin (dossier ou fichier) puis **Ajouter**.
|
||||
- **Retirer** enlève la source de la liste, sans rien effacer.
|
||||
|
||||
Un type de source peut en avoir plusieurs (par exemple deux dossiers de photos).
|
||||
|
||||
## Ce que RTG en fait
|
||||
|
||||
- **Traces GPS** : réparties par journée et recopiées dans `routes/AAAA-MM-JJ.gpx`. Le jour vient des heures de la trace, à défaut d'une date AAAA-MM-JJ dans le nom du fichier, à défaut du jour choisi dans [Journées](journees.md).
|
||||
- **Photos** : copiées dans `photos/`. Les HEIC de l'iPhone sont converties en JPEG.
|
||||
- **Vidéos** : lues directement pour l'encodage, sans copie.
|
||||
|
||||
## Pastilles
|
||||
|
||||
- **N fichiers** : la source est lue.
|
||||
- **introuvable** ou **disque débranché ?** : le chemin n'existe pas, ou le disque n'est pas branché. Les vidéos déjà encodées restent en ligne ; seules les nouvelles attendent le disque.
|
||||
|
||||
Dans la variante où l'administration tourne dans un conteneur Docker, seuls les disques déclarés dans `admin-docker.sh` sont visibles.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Mes voyages
|
||||
|
||||
La liste de tous les voyages du site, par rubrique, du plus récent au plus ancien.
|
||||
|
||||
## Rubriques
|
||||
|
||||
- Un voyage rangé **à la racine du site** fait partie de la rubrique **Voyages**. Ce sont aussi ceux de la carte globale de l'accueil.
|
||||
- Un voyage rangé **dans un dossier** (par exemple `Balades/Aisne-06-2026`) fait partie d'une rubrique du nom de ce dossier.
|
||||
|
||||
## Les colonnes
|
||||
|
||||
| Colonne | Signification |
|
||||
| --- | --- |
|
||||
| Dates, Jours | d'après les traces GPS |
|
||||
| Vidéos web | vidéos encodées / rushs trouvés. « sources absentes » : le disque des rushs n'est pas branché, les vidéos déjà encodées restent en ligne |
|
||||
| Photos web | photos allégées pour le web / photos du voyage |
|
||||
| Pages | **à jour**, **à refaire** (photos, vidéos ou récit plus récents que les pages) ou **jamais générées** |
|
||||
| ⚠️ | points à vérifier (survoler pour les lire) : source introuvable, trace sans jour… |
|
||||
|
||||
Un clic sur le titre ouvre le voyage ; **Voir ↗** ouvre sa page sur le site.
|
||||
|
||||
## Boutons
|
||||
|
||||
- **Nouveau voyage** : un titre (affiché sur le site), un nom de dossier (dans l'adresse des pages : sans espaces ni accents, par exemple `Ete-2027`), la moto (facultative) et la rubrique. RTG crée le dossier et son `voyage.toml` ; il reste à indiquer les [Sources](sources.md).
|
||||
- **Refaire l'accueil** : régénère la page d'accueil, les pages des rubriques et la carte globale, sans toucher aux voyages.
|
||||
|
||||
Le bandeau en haut à droite montre le traitement en cours ou le dernier terminé ; un clic ouvre son journal.
|
||||
@@ -0,0 +1,2 @@
|
||||
<div class="aide-texte">{{ contenu|safe }}</div>
|
||||
<p class="aide-pied"><a href="{{ base }}/aide/{{ sujet }}" target="_blank">Ouvrir dans une page ↗</a> · <a href="#" hx-get="{{ base }}/aide/index?panneau=1" hx-target="#aide-contenu">Sommaire</a></p>
|
||||
@@ -0,0 +1,94 @@
|
||||
<div class="analyse">
|
||||
{% if not a.details_traces %}
|
||||
<p class="vide">Aucune trace GPS. Indiquez où elles se trouvent dans l'onglet <a href="#sources">Sources</a>.</p>
|
||||
{% else %}
|
||||
<div class="carte" data-couches='{{ couches|tojson }}'></div>
|
||||
|
||||
{% if non_datees %}
|
||||
<div class="encart attention">
|
||||
<h3>{{ non_datees|length }} trace{{ 's' if non_datees|length > 1 }} à rattacher à un jour</h3>
|
||||
<p class="aide">Ces traces n'ont pas d'heures (itinéraire préparé, par exemple) et leur nom ne contient pas de date au format AAAA-MM-JJ.
|
||||
Choisissez le jour de chacune ; les traces en pointillé sur la carte sont celles-ci.</p>
|
||||
<table>
|
||||
<tbody>
|
||||
{% for nom in non_datees %}
|
||||
{% set d = a.details_traces|selectattr("nom", "equalto", nom)|first %}
|
||||
<tr>
|
||||
<td><code>{{ nom }}</code> <span class="discret">{{ d.km|round|int }} km</span></td>
|
||||
<td>
|
||||
<form class="en-ligne" hx-post="{{ base }}/voyage/date" hx-target="#analyse">
|
||||
<input type="hidden" name="v" value="{{ v.chemin }}"><input type="hidden" name="fichier" value="{{ nom }}">
|
||||
<input type="date" name="jour" required><button>OK</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% set s = suggestions.get(nom) %}
|
||||
{% if s and s[0] %}
|
||||
<button class="petit" hx-post="{{ base }}/voyage/date" hx-target="#analyse"
|
||||
hx-vals='{{ {"v": v.chemin, "fichier": nom, "jour": s[0].isoformat()}|tojson }}'
|
||||
title="{{ s[1] }} photo(s) ou vidéo(s) de ce jour prises le long de la trace">Le {{ s[0]|date_longue }} ?</button>
|
||||
{% else %}<span class="discret">pas de suggestion</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if non_datees|length > 1 %}
|
||||
<form class="en-ligne" hx-post="{{ base }}/voyage/dates-suite" hx-target="#analyse">
|
||||
<input type="hidden" name="v" value="{{ v.chemin }}">
|
||||
Ou bien, dans l'ordre des noms, un jour par trace à partir du <input type="date" name="debut" required>
|
||||
<button>Répartir</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h3>{{ jours|length }} journée{{ 's' if jours|length > 1 }} · {{ total_km|round|int }} km</h3>
|
||||
<table class="jours">
|
||||
<thead><tr><th>Jour</th><th class="num">km</th><th>Traces</th></tr></thead>
|
||||
<tbody>
|
||||
{% for j in jours %}
|
||||
<tr><td><span class="puce" style="background: {{ j.couleur }}"></span> {{ j.date|date_longue }}</td>
|
||||
<td class="num">{{ j.km|round|int }}</td>
|
||||
<td class="discret">{{ j.traces|join(", ") }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>Les {{ a.details_traces|length }} fichier{{ 's' if a.details_traces|length > 1 }} de traces et leur jour</summary>
|
||||
<p class="aide">Un jour imposé ici remplace celui que RTG déduit des heures ou du nom du fichier.</p>
|
||||
<table>
|
||||
<thead><tr><th>Fichier</th><th>Jour(s) retenu(s)</th><th>Jour imposé</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in a.details_traces %}
|
||||
<tr>
|
||||
<td><code title="{{ d.fichier }}">{{ d.nom }}</code>{% if d.erreur %} <span class="pastille attention" title="{{ d.erreur }}">illisible</span>{% endif %}</td>
|
||||
<td>{% for j in d.jours %}{{ j|date_courte }}{{ ", " if not loop.last }}{% else %}<span class="discret">—</span>{% endfor %}</td>
|
||||
<td>
|
||||
<form class="en-ligne" hx-post="{{ base }}/voyage/date" hx-target="#analyse">
|
||||
<input type="hidden" name="v" value="{{ v.chemin }}"><input type="hidden" name="fichier" value="{{ d.nom }}">
|
||||
<input type="date" name="jour" value="{{ d.impose.isoformat() if d.impose else '' }}">
|
||||
<button class="petit">{{ "Changer" if d.impose else "Imposer" }}</button>
|
||||
</form>
|
||||
{% if d.impose %}
|
||||
<button class="petit" hx-post="{{ base }}/voyage/date" hx-target="#analyse"
|
||||
hx-vals='{{ {"v": v.chemin, "fichier": d.nom, "jour": ""}|tojson }}'>Annuler</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% set autres = a.a_verifier|reject("commence_par", "Trace à rattacher")|list %}
|
||||
{% if autres %}
|
||||
<div class="encart attention">
|
||||
<h3>À vérifier</h3>
|
||||
<ul>{% for m in autres %}<li>{{ m }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="discret">Photos trouvées : {{ a.photos|length }} · Vidéos trouvées : {{ a.videos|length }}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
{% if tache and tache.en_cours %}
|
||||
<a class="bandeau en-cours" href="{{ base }}/tache">⏳ {{ tache.libelle }} · {{ tache.etape }}{% if tache.etape_voyage %} ({{ tache.etape_voyage }}){% endif %}{% if tache.video and tache.video.rang %} · vidéo {{ tache.video.rang[0] }}/{{ tache.video.rang[1] }} ({{ tache.video.pct|int }} %){% endif %}</a>
|
||||
{% elif tache %}
|
||||
<a class="bandeau {{ 'ok' if tache.ok else 'echec' }}" href="{{ base }}/tache">{{ "✓" if tache.ok else "⚠️" }} {{ tache.libelle }} : {{ tache.etape|lower }}</a>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,55 @@
|
||||
<div id="compression" class="encart compression" hx-get="{{ base }}/voyage/compression?v={{ v.chemin|url }}"
|
||||
hx-trigger="tache-finie from:body" hx-swap="outerHTML">
|
||||
<h3>Compression des vidéos</h3>
|
||||
<table>
|
||||
<thead><tr><th></th><th>Vidéos actuelles</th><th>Prochains encodages</th></tr></thead>
|
||||
<tbody>
|
||||
{% for version in ("1080p", "720p") %}
|
||||
{% set b = bilan[version] %}{% set c = cibles[version] %}
|
||||
<tr>
|
||||
<td><strong>{{ version }}</strong></td>
|
||||
<td>
|
||||
{% if not b.total %}<span class="discret">aucune</span>
|
||||
{% else %}
|
||||
{% for reg, n in b.reglages.most_common() %}
|
||||
<div>{{ reg[0] }}{% if reg[1] is not none %} CRF {{ reg[1]|crf }}{% else %} <span class="discret">(réglages non inscrits : encodeur matériel ?)</span>{% endif %}
|
||||
<span class="discret">× {{ n }}</span>
|
||||
{% if reg == c %}<span class="pastille ok">conforme</span>{% else %}<span class="pastille attention">différent</span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="discret">{{ b.total }} vidéo{{ 's' if b.total > 1 }}{% if b.lues < b.total %} · échantillon de {{ b.lues }}{% endif %}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ c[0] }}{% if c[1] is not none %} CRF {{ c[1]|crf }}{% else %} (encodeur matériel){% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="aide">Qualité {{ noms_qualites.get(cfg.video.qualite, cfg.video.qualite) }}, vitesse {{ cfg.video.preset }},
|
||||
encodeur {{ "matériel" if p.encodeur == "vt" else "processeur" }} :
|
||||
{{ "réglages propres à ce voyage" if propre else "réglages du site" }}.
|
||||
Plus le CRF est élevé, plus les fichiers sont légers, et moins la qualité est bonne.</p>
|
||||
|
||||
<details {{ 'open' if propre }}>
|
||||
<summary>Réglages vidéo propres à ce voyage</summary>
|
||||
<form class="formulaire" hx-post="{{ base }}/voyage/video" hx-target="#compression" hx-swap="outerHTML">
|
||||
<input type="hidden" name="v" value="{{ v.chemin }}">
|
||||
<label>Qualité
|
||||
<select name="qualite">
|
||||
<option value="">Comme le site ({{ noms_qualites.get(site.video.qualite, site.video.qualite) }})</option>
|
||||
{% for q in qualites %}<option value="{{ q }}" {{ 'selected' if propre.qualite == q }}>{{ noms_qualites[q]|capitalize }} (CRF {{ qualites[q].crf_hevc }} / {{ qualites[q].crf_h264 }})</option>{% endfor %}
|
||||
</select></label>
|
||||
<label>Vitesse de l'encodeur
|
||||
<select name="preset">
|
||||
<option value="">Comme le site ({{ site.video.preset }})</option>
|
||||
{% for pr in presets %}<option {{ 'selected' if propre.preset == pr }}>{{ pr }}</option>{% endfor %}
|
||||
</select>
|
||||
<span class="aide">Plus lent = un peu plus léger à qualité égale.</span></label>
|
||||
<label>CRF 1080p (HEVC), pour un essai
|
||||
<input type="number" name="crf_hevc" min="0" max="51" value="{{ propre.crf_hevc if propre.crf_hevc is defined }}" placeholder="selon la qualité ({{ qualites.get(cfg.video.qualite, qualites.equilibree).crf_hevc }})"></label>
|
||||
<label>CRF 720p (H.264), pour un essai
|
||||
<input type="number" name="crf_h264" min="0" max="51" value="{{ propre.crf_h264 if propre.crf_h264 is defined }}" placeholder="selon la qualité ({{ qualites.get(cfg.video.qualite, qualites.equilibree).crf_h264 }})"></label>
|
||||
<p><button class="principal">Enregistrer</button> <span class="ok">{{ message }}</span></p>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="fenetre-fond" data-fermer-fond>
|
||||
<div class="fenetre" role="dialog" aria-label="Choisir un dossier">
|
||||
<h3>Choisir un dossier de {{ libelle }}</h3>
|
||||
<div class="raccourcis">
|
||||
{% for nom, c in raccourcis %}
|
||||
<button class="petit" hx-get="{{ base }}/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ c|url }}" hx-target="#fenetre">{{ nom }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="chemin"><code>{{ chemin }}</code></p>
|
||||
<ul class="liste-dossiers">
|
||||
{% if parent %}<li><a href="#" hx-get="{{ base }}/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ parent|url }}" hx-target="#fenetre">⬆︎ Dossier parent</a></li>{% endif %}
|
||||
{% for d in sous %}
|
||||
<li><a href="#" hx-get="{{ base }}/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ (chemin.rstrip(sep) ~ sep ~ d)|url }}" hx-target="#fenetre">📁 {{ d }}</a></li>
|
||||
{% else %}
|
||||
<li class="vide">Pas de sous-dossier</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="pied">
|
||||
<span class="{{ 'ok' if nb else 'discret' }}">{{ nb }} fichier{{ 's' if nb > 1 }} de {{ libelle }} ici</span>
|
||||
<span>
|
||||
<button data-fermer>Annuler</button>
|
||||
<button class="principal" data-fermer hx-post="{{ base }}/voyage/sources" hx-target="#sources" hx-swap="outerHTML"
|
||||
hx-vals='{{ {"v": v, "cle": cle, "action": "ajouter", "chemin": chemin}|tojson }}'>Choisir ce dossier</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="infos">
|
||||
<div><span class="etiquette">Rushs (sources)</span><strong>{{ i.nb_rushs }}</strong> vidéo{{ 's' if i.nb_rushs > 1 }} · {{ i.rushs|taille }}</div>
|
||||
<div><span class="etiquette">Reste à encoder</span>
|
||||
{% if i.a_encoder %}<strong>{{ i.a_encoder }}</strong> · {{ i.a_encoder_octets|taille }}{% else %}<strong>rien</strong>{% endif %}</div>
|
||||
<div><span class="etiquette">Vidéos web</span>{{ i.videos_web|taille }}{% if i.apercus %} <span class="discret">+ aperçus {{ i.apercus|taille }}</span>{% endif %}</div>
|
||||
<div><span class="etiquette">Photos</span>{{ i.photos|taille }} <span class="discret">→ web {{ i.photos_web|taille }}</span></div>
|
||||
<div><span class="etiquette">Espace libre (site)</span>{{ i.libre|taille }}</div>
|
||||
<div><span class="etiquette">Encodage</span>
|
||||
{{ noms_qualites.get(cfg.video.qualite, cfg.video.qualite) }} · {{ "matériel" if i.encodage.encodeur == "vt" else "processeur" }}
|
||||
<span class="discret">(HEVC CRF {{ i.encodage.crf_hevc }} / H.264 CRF {{ i.encodage.crf_h264 }})</span>
|
||||
<a href="{{ base }}/reglages#video">changer</a></div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<div id="sources" class="sources">
|
||||
{% for cle, s in sources.items() %}
|
||||
<div class="bloc-source">
|
||||
<h3>{{ s.libelle }}</h3>
|
||||
<ul>
|
||||
{% for l in s.lignes %}
|
||||
<li class="{{ '' if l.existe else 'manque' }}">
|
||||
<code title="{{ l.absolu }}">{{ l.source }}</code>
|
||||
{% if l.existe %}<span class="pastille {{ 'ok' if l.nb else 'neutre' }}">{{ l.nb }} fichier{{ 's' if l.nb > 1 }}</span>
|
||||
{% elif l.lien_casse %}<span class="pastille attention">disque débranché ?</span>
|
||||
{% else %}<span class="pastille attention">introuvable</span>{% endif %}
|
||||
<button class="petit" hx-post="{{ base }}/voyage/sources" hx-target="#sources" hx-swap="outerHTML"
|
||||
hx-vals='{{ {"v": v.chemin, "cle": cle, "action": "retirer", "chemin": l.source}|tojson }}'>Retirer</button>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="vide">Aucune source</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="ajout">
|
||||
<button hx-get="{{ base }}/dossiers?cle={{ cle }}&v={{ v.chemin|url }}" hx-target="#fenetre">Choisir un dossier…</button>
|
||||
<form hx-post="{{ base }}/voyage/sources" hx-target="#sources" hx-swap="outerHTML" class="en-ligne">
|
||||
<input type="hidden" name="v" value="{{ v.chemin }}">
|
||||
<input type="hidden" name="cle" value="{{ cle }}">
|
||||
<input type="hidden" name="action" value="ajouter">
|
||||
<input name="chemin" placeholder="ou collez un chemin (dossier ou fichier)">
|
||||
<button>Ajouter</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
<pre class="journal court">{{ lignes|join("\n") }}</pre>
|
||||
<p class="discret">Disque du site : {{ libre|taille }} libres sur {{ total|taille }} · <code>{{ racine }}</code></p>
|
||||
@@ -0,0 +1,24 @@
|
||||
{% if message %}<div class="erreur">{{ message }}</div>{% endif %}
|
||||
{% if tache %}
|
||||
<div class="tache {{ 'en-cours' if tache.en_cours else ('ok' if tache.ok else 'echec') }}"
|
||||
{% if tache.en_cours %}hx-get="{{ base }}/tache/fragment{{ '?complet=1' if complet }}" hx-trigger="every 1s" hx-swap="outerHTML"{% endif %}>
|
||||
<div class="tache-tete">
|
||||
<strong>{{ tache.libelle }}</strong>
|
||||
<span class="etape">{{ "⏳ " if tache.en_cours }}{{ tache.etape }}{% if tache.etape_voyage %} — {{ tache.etape_voyage }}{% endif %}</span>
|
||||
<span class="discret">{{ tache.duree }}</span>
|
||||
{% if tache.en_cours %}
|
||||
<button class="danger petit" hx-post="{{ base }}/tache/arreter" hx-swap="none">Arrêter</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if tache.video and tache.video.rang %}
|
||||
{% set i, n = tache.video.rang %}
|
||||
<div class="video-cours">
|
||||
<div>🎬 Vidéo {{ i }}/{{ n }} · <code>{{ tache.video.nom }}</code> · {{ tache.video.pct|int }} %</div>
|
||||
<progress max="100" value="{{ tache.video.pct }}"></progress>
|
||||
<progress class="global" max="{{ n }}" value="{{ i - 1 + tache.video.pct / 100 }}" title="Ensemble des vidéos"></progress>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% set lignes = tache.lignes|list %}
|
||||
<pre class="journal">{{ lignes[-(1000 if complet else 30):]|join("\n") }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titre %}Aide{% endblock %}
|
||||
{% block contenu %}
|
||||
<div class="page-aide">
|
||||
<nav class="aide-sommaire">
|
||||
<h2>Aide</h2>
|
||||
<ul>
|
||||
{% for s, titre in sujets.items() %}
|
||||
<li><a href="{{ base }}/aide/{{ s }}" class="{{ 'actif' if s == sujet }}">{{ titre }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
<article class="aide-texte">{{ contenu|safe }}</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block titre %}{% endblock %} · RTG</title>
|
||||
<link rel="stylesheet" href="{{ base }}/static/rtg.css">
|
||||
{% block tete %}{% endblock %}
|
||||
<script src="{{ base }}/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body data-base="{{ base }}">
|
||||
<header class="barre">
|
||||
<a class="logo" href="{{ base }}/">RTG</a>
|
||||
<nav>
|
||||
<a href="{{ base }}/">Mes voyages</a>
|
||||
<a href="{{ base }}/reglages">Réglages</a>
|
||||
<a href="{{ base }}/tache">Traitement</a>
|
||||
<a href="{{ base }}/aide">Aide</a>
|
||||
<a href="{{ lien_site }}" target="_blank">Voir le site ↗</a>
|
||||
</nav>
|
||||
<div id="bandeau" hx-get="{{ base }}/tache/bandeau" hx-trigger="load, every 2s"></div>
|
||||
<button class="petit discret-bouton" hx-post="{{ base }}/deconnexion" title="Fermer la session de ce navigateur">Se déconnecter</button>
|
||||
</header>
|
||||
<main>
|
||||
{% block contenu %}{% endblock %}
|
||||
</main>
|
||||
<div id="messages" aria-live="polite"></div>
|
||||
<div id="fenetre"></div>
|
||||
<aside id="aide" class="aide-panneau" hidden aria-label="Aide">
|
||||
<button class="petit aide-fermer" data-fermer-aide title="Fermer (Échap)">✕</button>
|
||||
<div id="aide-contenu"></div>
|
||||
</aside>
|
||||
<footer>RoadTripsGenerator {{ version }} · <code>{{ racine }}</code></footer>
|
||||
<script src="{{ base }}/static/rtg.js"></script>
|
||||
{% block fin %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Connexion · RTG</title>
|
||||
<link rel="stylesheet" href="{{ base }}/static/rtg.css">
|
||||
<script src="{{ base }}/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body class="page-connexion">
|
||||
<main class="connexion">
|
||||
<p class="logo">RTG</p>
|
||||
{% if connecte %}
|
||||
<p>Vous êtes connecté.</p>
|
||||
<p><a class="bouton principal" href="{{ base }}/">Ouvrir l'interface</a></p>
|
||||
{% else %}
|
||||
<h1>Accès à l'interface</h1>
|
||||
{% if not distant %}
|
||||
<p class="aide">Ouvrez l'adresse complète affichée dans le Terminal où « rtg interface » a été lancé :
|
||||
elle contient la clé d'accès de cette session{% if mdp %}, ou entrez le mot de passe{% endif %}.</p>
|
||||
{% endif %}
|
||||
{% if mdp %}
|
||||
<form method="post" action="{{ base }}/connexion" hx-post="{{ base }}/connexion" hx-target="#retour-connexion">
|
||||
<label>Mot de passe
|
||||
<input type="password" name="mot_de_passe" autocomplete="current-password" required autofocus>
|
||||
</label>
|
||||
<button class="principal">Entrer</button>
|
||||
<div id="retour-connexion">{% if message %}<div class="erreur">{{ message }}</div>{% elif attente %}<div class="erreur">Trop d'essais : réessayez dans {{ attente }} s.</div>{% endif %}</div>
|
||||
</form>
|
||||
{% elif not distant %}
|
||||
<p class="aide">Pour entrer aussi avec un mot de passe (depuis un autre navigateur ou une autre machine) :
|
||||
<code>rtg mot-de-passe</code>.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if site %}<p class="aide"><a href="{{ site }}">← Retour au site</a></p>{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titre %}Nouveau voyage{% endblock %}
|
||||
{% block contenu %}
|
||||
<h1>Nouveau voyage <button class="bouton-aide" hx-get="{{ base }}/aide/voyages?panneau=1" hx-target="#aide-contenu" data-ouvrir-aide title="Aide">?</button></h1>
|
||||
<form id="formulaire" class="formulaire" hx-post="{{ base }}/nouveau" hx-target="#formulaire" hx-select="#formulaire" hx-swap="outerHTML">
|
||||
{% if erreur %}<div class="erreur">{{ erreur }}</div>{% endif %}
|
||||
<label>Titre
|
||||
<input name="titre" value="{{ form.titre[0] if form and form.titre else '' }}" placeholder="Été 2027 en Écosse" autofocus>
|
||||
</label>
|
||||
<label>Nom du dossier
|
||||
<input name="dossier" required value="{{ form.dossier[0] if form and form.dossier else '' }}" placeholder="Ete-2027">
|
||||
<span class="aide">Il apparaît dans l'adresse des pages : évitez espaces et accents.</span>
|
||||
</label>
|
||||
<label>Moto
|
||||
<input name="moto" value="{{ form.moto[0] if form and form.moto else '' }}" placeholder="facultatif">
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Rubrique</legend>
|
||||
<label class="choix"><input type="radio" name="groupe" value="" checked> Voyages <span class="aide">(dossier à la racine du site, affiché sur la carte globale)</span></label>
|
||||
{% for g in groupes %}
|
||||
<label class="choix"><input type="radio" name="groupe" value="{{ g }}"> {{ g }}</label>
|
||||
{% endfor %}
|
||||
<label class="choix">Nouvelle rubrique : <input name="groupe_nouveau" placeholder="Balades"></label>
|
||||
</fieldset>
|
||||
<p><button class="principal">Créer le voyage</button> <a href="{{ base }}/">Annuler</a></p>
|
||||
<p class="aide">Le dossier est créé avec un <code>voyage.toml</code>. Vous indiquerez ensuite où sont les traces, les vidéos et les photos : elles restent où elles sont.</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titre %}Réglages{% endblock %}
|
||||
{% block contenu %}
|
||||
<h1>Réglages du site <button class="bouton-aide" hx-get="{{ base }}/aide/reglages?panneau=1" hx-target="#aide-contenu" data-ouvrir-aide title="Aide">?</button></h1>
|
||||
<p class="aide">Enregistrés dans <code>site.toml</code>. Ils valent pour tous les voyages.</p>
|
||||
<form class="formulaire" hx-post="{{ base }}/reglages" hx-target="#retour-reglages">
|
||||
<fieldset>
|
||||
<legend>Site</legend>
|
||||
<label>Titre <input name="titre" value="{{ cfg.titre }}"></label>
|
||||
<label>Adresse publique <input name="adresse_publique" value="{{ cfg.adresse_publique }}" placeholder="https://…/">
|
||||
<span class="aide">Adresse où le site est consulté (liens de partage).</span></label>
|
||||
<label>Voyages récents par rubrique sur l'accueil <input type="number" min="1" max="12" name="accueil_nb_recents" value="{{ cfg.accueil_nb_recents }}"></label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="video">
|
||||
<legend>Vidéos</legend>
|
||||
<p class="aide">Chaque rush donne une version 1080p HEVC (H.265) et une version 720p H.264, lue par défaut et partout.</p>
|
||||
{% set descriptions = {"econome": "fichiers plus légers, légère perte visible sur les scènes chargées",
|
||||
"equilibree": "réglage d'origine, très bon compromis",
|
||||
"haute": "presque indiscernable de l'original, fichiers nettement plus gros"} %}
|
||||
{% for q, p in qualites.items() %}
|
||||
<label class="choix"><input type="radio" name="qualite" value="{{ q }}" {{ 'checked' if cfg.video.qualite == q }}>
|
||||
<strong>{{ {"econome": "Économe", "equilibree": "Équilibrée", "haute": "Haute"}[q] }}</strong>
|
||||
<span class="aide">{{ descriptions[q] }} (CRF {{ p.crf_hevc }} / {{ p.crf_h264 }})</span></label>
|
||||
{% endfor %}
|
||||
<label>Encodeur
|
||||
<select name="encodeur">
|
||||
<option value="processeur" {{ 'selected' if cfg.video.encodeur != 'materiel' }}>Processeur (x265 / x264) : meilleure qualité à taille égale</option>
|
||||
<option value="materiel" {{ 'selected' if cfg.video.encodeur == 'materiel' }}>Matériel (VideoToolbox, Mac) : bien plus rapide, fichiers plus gros</option>
|
||||
</select></label>
|
||||
<label>Vitesse de l'encodeur processeur
|
||||
<select name="preset">
|
||||
{% for p in ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower"] %}
|
||||
<option {{ 'selected' if cfg.video.preset == p }}>{{ p }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="aide">« medium » par défaut ; plus lent = un peu plus léger à qualité égale.</span></label>
|
||||
<label>Vidéos encodées en parallèle <input type="number" min="0" max="16" name="jobs" value="{{ cfg.video.jobs }}">
|
||||
<span class="aide">0 = automatique selon le nombre de cœurs.</span></label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Photos</legend>
|
||||
<label>Plus grand côté des photos web (pixels) <input type="number" min="640" max="6000" step="10" name="grand_cote" value="{{ cfg.photos.grand_cote }}"></label>
|
||||
<label>Qualité JPEG <input type="number" min="40" max="100" name="qualite_photos" value="{{ cfg.photos.qualite }}"></label>
|
||||
<label class="choix"><input type="checkbox" name="positions_deduites" {{ 'checked' if cfg.photos.positions_deduites }}>
|
||||
Placer les photos sans GPS d'après l'heure et la trace (à 5 minutes près)</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Journées</legend>
|
||||
<label>Fuseau horaire <input name="fuseau" value="{{ cfg.journees.fuseau }}" placeholder="Europe/Paris"></label>
|
||||
<label>Heure de changement de jour <input type="number" min="0" max="12" name="coupure_heure" value="{{ cfg.journees.coupure_heure }}">
|
||||
<span class="aide">Une étape finie après minuit mais avant cette heure reste sur la veille.</span></label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Carte globale</legend>
|
||||
<label>Cartes Google My Maps à reprendre (une adresse par ligne)
|
||||
<textarea name="mymaps" rows="3">{{ cfg.carte_globale.mymaps|join("\n") }}</textarea>
|
||||
<span class="aide">Leurs tracés sont récupérés une fois et ajoutés à la carte de l'accueil, sur fond OpenStreetMap.</span></label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Administration depuis le site</legend>
|
||||
<label>Adresse de l'interface <input name="administration" value="{{ cfg.administration.adresse }}" placeholder="/admin/">
|
||||
<span class="aide">Si elle est remplie, l'accueil du site affiche un bouton « Connexion » vers cette adresse.
|
||||
L'interface doit alors tourner sur le serveur du site (<code>rtg interface --proxy --prefixe /admin</code>), derrière le serveur web en HTTPS : voir le README.</span></label>
|
||||
</fieldset>
|
||||
<p><button class="principal">Enregistrer</button> <span id="retour-reglages"></span></p>
|
||||
</form>
|
||||
|
||||
<h2>Système</h2>
|
||||
<div hx-get="{{ base }}/systeme" hx-trigger="load"><p class="chargement">Vérification des outils…</p></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titre %}Traitement{% endblock %}
|
||||
{% block contenu %}
|
||||
<h1>Traitement <button class="bouton-aide" hx-get="{{ base }}/aide/generer?panneau=1" hx-target="#aide-contenu" data-ouvrir-aide title="Aide">?</button></h1>
|
||||
{% if tache %}
|
||||
{% if tache.voyage %}<p><a href="{{ base }}/voyage?v={{ tache.voyage|url }}">← {{ tache.voyage }}</a></p>{% endif %}
|
||||
{% with complet = True %}{% include "_tache.html" %}{% endwith %}
|
||||
{% else %}
|
||||
<p class="vide">Aucun traitement lancé depuis l'ouverture de l'interface.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titre %}{{ v.titre }}{% endblock %}
|
||||
{% block tete %}<link rel="stylesheet" href="{{ base }}/static/leaflet.css"><script src="{{ base }}/static/leaflet.js"></script>{% endblock %}
|
||||
{% block fin %}<script src="{{ base }}/static/editeur.js"></script>{% endblock %}
|
||||
{% block contenu %}
|
||||
<div class="entete-page">
|
||||
<div>
|
||||
<p class="fil"><a href="{{ base }}/">Mes voyages</a> › {{ v.rubrique }}</p>
|
||||
<h1>{{ v.titre }}</h1>
|
||||
<p class="discret">{{ [periode(v.debut, v.fin), v.moto]|select|join(" · ") }} <code>{{ v.chemin }}</code></p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if v.genere %}<a class="bouton" href="{{ base }}/site/{{ v.chemin }}/index.html" target="_blank">Voir la page ↗</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="onglets">
|
||||
<a href="#journees">Journées</a>
|
||||
<a href="#carte">Carte et corrections</a>
|
||||
<a href="#sources">Sources</a>
|
||||
<a href="#infos">Infos et récit</a>
|
||||
<a href="#generer">Générer</a>
|
||||
</nav>
|
||||
|
||||
<section class="onglet" id="onglet-journees">
|
||||
<div class="onglet-aide"><button class="bouton-aide" hx-get="{{ base }}/aide/journees?panneau=1" hx-target="#aide-contenu" data-ouvrir-aide title="Aide">? Aide</button></div>
|
||||
<div id="analyse" hx-get="{{ base }}/voyage/analyse?v={{ v.chemin|url }}" hx-trigger="load, sources-modifiees from:body, tache-finie from:body">
|
||||
<p class="chargement">Lecture des traces…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="onglet" id="onglet-carte">
|
||||
<div class="onglet-aide"><button class="bouton-aide" hx-get="{{ base }}/aide/carte?panneau=1" hx-target="#aide-contenu" data-ouvrir-aide title="Aide">? Aide</button></div>
|
||||
<div id="editeur" class="editeur" data-voyage="{{ v.chemin }}">
|
||||
<aside class="editeur-panneau">
|
||||
<label>Jour
|
||||
<select id="ed-jour"><option value="">Tous les jours</option></select>
|
||||
</label>
|
||||
<div class="ed-cases">
|
||||
<label class="choix"><input type="checkbox" id="ed-photos" checked> <span class="mk photo"></span> Photos</label>
|
||||
<label class="choix"><input type="checkbox" id="ed-videos" checked> <span class="mk video"></span> Vidéos</label>
|
||||
</div>
|
||||
<p class="aide">Glissez un marqueur pour corriger sa position ; un clic l'affiche.
|
||||
<span class="mk photo deplace"></span> = position corrigée à la main.</p>
|
||||
<div id="ed-mode" class="ed-mode" hidden></div>
|
||||
|
||||
<h4>Zones masquées</h4>
|
||||
<p class="aide">Les points de trace situés dans ces zones sont retirés des pages : autour de chez soi, ou sur une erreur du GPS.</p>
|
||||
<button id="ed-nouvelle-zone">+ Masquer une zone</button>
|
||||
<div id="ed-brouillon" class="ed-brouillon" hidden>
|
||||
<label>Rayon : <strong id="ed-rayon-texte">200 m</strong>
|
||||
<input type="range" id="ed-rayon" min="20" max="3000" step="10" value="200">
|
||||
</label>
|
||||
<label class="choix"><input type="checkbox" id="ed-portee-site"> Zone privée : la retirer de tous les voyages</label>
|
||||
<p><button class="principal" id="ed-zone-ok">Enregistrer la zone</button> <button id="ed-zone-annuler">Annuler</button></p>
|
||||
</div>
|
||||
<ul id="ed-zones" class="ed-liste"></ul>
|
||||
|
||||
<h4>Médias sans position <span id="ed-nb-sans" class="compte"></span></h4>
|
||||
<p class="aide">Cliquez sur un nom puis sur la carte pour le placer.</p>
|
||||
<ul id="ed-sans" class="ed-liste"></ul>
|
||||
<p id="ed-etat" class="aide"></p>
|
||||
</aside>
|
||||
<div id="ed-carte" class="editeur-carte"><p class="chargement">Chargement de la carte…</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="onglet" id="onglet-sources">
|
||||
<div class="onglet-aide"><button class="bouton-aide" hx-get="{{ base }}/aide/sources?panneau=1" hx-target="#aide-contenu" data-ouvrir-aide title="Aide">? Aide</button></div>
|
||||
<p class="aide">Indiquez où se trouvent les fichiers d'origine : un ou plusieurs dossiers (ou fichiers) par type, sur n'importe quel disque.
|
||||
RTG ne les modifie jamais : les traces sont recopiées par journée dans <code>routes/</code>, les photos dans <code>photos/</code> (HEIC converties en JPEG),
|
||||
et les vidéos sont lues directement pour l'encodage.</p>
|
||||
{% include "_sources.html" %}
|
||||
</section>
|
||||
|
||||
<section class="onglet" id="onglet-infos">
|
||||
<div class="onglet-aide"><button class="bouton-aide" hx-get="{{ base }}/aide/infos?panneau=1" hx-target="#aide-contenu" data-ouvrir-aide title="Aide">? Aide</button></div>
|
||||
<form class="formulaire" hx-post="{{ base }}/voyage/infos" hx-target="find .retour">
|
||||
<input type="hidden" name="v" value="{{ v.chemin }}">
|
||||
<label>Titre <input name="titre" value="{{ cfg.titre }}"></label>
|
||||
<label>Moto <input name="moto" value="{{ cfg.moto }}" placeholder="facultatif : affichée dans la légende de la carte globale"></label>
|
||||
<p class="aide">Rubrique : <strong>{{ v.rubrique }}</strong> (elle vient du dossier où est rangé le voyage).</p>
|
||||
<p><button class="principal">Enregistrer</button> <span class="retour"></span></p>
|
||||
</form>
|
||||
<form class="formulaire large" hx-post="{{ base }}/voyage/recit" hx-target="find .retour">
|
||||
<input type="hidden" name="v" value="{{ v.chemin }}">
|
||||
<label>Récit (<code>voyage.md</code>, en Markdown)
|
||||
<textarea name="recit" rows="18" spellcheck="true">{{ recit }}</textarea>
|
||||
</label>
|
||||
<p class="aide">Liens spéciaux : <code>[Carte du jour](jour:2026-08-01)</code> ouvre la carte d'un jour,
|
||||
<code>[Le col](media:photos/IMG_4831.jpeg)</code> un média du voyage.</p>
|
||||
<p><button class="principal">Enregistrer le récit</button> <span class="retour"></span></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="onglet" id="onglet-generer">
|
||||
<div class="onglet-aide"><button class="bouton-aide" hx-get="{{ base }}/aide/generer?panneau=1" hx-target="#aide-contenu" data-ouvrir-aide title="Aide">? Aide</button></div>
|
||||
<div hx-get="{{ base }}/voyage/infos-utiles?v={{ v.chemin|url }}" hx-trigger="load, sources-modifiees from:body, tache-finie from:body"><p class="chargement">Calcul des tailles…</p></div>
|
||||
<div id="compression" hx-get="{{ base }}/voyage/compression?v={{ v.chemin|url }}" hx-trigger="load, tache-finie from:body" hx-swap="outerHTML"><p class="chargement">Lecture de la compression des vidéos…</p></div>
|
||||
<form class="formulaire" hx-post="{{ base }}/tache/lancer" hx-target="#tache-zone">
|
||||
<input type="hidden" name="v" value="{{ v.chemin }}">
|
||||
<fieldset>
|
||||
<legend>Vidéos</legend>
|
||||
<label class="choix"><input type="radio" name="refaire" value="manquantes" checked> Encoder seulement celles qui manquent</label>
|
||||
<label class="choix"><input type="radio" name="refaire" value="differentes"> Réencoder aussi celles faites avec d'autres réglages <span class="aide">(lus dans chaque fichier)</span></label>
|
||||
<label class="choix"><input type="radio" name="refaire" value="toutes"> Tout réencoder</label>
|
||||
<p class="aide">Versions concernées :
|
||||
<label class="choix" style="display:inline"><input type="radio" name="versions" value="deux" checked> les deux</label>
|
||||
<label class="choix" style="display:inline"><input type="radio" name="versions" value="1080p"> 1080p seulement</label>
|
||||
<label class="choix" style="display:inline"><input type="radio" name="versions" value="720p"> 720p seulement</label></p>
|
||||
<label class="choix"><input type="checkbox" name="sans_compression"> Ne pas toucher aux vidéos <span class="aide">(photos, positions et pages seulement)</span></label>
|
||||
</fieldset>
|
||||
<label class="choix"><input type="checkbox" name="force"> Refaire aussi les photos web et les pages, même à jour</label>
|
||||
<p>
|
||||
<button class="principal" name="quoi" value="generer">Générer le voyage</button>
|
||||
<button name="quoi" value="compresser">Encoder les vidéos seulement</button>
|
||||
</p>
|
||||
</form>
|
||||
<div id="tache-zone">{% if tache and tache.voyage == v.chemin %}{% include "_tache.html" %}{% endif %}</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titre %}Mes voyages{% endblock %}
|
||||
{% block contenu %}
|
||||
{% if not site_existe %}
|
||||
<div class="encart">
|
||||
<p><strong>Ce dossier n'est pas encore un site RTG.</strong> Il n'y a pas de <code>site.toml</code> dans <code>{{ racine }}</code>.</p>
|
||||
<p><button class="principal" hx-post="{{ base }}/site/creer">Créer le site dans ce dossier</button></p>
|
||||
<p class="aide">Pour un autre dossier, relancez avec <code>rtg interface --racine CHEMIN</code>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="entete-page">
|
||||
<h1>Mes voyages <span class="compte">{{ nb }}</span> <button class="bouton-aide" hx-get="{{ base }}/aide/voyages?panneau=1" hx-target="#aide-contenu" data-ouvrir-aide title="Aide">?</button></h1>
|
||||
<div class="actions">
|
||||
<a class="bouton principal" href="{{ base }}/nouveau">+ Nouveau voyage</a>
|
||||
<form hx-post="{{ base }}/tache/lancer" hx-target="#tache-zone">
|
||||
<input type="hidden" name="quoi" value="accueil">
|
||||
<button title="index.html, pages des rubriques et carte globale">Refaire l'accueil</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tache-zone"></div>
|
||||
|
||||
{% for g, liste in groupes %}
|
||||
<section>
|
||||
<h2>{{ g }} <span class="compte">{{ liste|length }}</span></h2>
|
||||
<table class="voyages">
|
||||
<thead><tr><th>Voyage</th><th>Dates</th><th class="num">Jours</th><th>Vidéos web</th><th>Photos web</th><th>Pages</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in liste %}
|
||||
<tr>
|
||||
<td><a href="{{ base }}/voyage?v={{ v.chemin|url }}"><strong>{{ v.titre }}</strong></a>
|
||||
{% if v.moto %}<div class="discret">{{ v.moto }}</div>{% endif %}</td>
|
||||
<td>{{ periode(v.debut, v.fin) }}</td>
|
||||
<td class="num">{{ v.jours or "—" }}</td>
|
||||
<td>
|
||||
{% if v.videos_sources %}
|
||||
<span class="pastille {{ 'ok' if v.videos_faites == v.videos_sources else 'attention' }}">{{ v.videos_faites }}/{{ v.videos_sources }}</span>
|
||||
{% elif v.videos_web %}
|
||||
<span class="pastille neutre" title="Les rushs ne sont pas accessibles (disque débranché ?)">{{ v.videos_web }} · sources absentes</span>
|
||||
{% else %}<span class="discret">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if v.photos %}
|
||||
<span class="pastille {{ 'ok' if v.photos_web >= v.photos else 'attention' }}">{{ v.photos_web }}/{{ v.photos }}</span>
|
||||
{% else %}<span class="discret">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not v.genere %}<span class="pastille neutre">jamais générées</span>
|
||||
{% elif v.pages_a_jour %}<span class="pastille ok">à jour</span>
|
||||
{% else %}<span class="pastille attention">à refaire</span>{% endif %}
|
||||
{% if v.a_verifier %}<span class="pastille attention" title="{{ v.a_verifier|join('\n') }}">⚠️ {{ v.a_verifier|length }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="droite">{% if v.genere %}<a href="{{ base }}/site/{{ v.chemin }}/index.html" target="_blank">Voir ↗</a>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% else %}
|
||||
<p class="vide">Aucun voyage pour l'instant. Commencez par « Nouveau voyage ».</p>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Contrôle d'accès à l'interface.
|
||||
|
||||
Deux façons d'entrer :
|
||||
- la clé d'accès (jeton) tirée au hasard à chaque lancement et ajoutée à
|
||||
l'adresse que RTG ouvre dans le navigateur (et affiche dans le Terminal) :
|
||||
seul celui qui voit ce Terminal peut entrer ;
|
||||
- le mot de passe défini par `rtg mot-de-passe`, indispensable pour un accès
|
||||
depuis une autre machine (`rtg interface --distant`).
|
||||
|
||||
Une fois entré, le navigateur garde un cookie de session (HttpOnly,
|
||||
SameSite=Strict), valable 12 heures après la dernière requête et perdu à
|
||||
l'arrêt de l'interface. Après 5 mots de passe faux, une adresse IP est
|
||||
bloquée une minute, puis deux, quatre… jusqu'à une heure.
|
||||
"""
|
||||
import hmac
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
from rtg import acces
|
||||
|
||||
DUREE_SESSION = 12 * 3600
|
||||
ESSAIS_LIBRES = 5
|
||||
|
||||
|
||||
class Gardien:
|
||||
def __init__(self, port, distant=False, https=False, proxy=False, prefixe=""):
|
||||
self.jeton = secrets.token_urlsafe(24)
|
||||
self.cookie = f"rtg_{port}"
|
||||
self.distant, self.https, self.proxy = distant, https, proxy
|
||||
self.chemin_cookie = prefixe + "/" if prefixe else "/"
|
||||
self._sessions = {}
|
||||
self._echecs = {} # ip → (nombre d'échecs, heure du dernier)
|
||||
self._verrou = threading.Lock()
|
||||
|
||||
# --- sessions
|
||||
def ouvrir_session(self):
|
||||
sid = secrets.token_urlsafe(32)
|
||||
with self._verrou:
|
||||
self._sessions[sid] = time.time() + DUREE_SESSION
|
||||
return sid
|
||||
|
||||
def session_valide(self, sid):
|
||||
if not sid:
|
||||
return False
|
||||
maintenant = time.time()
|
||||
with self._verrou:
|
||||
fin = self._sessions.get(sid)
|
||||
if not fin or fin < maintenant:
|
||||
self._sessions.pop(sid, None)
|
||||
return False
|
||||
self._sessions[sid] = maintenant + DUREE_SESSION
|
||||
return True
|
||||
|
||||
def fermer_session(self, sid):
|
||||
with self._verrou:
|
||||
self._sessions.pop(sid, None)
|
||||
|
||||
def entete_cookie(self, sid, https=None, effacer=False):
|
||||
valeur = f"{self.cookie}={'' if effacer else sid}; Path={self.chemin_cookie}; HttpOnly; SameSite=Strict"
|
||||
if effacer:
|
||||
valeur += "; Max-Age=0"
|
||||
if self.https if https is None else https:
|
||||
valeur += "; Secure"
|
||||
return valeur
|
||||
|
||||
# --- clé d'accès et mot de passe
|
||||
def jeton_valide(self, jeton):
|
||||
return bool(jeton) and hmac.compare_digest(jeton.encode(), self.jeton.encode())
|
||||
|
||||
def attente(self, ip):
|
||||
"""Secondes à attendre avant un nouvel essai (0 si libre)."""
|
||||
with self._verrou:
|
||||
n, dernier = self._echecs.get(ip, (0, 0))
|
||||
if n < ESSAIS_LIBRES:
|
||||
return 0
|
||||
blocage = min(60 * 2 ** (n - ESSAIS_LIBRES), 3600)
|
||||
return max(0, int(dernier + blocage - time.time()))
|
||||
|
||||
def essayer(self, ip, mdp):
|
||||
"""Vérifie le mot de passe. Renvoie (ok, message d'erreur)."""
|
||||
reste = self.attente(ip)
|
||||
if reste:
|
||||
return False, f"Trop d'essais : réessayez dans {reste} s."
|
||||
if not acces.defini():
|
||||
return False, "Aucun mot de passe n'est défini (commande rtg mot-de-passe)."
|
||||
if acces.verifier(mdp):
|
||||
with self._verrou:
|
||||
self._echecs.pop(ip, None)
|
||||
return True, ""
|
||||
with self._verrou:
|
||||
n, _ = self._echecs.get(ip, (0, 0))
|
||||
self._echecs[ip] = (n + 1, time.time())
|
||||
return False, "Mot de passe incorrect."
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
Bibliothèques tierces fournies avec l'interface de RTG
|
||||
|
||||
htmx 1.9.12 (htmx.min.js) — https://htmx.org — licence Zero-Clause BSD
|
||||
|
||||
Leaflet 1.9.4 (leaflet.js, leaflet.css, images/) — https://leafletjs.com
|
||||
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
||||
Copyright (c) 2010-2011, CloudMade
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,283 @@
|
||||
// Onglet « Carte et corrections » : déplacer ou placer les photos et vidéos,
|
||||
// masquer des zones de trace. Chaque correction est enregistrée aussitôt dans
|
||||
// voyage.toml (ou site.toml pour une zone privée) ; les pages du site la
|
||||
// prennent en compte à la génération suivante.
|
||||
(function () {
|
||||
"use strict";
|
||||
const racine = document.getElementById("editeur");
|
||||
if (!racine || !window.L) return;
|
||||
const voyage = racine.dataset.voyage;
|
||||
const base = document.body.dataset.base || ""; // « /admin » derrière le serveur web
|
||||
const $ = id => document.getElementById(id);
|
||||
let carte = null, donnees = null, coucheTraces, coucheMedias, coucheZones;
|
||||
let brouillon = null, modeZone = false, aPlacer = null, cadree = false;
|
||||
|
||||
// --- Échanges avec le serveur -------------------------------------------
|
||||
function message(html) {
|
||||
const m = $("messages");
|
||||
m.innerHTML = html;
|
||||
clearTimeout(window._rtgMessage);
|
||||
window._rtgMessage = setTimeout(() => { m.innerHTML = ""; }, 6000);
|
||||
}
|
||||
|
||||
function lireReponse(r) {
|
||||
const redirection = r.headers.get("HX-Redirect");
|
||||
if (redirection) { location.href = redirection; throw new Error("session"); }
|
||||
if (!(r.headers.get("Content-Type") || "").includes("json")) {
|
||||
return r.text().then(t => { message(t); throw new Error("refus"); });
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function poster(url, params) {
|
||||
return fetch(base + url, {
|
||||
method: "POST",
|
||||
headers: { "HX-Request": "true", "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(Object.assign({ v: voyage }, params))
|
||||
}).then(lireReponse);
|
||||
}
|
||||
|
||||
function etat(texte) {
|
||||
$("ed-etat").textContent = texte;
|
||||
}
|
||||
|
||||
function charger() {
|
||||
return fetch(base + "/voyage/carte.json?v=" + encodeURIComponent(voyage), { headers: { "HX-Request": "true" } })
|
||||
.then(lireReponse)
|
||||
.then(d => { donnees = d; remplirJours(); dessinerTout(); });
|
||||
}
|
||||
|
||||
// --- Carte ----------------------------------------------------------------
|
||||
function initialiser() {
|
||||
$("ed-carte").innerHTML = "";
|
||||
carte = L.map("ed-carte");
|
||||
const plan = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
{ maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(carte);
|
||||
const satellite = L.tileLayer(
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||
{ maxZoom: 19, attribution: "Esri World Imagery" });
|
||||
L.control.layers({ "Plan": plan, "Satellite": satellite }).addTo(carte);
|
||||
carte.setView([46.5, 2.5], 5);
|
||||
coucheTraces = L.featureGroup().addTo(carte);
|
||||
coucheZones = L.featureGroup().addTo(carte);
|
||||
coucheMedias = L.featureGroup().addTo(carte);
|
||||
carte.on("click", clicCarte);
|
||||
$("ed-jour").addEventListener("change", () => { cadree = false; dessinerTout(); });
|
||||
$("ed-photos").addEventListener("change", dessinerMedias);
|
||||
$("ed-videos").addEventListener("change", dessinerMedias);
|
||||
$("ed-nouvelle-zone").addEventListener("click", commencerZone);
|
||||
$("ed-zone-annuler").addEventListener("click", finirZone);
|
||||
$("ed-zone-ok").addEventListener("click", enregistrerZone);
|
||||
$("ed-rayon").addEventListener("input", () => {
|
||||
$("ed-rayon-texte").textContent = $("ed-rayon").value + " m";
|
||||
if (brouillon) brouillon.setRadius(+$("ed-rayon").value);
|
||||
});
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.key === "Escape") { aPlacer = null; finirZone(); mode(""); }
|
||||
});
|
||||
document.body.addEventListener("tache-finie", () => charger());
|
||||
charger();
|
||||
}
|
||||
|
||||
function remplirJours() {
|
||||
const choix = $("ed-jour"), actuel = choix.value;
|
||||
choix.length = 1;
|
||||
donnees.jours.forEach(j => choix.add(new Option(j.nom, j.jour)));
|
||||
choix.value = actuel;
|
||||
}
|
||||
|
||||
function jourChoisi() { return $("ed-jour").value; }
|
||||
|
||||
function dessinerTout() {
|
||||
dessinerTraces();
|
||||
dessinerZones();
|
||||
dessinerMedias();
|
||||
if (!cadree) {
|
||||
const b = coucheTraces.getLayers().length ? coucheTraces.getBounds()
|
||||
: (coucheMedias.getLayers().length ? coucheMedias.getBounds() : null);
|
||||
if (b && b.isValid()) carte.fitBounds(b, { padding: [30, 30] });
|
||||
cadree = true;
|
||||
}
|
||||
}
|
||||
|
||||
function dessinerTraces() {
|
||||
coucheTraces.clearLayers();
|
||||
const jour = jourChoisi();
|
||||
donnees.couches.forEach(c => {
|
||||
if (jour && c.jour !== jour) return;
|
||||
c.segments.forEach(s => L.polyline(s, { color: c.couleur, weight: 4, opacity: 0.85, interactive: false })
|
||||
.addTo(coucheTraces));
|
||||
});
|
||||
}
|
||||
|
||||
function icone(m) {
|
||||
return L.divIcon({ className: "", iconSize: [16, 16], iconAnchor: [8, 8],
|
||||
html: '<span class="mk ' + m.type + (m.deplace ? " deplace" : "") + '"></span>' });
|
||||
}
|
||||
|
||||
function popup(m) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "ed-popup";
|
||||
if (m.vignette) {
|
||||
const img = document.createElement("img");
|
||||
img.src = m.vignette; img.alt = ""; img.loading = "lazy";
|
||||
d.append(img);
|
||||
}
|
||||
const t = document.createElement("div");
|
||||
t.textContent = m.fichier + " · " + m.jour + " " + m.heure;
|
||||
d.append(t);
|
||||
if (m.deplace) {
|
||||
const b = document.createElement("button");
|
||||
b.className = "petit";
|
||||
b.textContent = "Revenir à la position d'origine";
|
||||
b.addEventListener("click", () => { carte.closePopup(); enregistrerPosition(m.fichier, "", ""); });
|
||||
d.append(b);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function dessinerMedias() {
|
||||
coucheMedias.clearLayers();
|
||||
const jour = jourChoisi(), photos = $("ed-photos").checked, videos = $("ed-videos").checked;
|
||||
const sans = [];
|
||||
donnees.medias.forEach(m => {
|
||||
if (jour && m.jour !== jour) return;
|
||||
if ((m.type === "photo" && !photos) || (m.type === "video" && !videos)) return;
|
||||
if (m.lat === null) { sans.push(m); return; }
|
||||
L.marker([m.lat, m.lon], { icon: icone(m), draggable: true, title: m.fichier })
|
||||
.bindPopup(() => popup(m), { minWidth: 200 })
|
||||
.on("dragend", e => {
|
||||
const p = e.target.getLatLng();
|
||||
enregistrerPosition(m.fichier, p.lat.toFixed(6), p.lng.toFixed(6));
|
||||
})
|
||||
.addTo(coucheMedias);
|
||||
});
|
||||
const liste = $("ed-sans");
|
||||
liste.innerHTML = "";
|
||||
$("ed-nb-sans").textContent = sans.length;
|
||||
sans.forEach(m => {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = "#carte";
|
||||
a.textContent = (m.type === "photo" ? "📷 " : "🎬 ") + m.fichier;
|
||||
a.title = m.jour + " " + m.heure;
|
||||
a.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
finirZone();
|
||||
aPlacer = m.fichier;
|
||||
mode("Cliquez sur la carte pour placer " + m.fichier + " (Échap pour annuler)");
|
||||
});
|
||||
const quand = document.createElement("span");
|
||||
quand.className = "discret";
|
||||
quand.textContent = " " + m.jour + " " + m.heure;
|
||||
li.append(a, quand);
|
||||
liste.append(li);
|
||||
});
|
||||
if (!sans.length) liste.innerHTML = '<li class="vide">Aucun</li>';
|
||||
}
|
||||
|
||||
function enregistrerPosition(fichier, lat, lon) {
|
||||
poster("/voyage/position", { fichier: fichier, lat: lat, lon: lon }).then(r => {
|
||||
if (r.media) {
|
||||
const i = donnees.medias.findIndex(m => m.fichier === fichier);
|
||||
if (i >= 0) donnees.medias[i] = r.media;
|
||||
}
|
||||
dessinerMedias();
|
||||
etat("✓ " + fichier + (lat ? " placé" : " remis à sa position d'origine")
|
||||
+ ". Régénérez le voyage pour mettre le site à jour.");
|
||||
}).catch(() => dessinerMedias());
|
||||
}
|
||||
|
||||
// --- Zones masquées -------------------------------------------------------
|
||||
function dessinerZones() {
|
||||
coucheZones.clearLayers();
|
||||
const liste = $("ed-zones");
|
||||
liste.innerHTML = "";
|
||||
const ajouter = (z, i, portee) => {
|
||||
const site = portee === "site";
|
||||
const cercle = L.circle([z[0], z[1]], {
|
||||
radius: z[2], color: site ? "#7c3aed" : "#dc2626", weight: 2, fillOpacity: 0.15,
|
||||
dashArray: site ? "6 6" : null
|
||||
}).addTo(coucheZones);
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = "#carte";
|
||||
a.textContent = (site ? "Zone privée" : "Zone") + " · " + z[2] + " m";
|
||||
a.addEventListener("click", e => { e.preventDefault(); carte.fitBounds(cercle.getBounds(), { maxZoom: 16 }); });
|
||||
const b = document.createElement("button");
|
||||
b.className = "petit";
|
||||
b.textContent = "Supprimer";
|
||||
b.addEventListener("click", () => {
|
||||
poster("/voyage/masque", { action: "supprimer", portee: portee, index: i }).then(r => {
|
||||
donnees.masques = r.masques; donnees.zones = r.zones; dessinerZones();
|
||||
etat("✓ Zone supprimée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour.");
|
||||
});
|
||||
});
|
||||
li.append(a, " ", b);
|
||||
if (site) li.title = "Retirée de tous les voyages (site.toml)";
|
||||
liste.append(li);
|
||||
};
|
||||
donnees.masques.forEach((z, i) => ajouter(z, i, "voyage"));
|
||||
donnees.zones.forEach((z, i) => ajouter(z, i, "site"));
|
||||
}
|
||||
|
||||
function commencerZone() {
|
||||
aPlacer = null;
|
||||
modeZone = true;
|
||||
mode("Cliquez au centre de la zone à masquer ; un nouveau clic la déplace.");
|
||||
}
|
||||
|
||||
function finirZone() {
|
||||
modeZone = false;
|
||||
if (brouillon) { brouillon.remove(); brouillon = null; }
|
||||
$("ed-brouillon").hidden = true;
|
||||
if (!aPlacer) mode("");
|
||||
}
|
||||
|
||||
function enregistrerZone() {
|
||||
if (!brouillon) return;
|
||||
const c = brouillon.getLatLng(), site = $("ed-portee-site").checked;
|
||||
poster("/voyage/masque", {
|
||||
action: "ajouter", portee: site ? "site" : "voyage",
|
||||
lat: c.lat.toFixed(6), lon: c.lng.toFixed(6), rayon: $("ed-rayon").value
|
||||
}).then(r => {
|
||||
donnees.masques = r.masques; donnees.zones = r.zones;
|
||||
finirZone();
|
||||
dessinerZones();
|
||||
etat("✓ Zone enregistrée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour.");
|
||||
});
|
||||
}
|
||||
|
||||
function clicCarte(e) {
|
||||
if (aPlacer) {
|
||||
const nom = aPlacer;
|
||||
aPlacer = null;
|
||||
mode("");
|
||||
enregistrerPosition(nom, e.latlng.lat.toFixed(6), e.latlng.lng.toFixed(6));
|
||||
} else if (modeZone) {
|
||||
if (!brouillon) {
|
||||
brouillon = L.circle(e.latlng, { radius: +$("ed-rayon").value, color: "#dc2626", weight: 2,
|
||||
dashArray: "4 4", fillOpacity: 0.2 }).addTo(carte);
|
||||
} else {
|
||||
brouillon.setLatLng(e.latlng);
|
||||
}
|
||||
$("ed-brouillon").hidden = false;
|
||||
mode("Ajustez le rayon puis enregistrez ; un clic sur la carte déplace la zone.");
|
||||
}
|
||||
}
|
||||
|
||||
function mode(texte) {
|
||||
const m = $("ed-mode");
|
||||
m.textContent = texte;
|
||||
m.hidden = !texte;
|
||||
$("ed-carte").classList.toggle("en-mode", !!texte);
|
||||
}
|
||||
|
||||
// L'onglet peut être caché au chargement : on prépare la carte à sa première ouverture.
|
||||
document.addEventListener("rtg:onglet", e => {
|
||||
if (e.detail !== "carte") return;
|
||||
if (!carte) initialiser();
|
||||
else carte.invalidateSize();
|
||||
});
|
||||
if (location.hash === "#carte") setTimeout(() => { if (!carte) initialiser(); }, 0);
|
||||
})();
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/<body/.test(e)}function l(e){var t=!N(e);var r=A(e);var n=e;if(r==="head"){n=n.replace(S,"")}if(Q.config.useTemplateFragments&&t){var i=s("<body><template>"+n+"</template></body>",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s("<table>"+n+"</table>",1);case"col":return s("<table><colgroup>"+n+"</colgroup></table>",2);case"tr":return s("<table><tbody>"+n+"</tbody></table>",2);case"td":case"th":return s("<table><tbody><tr>"+n+"</tr></tbody></table>",3);case"script":case"style":return s("<div>"+n+"</div>",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r<e.length;r++){t.push(e[r])}}return t}function oe(e,t){if(e){for(var r=0;r<e.length;r++){t(e[r])}}}function X(e){var t=e.getBoundingClientRect();var r=t.top;var n=t.bottom;return r<window.innerHeight&&n>=0}function se(e){ifLine truncated
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* @preserve
|
||||
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
|
||||
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
|
||||
//# sourceMappingURL=leaflet.js.map
|
||||
@@ -0,0 +1,221 @@
|
||||
:root {
|
||||
--fond: #f6f5f2; --carte: #ffffff; --texte: #1d1d1f; --discret: #6b6b70; --bord: #dedcd6;
|
||||
--accent: #c2410c; --accent-texte: #ffffff; --ok: #15803d; --ok-fond: #e7f6ec;
|
||||
--attention: #a16207; --attention-fond: #fdf4dc; --echec: #b91c1c; --echec-fond: #fde8e8;
|
||||
--neutre-fond: #eeede9; --code: #f0efea;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fond: #17171a; --carte: #222226; --texte: #ececef; --discret: #9a9aa2; --bord: #36363c;
|
||||
--accent: #f97316; --accent-texte: #1a1a1a; --ok: #4ade80; --ok-fond: #183324;
|
||||
--attention: #facc15; --attention-fond: #3a3115; --echec: #f87171; --echec-fond: #3b1c1c;
|
||||
--neutre-fond: #2c2c31; --code: #2c2c31;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--fond); color: var(--texte); }
|
||||
a { color: var(--accent); }
|
||||
code { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--code);
|
||||
padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
|
||||
h1 { font-size: 26px; margin: 4px 0; }
|
||||
h2 { font-size: 19px; margin: 28px 0 10px; }
|
||||
h3 { font-size: 16px; margin: 16px 0 8px; }
|
||||
|
||||
.barre { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; padding: 10px 24px;
|
||||
background: var(--carte); border-bottom: 1px solid var(--bord); position: sticky; top: 0; z-index: 1000; }
|
||||
.logo { font-weight: 800; font-size: 20px; letter-spacing: 1px; text-decoration: none; color: var(--accent); }
|
||||
.barre nav { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.barre nav a { color: var(--texte); text-decoration: none; }
|
||||
.barre nav a:hover { color: var(--accent); }
|
||||
#bandeau { margin-left: auto; font-size: 13px; }
|
||||
.bandeau { text-decoration: none; padding: 4px 10px; border-radius: 99px; background: var(--neutre-fond); color: var(--texte); }
|
||||
.bandeau.en-cours { background: var(--attention-fond); }
|
||||
.bandeau.ok { background: var(--ok-fond); }
|
||||
.bandeau.echec { background: var(--echec-fond); }
|
||||
|
||||
main { max-width: 1100px; margin: 0 auto; padding: 20px 24px 60px; }
|
||||
footer { text-align: center; color: var(--discret); font-size: 12px; padding: 20px; }
|
||||
|
||||
.entete-page { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
|
||||
.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.actions form { margin: 0; }
|
||||
.fil { margin: 0; font-size: 13px; color: var(--discret); }
|
||||
.fil a { color: var(--discret); }
|
||||
.compte { font-size: 13px; font-weight: normal; color: var(--discret); background: var(--neutre-fond);
|
||||
padding: 1px 8px; border-radius: 99px; vertical-align: middle; }
|
||||
.discret { color: var(--discret); font-size: 13px; }
|
||||
.aide { color: var(--discret); font-size: 13px; }
|
||||
.vide { color: var(--discret); font-style: italic; }
|
||||
.chargement { color: var(--discret); }
|
||||
.chargement::before { content: "⏳ "; }
|
||||
.ok { color: var(--ok); }
|
||||
.droite { text-align: right; }
|
||||
|
||||
button, .bouton { font: inherit; font-size: 14px; padding: 6px 14px; border-radius: 8px; cursor: pointer;
|
||||
border: 1px solid var(--bord); background: var(--carte); color: var(--texte); text-decoration: none;
|
||||
display: inline-block; }
|
||||
button:hover, .bouton:hover { border-color: var(--accent); }
|
||||
button.principal, .bouton.principal { background: var(--accent); border-color: var(--accent); color: var(--accent-texte); }
|
||||
button.danger { color: var(--echec); border-color: var(--echec); }
|
||||
button.petit { font-size: 12px; padding: 2px 8px; }
|
||||
button.htmx-request { opacity: .6; cursor: progress; }
|
||||
input, select, textarea { font: inherit; padding: 6px 8px; border: 1px solid var(--bord); border-radius: 6px;
|
||||
background: var(--carte); color: var(--texte); max-width: 100%; }
|
||||
textarea { width: 100%; font: 13px/1.5 ui-monospace, Menlo, monospace; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--bord); vertical-align: top; }
|
||||
th { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--discret); font-weight: 600; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.pastille { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 99px; white-space: nowrap; background: var(--neutre-fond); }
|
||||
.pastille.ok { background: var(--ok-fond); color: var(--ok); }
|
||||
.pastille.attention { background: var(--attention-fond); color: var(--attention); }
|
||||
.puce { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; }
|
||||
|
||||
.onglets { display: flex; gap: 4px; border-bottom: 1px solid var(--bord); margin: 18px 0 16px; overflow-x: auto; }
|
||||
.onglets a { padding: 8px 14px; text-decoration: none; color: var(--discret); border-bottom: 2px solid transparent; white-space: nowrap; }
|
||||
.onglets a.actif { color: var(--texte); border-color: var(--accent); font-weight: 600; }
|
||||
.onglet { display: none; }
|
||||
.onglet.actif { display: block; }
|
||||
|
||||
.encart { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 12px 16px; margin: 16px 0; }
|
||||
.encart.attention { background: var(--attention-fond); border-color: transparent; }
|
||||
.encart h3 { margin-top: 4px; }
|
||||
.encart table { background: transparent; border: none; }
|
||||
.erreur { background: var(--echec-fond); color: var(--echec); padding: 8px 12px; border-radius: 8px; margin: 8px 0; }
|
||||
#messages { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 3000; max-width: 90vw; }
|
||||
#messages:empty { display: none; }
|
||||
|
||||
.formulaire { max-width: 640px; }
|
||||
.formulaire.large { max-width: none; }
|
||||
.formulaire label { display: block; margin: 12px 0; font-weight: 500; }
|
||||
.formulaire label > input:not([type=radio]):not([type=checkbox]), .formulaire label > select { display: block; width: 100%; margin-top: 4px; }
|
||||
.formulaire label > .aide { display: block; font-weight: normal; }
|
||||
.formulaire label.choix { font-weight: normal; }
|
||||
.formulaire label.choix .aide { display: inline; }
|
||||
fieldset { border: 1px solid var(--bord); border-radius: 10px; margin: 16px 0; padding: 4px 16px 10px; background: var(--carte); }
|
||||
legend { font-weight: 600; padding: 0 6px; }
|
||||
form.en-ligne { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 4px 0; }
|
||||
|
||||
.carte { height: 420px; border-radius: 10px; border: 1px solid var(--bord); margin-bottom: 12px; }
|
||||
details { margin: 16px 0; }
|
||||
summary { cursor: pointer; color: var(--accent); }
|
||||
|
||||
.sources { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
|
||||
.bloc-source { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 4px 16px 14px; }
|
||||
.bloc-source ul { list-style: none; padding: 0; margin: 0 0 10px; }
|
||||
.bloc-source li { padding: 6px 0; border-bottom: 1px dashed var(--bord); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.bloc-source li code { flex: 1 1 100%; }
|
||||
.bloc-source li.manque code { text-decoration: line-through; opacity: .7; }
|
||||
.bloc-source .ajout form input[name=chemin] { flex: 1; min-width: 160px; }
|
||||
|
||||
.fenetre-fond { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 2000; display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
.fenetre { background: var(--carte); border-radius: 12px; padding: 16px 20px; width: min(640px, 100%); max-height: 85vh; display: flex; flex-direction: column; }
|
||||
.fenetre h3 { margin-top: 0; }
|
||||
.raccourcis { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.liste-dossiers { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex: 1; border: 1px solid var(--bord); border-radius: 8px; min-height: 120px; }
|
||||
.liste-dossiers li a { display: block; padding: 6px 10px; text-decoration: none; color: var(--texte); }
|
||||
.liste-dossiers li a:hover { background: var(--neutre-fond); }
|
||||
.liste-dossiers li.vide { padding: 6px 10px; }
|
||||
.fenetre .pied { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
.infos { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-bottom: 12px; }
|
||||
.infos > div { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 8px 12px; }
|
||||
.etiquette { display: block; font-size: 12px; color: var(--discret); text-transform: uppercase; letter-spacing: .4px; }
|
||||
|
||||
.tache { background: var(--carte); border: 1px solid var(--bord); border-left: 4px solid var(--attention); border-radius: 10px; padding: 10px 14px; margin: 14px 0; }
|
||||
.tache.ok { border-left-color: var(--ok); }
|
||||
.tache.echec { border-left-color: var(--echec); }
|
||||
.tache-tete { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.tache-tete .danger { margin-left: auto; }
|
||||
.video-cours { margin: 8px 0; font-size: 14px; }
|
||||
progress { width: 100%; height: 8px; accent-color: var(--accent); }
|
||||
progress.global { height: 4px; opacity: .7; }
|
||||
.journal { background: #111; color: #ddd; font: 12px/1.45 ui-monospace, Menlo, monospace; padding: 10px; border-radius: 8px;
|
||||
max-height: 420px; overflow: auto; white-space: pre-wrap; margin: 8px 0 0; }
|
||||
.journal.court { max-height: none; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
main { padding: 14px 16px 40px; }
|
||||
.barre { padding: 10px 16px; gap: 10px; }
|
||||
#bandeau { margin-left: 0; }
|
||||
table.voyages th:nth-child(3), table.voyages td:nth-child(3) { display: none; }
|
||||
th, td { padding: 6px 8px; }
|
||||
.carte { height: 300px; }
|
||||
}
|
||||
|
||||
/* Connexion */
|
||||
.page-connexion main.connexion { max-width: 420px; margin: 12vh auto; background: var(--carte); border: 1px solid var(--bord);
|
||||
border-radius: 14px; padding: 24px 28px; }
|
||||
.connexion .logo { margin: 0 0 8px; }
|
||||
.connexion label { display: block; margin: 14px 0 10px; font-weight: 500; }
|
||||
.connexion input[type=password] { display: block; width: 100%; margin-top: 4px; }
|
||||
.discret-bouton { color: var(--discret); }
|
||||
|
||||
/* Carte et corrections */
|
||||
.editeur { display: grid; grid-template-columns: 300px 1fr; gap: 14px; align-items: start; }
|
||||
.editeur-panneau { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 8px 14px 12px;
|
||||
max-height: 74vh; overflow-y: auto; }
|
||||
.editeur-panneau h4 { margin: 18px 0 4px; font-size: 14px; }
|
||||
.editeur-panneau label { display: block; margin: 8px 0; }
|
||||
.editeur-panneau select { width: 100%; margin-top: 4px; }
|
||||
.editeur-panneau input[type=range] { width: 100%; }
|
||||
.ed-cases { display: flex; gap: 16px; }
|
||||
.editeur-carte { height: 74vh; border-radius: 10px; border: 1px solid var(--bord); }
|
||||
.editeur-carte.en-mode { cursor: crosshair; outline: 3px solid var(--accent); }
|
||||
.editeur-carte.en-mode .leaflet-container, .editeur-carte.en-mode.leaflet-container { cursor: crosshair; }
|
||||
.ed-mode { background: var(--attention-fond); color: var(--attention); padding: 6px 10px; border-radius: 8px; font-size: 13px; margin: 8px 0; }
|
||||
.ed-brouillon { background: var(--neutre-fond); border-radius: 8px; padding: 4px 10px; margin: 8px 0; }
|
||||
.ed-liste { list-style: none; padding: 0; margin: 4px 0; font-size: 13px; }
|
||||
.ed-liste li { padding: 3px 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.ed-liste a { text-decoration: none; overflow-wrap: anywhere; }
|
||||
.mk { display: inline-block; width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,.35); vertical-align: middle; }
|
||||
.mk.photo { background: #ef4444; }
|
||||
.mk.video { background: #2563eb; }
|
||||
.mk.deplace { border-color: #f59e0b; box-shadow: 0 0 0 2px #f59e0b; }
|
||||
.leaflet-marker-draggable .mk { cursor: grab; }
|
||||
.ed-popup { max-width: 240px; font-size: 12px; }
|
||||
.ed-popup img { display: block; max-width: 240px; max-height: 180px; border-radius: 6px; margin-bottom: 4px; }
|
||||
.ed-popup button { margin-top: 6px; }
|
||||
@media (max-width: 800px) {
|
||||
.editeur { grid-template-columns: 1fr; }
|
||||
.editeur-panneau { max-height: none; }
|
||||
.editeur-carte { height: 60vh; }
|
||||
}
|
||||
|
||||
/* Compression des vidéos (onglet Générer) */
|
||||
.encart.compression table { background: transparent; border: none; margin-bottom: 6px; }
|
||||
.encart.compression td div { margin: 2px 0; }
|
||||
|
||||
/* Aide */
|
||||
.bouton-aide { font-size: 13px; font-weight: 700; padding: 1px 8px; border-radius: 99px; vertical-align: middle;
|
||||
color: var(--accent); border-color: var(--accent); background: transparent; }
|
||||
.onglet-aide { display: flex; justify-content: flex-end; margin: -6px 0 4px; }
|
||||
.onglet-aide .bouton-aide { font-weight: 600; }
|
||||
.aide-panneau { position: fixed; top: 0; right: 0; bottom: 0; width: min(460px, 100%); z-index: 2500;
|
||||
background: var(--carte); border-left: 1px solid var(--bord); box-shadow: -8px 0 24px rgba(0,0,0,.15);
|
||||
overflow-y: auto; padding: 14px 22px 30px; }
|
||||
.aide-panneau[hidden] { display: none; }
|
||||
.aide-fermer { position: sticky; top: 0; float: right; z-index: 1; }
|
||||
.aide-texte { line-height: 1.55; }
|
||||
.aide-texte h1 { font-size: 21px; margin-top: 4px; }
|
||||
.aide-texte h2 { font-size: 16px; margin: 22px 0 6px; }
|
||||
.aide-texte table { font-size: 13px; margin: 8px 0; }
|
||||
.aide-texte th, .aide-texte td { padding: 5px 8px; }
|
||||
.aide-texte pre { background: var(--code); padding: 8px 10px; border-radius: 8px; overflow-x: auto; font-size: 12px; }
|
||||
.aide-texte pre code { background: none; padding: 0; }
|
||||
.aide-texte li { margin: 3px 0; }
|
||||
.aide-pied { border-top: 1px solid var(--bord); margin-top: 20px; padding-top: 10px; font-size: 13px; }
|
||||
.page-aide { display: grid; grid-template-columns: 240px 1fr; gap: 28px; align-items: start; }
|
||||
.aide-sommaire { position: sticky; top: 70px; background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 4px 14px 10px; }
|
||||
.aide-sommaire h2 { font-size: 15px; margin: 10px 0 6px; }
|
||||
.aide-sommaire ul { list-style: none; padding: 0; margin: 0; font-size: 14px; }
|
||||
.aide-sommaire li a { display: block; padding: 4px 6px; border-radius: 6px; text-decoration: none; color: var(--texte); }
|
||||
.aide-sommaire li a.actif { background: var(--neutre-fond); font-weight: 600; }
|
||||
.page-aide .aide-texte { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 6px 26px 20px; max-width: 760px; }
|
||||
@media (max-width: 800px) { .page-aide { grid-template-columns: 1fr; } .aide-sommaire { position: static; } }
|
||||
|
||||
@media (min-width: 1200px) { body.aide-ouverte main, body.aide-ouverte footer { margin-right: 470px; } }
|
||||
@@ -0,0 +1,116 @@
|
||||
// Onglets, cartes Leaflet, fenêtre de choix de dossier, journal des traitements.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// --- Onglets (#journees, #sources…) --------------------------------------
|
||||
function afficherOnglet() {
|
||||
const liens = document.querySelectorAll(".onglets a");
|
||||
if (!liens.length) return;
|
||||
const nom = (location.hash || liens[0].getAttribute("href")).slice(1);
|
||||
let trouve = false;
|
||||
liens.forEach(a => {
|
||||
const actif = a.getAttribute("href") === "#" + nom;
|
||||
a.classList.toggle("actif", actif);
|
||||
trouve = trouve || actif;
|
||||
});
|
||||
if (!trouve) { liens[0].classList.add("actif"); }
|
||||
document.querySelectorAll(".onglet").forEach(s => {
|
||||
s.classList.toggle("actif", s.id === "onglet-" + (trouve ? nom : liens[0].getAttribute("href").slice(1)));
|
||||
});
|
||||
document.querySelectorAll(".onglet.actif .carte").forEach(cadrer);
|
||||
document.dispatchEvent(new CustomEvent("rtg:onglet", { detail: trouve ? nom : "" }));
|
||||
}
|
||||
window.addEventListener("hashchange", afficherOnglet);
|
||||
document.addEventListener("DOMContentLoaded", afficherOnglet);
|
||||
|
||||
// --- Cartes -----------------------------------------------------------------
|
||||
// Une carte préparée dans un onglet caché n'a pas de taille : on la recadre
|
||||
// la première fois qu'elle devient visible.
|
||||
function cadrer(el) {
|
||||
if (!el._carte || !el.offsetWidth) return;
|
||||
el._carte.invalidateSize();
|
||||
if (!el._cadree) {
|
||||
el._cadree = true;
|
||||
if (el._bornes) el._carte.fitBounds(el._bornes, { padding: [20, 20] });
|
||||
else el._carte.setView([46.5, 2.5], 5);
|
||||
}
|
||||
}
|
||||
|
||||
function initCartes(racine) {
|
||||
if (!window.L) return;
|
||||
racine.querySelectorAll(".carte[data-couches]").forEach(el => {
|
||||
if (el._carte) return;
|
||||
const carte = L.map(el, { scrollWheelZoom: false });
|
||||
el._carte = carte;
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 18, attribution: "© OpenStreetMap"
|
||||
}).addTo(carte);
|
||||
const groupe = L.featureGroup().addTo(carte);
|
||||
JSON.parse(el.dataset.couches).forEach(c => {
|
||||
c.segments.forEach(s => {
|
||||
L.polyline(s, { color: c.couleur, weight: 4, opacity: 0.85, dashArray: c.pointille ? "6 8" : null })
|
||||
.bindTooltip(c.nom + (c.km ? " · " + c.km + " km" : ""), { sticky: true })
|
||||
.addTo(groupe);
|
||||
});
|
||||
});
|
||||
el._bornes = groupe.getLayers().length ? groupe.getBounds() : null;
|
||||
carte.setView([46.5, 2.5], 5);
|
||||
cadrer(el);
|
||||
// La molette ne zoome qu'après un clic dans la carte (pas en faisant défiler la page).
|
||||
carte.on("click", () => carte.scrollWheelZoom.enable());
|
||||
carte.on("mouseout", () => carte.scrollWheelZoom.disable());
|
||||
});
|
||||
}
|
||||
|
||||
// --- Après chaque échange htmx --------------------------------------------
|
||||
document.addEventListener("htmx:afterSettle", e => {
|
||||
initCartes(e.detail.elt.parentElement || document);
|
||||
document.querySelectorAll(".journal").forEach(j => { j.scrollTop = j.scrollHeight; });
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initCartes(document);
|
||||
document.querySelectorAll(".journal").forEach(j => { j.scrollTop = j.scrollHeight; });
|
||||
});
|
||||
|
||||
// --- Fenêtre (choix de dossier) -------------------------------------------
|
||||
function fermer() { document.getElementById("fenetre").innerHTML = ""; }
|
||||
document.addEventListener("click", e => {
|
||||
if (e.target.matches("[data-fermer-fond]")) fermer();
|
||||
if (e.target.matches("[data-fermer]") && !e.target.hasAttribute("hx-post")) fermer();
|
||||
});
|
||||
document.addEventListener("htmx:afterRequest", e => {
|
||||
if (e.detail.elt.matches && e.detail.elt.matches("[data-fermer]") && e.detail.successful) fermer();
|
||||
});
|
||||
document.addEventListener("keydown", e => { if (e.key === "Escape") fermer(); });
|
||||
|
||||
// --- Aide contextuelle (panneau à droite) --------------------------------
|
||||
function aideOuverte(oui) {
|
||||
const a = document.getElementById("aide");
|
||||
if (!a) return;
|
||||
a.hidden = !oui;
|
||||
document.body.classList.toggle("aide-ouverte", oui);
|
||||
}
|
||||
function fermerAide() { aideOuverte(false); }
|
||||
document.addEventListener("click", e => {
|
||||
if (e.target.closest("[data-ouvrir-aide]")) aideOuverte(true);
|
||||
if (e.target.closest("[data-fermer-aide]")) fermerAide();
|
||||
});
|
||||
// Panneau ouvert : il suit l'onglet affiché.
|
||||
document.addEventListener("rtg:onglet", e => {
|
||||
const a = document.getElementById("aide");
|
||||
const b = document.querySelector("#onglet-" + e.detail + " [data-ouvrir-aide]");
|
||||
if (a && !a.hidden && b && window.htmx) htmx.ajax("GET", b.getAttribute("hx-get"), "#aide-contenu");
|
||||
});
|
||||
document.addEventListener("keydown", e => { if (e.key === "Escape") fermerAide(); });
|
||||
document.addEventListener("htmx:afterSwap", e => {
|
||||
if (e.detail.target.id === "aide-contenu") e.detail.target.parentElement.scrollTop = 0;
|
||||
});
|
||||
|
||||
// Les messages (erreurs) s'effacent d'eux-mêmes.
|
||||
document.addEventListener("htmx:afterSwap", e => {
|
||||
if (e.detail.target.id === "messages") {
|
||||
clearTimeout(window._rtgMessage);
|
||||
window._rtgMessage = setTimeout(() => { e.detail.target.innerHTML = ""; }, 6000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Un traitement à la fois (génération, compression…) dans un fil à part.
|
||||
|
||||
Tout ce que la chaîne affiche est recopié dans le journal de la tâche, en
|
||||
plus du terminal ; l'interface relit ce journal et l'avancement chaque seconde.
|
||||
"""
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from collections import deque
|
||||
|
||||
from rtg.outils import ARRET, Interruption
|
||||
|
||||
_verrou = threading.Lock()
|
||||
COURANTE = None
|
||||
|
||||
|
||||
class Tache:
|
||||
def __init__(self, libelle, voyage=""):
|
||||
self.libelle = libelle
|
||||
self.voyage = voyage # chemin relatif, "" pour tout le site
|
||||
self.lignes = deque(maxlen=3000)
|
||||
self.etape = ""
|
||||
self.etape_voyage = "" # voyage en cours quand tout le site est généré
|
||||
self.video = None # {"nom", "pct", "rang": (i, n)}
|
||||
self.debut = time.time()
|
||||
self.fin = None
|
||||
self.ok = None
|
||||
self.arretee = False
|
||||
|
||||
@property
|
||||
def en_cours(self):
|
||||
return self.fin is None
|
||||
|
||||
@property
|
||||
def duree(self):
|
||||
s = int((self.fin or time.time()) - self.debut)
|
||||
return f"{s // 60} min {s % 60:02d} s" if s >= 60 else f"{s} s"
|
||||
|
||||
def suivi(self, ev):
|
||||
if "etape" in ev:
|
||||
self.etape = ev["etape"]
|
||||
self.etape_voyage = ev.get("voyage", "") if ev.get("voyage") != self.voyage else ""
|
||||
if ev["etape"] != "Vidéos":
|
||||
self.video = None
|
||||
elif "video" in ev:
|
||||
if ev["etat"] == "en_cours":
|
||||
rang = ev.get("rang") or (self.video or {}).get("rang")
|
||||
self.video = {"nom": ev["video"], "pct": ev["pct"] or 0, "rang": rang}
|
||||
elif ev.get("rang"):
|
||||
self.video = {"nom": ev["video"], "pct": 100, "rang": ev["rang"]}
|
||||
|
||||
|
||||
class _Recopie:
|
||||
"""Remplace sys.stdout pendant une tâche : écrit dans le terminal et dans
|
||||
le journal de la tâche (ligne par ligne ; un « \\r » remplace la ligne)."""
|
||||
|
||||
def __init__(self, original, tache):
|
||||
self.original, self.tache, self.tampon = original, tache, ""
|
||||
|
||||
def write(self, s):
|
||||
self.original.write(s)
|
||||
self.tampon += s
|
||||
while "\n" in self.tampon:
|
||||
ligne, self.tampon = self.tampon.split("\n", 1)
|
||||
self.tache.lignes.append(ligne.rsplit("\r", 1)[-1])
|
||||
return len(s)
|
||||
|
||||
def flush(self):
|
||||
self.original.flush()
|
||||
|
||||
def isatty(self):
|
||||
return False
|
||||
|
||||
def __getattr__(self, nom):
|
||||
return getattr(self.original, nom)
|
||||
|
||||
|
||||
def lancer(libelle, fonction, voyage=""):
|
||||
"""Démarre `fonction(suivi)` dans un fil. Renvoie la tâche, ou None si une
|
||||
autre tâche tourne encore."""
|
||||
global COURANTE
|
||||
with _verrou:
|
||||
if COURANTE and COURANTE.en_cours:
|
||||
return None
|
||||
t = Tache(libelle, voyage)
|
||||
COURANTE = t
|
||||
ARRET.clear()
|
||||
|
||||
def executer():
|
||||
original = sys.stdout
|
||||
sys.stdout = _Recopie(original, t)
|
||||
try:
|
||||
res = fonction(t.suivi)
|
||||
t.ok = res is not False
|
||||
except Interruption:
|
||||
t.ok, t.arretee = False, True
|
||||
print("⏹ Traitement arrêté.")
|
||||
except SystemExit as e:
|
||||
t.ok = False
|
||||
if e.code not in (None, 0):
|
||||
print(str(e.code))
|
||||
except Exception:
|
||||
t.ok = False
|
||||
print("❌ Erreur inattendue :")
|
||||
print(traceback.format_exc())
|
||||
finally:
|
||||
if ARRET.is_set():
|
||||
t.arretee = True
|
||||
sys.stdout.flush()
|
||||
sys.stdout = original
|
||||
t.fin = time.time()
|
||||
t.etape = "Arrêté" if t.arretee else ("Terminé" if t.ok else "Terminé avec des erreurs")
|
||||
t.video = None
|
||||
t.etape_voyage = ""
|
||||
|
||||
threading.Thread(target=executer, daemon=True, name="rtg-tache").start()
|
||||
return t
|
||||
|
||||
|
||||
def arreter():
|
||||
if COURANTE and COURANTE.en_cours:
|
||||
ARRET.set()
|
||||
return True
|
||||
return False
|
||||
+32
-1
@@ -3,10 +3,23 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
EXT_PHOTOS = (".jpg", ".jpeg", ".heic", ".heif")
|
||||
EXT_VIDEOS = (".mp4", ".mov")
|
||||
|
||||
# Demande d'arrêt d'un traitement en cours (bouton « Arrêter » de l'interface).
|
||||
ARRET = threading.Event()
|
||||
|
||||
|
||||
class Interruption(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def verifier_arret():
|
||||
if ARRET.is_set():
|
||||
raise Interruption()
|
||||
|
||||
|
||||
def dire(message=""):
|
||||
print(message, flush=True)
|
||||
@@ -70,7 +83,15 @@ def a_jour(sortie, source, force=False):
|
||||
|
||||
def coeurs_performance():
|
||||
"""Cœurs « performance » sur Apple Silicon (les cœurs d'efficience
|
||||
ralentiraient tout le pool de threads x265), sinon tous les cœurs."""
|
||||
ralentiraient tout le pool de threads x265), sinon les cœurs utilisables
|
||||
(dans un conteneur : ceux qui lui sont attribués)."""
|
||||
if hasattr(os, "sched_getaffinity") and sys.platform != "darwin":
|
||||
try:
|
||||
n = len(os.sched_getaffinity(0))
|
||||
quota = _quota_cgroup()
|
||||
return max(1, min(n, quota) if quota else n)
|
||||
except OSError:
|
||||
pass
|
||||
if sys.platform == "darwin":
|
||||
for cle in ("hw.perflevel0.logicalcpu", "hw.logicalcpu"):
|
||||
r = subprocess.run(["sysctl", "-n", cle], capture_output=True, text=True)
|
||||
@@ -79,6 +100,16 @@ def coeurs_performance():
|
||||
return os.cpu_count() or 4
|
||||
|
||||
|
||||
def _quota_cgroup():
|
||||
"""Limite de processeurs d'un conteneur (cgroup v2 « cpu.max »), ou None."""
|
||||
try:
|
||||
with open("/sys/fs/cgroup/cpu.max") as f:
|
||||
quota, periode = f.read().split()
|
||||
return None if quota == "max" else max(1, int(int(quota) / int(periode)))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def taille_lisible(octets):
|
||||
for unite in ("o", "Ko", "Mo", "Go"):
|
||||
if octets < 1024 or unite == "Go":
|
||||
|
||||
+23
-8
@@ -35,6 +35,8 @@ from folium import plugins
|
||||
from folium.plugins import MarkerCluster
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from rtg import corrections
|
||||
|
||||
DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
|
||||
# Fixés par generer() : racine du site et adresse publique.
|
||||
CONTEXTE = {"racine": None, "site_url": "https://roadtrip.ouaibe.fr/"}
|
||||
@@ -101,13 +103,18 @@ def bornes(points, marge=0.02, robuste=True):
|
||||
len(points) - len(gardes))
|
||||
|
||||
|
||||
def lire_csv(chemin):
|
||||
"""CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour."""
|
||||
def lire_csv(chemin, positions=None):
|
||||
"""CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour.
|
||||
`positions` : {fichier: (lat, lon)} placés à la main, qui l'emportent."""
|
||||
vide = pd.DataFrame(columns=COLONNES + ['DT', 'Jour'])
|
||||
# Un voyage sans photo produit un CSV de 0 octet, que pandas refuse de lire.
|
||||
if not os.path.exists(chemin) or os.path.getsize(chemin) == 0:
|
||||
return vide
|
||||
df = pd.read_csv(chemin, names=COLONNES, header=None)
|
||||
for nom, (lat, lon) in (positions or {}).items():
|
||||
lignes = df['Fichier'].astype(str).str.strip() == nom
|
||||
df.loc[lignes, 'Latitude'] = lat
|
||||
df.loc[lignes, 'Longitude'] = lon
|
||||
df = df[df['Latitude'].notnull() & df['Longitude'].notnull()]
|
||||
df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)].copy()
|
||||
if df.empty:
|
||||
@@ -312,12 +319,16 @@ def exporter_trace_globale(base_dir, pistes):
|
||||
class Voyage:
|
||||
"""Données communes à toutes les pages d'un voyage."""
|
||||
|
||||
def __init__(self, base_dir):
|
||||
def __init__(self, base_dir, cfg=None):
|
||||
self.base_dir = base_dir
|
||||
self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage"
|
||||
self.routes_dir = os.path.join(base_dir, "routes")
|
||||
self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"))
|
||||
self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"))
|
||||
cfg = cfg or {}
|
||||
self.zones = corrections.masques(cfg)
|
||||
pos = corrections.positions(cfg)
|
||||
self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"), pos)
|
||||
self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"), pos)
|
||||
self.points_masques = 0
|
||||
jours_gpx = set()
|
||||
for f in fichiers_gpx(self.routes_dir):
|
||||
m = re.search(r'(\d{4}-\d{2}-\d{2})', f)
|
||||
@@ -343,6 +354,7 @@ class Voyage:
|
||||
continue
|
||||
self.gpx_du_jour[jour] = gpx_file
|
||||
for track in gpx.tracks:
|
||||
self.points_masques += corrections.filtrer_piste(track, self.zones)
|
||||
pts = [[p.latitude, p.longitude] for seg in track.segments for p in seg.points]
|
||||
if pts:
|
||||
if not track.name:
|
||||
@@ -396,19 +408,22 @@ def carte_du_jour(v, jour, version, journal, pre, lien_accueil, bouton_telecharg
|
||||
return m_day, media_coords
|
||||
|
||||
|
||||
def generer(base_dir, racine=None, site_url=None):
|
||||
"""Pages d'un voyage. `racine` : racine du site (bouton « Retour »)."""
|
||||
def generer(base_dir, racine=None, site_url=None, cfg=None):
|
||||
"""Pages d'un voyage. `racine` : racine du site (bouton « Retour ») ;
|
||||
`cfg` : réglages du voyage (corrections faites dans l'interface)."""
|
||||
CONTEXTE["racine"] = racine
|
||||
if site_url:
|
||||
CONTEXTE["site_url"] = site_url.rstrip('/') + '/'
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
html_dir = os.path.join(base_dir, "html")
|
||||
os.makedirs(html_dir, exist_ok=True)
|
||||
v = Voyage(base_dir)
|
||||
v = Voyage(base_dir, cfg)
|
||||
if not v.jours:
|
||||
print(f"❌ {v.nom} : aucune date trouvée (ni GPX daté, ni média géolocalisé).")
|
||||
return False
|
||||
v.lire_traces()
|
||||
if v.points_masques:
|
||||
print(f" 🙈 {v.points_masques} point(s) de trace retiré(s) par les zones masquées")
|
||||
|
||||
version = installer_assets(base_dir)
|
||||
journal = Journal(base_dir, v.jours)
|
||||
|
||||
+92
-21
@@ -4,7 +4,7 @@ import os
|
||||
from rtg import accueil, extraction, pages, photos, sources, video
|
||||
from rtg.config import (SITE_TOML, VOYAGE_TOML, ecrire_toml, nom_lisible,
|
||||
reglages_site, reglages_voyage)
|
||||
from rtg.outils import dire, est_cache
|
||||
from rtg.outils import dire, est_cache, verifier_arret
|
||||
|
||||
IGNORES = {"Scripts", "RoadTripsGenerator", "html", "assets", "videos", "photos", "photos_web",
|
||||
"apercus", "rushs", "routes"}
|
||||
@@ -46,19 +46,31 @@ def _sources_disponibles(voyage, liste):
|
||||
return True
|
||||
|
||||
|
||||
def generer_voyage(voyage, racine, site_cfg, compression=True, force=False):
|
||||
def generer_voyage(voyage, racine, site_cfg, compression=True, force=False, suivi=None,
|
||||
paliers=("hd", "sd"), refaire="manquantes", force_videos=None):
|
||||
"""Toute la chaîne pour un voyage. `force` refait photos web et pages, et
|
||||
aussi les vidéos sauf si force_videos=False (l'interface choisit alors les
|
||||
vidéos à refaire par `refaire`). `suivi(evenement)` (interface) reçoit
|
||||
{"etape": …} au début de chaque étape et {"video": …} pendant la compression."""
|
||||
cfg = reglages_voyage(voyage, site_cfg)
|
||||
nom = os.path.relpath(voyage, racine)
|
||||
dire("=" * 50)
|
||||
dire(f"▶ {nom}")
|
||||
dire("=" * 50)
|
||||
|
||||
def etape(libelle):
|
||||
verifier_arret()
|
||||
if suivi:
|
||||
suivi({"etape": libelle, "voyage": nom})
|
||||
|
||||
etape("Sources")
|
||||
a = sources.analyser(voyage, cfg)
|
||||
for m in a.a_verifier:
|
||||
dire(f"⚠️ {m}")
|
||||
for r in sources.ranger(voyage, cfg, a):
|
||||
dire(r)
|
||||
|
||||
etape("Positions")
|
||||
extraction.extraire_photos(voyage, cfg, a)
|
||||
if _sources_disponibles(voyage, cfg["sources"]["videos"]):
|
||||
extraction.extraire_videos(voyage, a)
|
||||
@@ -66,17 +78,26 @@ def generer_voyage(voyage, racine, site_cfg, compression=True, force=False):
|
||||
dire("⏭️ Vidéos sources inaccessibles : positions reprises du dernier export.")
|
||||
|
||||
if compression and a.videos:
|
||||
video.compresser(voyage, cfg, a, force=force)
|
||||
etape("Vidéos")
|
||||
progression = None
|
||||
if suivi:
|
||||
def progression(fichier, etat, pct, rang):
|
||||
suivi({"video": fichier, "etat": etat, "pct": pct, "rang": rang})
|
||||
video.compresser(voyage, cfg, a, force=force if force_videos is None else force_videos,
|
||||
progression=progression, paliers=paliers, refaire=refaire)
|
||||
elif not compression:
|
||||
dire("⏭️ Compression sautée.")
|
||||
|
||||
etape("Photos")
|
||||
photos.preparer(voyage, cfg, force=force)
|
||||
ok = pages.generer(voyage, racine, cfg["adresse_publique"])
|
||||
etape("Pages")
|
||||
ok = pages.generer(voyage, racine, cfg["adresse_publique"], cfg)
|
||||
dire()
|
||||
return ok
|
||||
|
||||
|
||||
def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=False):
|
||||
def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=False, suivi=None,
|
||||
paliers=("hd", "sd"), refaire="manquantes", force_videos=None):
|
||||
racine = os.path.abspath(racine)
|
||||
cfg = reglages_site(racine)
|
||||
liste = [os.path.abspath(v) for v in voyages] if voyages else decouvrir(racine)
|
||||
@@ -84,7 +105,12 @@ def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=Fals
|
||||
dire("❌ Aucun voyage trouvé (dossiers avec routes/, photos/, rushs/ ou voyage.toml).")
|
||||
return False
|
||||
dire(f"📁 {len(liste)} voyage(s) : {', '.join(os.path.relpath(v, racine) for v in liste)}\n")
|
||||
echecs = [v for v in liste if not generer_voyage(v, racine, cfg, compression, force)]
|
||||
echecs = [v for v in liste
|
||||
if not generer_voyage(v, racine, cfg, compression, force, suivi, paliers, refaire,
|
||||
force_videos)]
|
||||
verifier_arret()
|
||||
if suivi:
|
||||
suivi({"etape": "Accueil", "voyage": ""})
|
||||
dire("=" * 50)
|
||||
dire("▶ Accueil")
|
||||
dire("=" * 50)
|
||||
@@ -94,32 +120,58 @@ def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=Fals
|
||||
return not echecs
|
||||
|
||||
|
||||
def etat(racine):
|
||||
"""Tableau de l'avancement de chaque voyage."""
|
||||
racine = os.path.abspath(racine)
|
||||
site_cfg = reglages_site(racine)
|
||||
lignes = [("Voyage", "Jours", "Vidéos web", "Photos web", "Pages", "À vérifier")]
|
||||
for v in decouvrir(racine):
|
||||
def rubrique_de(voyage, racine):
|
||||
parent = os.path.dirname(os.path.abspath(voyage))
|
||||
return "Voyages" if parent == os.path.abspath(racine) else nom_lisible(os.path.basename(parent))
|
||||
|
||||
|
||||
def etat_voyage(v, racine, site_cfg):
|
||||
"""Avancement d'un voyage (dictionnaire), sans rien lire de lourd."""
|
||||
cfg = reglages_voyage(v, site_cfg)
|
||||
a = sources.analyser(v, cfg)
|
||||
a = sources.analyser(v, cfg, leger=True)
|
||||
dossier = os.path.join(v, "videos")
|
||||
faites_web = len([f for f in os.listdir(dossier) if f.endswith("_mobile.mp4")]) if os.path.isdir(dossier) else 0
|
||||
if a.videos:
|
||||
faites = sum(1 for s in a.videos if video.deja_faite(v, s))
|
||||
faites = sum(1 for s in a.videos if video.deja_faite(v, s, rapide=True))
|
||||
vid = f"{faites}/{len(a.videos)}"
|
||||
else:
|
||||
dossier = os.path.join(v, "videos")
|
||||
n = len([f for f in os.listdir(dossier) if f.endswith("_mobile.mp4")]) if os.path.isdir(dossier) else 0
|
||||
vid = f"{n} (sources absentes)" if n else "—"
|
||||
faites = None
|
||||
vid = f"{faites_web} (sources absentes)" if faites_web else "—"
|
||||
ph = os.path.join(v, "photos")
|
||||
nb_ph = len([f for f in os.listdir(ph) if f.lower().endswith((".jpg", ".jpeg"))]) if os.path.isdir(ph) else 0
|
||||
pw = os.path.join(v, "photos_web")
|
||||
nb_pw = len([f for f in os.listdir(pw) if not est_cache(f)]) if os.path.isdir(pw) else 0
|
||||
index = os.path.join(v, "index.html")
|
||||
pages_ok = "à jour" if os.path.exists(index) and all(
|
||||
genere = os.path.exists(index)
|
||||
pages_ok = genere and all(
|
||||
os.path.getmtime(index) >= os.path.getmtime(os.path.join(v, c))
|
||||
for c in ("export_photos.csv", "export_videos.csv", "voyage.md")
|
||||
if os.path.exists(os.path.join(v, c))) else "à refaire"
|
||||
lignes.append((os.path.relpath(v, racine), str(len(a.journees)), vid,
|
||||
f"{nb_pw}/{nb_ph}" if nb_ph else "—", pages_ok, str(len(a.a_verifier)) or "0"))
|
||||
if os.path.exists(os.path.join(v, c)))
|
||||
jours = sorted(a.journees)
|
||||
return {
|
||||
"chemin": os.path.relpath(v, racine).replace(os.sep, "/"), "dossier": v,
|
||||
"titre": cfg["titre"], "moto": cfg.get("moto", ""), "rubrique": rubrique_de(v, racine),
|
||||
"debut": jours[0] if jours else None, "fin": jours[-1] if jours else None,
|
||||
"jours": len(jours), "videos": vid, "videos_sources": len(a.videos),
|
||||
"videos_faites": faites, "videos_web": faites_web,
|
||||
"photos": nb_ph, "photos_web": nb_pw, "genere": genere, "pages_a_jour": pages_ok,
|
||||
"a_verifier": a.a_verifier,
|
||||
}
|
||||
|
||||
|
||||
def tableau_etat(racine):
|
||||
racine = os.path.abspath(racine)
|
||||
site_cfg = reglages_site(racine)
|
||||
return [etat_voyage(v, racine, site_cfg) for v in decouvrir(racine)]
|
||||
|
||||
|
||||
def etat(racine):
|
||||
"""Tableau de l'avancement de chaque voyage."""
|
||||
lignes = [("Voyage", "Jours", "Vidéos web", "Photos web", "Pages", "À vérifier")]
|
||||
for e in tableau_etat(racine):
|
||||
lignes.append((e["chemin"], str(e["jours"]), e["videos"],
|
||||
f"{e['photos_web']}/{e['photos']}" if e["photos"] else "—",
|
||||
"à jour" if e["pages_a_jour"] else "à refaire", str(len(e["a_verifier"]))))
|
||||
largeurs = [max(len(l[i]) for l in lignes) for i in range(len(lignes[0]))]
|
||||
for k, l in enumerate(lignes):
|
||||
dire(" ".join(c.ljust(w) for c, w in zip(l, largeurs)))
|
||||
@@ -143,6 +195,25 @@ def analyser(voyage, racine):
|
||||
dire(f" ⚠️ {m}")
|
||||
|
||||
|
||||
def creer_voyage(racine, dossier, titre, groupe="", moto=""):
|
||||
"""Nouveau voyage : <racine>/[<groupe>/]<dossier>/voyage.toml, sans source.
|
||||
Renvoie le chemin du dossier créé."""
|
||||
for nom in (dossier, groupe):
|
||||
if nom and (os.sep in nom or "/" in nom or nom.startswith((".", "_")) or nom in IGNORES):
|
||||
raise ValueError(f"Nom de dossier refusé : {nom}")
|
||||
chemin = os.path.join(racine, groupe, dossier) if groupe else os.path.join(racine, dossier)
|
||||
if os.path.exists(chemin):
|
||||
raise ValueError(f"Le dossier {os.path.relpath(chemin, racine)} existe déjà")
|
||||
os.makedirs(chemin)
|
||||
donnees = {"titre": titre or nom_lisible(dossier)}
|
||||
if moto:
|
||||
donnees["moto"] = moto
|
||||
donnees["sources"] = {"traces": [], "videos": [], "photos": []}
|
||||
ecrire_toml(os.path.join(chemin, VOYAGE_TOML), donnees,
|
||||
entete="Voyage RoadTripsGenerator : sources, infos et réglages propres.")
|
||||
return chemin
|
||||
|
||||
|
||||
def migrer(racine):
|
||||
"""Écrit site.toml et un voyage.toml par voyage à partir de l'existant
|
||||
(mymaps.txt, moto.txt). Ne remplace jamais un fichier déjà présent."""
|
||||
|
||||
+126
-16
@@ -11,10 +11,14 @@ 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 csv
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import gpxpy
|
||||
@@ -33,6 +37,8 @@ class Analyse:
|
||||
self.photos = [] # chemins des photos sources
|
||||
self.videos = [] # chemins des vidéos sources
|
||||
self.a_verifier = [] # messages pour l'utilisateur
|
||||
self.details_traces = [] # un dict par fichier GPX (pour l'interface)
|
||||
self.non_datees = {} # fichier → [GPXTrack] des traces sans jour
|
||||
|
||||
def km(self, jour):
|
||||
total = 0.0
|
||||
@@ -65,21 +71,36 @@ def date_du_nom(nom):
|
||||
return None
|
||||
|
||||
|
||||
def decouper_par_journee(fichier, tz, coupure_h):
|
||||
def jour_texte(valeur):
|
||||
"""« 2025-08-04 » (ou date TOML) → date, sinon None."""
|
||||
try:
|
||||
return valeur if isinstance(valeur, date) else date.fromisoformat(str(valeur).strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def decouper_par_journee(fichier, tz, coupure_h, jour_impose=None, pistes_sans_jour=None, infos=None):
|
||||
"""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.
|
||||
trouvée. `jour_impose` (choisi dans l'interface) place tout le fichier sur
|
||||
ce jour. Sinon, 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. Les pistes d'un
|
||||
fichier qu'aucune date ne place sont ajoutées à `pistes_sans_jour`. `infos`
|
||||
(dictionnaire) reçoit "datable" : le fichier se date-t-il sans aide ?
|
||||
"""
|
||||
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)
|
||||
if infos is not None:
|
||||
infos["datable"] = a_des_heures or date_du_nom(os.path.basename(fichier)) is not None
|
||||
res = {}
|
||||
if not a_des_heures:
|
||||
jour = date_du_nom(os.path.basename(fichier))
|
||||
if jour_impose or not a_des_heures:
|
||||
jour = jour_impose or date_du_nom(os.path.basename(fichier))
|
||||
if jour is None:
|
||||
if pistes_sans_jour is not None:
|
||||
pistes_sans_jour.extend(gpx.tracks)
|
||||
return {}, "trace sans heures et sans date dans son nom"
|
||||
for t in gpx.tracks:
|
||||
res.setdefault(jour, []).append(t)
|
||||
@@ -110,25 +131,51 @@ def decouper_par_journee(fichier, tz, coupure_h):
|
||||
return res, None
|
||||
|
||||
|
||||
def analyser(voyage, cfg):
|
||||
"""Inventaire des sources d'un voyage (sans rien écrire)."""
|
||||
def analyser(voyage, cfg, leger=False):
|
||||
"""Inventaire des sources d'un voyage (sans rien écrire).
|
||||
|
||||
leger=True (pour `rtg etat`) : une trace dont le nom porte la date n'est
|
||||
pas lue ; sa journée est comptée, sans les points ni les kilomètres."""
|
||||
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)
|
||||
imposes = {k: jour_texte(v) for k, v in cfg.get("dates_traces", {}).items()}
|
||||
sources_traces = _sources(voyage, "traces", cfg)
|
||||
a.traces = [f for f in lister_fichiers(sources_traces, (".gpx",), voyage)
|
||||
if os.path.basename(f) not in exclus]
|
||||
if not _est_dossier_du_voyage(voyage, sources_traces, "routes"):
|
||||
# Les journées que RTG a lui-même écrites dans routes/ ne sont pas des
|
||||
# sources : les relire les compterait deux fois.
|
||||
routes = os.path.realpath(os.path.join(voyage, "routes"))
|
||||
a.traces = [f for f in a.traces
|
||||
if os.path.realpath(os.path.dirname(f)) != routes or not ecrite_par_rtg(f)]
|
||||
for f in a.traces:
|
||||
nom = os.path.basename(f)
|
||||
impose = imposes.get(nom)
|
||||
if leger and (impose or date_du_nom(nom)):
|
||||
a.journees.setdefault(impose or date_du_nom(nom), [])
|
||||
continue
|
||||
sans_jour, infos = [], {}
|
||||
try:
|
||||
parts, raison = decouper_par_journee(f, tz, coupure)
|
||||
parts, raison = decouper_par_journee(f, tz, coupure, impose, sans_jour, infos)
|
||||
except Exception as e:
|
||||
a.a_verifier.append(f"Trace illisible : {os.path.basename(f)} ({e})")
|
||||
a.a_verifier.append(f"Trace illisible : {nom} ({e})")
|
||||
a.details_traces.append({"fichier": f, "nom": nom, "jours": [], "km": 0,
|
||||
"impose": impose, "erreur": str(e)})
|
||||
continue
|
||||
if raison:
|
||||
a.a_verifier.append(f"Trace à rattacher à un jour : {os.path.basename(f)} ({raison})")
|
||||
a.a_verifier.append(f"Trace à rattacher à un jour : {nom} ({raison})")
|
||||
a.non_datees[f] = sans_jour
|
||||
km = 0.0
|
||||
for jour, pistes in parts.items():
|
||||
a.journees.setdefault(jour, []).extend(pistes)
|
||||
km += sum((p.length_2d() or 0) for p in pistes) / 1000
|
||||
if sans_jour:
|
||||
km = sum((p.length_2d() or 0) for p in sans_jour) / 1000
|
||||
a.details_traces.append({"fichier": f, "nom": nom, "jours": sorted(parts), "km": km,
|
||||
"impose": impose, "erreur": None, "datable": infos["datable"]})
|
||||
|
||||
a.photos = [f for f in lister_fichiers(_sources(voyage, "photos", cfg), EXT_PHOTOS, voyage)
|
||||
if os.path.basename(f) not in exclus]
|
||||
@@ -149,6 +196,11 @@ def analyser(voyage, cfg):
|
||||
return a
|
||||
|
||||
|
||||
def ecrite_par_rtg(fichier):
|
||||
with open(fichier, encoding="utf-8", errors="replace") as f:
|
||||
return f'creator="{CREATEUR}"' in f.read(600)
|
||||
|
||||
|
||||
def ranger(voyage, cfg, a):
|
||||
"""Range les sources dans le dossier du voyage (routes/, photos/).
|
||||
Renvoie un résumé des écritures."""
|
||||
@@ -181,9 +233,7 @@ def _ecrire_routes(voyage, a):
|
||||
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:
|
||||
if ecrite_par_rtg(chemin):
|
||||
os.remove(chemin)
|
||||
retires += 1
|
||||
return f"🧭 routes/ : {len(attendus)} journée(s) écrite(s)" + (f", {retires} retirée(s)" if retires else "")
|
||||
@@ -194,6 +244,8 @@ def _copier_photos(voyage, a):
|
||||
os.makedirs(dossier, exist_ok=True)
|
||||
copiees = converties = 0
|
||||
for src in a.photos:
|
||||
if os.path.realpath(os.path.dirname(src)) == os.path.realpath(dossier):
|
||||
continue # déjà dans 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))
|
||||
@@ -219,3 +271,61 @@ def convertir_heic(src, dest):
|
||||
exif = im.info.get("exif")
|
||||
im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"")
|
||||
os.replace(dest + ".tmp", dest)
|
||||
|
||||
|
||||
# --- Suggestion d'un jour pour une trace sans heures ---------------------------
|
||||
def positions_medias(voyage, a):
|
||||
"""[(date, lat, lon)] des photos et vidéos du voyage : exports CSV s'ils
|
||||
existent, sinon lecture rapide des photos sources par exiftool."""
|
||||
res = []
|
||||
for nom in ("export_photos.csv", "export_videos.csv"):
|
||||
chemin = os.path.join(voyage, nom)
|
||||
if not os.path.exists(chemin):
|
||||
continue
|
||||
with open(chemin, encoding="utf-8", errors="replace") as f:
|
||||
for ligne in csv.reader(f):
|
||||
try:
|
||||
res.append((date(*map(int, ligne[1][:10].replace("-", ":").split(":"))),
|
||||
float(ligne[2]), float(ligne[3])))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if res or not a.photos or not shutil.which("exiftool"):
|
||||
return res
|
||||
r = subprocess.run(["exiftool", "-n", "-q", "-p", "$DateTimeOriginal,$GPSLatitude,$GPSLongitude"]
|
||||
+ a.photos[:2000], capture_output=True, text=True, errors="replace")
|
||||
for ligne in r.stdout.splitlines():
|
||||
try:
|
||||
d, lat, lon = ligne.split(",")
|
||||
res.append((date(*map(int, d[:10].split(":"))), float(lat), float(lon)))
|
||||
except ValueError:
|
||||
pass
|
||||
return res
|
||||
|
||||
|
||||
def suggerer_jour(pistes, medias, rayon_km=1.5):
|
||||
"""Jour le plus probable pour une trace sans heures : celui dont le plus de
|
||||
photos et vidéos ont été prises à moins de `rayon_km` de la trace.
|
||||
Renvoie (date, nombre de médias) ou (None, 0)."""
|
||||
pts = [(p.latitude, p.longitude) for t in pistes for s in t.segments for p in s.points]
|
||||
if not pts or not medias:
|
||||
return None, 0
|
||||
pas = max(1, len(pts) // 2000)
|
||||
pts = pts[::pas]
|
||||
# Grille d'environ 1 km : on ne compare un média qu'aux points voisins.
|
||||
cellule = 0.01
|
||||
grille = {}
|
||||
for lat, lon in pts:
|
||||
grille.setdefault((math.floor(lat / cellule), math.floor(lon / cellule)), []).append((lat, lon))
|
||||
portee = int(rayon_km / 1.1 / (cellule * 100)) + 1
|
||||
compte = Counter()
|
||||
for jour, lat, lon in medias:
|
||||
i, j = math.floor(lat / cellule), math.floor(lon / cellule)
|
||||
cos = math.cos(math.radians(lat))
|
||||
proche = any(math.hypot((la - lat) * 111.2, (lo - lon) * 111.2 * cos) <= rayon_km
|
||||
for di in range(-portee, portee + 1) for dj in range(-portee, portee + 1)
|
||||
for la, lo in grille.get((i + di, j + dj), ()))
|
||||
if proche:
|
||||
compte[jour] += 1
|
||||
if not compte:
|
||||
return None, 0
|
||||
return compte.most_common(1)[0]
|
||||
@@ -54,6 +54,10 @@
|
||||
display: block; width: 100%; height: min(600px, 75vh); border: none;
|
||||
border-radius: 15px; box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
/* Bouton discret vers l'interface d'administration (site.toml, [administration]). */
|
||||
a.connexion { position: absolute; top: 16px; right: 20px; font-size: 0.85rem; color: #777;
|
||||
text-decoration: none; padding: 5px 12px; border: 1px solid #ccc; border-radius: 99px; }
|
||||
a.connexion:hover { color: #3498db; border-color: #3498db; }
|
||||
.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) {
|
||||
@@ -62,10 +66,12 @@
|
||||
.sous-titre, .vide, .periode { color: #9a9aa2; }
|
||||
.rubrique-titre { border-color: #333; }
|
||||
code { background: #2e2e36; }
|
||||
a.connexion { color: #9a9aa2; border-color: #444; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if administration %}<a class="connexion" href="{{ administration }}" rel="nofollow">Connexion</a>{% endif %}
|
||||
<header>
|
||||
<h1>{% block entete %}🌍 {{ titre_site }}{% endblock %}</h1>
|
||||
<div class="sous-titre">{% block sous_titre %}{% endblock %}</div>
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""`rtg verifier` : tout ce dont RTG a besoin est-il installé ?
|
||||
|
||||
Pour chaque élément manquant, la commande à taper sur ce système.
|
||||
"""
|
||||
import importlib
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from rtg.outils import dire, programme
|
||||
|
||||
SYSTEME = {"Darwin": "mac", "Windows": "windows"}.get(platform.system(), "linux")
|
||||
|
||||
# Commande d'installation par système, pour chaque programme externe.
|
||||
INSTALLER = {
|
||||
"exiftool": {"mac": "brew install exiftool", "linux": "sudo apt install libimage-exiftool-perl",
|
||||
"windows": "winget install OliverBetz.ExifTool"},
|
||||
"ffmpeg": {"mac": "brew install ffmpeg", "linux": "sudo apt install ffmpeg",
|
||||
"windows": "winget install Gyan.FFmpeg"},
|
||||
}
|
||||
|
||||
# Modules Python : (nom à importer, nom du paquet, indispensable ?)
|
||||
MODULES = [
|
||||
("folium", "folium", True), ("gpxpy", "gpxpy", True), ("jinja2", "jinja2", True),
|
||||
("markdown2", "markdown2", True), ("numpy", "numpy", True), ("pandas", "pandas", True),
|
||||
("PIL", "pillow", True), ("pillow_heif", "pillow-heif", False),
|
||||
]
|
||||
|
||||
|
||||
def _version(cmd):
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||
return (r.stdout or r.stderr).strip().splitlines()[0]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def verifier():
|
||||
"""Affiche le bilan ; renvoie True si l'essentiel est là."""
|
||||
ok = True
|
||||
dire(f"Système : {platform.system()} {platform.release()} ({platform.machine()})")
|
||||
|
||||
v = sys.version_info
|
||||
if v >= (3, 10):
|
||||
dire(f"✅ Python {v.major}.{v.minor}.{v.micro}")
|
||||
else:
|
||||
ok = False
|
||||
dire(f"❌ Python {v.major}.{v.minor} : il faut Python 3.10 ou plus récent")
|
||||
|
||||
manquants = []
|
||||
for module, paquet, indispensable in MODULES:
|
||||
try:
|
||||
importlib.import_module(module)
|
||||
except ImportError:
|
||||
if indispensable:
|
||||
ok = False
|
||||
manquants.append(paquet)
|
||||
dire(f"❌ Module Python {paquet} absent")
|
||||
else:
|
||||
dire(f"⚠️ Module Python {paquet} absent : les photos HEIC de l'iPhone seront ignorées")
|
||||
if manquants:
|
||||
dire(f" → réinstaller RTG avec pipx (il installe les modules), ou : "
|
||||
f"python3 -m pip install {' '.join(manquants)}")
|
||||
if not manquants:
|
||||
dire("✅ Modules Python")
|
||||
if sys.version_info < (3, 11):
|
||||
try:
|
||||
importlib.import_module("tomli")
|
||||
except ImportError:
|
||||
ok = False
|
||||
dire("❌ Module Python tomli absent (nécessaire avant Python 3.11)")
|
||||
|
||||
if programme("exiftool"):
|
||||
dire(f"✅ exiftool {_version(['exiftool', '-ver'])}")
|
||||
else:
|
||||
ok = False
|
||||
dire(f"❌ exiftool absent (positions GPS des photos et vidéos) → {INSTALLER['exiftool'][SYSTEME]}")
|
||||
|
||||
if programme("ffmpeg") and programme("ffprobe"):
|
||||
dire(f"✅ {_version(['ffmpeg', '-version'])[:60]}")
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True).stdout
|
||||
for nom, role in (("libx265", "1080p HEVC"), ("libx264", "720p H.264")):
|
||||
if nom in enc:
|
||||
dire(f" ✅ {nom} ({role})")
|
||||
else:
|
||||
ok = False
|
||||
dire(f" ❌ {nom} absent de ce ffmpeg ({role}) → installer un ffmpeg complet : "
|
||||
f"{INSTALLER['ffmpeg'][SYSTEME]}")
|
||||
if SYSTEME == "mac":
|
||||
if "hevc_videotoolbox" in enc:
|
||||
dire(" ✅ VideoToolbox (encodeur « matériel », facultatif)")
|
||||
else:
|
||||
dire(" ⚠️ VideoToolbox absent : seul l'encodeur « processeur » sera disponible")
|
||||
else:
|
||||
ok = False
|
||||
dire(f"❌ ffmpeg absent (vidéos et aperçus) → {INSTALLER['ffmpeg'][SYSTEME]}")
|
||||
|
||||
dire()
|
||||
dire("✅ Tout est prêt." if ok else "❌ Il manque des éléments : voir les lignes ❌ ci-dessus.")
|
||||
return ok
|
||||
+222
-30
@@ -9,13 +9,15 @@ sorties sont écrites sous un nom caché puis renommées : un encodage
|
||||
interrompu ne laisse jamais un fichier abîmé sous le vrai nom.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from rtg.config import parametres_video
|
||||
from rtg.outils import coeurs_performance, dire, exiger, lisible, taille_lisible
|
||||
from rtg.outils import ARRET, coeurs_performance, dire, exiger, lisible, taille_lisible
|
||||
|
||||
|
||||
def _encodeurs_disponibles():
|
||||
@@ -42,7 +44,7 @@ def repartition(p):
|
||||
return jobs, threads, max(2, threads // 2), coeurs
|
||||
|
||||
|
||||
def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src, stats):
|
||||
def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src):
|
||||
hd = palier == "hd"
|
||||
if hd:
|
||||
vf = "scale=-2:1080:flags=lanczos" if hauteur_src > 1080 else None
|
||||
@@ -54,7 +56,7 @@ def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src, stats):
|
||||
# pool de threads x265. hvc1 : sans lui, Safari refuse la vidéo.
|
||||
vopts = ["-c:v", "libx265", "-preset", p["preset"], "-profile:v", "main",
|
||||
"-crf", str(p["crf_hevc"]),
|
||||
"-x265-params", f"keyint=120:min-keyint=24:aq-mode=3:pools={threads}",
|
||||
"-x265-params", f"keyint=120:min-keyint=24:aq-mode=3:pools={threads}:log-level=error",
|
||||
"-maxrate", "8M", "-bufsize", "16M", "-tag:v", "hvc1"]
|
||||
audio = "96k"
|
||||
else:
|
||||
@@ -67,9 +69,9 @@ def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src, stats):
|
||||
"-crf", str(p["crf_h264"]), "-g", "120", "-keyint_min", "24",
|
||||
"-threads", str(threads_sd), "-maxrate", "5M", "-bufsize", "10M"]
|
||||
audio = "64k"
|
||||
cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error"]
|
||||
if stats:
|
||||
cmd.append("-stats")
|
||||
# -progress pipe:1 : avancement lisible par RTG (out_time_us=…) au lieu de
|
||||
# la ligne -stats, illisible quand deux encodages tournent ensemble.
|
||||
cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-nostats", "-progress", "pipe:1"]
|
||||
cmd += ["-i", src, "-map", "0:v:0", "-map", "0:a:0?"] + vopts
|
||||
if vf:
|
||||
cmd += ["-vf", vf]
|
||||
@@ -89,20 +91,149 @@ def _hauteur(src):
|
||||
return 1080
|
||||
|
||||
|
||||
def _duree(src):
|
||||
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", src], capture_output=True, text=True)
|
||||
try:
|
||||
return float(r.stdout.strip())
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _suivre(proc, palier, duree, avancement):
|
||||
"""Lit la sortie -progress de ffmpeg et met à jour avancement[palier] (0-100)."""
|
||||
for ligne in proc.stdout:
|
||||
if ligne.startswith(("out_time_us=", "out_time_ms=")) and duree > 0:
|
||||
try:
|
||||
avancement[palier] = min(100.0, int(ligne.split("=")[1]) / 1e6 / duree * 100)
|
||||
except ValueError:
|
||||
pass
|
||||
elif ligne.startswith("progress=end"):
|
||||
avancement[palier] = 100.0
|
||||
|
||||
|
||||
def sorties(voyage, src):
|
||||
base = os.path.splitext(os.path.basename(src))[0]
|
||||
dossier = os.path.join(voyage, "videos")
|
||||
return os.path.join(dossier, base + ".mp4"), os.path.join(dossier, base + "_mobile.mp4")
|
||||
|
||||
|
||||
def deja_faite(voyage, src):
|
||||
# --- Compression des vidéos déjà faites ------------------------------------------
|
||||
# x265 et x264 inscrivent leurs réglages dans la vidéo elle-même (« options:
|
||||
# … crf=26.0 … ») : on les relit au début des données, sans tout parcourir.
|
||||
_MARQUES = ((b"x265 (build", "HEVC"), (b"x264 - core", "H.264"))
|
||||
_cache_reglages = {}
|
||||
|
||||
|
||||
def _debut_des_donnees(chemin, octets=128 * 1024):
|
||||
taille = os.path.getsize(chemin)
|
||||
with open(chemin, "rb") as f:
|
||||
pos = 0
|
||||
while pos + 8 <= taille:
|
||||
f.seek(pos)
|
||||
h = f.read(16)
|
||||
n, typ, entete = int.from_bytes(h[:4], "big"), h[4:8], 8
|
||||
if n == 1:
|
||||
n, entete = int.from_bytes(h[8:16], "big"), 16
|
||||
elif n == 0:
|
||||
n = taille - pos
|
||||
if typ == b"mdat":
|
||||
f.seek(pos + entete)
|
||||
return f.read(octets)
|
||||
if n < 8:
|
||||
break
|
||||
pos += n
|
||||
return b""
|
||||
|
||||
|
||||
def reglages_fichier(chemin):
|
||||
"""(codec, crf) d'une vidéo encodée par RTG ; crf vaut None pour un encodage
|
||||
matériel ou inconnu. Résultat gardé tant que le fichier ne change pas."""
|
||||
try:
|
||||
st = os.stat(chemin)
|
||||
except OSError:
|
||||
return None
|
||||
cle = (chemin, st.st_mtime, st.st_size)
|
||||
if cle in _cache_reglages:
|
||||
return _cache_reglages[cle]
|
||||
res = ("autre", None)
|
||||
try:
|
||||
# HEVC (hvc1) : les réglages sont dans la description de la piste, au
|
||||
# début du fichier ; H.264 : dans la première image, au début des données.
|
||||
with open(chemin, "rb") as f:
|
||||
donnees = f.read(64 * 1024)
|
||||
donnees += _debut_des_donnees(chemin)
|
||||
for marque, codec in _MARQUES:
|
||||
i = donnees.find(marque)
|
||||
if i >= 0:
|
||||
texte = donnees[i:i + 6000].split(b"\x00")[0].decode("ascii", "replace")
|
||||
m = re.search(r"crf=([\d.]+)", texte)
|
||||
res = (codec, float(m.group(1)) if m else None)
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if res[0] == "autre":
|
||||
# Pas d'inscription (encodeur matériel VideoToolbox, ou autre) : le codec seul.
|
||||
r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||||
"stream=codec_name", "-of", "csv=p=0", chemin], capture_output=True, text=True)
|
||||
codec = {"hevc": "HEVC", "h264": "H.264"}.get(r.stdout.strip(), r.stdout.strip() or "inconnu")
|
||||
res = (codec, None)
|
||||
_cache_reglages[cle] = res
|
||||
return res
|
||||
|
||||
|
||||
def bilan_compression(voyage, maxi=40):
|
||||
"""Compression des vidéos web du voyage, par version :
|
||||
{"1080p": {"total": n, "lues": k, "reglages": Counter({(codec, crf): nb})}, "720p": …}.
|
||||
Au-delà de `maxi` vidéos, un échantillon réparti sur tout le voyage est lu."""
|
||||
from collections import Counter
|
||||
res = {}
|
||||
dossier = os.path.join(voyage, "videos")
|
||||
noms = sorted(n for n in os.listdir(dossier) if n.endswith(".mp4") and not n.startswith(".")) \
|
||||
if os.path.isdir(dossier) else []
|
||||
for version, liste in (("1080p", [n for n in noms if not n.endswith("_mobile.mp4")]),
|
||||
("720p", [n for n in noms if n.endswith("_mobile.mp4")])):
|
||||
pas = max(1, -(-len(liste) // maxi))
|
||||
lues = liste[::pas]
|
||||
compte = Counter(r for r in (reglages_fichier(os.path.join(dossier, n)) for n in lues) if r)
|
||||
res[version] = {"total": len(liste), "lues": len(lues), "reglages": compte}
|
||||
return res
|
||||
|
||||
|
||||
def deja_faite(voyage, src, rapide=False):
|
||||
"""Les deux sorties existent (rapide=True) ou sont lisibles par ffprobe."""
|
||||
hd, sd = sorties(voyage, src)
|
||||
if rapide:
|
||||
return all(os.path.exists(f) and os.path.getsize(f) > 0 for f in (hd, sd))
|
||||
return lisible(hd) and lisible(sd)
|
||||
|
||||
|
||||
def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||||
"""Compresse les rushs du voyage. `progression(nom, etat)` est appelée à
|
||||
chaque changement (pour l'interface). Renvoie True si tout a réussi."""
|
||||
REFAIRE = ("manquantes", "differentes", "toutes")
|
||||
|
||||
|
||||
def cible(palier, p):
|
||||
"""(codec, crf) que les réglages `p` donnent pour une version ; crf None
|
||||
pour l'encodeur matériel, qui n'inscrit pas ses réglages."""
|
||||
codec = "HEVC" if palier == "hd" else "H.264"
|
||||
if p["encodeur"] == "vt":
|
||||
return codec, None
|
||||
return codec, float(p["crf_hevc"] if palier == "hd" else p["crf_h264"])
|
||||
|
||||
|
||||
def compresser(voyage, cfg, analyse, force=False, progression=None, paliers=("hd", "sd"),
|
||||
refaire="manquantes"):
|
||||
"""Compresse les rushs du voyage. `progression(nom, etat, pourcentage, rang)`
|
||||
est appelée à chaque changement (pour l'interface) ; rang = (i, total).
|
||||
|
||||
refaire : « manquantes » (défaut) n'encode que les versions absentes ou
|
||||
illisibles ; « differentes » réencode aussi celles dont les réglages lus
|
||||
dans le fichier diffèrent des réglages actuels ; « toutes » réencode tout
|
||||
(= force). `paliers` limite la reprise au 1080p ("hd",) ou au 720p ("sd",) ;
|
||||
une version absente est toujours produite.
|
||||
Renvoie True si tout a réussi."""
|
||||
paliers = tuple(x for x in ("hd", "sd") if x in paliers) or ("hd", "sd")
|
||||
if force:
|
||||
refaire = "toutes"
|
||||
videos = analyse.videos
|
||||
os.makedirs(os.path.join(voyage, "videos"), exist_ok=True)
|
||||
p = parametres_video(cfg)
|
||||
@@ -116,48 +247,107 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||||
dire(f"⚙️ Encodeur : matériel VideoToolbox (q={p['vtq_hd']} / {p['vtq_sd']})")
|
||||
else:
|
||||
dire(f"⚙️ Encodeur : processeur, preset={p['preset']} (HEVC CRF {p['crf_hevc']} / H.264 CRF {p['crf_h264']})")
|
||||
dans_docker = os.path.exists("/.dockerenv")
|
||||
dire(f"🧵 Parallélisme : {jobs} rush(s) à la fois × 2 paliers ({threads} threads en 1080p, "
|
||||
f"{threads_sd} en 720p ; {coeurs} cœur(s) performance)")
|
||||
f"{threads_sd} en 720p ; {coeurs} cœur(s) "
|
||||
+ ("attribués au conteneur Docker)" if dans_docker else "performance)"))
|
||||
if dans_docker and coeurs < (os.cpu_count() or coeurs):
|
||||
dire(" (le conteneur est limité : voir Docker Desktop → Réglages → Resources → CPU)")
|
||||
dire(f"📼 {len(videos)} rush(s)")
|
||||
|
||||
verrou = threading.Lock()
|
||||
bilan = {"faits": 0, "sautes": 0, "echecs": 0, "octets_src": 0, "octets_web": 0}
|
||||
|
||||
def signaler(nom, etat):
|
||||
def signaler(nom, etat, detail=None, rang=None):
|
||||
if progression:
|
||||
progression(nom, etat)
|
||||
progression(nom, etat, detail, rang)
|
||||
|
||||
def un_rush(src):
|
||||
total = len(videos)
|
||||
en_direct = jobs == 1 and sys.stdout.isatty()
|
||||
|
||||
def un_rush(arg):
|
||||
i, src = arg
|
||||
nom = os.path.basename(src)
|
||||
rang = f"[{i:3d}/{total}]"
|
||||
hd, sd = sorties(voyage, src)
|
||||
if not force and lisible(hd) and lisible(sd):
|
||||
if ARRET.is_set():
|
||||
return False
|
||||
finals = {"hd": hd, "sd": sd}
|
||||
manquants = [x for x in ("hd", "sd") if not lisible(finals[x])]
|
||||
if refaire == "toutes":
|
||||
a_faire = list(paliers)
|
||||
elif refaire == "differentes":
|
||||
a_faire = [x for x in paliers if x in manquants or reglages_fichier(finals[x]) != cible(x, p)]
|
||||
else:
|
||||
a_faire = []
|
||||
a_faire = [x for x in ("hd", "sd") if x in a_faire or x in manquants]
|
||||
if not a_faire:
|
||||
with verrou:
|
||||
bilan["sautes"] += 1
|
||||
signaler(nom, "deja_faite")
|
||||
dire(f"⏭️ {rang} {nom} — déjà faite" + (" avec ces réglages" if refaire == "differentes" else ""))
|
||||
signaler(nom, "deja_faite", rang=(i, total))
|
||||
return True
|
||||
h = _hauteur(src)
|
||||
dire(f"🎬 {nom} ({h}p) — encodage des 2 paliers en parallèle…")
|
||||
signaler(nom, "en_cours")
|
||||
h, duree = _hauteur(src), _duree(src)
|
||||
debut = time.time()
|
||||
dire(f"🎬 {rang} {nom} ({h}p, {int(duree) // 60} min {int(duree) % 60:02d} s) — encodage…")
|
||||
signaler(nom, "en_cours", 0, rang=(i, total))
|
||||
tmp_hd = os.path.join(os.path.dirname(hd), ".tmp-" + os.path.basename(hd))
|
||||
tmp_sd = os.path.join(os.path.dirname(sd), ".tmp-" + os.path.basename(sd))
|
||||
stats = jobs == 1
|
||||
procs = [subprocess.Popen(_commande(src, tmp_hd, "hd", p, threads, threads_sd, h, stats)),
|
||||
subprocess.Popen(_commande(src, tmp_sd, "sd", p, threads, threads_sd, h, stats))]
|
||||
codes = [pr.wait() for pr in procs]
|
||||
if codes == [0, 0] and lisible(tmp_hd) and lisible(tmp_sd):
|
||||
os.replace(tmp_hd, hd)
|
||||
os.replace(tmp_sd, sd)
|
||||
tmps = {"hd": tmp_hd, "sd": tmp_sd}
|
||||
avancement = {x: 0.0 for x in a_faire}
|
||||
procs, lecteurs = [], []
|
||||
for palier in a_faire:
|
||||
tmp = tmps[palier]
|
||||
pr = subprocess.Popen(_commande(src, tmp, palier, p, threads, threads_sd, h),
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
th = threading.Thread(target=_suivre, args=(pr, palier, duree, avancement), daemon=True)
|
||||
th.start()
|
||||
procs.append(pr)
|
||||
lecteurs.append(th)
|
||||
dernier = -1
|
||||
while any(pr.poll() is None for pr in procs):
|
||||
time.sleep(0.5)
|
||||
if ARRET.is_set():
|
||||
for pr in procs:
|
||||
pr.terminate()
|
||||
moyenne = int(sum(avancement.values()) / len(avancement))
|
||||
if en_direct:
|
||||
print("\r " + " · ".join(f"{'1080p' if x == 'hd' else '720p'} {avancement[x]:3.0f} %"
|
||||
for x in a_faire) + " ", end="", flush=True)
|
||||
elif 0 < moyenne < 100 and moyenne // 25 > max(dernier, 0) // 25:
|
||||
dire(f" {nom} : {moyenne} %")
|
||||
if moyenne != dernier:
|
||||
signaler(nom, "en_cours", moyenne)
|
||||
dernier = moyenne
|
||||
if en_direct:
|
||||
print()
|
||||
for th in lecteurs:
|
||||
th.join(timeout=5)
|
||||
codes = [pr.returncode for pr in procs]
|
||||
if all(c == 0 for c in codes) and all(lisible(tmps[x]) for x in a_faire):
|
||||
avant = {x: os.path.getsize(finals[x]) for x in a_faire if os.path.exists(finals[x])}
|
||||
for x in a_faire:
|
||||
os.replace(tmps[x], finals[x])
|
||||
with verrou:
|
||||
bilan["faits"] += 1
|
||||
bilan["octets_src"] += os.path.getsize(src)
|
||||
bilan["octets_web"] += os.path.getsize(hd) + os.path.getsize(sd)
|
||||
dire(f" ✅ {nom:<24} {taille_lisible(os.path.getsize(src))} → 1080p "
|
||||
f"{taille_lisible(os.path.getsize(hd))} + 720p {taille_lisible(os.path.getsize(sd))}")
|
||||
bilan["octets_web"] += sum(os.path.getsize(finals[x]) for x in a_faire)
|
||||
details = " + ".join(
|
||||
f"{'1080p' if x == 'hd' else '720p'} {taille_lisible(os.path.getsize(finals[x]))}"
|
||||
+ (f" (avant {taille_lisible(avant[x])})" if x in avant else "") for x in a_faire)
|
||||
ecoule = max(time.time() - debut, 0.001)
|
||||
dire(f" ✅ {nom:<24} {taille_lisible(os.path.getsize(src))} → {details}"
|
||||
f" · {int(ecoule) // 60} min {int(ecoule) % 60:02d} s"
|
||||
+ (f" (×{duree / ecoule:.2f} temps réel)" if duree else ""))
|
||||
signaler(nom, "faite")
|
||||
return True
|
||||
for t in (tmp_hd, tmp_sd):
|
||||
if os.path.exists(t):
|
||||
os.remove(t)
|
||||
if ARRET.is_set():
|
||||
dire(f" ⏹ {nom} : arrêtée")
|
||||
signaler(nom, "arretee")
|
||||
return False
|
||||
with verrou:
|
||||
bilan["echecs"] += 1
|
||||
dire(f" ❌ Échec sur {nom}")
|
||||
@@ -171,7 +361,7 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||||
|
||||
t0 = time.time()
|
||||
with ThreadPoolExecutor(jobs) as pool:
|
||||
resultats = list(pool.map(un_rush, videos))
|
||||
resultats = list(pool.map(un_rush, enumerate(videos, 1)))
|
||||
duree = int(time.time() - t0)
|
||||
dire()
|
||||
if bilan["faits"]:
|
||||
@@ -182,4 +372,6 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||||
dire(f"ℹ️ Aucun nouvel encodage ({bilan['sautes']} déjà faite(s))")
|
||||
if bilan["echecs"]:
|
||||
dire(f"⚠️ {bilan['echecs']} échec(s)")
|
||||
if ARRET.is_set():
|
||||
dire("⏹ Compression arrêtée (les vidéos finies sont gardées).")
|
||||
return all(resultats)
|
||||
@@ -121,3 +121,28 @@ def test_deuxieme_passage_ne_refait_rien(site_test, capsys):
|
||||
sortie = capsys.readouterr().out
|
||||
assert "Aucun nouvel encodage" in sortie
|
||||
assert "0 réduite(s)" in sortie
|
||||
|
||||
|
||||
def test_reencodage_selon_reglages(site_test):
|
||||
"""Les réglages relus dans les vidéos ; seule la version dont les réglages
|
||||
ont changé est refaite, et rien quand ils n'ont pas changé."""
|
||||
from rtg import video
|
||||
racine, voyage = site_test
|
||||
cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
|
||||
a = sources.analyser(str(voyage), cfg)
|
||||
assert video.compresser(str(voyage), cfg, a)
|
||||
hd, sd = video.sorties(str(voyage), a.videos[0])
|
||||
assert video.reglages_fichier(hd) == ("HEVC", 26.0)
|
||||
assert video.reglages_fichier(sd) == ("H.264", 25.0)
|
||||
dates = (os.path.getmtime(hd), os.path.getmtime(sd))
|
||||
|
||||
config.modifier_toml(str(voyage / "voyage.toml"), {"video": {"crf_hevc": 30}})
|
||||
cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
|
||||
assert video.compresser(str(voyage), cfg, a, refaire="differentes")
|
||||
assert video.reglages_fichier(hd) == ("HEVC", 30.0)
|
||||
assert os.path.getmtime(sd) == dates[1] # 720p inchangé
|
||||
dates = (os.path.getmtime(hd), os.path.getmtime(sd))
|
||||
assert video.compresser(str(voyage), cfg, a, refaire="differentes")
|
||||
assert (os.path.getmtime(hd), os.path.getmtime(sd)) == dates # rien à refaire
|
||||
bilan = video.bilan_compression(str(voyage))
|
||||
assert bilan["1080p"]["reglages"] == {("HEVC", 30.0): 1}
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Interface : jours imposés aux traces sans heures, suggestion d'après les
|
||||
photos, et quelques échanges avec le serveur local."""
|
||||
import os
|
||||
import threading
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import date
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from rtg import config, sources
|
||||
from rtg import acces, corrections
|
||||
from rtg.interface import serveur
|
||||
from rtg.interface.securite import Gardien
|
||||
from test_chaine import _photo, pytestmark # noqa: F401 (mêmes prérequis)
|
||||
|
||||
|
||||
def _etape(chemin, lat0, lon0):
|
||||
pts = "".join(f'<trkpt lat="{lat0 + i * 0.01:.5f}" lon="{lon0 + i * 0.012:.5f}"></trkpt>' for i in range(50))
|
||||
with open(chemin, "w") as f:
|
||||
f.write(f'<?xml version="1.0"?><gpx version="1.1" creator="68degres.fr" '
|
||||
f'xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>{pts}</trkseg></trk></gpx>')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voyage_sans_heures(tmp_path):
|
||||
racine = tmp_path / "site"
|
||||
voyage = racine / "Ecosse-2027"
|
||||
gpx, photos = tmp_path / "gpx", tmp_path / "photos"
|
||||
for d in (voyage, gpx, photos):
|
||||
d.mkdir(parents=True)
|
||||
_etape(gpx / "Etape 1.gpx", 55.9, 3.2)
|
||||
_etape(gpx / "Etape 2.gpx", 56.7, 2.24)
|
||||
_photo(str(photos / "P1.jpg"), "2027:06:11 10:00:00", gps=(56.75, 2.30))
|
||||
config.ecrire_toml(str(racine / "site.toml"), {"titre": "Test"})
|
||||
config.ecrire_toml(str(voyage / "voyage.toml"), {
|
||||
"titre": "Écosse", "sources": {"traces": [str(gpx)], "videos": [], "photos": [str(photos)]}})
|
||||
return racine, voyage
|
||||
|
||||
|
||||
def _cfg(racine, voyage):
|
||||
return config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
|
||||
|
||||
|
||||
def test_jour_impose_et_suggestion(voyage_sans_heures):
|
||||
racine, voyage = voyage_sans_heures
|
||||
a = sources.analyser(str(voyage), _cfg(racine, voyage))
|
||||
assert not a.journees and len(a.non_datees) == 2
|
||||
medias = sources.positions_medias(str(voyage), a)
|
||||
par_nom = {os.path.basename(f): p for f, p in a.non_datees.items()}
|
||||
assert sources.suggerer_jour(par_nom["Etape 2.gpx"], medias) == (date(2027, 6, 11), 1)
|
||||
assert sources.suggerer_jour(par_nom["Etape 1.gpx"], medias) == (None, 0)
|
||||
|
||||
config.modifier_toml(str(voyage / "voyage.toml"), {"dates_traces": {"Etape 1.gpx": "2027-06-10"}})
|
||||
a = sources.analyser(str(voyage), _cfg(racine, voyage))
|
||||
assert sorted(a.journees) == [date(2027, 6, 10)]
|
||||
assert [os.path.basename(f) for f in a.non_datees] == ["Etape 2.gpx"]
|
||||
# Le titre et les sources ont survécu à la réécriture du fichier
|
||||
assert config.lire_toml(str(voyage / "voyage.toml"))["titre"] == "Écosse"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(voyage_sans_heures):
|
||||
racine, voyage = voyage_sans_heures
|
||||
app = serveur.Application(str(racine))
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", 0), None)
|
||||
port = srv.server_address[1]
|
||||
gardien = Gardien(port)
|
||||
srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port, gardien)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
sid = gardien.ouvrir_session()
|
||||
|
||||
def appel(chemin, donnees=None, htmx=True, session=True):
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{port}{chemin}",
|
||||
data=urllib.parse.urlencode(donnees).encode() if donnees else None)
|
||||
if htmx:
|
||||
req.add_header("HX-Request", "true")
|
||||
if session:
|
||||
req.add_header("Cookie", f"{gardien.cookie}={sid}")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return r.status, r.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
appel.port, appel.gardien = port, gardien
|
||||
yield appel, racine, voyage
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
def test_serveur(client):
|
||||
appel, racine, voyage = client
|
||||
code, html = appel("/")
|
||||
assert code == 200 and "Écosse" in html
|
||||
code, html = appel("/voyage/analyse?v=Ecosse-2027")
|
||||
assert "2 traces à rattacher" in html and "11 juin 2027" in html
|
||||
# Répartir : un jour par trace dans l'ordre des noms
|
||||
code, html = appel("/voyage/dates-suite", {"v": "Ecosse-2027", "debut": "2027-06-10"})
|
||||
assert "2 journées" in html
|
||||
assert config.lire_toml(str(voyage / "voyage.toml"))["dates_traces"] == {
|
||||
"Etape 1.gpx": "2027-06-10", "Etape 2.gpx": "2027-06-11"}
|
||||
# Requête de modification sans l'en-tête htmx : refusée
|
||||
code, _ = appel("/voyage/date", {"v": "Ecosse-2027", "fichier": "Etape 1.gpx", "jour": ""}, htmx=False)
|
||||
assert code == 403
|
||||
# Voyage hors du site : refusé
|
||||
code, html = appel("/voyage/analyse?v=../../etc")
|
||||
assert "introuvable" in html.lower()
|
||||
|
||||
|
||||
class _SansRedirection(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def test_acces(client, tmp_path, monkeypatch):
|
||||
appel, racine, voyage = client
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "config"))
|
||||
base = f"http://127.0.0.1:{appel.port}"
|
||||
ouvreur = urllib.request.build_opener(_SansRedirection)
|
||||
|
||||
def brut(chemin):
|
||||
try:
|
||||
return ouvreur.open(base + chemin)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e
|
||||
# Sans session : renvoyé vers la connexion, y compris pour les fichiers du site
|
||||
for chemin in ("/", "/site/site.toml", "/voyage?v=Ecosse-2027"):
|
||||
r = brut(chemin)
|
||||
assert r.status == 303 and r.headers["Location"] == "/connexion", chemin
|
||||
assert appel("/", session=False)[0] == 200 # htmx : HX-Redirect, corps vide
|
||||
# La clé d'accès ouvre une session (cookie HttpOnly), une fausse clé non
|
||||
r = brut("/?jeton=" + appel.gardien.jeton)
|
||||
assert r.status == 303 and "HttpOnly" in r.headers["Set-Cookie"] and "SameSite=Strict" in r.headers["Set-Cookie"]
|
||||
assert brut("/?jeton=faux").headers["Location"] == "/connexion"
|
||||
# Mot de passe : faux, juste, puis blocage après 5 échecs
|
||||
acces.definir("un mot de passe solide")
|
||||
code, html = appel("/connexion", {"mot_de_passe": "non"}, session=False)
|
||||
assert "incorrect" in html
|
||||
ok, _ = appel.gardien.essayer("10.0.0.1", "un mot de passe solide")
|
||||
assert ok
|
||||
for _ in range(5):
|
||||
appel.gardien.essayer("10.0.0.2", "faux")
|
||||
ok, message = appel.gardien.essayer("10.0.0.2", "un mot de passe solide")
|
||||
assert not ok and "Trop d'essais" in message
|
||||
# En-têtes de sécurité
|
||||
r = urllib.request.urlopen(urllib.request.Request(base + "/connexion"))
|
||||
assert r.headers["X-Frame-Options"] == "DENY" and "frame-ancestors 'none'" in r.headers["Content-Security-Policy"]
|
||||
|
||||
|
||||
def test_corrections(client):
|
||||
appel, racine, voyage = client
|
||||
# Médias : une photo localisée, une sans position
|
||||
with open(voyage / "export_photos.csv", "w") as f:
|
||||
f.write('"P1.jpg",2027:06:11 10:00:00,56.75,2.30,\n"P2.jpg",2027:06:11 11:00:00,,,\n')
|
||||
code, texte = appel("/voyage/position", {"v": "Ecosse-2027", "fichier": "P2.jpg", "lat": "56.8", "lon": "2.36"})
|
||||
assert '"deplace": true' in texte
|
||||
cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
|
||||
assert corrections.positions(cfg) == {"P2.jpg": (56.8, 2.36)}
|
||||
# Zone masquée du voyage, puis zone privée du site
|
||||
appel("/voyage/masque", {"v": "Ecosse-2027", "action": "ajouter", "lat": "56.7", "lon": "2.24", "rayon": "3000"})
|
||||
appel("/voyage/masque", {"v": "Ecosse-2027", "action": "ajouter", "portee": "site",
|
||||
"lat": "48.8", "lon": "2.1", "rayon": "500"})
|
||||
cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
|
||||
assert corrections.masques(cfg) == [(56.7, 2.24, 3000.0), (48.8, 2.1, 500.0)]
|
||||
# Génération : la photo placée apparaît, le début d'Etape 2 est retiré
|
||||
config.modifier_toml(str(voyage / "voyage.toml"), {"dates_traces": {"Etape 1.gpx": "2027-06-10",
|
||||
"Etape 2.gpx": "2027-06-11"}})
|
||||
from rtg import pages, sources
|
||||
cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
|
||||
sources.ranger(str(voyage), cfg, sources.analyser(str(voyage), cfg))
|
||||
v = pages.Voyage(str(voyage), cfg)
|
||||
assert set(v.df_p["Fichier"]) == {"P1.jpg", "P2.jpg"}
|
||||
v.lire_traces()
|
||||
assert v.points_masques > 0
|
||||
for pistes in v.traces.values():
|
||||
for piste, pts in pistes:
|
||||
assert all(corrections.distance_m(la, lo, 56.7, 2.24) > 3000 for la, lo in pts)
|
||||
|
||||
|
||||
def test_derriere_le_serveur_web(voyage_sans_heures, tmp_path, monkeypatch):
|
||||
"""--proxy --prefixe /admin : adresses préfixées, mot de passe, cookie Secure."""
|
||||
racine, voyage = voyage_sans_heures
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "config"))
|
||||
acces.definir("un mot de passe solide")
|
||||
app = serveur.Application(str(racine), "/admin")
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", 0), None)
|
||||
port = srv.server_address[1]
|
||||
gardien = Gardien(port, proxy=True, prefixe=app.prefixe)
|
||||
srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port, gardien)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
ouvreur = urllib.request.build_opener(_SansRedirection)
|
||||
entetes = {"Host": "roadtrip.example", "X-Forwarded-For": "203.0.113.7", "X-Forwarded-Proto": "https"}
|
||||
|
||||
def requete(chemin, donnees=None, extra=None):
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{port}{chemin}", headers=dict(entetes, **(extra or {})),
|
||||
data=urllib.parse.urlencode(donnees).encode() if donnees else None)
|
||||
try:
|
||||
return ouvreur.open(req)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e
|
||||
try:
|
||||
r = requete("/admin/")
|
||||
assert r.status == 303 and r.headers["Location"] == "/admin/connexion"
|
||||
assert requete("/").status == 404 # hors du préfixe
|
||||
assert requete("/admin/connexion").status == 200
|
||||
r = requete("/admin/connexion", {"mot_de_passe": "un mot de passe solide"}, {"HX-Request": "true"})
|
||||
cookie = r.headers["Set-Cookie"]
|
||||
assert r.headers["HX-Redirect"] == "/admin/" and "Path=/admin/" in cookie and "Secure" in cookie
|
||||
sid = cookie.split(";")[0]
|
||||
r = requete("/admin/", extra={"Cookie": sid})
|
||||
html = r.read().decode()
|
||||
assert r.status == 200 and 'href="/admin/nouveau"' in html and 'href="/"' in html # Voir le site
|
||||
# Les échecs sont comptés par visiteur (X-Forwarded-For), pas pour le serveur web
|
||||
for _ in range(5):
|
||||
requete("/admin/connexion", {"mot_de_passe": "faux"}, {"HX-Request": "true"})
|
||||
assert gardien.attente("203.0.113.7") > 0 and gardien.attente("127.0.0.1") == 0
|
||||
finally:
|
||||
srv.shutdown()
|
||||
serveur.ENV.globals["base"] = ""
|
||||
|
||||
|
||||
def test_aide(client):
|
||||
appel, racine, voyage = client
|
||||
code, html = appel("/aide")
|
||||
assert code == 200 and "Prise en main" in html and 'href="/aide/generer"' in html
|
||||
code, html = appel("/aide/generer?panneau=1")
|
||||
assert "<h1>Générer</h1>" in html and "CRF" in html
|
||||
code, html = appel("/aide/index?panneau=1")
|
||||
assert 'hx-get="/aide/sources?panneau=1"' in html # liens internes au panneau
|
||||
code, html = appel("/aide/nexistepas")
|
||||
assert "introuvable" in html
|
||||
# Chaque lien entre pages d'aide mène à une page qui existe
|
||||
import glob, re as _re
|
||||
dossier = os.path.join(os.path.dirname(serveur.__file__), "aide")
|
||||
sujets = {os.path.basename(f)[:-3] for f in glob.glob(os.path.join(dossier, "*.md"))}
|
||||
for f in glob.glob(os.path.join(dossier, "*.md")):
|
||||
for cible in _re.findall(r"\]\(([a-z]+)\.md\)", open(f, encoding="utf-8").read()):
|
||||
assert cible in sujets, (f, cible)
|
||||
Reference in new issue
Block a user