8x32 PI : interface web, matrice verticale, compatibilité Raspbian 11

- 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 <noreply@anthropic.com>
This commit is contained in:
bach
2026-10-08 16:08:18 +02:00
co-authored by Claude Opus 5.5
parent 98fca33142
commit 2d4b0d5a5f
63 changed files with 1592 additions and 156 deletions
+12
View File
@@ -0,0 +1,12 @@
{# Petit morceau de page, inclus dans chaque fiche : les informations d'une animation « a ». #}
{% if a.erreur %}
<span class="alerte">⚠ fichier illisible : {{ a.erreur }}</span>
{% else %}
<span class="infos">
{% 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 %}
</span>
{% if not a.infos.taille_ok %}
<span class="alerte">⚠ {{ a.infos.taille[0] }} × {{ a.infos.taille[1] }} px au lieu de {{ largeur }} × {{ hauteur }}</span>
{% endif %}
{% endif %}
+208
View File
@@ -0,0 +1,208 @@
<!doctype html>
{#
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.)
#}
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vitrine pixels</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="{{ 'debout' if hauteur > largeur else 'couchee' }}">
<header>
<h1><span class="logo" aria-hidden="true"></span>Vitrine pixels</h1>
<div class="statut">
<span class="pastille" aria-hidden="true"></span>
En ce moment : <strong id="en-cours">{{ en_cours or "—" }}</strong>
<form method="post" action="{{ url_for('action') }}">
<input type="hidden" name="action" value="suivante">
<button type="submit" class="bouton">Suivante ⏭</button>
</form>
</div>
</header>
<main>
{# Les messages « flash » : résultat de la dernière action #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="messages" id="messages" role="status">
{% for categorie, message in messages %}
<p class="message {{ categorie }}">{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- =================== AJOUTER =================== -->
<section id="ajouter">
<h2>Ajouter des animations</h2>
<div class="deux-colonnes">
<form class="carte" method="post" action="{{ url_for('ajouter') }}" enctype="multipart/form-data">
<h3>Un ou plusieurs fichiers</h3>
<p class="aide">GIF animé, PNG animé, WebP ou image fixe, exportés de Krita en
<strong>{{ largeur }} × {{ hauteur }} pixels</strong>. Chaque fichier devient une animation.</p>
<label class="depot">
<input type="file" name="fichiers" multiple accept="{{ extensions }},image/*">
<span>Choisir des fichiers…</span>
</label>
<button type="submit" class="bouton principal">Envoyer</button>
</form>
<form class="carte" method="post" action="{{ url_for('ajouter_sequence') }}" enctype="multipart/form-data">
<h3>Une séquence de PNG</h3>
<p class="aide">Toutes les images d'un export Krita « séquence d'images »
(<code>feu_0000.png</code>, <code>feu_0001.png</code>…), pour une seule animation.</p>
<label class="depot">
<input type="file" name="images" multiple accept=".png,image/png">
<span>Choisir les PNG…</span>
</label>
<div class="champs">
<label>Nom <input type="text" name="nom" placeholder="feu_de_camp" required></label>
<label>Images/s <input type="number" name="ips" value="12" min="0.5" max="60" step="0.5"></label>
</div>
<button type="submit" class="bouton principal">Envoyer</button>
</form>
</div>
<p class="aide">Maximum {{ taille_max }} Mo par envoi. Les nouvelles animations
se placent à la fin de la liste.</p>
</section>
<!-- =================== JOUÉES =================== -->
<section id="jouees">
<h2>Jouées, dans cet ordre <span class="compte">{{ actives|length }}</span></h2>
{% if not actives %}
<p class="vide">Aucune animation active : la matrice affiche un petit point d'attente.</p>
{% endif %}
<ol class="liste">
{% for a in actives %}
<li class="fiche" id="a-{{ loop.index }}" data-relatif="{{ a.relatif }}">
<span class="numero">{{ loop.index }}</span>
<img class="apercu" src="{{ url_for('apercu', relatif=a.relatif) }}?v={{ a.infos.version }}"
alt="Aperçu de {{ a.titre }}" loading="lazy">
<div class="details">
<strong>{{ a.titre }}</strong>
{% include "infos.html" %}
</div>
<div class="boutons">
{% 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)] %}
<form method="post" action="{{ url_for('action') }}"
{% if quoi == "supprimer" %}data-confirmer="Mettre « {{ a.titre }} » à la corbeille ?"{% endif %}>
<input type="hidden" name="action" value="{{ quoi }}">
<input type="hidden" name="nom" value="{{ a.nom }}">
<input type="hidden" name="relatif" value="{{ a.relatif }}">
<input type="hidden" name="ancre" value="{{ ancre }}">
<button type="submit" class="bouton icone {{ quoi }}" title="{{ titre }}"
aria-label="{{ titre }}" {% if not actif %}disabled{% endif %}>{{ signe }}</button>
</form>
{% endfor %}
</div>
</li>
{% endfor %}
</ol>
</section>
<!-- =================== DÉSACTIVÉES =================== -->
<section id="desactivees">
<h2>Pas jouées <span class="compte">{{ desactivees|length }}</span></h2>
{% if not desactivees %}
<p class="vide">Rien ici. Le bouton ⏸ d'une animation la range ici sans la supprimer.</p>
{% endif %}
<ul class="liste">
{% for a in desactivees %}
<li class="fiche eteinte" data-relatif="{{ a.relatif }}">
<span class="numero">–</span>
<img class="apercu" src="{{ url_for('apercu', relatif=a.relatif) }}?v={{ a.infos.version }}"
alt="Aperçu de {{ a.titre }}" loading="lazy">
<div class="details">
<strong>{{ a.titre }}</strong>
{% include "infos.html" %}
</div>
<div class="boutons">
{% for quoi, signe, titre in [("activer", "+", "Remettre dans la liste"),
("jouer", "▶", "Jouer maintenant (une fois)"),
("supprimer", "✕", "Supprimer")] %}
<form method="post" action="{{ url_for('action') }}"
{% if quoi == "supprimer" %}data-confirmer="Mettre « {{ a.titre }} » à la corbeille ?"{% endif %}>
<input type="hidden" name="action" value="{{ quoi }}">
<input type="hidden" name="nom" value="{{ a.nom }}">
<input type="hidden" name="relatif" value="{{ a.relatif }}">
<input type="hidden" name="ancre" value="#desactivees">
<button type="submit" class="bouton icone {{ quoi }}" title="{{ titre }}"
aria-label="{{ titre }}">{{ signe }}</button>
</form>
{% endfor %}
</div>
</li>
{% endfor %}
</ul>
</section>
</main>
<footer>
<form method="post" action="{{ url_for('action') }}"
data-confirmer="Effacer DÉFINITIVEMENT les {{ nb_corbeille }} élément(s) de la corbeille ?">
Corbeille : {{ nb_corbeille }} élément(s)
<input type="hidden" name="action" value="vider_corbeille">
<button type="submit" class="bouton" {% if not nb_corbeille %}disabled{% endif %}>Vider</button>
</form>
<p>Adresses de cette page :
{% for a in adresses %}<code>http://{{ a }}{{ port }}</code>{% if not loop.last %} · {% endif %}{% endfor %}
<br>(celle qui commence comme l'adresse de votre appareil marche depuis votre réseau)</p>
<p>Dossier : <code>{{ dossier }}</code></p>
</footer>
<script>
// Demander une confirmation avant les actions irréversibles.
document.querySelectorAll("form[data-confirmer]").forEach(function (form) {
form.addEventListener("submit", function (e) {
if (!confirm(form.dataset.confirmer)) e.preventDefault();
});
});
// Afficher le nom des fichiers choisis dans les zones de dépôt.
document.querySelectorAll(".depot input").forEach(function (champ) {
champ.addEventListener("change", function () {
var n = champ.files.length;
champ.nextElementSibling.textContent =
n === 0 ? "Choisir…" : n === 1 ? champ.files[0].name : n + " fichiers choisis";
});
});
// Les messages disparaissent tout seuls après 6 secondes (sauf les erreurs).
var messages = document.getElementById("messages");
if (messages && !messages.querySelector(".erreur")) {
setTimeout(function () { messages.remove(); }, 6000);
}
// Toutes les 2 secondes, demander au serveur ce que joue la matrice,
// et mettre en valeur la fiche correspondante.
function rafraichir() {
fetch("{{ url_for('etat') }}")
.then(function (r) { return r.json(); })
.then(function (etat) {
document.getElementById("en-cours").textContent = etat.en_cours || "—";
document.querySelectorAll(".fiche").forEach(function (f) {
f.classList.toggle("joue", f.dataset.relatif === etat.en_cours);
});
})
.catch(function () {});
}
rafraichir();
setInterval(rafraichir, 2000);
</script>
</body>
</html>
+172
View File
@@ -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; }