From 2d4b0d5a5f950db565fb21646ca2699041cddf45 Mon Sep 17 00:00:00 2001 From: bach Date: Thu, 8 Oct 2026 16:08:18 +0200 Subject: [PATCH] =?UTF-8?q?8x32=20PI=20:=20interface=20web,=20matrice=20ve?= =?UTF-8?q?rticale,=20compatibilit=C3=A9=20Raspbian=2011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Interface web (serveur_web.py, Flask, gabarits dans web/) : dépôt de GIF, PNG animés et séquences de PNG, aperçus animés, ordre de lecture, activer / désactiver, corbeille, « jouer maintenant » et « suivante ». Service systemd vitrine-web, activé par installer.sh. - Lecteur : réagit aux demandes de la page web (post-it dans /run/vitrine-pixels), écrit l'animation en cours, éteint la matrice proprement sur SIGTERM. - Matrice posée debout : réglage ROTATION (90 = image 8 × 32) dans matrice.py ; images couchées tournées automatiquement ; exemples, animations codées, aperçus et page web adaptés au format vertical. - Compatibilité Raspbian 11 Bullseye : partition /boot, Pillow 8.1, Flask 1.1. - Doc : nouveau guide INTERFACE_WEB.md ; README, GUIDE_KRITA, MODE_VITRINE, CODER_EN_PYTHON mis à jour (format 8 × 32, adresses Wi-Fi / câble du Pi). Co-Authored-By: Claude Opus 5.5 --- 8x32 PI/CODER_EN_PYTHON.md | 21 +- 8x32 PI/GUIDE_KRITA.md | 77 ++- 8x32 PI/INTERFACE_WEB.md | 212 +++++++++ 8x32 PI/MODE_VITRINE.md | 9 + 8x32 PI/README.md | 33 +- 8x32 PI/animations/01_coeur_qui_bat.gif | Bin 279 -> 299 bytes 8x32 PI/animations/02_texte_defilant.gif | Bin 12377 -> 13463 bytes 8x32 PI/animations/03_vague/vague_0000.png | Bin 171 -> 149 bytes 8x32 PI/animations/03_vague/vague_0001.png | Bin 176 -> 151 bytes 8x32 PI/animations/03_vague/vague_0002.png | Bin 174 -> 147 bytes 8x32 PI/animations/03_vague/vague_0003.png | Bin 174 -> 155 bytes 8x32 PI/animations/03_vague/vague_0004.png | Bin 171 -> 155 bytes 8x32 PI/animations/03_vague/vague_0005.png | Bin 181 -> 156 bytes 8x32 PI/animations/03_vague/vague_0006.png | Bin 166 -> 154 bytes 8x32 PI/animations/03_vague/vague_0007.png | Bin 170 -> 154 bytes 8x32 PI/animations/03_vague/vague_0008.png | Bin 165 -> 152 bytes 8x32 PI/animations/03_vague/vague_0009.png | Bin 163 -> 153 bytes 8x32 PI/animations/03_vague/vague_0010.png | Bin 161 -> 152 bytes 8x32 PI/animations/03_vague/vague_0011.png | Bin 166 -> 152 bytes 8x32 PI/animations/03_vague/vague_0012.png | Bin 160 -> 138 bytes 8x32 PI/animations/03_vague/vague_0013.png | Bin 167 -> 144 bytes 8x32 PI/animations/03_vague/vague_0014.png | Bin 168 -> 139 bytes 8x32 PI/animations/03_vague/vague_0015.png | Bin 172 -> 135 bytes 8x32 PI/animations/03_vague/vague_0016.png | Bin 165 -> 135 bytes 8x32 PI/animations/03_vague/vague_0017.png | Bin 171 -> 139 bytes 8x32 PI/animations/03_vague/vague_0018.png | Bin 169 -> 141 bytes 8x32 PI/animations/03_vague/vague_0019.png | Bin 176 -> 144 bytes 8x32 PI/animations/03_vague/vague_0020.png | Bin 177 -> 145 bytes 8x32 PI/animations/03_vague/vague_0021.png | Bin 166 -> 148 bytes 8x32 PI/animations/03_vague/vague_0022.png | Bin 169 -> 145 bytes 8x32 PI/animations/03_vague/vague_0023.png | Bin 169 -> 135 bytes 8x32 PI/animations/03_vague/vague_0024.png | Bin 170 -> 144 bytes 8x32 PI/animations/03_vague/vague_0025.png | Bin 175 -> 141 bytes 8x32 PI/animations/03_vague/vague_0026.png | Bin 164 -> 154 bytes 8x32 PI/animations/03_vague/vague_0027.png | Bin 168 -> 146 bytes 8x32 PI/animations/03_vague/vague_0028.png | Bin 167 -> 142 bytes 8x32 PI/animations/03_vague/vague_0029.png | Bin 168 -> 149 bytes 8x32 PI/animations/03_vague/vague_0030.png | Bin 167 -> 150 bytes 8x32 PI/animations/03_vague/vague_0031.png | Bin 165 -> 153 bytes 8x32 PI/animations/03_vague/vague_0032.png | Bin 170 -> 145 bytes 8x32 PI/animations/03_vague/vague_0033.png | Bin 170 -> 147 bytes 8x32 PI/animations/03_vague/vague_0034.png | Bin 164 -> 143 bytes 8x32 PI/animations/03_vague/vague_0035.png | Bin 169 -> 138 bytes 8x32 PI/animations/03_vague/vague_0036.png | Bin 166 -> 139 bytes 8x32 PI/animations/03_vague/vague_0037.png | Bin 173 -> 151 bytes 8x32 PI/animations/03_vague/vague_0038.png | Bin 177 -> 144 bytes 8x32 PI/animations/03_vague/vague_0039.png | Bin 177 -> 154 bytes 8x32 PI/animations/04_balle.png | Bin 3753 -> 3671 bytes 8x32 PI/installation/installer.sh | 26 +- 8x32 PI/installation/vitrine-web.service | 31 ++ 8x32 PI/lecteur.py | 172 ++++++- 8x32 PI/matrice.py | 84 +++- 8x32 PI/mes_animations.py | 52 +- 8x32 PI/outils/generer_exemples.py | 77 +-- 8x32 PI/outils/schemas.py | 2 +- 8x32 PI/outils/verifier_animation.py | 22 +- 8x32 PI/schemas/chaine_krita_matrice.svg | 4 +- 8x32 PI/serveur_web.py | 527 +++++++++++++++++++++ 8x32 PI/test_matrice.py | 3 +- 8x32 PI/web/infos.html | 12 + 8x32 PI/web/page.html | 208 ++++++++ 8x32 PI/web/style.css | 172 +++++++ CLAUDE.md | 4 +- 63 files changed, 1592 insertions(+), 156 deletions(-) create mode 100644 8x32 PI/INTERFACE_WEB.md create mode 100644 8x32 PI/installation/vitrine-web.service create mode 100644 8x32 PI/serveur_web.py create mode 100644 8x32 PI/web/infos.html create mode 100644 8x32 PI/web/page.html create mode 100644 8x32 PI/web/style.css diff --git a/8x32 PI/CODER_EN_PYTHON.md b/8x32 PI/CODER_EN_PYTHON.md index 56bd7de..983fc82 100644 --- a/8x32 PI/CODER_EN_PYTHON.md +++ b/8x32 PI/CODER_EN_PYTHON.md @@ -100,20 +100,23 @@ grâce à la ligne `from matrice import *`. | Outil | Effet | |---|---| -| `pixel(x, y, couleur)` | colore le pixel de la colonne `x` (0 à 31) et de la ligne `y` (0 à 7). En dehors : ignoré | +| `pixel(x, y, couleur)` | colore le pixel de la colonne `x` (0 à 7) et de la ligne `y` (0 à 31), matrice debout. En dehors : ignoré | | `lire_pixel(x, y)` | renvoie la couleur actuelle d'un pixel | | `effacer()` | éteint toute la toile | | `effacer(couleur)` | remplit toute la toile d'une couleur | | `assombrir(25)` | assombrit toute la toile de 25 % (pour les traînées) | | `montrer()` | **affiche** la toile. Rien n'apparaît tant qu'on ne l'appelle pas ! | -| `montrer(image)` | affiche une image Pillow de 32 × 8 (voir partie 5) | +| `montrer(image)` | affiche une image Pillow de 8 × 32 (voir partie 5) | | `attendre(100)` | attend 100 millisecondes | | `teinte(t)` | couleur de l'arc-en-ciel : 0 rouge, 32 orange, 64 jaune, 96 vert, 128 cyan, 160 bleu, 192 violet, 224 rose | | `teinte(t, saturation, valeur)` | idem, avec saturation (0 = blanc) et valeur (0 = noir), de 0 à 255 | **Couleurs nommées** : `NOIR`, `BLANC`, `ROUGE`, `ORANGE`, `JAUNE`, `VERT`, `CYAN`, `BLEU`, `VIOLET`, `MAGENTA`, `ROSE`. -**Dimensions** : `LARGEUR` (32), `HAUTEUR` (8). +**Dimensions** : `LARGEUR` (8) et `HAUTEUR` (32) quand la matrice est +debout (`ROTATION = 90` dans `matrice.py`) ; 32 et 8 quand elle est couchée. +Écrivez vos animations avec `LARGEUR` et `HAUTEUR` plutôt qu'avec des +nombres : elles marcheront dans les deux sens. Le point `(0, 0)` est **en haut à gauche**, comme dans Krita. @@ -181,19 +184,19 @@ utilise Pillow, la bibliothèque d'images : from PIL import Image def anim_logo_qui_defile(): - # Remplacez par le chemin d'une image de 32 × 8 exportée depuis Krita. + # Remplacez par le chemin d'une image de 8 × 32 exportée depuis Krita. logo = Image.open("animations/logo.png").convert("RGB") for i in range(100): effacer() - # On « colle » le logo deux fois, côte à côte, décalé de i pixels : - # ce qui sort à droite rentre à gauche (défilement en boucle). - toile.paste(logo, (i % LARGEUR - LARGEUR, 0)) - toile.paste(logo, (i % LARGEUR, 0)) + # On « colle » le logo deux fois, l'un au-dessus de l'autre, décalé + # de i pixels : ce qui sort en bas rentre en haut (défilement en boucle). + toile.paste(logo, (0, i % HAUTEUR - HAUTEUR)) + toile.paste(logo, (0, i % HAUTEUR)) montrer() attendre(60) ``` -Ici `toile` est l'image de 32 × 8 sur laquelle dessinent `pixel()` et +Ici `toile` est l'image de 8 × 32 sur laquelle dessinent `pixel()` et `effacer()`. Tout ce que sait faire Pillow (coller, retourner, changer les couleurs, écrire du texte…) fonctionne dessus. Documentation : . diff --git a/8x32 PI/GUIDE_KRITA.md b/8x32 PI/GUIDE_KRITA.md index ea43de4..9a5ab52 100644 --- a/8x32 PI/GUIDE_KRITA.md +++ b/8x32 PI/GUIDE_KRITA.md @@ -6,7 +6,8 @@ le fichier dans le dossier des animations. ![De Krita à la matrice](schemas/chaine_krita_matrice.svg) -> **En une phrase** : un document de **32 × 8 pixels**, un pixel = une LED, +> **En une phrase** : un document **vertical** de **8 × 32 pixels** (8 de +> large, 32 de haut), un pixel = une LED, > des couleurs **franches**, un fond **noir**, et un export en **séquence de > PNG** (ou en GIF). @@ -29,26 +30,47 @@ le fichier dans le dossier des animations. ## 1. Comprendre la « toile » -La matrice est un écran de **32 colonnes × 8 lignes** = 256 pixels. Chaque +La matrice est posée **debout** dans la vitrine : c'est un écran de +**8 colonnes × 32 lignes** = 256 pixels, comme une enseigne verticale. Chaque pixel est une LED, et les LED sont espacées de **1 cm** : la matrice mesure -32 × 8 cm. +8 cm de large et 32 cm de haut. ``` -x → 0 31 -y ↓ ┌────────────────────────────────┐ - 0 │■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ - │ │ 8 lignes - 7 │■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ - └────────────────────────────────┘ - 32 colonnes + x → 0 . . . . . . 7 + y ↓ ┌─────────────────┐ + 0 │ ■ ■ ■ ■ ■ ■ ■ ■ │ + │ ■ ■ ■ ■ ■ ■ ■ ■ │ + │ … │ 32 lignes + │ ■ ■ ■ ■ ■ ■ ■ ■ │ + 31 │ ■ ■ ■ ■ ■ ■ ■ ■ │ + └─────────────────┘ + 8 colonnes ``` Quelques ordres de grandeur pour se repérer : -- un caractère lisible fait **5 pixels de large × 7 de haut** : on écrit - **5 lettres** à la fois, pas plus. Un texte plus long doit **défiler** ; -- un visage simple tient dans **8 × 8** ; -- une ligne de 1 pixel d'épaisseur se voit très bien : c'est une rangée de LED. +- un caractère lisible fait **5 pixels de large × 7 de haut**. Deux façons + d'écrire : + - lettres **empilées**, les unes sous les autres, comme une enseigne + d'hôtel : **4 lettres** visibles à la fois (4 × 8 = 32) ; + - texte **couché**, qui se lit en penchant la tête, comme le titre au dos + d'un livre : **5 lettres** à la fois. + + Un texte plus long doit **défiler** (vers le haut) ; +- un visage simple tient dans **8 × 8** : il y a la place pour 4 visages + l'un au-dessus de l'autre ; +- une ligne de 1 pixel d'épaisseur se voit très bien : c'est une rangée de LED ; +- **jouez avec la verticalité** : ce qui monte, tombe, coule, grimpe, se remplit + (une pluie, une bulle, un thermomètre, une fusée, une liane…). + +> **Dessiné couché par erreur ?** Une image de 32 × 8 (couchée) est quand même +> jouée : le lecteur la fait tourner d'un quart de tour, et elle se lit alors +> de bas en haut. Pratique pour un texte « dos de livre », mais pensez en +> vertical dès le départ, c'est plus beau. + +> **La matrice finale fera 39 × 7** (ou 7 × 39 si elle est aussi verticale) : +> on vous le précisera. Le lecteur centre les images qui n'ont pas la bonne +> taille, et coupe ce qui dépasse. > **La matrice finale fera 39 × 7.** Si vous préparez déjà des animations > pour elle, créez des documents de **39 × 7** : sur la matrice d'essai, le @@ -63,8 +85,8 @@ Quelques ordres de grandeur pour se repérer : | Réglage | Valeur | |---|---| -| Largeur | **32** pixels | -| Hauteur | **8** pixels | +| Largeur | **8** pixels | +| Hauteur | **32** pixels | | Résolution | peu importe (72 ppp) | | Modèle de couleur | **RVB/Alpha**, **8 bits** entier, profil **sRGB** (celui par défaut) | | Contenu / arrière-plan | **noir** (ou transparent : le transparent devient noir, donc éteint) | @@ -75,8 +97,8 @@ Quelques ordres de grandeur pour se repérer : beaucoup. Si elle n'apparaît pas : **Configuration → Configurer Krita → Affichage → Grille de pixels**, et baissez le seuil d'affichage. -> ⚠️ **Ne dessinez pas en grand pour réduire ensuite.** Une image de 1920 × -> 480 réduite à 32 × 8 devient une bouillie floue. Dessinez **directement à +> ⚠️ **Ne dessinez pas en grand pour réduire ensuite.** Une image de 480 × +> 1920 réduite à 8 × 32 devient une bouillie floue. Dessinez **directement à > la taille réelle** : c'est le principe du pixel art. ### Importer la palette « Vitrine pixels » @@ -196,7 +218,7 @@ supplémentaire. 2. **Dossier** : créez un dossier **au nom de l'animation**, par exemple `05_feu_de_camp`. 3. **Nom de base** : `feu` → Krita crée `feu_0000.png`, `feu_0001.png`… -4. Taille : laissez **32 × 8**. +4. Taille : laissez **8 × 32**. 5. **Rendu**. 6. Dans ce dossier, créez un fichier texte **`images_par_seconde.txt`** qui contient seulement le nombre d'images par seconde de votre animation, @@ -220,7 +242,7 @@ Un seul fichier, pratique à partager, et la vitesse est enregistrée dedans. 2. Krita a besoin du logiciel gratuit **FFmpeg** pour ça. S'il le demande : téléchargez-le () et indiquez son emplacement dans la fenêtre de rendu. -3. Taille **32 × 8**. Nommez le fichier, par exemple `05_feu_de_camp.gif`. +3. Taille **8 × 32**. Nommez le fichier, par exemple `05_feu_de_camp.gif`. > **Limites du GIF** : 256 couleurs maximum (largement assez ici), et une > durée d'image arrondie au centième de seconde. Le **PNG animé (APNG)** et le @@ -264,7 +286,7 @@ Il affiche un rapport : ``` 05_feu_de_camp Type : séquence de 24 fichiers (12 images/s) - Taille : 32 × 8 ✓ + Taille : 8 × 32 ✓ Images : 24, durée 2.00 s (≈ 12.0 images/s) Couleurs : 9 différentes ✓ Trop sombre : aucune ✓ @@ -278,7 +300,7 @@ l'équipe. | Message | Signification | |---|---| -| `⚠ attendu 32 × 8` | mauvaise taille de document : l'image sera centrée et coupée | +| `⚠ attendu 8 × 32` | mauvaise taille de document : l'image sera centrée et coupée (ou tournée si elle est couchée, 32 × 8) | | `⚠ beaucoup ! anticrénelage ?` | plus de 64 couleurs : du flou s'est glissé dans le dessin | | `Trop sombre : N couleur(s) seront ÉTEINTES` | des couleurs trop foncées (partie 5) | | `⚠ limité par le logiciel` | image trop claire et trop remplie : elle sera assombrie | @@ -310,6 +332,11 @@ numéro : `01_`, `02_`, `03_`… Selon l'organisation de l'équipe : +- **Par la page web (le plus simple)** : ouvrez `http://172.16.50.27` (depuis le Wi-Fi) dans un + navigateur, sur le même réseau que le Pi, et déposez vos fichiers. On y + choisit aussi l'ordre et les animations jouées. Voir + [INTERFACE_WEB.md](INTERFACE_WEB.md). Les noms y sont nettoyés et numérotés + automatiquement. - **Carte SD** : on éteint le Pi, on sort la carte, on la met dans l'ordinateur, on copie les animations dans le dossier `animations` du lecteur nommé **`bootfs`**, on remet la carte. Voir @@ -329,8 +356,8 @@ dans un sous-dossier dont le nom commence par un point, par exemple (couleur vive sur noir) se lit de loin. - **Le mouvement attire l'œil** : une animation lente et régulière se regarde longtemps ; une image fixe passe inaperçue. -- **Texte** : 5 lettres visibles à la fois. Un défilement d'**1 pixel toutes - les 50 à 80 ms** se lit confortablement. Plus vite, on ne lit plus. +- **Texte** : 4 lettres empilées (ou 5 couchées) visibles à la fois. Un + défilement d'**1 pixel toutes les 50 à 80 ms** se lit confortablement. Plus vite, on ne lit plus. - **Boucles** : soignez la jonction fin → début. Un saut se remarque beaucoup plus sur 256 pixels que sur un écran. - **Diffuseur** : si la matrice est placée derrière un matériau diffusant @@ -356,7 +383,7 @@ crise. On s'en passe. ## 10. Liste de contrôle avant de rendre une animation -- [ ] Document en **32 × 8** pixels (ou 39 × 7 pour la matrice finale) +- [ ] Document **vertical** en **8 × 32** pixels - [ ] Pas de flou : **moins de 64 couleurs** (vérificateur) - [ ] Pas de couleurs **trop sombres** (vérificateur) - [ ] Fond **noir** ou transparent diff --git a/8x32 PI/INTERFACE_WEB.md b/8x32 PI/INTERFACE_WEB.md new file mode 100644 index 0000000..f70afa4 --- /dev/null +++ b/8x32 PI/INTERFACE_WEB.md @@ -0,0 +1,212 @@ +# L'interface web : gérer la vitrine depuis un navigateur + +Le Raspberry Pi héberge une petite **page web**. Depuis n'importe quel +ordinateur ou téléphone **connecté au même réseau que le Pi**, on peut : + +- **déposer** des animations (GIF, PNG animé, WebP, image fixe, séquence de PNG) ; +- voir un **aperçu animé** de chacune, tel que la matrice l'affichera ; +- choisir **lesquelles sont jouées** et **dans quel ordre** ; +- en jouer une **tout de suite**, ou **passer à la suivante** ; +- les **supprimer** (elles passent d'abord par une corbeille). + +C'est la façon la plus simple, pour l'équipe design, de mettre ses animations +en vitrine : pas de terminal, pas de carte SD à sortir. + +--- + +## Sommaire + +1. [Ouvrir la page](#1-ouvrir-la-page) +2. [Ajouter des animations](#2-ajouter-des-animations) +3. [Choisir ce qui est joué](#3-choisir-ce-qui-est-joué) +4. [Ce qu'il faut savoir](#4-ce-quil-faut-savoir) +5. [Comment ça marche (pour les curieux)](#5-comment-ça-marche-pour-les-curieux) +6. [Réglages et dépannage](#6-réglages-et-dépannage) + +--- + +## 1. Ouvrir la page + +Dans le navigateur (Firefox, Chrome, Safari…), tapez l'**adresse du Pi**. +Le Pi de l'école est branché sur **deux réseaux**, et a donc **deux adresses**. +Prenez celle du réseau sur lequel **vous** êtes : + +| Vous êtes connecté… | Adresse à taper | +|---|---| +| en **Wi-Fi** (téléphone, ordinateur portable) | **`http://172.16.50.27`** | +| par **câble** (ordinateur fixe de l'école) | `http://172.16.7.199` | + +> **Pourquoi deux adresses ?** Une adresse, c'est comme l'adresse postale du +> Pi, mais **sur un réseau donné**. Le réseau Wi-Fi et le réseau filaire de +> l'école sont deux « villes » différentes, et l'école ne fait pas passer le +> courrier de l'une à l'autre. Depuis le Wi-Fi, l'adresse filaire du Pi ne +> mène nulle part : la page semble « inaccessible ». + +> ⚠️ **Ces adresses peuvent changer** (au redémarrage du Pi, ou au bout de +> quelques jours) : c'est la box ou le réseau de l'école qui les distribue. +> Les adresses actuelles sont toujours écrites **en bas de la page web**, et +> la commande `hostname -I` sur le Pi les affiche. Pour une vitrine, demandez +> au service informatique de **réserver** une adresse fixe pour le Pi. + +> **La page ne s'ouvre toujours pas ?** Votre appareil doit être sur le +> **même réseau** que l'adresse utilisée. Les données mobiles (4G/5G) ne +> marchent pas : la page n'est pas sur Internet, seulement sur le réseau local. + +En haut de la page, en permanence : **ce que la matrice joue en ce moment** +(mis à jour toutes les 2 secondes) et le bouton **Suivante ⏭**. + +--- + +## 2. Ajouter des animations + +### Des fichiers (GIF, PNG animé, WebP, image fixe) + +1. Cadre **« Un ou plusieurs fichiers »** → **Choisir des fichiers…** +2. Sélectionnez un ou plusieurs exports Krita (voir + [GUIDE_KRITA.md](GUIDE_KRITA.md)). Sur téléphone, on peut les prendre dans + la galerie ou dans « Fichiers ». +3. **Envoyer**. + +Chaque fichier devient une animation, ajoutée **à la fin de la liste**. Les +noms sont nettoyés automatiquement : `Pluie de cœurs (v2)!.gif` devient +`pluie_de_coeurs_v2.gif`. + +### Une séquence de PNG + +Pour un export Krita en **séquence d'images** (`feu_0000.png`, +`feu_0001.png`…) : + +1. Cadre **« Une séquence de PNG »** → **Choisir les PNG…** → sélectionnez + **toutes** les images de la séquence (Ctrl + A dans le dossier). +2. Donnez un **nom** à l'animation et sa vitesse en **images par seconde** + (celle réglée dans Krita). +3. **Envoyer**. + +Les images sont mises dans l'ordre de leurs noms : gardez la numérotation de +Krita. + +### Vérifications automatiques + +Chaque envoi est **ouvert et vérifié** avant d'être accepté, comme le ferait +le lecteur. Un fichier abîmé ou d'un format inconnu est **refusé**, avec un +message en bas de l'écran. + +Si une image n'a pas la bonne taille (8 × 32, debout), elle est quand même +acceptée, mais sa fiche affiche un avertissement, par exemple **⚠ 32 × 8 px +au lieu de 8 × 32** (image couchée, qui sera tournée d'un quart de tour) : +regardez l'aperçu, et réexportez si le résultat ne convient pas. + +--- + +## 3. Choisir ce qui est joué + +La page a deux listes : + +**Jouées, dans cet ordre** : la « playlist » de la vitrine, jouée en boucle +de haut en bas. La fiche entourée de rouge est celle que la matrice affiche. + +| Bouton | Effet | +|---|---| +| ▲ ▼ | monter / descendre dans l'ordre de lecture | +| ▶ | jouer cette animation **tout de suite** (puis la liste reprend) | +| ⏸ | **ne plus la jouer** : elle passe dans « Pas jouées », sans être supprimée | +| ✕ | la mettre à la **corbeille** (une confirmation est demandée) | + +**Pas jouées** : la réserve. Les animations y attendent, prêtes à revenir. + +| Bouton | Effet | +|---|---| +| + | la remettre dans la liste (à la fin) | +| ▶ | la jouer **une fois**, tout de suite, sans la remettre dans la liste | +| ✕ | la mettre à la corbeille | + +**Corbeille** (en bas de la page) : les animations supprimées y restent tant +qu'on ne clique pas sur **Vider**. Vider la corbeille efface **définitivement**. + +--- + +## 4. Ce qu'il faut savoir + +- **Tout le monde sur le réseau peut modifier la vitrine.** Il n'y a pas de + mot de passe par défaut. Sur un réseau partagé (école, lieu public), + réglez-en un (partie 6). +- **Les changements sont immédiats** : le lecteur relit le dossier à chaque + tour. Après un changement d'ordre, la liste reprend depuis le début. +- **Avec l'overlay** (protection contre les coupures de courant, voir + [MODE_VITRINE.md](MODE_VITRINE.md#4-survivre-aux-coupures-de-courant)) : + si la partition de démarrage est protégée en écriture, la page ne peut plus + rien modifier et affiche une erreur. Pendant l'exposition, on peut choisir : + protection totale (et la page sert seulement à regarder et à jouer), ou + partition de démarrage modifiable (et la page reste pleinement utilisable). +- **Taille maximum** d'un envoi : 50 Mo (largement assez : un GIF de 8 × 32 + pèse quelques kilo-octets). + +--- + +## 5. Comment ça marche (pour les curieux) + +Deux programmes tournent en même temps sur le Pi, chacun dans son +**service** (lancé automatiquement au démarrage) : + +``` + navigateur ──(réseau)──▶ serveur_web.py lecteur.py ──▶ matrice + (service vitrine-web) (service vitrine-pixels) + │ ▲ + │ range les fichiers │ lit le dossier + ▼ │ à chaque tour + /boot/animations/ ─────────────────┘ + 01_coeur.gif, 02_vague/… + .desactivees/ ← « Pas jouées » (ignoré par le lecteur) + .corbeille/ ← corbeille (ignorée aussi) + + /run/vitrine-pixels/ (en mémoire vive, pas sur la carte SD) + en_cours ← écrit par le lecteur, lu par la page + demande ← écrit par la page (▶, Suivante), lu par le lecteur +``` + +- **L'ordre**, c'est l'ordre alphabétique des noms : quand on clique ▲ ou ▼, le + serveur **renomme** les fichiers `01_…`, `02_…`, `03_…` +- **Désactiver**, c'est déplacer le fichier dans `.desactivees`. Comme son + nom commence par un point, le lecteur l'ignore (c'est la même règle qui + lui fait ignorer les fichiers cachés des Mac). +- **Jouer maintenant** : le serveur dépose un « post-it » (le fichier + `demande`). Le lecteur le remarque en moins de 0,2 seconde, interrompt + l'animation en cours, et joue celle demandée. + +Le code : [`serveur_web.py`](serveur_web.py) (Python, avec la bibliothèque +**Flask**), la page [`web/page.html`](web/page.html) (HTML avec des « trous » +remplis par Python) et son style [`web/style.css`](web/style.css). Tout est +commenté. + +**L'essayer sur son ordinateur**, sans Pi : + +```bash +python3 serveur_web.py --port 8080 +``` + +puis ouvrir . Dans un autre terminal, +`python3 lecteur.py --simulateur` joue les animations dans une fenêtre et +réagit aux boutons de la page. (Il faut Flask : `pip install flask`.) + +--- + +## 6. Réglages et dépannage + +Les réglages sont en haut de [`serveur_web.py`](serveur_web.py) : + +| Réglage | Valeur | Effet | +|---|---|---| +| `MOT_DE_PASSE` | `None` | ex. `"pixels"` : le navigateur demande un nom (n'importe lequel) et ce mot de passe | +| `PORT` | `80` | le port web ; avec un autre, l'adresse devient `http://172.16.50.27:8080` | +| `TAILLE_MAX_MO` | `50` | taille maximum d'un envoi | + +Après une modification : `sudo systemctl restart vitrine-web`. + +| Problème | Solution | +|---|---| +| La page ne s'ouvre pas | Bonne adresse pour votre réseau (Wi-Fi ou câble, partie 1) ? Le service tourne-t-il ? `systemctl status vitrine-web` | +| « En ce moment : — » et rien ne bouge | Le **lecteur** ne tourne pas : `sudo systemctl enable --now vitrine-pixels` | +| ▶ ne fait rien | Idem : c'est le lecteur qui joue, la page ne fait que lui demander | +| « Impossible d'écrire dans le dossier des animations » | Partition protégée en écriture (overlay) : voir partie 4 | +| La page est lente au premier affichage | Normal sur un Pi 3 : il fabrique les aperçus. Ensuite, ils sont gardés en mémoire | +| Voir les erreurs du serveur | `journalctl -u vitrine-web -f` | diff --git a/8x32 PI/MODE_VITRINE.md b/8x32 PI/MODE_VITRINE.md index 1520ffe..22a6e9a 100644 --- a/8x32 PI/MODE_VITRINE.md +++ b/8x32 PI/MODE_VITRINE.md @@ -56,6 +56,7 @@ sudo systemctl disable --now vitrine-pixels | Je veux… | Commande | |---|---| | savoir si le lecteur tourne | `systemctl status vitrine-pixels` (`active (running)` = oui ; **q** pour quitter) | +| savoir si l'interface web tourne | `systemctl status vitrine-web` | | voir ce qu'il affiche, en direct | `journalctl -u vitrine-pixels -f` (**Ctrl + C** pour quitter) | | l'arrêter jusqu'au prochain démarrage | `sudo systemctl stop vitrine-pixels` | | le relancer (après avoir modifié un réglage) | `sudo systemctl restart vitrine-pixels` | @@ -79,6 +80,14 @@ le format des clés USB, que **Windows, macOS et Linux** savent tous lire et Le dossier est **relu à chaque tour de boucle** : pas besoin de redémarrer le lecteur après un changement. +### Méthode 0 : la page web (la plus simple) + +Ouvrez l'adresse du Pi dans un navigateur : `http://172.16.50.27` depuis le +Wi-Fi, `http://172.16.7.199` depuis le réseau filaire (voir +[INTERFACE_WEB.md, partie 1](INTERFACE_WEB.md#1-ouvrir-la-page)) : on y dépose les animations, on choisit celles qui sont jouées +et leur ordre. Tout est expliqué dans [INTERFACE_WEB.md](INTERFACE_WEB.md). +Les méthodes suivantes servent quand il n'y a pas de réseau. + ### Méthode 1 : la carte SD (sans réseau, sans terminal) C'est la méthode à donner à l'équipe design. diff --git a/8x32 PI/README.md b/8x32 PI/README.md index 87f847d..cbc68e4 100644 --- a/8x32 PI/README.md +++ b/8x32 PI/README.md @@ -14,6 +14,8 @@ dessinées dans **Krita** sont jouées telles quelles, sans les traduire en code > pour la matrice). Pas besoin de toucher au matériel ! > - **Pour la vitrine** : [MODE_VITRINE.md](MODE_VITRINE.md) (démarrage > automatique, changer les animations, coupures de courant). +> - **Pour déposer et choisir les animations depuis un navigateur** : +> [INTERFACE_WEB.md](INTERFACE_WEB.md) (adresse en Wi-Fi : `http://172.16.50.27`). > - **Pour qui veut coder** : [CODER_EN_PYTHON.md](CODER_EN_PYTHON.md). --- @@ -100,7 +102,8 @@ Côté **cerveau**, le Raspberry Pi fait tourner le programme [`lecteur.py`](lecteur.py), qui : 1. regarde ce qu'il y a dans le dossier `animations` ; -2. ouvre chaque fichier (GIF, PNG…) et le découpe en images de 32 × 8 ; +2. ouvre chaque fichier (GIF, PNG…) et le découpe en images de 8 × 32 + (la matrice est posée debout, voir partie 8) ; 3. pour chaque image, **corrige les couleurs** pour que la matrice ressemble à l'écran de Krita (la « correction gamma »), applique la luminosité et la limite de courant ; @@ -347,13 +350,16 @@ sudo ~/vitrine-env/bin/python test_matrice.py Le test se déroule en trois temps (voir [`test_matrice.py`](test_matrice.py)) : -**1. Les coins** (5 secondes). Posez la matrice **face LED vers vous** : +**1. Les coins** (5 secondes). La matrice est **posée debout**, comme dans +la vitrine, **face LED vers vous** (réglage `ROTATION = 90` dans +[`matrice.py`](matrice.py)). On doit voir : ``` - ROUGE ●· · · · · · · · · · · · · · · · ● VERT - · · · · · · · · · · · · · · · · · - · · · · · · · · · · · · · · · · · - BLEU ●· · · · · · · · · · · · · · · · ● BLANC + ROUGE ● · · · · · · ● VERT + · · · · · · · · + · · · · · · · · (32 lignes) + · · · · · · · · + BLEU ● · · · · · · ● BLANC ``` | Ce que vous voyez | Ce qu'il faut faire, dans [`matrice.py`](matrice.py) | @@ -361,7 +367,12 @@ Le test se déroule en trois temps (voir [`test_matrice.py`](test_matrice.py)) : | Rouge en haut à gauche, vert en haut à droite | rien, c'est parfait ✅ | | Rouge et vert inversés (gauche ↔ droite) | `INVERSER_X = True` | | Rouge et vert en bas | `INVERSER_Y = True` | -| Les deux à la fois | les deux à `True` | +| Les deux à la fois (image à l'envers) | les deux à `True` | +| Les couleurs sont sur les **grands côtés** (la matrice affiche « couché ») | `ROTATION` n'est pas à 90 : corrigez-la | + +> **Matrice couchée** (à l'horizontale, 32 de large × 8 de haut) : mettez +> `ROTATION = 0`. Tout le reste (Krita, lecteur, page web) suit +> automatiquement : les images se dessinent alors en 32 × 8. Pour modifier le fichier sur le Pi : `nano matrice.py`, changez la ligne, puis **Ctrl + O**, **Entrée** (enregistrer) et **Ctrl + X** (quitter). @@ -404,7 +415,8 @@ animation, pause entre deux, horaires) et de [`matrice.py`](matrice.py) : |---|---|---|---| | `LUMINOSITE` | matrice.py | `40` | de 0 à 255 ; montez en plein jour, en vitrine | | `COURANT_MAX_MA` | matrice.py | `2500` | un peu **sous** la limite de l'alimentation | -| `INVERSER_X`, `INVERSER_Y` | matrice.py | `False` | orientation (partie 8) | +| `ROTATION` | matrice.py | `90` | `90` = matrice debout (image 8 × 32), `0` = couchée (32 × 8) | +| `INVERSER_X`, `INVERSER_Y` | matrice.py | `False` | sens de l'image (partie 8) | | `GAMMA` | matrice.py | `2.2` | correction des couleurs (1.0 = aucune) | | `DUREE_MINIMUM` | lecteur.py | `10` | une animation courte est rejouée pendant au moins 10 s | | `HORAIRES` | lecteur.py | `None` | ex. `("09:00", "22:30")` | @@ -484,9 +496,12 @@ Pour voir ce que fait le lecteur quand il tourne tout seul (mode vitrine) : | [INSTALLATION_PI.md](INSTALLATION_PI.md) | électronique | préparer la carte SD et le Pi, pas à pas | | [GUIDE_KRITA.md](GUIDE_KRITA.md) | design | dessiner, exporter, vérifier pour la matrice | | [MODE_VITRINE.md](MODE_VITRINE.md) | tous | démarrage automatique, changer les animations, entretien | +| [INTERFACE_WEB.md](INTERFACE_WEB.md) | tous | la page web : déposer, ordonner, activer, jouer les animations | | [CODER_EN_PYTHON.md](CODER_EN_PYTHON.md) | code | créer des animations en Python ; comparaison avec l'Arduino | | [`matrice.py`](matrice.py) | | le moteur : réglages, `XY()`, gamma, vraie matrice et simulateur | | [`lecteur.py`](lecteur.py) | | joue les animations du dossier en boucle | +| [`serveur_web.py`](serveur_web.py) | | l'interface web (Flask) : dépôt et choix des animations | +| [`web/`](web/) | | la page web : `page.html`, `infos.html`, `style.css` | | [`test_matrice.py`](test_matrice.py) | | test de branchement et d'orientation | | [`mes_animations.py`](mes_animations.py) | | les animations de l'Arduino, traduites en Python | | [`animations/`](animations/) | | exemples : GIF, séquence de PNG, APNG | @@ -495,7 +510,7 @@ Pour voir ce que fait le lecteur quand il tourne tout seul (mode vitrine) : | [`outils/verifier_animation.py`](outils/verifier_animation.py) | design | vérifie un export et crée un aperçu « façon LED » | | [`outils/generer_exemples.py`](outils/generer_exemples.py) | | recrée les exemples et la palette | | [`outils/schemas.py`](outils/schemas.py) | | redessine les schémas du dossier `schemas/` | -| [`installation/`](installation/) | électronique | script d'installation et service de démarrage automatique | +| [`installation/`](installation/) | électronique | script d'installation, services de démarrage automatique (lecteur et interface web) | --- diff --git a/8x32 PI/animations/01_coeur_qui_bat.gif b/8x32 PI/animations/01_coeur_qui_bat.gif index cd2c790b7b07efeb9dbf54522373cd7eb776510e..51ef3d8b7d50c2a7208450ea78e80a5710586ad0 100644 GIT binary patch literal 299 zcmZ?wbhEHbXk=jcziQtz1`P%=@u`q-Vg)2ozNO$pV(u0Vx1! zVc^hbVC9hU*s$PWGl#HN%!v&P4>K^bIEY9vFdXR?(oZ_GW8$MDO@gvrGM<~3c(yPy zg3Ms#VPIwen!y7!gPoxf*-{Q!26h$>0|$kJKpO;1A~qx*Y~^Ehn<62YXk=jcziQtz1`P%=@u`q-Vg)2ozNO$pV(u0Vx1! zVc^haVC9hU*s$PWGl#HN%nkzw21Y(fuNIC23y*g4>c$;e5%Ac#Ptv|A=Y*jGNIxSF z12cmTH&8z(0}DeVvV|NH4D2it8WR$Ll5AQs90?5$?OgI!EfNBRG>S8@a{x6yIMB?& X$t4pIaDahO0dzWXkJA%C9qRPVM7O&` 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 literal 12377 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 diff --git a/8x32 PI/animations/03_vague/vague_0000.png b/8x32 PI/animations/03_vague/vague_0000.png index 0adfd74bdade6f0c225f4b828aaf530f9b940cfa..9456bbbd0bd610b1a0af90649d1f7f98483e7cb2 100644 GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq+&c>978JRBqvC)W;6(%Teah} z{Gk^CjvPiIcYdA^V00A_H8|PzpXaog)6Kw-JhDAL0Z$78+1T1rxwYT@Ne3$5pUk)G xtZcD_L?Dl2;nX`!YtE$}dfFezspY}IFsoI*dd04NNkDrTJYD@<);T3K0RV96GwA>T literal 171 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QbnFFjv*Cuk`oRvYQ6h&T-8-5 z;b3asaT%3uT*{(44HDN4*^k9<+t92i=h~%{Epf3oZks^&sudM%Y;9d4Y({Gm*X;N# zKmX#5HHs(s+}gVSxAeMgZSz>~z|=O;F^`dR+2J<{2c(luB>GKbmAJNm@reOD!!8yh U)}F+Y6rk-4p00i_>zopr02)|3d;kCd diff --git a/8x32 PI/animations/03_vague/vague_0001.png b/8x32 PI/animations/03_vague/vague_0001.png index fe42bde278d2a625d59c36e3afdae00a78551adb..00e03b9ba85102d873a35408e5b01277af8d47a8 100644 GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq~bhX978JRBqvC)W;6(%Teah} z{Gk^Ci75#Q360%lXAWKnEqre8e6*fzC#wL<#ZxB^95@j0aem@Wr=08 BIH~{u literal 176 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95Qstg5jv*Cuk`opfCb&Ecx%0C> zG_ajZIjk^=fqTzpJN>n&=a>!VD%!twu%)-m)0hxtoYG?JcFg9D?K4$ zg@@wVzAo$kl9ErvCOnw8;zySV8=KM|CVmMa-2{gU)_zmb@_&a9&ne+HSaXm?wpbvM ak0I!I?mLy?PLvLnVh_4$LIZrGZni&27cs`?eUQP13FdxY~m*&ZIz|AmshOp6|A z2T4eHeB?Uvx`(HTyQ9=6#D>;Q9{2zSD@2j97r+@|ZQoMvKc3E#l9MS_)K Y=^LGbzS)(1KnE~*y85}Sb4q9e0RCM*4gdfE diff --git a/8x32 PI/animations/03_vague/vague_0003.png b/8x32 PI/animations/03_vague/vague_0003.png index 8dbdec5107c23bc1a08f96e71891340a57eaa7ae..2b15f90ea646e6cf77a08bba892feb6f8c6ab814 100644 GIT binary patch literal 155 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq>?;c978JRyzSe_dq9DQm>mk_vZmg(VaRAx5S#u}-2MlPUj8?QiMO*(YyjHE;OXk;vd$@? F2>=Y0H{SpN literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95Ql*|Qjv*Cuk`opfCbR^Imj6qh z`Ys@G$pt2{j*GIz8@!!UH8-?PT=>YInVI?Urq&xz7eA7ekkAS`v1Re2bMzopr0Hu&V`Tzg` diff --git a/8x32 PI/animations/03_vague/vague_0004.png b/8x32 PI/animations/03_vague/vague_0004.png index a651eb56d5e9c79894716656b47c6656c5bb61dc..e991c9fefcaf8c21a187e7e9474e5a35a5953117 100644 GIT binary patch literal 155 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq>?;c978JRyzSd4*r34U8ZXgu zYGqE2jFPee3#*CGQN4%*D>K|4#LxOut}h<((}iK}TK&YyoC}y={7j0Hw(GrEcDu>U zt1j<7yCZ}7R)>?ZMgQxaj`d5ny$ z2H75)nB@#yA{aKed8~I}F5_cXN$A+t(6}L7z|Gj;K=WJ??u2U&dzv^e^t3TB^jT`R U%lmGw# diff --git a/8x32 PI/animations/03_vague/vague_0005.png b/8x32 PI/animations/03_vague/vague_0005.png index 41fa19d1bc3d27578ffaeff628b1c4fe3e6012c1..9ca2bfcda3768e46a0c5d7972a5e900eadefd17c 100644 GIT binary patch literal 156 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq>?>d978JRyzSe_d%%E$rC+0X z$*GXcJ#5MbE!{glhTJ=<*_3lKiDiGrg9^DDQ7R4F{+XAZR=H*M%G8X((Rypeca<}~ z$LBC@<+;2uCFIxe?6~%>8=;K+70BUHx3vIVCg! E0JRi0)Bpeg literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95Qn{Wkjv*Cuk`opfCal@k{VqtbZF0;mJ zf=Qys0h7du4H<{j4j#B**xoqNF^@6K@{N^S6AQy@J=vg&FH@a>mN9s``njxgN@xNA D%7-&d literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QiYx_jv*Cuk`opfCal@MbN~A+c|hQ_*rN>$ zX}l}r8m;Ff-gN5Vh~t*<_{_EG@WZASorcKf``R=3)GsozwS;eA@@YTJ$RN(8-{Hn7 R^blw{gQu&X%Q~loCIBa_Jd*$b diff --git a/8x32 PI/animations/03_vague/vague_0008.png b/8x32 PI/animations/03_vague/vague_0008.png index 61400d23985a075ae53bbdba21108661c42ad920..c95e67bec15f4f8ebf4da433b7160e3273cbbfda 100644 GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq~bkY978JRBqvC)W;ASkbgrIb zwR5YWYtup|t-y&*O2-dv$WaVocedHYr^Zrz(`m=X6+%2bvc(%-Ib3bgYFxq@%e0Qu zxv6nsGN1F&x&M23intA)N-TO*=#gNW)#AaxU|}IGY4z9UF3>6lPgg&ebxsLQ05ecA A3IG5A literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QaPS3jv*Cuk`opfCal@AVr zbL&JcH)o%;lobx!7bvv`Wh=OqpGiyrf|Lo-4SMpT{2SYBthL_#X_q*=AxDvqJK@@d zSE4P4r|Y(GKibyNXtI8R%;KJ zGHb4x$}}okJUF`Y&QZA>oglT0Q?8TG-)H#qc=?yWUmU3mTHLrUZGZYZXKG=T$N8Ky)p5olZ@DcDCbGC z9PNjFj^{F-ky6^*^s?ZQPJ+T4(Uyxm5kBgNm}Zy?tWRLFadT#1xXY#A@%_NGY@n?S Mp00i_>zopr0PDv&LI3~& diff --git a/8x32 PI/animations/03_vague/vague_0010.png b/8x32 PI/animations/03_vague/vague_0010.png index 5766e6d91762bc081cdb6ac21911a3eaaa8040ed..630dcadcbbab23ab2af4c93be1677f61fb0bc712 100644 GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq~bkY978JRBqvC)zF6?+-2X1? z{~J&JNnde5OMQETV;-Y3>n;}IDRJ8be1(PQTs3TGjO=4wn0E6JUt5Ka)de1xsR;=$ zW;BPgPg3LIoS`it`9v(?l%dPf#}f`@UHLGHfq~OZ#VIVssRw8kgQu&X%Q~loCICRu BGuQwC literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QW>5ujv*Cul2`2bJil>T!^DM; z>Rq{}TwZv{L$~6&o{;ZmdKI;Vst0K;xE A(f|Me literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95Qn{Wkjv*Cuk`opfCal@j zqgTe~DWM4fm}fVq diff --git a/8x32 PI/animations/03_vague/vague_0012.png b/8x32 PI/animations/03_vague/vague_0012.png index 95a461ce78198e936bd389dcdd3aaff4e76656a0..03260d925f7c40e92464b4ae19cd2238d96d8583 100644 GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tqyjx%978JRBqvC)zF6?+Tz!nu z|H2a|bq^@2Z)Z?yS3AS9bh@s~TFF%ZBW)Qr*~g nvc8b(=sK?0c(gB3gONe~n?h~j>c888hBJ7&`njxgN@xNAMrJP| literal 160 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95Qt6&9jv*Cuk`opfCal@NiDEX~mCVzopr05vu=;s5{u diff --git a/8x32 PI/animations/03_vague/vague_0013.png b/8x32 PI/animations/03_vague/vague_0013.png index f70f24adb81896da9ee33f79a5a730b716c1b616..7955d9d96061bf5ba6ab1d06933e0f656159e226 100644 GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq{2O2978JRBqvC)zF6?+Tz!nu z|H2a|brV(`P&#T=$Oa2Og%tHewh$M@DAzEhdec215Ib}boFyt=akR{06%Ijod5s; literal 168 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95Qu&@Pjv*Cuww~Y3+hD-c^3c9J zz)+Nz*SVsFXCjl-vQv7?HZ0|EUgOQWLFL)BiS5&GeRoLG+jmJWj@_bOHT-(JdE=2A+Q(zoY|GiJq2IAm Sq5mh)Y6eeNKbLh*2~7atP(9!P diff --git a/8x32 PI/animations/03_vague/vague_0015.png b/8x32 PI/animations/03_vague/vague_0015.png index 1cb8b95367d87d61657d1a2cd59a62bcc785a3cb..3c0a9227bfaeb5edccc567f697389d863b5df24b 100644 GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tr2ITx978JRBqvC)x-@rL|1UiG zVA6!>hEQQ)W@hK(GHcjQ=_UaAnF_*F;SA%}ZcHqZpF(a3w&bkRJew;sWAYkGlhk2$NZu-$3D;p*{<}ore zGaotBC35KvNPqvu6=Dt!AvP1^94%zFU%a7{;84Lj-IV2U%LG=5YYP}3^|moEL@zK2 URVzQf0%$#hr>mdKI;Vst0H8EKGXMYp diff --git a/8x32 PI/animations/03_vague/vague_0016.png b/8x32 PI/animations/03_vague/vague_0016.png index 4c910a558344bbc989f34536e78848c2359b2ec6..646a5b8bc87b90ed501f8ba2ca62292b30ed2fea 100644 GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tr2ITx978JRBqvC)PHFD4{$F_V z&v8Zd?F|c=x>IMeHXtxs<7ilQ(@93`glZEZz3Zz^BwJqHHw!WTId|uz$Ps4vcQ6YVdj3Fj#{De7NETh Mp00i_>zopr0La2Pj{pDw diff --git a/8x32 PI/animations/03_vague/vague_0017.png b/8x32 PI/animations/03_vague/vague_0017.png index 9f2f3e3fcad1e394589e024bf65526759b4757db..8d0d918d54a8a25a4231b28693f8486f224d9b4e 100644 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq=GzM978JRBqvC)PHFD4{$F_V z!K4Y%4WYuq%*@WmWprN6lmLQhx<{@YVOeNk>Zp3bB=pYD^Gwf6&Ny1^P3B`(Zob3R m@^3}O4#pKi5pN{62{AZvNIiP{rTZJubOujXKbLh*2~7YPVJ|%Z literal 171 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QbnFFjv*Cuw)Su2J)pqDazE(V zf+Q^|skRRsJQJHtW_HE;I0{KAb9k*|dHr7ghvYAn*3B#4U%YfIa#8H{D=&7G*K_K~ zY5z*K7Q+ WFmU-7z~l+Eox#)9&t;ucLK6V0HA3G2 diff --git a/8x32 PI/animations/03_vague/vague_0018.png b/8x32 PI/animations/03_vague/vague_0018.png index 52d74f4ce1dd8ceeecdb2cfc4c0e78377a48e39a..6f29442eb6aaa582c7b295e256ed1bdd354862f1 100644 GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq(VGh978JRBqvC)ny`qL|9d>? zpsM0_hSh3nY;0|PmJwHs4GjzobfP;}c5*tEo^dR6Jv#S)4^I*If;}sKbcwL-uu|a! ns$5Z_k$lrBgww~mZ9NNvW`IOd6Q}nRp!p1*u6{1-oD!Mzr(PjOA zV^{eK10w^24FP#2$7O0Z^@x`Lb6%r#xMan}(_%^Cf}-aS95`?y;NyO!)<(u>%1XUy z4Hh!fFM2dgbj)KsvZ_mD(Hn&~1v+954k0#m>nA)Z$PqIzIIy=#6v(t-U|8X->+*D( R`h1|>44$rjF6*2UngAggKKTFu diff --git a/8x32 PI/animations/03_vague/vague_0019.png b/8x32 PI/animations/03_vague/vague_0019.png index 0c9943e22a5515e5a957a6fe28ee291e8037db2f..a0193c5b71906d3df4784e34255d45d3e75119b5 100644 GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq{2O2978JRyzM*4dBA{&dA(wB zg({wQQytQ5Q_pjk arm?+fGt>)T{Wcxw1O`u6KbLh*2~7YxLP3oH diff --git a/8x32 PI/animations/03_vague/vague_0020.png b/8x32 PI/animations/03_vague/vague_0020.png index 06441edb2351bf8c0c016af1c468af22fa9db8b7..d0153365601c91001887fb467307a643869514fe 100644 GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq#`_B978JRBqvC)GBawu`*U2i z+1n|PaoMsi>;IAxPs9=~hJ2jg+1S{ac+)AxSG4?}u}<^}riUeG9Cs{A=3@o|<>os~ sD^9Jb&`7@N6v7#Dpylw|!){CrGHYcV)JuMD0ouXf>FVdQ&MBb@07NP6K1cN;p4cpY)>HLIOQ52kp@i#t+B^QeI}BG^1+X_hNV;`h)&0!! zFa?H`xPJ|LH!Td$GE^`!Ji7J8CI5y0i`i`ld_&eftJu*Urr5K3-ia$e>K}2acom diff --git a/8x32 PI/animations/03_vague/vague_0021.png b/8x32 PI/animations/03_vague/vague_0021.png index 79e307a76764580450d0640d1c675cfe8877dbec..88b5ebe8544348cb01e249eac07b9a743a92515c 100644 GIT binary patch literal 148 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq@q1t978JRyzSr3+hD-sBHv+Y z!;^erNn(ezV)Bf(m7BUVn~vz)I_-J)r$k+{F0R#U%bDf?PJ{hZLYP=*EVi&@eJ1;i yA^1|v`^uPxh097$E_wXm-j0jg@BQey!BBKVs$$=^Y5#!MFnGH9xvXmdKI;Vst00o3R>Hq)$ diff --git a/8x32 PI/animations/03_vague/vague_0022.png b/8x32 PI/animations/03_vague/vague_0022.png index 384d9e7a0e4167e0115120618dadcbb53b5b6190..e08eb487d83887811499f3cbbd03204963406b23 100644 GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq#`_B978JRyzSe_+hD-M!hb|Y zuBXk^#;MKYp~=isyDeND6j-ZTEKU{`*1dlC{O=0(SB2?|&KKyhC0(CTwV?3^(~=rH uAIU66wIdAIa{I4b`mV_KZ27TxL0%hi>8-q{cNYQeVDNPHb6Mw<&;$Us2Qwc4 literal 169 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QU#tajv*Cuk`opfCa6TGAD#O@ z;;3~<+S!;S0)C|InwI8-x{t1n#|zo>gTe~DWM4fF#;-{ literal 169 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QU#tajv*Cuk`opfCaCyFAD#O@ z;;41V1>O|J^Dp`=D^e$}Q9Rq!W&MAln|^dhs3W_xO^D7?#MYVlBhWz+iV=XVamg S_bNcU89ZJ6T-G@yGywoZH#bNC diff --git a/8x32 PI/animations/03_vague/vague_0024.png b/8x32 PI/animations/03_vague/vague_0024.png index 63ff4378aab09393329b1b38f2ce76db445a092c..200251e71d715b75959fce2f7e92d9c17521e4cd 100644 GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq{2O2978JRyzSe_dq9DQMPIDg zV7Yq&2Uo_7uGl$DCp|hZFYEX;_y4{1?eY2E%WWreyf7)6Y^@>cpjT?udyz5nPOQD~ s;@`n5*H}#0BeIQK=7|^|Usx^ooKI=e*U$0xg6w1PboFyt=akR{06(NKy#N3J literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QiYx_jv*Cuk`opfCa5_3AD#O@ z;;41Vh8f44+Fr^Qe~9c!L$Ln3ZLu_UZs}9g){mX0zdNb@Q7*# zSaU=>TzI%+%}S5N1Rz+m5K$;uP> mF+k48(uYafq5SHG$v{KXq>aQmJdA+mGkCiCxvX35!;2y1DtxlVc^3>))|2IC`|?jd$ON zM=y@OSR)v_>C(nZ1`z{;^$e^2h^#fdYtT?!U>s%iSF2BJ_5bAkov9297}y^zUD+D*mE2=ffx%5U=32Y5x z|5NMWv&HIxz~sLzTVs~CMk(F4I4Jn(%gK~?+aEBzNRyfw`KaM7&@u*3S3j3^P6!VD&7%wu%)-A=w=4fk0(HdfggURIstNb%}^r ze~4@-uy9-)xOSySVnRbD>vC2V3z^xnJUl!C*%F1zxPoN+4ofkxtkC}Q;N+RxKx-L1 MUHx3vIVCg!0EA9AlX z$vJY0&PzCWgDb^@3(^$~MP=K)>_v_KREnF{PvW^U=hAe^EM^JEvp27?^!?%p+}}9M vU3S8Z80kRvCv$3cGC93GssDM^`Gb5<56fs|8O_@cw1mOa)z4*}Q$iB}6WlRi literal 168 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95Qu&@Pjv*Cuk`opfCa8Fp9-aF? z;;3~j!2xlMyx>)oGrsl7RhVXU$|9|KY)$^>KK{0E9?LO literal 167 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QhA;(jv*Cuk`opfCaA<^9-aF? z;;41V2gyTDdoIcre>nAML)!*c(ei)Jy>Z(FRyXmrRje?|DSPl)_RvF%9yi9okD{79 zJQ8sSGZ`J*leacEHZD|RJ<6veHsM)8&NYSVRzc(332zE=$_xw)9M&*0NdDEWW!SCX Q3ACBP)78&qol`;+07{xWrT_o{ diff --git a/8x32 PI/animations/03_vague/vague_0029.png b/8x32 PI/animations/03_vague/vague_0029.png index 3f653b388c09631f94293fd237713304c15e9341..d8c217c041e6b32653e23bf2b4f4aabcbcf40b0a 100644 GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq+&c>978JRyzSq}dq9DQH#3->ZH Ah5!Hn literal 168 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95Qu&@Pjv*Cuk`opfCa46(9-aF? z;;3~9$BY9`OJ2$re~9ckF2y9~@b!FRLc}(K)lGbD6@RWh_^fc~p@olo!-@(vwze)2 zow9`O4GRtzMg~tjbfDptXbHD~oAHDUt22CTA?pnmsq%m7b=%7JNR*9@jiE45x3+i2 RGCrWy44$rjF6*2UngC$wJHr3~ diff --git a/8x32 PI/animations/03_vague/vague_0030.png b/8x32 PI/animations/03_vague/vague_0030.png index 8ffd215afc5048b8a3724f9ae6c747102e98f24d..a630baaa61ba0e4693c2a85f48b2fd0e0bb34054 100644 GIT binary patch literal 150 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq+&f?978JRBqvC)x-@oK|1aza zOPHLn;>US~?G2pWpHjIcB_uAWT0L`+^of|d*dyf5PyX<82M!$QNK*H)bYkD6C~$!3 wBljjngT)GUj-eo#6AIfIVhxuSw=ZU4n3ShD!K&Lh8fX!Nr>mdKI;Vst0D*Bc8vptnZc389uK#&&6nxAcv>teP*C*zfdc^(4>cT@nj;3( z)_*=D=UT#6=5B#Q4_SKMwz91b53p!C+%iFyM?&#f$DB7O%%$2*d3bmj&ZHP$NNzIq Q1=`Hu>FVdQ&MBb@0PnUwoB#j- diff --git a/8x32 PI/animations/03_vague/vague_0031.png b/8x32 PI/animations/03_vague/vague_0031.png index c03aa1d58ac6d47048acabd62c0a2d9acce8b56a..e58873f0f4e62522cc2ef14e826e866509d0c135 100644 GIT binary patch literal 153 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq!K(`978JRBqvC)x-@oK|1aza zOYm@TeJW;=i1Cr(L9NF+=PU!akm zkdV;wW9eZ|*C{~7tY=wN7C#Y7n3`}%%c@6(!E1)%X8sG;4gl?9@O1TaS?83{1OQIP BHRS*R literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QaPS3jv*Cuk`opfCaAat9-aF? z;;3~9M|Qg6u@`-o7O9-K8ZR@6ES|1=WYw}}MY&Zx+f8q%YQ6h&Jl}-L<*2cNLCBH^ zTbN@-9u_pYEshGFc<4aGS#6CPA(_6jd~9rNKG_lzgE>y}9na=vVPNo<)U9QDuxJ6$ OUItHBKbLh*2~7aNT{l_) diff --git a/8x32 PI/animations/03_vague/vague_0032.png b/8x32 PI/animations/03_vague/vague_0032.png index 3f00d1f490b4baa1b3e0d7b453655b388a7e4a23..416c4966fbf59ad8172a32cf2045eebfabf7e40c 100644 GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq#`_B978JRBqvC)W;6%}uXuE> zUPnl@{9pN*gR2%lvOm13m4}DN=S7=NG)LlM4+kE{=?-CiN{wq|6dRYxXfc*6b+Lqq sgjzB)Gb`VDC?QnDZQy$Guc3@8Lvn>;DNk;`JkSmXPgg&ebxsLQ0FHDk0{{R3 literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QiYx_jv*Cuk`opfCaA1f^ypmu znn$u)Enkfc3^oXu>`LZ~i*&m7V9Nra#EOfj#galV92DRWZM)8=Q^e8!*g%Tay8 S=5_#RIfJLGpUXO@geCy3_&Zww diff --git a/8x32 PI/animations/03_vague/vague_0033.png b/8x32 PI/animations/03_vague/vague_0033.png index f1e47aa74f46460db122a5651071510062460fa1..78fdc73d90cd21a54d6bb9994a3411ba3ec31cb6 100644 GIT binary patch literal 147 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq@p}s978JRBqvC)W;6%}uXuE> zUPnl@{9pN*gR8bZvTwe_#I}=lg_YJnW1VP@M5Bco?VXD}7D%3L3AJ=$x~%FGG1b_> vz#y^Qf|*%)lW?Hw6S0KA{x^3LwHO&zT~hr0yfo)5&=v+yS3j3^P6;8+f#UD-yCGwVWYrXr^F10sDF^pA~=VL%>%NvL3ZWc1_7c~^L_9?YC zGOiB~P~otWSuM-M!y~F`@iw7F`mDpA!y6-;PiyyRO!S=h(!g3mLPFu10yBerwr*|F TC*g@e%Naaf{an^LB{Ts5^FKDY diff --git a/8x32 PI/animations/03_vague/vague_0034.png b/8x32 PI/animations/03_vague/vague_0034.png index 5a5e519337e05f2f339cb00b9190db84fc50c2f1..9b1d1b66e452f4abc4488afd809daf4d5f1040e4 100644 GIT binary patch literal 143 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq{2L1978JRBqvC)W;6))#uq-f zFXL?E$`KMR|5t9I+p=M1fXnlQt&1PoAKuhjASZmtPbIL0Ws@QYr_WO!9v+{JGNs74 qMpsK_W@hE)J4`ySCjK?Nq{8qZPgYlF(xF_S4Gf;HelF{r5}E)Twk#z8 literal 164 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QrVs^jv*Cuk`opfCLB=?FMMtv z_K{DtX4eG5Fr!`ep5|28!v&Bk1ERb2I>=0tZ%I$ZjV_lH%M2cT<5K2KbUGo`5q^Pzp=`fyt(?t{Wk72g NJYD@<);T3K0RR=0HYfl9 diff --git a/8x32 PI/animations/03_vague/vague_0035.png b/8x32 PI/animations/03_vague/vague_0035.png index bb107f65daf5cb9948d80183ddbbc6fd61344c63..3ef76de8a4d20de063a7cc866e8b924f4cc95b6e 100644 GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tqyjx%978JRBqvC)W;6))#uq-f z7whb@{%`#(;cDQ=`H453I3{x*6_}yX#d1eLgwy0{0B4GI;N0lOb#odU8#ylvtYBHD mXt4N+Si;nVLt0isDhz@hGTX26hy?--XYh3Ob6Mw<&;$Ugek*GL literal 169 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QU#tajv*Cuk`ok|Lhk(Z?=f1_ z&?-@E{Y+tFsKm+y#v?oWEDio`YL(^rs1f86-Mo(P=!;bDjW0U7(i0L^cqA%Zk`fUW zE&un}SZcN@%iDw&>9Y)l7LJPp*DAgcbvu;kbb^m_W@CukW_ZCp S*C-okH-o3EpUXO@geCy+N;|*+ diff --git a/8x32 PI/animations/03_vague/vague_0036.png b/8x32 PI/animations/03_vague/vague_0036.png index cac54de573c502585bb8aad8e0e3e77ed6e7d4ac..4ece8140279826b72932e11f9bab860b51a1882d 100644 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq=GzM978JRBqvC)W;6))#uq-f z7whb@{%`#(;YCB2UUbL8#gFV`rZhG-aweKg(r{wRGwEVkR>a1}rj{~&!LP+k-5|Cb k+fLSqD~EOEr#dh&gx`|cc4EdtQK0Dzp00i_>zopr0KPmcqyPW_ literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95Qn{Wkjv*Cuk`opfCLB=>EPQSs z_K{C?PSge_u^6}VGY2ybJ=Q{d6L|9|wlHLR#$V{7XY zS@b3$#i7i?adF_a1} zrncskq=dwb4O;052?;3^q8mb+ICuY=sKdzct5mwpTD%o#5d(v#tDnm{r-UW|AOtWH literal 173 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QYD@)jv*Cuw)Su2ZE)alweMVP zG4-seIg7Hv5mPh2(%lJ)VlFlpkHjB3U(N7GhHLp9(>bSQzO&DC+OlOw?rn1^^#|+P z?p@oUs}bgHePZslyo-9?54oPiDXQ@C%QEb+vp(^na?ACe_=oFQuJwPp%=q9!j54qF Yp<=x+t*hPZf%Y?ay85}Sb4q9e09E%vSpWb4 diff --git a/8x32 PI/animations/03_vague/vague_0038.png b/8x32 PI/animations/03_vague/vague_0038.png index 1afa413ad09f1f0241f2dd5d39b79c714f9ba2b9..4a540adfb099b0f3dd86a23b4bb9a1867893cbdc 100644 GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq{2O2978JRBqvC)W;6)S3@&_b z-*R!`p%YCyw+z`2rnv~lZWGWF7Uefy^~m07N+SqxW|^$g2w}=I>0$|c%EQCs6EW4; pz`(%hgzf>YgDtUtSFngNFmT68Cxst-+y}IR!PC{xWt~$(697^@EGhs1 literal 177 zcmeAS@N?(olHy`uVBq!ia0vp^3P8-k!2~45pJ)95QWc&qjv*Cuk`opfCL9r2wd1q= z>Pvj0I$fMhVm^zlpCyEIO;x=p)xs)T{?EBLZkqs5wyk1?QP7)&6o)bkzQutKAvLaC z%*+?#5>;LltSK`vFbG*9kjk#?e6(Ju$89Uy{%{4x!zUEBH^^L01j#zAImj}hfsuiA Xht?OaOUGS+ZeZ|q^>bP0l+XkKmIpd) diff --git a/8x32 PI/animations/03_vague/vague_0039.png b/8x32 PI/animations/03_vague/vague_0039.png index f848a9e833c8d40dc79fe3bef655569cc486d776..16cb3536e1daac9677d6aba79879a8c2b2f1fdae 100644 GIT binary patch literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^96+qV!2~21Oj_3tq!K+{978JRBqvC)W;6(%Teah} z{Gk^Ci7pcpRy25pEHNlqVX*1I)N%{mmRAWK>vw#fFQ{eJ-k5mPspZ{@3bz%CCl>L5 zfRAYTh7KbG1B22t2cO35WQ|arv0=6RR0UTihT2A{i!VxS+9WZeXAj-4Png?rc+e&A>>(TBiLahk)q$0|x>o zIyK4m1YG8n>do?X diff --git a/8x32 PI/animations/04_balle.png b/8x32 PI/animations/04_balle.png index 892d737d394a3c653c240d4dea8004e74c61e4a5..4b56de714b73441648131b922fd2f3d4e2a46105 100644 GIT binary patch literal 3671 zcmZvf4NR3~6vv;J3pYT)FBG_m5SSk&N(Pu@5+5Qc0vd|>wJVB!_|a&&Tvi~FM1G~K z^p>XR)Rr6O*Oo3DD>FCKmYT}65*=xCHJ8q;q4fX1?{nJYz4zH(o^#%N?vHcMd;ZT0 zjb$Zcqar<#N~x%#!u(0t!*L~rhv4Uyb(HYL$>=QFWUpF=)wnEKsfC*?-=)Fzz44KNT(EgX;Nt4$fNIt> zPlxBxpiqG3dcAjqbx5gqzPEhOpLy#+JIX0A3^tQukxVecVa*CzI3J!Qh*wQM3h{CfQ)|s6PTiVq1{x(^?FatL%Ut`VLU3Qey zrYP7r(mFODZNUnSb*cE_mapIIZA}Q2A~~`S#=+__{dvTf z>2{Pe?|p1skFM zdKcCpL_iP0CWtyDNYCiysInq>0!lVOqQj;wN1j56fKp%+#1;{xwX>qnjNf1jsJ{uq zBT6i{eCONlP+kIh7&ak(pgp6TkKk@!t(pM zhMoHAupQ-`fx}>vMct64HQ03JAnC$NvsnQe+;_8Kryb?69)lf(HQ{nJ)hRPE94vng zdV1A7X@DNrAio0hN*BFvfYMFSD5WYFS-yg_^IHv22J9e>&uWgTvZI_6CKDjMQQJDx z-B<(MFDiBGsw?%E{S77y?<2c9zj?@byS)*gNOez`Noft zaNHGm0?IN#qQQ%Y4}OLa0kLJQj+ddqnc4Bqv^dp`@~aMi6-`NSrM7%GD$ih1+kAF@ ztmk5{-^N`T(a~T9e-}=eS9RLJWosPkV`{p_=&o0vg(o_Enz#TR4!d2#SE*E)1Dlz# z??madFLC3AbMOR}OH_ak=SL(gMUbFaFbkv(xoaJcT$%j^JUMIfiQ1y@9=uWGabfkE z5w0@VyHZDdn$ARoHU>6*PAO?xhOKK-3gL-#3hcxJWEhfvjZ-eHv9QVVdwrct&imG2 z722!-86JqOs4`eZ!C6@oU*^Fj^ED1Ee;F1={GKtW`9zO2G)u=ocA85{@hdK^5{l8(WX>dMTdvvd z9kz@DOp##I_KiF~q{bOU1(RUj{@M$O6WUDJOpFuCr6riNqc#PeNN|>&SbzjwYf@R5 NDjHLg-<<2M`wvq#0FnRz literal 3753 zcmZ{n3s96*6vyw!t}6m-fUgDFz%cXCD$0h48fbv55J=!pXw&!hCqAzz{^I3W&4p7;)CNuKOUh1HGaUPq6eHDymkPHM?*3HEE?>vjw@<2aVJv zsgy;6Mt^YsZ7?+JA7fP+Xi8bsohbt+oJS3R>!+nfU`Q#mgT{0rPSuEXP1L?Q8&=}< z4)hLk%_bbz41Z?7lLcTzsJlQ@<}qUJ*;cP_3+M!miUyRo#>lnVHg=zN!1KyaWsnTR zA2Z6=5A28*1Da@9{W|}-ox4UeT5KrVde0nQw6MoAYe>Wl>dJ`8NIwO$f!epeNXrVV zk8baF+u#{e5htAI4F8AI9`}G5xrT0%Ybt*-XI)V;gA{20P_C`&?DH<8K`BcbZfb8B zx}VPc@a$7CBbozg&DQ#;sHpBeqp>+Z`%=V37=G`?_61-^zCl^trs0NY+O6b8m!y?y zfq0;qbmz}4sT7tPFNBF(Q;Pre3;QjfW}af7!N~N#@fa0V_`Iom+k1>QO4HCiEMVPFzx{FrZ(O3KAkC~hZ+99y@ye-nyn9 zR;aqNfaR^Jh3oRx^m6EQ?}kW2q*Yp63#Qb(#b}cljU9^HuQwx~Xp=+HS_bXzWVDAs zQ%%Hx)=fF*`|Ee~nUVURNY5!!2{RirJoxQ<^*!9#{< z9?<9)(%AhwzTfadX{F+v3N(|R+}V`zq_k3f1kvA)(=N3h(3UX7A~PpoulhoHe|`@xHWD*?I&t`h}eo4nwor@@y+W6AS-;!ZWWsX~B)x z@l2pyoDCYYg)H`2obc7T$*@A%DhjXy{aw`Y*A=iK(xad$TkK=KVfAQ!wG{)&v?oWD zxR){797aR`^hFudagJZRb3@TSo*3(5v=Y!%6Bw!P$)(nLrP4~(>O7>~*^@o<4`VzO nC;o!sG^CcbdcE@4w=knFnjgxuteWChuoIq~g6w^hy&L`q<^=ey diff --git a/8x32 PI/installation/installer.sh b/8x32 PI/installation/installer.sh index 455b015..cb1d43c 100755 --- a/8x32 PI/installation/installer.sh +++ b/8x32 PI/installation/installer.sh @@ -35,10 +35,10 @@ echo "=== Partition de démarrage : $BOOT" echo echo "=== 1/5 Installation des logiciels nécessaires (quelques minutes)" -# python3-pil = Pillow (lire les images) ; python3-dev et gcc = pour compiler -# la bibliothèque rpi_ws281x, qui parle aux LED. +# python3-pil = Pillow (lire les images) ; python3-flask = le serveur web ; +# python3-dev et gcc = pour compiler la bibliothèque rpi_ws281x, qui parle aux LED. sudo apt-get update -sudo apt-get install -y python3-venv python3-pil python3-dev gcc +sudo apt-get install -y python3-venv python3-pil python3-flask python3-dev gcc echo echo "=== 2/5 Environnement Python dans $ENV" @@ -73,14 +73,22 @@ else fi echo -echo "=== 5/5 Service de démarrage automatique « vitrine-pixels »" -# On recopie le modèle de service en remplaçant @PROJET@ et @PYTHON@ +echo "=== 5/5 Services de démarrage automatique : lecteur et interface web" +# On recopie chaque modèle de service en remplaçant @PROJET@ et @PYTHON@ # par les vrais chemins. -sed -e "s|@PROJET@|$PROJET|g" -e "s|@PYTHON@|$ENV/bin/python|g" \ - "$PROJET/installation/vitrine-pixels.service" \ - | sudo tee /etc/systemd/system/vitrine-pixels.service > /dev/null +for SERVICE in vitrine-pixels vitrine-web; do + sed -e "s|@PROJET@|$PROJET|g" -e "s|@PYTHON@|$ENV/bin/python|g" \ + "$PROJET/installation/$SERVICE.service" \ + | sudo tee "/etc/systemd/system/$SERVICE.service" > /dev/null +done sudo systemctl daemon-reload -echo " service installé (pas encore activé)" +# L'interface web ne touche pas à la matrice : on peut l'activer tout de suite. +sudo systemctl enable --now vitrine-web +sudo systemctl restart vitrine-web +for ADRESSE in $(hostname -I); do + case "$ADRESSE" in *.*) echo " interface web : http://$ADRESSE" ;; esac +done +echo " lecteur installé (pas encore activé : testez d'abord la matrice)" echo echo "================================================================" diff --git a/8x32 PI/installation/vitrine-web.service b/8x32 PI/installation/vitrine-web.service new file mode 100644 index 0000000..0512c72 --- /dev/null +++ b/8x32 PI/installation/vitrine-web.service @@ -0,0 +1,31 @@ +# ============================================================================= +# Service « vitrine-web » : l'interface web (serveur_web.py) +# ============================================================================= +# +# Comme vitrine-pixels.service, c'est un MODÈLE : installation/installer.sh +# remplace @PROJET@ et @PYTHON@ par les vrais chemins. +# +# Il tourne À CÔTÉ du lecteur (service vitrine-pixels) : le serveur range les +# fichiers et laisse des « post-it », le lecteur joue les animations. +# +# Commandes utiles : +# sudo systemctl enable --now vitrine-web activer + démarrer +# sudo systemctl restart vitrine-web relancer (après une modification) +# journalctl -u vitrine-web -f voir les visites et les erreurs + +[Unit] +Description=Vitrine pixels : interface web de gestion des animations +After=network.target + +[Service] +Type=simple +ExecStart="@PYTHON@" "@PROJET@/serveur_web.py" +WorkingDirectory=@PROJET@ +# Administrateur : pour écrire dans /boot et utiliser le port 80. +User=root +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/8x32 PI/lecteur.py b/8x32 PI/lecteur.py index 1ad2007..03e2e53 100644 --- a/8x32 PI/lecteur.py +++ b/8x32 PI/lecteur.py @@ -42,13 +42,17 @@ ORGANISATION DU PROGRAMME 2. Trouver les animations dans le dossier. 3. Charger une animation : la découper en images de 32 × 8. 4. Jouer une animation. - 5. Les horaires (allumer la vitrine seulement à certaines heures). - 6. Le programme principal. + 5. Parler avec le serveur web (serveur_web.py). + 6. Les horaires (allumer la vitrine seulement à certaines heures). + 7. Le programme principal. """ import argparse # pour lire les options (--simulateur, --dossier…) +import sys import datetime # pour connaître l'heure (horaires de la vitrine) import os # pour parcourir les dossiers et les fichiers +import signal # pour réagir quand Linux demande au programme de s'arrêter +import tempfile # pour trouver le dossier temporaire de l'ordinateur import time from PIL import Image, ImageSequence @@ -169,7 +173,17 @@ def adapter_image(image, nom): and largeur // LARGEUR == hauteur // HAUTEUR: return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN) - # 3) Autre taille (par exemple 39 × 7, le format final) : on la pose + # 3) L'image est dessinée dans l'AUTRE SENS (couchée, 32 × 8, alors que la + # matrice est debout, ou l'inverse) : on la fait tourner d'un quart de + # tour, comme un texte sur le dos d'un livre (il se lit de bas en haut). + if LARGEUR != HAUTEUR and largeur % HAUTEUR == 0 and hauteur % LARGEUR == 0 \ + and largeur // HAUTEUR == hauteur // LARGEUR: + avertir(nom, f"image couchée ({largeur} × {hauteur}) sur une matrice " + f"{'debout' if HAUTEUR > LARGEUR else 'couchée'} : tournée d'un quart de tour") + image = image.transpose(UN_QUART_DE_TOUR) + return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN) + + # 4) Autre taille (par exemple 39 × 7, le format final) : on la pose # au centre, sans la déformer. Ce qui dépasse est coupé. avertir(nom, f"taille {largeur} × {hauteur} au lieu de {LARGEUR} × {HAUTEUR} : " "image centrée (ce qui dépasse est coupé)") @@ -181,6 +195,8 @@ def adapter_image(image, nom): # Le nom du filtre « plus proche voisin » a changé avec Pillow 9.1 : # on prend le nouveau s'il existe, sinon l'ancien (Raspbian 11 a Pillow 8). PLUS_PROCHE_VOISIN = getattr(Image, "Resampling", Image).NEAREST +# Idem pour « tourner d'un quart de tour dans le sens inverse des aiguilles d'une montre ». +UN_QUART_DE_TOUR = getattr(Image, "Transpose", Image).ROTATE_90 _deja_averti = set() @@ -241,18 +257,52 @@ def charger(chemin): # ============================================================================= def jouer(etapes, duree_minimum=DUREE_MINIMUM): - """Joue la liste d'étapes, et la rejoue jusqu'à atteindre la durée minimum.""" + """Joue la liste d'étapes, et la rejoue jusqu'à atteindre la durée minimum. + S'arrête plus tôt si le serveur web demande autre chose (partie 5).""" debut = time.monotonic() while True: for image, duree in etapes: moment = time.monotonic() matrice.montrer(image) # On retire le temps déjà passé à afficher, pour garder le rythme. - matrice.attendre((duree - (time.monotonic() - moment)) * 1000) + if not attendre_ou_interrompre(duree - (time.monotonic() - moment)): + return if time.monotonic() - debut >= duree_minimum: break +def attendre_ou_interrompre(secondes): + """Attend, par petits bouts de 0,2 s, en surveillant les demandes du + serveur web. Renvoie False si une demande est arrivée entre-temps.""" + fin = time.monotonic() + secondes + while True: + if demande_en_attente(): + return False + reste = fin - time.monotonic() + if reste <= 0: + return True + matrice.attendre(min(reste, 0.2) * 1000) + + +def jouer_animation(chemin, dossier): + """Charge et joue UNE animation (fichier ou séquence), puis un court noir.""" + nom = os.path.relpath(chemin, dossier) + print("▶", nom) + ecrire_en_cours(nom) + try: + etapes = charger(chemin) + except Exception as erreur: + # Un fichier abîmé ne doit pas arrêter la vitrine : on le saute. + print(f" ✗ impossible de lire ce fichier ({erreur}) : on passe au suivant") + return + if etapes: + jouer(etapes) + if PAUSE_ENTRE_ANIMATIONS > 0: + matrice.effacer() + matrice.montrer() + matrice.attendre(PAUSE_ENTRE_ANIMATIONS * 1000) + + def ecran_d_attente(dossier): """Quand il n'y a rien à jouer : un petit point qui respire en bas à droite.""" print(f"Aucune animation dans {dossier} : en attente…") @@ -261,11 +311,88 @@ def ecran_d_attente(dossier): lumiere = int(60 + 60 * abs(25 - i) / 25) matrice.pixel(LARGEUR - 1, HAUTEUR - 1, (0, lumiere, lumiere)) matrice.montrer() - matrice.attendre(100) + if not attendre_ou_interrompre(0.1): + return # une demande est arrivée depuis la page web # ============================================================================= -# 5. LES HORAIRES +# 5. PARLER AVEC LE SERVEUR WEB +# ============================================================================= +# +# Le serveur web (serveur_web.py) et le lecteur sont deux programmes séparés, +# qui tournent en même temps. Pour se parler, ils s'échangent de tout petits +# fichiers, comme des post-it, dans un dossier qui est en MÉMOIRE VIVE +# (/run/vitrine-pixels) : rien n'est écrit sur la carte SD. +# - « en_cours » : le lecteur y écrit le nom de l'animation qu'il joue, +# pour que la page web puisse l'afficher ; +# - « demande » : le serveur y écrit le nom d'une animation à jouer +# TOUT DE SUITE, ou « suivante » pour passer à la suivante. +# Le lecteur la lit, puis efface le post-it. + +SUIVANTE = "suivante" + + +def trouver_dossier_etat(): + """/run/vitrine-pixels sur le Pi ; le dossier temporaire sur un ordinateur + où l'on n'a pas le droit d'écrire dans /run.""" + for dossier in ("/run/vitrine-pixels", + os.path.join(tempfile.gettempdir(), "vitrine-pixels")): + try: + os.makedirs(dossier, exist_ok=True) + if os.access(dossier, os.W_OK): + return dossier + except OSError: + pass + return tempfile.gettempdir() + + +DOSSIER_ETAT = trouver_dossier_etat() +FICHIER_EN_COURS = os.path.join(DOSSIER_ETAT, "en_cours") +FICHIER_DEMANDE = os.path.join(DOSSIER_ETAT, "demande") + + +def ecrire_post_it(fichier, texte): + try: + with open(fichier, "w") as f: + f.write(texte) + except OSError: + pass # pas grave : la page web n'affichera simplement pas l'info + + +def ecrire_en_cours(nom): + ecrire_post_it(FICHIER_EN_COURS, nom) + + +def demande_en_attente(): + return os.path.exists(FICHIER_DEMANDE) + + +def lire_demande(): + """Renvoie la demande du serveur web (et l'efface), ou None s'il n'y en a pas.""" + try: + with open(FICHIER_DEMANDE) as f: + demande = f.read().strip() + os.remove(FICHIER_DEMANDE) + return demande or None + except OSError: + return None + + +def traiter_demandes(dossier): + """Joue les animations demandées depuis la page web, s'il y en a.""" + demande = lire_demande() + while demande: + if demande != SUIVANTE: + chemin = os.path.join(dossier, demande) + if os.path.exists(chemin): + jouer_animation(chemin, dossier) + else: + print(" ✗ animation demandée introuvable :", demande) + demande = lire_demande() + + +# ============================================================================= +# 6. LES HORAIRES # ============================================================================= def en_horaires(): @@ -280,7 +407,7 @@ def en_horaires(): # ============================================================================= -# 6. LE PROGRAMME PRINCIPAL +# 7. LE PROGRAMME PRINCIPAL # ============================================================================= def main(): @@ -295,33 +422,33 @@ def main(): matrice.demarrer(simulateur=args.simulateur) dossier = args.dossier or trouver_dossier() + # Quand le service est arrêté (systemctl stop, extinction du Pi), Linux + # envoie le signal « SIGTERM ». Par défaut, Python s'arrêterait net, sans + # passer par « finally » : la matrice resterait figée sur la dernière image. + # On transforme ce signal en sortie normale du programme. + signal.signal(signal.SIGTERM, lambda numero, cadre: sys.exit(0)) + try: while True: # boucle infinie : la vitrine tourne jusqu'à ce qu'on l'arrête if not en_horaires(): + ecrire_en_cours("(éteint : en dehors des horaires)") matrice.eteindre() matrice.attendre(30_000) # on revérifie l'heure toutes les 30 s continue # Un seul fichier demandé, ou tout le dossier ? - playlist = [args.chemin] if args.chemin else lister_animations(dossier) + playlist = [os.path.abspath(args.chemin)] if args.chemin \ + else lister_animations(dossier) if not playlist: + ecrire_en_cours("(rien à jouer)") ecran_d_attente(dossier) + traiter_demandes(dossier) continue for chemin in playlist: - print("▶", os.path.relpath(chemin, dossier) if not args.chemin else chemin) - try: - etapes = charger(chemin) - except Exception as erreur: - # Un fichier abîmé ne doit pas arrêter la vitrine : on le saute. - print(f" ✗ impossible de lire ce fichier ({erreur}) : on passe au suivant") - continue - if etapes: - jouer(etapes) - if PAUSE_ENTRE_ANIMATIONS > 0: - matrice.effacer() - matrice.montrer() - matrice.attendre(PAUSE_ENTRE_ANIMATIONS * 1000) + traiter_demandes(dossier) # « jouer maintenant » depuis le web ? + if os.path.exists(chemin): # (elle a pu être supprimée entre-temps) + jouer_animation(chemin, dossier) if not en_horaires(): break @@ -333,6 +460,7 @@ def main(): # on ne laisse jamais la matrice allumée en partant. try: matrice.eteindre() + os.remove(FICHIER_EN_COURS) except Exception: pass diff --git a/8x32 PI/matrice.py b/8x32 PI/matrice.py index a5ca469..ae1858e 100644 --- a/8x32 PI/matrice.py +++ b/8x32 PI/matrice.py @@ -68,10 +68,25 @@ from PIL import Image # 1. RÉGLAGES # ============================================================================= -# Taille de la matrice : 32 colonnes (largeur) × 8 lignes (hauteur). -LARGEUR = 32 -HAUTEUR = 8 -NB_LEDS = LARGEUR * HAUTEUR # = 256 +# Le PANNEAU, tel qu'il est fabriqué : 32 colonnes × 8 lignes de LED, +# câblées en serpentin par colonnes de 8. Ça ne change jamais. +COLONNES_PANNEAU = 32 +LIGNES_PANNEAU = 8 +NB_LEDS = COLONNES_PANNEAU * LIGNES_PANNEAU # = 256 + +# ROTATION : comment le panneau est POSÉ dans la vitrine, en degrés. +# 0 = couché (à l'horizontale) : l'image fait 32 de large × 8 de haut ; +# 90 = debout (à la verticale) : l'image fait 8 de large × 32 de haut. +# (180 et 270 existent aussi : couché à l'envers, debout dans l'autre sens. +# Pour remettre l'image à l'endroit, INVERSER_X / INVERSER_Y suffisent.) +ROTATION = 90 + +# Taille de l'IMAGE à dessiner (dans Krita, dans le code…), selon la rotation. +# Tout le reste du programme utilise LARGEUR et HAUTEUR. +if ROTATION in (90, 270): + LARGEUR, HAUTEUR = LIGNES_PANNEAU, COLONNES_PANNEAU # 8 × 32 : debout +else: + LARGEUR, HAUTEUR = COLONNES_PANNEAU, LIGNES_PANNEAU # 32 × 8 : couché # Broche du Raspberry Pi reliée (à travers l'adaptateur 74AHCT125) au fil # VERT (DIN) de la matrice. Ce numéro est le numéro « GPIO », pas le numéro @@ -101,10 +116,11 @@ LUMINOSITE = 40 # (ex. : alimentation d'établi limitée à 3 A → 2500 mA ici). COURANT_MAX_MA = 2500 -# ORIENTATION de la matrice (voir test_matrice.py). +# SENS de l'image (voir test_matrice.py), une fois ROTATION choisie. # Le pixel ROUGE du test doit être EN HAUT À GAUCHE, le VERT EN HAUT À DROITE. # - rouge et vert inversés (gauche/droite) → INVERSER_X = True # - rouge et vert en bas au lieu d'en haut → INVERSER_Y = True +# - les deux à la fois (image à l'envers) → les deux à True INVERSER_X = False INVERSER_Y = False @@ -159,35 +175,57 @@ def teinte(t, saturation=255, valeur=255): # 3. DE LA POSITION (x, y) AU NUMÉRO DE LED # ============================================================================= # -# Pour dessiner, on pense en coordonnées : -# x = colonne, de 0 (à gauche) à 31 (à droite) -# y = ligne, de 0 (en haut) à 7 (en bas) +# Pour dessiner, on pense en coordonnées, sur l'IMAGE telle qu'on la voit : +# x = colonne, de 0 (à gauche) à LARGEUR - 1 (à droite) +# y = ligne, de 0 (en haut) à HAUTEUR - 1 (en bas) # Le point (0, 0) est en haut à gauche, comme dans Krita. # -# Mais les LED sont numérotées dans l'ordre de la chaîne, qui fait des -# zigzags (« serpentin ») : elle descend la 1re colonne, remonte la 2e… +# Pour trouver la LED correspondante, trois étapes : # -# x=0 x=1 x=2 x=3 -# y=0 0 15 16 31 … -# y=1 1 14 17 30 -# … … … … … -# y=7 7 → 8 23 → 24 +# 1. Retourner l'image si besoin (INVERSER_X / INVERSER_Y). # -# C'est la même fonction que dans matrice_8x32.ino, traduite en Python. +# 2. Tourner : passer de la position sur l'IMAGE à la position sur le +# PANNEAU (px de 0 à 31, py de 0 à 7, panneau vu couché). Exemple, +# panneau debout (ROTATION = 90, tourné d'un quart de tour vers la +# droite) : le haut de l'image correspond à la gauche du panneau, et la +# droite de l'image au haut du panneau. +# +# 3. Le serpentin : les LED sont numérotées dans l'ordre de la chaîne, qui +# descend la 1re colonne du panneau, remonte la 2e… +# +# px=0 px=1 px=2 px=3 +# py=0 0 15 16 31 … +# py=1 1 14 17 30 +# … … … … … +# py=7 7 → 8 23 → 24 +# +# L'étape 3 est la même fonction que dans matrice_8x32.ino. def XY(x, y): - """Renvoie le numéro (0 à 255) de la LED placée en colonne x, ligne y.""" + """Renvoie le numéro (0 à 255) de la LED qui affiche le pixel (x, y) de l'image.""" + # 1. Retourner. if INVERSER_X: x = (LARGEUR - 1) - x if INVERSER_Y: y = (HAUTEUR - 1) - y - if x % 2 == 0: - # Colonne paire : la chaîne DESCEND. - return x * HAUTEUR + y + # 2. Tourner : position (px, py) sur le panneau couché. + if ROTATION == 90: + px, py = y, (LIGNES_PANNEAU - 1) - x + elif ROTATION == 180: + px, py = (COLONNES_PANNEAU - 1) - x, (LIGNES_PANNEAU - 1) - y + elif ROTATION == 270: + px, py = (COLONNES_PANNEAU - 1) - y, x + else: + px, py = x, y + + # 3. Le serpentin. + if px % 2 == 0: + # Colonne paire du panneau : la chaîne DESCEND. + return px * LIGNES_PANNEAU + py else: # Colonne impaire : la chaîne REMONTE. - return x * HAUTEUR + (HAUTEUR - 1 - y) + return px * LIGNES_PANNEAU + (LIGNES_PANNEAU - 1 - py) # ============================================================================= @@ -316,7 +354,9 @@ class MatriceReelle: class Simulateur: """Une fenêtre sur l'ordinateur qui imite la matrice (256 ronds).""" - TAILLE = 26 # taille d'un pixel à l'écran, en points + # Taille d'un pixel à l'écran, en points : plus petit si l'image est + # haute (panneau debout), pour que la fenêtre tienne dans l'écran. + TAILLE = min(26, 640 // max(LARGEUR, HAUTEUR)) MARGE = 4 # espace entre deux pixels def __init__(self, titre="Simulateur — matrice 8 × 32"): diff --git a/8x32 PI/mes_animations.py b/8x32 PI/mes_animations.py index 2bf6372..abe430c 100644 --- a/8x32 PI/mes_animations.py +++ b/8x32 PI/mes_animations.py @@ -67,8 +67,8 @@ def anim_arc_en_ciel(): for temps in range(256): for x in range(LARGEUR): for y in range(HAUTEUR): - # La teinte dépend de la colonne et du temps qui passe. - pixel(x, y, teinte(x * 8 + temps * 2)) + # La teinte dépend de la position et du temps qui passe. + pixel(x, y, teinte((x + y) * 8 + temps * 2)) montrer() attendre(20) @@ -95,16 +95,16 @@ def anim_pluie(): # --- Animation 3 : un dessin en pixel art qui défile --- -# Une lettre par pixel, « . » = éteint. 8 lignes de même longueur. +# Une lettre par pixel, « . » = éteint. Toutes les lignes de même longueur, +# pas plus de 8 pixels de large ni de haut (le petit côté de la matrice). DESSIN = [ - "..RR.RR..", - ".RBRRRRR.", - ".RRRRRRR.", - ".RRRRRRR.", - "..RRRRR..", - "...RRR...", - "....R....", - ".........", + ".RR..RR.", + "RBBRRRRR", + "RBRRRRRR", + "RRRRRRRR", + ".RRRRRR.", + "..RRRR..", + "...RR...", ] # Un « dictionnaire » associe chaque lettre à une couleur. @@ -114,20 +114,32 @@ COULEUR_DE_LETTRE = { } -def dessiner_dessin(depart_x): +def dessiner_dessin(depart_x, depart_y): for y, ligne in enumerate(DESSIN): # enumerate donne le numéro ET la ligne for x, lettre in enumerate(ligne): if lettre in COULEUR_DE_LETTRE: - pixel(depart_x + x, y, COULEUR_DE_LETTRE[lettre]) + pixel(depart_x + x, depart_y + y, COULEUR_DE_LETTRE[lettre]) def anim_dessin_qui_defile(): largeur_dessin = len(DESSIN[0]) # len = longueur de la ligne - for x in range(LARGEUR, -largeur_dessin - 1, -1): - effacer() - dessiner_dessin(x) - montrer() - attendre(70) + hauteur_dessin = len(DESSIN) # = nombre de lignes + if HAUTEUR > LARGEUR: + # Matrice DEBOUT : le dessin monte du bas vers le haut. + x = (LARGEUR - largeur_dessin) // 2 # centré + for y in range(HAUTEUR, -hauteur_dessin - 1, -1): + effacer() + dessiner_dessin(x, y) + montrer() + attendre(70) + else: + # Matrice COUCHÉE : le dessin passe de droite à gauche. + y = (HAUTEUR - hauteur_dessin) // 2 + for x in range(LARGEUR, -largeur_dessin - 1, -1): + effacer() + dessiner_dessin(x, y) + montrer() + attendre(70) # --- Animation 4 : étincelles --- @@ -144,8 +156,8 @@ def anim_etincelles(): def anim_ma_premiere(): for x in range(LARGEUR): effacer() - pixel(x, 3, JAUNE) - pixel(x, 4, JAUNE) + pixel(x, HAUTEUR // 2 - 1, JAUNE) # deux points au milieu de la hauteur + pixel(x, HAUTEUR // 2, JAUNE) montrer() attendre(50) diff --git a/8x32 PI/outils/generer_exemples.py b/8x32 PI/outils/generer_exemples.py index 7c9d878..3fa7509 100755 --- a/8x32 PI/outils/generer_exemples.py +++ b/8x32 PI/outils/generer_exemples.py @@ -25,12 +25,15 @@ Pillow, puis les jouer comme des dessins. import math import os +import sys from PIL import Image ICI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ANIMATIONS = os.path.join(ICI, "animations") -L, H = 32, 8 +sys.path.insert(0, ICI) +from matrice import LARGEUR as L, HAUTEUR as H # noqa: E402 (8 × 32 si debout) +DEBOUT = H > L def nouvelle_image(): @@ -56,14 +59,13 @@ PETIT_COEUR = [ "...R...", ] GRAND_COEUR = [ - ".RRR.RRR.", - "RBBRRRRRR", - "RBRRRRRRR", - "RRRRRRRRR", - ".RRRRRRR.", - "..RRRRR..", - "...RRR...", - "....R....", + ".RR..RR.", + "RBBRRRRR", + "RBRRRRRR", + "RRRRRRRR", + ".RRRRRR.", + "..RRRR..", + "...RR...", ] @@ -75,7 +77,7 @@ def coeur_qui_bat(): (GRAND_COEUR, 160)]: im = nouvelle_image() largeur = len(motif[0]) - dessiner(im, motif, (L - largeur) // 2, (H - len(motif)) // 2 + (len(motif) == 6), couleurs) + dessiner(im, motif, (L - largeur) // 2, (H - len(motif)) // 2, couleurs) images.append(im) durees.append(duree) images[0].save(os.path.join(ANIMATIONS, "01_coeur_qui_bat.gif"), save_all=True, @@ -103,26 +105,43 @@ POLICE = { } -def texte_defilant(texte="VITRINE PIXELS"): - # 1) On écrit tout le texte sur une longue bande. - largeur_totale = sum(len(POLICE[c][0]) + 1 for c in texte) - bande = Image.new("RGB", (largeur_totale, H)) - x = 0 - for i, c in enumerate(texte): - # chaque lettre a sa propre teinte : un petit arc-en-ciel - r, v, b = [int(255 * (0.5 + 0.5 * math.cos(2 * math.pi * (i / len(texte) + k / 3)))) - for k in range(3)] - for y, ligne in enumerate(POLICE[c]): - for dx, p in enumerate(ligne): - if p == "#": - bande.putpixel((x + dx, y), (r, v, b)) - x += len(POLICE[c][0]) + 1 +def couleur_de_lettre(i, n): + """Chaque lettre a sa propre teinte : un petit arc-en-ciel.""" + return tuple(int(255 * (0.5 + 0.5 * math.cos(2 * math.pi * (i / n + k / 3)))) + for k in range(3)) - # 2) On fait glisser une « fenêtre » de 32 pixels sur la bande. + +def texte_defilant(texte="VITRINE PIXELS"): + # 1) On écrit tout le texte sur une longue bande : + # - matrice couchée : les lettres côte à côte, la bande défile vers la gauche ; + # - matrice debout : les lettres les unes SOUS les autres (comme une + # enseigne verticale), la bande défile vers le haut. + if DEBOUT: + bande = Image.new("RGB", (L, len(texte) * 8)) + for i, c in enumerate(texte): + x0 = (L - len(POLICE[c][0])) // 2 # lettre centrée + for y, ligne in enumerate(POLICE[c]): + for dx, p in enumerate(ligne): + if p == "#": + bande.putpixel((x0 + dx, i * 8 + y), couleur_de_lettre(i, len(texte))) + longueur, fenetre = bande.size[1], H + else: + longueur = sum(len(POLICE[c][0]) + 1 for c in texte) + bande = Image.new("RGB", (longueur, H)) + x = 0 + for i, c in enumerate(texte): + for y, ligne in enumerate(POLICE[c]): + for dx, p in enumerate(ligne): + if p == "#": + bande.putpixel((x + dx, y), couleur_de_lettre(i, len(texte))) + x += len(POLICE[c][0]) + 1 + fenetre = L + + # 2) On fait glisser une « fenêtre » de la taille de la matrice sur la bande. images = [] - for decalage in range(-L, largeur_totale + 1): + for decalage in range(-fenetre, longueur + 1): im = nouvelle_image() - im.paste(bande, (-decalage, 0)) + im.paste(bande, (0, -decalage) if DEBOUT else (-decalage, 0)) images.append(im) images[0].save(os.path.join(ANIMATIONS, "02_texte_defilant.gif"), save_all=True, append_images=images[1:], duration=60, loop=0) @@ -137,7 +156,9 @@ def vague(nb_images=40, ips=20): im = nouvelle_image() phase = 2 * math.pi * n / nb_images for x in range(L): - hauteur = (H - 1) / 2 * (1 + math.sin(2 * math.pi * x / L + phase)) + # Hauteur de l'eau dans la colonne x : la moitié de la matrice, + # plus ou moins une vague qui se déplace avec « phase ». + hauteur = H / 2 + max(1.5, H * 0.15) * math.sin(2 * math.pi * x / max(L, 16) + phase) for y in range(H): if H - 1 - y <= hauteur: # sous la crête de la vague # 3 bleus seulement, du plus clair (crête) au plus profond : diff --git a/8x32 PI/outils/schemas.py b/8x32 PI/outils/schemas.py index a63a7b2..853af18 100755 --- a/8x32 PI/outils/schemas.py +++ b/8x32 PI/outils/schemas.py @@ -416,7 +416,7 @@ def brochage(): def chaine(): s = Svg(1200, 330, "De Krita à la matrice : le trajet d'une animation") etapes = [ - ("#7b3fa0", "1. Dessiner", ["Krita", "document 32 × 8 px", "pixel art, sans flou"]), + ("#7b3fa0", "1. Dessiner", ["Krita", "document 8 × 32 px", "(vertical), sans flou"]), ("#b5418f", "2. Exporter", ["GIF animé", "ou séquence de PNG", "(Rendu de l'animation)"]), ("#c9682a", "3. Vérifier", ["verifier_animation.py", "+ simulateur", "sur l'ordinateur"]), ("#b08900", "4. Déposer", ["dossier « animations »", "carte SD ou réseau", "(01_, 02_… = ordre)"]), diff --git a/8x32 PI/outils/verifier_animation.py b/8x32 PI/outils/verifier_animation.py index 9dbd4fb..5314ec7 100755 --- a/8x32 PI/outils/verifier_animation.py +++ b/8x32 PI/outils/verifier_animation.py @@ -39,7 +39,7 @@ from matrice import LARGEUR, HAUTEUR, TABLE_GAMMA, LUMINOSITE, courant_estime_ma DOSSIER_APERCUS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "apercus") TAILLE_LED = 14 # taille d'un pixel dans l'aperçu, en points -MAX_PLANCHE = 48 # nombre maximum d'images sur la planche +MAX_PLANCHE = 24 # nombre maximum d'images sur la planche def pixel_eteint_sur_matrice(r, v, b): @@ -137,14 +137,22 @@ def verifier(chemin): base = os.path.join(DOSSIER_APERCUS, os.path.splitext(nom)[0] + "_apercu") images = [dessiner_leds(image) for image, _ in etapes] - # Planche : les images les unes sous les autres, numérotées. + # Planche : toutes les images, numérotées. Matrice couchée : les unes sous + # les autres ; matrice debout : côte à côte, comme une bande dessinée. montrees = images[:MAX_PLANCHE] l, h = images[0].size - planche = Image.new("RGB", (l + 50, len(montrees) * (h + 6)), (255, 255, 255)) - crayon = ImageDraw.Draw(planche) - for i, im in enumerate(montrees): - planche.paste(im, (50, i * (h + 6))) - crayon.text((6, i * (h + 6) + h // 2 - 6), str(i + 1), fill=(0, 0, 0)) + if h > l: + planche = Image.new("RGB", (len(montrees) * (l + 8), h + 24), (255, 255, 255)) + crayon = ImageDraw.Draw(planche) + for i, im in enumerate(montrees): + planche.paste(im, (i * (l + 8), 24)) + crayon.text((i * (l + 8) + l // 2 - 6, 6), str(i + 1), fill=(0, 0, 0)) + else: + planche = Image.new("RGB", (l + 50, len(montrees) * (h + 6)), (255, 255, 255)) + crayon = ImageDraw.Draw(planche) + for i, im in enumerate(montrees): + planche.paste(im, (50, i * (h + 6))) + crayon.text((6, i * (h + 6) + h // 2 - 6), str(i + 1), fill=(0, 0, 0)) planche.save(base + ".png") print(f" Aperçu : {base}.png", end="") diff --git a/8x32 PI/schemas/chaine_krita_matrice.svg b/8x32 PI/schemas/chaine_krita_matrice.svg index 445c12d..58b444c 100644 --- a/8x32 PI/schemas/chaine_krita_matrice.svg +++ b/8x32 PI/schemas/chaine_krita_matrice.svg @@ -7,8 +7,8 @@ 1. Dessiner Krita - document 32 × 8 px - pixel art, sans flou + document 8 × 32 px + (vertical), sans flou diff --git a/8x32 PI/serveur_web.py b/8x32 PI/serveur_web.py new file mode 100644 index 0000000..4c28b7e --- /dev/null +++ b/8x32 PI/serveur_web.py @@ -0,0 +1,527 @@ +""" +============================================================================= + SERVEUR WEB — gérer les animations depuis un navigateur + Workshop « Vitrine pixels » +============================================================================= + +Ce programme transforme le Raspberry Pi en petit SITE WEB. Depuis +n'importe quel ordinateur ou téléphone connecté au même réseau, on ouvre +l'adresse du Pi dans un navigateur (par exemple http://172.16.50.27 en +Wi-Fi ; voir INTERFACE_WEB.md) et on peut : + + - DÉPOSER des animations (GIF, PNG animés, séquences de PNG…) ; + - voir un APERÇU animé de chacune ; + - choisir celles qui sont JOUÉES (activer / désactiver) et leur ORDRE ; + - en jouer une TOUT DE SUITE, ou passer à la suivante ; + - les SUPPRIMER (elles passent d'abord par une corbeille). + +Il ne parle pas lui-même à la matrice : c'est toujours lecteur.py qui joue +les animations. Le serveur se contente de ranger les fichiers dans le +dossier des animations, et de laisser des « post-it » au lecteur +(voir lecteur.py, partie 5). + + +COMMENT LE LANCER +----------------- +Sur le Raspberry Pi (normalement, c'est le service « vitrine-web » qui le +lance tout seul, voir MODE_VITRINE.md) : + sudo ~/vitrine-env/bin/python serveur_web.py + +Sur votre ordinateur, pour essayer (avec le lecteur en simulateur à côté) : + python3 serveur_web.py --port 8080 +puis ouvrir http://localhost:8080 dans le navigateur. + + +LES OUTILS UTILISÉS +------------------- + - Flask : une bibliothèque Python pour fabriquer des sites web. Chaque + « route » (@app.route) associe une adresse (/, /ajouter…) à une fonction + Python qui fabrique la réponse. + - Jinja : le système de « gabarits » de Flask. La page web est écrite en + HTML dans web/page.html, avec des trous {{ … }} que Python remplit. + - Pillow : pour vérifier les images reçues et fabriquer les aperçus. + + +ORGANISATION DU PROGRAMME +------------------------- + 1. Réglages. + 2. Les noms de fichiers. + 3. Lister les animations et leurs informations. + 4. Ranger : ordre, activer / désactiver, corbeille. + 5. Les aperçus animés. + 6. Les pages et les actions (les « routes »). + 7. Le programme principal. +""" + +import argparse +import io +import os +import re +import shutil +import socket +import subprocess +import tempfile +import unicodedata + +from flask import (Flask, Response, abort, flash, jsonify, redirect, + render_template, request, send_file, url_for) +from PIL import Image + +import lecteur +from matrice import LARGEUR, HAUTEUR + + +# ============================================================================= +# 1. RÉGLAGES +# ============================================================================= + +# Port du serveur. 80 est le port « normal » du web : on tape juste +# http://172.16.50.27 dans le navigateur. (Il faut être administrateur.) +PORT = 80 + +# Mot de passe pour accéder à la page. None = pas de mot de passe : +# toute personne sur le même réseau peut ajouter ou supprimer des animations. +# Exemple : MOT_DE_PASSE = "pixels" (le navigateur demandera un nom +# d'utilisateur, n'importe lequel, et ce mot de passe). +MOT_DE_PASSE = None + +# Taille maximum d'un envoi, en mégaoctets. +TAILLE_MAX_MO = 50 + +# Sous-dossiers spéciaux du dossier des animations. Leur nom commence par +# un point : le lecteur les ignore (voir lecteur.est_cache). +DESACTIVEES = ".desactivees" +CORBEILLE = ".corbeille" + +ICI = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__, + template_folder=os.path.join(ICI, "web"), # où est page.html + static_folder=os.path.join(ICI, "web"), # où est style.css + static_url_path="/static") +app.secret_key = os.urandom(16) # nécessaire pour les messages « flash » +app.config["MAX_CONTENT_LENGTH"] = TAILLE_MAX_MO * 1024 * 1024 + +DOSSIER = lecteur.trouver_dossier() # peut être changé par --dossier + + +# ============================================================================= +# 2. LES NOMS DE FICHIERS +# ============================================================================= + +def nettoyer_nom(nom): + """« Pluie de cœurs (v2)!! » → « pluie_de_coeurs_v2 ». + Sans accent, sans espace, sans caractère spécial : des noms qui ne posent + de problème à aucun ordinateur (et pas de « ../ » malicieux).""" + nom = unicodedata.normalize("NFKD", nom) + nom = nom.replace("œ", "oe").replace("Œ", "oe").replace("æ", "ae") + nom = nom.encode("ascii", "ignore").decode("ascii").lower() # enlève les accents + nom = re.sub(r"[^a-z0-9]+", "_", nom).strip("_") + return nom or "animation" + + +def sans_numero(nom): + """« 03_vague » → « vague » : on retire le numéro d'ordre du début.""" + return re.sub(r"^\d+[_-]?", "", nom) + + +def decouper(nom): + """« 03_vague.gif » → (« 03_vague », « .gif ») ; un dossier n'a pas d'extension.""" + base, extension = os.path.splitext(nom) + if extension.lower() not in lecteur.EXTENSIONS: + return nom, "" + return base, extension.lower() + + +def chemin_sur(relatif): + """Transforme un nom reçu du navigateur en chemin, en vérifiant qu'il reste + DANS le dossier des animations. Sinon, quelqu'un pourrait demander + « ../../etc/passwd »… On refuse tout ce qui sort du dossier.""" + chemin = os.path.realpath(os.path.join(DOSSIER, relatif)) + racine = os.path.realpath(DOSSIER) + if not chemin.startswith(racine + os.sep) or not os.path.exists(chemin): + abort(404) + return chemin + + +def nom_libre(dossier, nom): + """Si « vague.gif » existe déjà dans le dossier, propose « vague_2.gif »…""" + base, extension = decouper(nom) + candidat, n = nom, 2 + while os.path.exists(os.path.join(dossier, candidat)): + candidat = f"{base}_{n}{extension}" + n += 1 + return candidat + + +# ============================================================================= +# 3. LISTER LES ANIMATIONS ET LEURS INFORMATIONS +# ============================================================================= + +def actives(): + """Noms des animations jouées, dans l'ordre de lecture.""" + return [os.path.basename(c) for c in lecteur.lister_animations(DOSSIER)] + + +def desactivees(): + return [os.path.basename(c) + for c in lecteur.lister_animations(os.path.join(DOSSIER, DESACTIVEES))] + + +def date_de_modif(chemin): + """Date de dernière modification (pour savoir si un aperçu est à refaire).""" + if os.path.isdir(chemin): + return max([os.path.getmtime(chemin)] + + [os.path.getmtime(f) for f in lecteur.images_du_dossier(chemin)]) + return os.path.getmtime(chemin) + + +# Clé du cache : le nom SANS numéro + la date, pour qu'un simple changement +# d'ordre (01_vague → 02_vague) ne force pas à tout recalculer. +_cache = {} # clé → (informations, aperçu GIF) + + +def analyser(chemin): + """Charge une animation comme le lecteur, et en tire les informations + affichées sur la page + l'aperçu. Le résultat est gardé en mémoire + (« cache ») tant que le fichier ne change pas : le Pi 3 n'est pas rapide.""" + date = date_de_modif(chemin) + cle = (sans_numero(os.path.basename(chemin)), date) + if cle in _cache: + return _cache[cle] + + # Taille d'origine (avant adaptation à 32 × 8). + premier = lecteur.images_du_dossier(chemin)[0] if os.path.isdir(chemin) else chemin + with Image.open(premier) as image: + taille = image.size + + etapes = lecteur.charger(chemin) + fixe = len(etapes) == 1 + infos = { + "images": len(etapes), + "duree": sum(d for _, d in etapes), + "fixe": fixe, + "sequence": os.path.isdir(chemin), + "taille": taille, + "taille_ok": taille == (LARGEUR, HAUTEUR), + "version": int(date), + } + apercu = fabriquer_apercu(etapes, fixe) + _cache[cle] = (infos, apercu) + return infos, apercu + + +# ============================================================================= +# 4. RANGER : ORDRE, ACTIVER / DÉSACTIVER, CORBEILLE +# ============================================================================= +# +# L'ordre de lecture, c'est l'ordre ALPHABÉTIQUE des noms (voir lecteur.py). +# Pour changer l'ordre, on renomme donc les fichiers : 01_…, 02_…, 03_… + +def renumeroter(noms): + """Renomme les animations actives pour qu'elles portent les numéros + 01, 02, 03… dans l'ordre de la liste « noms ».""" + # 1re étape : noms temporaires (commençant par un point, donc ignorés par + # le lecteur), pour ne jamais écraser un fichier qui a déjà le nom visé. + temporaires = [] + for i, nom in enumerate(noms): + temporaire = f".renommage_{i}_{nom}" + os.rename(os.path.join(DOSSIER, nom), os.path.join(DOSSIER, temporaire)) + temporaires.append(temporaire) + # 2e étape : les noms définitifs. + # Tous les numéros ont le même nombre de chiffres (01…99, ou 001…999 au-delà + # de 99 animations), sinon l'ordre alphabétique mettrait « 100 » avant « 11 ». + chiffres = max(2, len(str(len(noms)))) + for i, (nom, temporaire) in enumerate(zip(noms, temporaires)): + definitif = f"{i + 1:0{chiffres}d}_{sans_numero(nom)}" + os.rename(os.path.join(DOSSIER, temporaire), os.path.join(DOSSIER, definitif)) + + +def ranger_dans(sous_dossier, nom, depuis=None): + """Déplace une animation dans un sous-dossier (désactivées ou corbeille).""" + depuis = depuis or DOSSIER + destination = os.path.join(DOSSIER, sous_dossier) + os.makedirs(destination, exist_ok=True) + nouveau = nom_libre(destination, sans_numero(nom)) + shutil.move(os.path.join(depuis, nom), os.path.join(destination, nouveau)) + + +def ajouter_a_la_fin(chemin_source, nom): + """Place une nouvelle animation à la fin de la liste de lecture.""" + noms = actives() + nom = nom_libre(DOSSIER, f"{len(noms) + 1:02d}_{nom}") + shutil.move(chemin_source, os.path.join(DOSSIER, nom)) + renumeroter(noms + [nom]) + + +# ============================================================================= +# 5. LES APERÇUS ANIMÉS +# ============================================================================= + +# Agrandissement des aperçus : le grand côté fait 192 points +# (32 × 8 → 192 × 48 si la matrice est couchée, 8 × 32 → 48 × 192 si elle est debout). +ZOOM = 192 // max(LARGEUR, HAUTEUR) + + +def fabriquer_apercu(etapes, fixe): + """Fabrique un GIF agrandi, exactement comme la matrice va l'afficher + (après adaptation de la taille). Renvoie les octets du fichier.""" + images, durees = [], [] + for image, duree in etapes: + images.append(image.resize((LARGEUR * ZOOM, HAUTEUR * ZOOM), lecteur.PLUS_PROCHE_VOISIN)) + durees.append(max(20, round(duree * 1000))) + if fixe: + durees = [1000] + sortie = io.BytesIO() # un « fichier » en mémoire, sans toucher au disque + images[0].save(sortie, format="GIF", save_all=True, append_images=images[1:], + duration=durees, loop=0) + return sortie.getvalue() + + +# ============================================================================= +# 6. LES PAGES ET LES ACTIONS (LES « ROUTES ») +# ============================================================================= + +@app.before_request +def verifier_mot_de_passe(): + """Avant chaque page : si un mot de passe est réglé, on le demande.""" + if MOT_DE_PASSE is None: + return None + auth = request.authorization + if auth is None or auth.password != MOT_DE_PASSE: + return Response("Mot de passe nécessaire.", 401, + {"WWW-Authenticate": 'Basic realm="Vitrine pixels"'}) + return None + + +def lire_en_cours(): + try: + with open(lecteur.FICHIER_EN_COURS) as f: + return f.read().strip() + except OSError: + return "" + + +def adresses_du_pi(): + """Les adresses du Pi sur ses réseaux (câble, Wi-Fi), pour les afficher + en bas de la page : elles peuvent changer d'un jour à l'autre.""" + try: + sortie = subprocess.run(["hostname", "-I"], capture_output=True, text=True, + timeout=2).stdout + adresses = [a for a in sortie.split() if "." in a] # IPv4 seulement + except (OSError, subprocess.SubprocessError): + adresses = [] + return adresses or [socket.gethostname()] + + +def fiche(nom, sous_dossier=""): + """Tout ce que la page doit savoir sur une animation.""" + relatif = os.path.join(sous_dossier, nom) if sous_dossier else nom + chemin = os.path.join(DOSSIER, relatif) + try: + infos, _ = analyser(chemin) + erreur = None + except Exception as e: # fichier abîmé : on l'affiche quand même, avec l'erreur + infos, erreur = {"version": 0}, str(e) + return {"nom": nom, "titre": sans_numero(decouper(nom)[0]).replace("_", " "), + "relatif": relatif, "infos": infos, "erreur": erreur} + + +@app.route("/") +def accueil(): + """La page principale.""" + corbeille = os.path.join(DOSSIER, CORBEILLE) + nb_corbeille = len(os.listdir(corbeille)) if os.path.isdir(corbeille) else 0 + return render_template( + "page.html", + actives=[fiche(n) for n in actives()], + desactivees=[fiche(n, DESACTIVEES) for n in desactivees()], + en_cours=lire_en_cours(), + nb_corbeille=nb_corbeille, + dossier=DOSSIER, + adresses=adresses_du_pi(), + port="" if request.host.endswith(":80") or ":" not in request.host + else ":" + request.host.rsplit(":", 1)[1], + extensions=",".join(lecteur.EXTENSIONS), + taille_max=TAILLE_MAX_MO, + largeur=LARGEUR, hauteur=HAUTEUR, + ) + + +@app.route("/apercu/") +def apercu(relatif): + """L'aperçu GIF d'une animation (affiché dans la page par une balise ).""" + try: + _, octets = analyser(chemin_sur(relatif)) + except Exception: + abort(404) + reponse = send_file(io.BytesIO(octets), mimetype="image/gif") + reponse.headers["Cache-Control"] = "max-age=86400" # l'URL change avec la version + return reponse + + +@app.route("/etat") +def etat(): + """Ce que joue le lecteur en ce moment (la page le redemande toutes les 2 s).""" + return jsonify(en_cours=lire_en_cours()) + + +def verifier_animation(chemin): + """Vérifie qu'une animation se charge vraiment, comme le ferait le lecteur. + Renvoie un message d'erreur, ou None si tout va bien.""" + try: + if not lecteur.charger(chemin): + return "aucune image trouvée" + except Exception as erreur: + return f"fichier illisible ({erreur})" + return None + + +@app.route("/ajouter", methods=["POST"]) +def ajouter(): + """Reçoit un ou plusieurs fichiers : chacun devient une animation.""" + fichiers = [f for f in request.files.getlist("fichiers") if f.filename] + if not fichiers: + flash("Aucun fichier choisi.", "erreur") + for fichier in fichiers: + base, extension = os.path.splitext(fichier.filename) + extension = extension.lower() + if extension not in lecteur.EXTENSIONS: + flash(f"« {fichier.filename} » : format non accepté " + f"(acceptés : {', '.join(lecteur.EXTENSIONS)}).", "erreur") + continue + # On enregistre d'abord dans un dossier temporaire, on vérifie, puis on range. + descripteur, temporaire = tempfile.mkstemp(suffix=extension, dir=lecteur.DOSSIER_ETAT) + os.close(descripteur) + fichier.save(temporaire) + probleme = verifier_animation(temporaire) + if probleme: + os.remove(temporaire) + flash(f"« {fichier.filename} » refusé : {probleme}.", "erreur") + continue + ajouter_a_la_fin(temporaire, nettoyer_nom(sans_numero(base)) + extension) + flash(f"« {fichier.filename} » ajouté à la fin de la liste.", "ok") + return redirect(url_for("accueil")) + + +@app.route("/ajouter_sequence", methods=["POST"]) +def ajouter_sequence(): + """Reçoit plusieurs PNG qui forment UNE animation (export Krita en séquence).""" + fichiers = sorted((f for f in request.files.getlist("images") if f.filename), + key=lambda f: f.filename) # ordre des noms = ordre des images + nom = nettoyer_nom(request.form.get("nom", "") or "sequence") + try: + ips = float(request.form.get("ips", "12").replace(",", ".")) + assert 0.5 <= ips <= 60 + except (ValueError, AssertionError): + flash("Images par seconde : un nombre entre 0,5 et 60.", "erreur") + return redirect(url_for("accueil")) + if len(fichiers) < 2: + flash("Une séquence demande au moins 2 images PNG.", "erreur") + return redirect(url_for("accueil")) + + temporaire = tempfile.mkdtemp(dir=lecteur.DOSSIER_ETAT) + for i, fichier in enumerate(fichiers): + if not fichier.filename.lower().endswith(".png"): + shutil.rmtree(temporaire) + flash(f"« {fichier.filename} » n'est pas un PNG.", "erreur") + return redirect(url_for("accueil")) + fichier.save(os.path.join(temporaire, f"{nom}_{i:04d}.png")) + with open(os.path.join(temporaire, "images_par_seconde.txt"), "w") as f: + f.write(f"{ips:g}\n") + probleme = verifier_animation(temporaire) + if probleme: + shutil.rmtree(temporaire) + flash(f"Séquence refusée : {probleme}.", "erreur") + else: + ajouter_a_la_fin(temporaire, nom) + flash(f"Séquence « {nom} » ajoutée ({len(fichiers)} images, {ips:g} i/s).", "ok") + return redirect(url_for("accueil")) + + +@app.route("/action", methods=["POST"]) +def action(): + """Tous les boutons des fiches : monter, descendre, jouer, désactiver…""" + quoi = request.form.get("action") + nom = os.path.basename(request.form.get("nom", "")) # basename : pas de « ../ » + noms = actives() + + if quoi == "suivante": + lecteur.ecrire_post_it(lecteur.FICHIER_DEMANDE, lecteur.SUIVANTE) + flash("On passe à l'animation suivante.", "ok") + + elif quoi in ("monter", "descendre") and nom in noms: + i = noms.index(nom) + j = i - 1 if quoi == "monter" else i + 1 + if 0 <= j < len(noms): + noms[i], noms[j] = noms[j], noms[i] # on échange les deux places + renumeroter(noms) + + elif quoi == "jouer": + relatif = request.form.get("relatif", "") + chemin_sur(relatif) # vérifie que l'animation existe bien + lecteur.ecrire_post_it(lecteur.FICHIER_DEMANDE, relatif) + flash(f"« {nom} » va être jouée tout de suite.", "ok") + + elif quoi == "desactiver" and nom in noms: + ranger_dans(DESACTIVEES, nom) + noms.remove(nom) + renumeroter(noms) + flash(f"« {nom} » ne sera plus jouée.", "ok") + + elif quoi == "activer" and nom in desactivees(): + ajouter_a_la_fin(os.path.join(DOSSIER, DESACTIVEES, nom), nom) + flash(f"« {nom} » est ajoutée à la fin de la liste.", "ok") + + elif quoi == "supprimer": + if nom in noms: + ranger_dans(CORBEILLE, nom) + noms.remove(nom) + renumeroter(noms) + elif nom in desactivees(): + ranger_dans(CORBEILLE, nom, depuis=os.path.join(DOSSIER, DESACTIVEES)) + flash(f"« {nom} » est dans la corbeille.", "ok") + + elif quoi == "vider_corbeille": + shutil.rmtree(os.path.join(DOSSIER, CORBEILLE), ignore_errors=True) + flash("Corbeille vidée.", "ok") + + return redirect(url_for("accueil") + request.form.get("ancre", "")) + + +@app.errorhandler(413) +def trop_gros(erreur): + flash(f"Envoi trop gros (maximum {TAILLE_MAX_MO} Mo).", "erreur") + return redirect(url_for("accueil")) + + +@app.errorhandler(OSError) +def erreur_disque(erreur): + # Cas typique : partition protégée en écriture (overlay, voir MODE_VITRINE.md). + flash(f"Impossible d'écrire dans le dossier des animations ({erreur}). " + "La carte SD est-elle protégée en écriture ?", "erreur") + return redirect(url_for("accueil")) + + +# ============================================================================= +# 7. LE PROGRAMME PRINCIPAL +# ============================================================================= + +def main(): + global DOSSIER + options = argparse.ArgumentParser(description="Interface web de la vitrine pixels.") + options.add_argument("--port", type=int, default=PORT) + options.add_argument("--dossier", help="dossier d'animations à gérer") + args = options.parse_args() + + if args.dossier: + DOSSIER = os.path.abspath(args.dossier) + os.makedirs(DOSSIER, exist_ok=True) + print("Dossier des animations :", DOSSIER) + print(f"Ouvrez http://:{args.port} dans un navigateur.") + # host="0.0.0.0" : accepter les visites venant des autres ordinateurs du réseau. + app.run(host="0.0.0.0", port=args.port, threaded=True) + + +if __name__ == "__main__": + main() diff --git a/8x32 PI/test_matrice.py b/8x32 PI/test_matrice.py index d0d7edc..844f775 100644 --- a/8x32 PI/test_matrice.py +++ b/8x32 PI/test_matrice.py @@ -15,7 +15,8 @@ Trois tests s'enchaînent, puis tout s'éteint : 1. LES COINS : un pixel de couleur dans chaque coin, 5 secondes. ROUGE en haut à gauche VERT en haut à droite BLEU en bas à gauche BLANC en bas à droite - Sinon : changez INVERSER_X / INVERSER_Y dans matrice.py. + (la matrice étant posée comme dans la vitrine : debout si ROTATION = 90) + Sinon : changez INVERSER_X / INVERSER_Y (ou ROTATION) dans matrice.py. 2. L'ORDRE DE LA CHAÎNE : un point orange parcourt les 256 LED dans l'ordre où le signal les traverse. On voit le serpentin. diff --git a/8x32 PI/web/infos.html b/8x32 PI/web/infos.html new file mode 100644 index 0000000..9083267 --- /dev/null +++ b/8x32 PI/web/infos.html @@ -0,0 +1,12 @@ +{# Petit morceau de page, inclus dans chaque fiche : les informations d'une animation « a ». #} +{% if a.erreur %} + ⚠ fichier illisible : {{ a.erreur }} +{% else %} + + {% if a.infos.fixe %}image fixe{% else %}{{ a.infos.images }} images · {{ ("%.1f"|format(a.infos.duree)).replace(".", ",") }} s{% endif %} + {% if a.infos.sequence %}· séquence PNG{% endif %} + + {% if not a.infos.taille_ok %} + ⚠ {{ a.infos.taille[0] }} × {{ a.infos.taille[1] }} px au lieu de {{ largeur }} × {{ hauteur }} + {% endif %} +{% endif %} diff --git a/8x32 PI/web/page.html b/8x32 PI/web/page.html new file mode 100644 index 0000000..de3f2d4 --- /dev/null +++ b/8x32 PI/web/page.html @@ -0,0 +1,208 @@ + +{# + La page web de la vitrine. C'est un « gabarit » Jinja : Python (serveur_web.py) + remplace les doubles accolades par des valeurs, et répète les blocs « for ». + (Ce commentaire-ci, entre accolades-dièses, n'est pas envoyé au navigateur.) +#} + + + + + Vitrine pixels + + + + +
+

