From d5e3a1eaf8d8f4ca3568e600c2f523dca4078616 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:13:11 +0000 Subject: [PATCH 01/27] =?UTF-8?q?Trente-deux=20tests=20d'interface=20s'ann?= =?UTF-8?q?on=C3=A7aient=20ignor=C3=A9s,=20et=20la=20suite=20restait=20ver?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findBrowser()` ne regardait que les chemins système. Les conteneurs installent Chromium sous `PLAYWRIGHT_BROWSERS_PATH`, jamais dans `/usr/bin` : la suite d'interface s'y annonçait « ignorée », la sortie restait verte, et personne ne voyait que 32 tests sur 262 ne tournaient pas. C'est le même mode de panne que la palette spatiale morte en silence le 28/07. Deux voies ajoutées avant les chemins système : `CASCADE_NAVIGATEUR` pour imposer un binaire à la main, puis un balayage de l'installation Playwright (le sous-dossier porte la révision, donc on cherche plutôt que de deviner ; le Chromium complet est préféré au `headless_shell`). Chromium refuse ensuite de démarrer en root sans `--no-sandbox`, et les conteneurs tournent en root. Le drapeau n'est ajouté que dans ce cas : sur une machine de développement le bac à sable reste en place. En CI, l'absence de navigateur n'était qu'un `echo`. Elle échoue maintenant : en local le repli gracieux se défend, en CI c'est un angle mort. Les 32 tests passent — aucune régression ne se cachait derrière le skip. 262 tests, zéro échec. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH --- .github/workflows/tests.yml | 17 ++++++++++--- tests/browser.js | 48 ++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0e1ebb3..dc3a056 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,9 +38,20 @@ jobs: " # Les tests d'interface pilotent un vrai navigateur via CDP. Sans lui ils - # s'annoncent ignorés au lieu d'échouer — mais on préfère les faire tourner. - - name: Vérifier la présence d'un navigateur - run: which google-chrome chromium chromium-browser 2>/dev/null || echo "aucun navigateur — les tests d'UI seront ignorés" + # s'annoncent ignorés SANS faire rougir la suite : 32 tests muets et une + # sortie verte. En local c'est un repli acceptable ; en CI c'est un angle + # mort, donc ici on échoue franchement. + - name: Exiger un navigateur pour les tests d'interface + run: | + node -e " + const { findBrowser } = require('./tests/browser.js'); + const bin = findBrowser(); + if (!bin) { + console.error('aucun navigateur trouvé : les 32 tests d\'interface seraient ignorés en silence'); + process.exit(1); + } + console.log('navigateur : ' + bin); + " - name: Suite complète run: npm test diff --git a/tests/browser.js b/tests/browser.js index c8b3687..27502a5 100644 --- a/tests/browser.js +++ b/tests/browser.js @@ -16,10 +16,51 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -/** Chemins usuels de Chrome / Edge / Chromium, par plateforme. */ +/** + * Binaire Chromium déposé par Playwright, s'il y en a un. + * + * Les conteneurs (CI, environnements distants) installent Chromium sous + * `PLAYWRIGHT_BROWSERS_PATH`, jamais dans `/usr/bin`. Le sous-dossier porte la + * révision (`chromium-1194`), donc on balaie plutôt que de deviner. On préfère + * le Chromium complet au `headless_shell`, plus étriqué. + */ +function playwrightBrowser() { + const racine = process.env['PLAYWRIGHT_BROWSERS_PATH']; + if (!racine) return null; + let entrees; + try { + entrees = fs.readdirSync(racine).filter((n) => n.startsWith('chromium')); + } catch (e) { return null; } + // `chromium-1194` avant `chromium_headless_shell-1194`, révision décroissante. + entrees.sort((a, b) => (a.includes('headless') - b.includes('headless')) || b.localeCompare(a)); + const relatifs = process.platform === 'win32' ? ['chrome-win\\chrome.exe'] + : process.platform === 'darwin' ? ['chrome-mac/Chromium.app/Contents/MacOS/Chromium'] + : ['chrome-linux/chrome', 'chrome-linux/headless_shell']; + for (const e of entrees) { + for (const r of relatifs) { + const p = path.join(racine, e, r); + try { if (fs.existsSync(p)) return p; } catch (err) { /* dossier illisible */ } + } + } + return null; +} + +/** + * Chemins usuels de Chrome / Edge / Chromium, par plateforme. + * + * ⚠ Sans navigateur, toute la suite d'interface s'annonce « ignorée » et la + * sortie reste VERTE : 32 tests muets, personne ne le voit. C'est exactement ce + * qui se passait sur les conteneurs, où Chromium n'est pas dans `/usr/bin`. + * D'où les deux voies ajoutées avant les chemins système : `CASCADE_NAVIGATEUR` + * (chemin imposé à la main) puis l'installation Playwright. + */ function findBrowser() { const existe = (p) => { try { return p && fs.existsSync(p) ? p : null; } catch (e) { return null; } }; const premier = (liste) => liste.map(existe).find(Boolean) || null; + const impose = existe(process.env['CASCADE_NAVIGATEUR']); + if (impose) return impose; + const pw = playwrightBrowser(); + if (pw) return pw; if (process.platform === 'win32') { const pf = process.env['ProgramFiles'] || 'C:\\Program Files'; const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; @@ -120,6 +161,10 @@ async function launch() { const profil = fs.mkdtempSync(path.join(os.tmpdir(), 'cascade-cdp-')); const port = await portLibre(); + // Chromium REFUSE de démarrer en root sans `--no-sandbox` (crbug 638180), et + // les conteneurs tournent en root. On ne l'ajoute que dans ce cas précis : + // sur une machine de développement ordinaire, le bac à sable reste en place. + const racine = typeof process.getuid === 'function' && process.getuid() === 0; const child = spawn(bin, [ '--headless=new', '--remote-debugging-port=' + port, @@ -128,6 +173,7 @@ async function launch() { '--disable-extensions', '--disable-background-networking', '--disable-features=Translate,MediaRouter', '--window-size=1400,1000', + ...(racine ? ['--no-sandbox'] : []), 'about:blank', ], { stdio: 'ignore', detached: process.platform !== 'win32' }); From aa15759c1901847333ac537a2a8f78181c91f1f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:22:15 +0000 Subject: [PATCH 02/27] =?UTF-8?q?Les=20docs=20de=20reprise=20annon=C3=A7ai?= =?UTF-8?q?ent=20la=201.6.0=20et=20deux=20bloquants=20d=C3=A9j=C3=A0=20cor?= =?UTF-8?q?rig=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ETAT-DU-PROJET.md` est le fichier que le projet impose de lire en premier. Il annonçait la version 1.6.0 en tête et 1.2.0 six lignes plus bas, « `main` est la 1.6.0 ; le front de développement est `v2` », 111 tests, un server.js de 1400 lignes, et un schéma d'état sans rien de la v2 — ni `p3`, ni `field`, ni palette, ni crossfader, ni vues. Il se termine pourtant par : « le laisser mentir coûte une demi-journée à celui qui reprend ». Le plus coûteux était ailleurs. `CLAUDE.md` présentait deux défauts comme ouverts et « déjà en v1 » : la couche couleur qui allume les barres qu'elle ne pilote pas, et la phase non préservée au changement de tempo. **Les deux sont corrigés depuis fin juillet** — vérifié dans le code, pas dans les docs : `mixLevel()` décide barre par barre, `phaseContinue()` intègre `dt / period`. Tous deux listés « Corrigé » au CHANGELOG et couverts par des tests. Ils venaient d'une relecture arrêtée avant sa phase de vérification ; la consigne envoyait donc rechasser du gibier mort. Reste une piste, elle, bien ouverte et non couverte, notée à la place : `resolveBars()` replie sur TOUTES les barres actives quand le groupe d'une couche est vide — seule voie vivante qui reproduit le symptôme d'origine. Le reste, par fichier : - comptes remis d'aplomb partout : 262 tests (pas 248 ni 111), 27 mutants (pas 21), tailles réelles des sources, badges du README ; - le pivot de `output/rot` est mesuré depuis le 29/07, mais quatre documents le disaient encore « jamais fait » : propagé, et il ne reste que DEUX mesures MadMapper, pas trois ; - `V2-TESTS-MADMAPPER.md` : le haut du fichier contredisait le bas. T11 à T16, T18 et T21 étaient mesurés dans les sections de résultat mais leurs cases restaient vides — cochées et reliées à leur résultat. On a failli refaire T14 ; - `PLAN-V2.md` : une « Étape 4 — à faire » traînait juste au-dessus d'une « Étape 4 ✅ » ; la boîte du champ y était encore décrite en coques de Tchebychev alors que c'est un pavé mobile ; trois items « non faits » sont livrés depuis la 2.0. Le fichier est marqué historique ; - `V1-V2.md` organisait la cohabitation de `main` et d'une branche `v2` qui n'existe plus, et disait de committer dessus. Remplacé par une pierre tombale ; - trois pièges ajoutés : une suite verte ne prouve pas que tout a tourné, les tests d'interface ne sont pas Playwright, et les fichiers hors dépôt sont absents des clones frais — donc des sessions distantes et de la CI. Les tests d'interface étaient d'ailleurs listés « à faire » alors qu'ils existent depuis la 2.0. Troisième piste faite qui traînait dans cette liste, après la synchro de phase Link et le `dist/` généré : la purger fait partie du travail de livraison. 262 tests, zéro échec. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH --- CLAUDE.md | 62 ++++++++++++++++------ README.md | 4 +- docs/ETAT-DU-PROJET.md | 106 +++++++++++++++++++++++++------------ docs/PLAN-V2.md | 35 ++++++------ docs/V1-V2.md | 61 +++++++-------------- docs/V2-AXES-PISTES.md | 18 +++++-- docs/V2-TESTS-MADMAPPER.md | 63 ++++++++++++++++------ 7 files changed, 222 insertions(+), 127 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5cc6c17..8121306 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ qui ont déjà coûté cher. `docs/madmapper-osc-api.md` = référence OSC MadMa ## Lancer / tester - `node server.js` → http://localhost:3333 (config générée : `cascade-config.json`). -- **`npm test`** (= `node --test`) : 248 tests, zéro dépendance. **À lancer avant +- **`npm test`** (= `node --test`) : 262 tests, zéro dépendance. **À lancer avant de conclure toute modif du serveur.** ⚠ `node --test tests/` échoue sur Node 24 (chemin pris pour un module) — utiliser `node --test` tout court. - Instance isolée pour tester à la main : `CASCADE_PORT=3461 CASCADE_NO_BROWSER=1 @@ -44,8 +44,13 @@ qui ont déjà coûté cher. `docs/madmapper-osc-api.md` = référence OSC MadMa `CASCADE_OSCIN`, `CASCADE_FEEDBACK`, `CASCADE_MMPORT`, `CASCADE_MMHOST`). - Faux Carabiner = serveur TCP local port 17000 qui pousse `status { :peers 1 :bpm 128.0 ... }\n`. -- UI : Playwright dispo ; utiliser `waitUntil: 'domcontentloaded'` - (`networkidle` ne vient jamais, l'UI poll toutes les 120 ms). +- UI : les 32 tests d'interface pilotent un vrai navigateur en CDP maison + (`tests/browser.js`, zéro dépendance) — **pas** Playwright. Sans navigateur ils + s'annoncent ignorés SANS faire rougir la suite : vérifier le compte (262), pas + la couleur. `CASCADE_NAVIGATEUR=` impose un navigateur ; + `PLAYWRIGHT_BROWSERS_PATH` est balayé tout seul. Si tu passes par Playwright + à la main, `waitUntil: 'domcontentloaded'` (`networkidle` ne vient jamais, + l'UI poll toutes les 120 ms). - Exécutables : `node build.js` → `build/` (voir `docs/ETAT-DU-PROJET.md` pour les pièges pkg : `--no-bytecode` obligatoire, cibles `node22-*` seulement). - Manuel : `python docs/build-manuel.py ` (reportlab ; les polices sont @@ -75,7 +80,7 @@ dossiers et fondu · 7 modes de fusion · perspective atmosphérique · palette N arrêts, branchable sur la profondeur ou la hauteur · décalage réparti · crossfader A/B · modulateurs (LFO) par couche et global · coupure de secours · renvoi de disposition · démos · repère 3D. -**248 tests, 21 mutations sur 21 détectées.** Manuel PDF, README, CHANGELOG et +**262 tests, 27 mutations sur 27 détectées.** Manuel PDF, README, CHANGELOG et exécutables des 4 plateformes sont à jour ; l'exécutable Windows a été lancé et interrogé pour de vrai. @@ -88,10 +93,16 @@ Trois garde-fous à connaître avant de toucher au code : - `build-manuel.py` refuse de générer si un caractère manque à la police. Ce qui reste, par ordre de valeur : suiveur audio (à faire côté navigateur, -en Web Audio, pour tenir le zéro-dépendance), grille visuelle des 16 presets, les trois mesures MadMapper non faites (empreinte d'une -barre en pixels, pivot de `output/rot`, DMX Filtering), et « dessiner les -fixtures » — qui attend d'abord un `Export Fixture Definitions…` depuis -MadMapper. Détail dans `docs/V2-INSPIRATIONS.md` et `docs/V2-AXES-PISTES.md`. +en Web Audio, pour tenir le zéro-dépendance ; point d'entrée tout trouvé = +`midiApply()` dans `public/index.html`), grille visuelle des 16 presets, les +**deux** mesures MadMapper non faites (empreinte d'une barre en pixels, DMX +Filtering), et « dessiner les fixtures » — qui attend d'abord un +`Export Fixture Definitions…` depuis MadMapper. Détail dans +`docs/V2-INSPIRATIONS.md` et `docs/V2-AXES-PISTES.md`. + +✅ **Le pivot de `output/rot` est mesuré** (2026-07-29) : la rotation ne +translate pas la fixture (écart 0,00 en x et y), donc l'ordre position/rotation +est libre. C'était la troisième mesure ; elle est faite. ## Prochaines demandes de Pym (exprimées, PAS encore réalisées) @@ -104,14 +115,33 @@ MadMapper. Détail dans `docs/V2-INSPIRATIONS.md` et `docs/V2-AXES-PISTES.md`. 2. **Capture ou GIF dans le README** — nécessite une vraie session MadMapper. 3. Suite de l'audit : voir la section « À faire » de `../Cascade-AUDIT.md` (hors dépôt) (code d'accès 4 chiffres, repli « avancé » du panneau Couches, tests d'UI). -4. **`../Cascade-RELECTURES.md` (hors dépôt) — à lire avant de reprendre la v2.** - État des deux campagnes de relecture par agents : ce qui a été mesuré et - exploité, ce qui reste à mesurer sur MadMapper, et **32 trouvailles sur le - moteur de champ 3D dont trois bloquantes** — la relecture s'est arrêtée avant - la phase de vérification, donc rien n'y est confirmé contradictoirement. - Deux de ces défauts sont **déjà en v1** (couche couleur qui allume les barres - qu'elle ne pilote pas ; phase non préservée au changement de tempo pour le - moteur vague). +4. **`../Cascade-RELECTURES.md` (hors dépôt)** — état des deux campagnes de + relecture par agents. ⚠ Ce fichier vit **hors du dépôt** : il est absent des + clones frais (sessions distantes, CI), donc ne jamais faire dépendre une + décision de son contenu sans l'avoir sous les yeux. + + ⚠ **Les deux « défauts déjà en v1 » que ce fichier signalait sont CORRIGÉS** + — vérifié dans le code le 2026-08-04, ne pas repartir les chasser : + - *couche couleur qui allume les barres qu'elle ne pilote pas* → `mixLevel()` + décide barre par barre (`server.js`) ; la forme fautive globale + (`mix.anyCol`) a disparu. Corrigé par `648327c`, listé `CHANGELOG.md:213`, + couvert par `tests/regressions.test.js`. + - *phase non préservée au changement de tempo (moteur vague)* → `phaseContinue()` + intègre `dt / period` au lieu de `now / period` ; le chaser, lui, rebase + `startTime`. Corrigé par `619bee8`, listé `CHANGELOG.md:215`, couvert par + `tests/regressions.test.js` (`stepMs` 10000 → 9999, écart < 0,05). + + La relecture s'était arrêtée avant sa phase de vérification : ses trouvailles + n'étaient **pas** confirmées contradictoirement, et celles-là étaient périmées. + Traiter le reste de la liste avec la même prudence — vérifier dans le code + avant d'y croire. + + **Piste restée ouverte, elle, et non couverte** : `resolveBars()` replie sur + *toutes* les barres actives quand le groupe d'une couche est vide. Vider un + groupe (au lieu de le supprimer, qui remet `L.groupId = null`) fait donc + basculer une couche couleur sur tout le plateau. Comportement délibéré et + documenté, mais c'est la seule voie vivante qui reproduit le symptôme + d'origine, et aucun test ne la couvre. Le chantier v1.3 « spectacle » (features de chase, UI premium, tests, audit, exécutables, manuel, CHANGELOG) est **livré** — voir `CHANGELOG.md`. diff --git a/README.md b/README.md index 6c51f63..b77db95 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ *Pierre-Yves Mansour — Collectif WSK* -![version](https://img.shields.io/badge/version-2.0.0-orange) ![licence](https://img.shields.io/badge/licence-MIT-blue) ![dépendances](https://img.shields.io/badge/d%C3%A9pendances-aucune-brightgreen) ![tests](https://img.shields.io/badge/tests-248-green) ![plateformes](https://img.shields.io/badge/plateformes-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey) +![version](https://img.shields.io/badge/version-2.0.1-orange) ![licence](https://img.shields.io/badge/licence-MIT-blue) ![dépendances](https://img.shields.io/badge/d%C3%A9pendances-aucune-brightgreen) ![tests](https://img.shields.io/badge/tests-262-green) ![plateformes](https://img.shields.io/badge/plateformes-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey) --- @@ -138,7 +138,7 @@ Le préfixe historique `/chaser/…` reste accepté. ## Développement ```bash -npm test # 111 tests, sans aucune dépendance +npm test # 262 tests, sans aucune dépendance node build.js # exécutables des 4 plateformes → build/ ``` diff --git a/docs/ETAT-DU-PROJET.md b/docs/ETAT-DU-PROJET.md index 590cafe..33f9067 100644 --- a/docs/ETAT-DU-PROJET.md +++ b/docs/ETAT-DU-PROJET.md @@ -1,15 +1,21 @@ # Cascade — état du projet (reprise de travail) > Fichier de reprise. **À lire en premier** avant toute modification. -> Dernière mise à jour : 2026-07-24 — version **1.6.0**. +> Dernière mise à jour : 2026-08-04 — version **2.0.1** (+ des correctifs non publiés). > Voir aussi `CLAUDE.md` (règles) et `CHANGELOG.md` (versions). -> L'audit technique vit **hors du dépôt** : `../Cascade-AUDIT.md`. +> L'audit technique vit **hors du dépôt** : `../Cascade-AUDIT.md`. ⚠ Il est donc +> **absent des clones frais** (sessions distantes, CI) : ne jamais faire dépendre +> une décision de son contenu sans l'avoir sous les yeux. Même chose pour +> `../Cascade-RELECTURES.md`. +> +> ⚠ Les sections « Nouveautés v1.x » plus bas sont conservées comme **historique**. +> Pour l'état courant, se fier à `CHANGELOG.md` et à ce préambule. ## Identité - **Nom** : Cascade (avant : « Chaser pour MadMapper »). Dossier renommé `Cascade` le 2026-07-09. - **Auteur / signature** : Pierre-Yves Mansour — Collectif WSK -- **Version** : 1.2.0 · **Licence** : MIT · destiné à GitHub +- **Version** : 2.0.1 · **Licence** : MIT · publié sur GitHub - **Quoi** : séquenceur LED multi-couches qui pilote les fixtures DMX de MadMapper en OSC, depuis une page web (ordi, iPad, téléphone). Équivalent de « Chaser » (Hybrid Constructs, pour Resolume), mais pour MadMapper. ## Nouveautés v1.2 (2026-07-09) @@ -24,10 +30,12 @@ 2. **Ableton Link** (Pulse, Live, Traktor…) via **Carabiner** (Deep Symmetry) — binaire officiel embarquant la lib Link, exposé en TCP local port 17000, téléchargé par les lanceurs dans `runtime/` (`carabiner.exe` / `carabiner`). Zéro dépendance npm conservée (client `net` maison, parse EDN par regex `:bpm`/`:peers`). Toggle **⧉ ABLETON LINK** dans le panneau Vitesse ; BPM session → `stepMs` de **toutes** les couches (1 beat = 1 pas, borné 30–2000 ms), chaque couche garde sa « Vitesse » ×0.1–×4. Quand Link actif : slider Temps/pas, TAP, ÷2, ×2 désactivés côté UI, `tap()` neutralisé côté serveur. `state.settings.linkEnabled` persisté (réactivé au boot). API : `POST /api/link {enabled}` ; état dans `/api/state` → `link {active, connected, bpm, peers, error}` ; OSC : `/cascade/link 0-1`. Cascade lance Carabiner lui-même (spawn, `windowsHide`), le tue au disable/quit/exit ; se connecte d'abord au cas où un Carabiner tourne déjà ; ~20 tentatives à 700 ms puis erreur propre (binaire absent → message « relance le lanceur »). 3. **Charte graphique** reprise du manuel : orange signature `#f2900f` (`--accent`), anthracite profond, titres de sections orange espacés avec filet fin (`h2` border-bottom), panneaux radius 14 + ombre, TAP orange plein avec glow, `--accent2` devenu gris-bleu neutre (axes miroirs, hints), footer façon PDF (filet orange centré + signature orange). `button[disabled]`/`input[disabled]` à .35. -## Branche v2 — moteur de champ 3D (en cours) +## Moteur de champ 3D (livré en 2.0.0) -⚠ **`main` est la 1.6.0 ; le front de développement est `v2`.** Elle contient -tout ce qui suit PLUS un moteur de champ 3D : les barres ont une position en +✅ **`v2` a été fusionnée dans `main` et taguée `v2.0.0` le 2026-07-28. La +branche `v2` n'a plus de raison d'être ; le développement continue sur `main`.** +Cette section décrit du code **livré**, pas un chantier. Elle contient +tout ce qui précède PLUS un moteur de champ 3D : les barres ont une position en mètres (`p3`) et un champ scalaire les traverse selon une forme, un axe, une source, une étendue, une netteté, une course, une dérive. S'y ajoutent les modes de fusion, la perspective atmosphérique, la palette à N arrêts, les @@ -267,7 +275,7 @@ nom de couche, nom de projet, message d'erreur du serveur — se pose par `tests/interface.test.js` relit le source et échoue si la règle est enfreinte (garde-fou vérifié en réintroduisant volontairement le motif fautif). -### Tests — `npm test` (111 tests, zéro dépendance) +### Tests — `npm test` (262 tests, zéro dépendance) `tests/helpers.js` lance un **vrai** serveur en sous-processus (ports libres, config jetable) et écoute l'OSC réellement émis avec un décodeur **indépendant** @@ -278,7 +286,17 @@ paquets hostiles), `madmapper.test.js` (voyant dans les deux sens), `charge.test.js` (8 couches × 128 barres, 50 requêtes simultanées, presets en rafale, scéno changée 12 fois, START/STOP martelés), `interface.test.js` (garde-fous de source : pas d'`innerHTML` avec donnée externe, version cohérente, -sémantiques non négociables toujours présentes). +sémantiques non négociables toujours présentes, `dist/` synchrone, aucun mutant +resté dans `server.js`), et `ui.test.js` (**32 tests dans un vrai navigateur**). + +⚠ **Les 32 tests d'interface peuvent ne pas tourner sans que rien ne rougisse.** +Sans navigateur, `ui.test.js` s'annonce « ignoré » et la suite reste VERTE : on +lit 230 tests au lieu de 262 et personne ne le voit. **Vérifier le compte, pas la +couleur.** `tests/browser.js` cherche dans l'ordre : `CASCADE_NAVIGATEUR` (chemin +imposé), l'installation Playwright (`PLAYWRIGHT_BROWSERS_PATH`), puis les chemins +système. En CI l'absence de navigateur fait désormais échouer le job. +En conteneur, Chromium exige `--no-sandbox` parce qu'on y tourne en root : le +drapeau n'est ajouté que si l'uid vaut 0. **Endurance mesurée** (4 min, config maximale) : mémoire 64 → 67 Mo avec récupération visible à 60 Mo (donc pas de fuite), ~6 450 messages OSC/s, @@ -316,10 +334,10 @@ focus clavier, `prefers-reduced-motion`. ``` Cascade/ -├── server.js ← moteur + API (source de travail, ~1400 l.) -├── public/index.html ← interface complète (source de travail, ~1500 l.) +├── server.js ← moteur + API (source de travail, ~3020 l.) +├── public/index.html ← interface complète (source de travail, ~3320 l.) ├── build.js ← génère les exécutables des 4 plateformes -├── tests/ ← npm test — 55 tests, zéro dépendance +├── tests/ ← npm test — 262 tests en 17 fichiers, zéro dép. │ ├── helpers.js ← lance un vrai serveur + faux MadMapper │ ├── api.test.js · engine.test.js · control.test.js · madmapper.test.js ├── dist/ ← DISTRIBUABLE : copie autonome à envoyer @@ -361,20 +379,33 @@ Cascade/ ## Architecture -**Serveur Node.js zéro dépendance** (`server.js`, ~1080 lignes) : encodeur/décodeur OSC maison sur UDP, serveur HTTP + API JSON, client TCP Carabiner, moteur à `setInterval(tick, 25)` (~40 fps). L'interface (`public/index.html`, ~1080 lignes, tout-en-un) fait du polling `/api/state` toutes les 120 ms (`pollTimer`). +**Serveur Node.js zéro dépendance** (`server.js`, ~3020 lignes) : encodeur/décodeur OSC maison sur UDP, serveur HTTP + API JSON, client TCP Carabiner, moteur à `setInterval(tick, 25)` (~40 fps). L'interface (`public/index.html`, ~3320 lignes, tout-en-un) fait du polling `/api/state` toutes les 120 ms (`pollTimer`). ### État serveur - `state.projectName` : nom du projet (≤40 car., défaut « Sans titre ») — export/import/quit - `state.settings` : `mmHost, mmPort (8000), feedbackPort (9000), httpPort (3333), oscInPort (7000), linkEnabled (bool)` -- `state.fixtures[]` : `{ id, name, address, enabled, x, y, rot, len, vert }` (x/y normalisés 0–1) +> ⚠ Ce schéma a longtemps décrit la v1 alors que la v2 était livrée. La liste +> qui fait foi est `LAYER_KEYS` dans `server.js` — s'y reporter en cas de doute. + +- `state.fixtures[]` : `{ id, name, address, enabled, x, y, rot, len, vert, inverse, p3, dir3 }` + (x/y normalisés 0–1 ; `p3`/`dir3` = position et vecteur **en mètres**, v2 ; + `inverse` = barre branchée à l'envers, sert à la *génération de vues*, pas au pilotage) - `state.layers[]` (max 8, une couche = un séquenceur) : - `{ id, name, enabled, engine: 'steps'|'wave', target: 'intensity'|'color', bars: null|[ids], - pattern, mode: 'onoff'|'fade', curve, waveform, stepMs, speed, width, group, - mirrorH, mirrorV, axisX, axisY, fadeInPct, fadeOutPct, invert, level, colorA, colorB, - phase, swing, floor, blocks, oneShot, sparkle }` ← les 6 derniers = v1.3 -- `state.global` : `{ running, speed, master, param: 'luminosity', dimmer: 'linear' }` -- `state.presets[16]` : `{ name, layers[], fixtures[] }` — mémorise aussi la disposition + `{ id, name, enabled, engine: 'steps'|'wave'|'field', target: 'intensity'|'color', + bars: null|[ids], groupId, pattern, mode: 'onoff'|'fade', curve, waveform, + stepMs, speed, width, group, mirrorH, mirrorV, axisX, axisY, + fadeInPct, fadeOutPct, invert, level, colorA, colorB, + phase, swing, floor, blocks, oneShot, sparkle, ← v1.3 + field, axAz, axEl, srcX, srcY, srcZ, ordre3d, duty, course, + blend, prof, palette, palSrc, spread, deck, lfo }` ← v2 +- `state.global` : `{ running, speed, master, param: 'luminosity', dimmer: 'linear', + xfade, modGlobal, vueActive, vueIncertaine, presetFade, coupure }` (les 6 derniers = v2) +- `state.vues[]` : dossiers de fixtures MadMapper, une vue = un axe de projection (v2) +- `state.groups[]` : groupes de barres nommés. **Pas** mémorisés dans les presets + (ils appartiennent à la scéno, pas au look), mais voyagent avec le fichier projet +- `state.presets[16]` : `{ name, layers[], fixtures[] }` — mémorise aussi la disposition. + ⚠ `/api/state` n'en expose que les **noms** (`presetNames()`), pas le contenu - `state.midiMap` : `{ 'cc:ch:num' | 'note:ch:num' → cible }` - `link` (hors state, runtime) : `{ active, connected, bpm, peers, error }` + `linkSock/linkChild/linkRetry` @@ -394,7 +425,9 @@ Cascade/ ### API HTTP `GET /api/ping` · `GET /api/state` · `GET /api/export` -`POST` : `/api/layer {id,set}` · `/api/layers {action:add|remove,id}` · `/api/global {speed,master,param}` · `/api/preset {action:save|recall|clear, slot}` · `/api/resync {id?}` · `/api/link {enabled}` · `/api/quit` · `/api/project {name}` · `/api/start` · `/api/stop` · `/api/blackout` · `/api/tap {id}` · `/api/fixtures {fixtures}` · `/api/discover` · `/api/layout` · `/api/inspect {index}` · `/api/test {index}` · `/api/midimap {map}` · `/api/settings` · `/api/import` · `/api/new {keepFixtures}` +`POST` : `/api/layer {id,set}` · `/api/layers {action:add|remove,id}` · `/api/global {speed,master,param}` · `/api/preset {action:save|recall|clear|rename, slot, name?, fadeMs?}` · `/api/resync {id?}` · `/api/link {enabled}` · `/api/quit` · `/api/project {name}` · `/api/start` · `/api/stop` · `/api/blackout` · `/api/tap {id}` · `/api/fixtures {fixtures}` · `/api/discover` · `/api/layout` · `/api/inspect {index}` · `/api/test {index}` · `/api/midimap {map}` · `/api/settings` · `/api/import` · `/api/new {keepFixtures}` + +Ajoutées en **v2** : `/api/scene` (cotes du plateau) · `/api/fixture3d {id,p3,dir3}` · `/api/geometrie` (renvoi de la disposition vers MadMapper) · `/api/groups` · `/api/vues` · `/api/vue` · `/api/vuecheck` (relecture des dossiers) · `/api/coupure` (coupure de secours) · `/api/demo` · `/api/trouverport` Toutes les entrées sont validées/bornées (`sanitizeLayerSet`, `sanitizeLayer`, `sanitizeFixtures`, `cnum`, `ENUMS`). @@ -419,7 +452,9 @@ Interactions : **double-clic ou double-tap** (helper `onDblTap`, anti-rebond 500 2. **Impossible de compiler un vrai `.exe`/`.app`** dans l'environnement : `pkg`, `@yao-pkg/pkg`, `postject` et `nodejs.org/dist` sont bloqués (403 allowlist). GitHub releases (Carabiner) aussi bloqué **dans le sandbox** — mais téléchargeable sur la machine de l'utilisateur (les lanceurs s'en chargent). D'où : lanceurs `.bat`/`.command` qui téléchargent Node portable + Carabiner, et « mode app » via `--app=` de Chrome/Edge. 3. **macOS + WhatsApp/mail** : le `.command` arrive en quarantaine → « fichier endommagé » / Terminal −128. Fix utilisateur : `xattr -cr ` puis `chmod +x <.command>`. Documenté dans `LISEZ-MOI.txt` et le manuel. Seule solution définitive : signer + notariser (compte Apple Developer, 99 $/an). 4. **Manuel PDF** : régénéré en v1.2 (Link, mode app, quitter, sauvegarde). Le script est désormais **conservé** : `docs/build-manuel.py` (`python3 build-manuel.py `, reportlab + polices DejaVu). ⚠️ DejaVu n'a **pas** les glyphes ⏻ (U+23FB) ni ⧉ (U+29C9) ni les exposants/indices Unicode — ils rendent des carrés vides : écrire les mots (« bouton Quitter », « ABLETON LINK »). Copier le PDF généré à la racine **et** dans `dist/`. -5. **Playwright dans le sandbox** : `waitUntil: 'networkidle'` ne se déclenche jamais (polling 120 ms) → utiliser `domcontentloaded`. +5. **Playwright dans le sandbox** : `waitUntil: 'networkidle'` ne se déclenche jamais (polling 120 ms) → utiliser `domcontentloaded`. ⚠ Les tests d'interface du dépôt n'utilisent **pas** Playwright : c'est du CDP maison (`tests/browser.js`), pour tenir le zéro-dépendance. +6. **Une suite verte ne prouve pas que tout a tourné.** `ui.test.js` s'annonce « ignoré » sans navigateur, sans faire rougir quoi que ce soit — 32 tests muets. Lire le **compte** (262), pas la couleur. Même famille que le mutant resté dans `server.js` le 28/07 : les deux fois, la suite était verte. +7. **Les fichiers hors dépôt n'existent pas partout.** `../Cascade-AUDIT.md` et `../Cascade-RELECTURES.md` sont absents des clones frais (sessions distantes, CI). Une consigne qui en dépend est inapplicable là-bas : recopier dans le dépôt ce qui doit survivre. ## Historique des décisions @@ -433,19 +468,24 @@ Interactions : **double-clic ou double-tap** (helper `onDblTap`, anti-rebond 500 **Demandées explicitement par Pym (prioritaires — détail dans `CLAUDE.md`) :** -- **Icône systray** : point vert/rouge = serveur en route/arrêté ; clic droit → ouvrir l'interface / démarrer / arrêter. Objectif : pouvoir fermer la fenêtre en laissant le serveur tourner, tout en le voyant. (⚠ zéro-dep : pas de systray en Node pur — solution à discuter.) -- **Capture ou GIF dans le README** : le seul point des « finitions GitHub » qui reste (nécessite une vraie session avec MadMapper). -- **Suite de l'audit** (`../Cascade-AUDIT.md` (hors dépôt), section « À faire ») : code d'accès facultatif à 4 chiffres, repli « avancé » du panneau Couches, tests d'interface Playwright, signature/notarisation macOS. +- **Icône systray** : point vert/rouge = serveur en route/arrêté ; clic droit → ouvrir l'interface / démarrer / arrêter. Objectif : pouvoir fermer la fenêtre en laissant le serveur tourner, tout en le voyant. (⚠ zéro-dep : pas de systray en Node pur — **arbitrage de Pym nécessaire avant d'écrire une ligne** : petit utilitaire par plateforme, PowerShell/AppleScript, ou accepter une dépendance ici.) +- **Capture ou GIF dans le README** (nécessite une vraie session avec MadMapper). La manip est déjà scénarisée — c'est T16, « le dégradé qui voyage », et son mécanisme est mesuré. Il ne reste que le tournage. +- **Suite de l'audit** (`../Cascade-AUDIT.md` (hors dépôt), section « À faire ») : code d'accès facultatif à 4 chiffres, repli « avancé » du panneau Couches, signature/notarisation macOS (compte Apple Developer, 99 $/an → décision + dépense). **Autres pistes :** -- Horloge MIDI en alternative à Link. +- Horloge MIDI en alternative à Link. ⚠ À arbitrer avant d'écrire : Web MIDI n'existe que sur Chrome/Edge, et Node n'a pas de MIDI natif sans dépendance. - Séquenceur pas-à-pas dessinable (grille barres × pas). -- Thème clair. - -⚠ Deux pistes de cette liste ont été FAITES et y traînaient encore : -la **synchro de phase Link** (livrée en 1.6.0 — les pas sont calés sur la -grille de beats, pas seulement sur le BPM) et le **`dist/` généré** -(`sync-dist.js`, avec un test qui échoue si la copie diverge). Ce fichier est -censé être lu en premier à chaque reprise : le laisser mentir coûte une -demi-journée à celui qui reprend. +- Thème clair. ⚠ Va contre une décision de charte déjà prise (sombre retenu pour la scène) → demander à Pym. +- **Grille visuelle des 16 presets** : aujourd'hui une simple rangée. La brique d'aperçu existe déjà (`renderPreviewCells()`), mais le serveur n'expose que les *noms* (`presetNames()`) — il faudrait enrichir l'état. +- **Suiveur audio Web Audio**, côté navigateur pour tenir le zéro-dépendance. Rien n'existe encore ; le point d'entrée désigné est `midiApply()`, qui traduit déjà « une valeur 0-1 + un déclencheur » en appels API. + +⚠ **Cette liste a déjà menti trois fois.** Y traînaient, faites : la **synchro de +phase Link** (livrée en 1.6.0), le **`dist/` généré** (`sync-dist.js`, avec un +test qui échoue si la copie diverge), et les **tests d'interface** — réputés « à +faire en Playwright » alors qu'ils existent en CDP maison depuis la 2.0 +(`tests/browser.js`, 32 tests). Ce fichier est censé être lu en premier à chaque +reprise : le laisser mentir coûte une demi-journée à celui qui reprend. + +⚠ **Purger cette liste fait partie du travail de livraison**, au même titre que +le CHANGELOG. diff --git a/docs/PLAN-V2.md b/docs/PLAN-V2.md index 8a52a29..3466939 100644 --- a/docs/PLAN-V2.md +++ b/docs/PLAN-V2.md @@ -1,12 +1,14 @@ # Cascade v2 — plan de version -> **Statut : plan, pas encore du code.** Établi le 2026-07-26 à partir d'une -> recherche documentée (sources citées dans le texte) et de mesures faites sur -> la machine de Pym. La v1 reste sur la branche `main` et continue de vivre ; -> ce chantier se fait sur la branche `v2`. +> ⚠ **Statut : document HISTORIQUE.** Ce plan a été établi le 2026-07-26, et +> **la v2 est sortie depuis** (`v2.0.0`, 2026-07-28, fusionnée dans `main` ; la +> branche `v2` n'existe plus). Il est conservé pour le **raisonnement** — les +> voies écartées, les mesures qui ont tranché, les justifications de repère et +> de modèle de données. Pour ce qui est réellement livré, se fier à +> `CHANGELOG.md` et `docs/ETAT-DU-PROJET.md`, jamais à ce fichier. > -> ⚠ **Rien ne commence avant les vérifications de la section 7**, à faire avec -> MadMapper ouvert. Trois d'entre elles (T3, T4, T5) peuvent changer le plan. +> Les mentions « à faire », « pas encore du code » et « branche `v2` » qui +> subsistent ci-dessous décrivent l'état de juillet, pas l'état courant. > > 📄 **Suite indispensable : [V2-TEXTURES.md](V2-TEXTURES.md)** — comment faire > voyager une vraie texture sur un axe. Ce document-ci conclut « une valeur par @@ -273,15 +275,15 @@ Banc d'essai du rendu 3D, réutilisable : `C:\Users\PYMENV~1\AppData\Local\Temp\ - Les axes du glissé sont dérivés de la caméra (`axesEcran()`) au lieu d'inverser la projection : ça évite le cas dégénéré de la vue de face, où le plan du sol est vu par la tranche. -- **Étape 4** — à faire, et **conditionnée par T11/T12** (voir - `V2-TESTS-MADMAPPER.md`, section « Relevé du 2026-07-26 » : l'entrée OSC de - MadMapper est désactivée sur le poste, ces tests restent à faire à la main). - - **Étape 4** ✅ moteur « champ 3D » (`engine: 'field'`), cinq formes : - **plan / balayage** — axe réglable en azimut et élévation ; - **sphère** — ondes concentriques depuis une source en mètres ; - **cylindre** — balayage rotatif autour d'un axe (le « phare ») ; - - **boîte** — coques rectangulaires (distance de Tchebychev) ; + - **boîte** — ⚠ **pavé MOBILE**, pas des coques : on teste l'appartenance à + un volume qui traverse le plateau une fois par cycle. La version « coques + rectangulaires (distance de Tchebychev) » décrite ici a été abandonnée — + mesurée indiscernable de la sphère (66 % de valeurs intermédiaires contre + 68 %) sur un rig plan ; - **bruit 3D** — value noise trilinéaire écrit à la main, zéro dépendance. Le champ ne produit qu'une grandeur `u` : elle traverse **exactement** la @@ -312,9 +314,9 @@ Banc d'essai du rendu 3D, réutilisable : `C:\Users\PYMENV~1\AppData\Local\Temp\ pas de source, la sphère n'affiche pas d'axe, le bruit ni l'un ni l'autre, et la grille de motifs disparaît. Un réglage sans effet passe pour une panne. -- **Reste de la spécification, non fait** : les sources mobiles (comètes, - étape 5), la palette à N arrêts, et l'option « ordre = projection sur l'axe » - pour le moteur pas-à-pas. +- ~~**Reste de la spécification, non fait**~~ → ✅ **les trois sont livrés en + 2.0.0** : sources mobiles, palette à N arrêts, et l'option « ordre = projection + sur l'axe » pour le moteur pas-à-pas. Voir `CHANGELOG.md`. --- @@ -396,8 +398,9 @@ lui-même (couches, niveaux, couleurs) reste dans les presets existants. point. Méthode : un dégradé fin sur toute la sortie, déplacements de 5 px, et corrélation du profil. C'est le chiffre qui décide si « ranger » peut éviter les chevauchements. -2. **Le pivot de `output/rot`** : autour du centre de la barre, ou d'une - extrémité ? Le calcul de placement en dépend directement (T14, jamais fait). +2. ~~**Le pivot de `output/rot`**~~ → ✅ **mesuré le 2026-07-29** (T14) : la + rotation se fait autour du point `output/x, output/y` lui-même (écart 0,00 en + x et y), donc l'ordre position/orientation est libre. Plus un préalable. 3. **Le DMX Filtering** de chaque fixture : c'est lui qui fabrique les demi-teintes, et Cascade ne peut ni le lire ni le garantir. diff --git a/docs/V1-V2.md b/docs/V1-V2.md index ed5d524..4bcccb0 100644 --- a/docs/V1-V2.md +++ b/docs/V1-V2.md @@ -1,47 +1,26 @@ -# Deux versions en parallèle +# Deux versions en parallèle — **terminé, document historique** -## En un coup d'œil +> ⚠ **Ce fichier ne décrit plus rien d'actuel.** Il organisait la cohabitation +> de `main` (v1) et de la branche `v2` pendant le chantier 3D, de juillet 2026 +> jusqu'à la sortie de la 2.0. -| | `main` — **v1** | `v2` | -|---|---|---| -| Ce que c'est | La version qui part en spectacle | Le chantier 3D | -| Dernière version | 1.6.0 (releases publiées) | rien encore, part de 1.6.0 | -| État | **Stable, à ne pas déstabiliser** | En construction | -| Ce qu'on y fait | Corrections et petites améliorations | Scénographie et effets 3D | -| Releases GitHub | oui | pas avant qu'elle soit prête | +**La cohabitation est finie.** Le 2026-07-28, `v2` a été fusionnée dans `main` +et taguée `v2.0.0`, sur décision explicite de Pym. **La branche `v2` n'existe +plus, et le développement continue sur `main`.** -**La règle** : `main` reste utilisable à tout moment. Tant que la v2 n'est pas -finie, c'est elle qu'on télécharge, qu'on installe et qu'on emmène en salle. +Les consignes qui vivaient ici sont donc caduques, et dangereuses si on les +suit encore — notamment « une nouveauté v2 : uniquement sur `v2` » et +« `git checkout v2 && git merge main` », qui visent une branche disparue. -## Pour Pym, concrètement +Ce qui reste vrai, et qui a survécu ailleurs : -Rien ne change pour toi tant que je ne te dis pas le contraire. Les exécutables -de la page des releases restent ceux de la v1.6. Si un bug apparaît en -spectacle, on le corrige sur `main` et on publie une 1.6.1 — le chantier 3D -n'entre pas en jeu. +- `npm test` doit passer avant de conclure — et **lire le compte, pas la + couleur** : sans navigateur, 32 tests d'interface s'annoncent ignorés sans + faire rougir la suite. +- `node sync-dist.js` avant de conclure (un test échoue si `dist/` diverge). +- La v1 reste consultable par son tag `v1.6.0`. -Quand la v2 sera présentable, tu l'essaieras à côté de la v1 (elles peuvent -cohabiter : chacune écrit sa propre configuration à côté de son exécutable). -Elle ne remplacera la v1 que le jour où tu l'auras validée sur ta scéno. - -## Pour celui qui code - -- Un correctif qui concerne les deux : le faire sur `main`, puis - `git checkout v2 && git merge main`. Jamais l'inverse. -- Une nouveauté v2 : uniquement sur `v2`. -- `npm test` doit passer sur les deux branches, tout le temps. -- `node sync-dist.js` avant de conclure, sur les deux branches - (un test échoue si `dist/` diverge). -- Le plan du chantier : [`PLAN-V2.md`](PLAN-V2.md). Sa section 7 liste les - vérifications à faire **avec MadMapper ouvert avant d'écrire du code** — - trois d'entre elles peuvent changer le plan. - -## Quand v2 remplacera v1 - -Le jour où la v2 est validée sur une vraie scéno : - -```bash -git checkout main && git merge v2 # v2 devient la version courante -``` - -et la v1 reste consultable par son tag `v1.6.0`. +Pour l'état courant : [`ETAT-DU-PROJET.md`](ETAT-DU-PROJET.md) et +[`../CHANGELOG.md`](../CHANGELOG.md). Le raisonnement du chantier, y compris +les voies écartées, est conservé dans [`PLAN-V2.md`](PLAN-V2.md) — lui aussi +historique. diff --git a/docs/V2-AXES-PISTES.md b/docs/V2-AXES-PISTES.md index d2eb003..766d4ee 100644 --- a/docs/V2-AXES-PISTES.md +++ b/docs/V2-AXES-PISTES.md @@ -127,11 +127,23 @@ mesure de ce que MadMapper émet, pas sur un essai avec le boîtier. barre de 10 LED ≈ 1 600 px, soit 160 px/LED) mais un seul déplacement sur cinq a donné une corrélation franche. C'est le chiffre qui dit si deux barres rangées côte à côte se chevaucheront. -2. **Le pivot de `output/rot`** : autour du centre de la barre, ou d'une - extrémité ? Le calcul de placement en dépend (T14, jamais fait). -3. **Le DMX Filtering** de chaque fixture : c'est lui qui fabrique les +2. **Le DMX Filtering** de chaque fixture : c'est lui qui fabrique les demi-teintes, et Cascade ne peut ni le lire ni le garantir. +## ✅ Mesuré depuis — le pivot de `output/rot` (T14, 2026-07-29) + +**La rotation se fait autour du point `output/x, output/y` lui-même.** Fixture à +x=960 y=270, écriture de rot=90, relecture : **écart 0,00 sur x et sur y**. +Restauration à rot=0 vérifiée. + +Conséquence pour le placement : **l'ordre des opérations est libre.** Poser la +position puis l'orientation, ou l'inverse, donne le même résultat — la rotation +ne translate pas la fixture. Sans ça, il aurait fallu compenser le déplacement +induit par chaque rotation, avec la longueur de la barre en paramètre. + +Reste non mesuré, mais **sans effet sur le placement** : savoir si ce point est +le centre géométrique de la barre. Détail dans `madmapper-osc-api.md`. + --- ## Dessiner les fixtures depuis la 3D — où ça en est (2026-07-28) diff --git a/docs/V2-TESTS-MADMAPPER.md b/docs/V2-TESTS-MADMAPPER.md index 5db9bb9..143c62b 100644 --- a/docs/V2-TESTS-MADMAPPER.md +++ b/docs/V2-TESTS-MADMAPPER.md @@ -6,13 +6,25 @@ > Coche au fur et à mesure et note le résultat directement ici : ce fichier est > la mémoire du projet. Les réponses de T1–T4 partent ensuite dans > [`madmapper-osc-api.md`](madmapper-osc-api.md). +> +> **État au 2026-08-04 — mesuré : T11, T12, T13, T14, T15, T16, T18, T21.** +> Les cases ci-dessous renvoient désormais aux sections de résultat, plus bas +> dans ce même fichier. ⚠ Elles sont restées vides longtemps après la mesure : +> le haut du fichier contredisait le bas, et on a failli refaire T14. +> +> **Restent à mesurer, tous bloqués sur une session MadMapper** : **T17** (DMX +> Filtering — c'est lui qui fabrique les demi-teintes), **T19** (format +> `Import Fixtures…`, préalable à « dessiner les fixtures »), **T20** +> (résolution du canvas DMX), **T22** (Spout depuis Resolume). Plus, hors +> numérotation, **l'empreinte d'une barre en pixels** — le chiffre qui décide si +> deux barres rangées côte à côte se chevauchent. ## Les trois qui décident de tout Si tu ne fais que trois choses, fais celles-là. Elles arbitrent des semaines de travail. -- [ ] **T11 — `luminosity` par-dessus une texture.** +- [x] **T11 — `luminosity` par-dessus une texture.** Un Material **coloré et contrasté** (dégradé arc-en-ciel, surtout pas du blanc) en plein cadre, une fixture Line dessus. Envoyer `luminosity` à 1, 0.5, puis 0. @@ -21,15 +33,19 @@ de travail. mais par-dessus du contenu vivant. - *Exclusif* → aucun effet, ou saut binaire. Il faudra choisir barre par barre entre « pilotée par Cascade » et « texturée ». - - Résultat : ⟨à remplir⟩ + - Résultat : **MULTIPLICATIF — le jackpot.** La texture survit à + l'atténuation, et `luminosity` multiplie linéairement, canal par canal, en + préservant la forme du dégradé. Voir « T11 — la texture survit-elle à + l'atténuation ? » et « `luminosity` multiplie canal par canal ». -- [ ] **T12 — `color/*` par-dessus une texture.** +- [x] **T12 — `color/*` par-dessus une texture.** Même Material. Envoyer `color/red`=1, `green`=0, `blue`=0. - *Teinte multiplicative* → le dégradé apparaît filtré en rouge. - *Écrasement* → toute la barre devient rouge uni, le dégradé disparaît. - - Résultat : ⟨à remplir⟩ + - Résultat : **TEINTE MULTIPLICATIVE.** `color/*` est un filtre par canal, il + ne remplace pas. Voir « La couleur FILTRE, elle ne remplace pas ». -- [ ] **T13 — Peut-on écrire la position en OSC ?** +- [x] **T13 — Peut-on écrire la position en OSC ?** D'abord `POST /api/layout` depuis Cascade pour relever `output/x` (ça donne l'unité et l'ordre de grandeur). Puis renvoyer cette valeur **+200**. - *La fixture bouge* → le dépliage peut s'ajuster à chaud. @@ -37,29 +53,41 @@ de travail. fichier SVG, et l'ajustement live sort du plan. - ⚠ Aucune documentation n'atteste ce sens d'écriture. Cascade n'a **jamais** envoyé vers `output/*` à ce jour. - - Résultat : ⟨à remplir⟩ + - Résultat : **OUI, la fixture bouge** — avec deux pièges. Voir + « T13 / T14 / T15 — la géométrie est-elle pilotable ? ». ⚠ `output/handles/*` + reste **INTERDIT** : lecture morte, écriture irréversible (une surface + détruite le 27/07, `Ctrl+Z` ne la rattrape pas). ## La démonstration à filmer -- [ ] **T16 — Le dégradé qui voyage.** +- [x] **T16 — Le dégradé qui voyage.** Un Material à dégradé animé horizontal, une barre **horizontale** : les LED doivent s'allumer successivement d'un bout à l'autre. Puis pivoter la barre à 90° : **toutes les LED doivent changer ensemble.** C'est tout le principe en une manip — et l'image du README. - - Résultat : ⟨à remplir⟩ + - Résultat : **CONFIRMÉ à la mesure** — tourner la barre de 90° change + **17 canaux DMX sur 30**. Voir « T16 — tourner la barre change ce qu'elle + joue ». ⚠ Reste à FILMER : c'est le GIF du README, et il manque toujours. ## Géométrie et dépliage -- [ ] **T14 — Rotation.** `output/rot` à 90. Autour de quel point pivote-t-elle, +- [x] **T14 — Rotation.** `output/rot` à 90. Autour de quel point pivote-t-elle, son centre ou une extrémité ? Le calcul de dépliage en dépend. - Résultat : ⟨à remplir⟩ -- [ ] **T15 — Échelle.** `output/width` de 1 à 2. Les points d'échantillonnage + Résultat : **autour du point `output/x, output/y` lui-même** (2026-07-29). + Fixture à x=960 y=270, rot 0→90 : écart **0,00** sur x et sur y. Donc l'ordre + position/orientation est libre. Détail dans `madmapper-osc-api.md`. +- [x] **T15 — Échelle.** `output/width` de 1 à 2. Les points d'échantillonnage s'écartent-ils (le dégradé lu s'étale) ou rien ne bouge ? - Résultat : ⟨à remplir⟩ -- [ ] **T18 — Recouvrement.** Superposer deux fixtures volontairement. + Résultat : **RIEN NE BOUGE.** `output/width|height` d'une *fixture* n'ont + aucun effet (testé de 0,25 à 4), et rien n'expose son empreinte. Voir + « T13 / T14 / T15 ». +- [x] **T18 — Recouvrement.** Superposer deux fixtures volontairement. Attendu : celle du dessus écrase, sans mélange — donc le placement automatique devra interdire les chevauchements. - Résultat : ⟨à remplir⟩ + Résultat : **AUCUNE fixture n'en écrase une autre** — chacune échantillonne + pour son compte. ⚠ En revanche, deux *copies à la même adresse DMX* : c'est la + **dernière de la liste qui gagne**, pas un mélange ni du HTP. Voir + « Recouvrement : aucune fixture n'en écrase une autre ». - [ ] **T19 — Format SVG de TA version.** Exporter un patch de fixtures, ouvrir le fichier dans un éditeur de texte, relever le format exact des attributs. ⚠ Il a changé en MadMapper 6.1. **Ranger le fichier d'exemple dans `docs/`.** @@ -80,7 +108,7 @@ de travail. ## Le point de sécurité — à ne pas oublier -- [ ] **T21 — Le noir doit faire noir.** +- [x] **T21 — Le noir doit faire noir.** Une barre en régime texture (elle joue un Material). Faire **STOP** dans Cascade, puis **BLACKOUT**. ⚠ Règle n°4 du projet : STOP relâche le contrôle, seul BLACKOUT envoie des @@ -89,7 +117,10 @@ de travail. Il faudra alors une seconde voie : couper la source côté MadMapper, ou masquer les fixtures avec `visible`. Pour un régisseur, un noir qui ne fait pas noir est le pire des défauts. - Résultat : ⟨à remplir⟩ + Résultat : **le blackout coupe vraiment, même en régime texture.** Et la + seconde voie existe : `master_dmx_level`, livré en 2.0.0 sous le nom + **Coupure de secours**. Voir « T21 — le blackout coupe-t-il vraiment » et + « Le noir de secours existe — et c'est `master_dmx_level` ». - [ ] **T22 — Spout de Resolume échantillonné par une fixture.** Sortie Spout dans Resolume, flux récupéré dans MadMapper, posé sur une From 48da13c9cacbdef06e98dd99dbddc6f65056c941 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:35:49 +0000 Subject: [PATCH 03/27] =?UTF-8?q?Cascade=202.0.2=20=E2=80=94=20la=20suite?= =?UTF-8?q?=20de=20tests=20=C3=A9tait=20verte=20pour=20de=20mauvaises=20ra?= =?UTF-8?q?isons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six commits attendaient depuis la 2.0.1, dont un correctif bloquant : le crossfader posé en butée — la position de repos de tout fader — rallumait le plateau entier. Ils sortent. Version passée à 2.0.2 dans package.json, server.js et dist/ (via `node sync-dist.js`, qui aligne dist/package.json tout seul). Le manuel PDF n'est PAS régénéré, et c'est volontaire : `build-manuel.py` porte une version majeure.mineure, comme le pied de page de l'interface, et cette version n'apporte ni réglage ni fonction. Vérifié en interrogeant un vrai serveur : `/api/ping` répond 2.0.2, le pied de page affiche « CASCADE v2.0 ». La section `[Non publié]` du CHANGELOG est datée, et complétée par ce qui n'y figurait pas encore : - le lot « tests » — 32 tests d'interface qui ne tournaient pas, un test qui ne mesurait rien, un test de redémarrage qui ne redémarrait rien, et le filet d'`afterEach` du champ 3D (garantie structurelle, pas correction : la cascade d'échecs a été cherchée sans être reproduite, c'est dit tel quel) ; - le lot « documentation » — les fichiers de reprise annonçaient la 1.6.0, et deux défauts présentés comme ouverts étaient corrigés depuis la 2.0.1 ; - les deux mesures MadMapper consignées depuis : le pivot de `output/rot`, et les motifs OSC que MadMapper ne développe pas. `tools/notes-version.js 2.0.2` extrait bien la section : la description de la release GitHub viendra donc du CHANGELOG, pas d'un texte recopié à côté. Au passage, le tableau de téléchargement du README annonçait encore les exécutables **1.6.0** — deux versions majeures en retard, sur la seule page que lit quelqu'un qui vient télécharger. Il est rendu insensible à la version (`Cascade--…`) plutôt que corrigé une fois de plus. ⚠ Le tag `v2.0.2` n'est PAS posé : il déclenche la construction des quatre exécutables et la publication de la release, et il attend que cette branche soit fusionnée et que Pym le décide. 262 tests, zéro échec. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH --- CHANGELOG.md | Bin 28185 -> 31778 bytes README.md | 15 ++++++++------- dist/package.json | 2 +- dist/server.js | 2 +- package.json | 2 +- server.js | 2 +- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e42ce4d1aa7fec73f9599b0dac2f00617412f7..476cd5d6d34cef642e8b41d57f5143794c2eb635 100644 GIT binary patch delta 3548 zcmZvfPj4Jq6~#3YNT>#}86-9=9x_tfCA+87fq*iLk>gmCpiE-LPDTigQm?CCcfY8r zUgf>2o>&MK9|06LtQfI@o>`$`H@ox);0v(g1F%Da9l!gk?D?bF*zW3j_uYH$Ip<#g z@t<$}`M+=c?jQYpI-AZvRR8+zAJlv{zjZYG`O)krNpjb8FYWHHl~EhxgLSnkj9OZ~ zu%!*U<-t_RJF3`im%3|%T9ocZr)&Odz3Vo+ZBUK&%A3;UZRbs*3kjvY)ulO9U9A?r zv)^ge&AaMFXMg{{ANGgJv?_GIbzRE~Mx|-!Y-oV5Z4&ms3qm(MEU5@3yN_uP9N=b2EHFmTKwhT#$=k^SRP>XG%r3 z%GUW>f2&^Ywy&~pfB$d){_dOop1O#2NM}k7EC8W)+gLY1@}VJ9X-lQMjkbZxDKxk` zB*_!*9Zn3d^0ASw@Qa$Hj98Q&s@-j;>cTr4RIbCJJPYc3bkN(Sl|Gs}dG)7X{Gcc2 z+*OU^a_PX3z{Xjx_(o9YXtgkYnj}Z`?i*nyldmE?rEl5N*2Nv~J_^R~A7l|;v(mnh zCX;QVxFPf^Uz^I(hj1C%NX~cUIZ4sQ+H^huWN0;DlikVMyUKQz3e-b{?CinuFF$yE z|Gj$`&+a^a^uhV*_2-9@ z6vsfr!Un3Bm(nx>o~Dt@^jhS?^ZCu$$T=udTGL76=C|h8yds+LQ%VQSz(vSp)EE#` zrS6|YL@wvg4MH1A|2iVKrP7n*^ONJdr_UZ9KfV9n@x|$r$C=t+SZM8yvUD))wg)0M zQK2sNC47~HO7sx%iwY;w8Q*>6?P}dl)k%K^s5i?FJ+yKJ{a4z1<6#M!aP6VWj*e<~ z#0$m3z04xN^^iId)3mECEsX>UmMc43s0|jPW-vk6j$otuE1j<`cnr5}OkcXDi3bk_ zqlL~f073@6;G=ZFND@tnoTuuvh77>9^0Amm5{uwCJJE|^YBW#eT5s%1w~$9IX%yk8 zh^AIZ3AyEK*HIhspSym6q{rq>cAvChIrK>gAzlZX0d#p8*9l-cjXHQz58@E>qMXr@ z20xOt^0E_(e4)y+l+O$3AmVs1(bxs>AB8GK_D#)KQmQG5K!3Hut;+BQ}1473vO zMaEL`_|}GI^WIE)Pnx1xLgEQRQe+_98ZN^|#QUPNs+FlJmiw{XEtn3ZiB!BDX-NCP zpCoFDN3hypBw!Mabs{E>FN--40^h&X)j)vQ>Nq4wRBdeWHOy$CZM%~1UVqw)-g4J9 zOcNo=hDw+KVBpP2L*eo3&jBSc73%w#DV z#?-yH!jn=by^?M%?TUdCeH>7|xU%#fG#igQ8GlGRaRkbt>wHbU(%3u1JY%k|#L@^R z{Gm2z6?;JT;3>@_u2d zSHm{S%1$3XI(>MNVapF7ylGoE3jS(V(rD&S)yOQCM6T(adxz(?D$~o>^!^k@W~9hi zORjkWcy{Qm;NBRN+>}fZm?l(`43K0ivoSi343Ungs*sV&w=&e)gb_%>03lOtCTtz zBt_z7NELob79oio-XMuMEEqHcaKUut;E%+JE|4U9dwZ(C>+&wHKEghkv7*A8IoLDH zD6p6upWWxG+?D7O(bJ7AO2<#1hYa(P(ykJO7!gx)!9v z0Y_vwKRkLmpH>AE1HwJMarKGu@}lD+(chX3oR|3#uC}N^B6WdE;+)0Soq!(kuPhDm zF+pkURnfdkXS&g8zPiOW=#1F_%Ve#HNph5tPmIZPFOAwYd`Udz4 z8>x6?IKN6U6j2CU3C(-;*^M8)72D0BX02%!eNWV@*?g~`Vo!mEdt5&YJV|;wF3=-w zgvfFJb4|F4LA|4`<>PYxM6?+DCHo2tj($57Gx$pFsFIcPW7I~DP1(yD9QOyj+?5-1 zNBbb_7_?$#i8!XRHySuBhQ5%&kJJ#uhTpRO7b4vit7aoa5xHE0A^O7LSvFyAL_JPI z4!*C8_jS|s^y=CZd3+n*60NZt7gvpptDCNU+q-tyD&))P0fk({R%4?rmz}--k6?cs ze6vGrljY#0kn3{b&cyiwu3VjayN6lyckp(yCFW?d3vUi$)5c6mRAz)|dt>5OBDNVZ zsa$K9K|MM@fergvs@_$-windows-x64.exe` | +| macOS (Apple Silicon, M1 et suivants) | `Cascade--macos-apple-silicon` | +| macOS (Intel) | `Cascade--macos-intel` | +| Linux | `Cascade--linux-x64` | -> **macOS et Linux**, la première fois, dans un Terminal ouvert sur le dossier : +> **macOS et Linux**, la première fois, dans un Terminal ouvert sur le dossier +> (remplacez `` par celle que vous avez téléchargée) : > ```bash -> xattr -cr Cascade-1.6.0-macos-apple-silicon && chmod +x Cascade-1.6.0-macos-apple-silicon +> xattr -cr Cascade--macos-apple-silicon && chmod +x Cascade--macos-apple-silicon > ``` > (les binaires ne sont pas signés — sans cela macOS annonce un « fichier endommagé ») diff --git a/dist/package.json b/dist/package.json index eec2281..c8420e3 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "cascade-madmapper", - "version": "2.0.1", + "version": "2.0.2", "description": "Cascade — séquenceur LED multi-couches pour MadMapper (OSC) : chases, vagues, couleur, presets, MIDI/OSC, Ableton Link", "author": "Pierre-Yves Mansour — Collectif WSK", "license": "MIT", diff --git a/dist/server.js b/dist/server.js index ace9733..22b8604 100644 --- a/dist/server.js +++ b/dist/server.js @@ -16,7 +16,7 @@ const os = require('os'); const { exec, spawn } = require('child_process'); const APP_NAME = 'Cascade'; -const VERSION = '2.0.1'; +const VERSION = '2.0.2'; const SIGNATURE = 'Pierre-Yves Mansour — Collectif WSK'; const PRESET_SLOTS = 16; const MAX_LAYERS = 8; diff --git a/package.json b/package.json index afca50a..1e3dc85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cascade-madmapper", - "version": "2.0.1", + "version": "2.0.2", "description": "Cascade — séquenceur LED multi-couches pour MadMapper (OSC) : chases, vagues, couleur, presets, MIDI/OSC, Ableton Link", "author": "Pierre-Yves Mansour — Collectif WSK", "license": "MIT", diff --git a/server.js b/server.js index ace9733..22b8604 100644 --- a/server.js +++ b/server.js @@ -16,7 +16,7 @@ const os = require('os'); const { exec, spawn } = require('child_process'); const APP_NAME = 'Cascade'; -const VERSION = '2.0.1'; +const VERSION = '2.0.2'; const SIGNATURE = 'Pierre-Yves Mansour — Collectif WSK'; const PRESET_SLOTS = 16; const MAX_LAYERS = 8; From 85bfb0c168a18140c5d197338f01c4ca485cc1f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:38:07 +0000 Subject: [PATCH 04/27] Les 16 presets deviennent une grille, et disent ce qu'ils pilotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une rangée de boutons qui se replie selon la largeur de la fenêtre : en régie, dans le noir, on vise une case dont on croit connaître la place et elle a bougé. C'est une grille — 8 colonnes sur poste, 4 sur tablette — et chaque pavé porte trois choses qu'il fallait deviner jusqu'ici. **L'empreinte** : une case par barre que le preset pilote, teintée comme la couche qui la pilote. C'est une COUVERTURE, pas une image du motif — un chase n'allume qu'une barre à la fois, une photo serait presque vide et changerait à chaque image. Elle passe par `resolveBars`, donc elle ment exactement comme mentirait le moteur : un groupe vidé la ramène sur toutes les barres, ici comme en scène. Une vignette qui corrigerait le moteur serait pire qu'inutile. **Le pavé qui joue**, et celui d'où l'on vient pendant un fondu. `presetActif` est posé dans `recallPreset` et pas dans la route : le rappel arrive aussi par OSC et par MIDI, et le poser au seul endroit que tous traversent évite d'en oublier un. **« Ce preset ne pilote plus aucune barre »**, en bord pointillé. C'est l'information la plus utile avant de tirer un pavé ; la masquer laisserait croire à une panne au moment du rappel. Le coût du poll était le vrai sujet. `/api/state` part HUIT FOIS PAR SECONDE : y loger 16 empreintes aurait coûté une demi-kilo-octet à chaque tour pour une donnée qui ne change qu'à l'enregistrement. Elles sont donc servies par une route dédiée, tirée seulement quand un compteur de révision bouge, et `/api/state` ne gagne que trois entiers. Un rappel n'incrémente PAS ce compteur : la banque n'a pas changé, et recharger les empreintes à ce moment-là ferait une requête de plus au pire moment. Un test vérifie qu'aucune empreinte n'est réapparue dans `/api/state`. Le calcul n'instancie aucun moteur, ne touche à aucun état d'exécution et n'émet aucun OSC : appelable en plein spectacle. Si la route échoue, les pavés s'affichent sans bande et sans un bruit — c'est testé. Corrigé au passage, trouvé en lisant ce chemin : `sanitizePresets` coupait les noms à 12 là où l'enregistrement et le renommage coupent à 16. Le rabotage n'arrivait ni à la saisie ni au rappel, mais au RECHARGEMENT du fichier, quand plus personne ne regarde — « Refrain final 2 » revenait « Refrain fin ». Une piste de la conception n'a PAS été suivie : elle signalait que la signature de rendu confondait ['AB', null] et ['A', 'B']. Vérifié dans le source — le `join` utilise un séparateur \x01 invisible, il ne confond rien. Le code existant est resté tel quel. Neuf tests ajoutés (271 au total), dont cinq dans un vrai navigateur : la grille et son empreinte, le pavé qui joue et le pavé sortant, le preset muet, un nom hostile qui n'injecte rien, et la dégradation quand la route échoue. Deux anti-mutants gardent un chemin qui n'en avait aucun ; les deux ont été vérifiés tués, pas seulement ajoutés. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH --- CHANGELOG.md | Bin 31778 -> 33980 bytes dist/public/index.html | 115 +++++++++++++++++++++++++++++++----- dist/server.js | 122 ++++++++++++++++++++++++++++++++++++--- docs/ETAT-DU-PROJET.md | 1 - public/index.html | 115 +++++++++++++++++++++++++++++++----- server.js | 122 ++++++++++++++++++++++++++++++++++++--- tests/api.test.js | 74 ++++++++++++++++++++++++ tests/interface.test.js | 3 + tests/mutations-liste.js | 16 +++++ tests/ui.test.js | 111 +++++++++++++++++++++++++++++++++++ 10 files changed, 636 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 476cd5d6d34cef642e8b41d57f5143794c2eb635..4df414cfc4352edd73edb0fa3f0b748da59fdbbc 100644 GIT binary patch delta 2209 zcmY*bJ&znk6cmIL^8=pbU}3elmMvr<6Cf}a60*R;L`X>6JNtI8O;7h+f2?d13@I5B zL_`)53lfq;WFk*O#((gyAf&2i?u?VW*`4m!^{VPs?=Szp@z?in{_xN6S@6=At+V6l z>!!JO?V5b}FqAx=;ng25Y7T z(fGr7QtXp0iUyy=mu@`8fCG|T6gx;#iP}4BY{s(#9A_G5;z3K~MrUNNe*7&*mD{3T z;&?h(UCGX%oKx|^#+6{-(V`Y{wFVEn01)`Ljlt%o4c?pa4;Dee-kDZKv*>^Z#1I{T zuI0``Qgi5FmP__QXr* zM+?z4PqqAEdWo$wmG$7Y+@)>+gTvVQ2AhLB>PIG~4XhD-E6ODutcX(j$h2AcVeX%g z7UvF`T%ifNjgBYWqdoYlB8h23jCO``wG<^UVcSai!upae-oIak!o#FFD`1`=){1^> z)!~cXB8-2ETfm(I2MW!rhn+bz;Kzg3smzPtvEx!n0Gq^weM=Hk+E>)RcwP_iFml|% zXwVv?dF5!pbwD^-4vw2<07c4BL7O8F2k+nnZUc&nzCwm%ouM)jyDSK&T2u!bfPwsvx*G+G) z&&hH|{F`@H4KQ*=(&8pXFN*Oy$_Q)sRY-J#HOK?@I%UY#c)E*&u4~!U$|;*6>!!i+ z2Ls9L=uGoTduU9Uu(mn@<$6pi8;uz=ab93qTA5z}3-V=mDe~jS1sI0G`M*E1K!N`xB}4l#baT(Jv4~7M1G);iAAeI$V}H3hsm`bwD{IiO*m|xjTO%YD8>a2 zonA5PVVi|M8c!|k53(`1eld)v4&6+PK7VirEaFwK1;e(et^hko^4=koB=y>6(?Y+* zM2{E<`ha(b#`VPx$Vf3<% zkPZFvn!8iydcNoI&WDaxnPZbroHG2saTDZ3?e46*ygu>-hvnMJ}u4#&#MGkOVbK}-p?!m-7gh7iyMG75Rv}T*P zEDl~0w1ianjM span, #presets > button:not(.slot) { grid-column: 1 / -1; } /* Fondu en cours : un filet de progression sous la rangée de presets */ #presets::after { content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: var(--fade, 0%); background: var(--accent); border-radius: 2px; opacity: 0; transition: opacity .2s; box-shadow: 0 0 8px var(--glow); } #presets.fading::after { opacity: 1; } - #presets button.slot { min-width: 34px; padding: 8px 0; font-weight: 700; position: relative; - font-variant-numeric: tabular-nums; } + #presets button.slot { min-width: 0; padding: 7px 7px 6px; font-weight: 700; position: relative; + font-variant-numeric: tabular-nums; display: flex; flex-direction: column; + align-items: stretch; gap: 5px; min-height: 54px; text-align: left; } + /* Empreinte : une case par barre pilotée par le preset, teintée comme la + couche qui la pilote. C'est une couverture, PAS une image du motif — un + chase n'allume qu'une barre à la fois, une photo serait presque vide. */ + #presets .emp { display: flex; gap: 1px; height: 6px; margin-top: auto; + border-radius: 2px; overflow: hidden; } + #presets .emp i { flex: 1 1 0; min-width: 0; background: var(--border); } + /* Ce preset ne pilote plus aucune barre : à dire, surtout pas à masquer */ + #presets button.slot.muet { border-style: dashed; } + /* Le pavé qui joue, et celui d'où l'on vient pendant un fondu */ + #presets button.slot.joue { border-color: var(--accent); color: var(--accent); + box-shadow: 0 0 10px var(--glow); } + #presets button.slot.sortante { border-color: var(--accent2); opacity: .7; } /* Un slot occupé porte une pastille orange : on lit la banque d'un coup d'œil */ #presets button.slot:not(.empty)::after { content: ''; position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); } /* Preset nommé : le nom prime, le numéro reste lisible pour le MIDI/OSC */ - #presets button.slot.named { min-width: 0; padding: 6px 12px 6px 9px; display: inline-flex; - align-items: baseline; gap: 6px; } + /* ⚠ Le pavé nommé garde la colonne : le numéro au-dessus, le nom en dessous, + l'empreinte en bas. Repasser en `inline-flex` remettrait l'empreinte sur la + même ligne que le nom. L'ordre numéro puis nom est un contrat de test. */ + #presets button.slot.named { padding: 7px 7px 6px; } #presets button.slot.named::after { display: none; } - #presets .pnum { font-size: 10px; color: var(--dim); font-weight: 700; } + #presets .pnum { font-size: 10px; color: var(--dim); font-weight: 700; line-height: 1; } #presets button.slot.named:hover .pnum { color: var(--accent); } - #presets .pname { font-size: 12px; font-weight: 600; letter-spacing: .3px; - max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #presets button.slot.joue .pnum { color: var(--accent); } + #presets .pname { font-size: 12px; font-weight: 600; letter-spacing: .3px; line-height: 1.2; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #presets button.empty { opacity: .4; font-weight: 400; } #presets button.savemode { border-color: var(--off); color: var(--off); animation: blinkSave 1s steps(2, start) infinite; } @@ -374,6 +396,7 @@

