""" ============================================================================= LECTEUR D'ANIMATIONS — de Krita à la matrice Workshop « Vitrine pixels » ============================================================================= Ce programme joue EN BOUCLE toutes les animations d'un dossier : - des GIF animés (coeur.gif) - des PNG animés (APNG) (vague.png) - des WebP animés (pluie.webp) - des images fixes (logo.png : affichée quelques secondes) - des SÉQUENCES d'images (un sous-dossier rempli de PNG numérotés : vague_0000.png, vague_0001.png…, comme Krita les exporte) Elles sont jouées par ordre alphabétique : pour choisir l'ordre, commencez les noms par un numéro (01_coeur.gif, 02_vague/…). Le dossier est relu à chaque tour : on peut ajouter ou retirer des animations sans arrêter le programme. COMMENT LE LANCER ----------------- Sur le Raspberry Pi (sudo est obligatoire pour parler à la matrice) : sudo ~/vitrine-env/bin/python lecteur.py Sur votre ordinateur, avec le simulateur : python3 lecteur.py --simulateur Pour essayer UN SEUL fichier ou dossier : python3 lecteur.py --simulateur mon_animation.gif Pour lire un autre dossier : python3 lecteur.py --dossier /chemin/vers/mes/animations ORGANISATION DU PROGRAMME ------------------------- 1. Réglages. 2. Trouver les animations dans le dossier. 3. Charger une animation : la découper en images de 32 × 8. 4. Jouer une animation. 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 import matrice # notre moteur (fichier matrice.py, dans le même dossier) from matrice import LARGEUR, HAUTEUR # ============================================================================= # 1. RÉGLAGES # ============================================================================= # Où chercher les animations ? On essaie ces dossiers dans l'ordre et on # prend le premier qui existe. # - /boot/firmware/animations : sur la carte SD du Pi, dans la partie # qu'on peut ouvrir depuis N'IMPORTE QUEL ordinateur (Windows, Mac, # Linux). Voir MODE_VITRINE.md. # - /boot/animations : la même chose sur les systèmes plus anciens # (Raspbian 11 « Bullseye » et avant). # - le dossier « animations » à côté de ce programme. ICI = os.path.dirname(os.path.abspath(__file__)) DOSSIERS_POSSIBLES = [ "/boot/firmware/animations", "/boot/animations", os.path.join(ICI, "animations"), ] # Vitesse des SÉQUENCES d'images (sous-dossiers de PNG), en images par # seconde, si le dossier ne contient pas de fichier « images_par_seconde.txt ». # Les GIF, APNG et WebP, eux, contiennent leur propre vitesse : Krita # l'enregistre à l'export. IMAGES_PAR_SECONDE = 12 # Durée minimum d'une animation, en secondes. Une animation courte # (un cœur qui bat en 1 s) est rejouée jusqu'à atteindre cette durée. # Une image fixe reste affichée pendant cette durée. DUREE_MINIMUM = 10 # VITESSE de lecture de toutes les animations : # 1 = la vitesse prévue dans Krita ; # 0.5 = deux fois plus lent ; 2 = deux fois plus vite. # La page web peut la changer : elle l'écrit alors dans le fichier caché # « .vitesse.txt » du dossier des animations, qui a la priorité sur ce réglage. VITESSE = 1.0 # Noir entre deux animations, en secondes (0 = enchaîner directement). PAUSE_ENTRE_ANIMATIONS = 0.3 # HORAIRES de la vitrine : la matrice ne s'allume qu'entre ces deux heures. # None = toujours allumée. Exemple : HORAIRES = ("09:00", "22:30") HORAIRES = None # Extensions de fichiers reconnues (en minuscules). EXTENSIONS = (".gif", ".png", ".apng", ".webp", ".jpg", ".jpeg", ".bmp") # ============================================================================= # 2. TROUVER LES ANIMATIONS # ============================================================================= def trouver_dossier(): """Renvoie le premier dossier d'animations qui existe.""" for dossier in DOSSIERS_POSSIBLES: if os.path.isdir(dossier): return dossier return DOSSIERS_POSSIBLES[-1] def est_cache(nom): """Les fichiers qui commencent par un point sont « cachés ». Un Mac en crée sur les clés USB et cartes SD (._coeur.gif, .DS_Store) : ce ne sont pas des images, il faut les ignorer.""" return nom.startswith(".") def images_du_dossier(dossier): """Liste triée des images contenues dans un dossier (pour une séquence).""" return sorted( os.path.join(dossier, nom) for nom in os.listdir(dossier) if not est_cache(nom) and nom.lower().endswith(EXTENSIONS) ) def lister_animations(dossier): """Liste, par ordre alphabétique, ce qu'il y a à jouer dans le dossier : les fichiers images, et les sous-dossiers qui contiennent des images.""" if not os.path.isdir(dossier): return [] resultat = [] for nom in sorted(os.listdir(dossier), key=str.lower): if est_cache(nom): continue chemin = os.path.join(dossier, nom) if os.path.isdir(chemin): if images_du_dossier(chemin): resultat.append(chemin) elif nom.lower().endswith(EXTENSIONS): resultat.append(chemin) return resultat # ============================================================================= # 3. CHARGER UNE ANIMATION # ============================================================================= # # Une animation, pour nous, c'est une LISTE d'étapes. Chaque étape est un # couple (image de 32 × 8, durée en secondes). def adapter_image(image, nom): """Met n'importe quelle image au format de la matrice : 32 × 8, RVB.""" # 1) La transparence devient du noir (= LED éteinte). image = image.convert("RGBA") fond = Image.new("RGBA", image.size, (0, 0, 0, 255)) image = Image.alpha_composite(fond, image).convert("RGB") largeur, hauteur = image.size if (largeur, hauteur) == (LARGEUR, HAUTEUR): return image # parfait, rien à faire # 2) L'image est un AGRANDISSEMENT exact (320 × 80 = 32 × 8 fois 10) : # on la réduit avec « le plus proche voisin », qui garde des pixels # nets sans inventer de couleurs intermédiaires. if largeur % LARGEUR == 0 and hauteur % HAUTEUR == 0 \ and largeur // LARGEUR == hauteur // HAUTEUR: return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN) # 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é)") toile = Image.new("RGB", (LARGEUR, HAUTEUR)) toile.paste(image, ((LARGEUR - largeur) // 2, (HAUTEUR - hauteur) // 2)) return toile # 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() def avertir(nom, message): """Affiche un avertissement, une seule fois par fichier et par message.""" if (nom, message) not in _deja_averti: _deja_averti.add((nom, message)) print(f" ⚠ {os.path.basename(nom)} : {message}") def lire_vitesse(dossier): """Lit « images_par_seconde.txt » dans le dossier d'une séquence, s'il existe.""" fichier = os.path.join(dossier, "images_par_seconde.txt") try: with open(fichier) as f: return float(f.read().strip().replace(",", ".")) except (OSError, ValueError): return IMAGES_PAR_SECONDE def charger_fichier(chemin): """Découpe un fichier image (fixe ou animé) en liste d'étapes.""" etapes = [] with Image.open(chemin) as fichier: # ImageSequence.Iterator parcourt les images d'un fichier animé. # Pour une image fixe, il n'y en a qu'une. for image in ImageSequence.Iterator(fichier): # La durée de chaque image est rangée dans le fichier, en ms. # Certains GIF indiquent 0 : on prend alors 100 ms. duree_ms = image.info.get("duration") or 100 etapes.append((adapter_image(image, chemin), duree_ms / 1000)) if len(etapes) == 1: # Image fixe : on l'affiche pendant toute la durée minimum. etapes = [(etapes[0][0], DUREE_MINIMUM)] return etapes def charger_sequence(dossier): """Charge un dossier de PNG numérotés comme une animation.""" duree = 1 / lire_vitesse(dossier) etapes = [] for chemin in images_du_dossier(dossier): with Image.open(chemin) as image: etapes.append((adapter_image(image, dossier), duree)) return etapes def charger(chemin): """Charge une animation, que ce soit un fichier ou un dossier.""" if os.path.isdir(chemin): return charger_sequence(chemin) return charger_fichier(chemin) # ============================================================================= # 4. JOUER UNE ANIMATION # ============================================================================= def lire_vitesse_de_lecture(dossier): """La vitesse choisie sur la page web (fichier .vitesse.txt), sinon VITESSE.""" try: with open(os.path.join(dossier, ".vitesse.txt")) as f: vitesse = float(f.read().strip().replace(",", ".")) return min(4.0, max(0.1, vitesse)) # entre ×0,1 et ×4, par prudence except (OSError, ValueError): return VITESSE def jouer(etapes, duree_minimum=DUREE_MINIMUM, vitesse=1.0): """Joue la liste d'étapes, et la rejoue jusqu'à atteindre la durée minimum. vitesse = 0.5 : chaque image reste affichée deux fois plus longtemps. 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. if not attendre_ou_interrompre(duree / vitesse - (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, vitesse=lire_vitesse_de_lecture(dossier)) 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…") for i in range(50): matrice.effacer() lumiere = int(60 + 60 * abs(25 - i) / 25) matrice.pixel(LARGEUR - 1, HAUTEUR - 1, (0, lumiere, lumiere)) matrice.montrer() if not attendre_ou_interrompre(0.1): return # une demande est arrivée depuis la page web # ============================================================================= # 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(): """Vrai si la vitrine doit être allumée à cette heure-ci.""" if HORAIRES is None: return True maintenant = datetime.datetime.now().strftime("%H:%M") # ex. "14:05" debut, fin = HORAIRES if debut <= fin: return debut <= maintenant < fin # ex. de 09:00 à 22:30 return maintenant >= debut or maintenant < fin # ex. de 18:00 à 02:00 (passe minuit) # ============================================================================= # 7. LE PROGRAMME PRINCIPAL # ============================================================================= def main(): options = argparse.ArgumentParser(description="Joue les animations sur la matrice 8 × 32.") options.add_argument("chemin", nargs="?", help="un seul fichier ou dossier à jouer (facultatif)") options.add_argument("--dossier", help="dossier d'animations à lire") options.add_argument("--simulateur", action="store_true", help="afficher dans une fenêtre au lieu de la matrice") args = options.parse_args() 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 = [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: 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 except KeyboardInterrupt: # Ctrl + C dans le terminal : on sort proprement. print("\nArrêt demandé.") finally: # « finally » s'exécute toujours, même en cas d'erreur : # on ne laisse jamais la matrice allumée en partant. try: matrice.eteindre() os.remove(FICHIER_EN_COURS) except Exception: pass # Cette ligne veut dire : « si on lance CE fichier directement, exécuter # main() ». Quand un autre programme l'importe (outils/verifier_animation.py), # main() n'est pas lancé : il peut juste réutiliser ses fonctions. if __name__ == "__main__": main()