Administration native sur le Mac, réencodage sélectif et aide intégrée

- admin.sh : RTG lancé directement sur le Mac (plus de conteneur), nginx
  joint l'interface par host.docker.internal:8765 ; variante conteneur
  conservée (admin-docker.sh, docker-compose-conteneur.yml)
- Compression : réglages lus dans les fichiers (CRF, codec), refaire
  seulement le 1080p ou le 720p, seulement si les réglages ont changé ;
  réglages vidéo propres à chaque voyage ; « Compression actuelle » affichée
- Plus d'arrêt automatique après 2 h d'inactivité (--arret-inactif, 0 par défaut) ;
  arrêt propre qui attend la fin des encodages en cours
- Aide contextuelle dans l'interface (rtg/interface/aide) ; README centré
  sur l'installation et la mise en place
- Cœurs attribués au conteneur détectés (cgroup) et signalés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
FabriceandClaude Opus 5.5 committed 2026-10-19 05:32:05 +00:00
1 parent b54ae69b19
commit ada3d76f50
38 files changed
+1067 -264

No files matched your search

+22
View File
@@ -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).
+41
View File
@@ -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 ».
+22
View File
@@ -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.
+49
View File
@@ -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 ».
+25
View File
@@ -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
+27
View File
@@ -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)).
+19
View File
@@ -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)).
+38
View File
@@ -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.
+24
View File
@@ -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.
+27
View File
@@ -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.
+2
View File
@@ -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>
+55
View File
@@ -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>
+15
View File
@@ -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 %}
+5
View File
@@ -15,6 +15,7 @@
<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>
@@ -25,6 +26,10 @@
</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 %}
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block titre %}Nouveau voyage{% endblock %}
{% block contenu %}
<h1>Nouveau voyage</h1>
<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
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block titre %}Réglages{% endblock %}
{% block contenu %}
<h1>Réglages du site</h1>
<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>
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block titre %}Traitement{% endblock %}
{% block contenu %}
<h1>Traitement</h1>
<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 %}
+18 -2
View File
@@ -23,12 +23,14 @@
</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
@@ -64,6 +66,7 @@
</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>
@@ -71,6 +74,7 @@
</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>
@@ -90,11 +94,23 @@
</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 }}">
<label class="choix"><input type="checkbox" name="sans_compression"> Sans encoder les vidéos <span class="aide">(photos, positions et pages seulement)</span></label>
<label class="choix"><input type="checkbox" name="force"> Tout refaire, même ce qui est déjà à jour</label>
<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>
+1 -1
View File
@@ -10,7 +10,7 @@
{% endif %}
<div class="entete-page">
<h1>Mes voyages <span class="compte">{{ nb }}</span></h1>
<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">
+103 -11
View File
@@ -36,8 +36,8 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit
from jinja2 import Environment, FileSystemLoader, select_autoescape
from rtg import __version__, acces, accueil, corrections, site, sources, video
from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, modifier_toml, parametres_video,
reglages_site, reglages_voyage)
from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, lire_toml, modifier_toml,
parametres_video, reglages_site, reglages_voyage)
from rtg.interface import taches
from rtg.interface.securite import Gardien
from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers, taille_lisible
@@ -49,6 +49,7 @@ MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août
JOURS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
COULEURS = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#46a0b4", "#f032e6",
"#7a9a01", "#c47f00", "#008080", "#9a6324", "#800000", "#2f4b7c", "#808000"]
PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower"]
CLES_SOURCES = {"traces": ("Traces GPS", (".gpx",), "traces GPS"),
"videos": ("Vidéos", EXT_VIDEOS, "vidéos"),
"photos": ("Photos", EXT_PHOTOS, "photos")}
@@ -74,6 +75,7 @@ ENV.filters["date_longue"] = _date_longue
ENV.filters["date_courte"] = lambda d: f"{d.day} {MOIS[d.month - 1][:4].rstrip('.')}" if d else ""
ENV.filters["taille"] = taille_lisible
ENV.filters["url"] = lambda s: quote(str(s), safe="")
ENV.filters["crf"] = lambda x: f"{x:g}"
ENV.tests["commence_par"] = lambda s, debut: str(s).startswith(debut)
ENV.globals.update(version=__version__, periode=_periode, qualites=QUALITES,
noms_qualites={"econome": "économe", "equilibree": "équilibrée", "haute": "haute"})
@@ -124,12 +126,15 @@ class Application:
("POST", r"/voyage/dates-suite", self.voyage_dates_suite),
("GET", r"/voyage/infos-utiles", self.voyage_infos_utiles),
("GET", r"/voyage/carte.json", self.voyage_carte),
("GET", r"/voyage/compression", self.voyage_compression),
("POST", r"/voyage/video", self.voyage_video),
("POST", r"/voyage/position", self.voyage_position),
("POST", r"/voyage/masque", self.voyage_masque),
("GET", r"/dossiers", self.dossiers),
("GET", r"/reglages", self.reglages),
("POST", r"/reglages", self.reglages_enregistrer),
("GET", r"/systeme", self.systeme),
("GET", r"/aide(?:/[a-z]+)?", self.aide),
("POST", r"/tache/lancer", self.tache_lancer),
("POST", r"/tache/arreter", self.tache_arreter),
("GET", r"/tache", self.tache),
@@ -361,6 +366,41 @@ class Application:
infos["a_encoder_octets"] = sum(os.path.getsize(f) for f in reste if os.path.exists(f))
return page("_infos_utiles.html", i=infos, cfg=cfg)
# --- Compression des vidéos
def voyage_compression(self, r, message=""):
rel = r.q("v") or r.f("v")
chemin = self.chemin_voyage(rel)
cfg = self.cfg_voyage(chemin)
site_cfg = reglages_site(self.racine)
propre = lire_toml(os.path.join(chemin, VOYAGE_TOML)).get("video", {})
p = parametres_video(cfg)
return page("_compression.html", v={"chemin": rel}, bilan=video.bilan_compression(chemin),
p=p, cibles={"1080p": video.cible("hd", p), "720p": video.cible("sd", p)},
cfg=cfg, site=site_cfg, site_p=parametres_video(site_cfg), propre=propre,
presets=PRESETS, message=message)
def voyage_video(self, r):
chemin = self.chemin_voyage(r.f("v"))
changements = {}
q = r.f("qualite")
changements["qualite"] = q if q in QUALITES else None
pr = r.f("preset")
changements["preset"] = pr if pr in PRESETS else None
for k in ("crf_hevc", "crf_h264"):
val = r.f(k).strip()
if not val:
changements[k] = None
continue
try:
n = int(val)
except ValueError:
raise Erreur(400, f"CRF invalide : {val}")
if not 0 <= n <= 51:
raise Erreur(400, "Le CRF va de 0 (sans perte) à 51")
changements[k] = n
self.toml_voyage(chemin, {"video": changements})
return self.voyage_compression(r, message="✓ Réglages vidéo du voyage enregistrés.")
# --- Carte et corrections
def _media_json(self, chemin, rel, m):
nom = m["fichier"]
@@ -447,6 +487,18 @@ class Application:
self.toml_voyage(chemin, {"corrections": {"masques": liste}})
return json_rep(self._masques_json(self.cfg_voyage(chemin)))
# --- Aide (pages Markdown de aide/, aussi lisibles sur git)
def aide(self, r):
sujet = r.chemin.rstrip("/").rsplit("/", 1)[-1]
sujet = "index" if sujet == "aide" else sujet
panneau = r.q("panneau") == "1"
sujets = _sujets_aide()
if sujet not in sujets:
raise Erreur(404, "Page d'aide introuvable")
html = _aide_html(sujet, self.prefixe, panneau)
modele = "_aide.html" if panneau else "aide.html"
return page(modele, **self.contexte(sujet=sujet, sujets=sujets, contenu=html))
# --- Navigateur de dossiers
def dossiers(self, r):
chemin = r.q("chemin") or os.path.expanduser("~")
@@ -527,23 +579,28 @@ class Application:
quoi, rel = r.f("quoi"), r.f("v")
compression = r.f("sans_compression") != "on"
force = r.f("force") == "on"
paliers = {"1080p": ("hd",), "720p": ("sd",)}.get(r.f("versions"), ("hd", "sd"))
refaire = r.f("refaire") if r.f("refaire") in video.REFAIRE else "manquantes"
nom_versions = {"1080p": " (1080p seulement)", "720p": " (720p seulement)"}.get(r.f("versions"), "")
racine = self.racine
if quoi in ("generer", "compresser"):
chemin = self.chemin_voyage(rel)
if quoi == "generer":
libelle = f"Génération de {rel}" + ("" if compression else " (sans les vidéos)")
libelle = f"Génération de {rel}" + (nom_versions if compression else " (sans les vidéos)")
def fn(suivi):
return site.generer(racine, [chemin], compression=compression, force=force, suivi=suivi)
return site.generer(racine, [chemin], compression=compression, force=force, suivi=suivi,
paliers=paliers, refaire=refaire, force_videos=False)
elif quoi == "compresser":
libelle = f"Compression des vidéos de {rel}"
libelle = f"Compression des vidéos de {rel}{nom_versions}"
def fn(suivi):
cfg = reglages_voyage(chemin, reglages_site(racine))
suivi({"etape": "Vidéos", "voyage": rel})
return video.compresser(chemin, cfg, sources.analyser(chemin, cfg), force=force,
return video.compresser(chemin, cfg, sources.analyser(chemin, cfg), force=False,
progression=lambda n, e, p, rg: suivi(
{"video": n, "etat": e, "pct": p, "rang": rg}))
{"video": n, "etat": e, "pct": p, "rang": rg}),
paliers=paliers, refaire=refaire)
elif quoi == "tout":
libelle = "Génération de tout le site" + ("" if compression else " (sans les vidéos)")
@@ -603,6 +660,36 @@ def _couches(a, jours, maxi=1500):
return couches
AIDE = os.path.join(ICI, "aide")
def _sujets_aide():
"""{sujet: titre} des pages d'aide, dans l'ordre du sommaire (index.md)."""
titres = {}
for nom in os.listdir(AIDE):
if nom.endswith(".md"):
with open(os.path.join(AIDE, nom), encoding="utf-8") as f:
titres[nom[:-3]] = f.readline().lstrip("# ").strip()
with open(os.path.join(AIDE, "index.md"), encoding="utf-8") as f:
ordre = ["index"] + [s for s in re.findall(r"\]\(([a-z]+)\.md\)", f.read())]
ordre += sorted(set(titres) - set(ordre))
return {s: titres[s] for s in dict.fromkeys(ordre) if s in titres}
def _aide_html(sujet, base, panneau):
import markdown2
with open(os.path.join(AIDE, sujet + ".md"), encoding="utf-8") as f:
html = markdown2.markdown(f.read(), extras=["tables", "fenced-code-blocks", "cuddled-lists"])
def lien(m):
cible = m.group(1)
if panneau:
return (f'href="{base}/aide/{cible}" hx-get="{base}/aide/{cible}?panneau=1" '
f'hx-target="#aide-contenu"')
return f'href="{base}/aide/{cible}"'
return re.sub(r'href="([a-z]+)\.md"', lien, html)
def _valeur(d, k, defaut):
v = d.get(k, defaut)
if isinstance(v, list):
@@ -611,8 +698,8 @@ def _valeur(d, k, defaut):
class Requete:
def __init__(self, query, form):
self.query, self.form = query, form
def __init__(self, query, form, chemin=""):
self.query, self.form, self.chemin = query, form, chemin
def q(self, k, defaut=""):
return _valeur(self.query, k, defaut)
@@ -727,7 +814,7 @@ def creer_gestionnaire(app, port, gardien=None):
"Set-Cookie": gardien.entete_cookie("", self.https(), effacer=True)}), tete)
if methode == "GET" and chemin.startswith("/site/"):
return self.fichier(app.racine, chemin[len("/site/"):], tete)
req = Requete(query, form)
req = Requete(query, form, chemin)
for m, motif, fn in app.routes:
if m == methode and motif.match(chemin):
rep = fn(req)
@@ -940,7 +1027,12 @@ def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=N
try:
serveur.serve_forever()
except KeyboardInterrupt:
taches.arreter()
# Un encodage en cours : on arrête proprement ffmpeg (sinon il continuerait
# seul et laisserait des fichiers .tmp-).
if taches.arreter():
fin = time.time() + 15
while taches.COURANTE.en_cours and time.time() < fin:
time.sleep(0.2)
print("\nInterface arrêtée.")
finally:
serveur.server_close()
+34
View File
@@ -185,3 +185,37 @@ progress.global { height: 4px; opacity: .7; }
.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; } }
+23
View File
@@ -83,6 +83,29 @@
});
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") {