Raccourcis & gestes

Double-clicSur une ligne de réglage : retour à la valeur par défaut Double-clicSur un nom de couche : renommer · sur une barre : pivoter Double-clicSur un preset : renommer + Grille de presetsLa bande de couleur montre les barres que le preset pilote — une couverture, pas une image du motif. Bord en pointillé : ce preset ne pilote plus aucune barre. Pavé orange : le dernier rappelé. GlisserUne barre dans la vue spatiale : la déplacer Page Scène (vue 3D) Glisser une barreLa déplacer · Maj = en hauteur · Alt = l'orienter @@ -474,7 +497,7 @@

Premiers pas

sur un iPad en un scan.

-
+
@@ -990,6 +1013,25 @@

Diagnostic OSC

// null = inactif · {type:'layer'} = pour la couche courante · {type:'group', id} let assignMode = null; let saveMode = false; // presets : prochain clic = sauvegarde +// Empreintes des presets. Elles ne voyagent PAS dans /api/state (poll 120 ms) : +// on les retire seulement quand le serveur signale que la banque a changé, via +// son compteur de révision. `infoEnCours` évite d'empiler les requêtes si le +// réseau traîne ; un échec laisse simplement les pavés sans bande, sans bruit. +let infoRev = -1, infosPresets = null, infoEnCours = false; +function chargerInfosPresets() { + if (!S || infoEnCours || S.presetsRev === infoRev) return; + infoEnCours = true; + fetch('/api/presets-info') + .then(r => r.json()) + .then(r => { + if (!r || !r.ok) return; + infosPresets = r.infos; infoRev = r.rev; + const pr = $('#presets'); + if (pr) pr.dataset.sig = ''; // force un re-rendu des pavés + }) + .catch(() => {}) // hors ligne : les pavés restent lisibles, sans empreinte + .finally(() => { infoEnCours = false; }); +} const post = (url, body = {}) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json()); @@ -1139,8 +1181,13 @@