Vitrine pixels

+
+ + En ce moment : {{ en_cours or "—" }} +
+ + +
+
+
+ +
+ + {# Les messages « flash » : résultat de la dernière action #} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for categorie, message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + + +
+

Ajouter des animations

+
+ +
+

Un ou plusieurs fichiers

+

GIF animé, PNG animé, WebP ou image fixe, exportés de Krita en + {{ largeur }} × {{ hauteur }} pixels. Chaque fichier devient une animation.

+ + +
+ +
+

Une séquence de PNG

+

Toutes les images d'un export Krita « séquence d'images » + (feu_0000.png, feu_0001.png…), pour une seule animation.

+ +
+ + +
+ +
+ +
+

Maximum {{ taille_max }} Mo par envoi. Les nouvelles animations + se placent à la fin de la liste.

+
+ + +
+

Jouées, dans cet ordre {{ actives|length }}

+ {% if not actives %} +

Aucune animation active : la matrice affiche un petit point d'attente.

+ {% endif %} +
    + {% for a in actives %} +
  1. + {{ loop.index }} + Aperçu de {{ a.titre }} +
    + {{ a.titre }} + {% include "infos.html" %} +
    +
    + {% set ancre = "#a-" ~ loop.index %} + {% for quoi, signe, titre, actif in [ + ("monter", "▲", "Monter", not loop.first), + ("descendre", "▼", "Descendre", not loop.last), + ("jouer", "▶", "Jouer maintenant", true), + ("desactiver", "⏸", "Ne plus jouer", true), + ("supprimer", "✕", "Supprimer", true)] %} +
    + + + + + +
    + {% endfor %} +
    +
  2. + {% endfor %} +
+
+ + +
+

Pas jouées {{ desactivees|length }}

+ {% if not desactivees %} +

Rien ici. Le bouton ⏸ d'une animation la range ici sans la supprimer.

+ {% endif %} +
    + {% for a in desactivees %} +
  • + – + Aperçu de {{ a.titre }} +
    + {{ a.titre }} + {% include "infos.html" %} +
    +
    + {% for quoi, signe, titre in [("activer", "+", "Remettre dans la liste"), + ("jouer", "▶", "Jouer maintenant (une fois)"), + ("supprimer", "✕", "Supprimer")] %} +
    + + + + + +
    + {% endfor %} +
    +
  • + {% endfor %} +
+
+ +
+ +
+
+ Corbeille : {{ nb_corbeille }} élément(s) + + +
+

Adresses de cette page : + {% for a in adresses %}http://{{ a }}{{ port }}{% if not loop.last %} · {% endif %}{% endfor %} +
(celle qui commence comme l'adresse de votre appareil marche depuis votre réseau)

+

Dossier : {{ dossier }}

+
+ + + + diff --git a/8x32 PI/web/style.css b/8x32 PI/web/style.css new file mode 100644 index 0000000..d9e913a --- /dev/null +++ b/8x32 PI/web/style.css @@ -0,0 +1,172 @@ +/* ============================================================================= + Feuille de style de l'interface web « Vitrine pixels » + Les couleurs sont rangées dans des « variables » (--fond, --texte…) : + on les redéfinit pour le mode sombre, sans toucher au reste. + ============================================================================= */ + +:root { + --fond: #f4f1ea; + --carte: #ffffff; + --texte: #1d1b18; + --discret: #6b665d; + --bord: #ddd6c8; + --accent: #e8462b; /* le rouge-orangé d'une LED allumée */ + --accent-texte: #ffffff; + --vert: #1f8a4c; + --alerte: #b3261e; + --ecran: #101010; /* le noir de la matrice */ + --rayon: 12px; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --fond: #141312; + --carte: #1f1d1b; + --texte: #f1ede6; + --discret: #a39d92; + --bord: #36322d; + --accent: #ff5a3c; + --vert: #4cc283; + --alerte: #ff8a80; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--fond); + color: var(--texte); + font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +code { font-size: 0.9em; } + +/* ---------- en-tête ---------- */ +header { + position: sticky; top: 0; z-index: 10; + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: 8px 24px; + padding: 12px 20px; + background: var(--ecran); + color: #f1ede6; +} +h1 { margin: 0; font-size: 1.3rem; display: flex; align-items: center; gap: 10px; } +.logo { + width: 32px; height: 8px; + background: + radial-gradient(circle, var(--accent) 1.6px, transparent 2px) 0 0 / 4px 4px; +} +.statut { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.statut form { margin: 0; } +.pastille { + width: 10px; height: 10px; border-radius: 50%; + background: #4cc283; box-shadow: 0 0 8px #4cc283; +} + +main { max-width: 980px; margin: 0 auto; padding: 8px 16px 32px; } +section { margin-top: 28px; } +h2 { font-size: 1.15rem; margin: 0 0 12px; display: flex; align-items: center; gap: 8px; } +h3 { margin: 0 0 6px; font-size: 1rem; } +.compte { + font-size: 0.8rem; font-weight: 600; + background: var(--bord); border-radius: 99px; padding: 1px 9px; +} +.aide, .vide, footer { color: var(--discret); font-size: 0.9rem; } + +/* ---------- messages ---------- */ +/* En bas de l'écran, toujours visibles même si la page a défilé. */ +.messages { + position: fixed; z-index: 20; left: 50%; bottom: 16px; transform: translateX(-50%); + width: min(560px, calc(100% - 32px)); + display: flex; flex-direction: column; gap: 8px; +} +.message { + margin: 0; padding: 10px 14px; + border-radius: var(--rayon); border-left: 5px solid var(--vert); + background: var(--carte); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35); +} +.message.erreur { border-left-color: var(--alerte); } + +/* ---------- formulaires d'ajout ---------- */ +.deux-colonnes { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } +.carte { + background: var(--carte); border: 1px solid var(--bord); + border-radius: var(--rayon); padding: 16px; + display: flex; flex-direction: column; gap: 10px; +} +.carte .aide { margin: 0; } +.depot { + display: block; position: relative; cursor: pointer; + border: 2px dashed var(--bord); border-radius: var(--rayon); + padding: 18px; text-align: center; color: var(--discret); +} +.depot:hover { border-color: var(--accent); color: var(--texte); } +.depot input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; } +.champs { display: flex; gap: 10px; flex-wrap: wrap; } +.champs label { display: flex; flex-direction: column; font-size: 0.85rem; color: var(--discret); flex: 1; } +.champs input { + margin-top: 4px; padding: 8px; font: inherit; color: var(--texte); + background: var(--fond); border: 1px solid var(--bord); border-radius: 8px; +} + +/* ---------- boutons ---------- */ +.bouton { + font: inherit; cursor: pointer; + padding: 7px 14px; border-radius: 8px; + border: 1px solid var(--bord); background: var(--carte); color: var(--texte); +} +header .bouton { background: #2a2826; border-color: #3d3935; color: #f1ede6; } +.bouton:hover:not(:disabled) { border-color: var(--accent); } +.bouton:disabled { opacity: 0.35; cursor: default; } +.bouton.principal { background: var(--accent); border-color: var(--accent); color: var(--accent-texte); font-weight: 600; } +.bouton.icone { width: 38px; height: 38px; padding: 0; font-size: 1rem; } +.bouton.jouer { color: var(--vert); } +.bouton.supprimer { color: var(--alerte); } + +/* ---------- listes d'animations ---------- */ +.liste { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.fiche { + display: grid; align-items: center; gap: 8px 14px; + grid-template-columns: 28px 256px 1fr auto; + background: var(--carte); border: 1px solid var(--bord); + border-radius: var(--rayon); padding: 10px 12px; + scroll-margin-top: 80px; +} +.fiche.joue { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); } +.fiche.eteinte .apercu { opacity: 0.55; } +.numero { font-weight: 700; color: var(--discret); text-align: center; } +.apercu { + width: 256px; height: 64px; display: block; + background: var(--ecran); border-radius: 4px; + image-rendering: pixelated; /* garder les pixels nets, pas de flou */ +} +.details { display: flex; flex-direction: column; min-width: 0; } +.details strong { overflow-wrap: anywhere; } +.infos { font-size: 0.85rem; color: var(--discret); } +.alerte { font-size: 0.85rem; color: var(--alerte); } +.boutons { display: flex; gap: 6px; flex-wrap: wrap; } +.boutons form { margin: 0; } + +/* Matrice DEBOUT (8 × 32) : aperçus verticaux, plus étroits. */ +.debout .fiche { grid-template-columns: 28px 48px 1fr auto; } +.debout .apercu { width: 48px; height: 192px; } + +/* Sur téléphone : les boutons passent sous l'aperçu. */ +@media (max-width: 640px) { + .couchee .fiche { grid-template-columns: 24px 1fr; } + .couchee .apercu { width: 100%; height: auto; aspect-ratio: 4 / 1; grid-column: 2; } + .couchee .details, .couchee .boutons { grid-column: 1 / -1; } + .debout .fiche { grid-template-columns: 20px 48px 1fr; } + .debout .boutons { grid-column: 1 / -1; } +} + +footer { + max-width: 980px; margin: 0 auto; padding: 16px; + border-top: 1px solid var(--bord); + display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; +} +footer form { display: flex; align-items: center; gap: 10px; margin: 0; } +footer p { margin: 0; } diff --git a/CLAUDE.md b/CLAUDE.md index 49bddb1..be7f258 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,9 @@ Le projet avance en deux temps : - Une équipe design dessine les animations dans **Krita** (32 × 8 px, export séquence PNG ou GIF) ; le Pi les joue telles quelles. - Python : `matrice.py` (moteur : réglages, `XY()`, gamma 2.2, limite de courant, écrans `MatriceReelle` / `Simulateur` tkinter / `Enregistreur` GIF), `lecteur.py` (joue en boucle `/boot/firmware/animations` ou `animations/`), `test_matrice.py`, `mes_animations.py` (animations de l'Arduino traduites). - Matériel : Pi 3B+ / 4 / Zero 2 W (pas le Pi 5 : `rpi_ws281x` ne le gère pas), GPIO 18 (patte 12) → **74AHCT125** (3,3 V → 5 V, VCC pris sur le fil rouge de la fiche DIN) → 330 Ω → DIN. Audio désactivé (`dtparam=audio=off`). -- Installation : `installation/installer.sh` (venv `~/vitrine-env`, service systemd `vitrine-pixels`). Docs : README, INSTALLATION_PI, GUIDE_KRITA, MODE_VITRINE (overlay FS), CODER_EN_PYTHON. +- Installation : `installation/installer.sh` (venv `~/vitrine-env`, services systemd `vitrine-pixels` (lecteur) et `vitrine-web` (interface)). Docs : README, INSTALLATION_PI, GUIDE_KRITA, MODE_VITRINE (overlay FS), INTERFACE_WEB, CODER_EN_PYTHON. +- Interface web `serveur_web.py` (Flask, port 80, gabarits dans `web/`) : dépôt GIF/PNG/séquences, ordre (renommage `01_…`), activer/désactiver (`.desactivees/`), corbeille (`.corbeille/`), « jouer maintenant » via post-it `/run/vitrine-pixels/demande` lu par le lecteur. +- Pi installé : Raspberry Pi 3 Model B, Raspbian 11 Bullseye 32 bits (dossier boot = `/boot`, Pillow 8.1, Flask 1.1), `ssh devel@172.16.7.199` (clé ESADHaR, câble eth0) ; Wi-Fi wlan0 = 172.16.50.27. Le Wi-Fi et le filaire de l'école ne se voient pas : depuis le Wi-Fi, page web sur http://172.16.50.27 (adresses DHCP, affichées en bas de la page). Matrice posée debout : `ROTATION = 90`, image 8 × 32. Pas de 74AHCT125 pendant le workshop : montage A (matrice en 4,4 V) ou B (transistor, `INVERSER_SIGNAL`). - Schémas générés par `outils/schemas.py`, exemples et palette Krita par `outils/generer_exemples.py`. ### Chiffres clés