Diagnostic OSC

// presets const pr = $('#presets'); + chargerInfosPresets(); // La signature inclut les NOMS : sinon un renommage ne se verrait jamais. - const psig = S.presets.map(p => p || '·').join('') + (saveMode ? 'S' : ''); + // `infoRev` en fait partie pour que des empreintes fraîchement chargées + // déclenchent le re-rendu. En revanche presetActif, fade et fadeDe en sont + // EXCLUS, à dessein : ils changent à chaque poll, et les inclure + // reconstruirait les 16 pavés huit fois par seconde, en plein spectacle. + const psig = S.presets.map(p => p || '·').join('') + '|' + infoRev + (saveMode ? 'S' : ''); if (pr.dataset.sig !== psig) { pr.dataset.sig = psig; pr.innerHTML = 'PRESETS'; @@ -1159,9 +1206,39 @@

Diagnostic OSC

} else { b.textContent = i + 1; } + // Empreinte : une case par barre pilotée, teintée comme sa couche. + // ⚠ Aucun texte dans ces éléments — le textContent du pavé doit rester + // « 1Refrain », c'est un contrat vérifié par les tests d'interface. + const info = infosPresets && infosPresets[i]; + if (info && info.c) { + const emp = document.createElement('span'); + emp.className = 'emp'; + let peintes = 0; + for (const ch of info.c) { + const cell = document.createElement('i'); + if (ch !== '.') { + const coul = info.pal[+ch]; + if (coul) { cell.style.background = coul; peintes++; } + } + emp.appendChild(cell); + } + b.appendChild(emp); + // Un preset qui ne pilote plus aucune barre : le DIRE. C'est + // l'information la plus utile de la grille avant de tirer un pavé — + // le masquer laisserait croire à une panne au moment du rappel. + if (!peintes) b.classList.add('muet'); + } + // L'infobulle porte ce que l'empreinte ne peut pas montrer : le nombre de + // couches actives et leurs moteurs (P = pas-à-pas, V = vague, C = champ). + const detail = info + ? '\n' + info.a + ' couche' + (info.a > 1 ? 's' : '') + ' active' + + (info.a > 1 ? 's' : '') + ' sur ' + info.t + + (info.e ? ' — moteurs ' + info.e.split('').join(', ') : '') + + (info.c && !/[^.]/.test(info.c) ? '\n⚠ ne pilote aucune barre' : '') + : ''; b.title = saveMode ? 'Enregistrer ici' : (p ? 'Rappeler « ' + p +' » : couches + ordre des fixtures + vue spatiale' - + '\nDouble-clic : renommer' + + detail + '\nDouble-clic : renommer' : 'Slot vide'); b.onclick = () => { if (saveMode) { @@ -1406,6 +1483,18 @@

Diagnostic OSC

const barrePresets = $('#presets'); barrePresets.classList.toggle('fading', S.fade != null); barrePresets.style.setProperty('--fade', ((S.fade || 0) * 100).toFixed(1) + '%'); + // Quel pavé joue, et d'où l'on vient pendant un fondu. Appliqué à CHAQUE poll + // mais hors de la signature de rendu : on repeint deux classes, on ne + // reconstruit pas les 16 pavés. Les accolades gardent ces locales hors de la + // portée de render(), où un `const` en double ferait tomber un garde-fou. + { + const pads = barrePresets.querySelectorAll('button.slot'); + const sortant = S.fade != null ? S.fadeDe : null; + pads.forEach((b, i) => { + b.classList.toggle('joue', S.presetActif === i); + b.classList.toggle('sortante', sortant === i && S.presetActif !== i); + }); + } $('#bpm').innerHTML = '— ' + Math.round(60000 / L.stepMs) + ' BPM (pas)'; // Ableton Link : quand il pilote, le tempo manuel est neutralisé diff --git a/dist/server.js b/dist/server.js index 22b8604..2aa2908 100644 --- a/dist/server.js +++ b/dist/server.js @@ -19,6 +19,9 @@ const APP_NAME = 'Cascade'; const VERSION = '2.0.2'; const SIGNATURE = 'Pierre-Yves Mansour — Collectif WSK'; const PRESET_SLOTS = 16; +// Orange signature de la charte (`--accent` dans public/index.html). Sert aux +// empreintes de presets, où une couche d'intensité n'a pas de couleur à montrer. +const ACCENT = '#f2900f'; const MAX_LAYERS = 8; const MAX_FIXTURES = 128; const MAX_MIDI_BINDINGS = 256; @@ -726,7 +729,11 @@ function sanitizePresets(list) { if (!Array.isArray(list)) return Array(PRESET_SLOTS).fill(null); return list.slice(0, PRESET_SLOTS).map(p => p && typeof p === 'object' && Array.isArray(p.layers) && p.layers.length - ? { name: String(p.name || 'P').slice(0, 12), + // ⚠ 16, pas 12 : `savePreset` et le renommage coupent tous deux à 16. + // Couper plus court ICI rabotait silencieusement tout nom de 13 à 16 + // caractères — pas à la saisie, mais au redémarrage et à l'import, quand + // plus personne ne regarde. « Refrain final 2 » revenait « Refrain fin ». + ? { name: String(p.name || 'P').slice(0, 16), layers: p.layers.slice(0, MAX_LAYERS).map(sanitizeLayer), fixtures: Array.isArray(p.fixtures) ? sanitizeFixtures(p.fixtures) : null } : null @@ -1485,9 +1492,18 @@ function savePreset(i, name) { return { name: n || 'P' + (i + 1), layers: deep(state.layers), fixtures: deep(state.fixtures) }; } /** Fondu en cours entre deux presets (null = rappel sec). */ -let fade = null; // { start, dur, layers, fixtures, engines } +let fade = null; // { start, dur, de, layers, fixtures, engines } function cancelFade() { fade = null; } +// ── Repères de la banque de presets, pour l'interface ───────────────────────── +// Les deux vivent en MÉMOIRE et n'entrent PAS dans `state` : rien à sanitiser, +// rien à exporter, rien à migrer. `presetActif` = dernier slot rappelé ou +// enregistré, pour que le régisseur voie quel pavé joue. `presetsRev` = compteur +// de révision de la banque : l'interface ne recharge les empreintes que +// lorsqu'il bouge, au lieu de les faire voyager 8 fois par seconde. +let presetActif = null; +let presetsRev = 1; + /** * Rappelle un preset. Si un temps de fondu est réglé ET qu'un show tourne, * la scène sortante est mise de côté avec son état moteur : elle continue de @@ -1506,7 +1522,7 @@ function recallPreset(i, fadeMs) { } // Un rappel pendant un fondu remplace le précédent : la scène en cours de // sortie est abandonnée, sinon il faudrait empiler les fondus à l'infini. - fade = { start: Date.now(), dur, layers: deep(state.layers), + fade = { start: Date.now(), dur, de: presetActif, layers: deep(state.layers), fixtures: deep(state.fixtures), engines: store }; } else cancelFade(); state.layers = deep(p.layers); @@ -1521,11 +1537,66 @@ function recallPreset(i, fadeMs) { pruneCaches(); } engines.clear(); + // Ici et pas dans la route : le rappel arrive AUSSI par OSC (`/chaser/preset`) + // et par MIDI. Poser le repère au seul endroit que tous traversent évite de + // dupliquer la logique — et d'en oublier une. + presetActif = i; return true; } /** Noms des presets pour l'interface : null = slot vide. */ function presetNames() { return state.presets.map((p, i) => p ? (p.name || 'P' + (i + 1)) : null); } +/** + * Empreinte d'un preset, pour la vignette d'un pavé de la grille. + * + * ⚠ C'est une empreinte de COUVERTURE, pas une photo du motif : elle dit quelles + * barres le preset pilote et avec quelles teintes, pas ce qui est allumé à un + * instant donné (un chase n'allume qu'une barre à la fois — une photo serait + * presque vide et changerait à chaque image). + * + * Elle passe par `resolveBars`, donc elle ment EXACTEMENT comme mentirait le + * moteur : un groupe vidé ramène la couche sur toutes les barres, ici comme en + * scène. C'est voulu — une vignette qui corrige le moteur serait pire. + * + * Aucun moteur n'est instancié, aucun état runtime touché, aucun OSC émis : on + * peut l'appeler pendant un show sans rien déranger. Et elle n'est PAS dans + * `/api/state`, à dessein : cette réponse part 8 fois par seconde. + */ +function infoPreset(p) { + if (!p) return null; + // Un preset importé d'une vieille config peut n'avoir aucune fixture : on + // retombe alors sur le plateau courant, faute de mieux. + const fx = (Array.isArray(p.fixtures) && p.fixtures.length ? p.fixtures : state.fixtures) + .filter(f => f && f.enabled !== false); + // Plafond de 64 cases : au-delà on sous-échantillonne. Une bande de 128 cases + // serait illisible, et la charge utile grossirait pour rien. + const pas = Math.ceil(fx.length / 64) || 1; + const vus = fx.filter((f, i) => i % pas === 0); + const rang = new Map(vus.map((f, i) => [f.id, i])); + const cases = new Array(vus.length).fill('.'); + const pal = []; + const act = p.layers.filter(L => L.enabled); + for (const L of act) { + const c = L.target === 'color' ? (L.colorA || '#ff2000') : ACCENT; + let k = pal.indexOf(c); + if (k < 0) { k = pal.length; pal.push(c); } + // ⚠ Résoudre sur `fx` COMPLET, pas sur l'échantillon : `resolveBars` replie + // sur toutes les barres quand aucune de la sélection n'est présente, et + // sous-échantillonner d'abord déclencherait ce repli à tort. + for (const f of resolveBars(L, fx)) { + const r = rang.get(f.id); + if (r != null) cases[r] = String(k); + } + } + return { + a: act.length, t: p.layers.length, + e: act.map(L => L.engine === 'steps' ? 'P' : L.engine === 'wave' ? 'V' : 'C').join('').slice(0, 8), + pal, c: cases.join(''), + }; +} +/** Empreintes des 16 slots. Servie à la demande, jamais dans `/api/state`. */ +function presetsInfo() { return state.presets.map(infoPreset); } + /** Resync : recale la phase (départ des pas) d'une couche, ou de toutes. */ /** * GO / RESYNC : tout repart ensemble, sur le temps fort. @@ -2499,6 +2570,16 @@ const server = http.createServer(async (req, res) => { }, null, 2)); } + // Empreintes des presets, servies À LA DEMANDE. Volontairement hors de + // `/api/state` : celle-ci part 8 fois par seconde, et 16 empreintes y + // pèseraient une demi-kilo-octet à chaque fois pour une donnée qui ne bouge + // qu'à l'enregistrement. L'interface ne rappelle cette route que lorsque + // `presetsRev` change. Aucune écriture, aucun effet de bord — contrairement à + // `/api/export`, qui remet `dirtySinceExport` à false. + if (url === '/api/presets-info') { + return json(res, { ok: true, rev: presetsRev, infos: presetsInfo() }); + } + if (url === '/api/state') { lastUiPollAt = Date.now(); // une interface est ouverte (voir arrêt automatique) return json(res, { @@ -2506,7 +2587,10 @@ const server = http.createServer(async (req, res) => { settings: state.settings, scene: state.scene, vues: state.vues, fixtures: state.fixtures, groups: state.groups, layers: state.layers, global: state.global, - presets: presetNames(), + // `presets` garde sa forme de tableau de 16 chaînes. Les deux repères qui + // suivent sont des entiers : ~40 octets, contre ~560 pour les empreintes, + // qui vivent sur `/api/presets-info`. + presets: presetNames(), presetsRev, presetActif, midiMap: state.midiMap, link: { active: link.active, connected: link.connected, bpm: link.bpm, peers: link.peers, error: link.error, @@ -2518,6 +2602,9 @@ const server = http.createServer(async (req, res) => { host: state.settings.mmHost, port: state.settings.mmPort }, // Progression du fondu entre presets (0-1), pour l'afficher en direct fade: fade ? Math.min(1, (Date.now() - fade.start) / fade.dur) : null, + // Slot d'où part le fondu (null = on ne sait pas d'où on vient), pour que + // la grille montre le pavé sortant en même temps que l'entrant. + fadeDe: fade && fade.de != null ? fade.de : null, project: { name: state.projectName, dirty: dirtySinceExport, lastExportAt }, levels: runtime.levels, colors: runtime.colors, }); @@ -2552,13 +2639,25 @@ const server = http.createServer(async (req, res) => { } case '/api/preset': { const i = Math.max(0, Math.min(PRESET_SLOTS - 1, body.slot | 0)); - if (body.action === 'save') state.presets[i] = savePreset(i, body.name); - else if (body.action === 'recall') recallPreset(i, body.fadeMs); - else if (body.action === 'clear') state.presets[i] = null; - else if (body.action === 'rename' && state.presets[i]) { + if (body.action === 'save') { + state.presets[i] = savePreset(i, body.name); + // On vient d'enregistrer la scène courante : c'est bien elle qui joue. + presetActif = i; + presetsRev++; + } else if (body.action === 'recall') { + recallPreset(i, body.fadeMs); // pose `presetActif` lui-même + } else if (body.action === 'clear') { + state.presets[i] = null; + if (presetActif === i) presetActif = null; + presetsRev++; + } else if (body.action === 'rename' && state.presets[i]) { const n = String(body.name || '').trim().slice(0, 16); state.presets[i].name = n || 'P' + (i + 1); + presetsRev++; } + // ⚠ `recall` n'incrémente PAS `presetsRev` : la banque n'a pas changé, + // seule la scène en cours. Recharger les empreintes à chaque rappel + // ferait une requête de plus au pire moment, en plein spectacle. saveConfig(); return json(res, { ok: true, presets: presetNames(), layers: state.layers, fixtures: state.fixtures }); } @@ -2801,6 +2900,11 @@ const server = http.createServer(async (req, res) => { if (typeof body.projectName === 'string' && body.projectName.trim()) { state.projectName = body.projectName.trim().slice(0, 40); } + // La banque entière a changé de spectacle : les empreintes en mémoire de + // l'interface ne valent plus rien, et « en cours » sur un slot importé + // désignerait une scène qui n'a jamais été jouée. + presetActif = null; + presetsRev++; saveConfig(); dirtySinceExport = false; // l'état vient d'un fichier : rien à ré-exporter return json(res, { ok: true }); @@ -2810,6 +2914,8 @@ const server = http.createServer(async (req, res) => { state.layers = [defaultLayer('Chaser 1')]; layerSeq = 2; state.presets = Array(PRESET_SLOTS).fill(null); + presetActif = null; + presetsRev++; if (!body.keepFixtures) { state.fixtures = []; state.groups = []; pruneCaches(); } // Les vues portent les noms des dossiers d'un AUTRE spectacle : les // garder ferait pointer Cascade sur des dossiers qui n'existent pas. diff --git a/docs/ETAT-DU-PROJET.md b/docs/ETAT-DU-PROJET.md index 33f9067..4ec7acc 100644 --- a/docs/ETAT-DU-PROJET.md +++ b/docs/ETAT-DU-PROJET.md @@ -477,7 +477,6 @@ Interactions : **double-clic ou double-tap** (helper `onDblTap`, anti-rebond 500 - Horloge MIDI en alternative à Link. ⚠ À arbitrer avant d'écrire : Web MIDI n'existe que sur Chrome/Edge, et Node n'a pas de MIDI natif sans dépendance. - Séquenceur pas-à-pas dessinable (grille barres × pas). - Thème clair. ⚠ Va contre une décision de charte déjà prise (sombre retenu pour la scène) → demander à Pym. -- **Grille visuelle des 16 presets** : aujourd'hui une simple rangée. La brique d'aperçu existe déjà (`renderPreviewCells()`), mais le serveur n'expose que les *noms* (`presetNames()`) — il faudrait enrichir l'état. - **Suiveur audio Web Audio**, côté navigateur pour tenir le zéro-dépendance. Rien n'existe encore ; le point d'entrée désigné est `midiApply()`, qui traduit déjà « une valeur 0-1 + un déclencheur » en appels API. ⚠ **Cette liste a déjà menti trois fois.** Y traînaient, faites : la **synchro de diff --git a/public/index.html b/public/index.html index 55390d2..17c6381 100644 --- a/public/index.html +++ b/public/index.html @@ -259,27 +259,49 @@ .chip.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } .chip.off { opacity: .45; } .chip input { accent-color: var(--on); } - /* Presets */ - #presets { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; - position: relative; padding-bottom: 4px; } + /* Presets — une GRILLE de pavés, pas une rangée de boutons. En régie, dans le + noir, on vise une case dont on connaît la place ; une rangée qui se replie + déplace les slots dès qu'on change la largeur de la fenêtre. 8 colonnes sur + poste (2 rangées), 4 sur tablette, pour garder une cible cliquable au doigt. */ + #presets { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; + margin-bottom: 12px; position: relative; padding-bottom: 6px; } + @media (min-width: 1181px) { #presets { grid-template-columns: repeat(8, minmax(0, 1fr)); } } + /* Le libellé et le bouton Sauver traversent toute la grille */ + #presets > span, #presets > button:not(.slot) { grid-column: 1 / -1; } /* Fondu en cours : un filet de progression sous la rangée de presets */ #presets::after { content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: var(--fade, 0%); background: var(--accent); border-radius: 2px; opacity: 0; transition: opacity .2s; box-shadow: 0 0 8px var(--glow); } #presets.fading::after { opacity: 1; } - #presets button.slot { min-width: 34px; padding: 8px 0; font-weight: 700; position: relative; - font-variant-numeric: tabular-nums; } + #presets button.slot { min-width: 0; padding: 7px 7px 6px; font-weight: 700; position: relative; + font-variant-numeric: tabular-nums; display: flex; flex-direction: column; + align-items: stretch; gap: 5px; min-height: 54px; text-align: left; } + /* Empreinte : une case par barre pilotée par le preset, teintée comme la + couche qui la pilote. C'est une couverture, PAS une image du motif — un + chase n'allume qu'une barre à la fois, une photo serait presque vide. */ + #presets .emp { display: flex; gap: 1px; height: 6px; margin-top: auto; + border-radius: 2px; overflow: hidden; } + #presets .emp i { flex: 1 1 0; min-width: 0; background: var(--border); } + /* Ce preset ne pilote plus aucune barre : à dire, surtout pas à masquer */ + #presets button.slot.muet { border-style: dashed; } + /* Le pavé qui joue, et celui d'où l'on vient pendant un fondu */ + #presets button.slot.joue { border-color: var(--accent); color: var(--accent); + box-shadow: 0 0 10px var(--glow); } + #presets button.slot.sortante { border-color: var(--accent2); opacity: .7; } /* Un slot occupé porte une pastille orange : on lit la banque d'un coup d'œil */ #presets button.slot:not(.empty)::after { content: ''; position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); } /* Preset nommé : le nom prime, le numéro reste lisible pour le MIDI/OSC */ - #presets button.slot.named { min-width: 0; padding: 6px 12px 6px 9px; display: inline-flex; - align-items: baseline; gap: 6px; } + /* ⚠ Le pavé nommé garde la colonne : le numéro au-dessus, le nom en dessous, + l'empreinte en bas. Repasser en `inline-flex` remettrait l'empreinte sur la + même ligne que le nom. L'ordre numéro puis nom est un contrat de test. */ + #presets button.slot.named { padding: 7px 7px 6px; } #presets button.slot.named::after { display: none; } - #presets .pnum { font-size: 10px; color: var(--dim); font-weight: 700; } + #presets .pnum { font-size: 10px; color: var(--dim); font-weight: 700; line-height: 1; } #presets button.slot.named:hover .pnum { color: var(--accent); } - #presets .pname { font-size: 12px; font-weight: 600; letter-spacing: .3px; - max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #presets button.slot.joue .pnum { color: var(--accent); } + #presets .pname { font-size: 12px; font-weight: 600; letter-spacing: .3px; line-height: 1.2; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #presets button.empty { opacity: .4; font-weight: 400; } #presets button.savemode { border-color: var(--off); color: var(--off); animation: blinkSave 1s steps(2, start) infinite; } @@ -374,6 +396,7 @@

Raccourcis & gestes

Double-clicSur une ligne de réglage : retour à la valeur par défaut Double-clicSur un nom de couche : renommer · sur une barre : pivoter Double-clicSur un preset : renommer + Grille de presetsLa bande de couleur montre les barres que le preset pilote — une couverture, pas une image du motif. Bord en pointillé : ce preset ne pilote plus aucune barre. Pavé orange : le dernier rappelé. GlisserUne barre dans la vue spatiale : la déplacer Page Scène (vue 3D) Glisser une barreLa déplacer · Maj = en hauteur · Alt = l'orienter @@ -474,7 +497,7 @@

Premiers pas

sur un iPad en un scan.

-
+
@@ -990,6 +1013,25 @@

Diagnostic OSC

// null = inactif · {type:'layer'} = pour la couche courante · {type:'group', id} let assignMode = null; let saveMode = false; // presets : prochain clic = sauvegarde +// Empreintes des presets. Elles ne voyagent PAS dans /api/state (poll 120 ms) : +// on les retire seulement quand le serveur signale que la banque a changé, via +// son compteur de révision. `infoEnCours` évite d'empiler les requêtes si le +// réseau traîne ; un échec laisse simplement les pavés sans bande, sans bruit. +let infoRev = -1, infosPresets = null, infoEnCours = false; +function chargerInfosPresets() { + if (!S || infoEnCours || S.presetsRev === infoRev) return; + infoEnCours = true; + fetch('/api/presets-info') + .then(r => r.json()) + .then(r => { + if (!r || !r.ok) return; + infosPresets = r.infos; infoRev = r.rev; + const pr = $('#presets'); + if (pr) pr.dataset.sig = ''; // force un re-rendu des pavés + }) + .catch(() => {}) // hors ligne : les pavés restent lisibles, sans empreinte + .finally(() => { infoEnCours = false; }); +} const post = (url, body = {}) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json()); @@ -1139,8 +1181,13 @@

Diagnostic OSC

// presets const pr = $('#presets'); + chargerInfosPresets(); // La signature inclut les NOMS : sinon un renommage ne se verrait jamais. - const psig = S.presets.map(p => p || '·').join('') + (saveMode ? 'S' : ''); + // `infoRev` en fait partie pour que des empreintes fraîchement chargées + // déclenchent le re-rendu. En revanche presetActif, fade et fadeDe en sont + // EXCLUS, à dessein : ils changent à chaque poll, et les inclure + // reconstruirait les 16 pavés huit fois par seconde, en plein spectacle. + const psig = S.presets.map(p => p || '·').join('') + '|' + infoRev + (saveMode ? 'S' : ''); if (pr.dataset.sig !== psig) { pr.dataset.sig = psig; pr.innerHTML = 'PRESETS'; @@ -1159,9 +1206,39 @@

Diagnostic OSC

} else { b.textContent = i + 1; } + // Empreinte : une case par barre pilotée, teintée comme sa couche. + // ⚠ Aucun texte dans ces éléments — le textContent du pavé doit rester + // « 1Refrain », c'est un contrat vérifié par les tests d'interface. + const info = infosPresets && infosPresets[i]; + if (info && info.c) { + const emp = document.createElement('span'); + emp.className = 'emp'; + let peintes = 0; + for (const ch of info.c) { + const cell = document.createElement('i'); + if (ch !== '.') { + const coul = info.pal[+ch]; + if (coul) { cell.style.background = coul; peintes++; } + } + emp.appendChild(cell); + } + b.appendChild(emp); + // Un preset qui ne pilote plus aucune barre : le DIRE. C'est + // l'information la plus utile de la grille avant de tirer un pavé — + // le masquer laisserait croire à une panne au moment du rappel. + if (!peintes) b.classList.add('muet'); + } + // L'infobulle porte ce que l'empreinte ne peut pas montrer : le nombre de + // couches actives et leurs moteurs (P = pas-à-pas, V = vague, C = champ). + const detail = info + ? '\n' + info.a + ' couche' + (info.a > 1 ? 's' : '') + ' active' + + (info.a > 1 ? 's' : '') + ' sur ' + info.t + + (info.e ? ' — moteurs ' + info.e.split('').join(', ') : '') + + (info.c && !/[^.]/.test(info.c) ? '\n⚠ ne pilote aucune barre' : '') + : ''; b.title = saveMode ? 'Enregistrer ici' : (p ? 'Rappeler « ' + p +' » : couches + ordre des fixtures + vue spatiale' - + '\nDouble-clic : renommer' + + detail + '\nDouble-clic : renommer' : 'Slot vide'); b.onclick = () => { if (saveMode) { @@ -1406,6 +1483,18 @@

Diagnostic OSC

const barrePresets = $('#presets'); barrePresets.classList.toggle('fading', S.fade != null); barrePresets.style.setProperty('--fade', ((S.fade || 0) * 100).toFixed(1) + '%'); + // Quel pavé joue, et d'où l'on vient pendant un fondu. Appliqué à CHAQUE poll + // mais hors de la signature de rendu : on repeint deux classes, on ne + // reconstruit pas les 16 pavés. Les accolades gardent ces locales hors de la + // portée de render(), où un `const` en double ferait tomber un garde-fou. + { + const pads = barrePresets.querySelectorAll('button.slot'); + const sortant = S.fade != null ? S.fadeDe : null; + pads.forEach((b, i) => { + b.classList.toggle('joue', S.presetActif === i); + b.classList.toggle('sortante', sortant === i && S.presetActif !== i); + }); + } $('#bpm').innerHTML = '— ' + Math.round(60000 / L.stepMs) + ' BPM (pas)'; // Ableton Link : quand il pilote, le tempo manuel est neutralisé diff --git a/server.js b/server.js index 22b8604..2aa2908 100644 --- a/server.js +++ b/server.js @@ -19,6 +19,9 @@ const APP_NAME = 'Cascade'; const VERSION = '2.0.2'; const SIGNATURE = 'Pierre-Yves Mansour — Collectif WSK'; const PRESET_SLOTS = 16; +// Orange signature de la charte (`--accent` dans public/index.html). Sert aux +// empreintes de presets, où une couche d'intensité n'a pas de couleur à montrer. +const ACCENT = '#f2900f'; const MAX_LAYERS = 8; const MAX_FIXTURES = 128; const MAX_MIDI_BINDINGS = 256; @@ -726,7 +729,11 @@ function sanitizePresets(list) { if (!Array.isArray(list)) return Array(PRESET_SLOTS).fill(null); return list.slice(0, PRESET_SLOTS).map(p => p && typeof p === 'object' && Array.isArray(p.layers) && p.layers.length - ? { name: String(p.name || 'P').slice(0, 12), + // ⚠ 16, pas 12 : `savePreset` et le renommage coupent tous deux à 16. + // Couper plus court ICI rabotait silencieusement tout nom de 13 à 16 + // caractères — pas à la saisie, mais au redémarrage et à l'import, quand + // plus personne ne regarde. « Refrain final 2 » revenait « Refrain fin ». + ? { name: String(p.name || 'P').slice(0, 16), layers: p.layers.slice(0, MAX_LAYERS).map(sanitizeLayer), fixtures: Array.isArray(p.fixtures) ? sanitizeFixtures(p.fixtures) : null } : null @@ -1485,9 +1492,18 @@ function savePreset(i, name) { return { name: n || 'P' + (i + 1), layers: deep(state.layers), fixtures: deep(state.fixtures) }; } /** Fondu en cours entre deux presets (null = rappel sec). */ -let fade = null; // { start, dur, layers, fixtures, engines } +let fade = null; // { start, dur, de, layers, fixtures, engines } function cancelFade() { fade = null; } +// ── Repères de la banque de presets, pour l'interface ───────────────────────── +// Les deux vivent en MÉMOIRE et n'entrent PAS dans `state` : rien à sanitiser, +// rien à exporter, rien à migrer. `presetActif` = dernier slot rappelé ou +// enregistré, pour que le régisseur voie quel pavé joue. `presetsRev` = compteur +// de révision de la banque : l'interface ne recharge les empreintes que +// lorsqu'il bouge, au lieu de les faire voyager 8 fois par seconde. +let presetActif = null; +let presetsRev = 1; + /** * Rappelle un preset. Si un temps de fondu est réglé ET qu'un show tourne, * la scène sortante est mise de côté avec son état moteur : elle continue de @@ -1506,7 +1522,7 @@ function recallPreset(i, fadeMs) { } // Un rappel pendant un fondu remplace le précédent : la scène en cours de // sortie est abandonnée, sinon il faudrait empiler les fondus à l'infini. - fade = { start: Date.now(), dur, layers: deep(state.layers), + fade = { start: Date.now(), dur, de: presetActif, layers: deep(state.layers), fixtures: deep(state.fixtures), engines: store }; } else cancelFade(); state.layers = deep(p.layers); @@ -1521,11 +1537,66 @@ function recallPreset(i, fadeMs) { pruneCaches(); } engines.clear(); + // Ici et pas dans la route : le rappel arrive AUSSI par OSC (`/chaser/preset`) + // et par MIDI. Poser le repère au seul endroit que tous traversent évite de + // dupliquer la logique — et d'en oublier une. + presetActif = i; return true; } /** Noms des presets pour l'interface : null = slot vide. */ function presetNames() { return state.presets.map((p, i) => p ? (p.name || 'P' + (i + 1)) : null); } +/** + * Empreinte d'un preset, pour la vignette d'un pavé de la grille. + * + * ⚠ C'est une empreinte de COUVERTURE, pas une photo du motif : elle dit quelles + * barres le preset pilote et avec quelles teintes, pas ce qui est allumé à un + * instant donné (un chase n'allume qu'une barre à la fois — une photo serait + * presque vide et changerait à chaque image). + * + * Elle passe par `resolveBars`, donc elle ment EXACTEMENT comme mentirait le + * moteur : un groupe vidé ramène la couche sur toutes les barres, ici comme en + * scène. C'est voulu — une vignette qui corrige le moteur serait pire. + * + * Aucun moteur n'est instancié, aucun état runtime touché, aucun OSC émis : on + * peut l'appeler pendant un show sans rien déranger. Et elle n'est PAS dans + * `/api/state`, à dessein : cette réponse part 8 fois par seconde. + */ +function infoPreset(p) { + if (!p) return null; + // Un preset importé d'une vieille config peut n'avoir aucune fixture : on + // retombe alors sur le plateau courant, faute de mieux. + const fx = (Array.isArray(p.fixtures) && p.fixtures.length ? p.fixtures : state.fixtures) + .filter(f => f && f.enabled !== false); + // Plafond de 64 cases : au-delà on sous-échantillonne. Une bande de 128 cases + // serait illisible, et la charge utile grossirait pour rien. + const pas = Math.ceil(fx.length / 64) || 1; + const vus = fx.filter((f, i) => i % pas === 0); + const rang = new Map(vus.map((f, i) => [f.id, i])); + const cases = new Array(vus.length).fill('.'); + const pal = []; + const act = p.layers.filter(L => L.enabled); + for (const L of act) { + const c = L.target === 'color' ? (L.colorA || '#ff2000') : ACCENT; + let k = pal.indexOf(c); + if (k < 0) { k = pal.length; pal.push(c); } + // ⚠ Résoudre sur `fx` COMPLET, pas sur l'échantillon : `resolveBars` replie + // sur toutes les barres quand aucune de la sélection n'est présente, et + // sous-échantillonner d'abord déclencherait ce repli à tort. + for (const f of resolveBars(L, fx)) { + const r = rang.get(f.id); + if (r != null) cases[r] = String(k); + } + } + return { + a: act.length, t: p.layers.length, + e: act.map(L => L.engine === 'steps' ? 'P' : L.engine === 'wave' ? 'V' : 'C').join('').slice(0, 8), + pal, c: cases.join(''), + }; +} +/** Empreintes des 16 slots. Servie à la demande, jamais dans `/api/state`. */ +function presetsInfo() { return state.presets.map(infoPreset); } + /** Resync : recale la phase (départ des pas) d'une couche, ou de toutes. */ /** * GO / RESYNC : tout repart ensemble, sur le temps fort. @@ -2499,6 +2570,16 @@ const server = http.createServer(async (req, res) => { }, null, 2)); } + // Empreintes des presets, servies À LA DEMANDE. Volontairement hors de + // `/api/state` : celle-ci part 8 fois par seconde, et 16 empreintes y + // pèseraient une demi-kilo-octet à chaque fois pour une donnée qui ne bouge + // qu'à l'enregistrement. L'interface ne rappelle cette route que lorsque + // `presetsRev` change. Aucune écriture, aucun effet de bord — contrairement à + // `/api/export`, qui remet `dirtySinceExport` à false. + if (url === '/api/presets-info') { + return json(res, { ok: true, rev: presetsRev, infos: presetsInfo() }); + } + if (url === '/api/state') { lastUiPollAt = Date.now(); // une interface est ouverte (voir arrêt automatique) return json(res, { @@ -2506,7 +2587,10 @@ const server = http.createServer(async (req, res) => { settings: state.settings, scene: state.scene, vues: state.vues, fixtures: state.fixtures, groups: state.groups, layers: state.layers, global: state.global, - presets: presetNames(), + // `presets` garde sa forme de tableau de 16 chaînes. Les deux repères qui + // suivent sont des entiers : ~40 octets, contre ~560 pour les empreintes, + // qui vivent sur `/api/presets-info`. + presets: presetNames(), presetsRev, presetActif, midiMap: state.midiMap, link: { active: link.active, connected: link.connected, bpm: link.bpm, peers: link.peers, error: link.error, @@ -2518,6 +2602,9 @@ const server = http.createServer(async (req, res) => { host: state.settings.mmHost, port: state.settings.mmPort }, // Progression du fondu entre presets (0-1), pour l'afficher en direct fade: fade ? Math.min(1, (Date.now() - fade.start) / fade.dur) : null, + // Slot d'où part le fondu (null = on ne sait pas d'où on vient), pour que + // la grille montre le pavé sortant en même temps que l'entrant. + fadeDe: fade && fade.de != null ? fade.de : null, project: { name: state.projectName, dirty: dirtySinceExport, lastExportAt }, levels: runtime.levels, colors: runtime.colors, }); @@ -2552,13 +2639,25 @@ const server = http.createServer(async (req, res) => { } case '/api/preset': { const i = Math.max(0, Math.min(PRESET_SLOTS - 1, body.slot | 0)); - if (body.action === 'save') state.presets[i] = savePreset(i, body.name); - else if (body.action === 'recall') recallPreset(i, body.fadeMs); - else if (body.action === 'clear') state.presets[i] = null; - else if (body.action === 'rename' && state.presets[i]) { + if (body.action === 'save') { + state.presets[i] = savePreset(i, body.name); + // On vient d'enregistrer la scène courante : c'est bien elle qui joue. + presetActif = i; + presetsRev++; + } else if (body.action === 'recall') { + recallPreset(i, body.fadeMs); // pose `presetActif` lui-même + } else if (body.action === 'clear') { + state.presets[i] = null; + if (presetActif === i) presetActif = null; + presetsRev++; + } else if (body.action === 'rename' && state.presets[i]) { const n = String(body.name || '').trim().slice(0, 16); state.presets[i].name = n || 'P' + (i + 1); + presetsRev++; } + // ⚠ `recall` n'incrémente PAS `presetsRev` : la banque n'a pas changé, + // seule la scène en cours. Recharger les empreintes à chaque rappel + // ferait une requête de plus au pire moment, en plein spectacle. saveConfig(); return json(res, { ok: true, presets: presetNames(), layers: state.layers, fixtures: state.fixtures }); } @@ -2801,6 +2900,11 @@ const server = http.createServer(async (req, res) => { if (typeof body.projectName === 'string' && body.projectName.trim()) { state.projectName = body.projectName.trim().slice(0, 40); } + // La banque entière a changé de spectacle : les empreintes en mémoire de + // l'interface ne valent plus rien, et « en cours » sur un slot importé + // désignerait une scène qui n'a jamais été jouée. + presetActif = null; + presetsRev++; saveConfig(); dirtySinceExport = false; // l'état vient d'un fichier : rien à ré-exporter return json(res, { ok: true }); @@ -2810,6 +2914,8 @@ const server = http.createServer(async (req, res) => { state.layers = [defaultLayer('Chaser 1')]; layerSeq = 2; state.presets = Array(PRESET_SLOTS).fill(null); + presetActif = null; + presetsRev++; if (!body.keepFixtures) { state.fixtures = []; state.groups = []; pruneCaches(); } // Les vues portent les noms des dossiers d'un AUTRE spectacle : les // garder ferait pointer Cascade sur des dossiers qui n'existent pas. diff --git a/tests/api.test.js b/tests/api.test.js index 36efed1..86ad16d 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -220,6 +220,80 @@ describe('API HTTP', () => { await h.post('/api/preset', { action: 'clear', slot: 9 }); }); + test('un nom de 16 caractères survit au redémarrage', async () => { + // Régression : `sanitizePresets` recoupait à 12 alors que la sauvegarde et + // le renommage coupent à 16. Le rabotage n'arrivait donc PAS à la saisie, + // mais au rechargement du fichier — quand plus personne ne regarde. + await h.post('/api/preset', { action: 'save', slot: 9, name: 'ABCDEFGHIJKLMNOP' }); + const exp = await h.get('/api/export'); + await h.post('/api/new', { keepFixtures: true }); + await h.post('/api/import', exp.body); + assert.equal((await h.state()).presets[9], 'ABCDEFGHIJKLMNOP'); + await h.post('/api/preset', { action: 'clear', slot: 9 }); + }); + + test('l’empreinte d’un preset décrit les barres qu’il pilote', async () => { + await h.post('/api/fixtures', { fixtures: fixtures(4) }); + const id = (await h.state()).layers[0].id; + const bars = (await h.state()).fixtures.slice(0, 2).map(f => f.id); + await h.post('/api/layer', { id, set: { target: 'color', colorA: '#00ff00', bars } }); + await h.post('/api/preset', { action: 'save', slot: 3, name: 'Vert' }); + + const r = await h.get('/api/presets-info'); + const info = r.body.infos[3]; + assert.equal(info.c.length, 4, 'une case par barre active'); + assert.equal(info.c, '00..', 'seules les deux barres pilotées sont peintes'); + assert.deepEqual(info.pal, ['#00ff00'], 'la teinte vient de la couche'); + assert.equal(info.a, 1); assert.equal(info.t, 1); + assert.equal(r.body.infos[0], null, 'un slot vide n’a pas d’empreinte'); + + // Une couche désactivée ne peint rien. + await h.post('/api/layers', { action: 'add' }); + const l2 = (await h.state()).layers[1].id; + await h.post('/api/layer', { id: l2, set: { enabled: false } }); + await h.post('/api/preset', { action: 'save', slot: 4 }); + const info4 = (await h.get('/api/presets-info')).body.infos[4]; + assert.equal(info4.a, 1, 'une seule couche active'); + assert.equal(info4.t, 2, 'sur deux couches mémorisées'); + await h.post('/api/layers', { action: 'remove', id: l2 }); + for (const s of [3, 4]) await h.post('/api/preset', { action: 'clear', slot: s }); + }); + + test('les empreintes ne voyagent PAS dans /api/state', async () => { + // C'est tout l'intérêt de l'endpoint séparé : /api/state part 8 fois par + // seconde. Si ce test tombe, le coût du poll a été réintroduit. + const st = await h.state(); + assert.ok(!('infos' in st), 'aucune empreinte dans /api/state'); + assert.ok(Array.isArray(st.presets) && st.presets.length === 16, + '`presets` reste un tableau de 16 entrées'); + assert.ok(st.presets.every(p => p === null || typeof p === 'string'), + '`presets` ne contient que des noms'); + assert.equal(typeof st.presetsRev, 'number'); + }); + + test('presetActif suit ce qui joue, presetsRev ce qui change', async () => { + await h.post('/api/new', { keepFixtures: true }); + assert.equal((await h.state()).presetActif, null, 'rien ne joue au départ'); + + await h.post('/api/preset', { action: 'save', slot: 2 }); + assert.equal((await h.state()).presetActif, 2, 'enregistrer désigne le slot'); + const rev = (await h.state()).presetsRev; + + await h.post('/api/preset', { action: 'save', slot: 5 }); + await h.post('/api/preset', { action: 'recall', slot: 2 }); + assert.equal((await h.state()).presetActif, 2, 'rappeler désigne le slot'); + // Un rappel ne change PAS la banque : recharger les empreintes à ce + // moment-là ferait une requête de plus au pire moment, en plein spectacle. + assert.equal((await h.state()).presetsRev, rev + 1, 'un save, pas le recall'); + + await h.post('/api/preset', { action: 'clear', slot: 2 }); + assert.equal((await h.state()).presetActif, null, 'effacer le slot actif l’oublie'); + await h.post('/api/preset', { action: 'rename', slot: 5, name: 'Z' }); + assert.ok((await h.state()).presetsRev > rev + 1, 'renommer change la banque'); + await h.post('/api/new', { keepFixtures: true }); + assert.equal((await h.state()).presetActif, null, 'un projet neuf n’a rien en cours'); + }); + test('un slot de preset hors bornes ne fait pas planter', async () => { const r = await h.post('/api/preset', { action: 'recall', slot: 9999 }); assert.equal(r.body.ok, true); diff --git a/tests/interface.test.js b/tests/interface.test.js index 29ef7c2..faadd49 100644 --- a/tests/interface.test.js +++ b/tests/interface.test.js @@ -101,6 +101,9 @@ describe('Interface — garde-fous du source', () => { ['bouton d’aide', /id="btnAide"/], ['voyant MadMapper', /id="mmLink"/], ['bouton QR', /id="btnQR"/], + // La classe est statique : un retour accidentel à la rangée de boutons + // serait attrapé ici, et pas seulement à l'œil. + ['grille de presets', /id="presets" class="grille"/], ]) assert.match(UI, motif, quoi + ' introuvable'); }); diff --git a/tests/mutations-liste.js b/tests/mutations-liste.js index 20f5d5c..ce7c29d 100644 --- a/tests/mutations-liste.js +++ b/tests/mutations-liste.js @@ -191,6 +191,22 @@ const MUTATIONS = [ de: " const ext = Math.max(0.1, Math.abs(a[0]) * sc.w + Math.abs(a[1]) * sc.d + Math.abs(a[2]) * sc.h);", vers: " const ext = 10;", }, + { + // Le rabotage n'arrivait ni à la saisie ni au rappel, mais au RECHARGEMENT + // du fichier : « Refrain final 2 » revenait « Refrain fin ». + nom: 'le nom de preset est re-tronque a 12 au chargement', + cible: 'tests/api.test.js', + de: " ? { name: String(p.name || 'P').slice(0, 16),", + vers: " ? { name: String(p.name || 'P').slice(0, 12),", + }, + { + // Si l'empreinte cesse de lire la teinte de la couche, tous les pavés de la + // grille se ressemblent — et une grille qui ne distingue rien ne sert plus. + nom: 'l empreinte de preset ignore la couleur de la couche', + cible: 'tests/api.test.js', + de: " const c = L.target === 'color' ? (L.colorA || '#ff2000') : ACCENT;", + vers: " const c = ACCENT;", + }, ]; module.exports = MUTATIONS; diff --git a/tests/ui.test.js b/tests/ui.test.js index ddca6b1..798dd3f 100644 --- a/tests/ui.test.js +++ b/tests/ui.test.js @@ -218,6 +218,117 @@ describe('Interface dans un vrai navigateur', { skip: AUCUN_NAVIGATEUR && for (const s of [0, 1]) await h.post('/api/preset', { action: 'clear', slot: s }); }); + test('les presets forment une grille de 16 pavés, avec l’empreinte des barres', async () => { + const id = (await h.state()).layers[0].id; + const bars = (await h.state()).fixtures.slice(0, 2).map(f => f.id); + await h.post('/api/layer', { id, set: { target: 'color', colorA: '#00ff00', bars, enabled: true } }); + await h.post('/api/preset', { action: 'save', slot: 4, name: 'Vert' }); + await rafraichir(); + const vu = await nav.evaluate(` + await poll(); + const pr = document.querySelector('#presets'); + const pads = [...pr.querySelectorAll('button.slot')]; + const p4 = pads[4]; + return { n: pads.length, + affichage: getComputedStyle(pr).display, + hauteur: p4.getBoundingClientRect().height, + cases: p4.querySelectorAll('.emp i').length, + peintes: [...p4.querySelectorAll('.emp i')] + .filter(c => c.style.background).length, + texte: p4.textContent };`); + assert.equal(vu.n, 16, 'les 16 slots sont là'); + assert.equal(vu.affichage, 'grid', '#presets doit être une grille'); + assert.ok(vu.hauteur >= 50, 'un pavé doit être cliquable au doigt, vu ' + vu.hauteur); + assert.equal(vu.cases, 6, 'une case par barre du plateau'); + assert.equal(vu.peintes, 2, 'seules les deux barres pilotées sont peintes'); + // L'empreinte ne doit ajouter AUCUN texte : le contrat « 5Vert » tient. + assert.equal(vu.texte, '5Vert', 'l’empreinte ne doit rien écrire dans le pavé'); + assert.deepEqual(nav.erreurs(), []); + await h.post('/api/preset', { action: 'clear', slot: 4 }); + }); + + test('le pavé qui joue se voit, et celui d’où l’on vient pendant un fondu', async () => { + await h.post('/api/preset', { action: 'save', slot: 6, name: 'Un' }); + await h.post('/api/preset', { action: 'save', slot: 7, name: 'Deux' }); + await h.post('/api/preset', { action: 'recall', slot: 6 }); + await rafraichir(); + const seul = await nav.evaluate(` + await poll(); + return [...document.querySelectorAll('#presets button.slot')] + .map((b, i) => b.classList.contains('joue') ? i : -1).filter(i => i >= 0);`); + assert.deepEqual(seul, [6], 'un seul pavé joue, et c’est le bon'); + + await h.post('/api/global', { presetFade: 2500 }); + await h.post('/api/start'); + await h.post('/api/preset', { action: 'recall', slot: 7 }); + await sleep(400); + const pendant = await nav.evaluate(` + await poll(); + const pads = [...document.querySelectorAll('#presets button.slot')]; + return { joue: pads.findIndex(b => b.classList.contains('joue')), + sortante: pads.findIndex(b => b.classList.contains('sortante')) };`); + await h.post('/api/stop'); + await h.post('/api/global', { presetFade: 0 }); + assert.equal(pendant.joue, 7, 'le pavé entrant'); + assert.equal(pendant.sortante, 6, 'le pavé sortant, pendant le fondu'); + assert.deepEqual(nav.erreurs(), []); + for (const s of [6, 7]) await h.post('/api/preset', { action: 'clear', slot: s }); + }); + + test('un preset qui ne pilote plus aucune barre le DIT', async () => { + const id = (await h.state()).layers[0].id; + // Une couche restreinte à une barre qui n'existe pas : plus rien à piloter. + await h.post('/api/layer', { id, set: { bars: ['fantome'], groupId: null } }); + await h.post('/api/preset', { action: 'save', slot: 8, name: 'Vide' }); + await rafraichir(); + const vu = await nav.evaluate(` + await poll(); + const b = document.querySelectorAll('#presets button.slot')[8]; + return { muet: b.classList.contains('muet'), title: b.title };`); + assert.equal(vu.muet, true, 'le pavé doit être marqué, pas masqué'); + assert.match(vu.title, /aucune barre/, 'et l’infobulle doit le dire'); + assert.deepEqual(nav.erreurs(), []); + await h.post('/api/layer', { id, set: { bars: null } }); + await h.post('/api/preset', { action: 'clear', slot: 8 }); + }); + + test('un nom de preset hostile ne peut pas injecter de HTML dans la grille', async () => { + await h.post('/api/preset', { action: 'save', slot: 10, name: '">' }); + await rafraichir(); + const vu = await nav.evaluate(` + await poll(); + const pr = document.querySelector('#presets'); + const b = pr.querySelectorAll('button.slot')[10]; + return { pwn: window.__pwn === undefined ? null : window.__pwn, + balises: pr.querySelectorAll('svg, img, script').length, + texte: b.textContent };`); + assert.equal(vu.pwn, null, 'aucun script ne doit s’exécuter'); + assert.equal(vu.balises, 0, 'aucune balise injectée dans la grille'); + assert.match(vu.texte, /svg onload/, 'le nom s’affiche comme du TEXTE'); + assert.deepEqual(nav.erreurs(), []); + await h.post('/api/preset', { action: 'clear', slot: 10 }); + }); + + test('si les empreintes ne se chargent pas, la grille reste utilisable', async () => { + await h.post('/api/preset', { action: 'save', slot: 12, name: 'Sans' }); + const vu = await nav.evaluate(` + const vrai = window.fetch; + window.fetch = (u, o) => String(u).includes('/api/presets-info') + ? Promise.reject(new Error('coupé')) : vrai(u, o); + infosPresets = null; infoRev = -1; + for (let i = 0; i < 6; i++) { await poll(); await new Promise(r => setTimeout(r, 60)); } + window.fetch = vrai; + const pads = [...document.querySelectorAll('#presets button.slot')]; + return { n: pads.length, emp: document.querySelectorAll('#presets .emp').length, + texte: pads[12].textContent };`); + assert.equal(vu.n, 16, 'les 16 pavés restent là'); + assert.equal(vu.emp, 0, 'simplement sans empreinte'); + assert.equal(vu.texte, '13Sans', 'et toujours lisibles'); + // Le point qui compte : une empreinte manquante ne fait pas de bruit. + assert.deepEqual(nav.erreurs(), []); + await h.post('/api/preset', { action: 'clear', slot: 12 }); + }); + test('l’accueil disparaît dès qu’il y a des barres, et revient sans', async () => { await rafraichir(); const visible = () => nav.evaluate( From 882023bb4f24fbf7f41205d2273c1bcf6a6bc5ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:38:24 +0000 Subject: [PATCH 05/27] =?UTF-8?q?Le=20fichier=20de=20reprise=20annon=C3=A7?= =?UTF-8?q?ait=20encore=20la=202.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux lignes, mais c'est précisément le fichier qui vient d'être remis d'aplomb pour avoir menti sur sa version. Le laisser diverger dès le commit suivant aurait été une leçon mal retenue. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH --- docs/ETAT-DU-PROJET.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ETAT-DU-PROJET.md b/docs/ETAT-DU-PROJET.md index 4ec7acc..dbe5c7d 100644 --- a/docs/ETAT-DU-PROJET.md +++ b/docs/ETAT-DU-PROJET.md @@ -1,7 +1,7 @@ # Cascade — état du projet (reprise de travail) > Fichier de reprise. **À lire en premier** avant toute modification. -> Dernière mise à jour : 2026-08-04 — version **2.0.1** (+ des correctifs non publiés). +> Dernière mise à jour : 2026-08-04 — version **2.0.2** (+ la grille de presets, non publiée). > Voir aussi `CLAUDE.md` (règles) et `CHANGELOG.md` (versions). > L'audit technique vit **hors du dépôt** : `../Cascade-AUDIT.md`. ⚠ Il est donc > **absent des clones frais** (sessions distantes, CI) : ne jamais faire dépendre @@ -15,7 +15,7 @@ - **Nom** : Cascade (avant : « Chaser pour MadMapper »). Dossier renommé `Cascade` le 2026-07-09. - **Auteur / signature** : Pierre-Yves Mansour — Collectif WSK -- **Version** : 2.0.1 · **Licence** : MIT · publié sur GitHub +- **Version** : 2.0.2 · **Licence** : MIT · publié sur GitHub - **Quoi** : séquenceur LED multi-couches qui pilote les fixtures DMX de MadMapper en OSC, depuis une page web (ordi, iPad, téléphone). Équivalent de « Chaser » (Hybrid Constructs, pour Resolume), mais pour MadMapper. ## Nouveautés v1.2 (2026-07-09) From 7af9e80ed00d6e9e015ad7dca61a8f7e0f089dd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:16:17 +0000 Subject: [PATCH 06/27] Suiveur audio : le micro devient une source de modulateur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le micro se branche là où l'oscillateur l'était déjà. Un modulateur — de couche ou global — gagne un réglage Source : « Oscillateur (boucle) » ou « Micro ». Rien d'autre ne change, donc le micro pilote tout ce qu'un modulateur pilotait : niveau, largeur, netteté, profondeur, crossfader, master. C'est le choix qui rend le reste possible. Router l'enveloppe par `midiApply` aurait ÉCRIT le niveau dans `state.layers[].level` seize fois par seconde : il aurait été sauvegardé, exporté, figé dans les presets — mot pour mot le défaut que le commentaire de `appliquerLFO` dit avoir évité. Ici la valeur vit le temps d'une image et hérite gratuitement du non-écrasement de l'état, du voyage dans les presets, et du relâchement à STOP (`tick` sort avant les modulateurs). DEUX LIMITES, dites dans l'aide, dans le panneau et au CHANGELOG : - il lit une ÉNERGIE, pas un tempo. Décision de Pym : on livre l'énergie, il l'essaie en salle, et on décide ensuite de la détection de battement — ~150 lignes de DSP dont la justesse dépend du répertoire, avec l'erreur d'octave comme échec structurel, invalidable hors d'une vraie salle ; - le micro n'est lisible que sur la machine hôte : les navigateurs l'interdisent hors origine sûre. Assumé et écrit, comme pour Web MIDI. Pas de HTTPS auto-signé (certificat à générer, openssl absent de Windows, écran d'avertissement à chaque connexion — impossible à demander à un régisseur). Sans micro la page ne pousse rien : un iPad ne peut pas écraser l'hôte. LA PROPRIÉTÉ DE SÉCURITÉ, et elle a son anti-mutant : la péremption REND LA MAIN. Sans niveau frais depuis 700 ms — onglet fermé, micro débranché, machine en veille — le réglage du régisseur reprend, avec un fondu. Retomber sur `min` aurait été le noir en plein show sur un master à min 0 ; rester figé aurait cloué le spectacle sur ce qu'entendait un onglet fermé. Aucun conflit avec Ableton Link, et c'est mieux que de bien l'arbitrer : le suiveur n'écrit jamais `stepMs`, donc `applyLinkBpm` n'a rien à écraser. La calibration vit dans `state.settings` et NON dans le localStorage — écart assumé avec le plan de conception : la promesse du projet est que la régie tienne sur une clé USB, et un réglage laissé dans le navigateur resterait sur la machine. Vérifiable sans micro, ce qui a décidé du choix : la moitié serveur se teste en poussant un niveau en query, et la DSP est isolée en fonction PURE nourrie d'un spectre fabriqué à la main. Le chemin nominal s'exécute quand même pour de vrai — Chromium est lancé avec un faux périphérique audio, donc getUserMedia, AudioContext et la boucle d'analyse tournent réellement en test. Deux pièges rencontrés en écrivant les tests, tous deux du genre que ce dépôt a déjà payé : le cache d'envoi ne réémet pas une valeur inchangée, donc quatre assertions passaient sur un tableau VIDE sans rien mesurer — elles lisent maintenant les niveaux calculés, avec une garde qui refuse un relevé vide ; et le test STOP pouvait passer même si le moteur n'émettait jamais rien, il a désormais un témoin positif. 19 tests ajoutés (290 au total), dont 6 dans un vrai navigateur. Le nouvel anti-mutant a été vérifié tué, pas seulement ajouté. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH --- CHANGELOG.md | 51 ++++++++ CLAUDE.md | 28 +++-- dist/public/index.html | 256 ++++++++++++++++++++++++++++++++++++++- dist/server.js | 89 +++++++++++++- docs/ETAT-DU-PROJET.md | 10 +- public/index.html | 256 ++++++++++++++++++++++++++++++++++++++- server.js | 89 +++++++++++++- tests/browser.js | 5 + tests/interface.test.js | 7 +- tests/modulateur.test.js | 234 ++++++++++++++++++++++++++++++++++- tests/mutations-liste.js | 10 ++ tests/ui.test.js | 135 +++++++++++++++++++++ 12 files changed, 1138 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df414c..325736b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,57 @@ versionnage [sémantique](https://semver.org/lang/fr/). ## [Non publié] +### Ajouté — suiveur audio : le micro devient une source de modulateur + +Le micro se branche là où l'oscillateur était déjà branché. Un modulateur — de +couche ou global — gagne un réglage **Source** : « Oscillateur (boucle) » ou +« Micro (suiveur audio) ». Tout le reste est inchangé, donc le micro peut piloter +tout ce qu'un modulateur pilotait déjà : niveau, largeur, netteté, profondeur, +crossfader, master… + +L'analyse vit **dans le navigateur** (Web Audio) : le zéro-dépendance interdit +une entrée son côté serveur. Le niveau part ensuite en paramètre du poll qu'on +fait déjà — aucune requête de plus, ~8 octets sur la ligne de requête, zéro dans +la réponse. Réglages : bande (grave / médium / aigu / tout), gain, seuil (porte +de bruit), attaque et relâchement, avec un vu-mètre. + +**Deux limites, dites noir sur blanc dans l'aide et dans le panneau :** + +- ⚠ **Il lit une ÉNERGIE, pas un tempo.** Il fait respirer la lumière avec le + son ; il ne détecte pas les battements et ne cale pas le chase sur la musique. + La détection de tempo est ~150 lignes de DSP dont la justesse dépend du + répertoire, avec l'erreur d'octave comme échec structurel : elle attend un + essai en salle plutôt qu'une hypothèse. +- ⚠ **Le micro n'est lisible que sur la machine où tourne Cascade.** Les + navigateurs interdisent l'accès au micro hors origine sûre, donc jamais depuis + `http://:3333`. C'est le même choix que celui déjà assumé pour Web + MIDI. Depuis un iPad, tout le reste fonctionne — et sans micro, la page ne + pousse rien, donc une tablette ne peut pas écraser ce qu'entend la machine hôte. + +**La propriété de sécurité, et elle est verrouillée par un anti-mutant : la +péremption REND LA MAIN.** Sans niveau frais depuis 700 ms — onglet fermé, micro +débranché, machine en veille — le modulateur laisse le réglage du régisseur +intact, avec un fondu pour éviter la marche d'escalier. Retomber sur la borne +basse aurait été le noir en plein spectacle dès que `min` vaut 0 sur le master ; +rester figé sur la dernière valeur aurait cloué le show sur ce qu'entendait un +onglet fermé. + +Aucun conflit avec Ableton Link, et c'est mieux que de bien l'arbitrer : le +suiveur n'écrit jamais `stepMs`, donc il n'y a rien à écraser. L'état n'est +jamais écrit non plus — comme les modulateurs, le niveau vit le temps d'une +image et ne se retrouve ni dans la configuration, ni dans un export, ni figé +dans un preset. + +La calibration vit dans `state.settings`, **pas** dans le `localStorage` : la +promesse du projet est que la régie tienne sur une clé USB, et un réglage laissé +dans le navigateur resterait sur la machine. + +Vérifiable **sans micro**, ce qui était le critère décisif du choix : la moitié +serveur se teste en poussant un niveau en query (`/api/state?a=0.9`), et la DSP +est isolée en fonction pure qu'on nourrit d'un spectre fabriqué à la main. Le +chemin nominal est tout de même exécuté pour de vrai — les tests d'interface +lancent Chromium avec un faux périphérique audio. + ### Ajouté — les 16 presets deviennent une grille Les presets étaient une rangée de boutons numérotés qui se repliait selon la diff --git a/CLAUDE.md b/CLAUDE.md index 8121306..241ac8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ App web (Node.js zéro dépendance + page unique) qui pilote les fixtures DMX de MadMapper en OSC : multi-chasers, vagues, couleur, presets, MIDI/OSC, Ableton -Link. **2.0.1** · MIT · Pierre-Yves Mansour — Collectif WSK. Travail en **français**. +Link. **2.0.2** · MIT · Pierre-Yves Mansour — Collectif WSK. Travail en **français**. ## À lire EN PREMIER @@ -36,7 +36,7 @@ qui ont déjà coûté cher. `docs/madmapper-osc-api.md` = référence OSC MadMa ## Lancer / tester - `node server.js` → http://localhost:3333 (config générée : `cascade-config.json`). -- **`npm test`** (= `node --test`) : 262 tests, zéro dépendance. **À lancer avant +- **`npm test`** (= `node --test`) : 290 tests, zéro dépendance. **À lancer avant de conclure toute modif du serveur.** ⚠ `node --test tests/` échoue sur Node 24 (chemin pris pour un module) — utiliser `node --test` tout court. - Instance isolée pour tester à la main : `CASCADE_PORT=3461 CASCADE_NO_BROWSER=1 @@ -44,9 +44,9 @@ qui ont déjà coûté cher. `docs/madmapper-osc-api.md` = référence OSC MadMa `CASCADE_OSCIN`, `CASCADE_FEEDBACK`, `CASCADE_MMPORT`, `CASCADE_MMHOST`). - Faux Carabiner = serveur TCP local port 17000 qui pousse `status { :peers 1 :bpm 128.0 ... }\n`. -- UI : les 32 tests d'interface pilotent un vrai navigateur en CDP maison +- UI : les 43 tests d'interface pilotent un vrai navigateur en CDP maison (`tests/browser.js`, zéro dépendance) — **pas** Playwright. Sans navigateur ils - s'annoncent ignorés SANS faire rougir la suite : vérifier le compte (262), pas + s'annoncent ignorés SANS faire rougir la suite : vérifier le compte (290), pas la couleur. `CASCADE_NAVIGATEUR=` impose un navigateur ; `PLAYWRIGHT_BROWSERS_PATH` est balayé tout seul. Si tu passes par Playwright à la main, `waitUntil: 'domcontentloaded'` (`networkidle` ne vient jamais, @@ -80,7 +80,7 @@ dossiers et fondu · 7 modes de fusion · perspective atmosphérique · palette N arrêts, branchable sur la profondeur ou la hauteur · décalage réparti · crossfader A/B · modulateurs (LFO) par couche et global · coupure de secours · renvoi de disposition · démos · repère 3D. -**262 tests, 27 mutations sur 27 détectées.** Manuel PDF, README, CHANGELOG et +**290 tests, 30 mutations sur 30 détectées.** Manuel PDF, README, CHANGELOG et exécutables des 4 plateformes sont à jour ; l'exécutable Windows a été lancé et interrogé pour de vrai. @@ -92,13 +92,17 @@ Trois garde-fous à connaître avant de toucher au code : palette spatiale morte en silence ; - `build-manuel.py` refuse de générer si un caractère manque à la police. -Ce qui reste, par ordre de valeur : suiveur audio (à faire côté navigateur, -en Web Audio, pour tenir le zéro-dépendance ; point d'entrée tout trouvé = -`midiApply()` dans `public/index.html`), grille visuelle des 16 presets, les -**deux** mesures MadMapper non faites (empreinte d'une barre en pixels, DMX -Filtering), et « dessiner les fixtures » — qui attend d'abord un -`Export Fixture Definitions…` depuis MadMapper. Détail dans -`docs/V2-INSPIRATIONS.md` et `docs/V2-AXES-PISTES.md`. +✅ **Suiveur audio et grille de presets sont LIVRÉS** (2026-08-04) — ne pas les +reprendre. Le micro est une *source de modulateur* (`src: 'lfo' | 'audio'`), +analysé en Web Audio côté page et poussé en query sur le poll. ⚠ Il lit une +**énergie, pas un tempo**, et n'est lisible que sur la machine hôte (origine +sûre) : les deux limites sont assumées, écrites, et **décidées avec Pym** — ne +pas rouvrir sans lui. La détection de battement attend un essai en salle. + +Ce qui reste, par ordre de valeur : les **deux** mesures MadMapper non faites +(empreinte d'une barre en pixels, DMX Filtering), et « dessiner les fixtures » — +qui attend d'abord un `Export Fixture Definitions…` depuis MadMapper. Détail +dans `docs/V2-INSPIRATIONS.md` et `docs/V2-AXES-PISTES.md`. ✅ **Le pivot de `output/rot` est mesuré** (2026-07-29) : la rotation ne translate pas la fixture (écart 0,00 en x et y), donc l'ordre position/rotation diff --git a/dist/public/index.html b/dist/public/index.html index 17c6381..a3daf60 100644 --- a/dist/public/index.html +++ b/dist/public/index.html @@ -306,6 +306,12 @@ #presets button.savemode { border-color: var(--off); color: var(--off); animation: blinkSave 1s steps(2, start) infinite; } @keyframes blinkSave { 50% { opacity: .55; } } + /* Suiveur audio : un vu-mètre, seule CSS nouvelle du panneau — le reste + hérite du style générique des replis `details` déjà en place. */ + #audioVu { display: block; width: 100%; height: 8px; border-radius: 4px; + background: var(--panel2); overflow: hidden; } + #audioVu i { display: block; height: 100%; width: 0; background: var(--accent); + transition: width .08s linear; } /* Vue spatiale */ #stage { position: relative; height: 320px; background: radial-gradient(ellipse at 50% 120%, #141926 0%, #0a0c10 70%); @@ -396,6 +402,11 @@

Raccourcis & gestes

Double-clicSur une ligne de réglage : retour à la valeur par défaut Double-clicSur un nom de couche : renommer · sur une barre : pivoter Double-clicSur un preset : renommer + Suiveur audio + PrincipeLe micro est une source de modulateur, comme l'oscillateur : Global → repli Suiveur audio, puis Source = Micro sur le modulateur d'une couche ou sur le modulateur global. + Ce qu'il litUne énergie, pas un tempo : il fait respirer la lumière avec le son. Il ne détecte pas les battements et ne cale pas le chase sur la musique. + LimiteLe micro n'est lisible que sur la machine où tourne Cascade — jamais depuis une adresse réseau, les navigateurs l'interdisent. Depuis un iPad, tout le reste fonctionne. + SécuritéSi le son s'arrête d'arriver (onglet fermé, micro débranché), le réglage revient à ce que vous aviez posé — il ne reste jamais figé sur la dernière valeur entendue. Grille de presetsLa bande de couleur montre les barres que le preset pilote — une couverture, pas une image du motif. Bord en pointillé : ce preset ne pilote plus aucune barre. Pavé orange : le dernier rappelé. GlisserUne barre dans la vue spatiale : la déplacer Page Scène (vue 3D) @@ -681,6 +692,12 @@

Pattern

+
+ Mélange & espace