From ee08415f1565eb1ca3421b6d7e5080a1d3f9cdac Mon Sep 17 00:00:00 2001 From: Jean-Sylvain Boige Date: Sun, 25 Oct 2026 19:19:56 +0100 Subject: [PATCH 1/2] Feat(genai,#15604): devoilement progressif sequence du code (critere 5a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le mode `page` de `capture_repl_session` rend le code visible depuis #21045, mais statique : l'editeur affiche le script entier des la premiere seconde. Le critere 5a demande un typage progressif sequence, c'est-a-dire des etats de code successifs pendant la capture. Mecanisme : - `plan_code_reveal(script, acts)` rend les etats successifs en retirant une QUEUE de lignes `$:` et en conservant toutes les autres lignes a leur place. Consequence verifiable, et testee : l'etat final est byte-identique au script joue. C'est la condition pour que l'export WAV, qui relit l'editeur apres la capture, reste l'oeuvre entiere et non la derniere image du devoilement. - `reveal_schedule` cadence les etats uniformement sur la fenetre de capture (premier a t=0). - `_set_editor_code` ecrit via l'API CodeMirror 6 exposee sur le noeud `.cm-content`, avec repli clavier (clic, Control+A, insert_text). Il n'envoie JAMAIS Control+Enter : reevaluer le pattern relancerait la lecture et casserait la continuite du rendu. Contrats d'echec honnete plutot que silencieux : - `reveal_states` avec `capture_mode="canvas"` leve `ValueError` au lieu d'ignorer le drapeau ; - un devoilement demande sur un script qui ne porte pas 2 voix est rapporte INERTE dans la chaine de capture. Interface : `capture_repl_session(..., reveal_states=None)`, `run_pipeline(..., reveal_acts=0)`, drapeau CLI `--reveal-acts` (0 = comportement historique, la capture reste statique). Tests : 26 nouveaux cas (plan, cadence, ecriture editeur avec horloge virtuelle, garde de mode, wiring du drapeau). Suite : 119 passed. Repare au passage un rouge pre-existant de ce fichier de tests : les 6 sondes CLI fixaient `encoding="utf-8"` sur un enfant qui ecrit sous l'encodage de la console (cp1252 sous Windows) — le thread lecteur mourait sur l'em-dash de l'aide et rendait `stdout=None`. Mesure sur HEAD : l'octet 0x97 vient de la ligne 1512 du script, inchangee par cette PR. `errors="replace"` rend la sonde tolerante sans toucher au sens des assertions (jetons ASCII). See #15604 Co-Authored-By: Claude Sonnet 5.5 --- scripts/livecoding_video_pipeline.py | 228 ++++++++++++- .../tests/test_livecoding_video_pipeline.py | 304 +++++++++++++++++- 2 files changed, 530 insertions(+), 2 deletions(-) diff --git a/scripts/livecoding_video_pipeline.py b/scripts/livecoding_video_pipeline.py index df9bb750b5d..9b5fe4c6715 100644 --- a/scripts/livecoding_video_pipeline.py +++ b/scripts/livecoding_video_pipeline.py @@ -779,6 +779,7 @@ def run_pipeline( tts_engine: Optional[str] = None, tts_gateway: Optional[str] = None, tts_out_dir: Optional[str] = None, + reveal_acts: int = 0, ) -> Dict[str, object]: """Orchestrateur : compose le script Strudel, genere la narration timestampee (etape 2), la synthetise en WAV expressifs (etape 3) et @@ -857,6 +858,7 @@ def run_pipeline( if capture: import subprocess + reveal_states = plan_code_reveal(strudel, reveal_acts) if reveal_acts else [] capture_result = capture_repl_session( pattern=strudel, output_dir=Path(output_path).parent, @@ -864,11 +866,29 @@ def run_pipeline( cycles=cycles_for_duration(style_cps(style_name), capture_seconds) + 1, headless=headless, capture_mode=capture_mode, + reveal_states=reveal_states, ) + reveal_info = capture_result.get("reveal") + reveal_note = "" + if reveal_acts: + if len(reveal_states) >= 2: + reveal_note = ( + f" ; devoilement {len(reveal_states)} etats a " + f"t={[r for r in (reveal_info or {}).get('applied_at', [])]} s " + f"via {(reveal_info or {}).get('editor_api')}" + ) + else: + # Contrat honnete : le drapeau a ete demande mais le script + # ne porte pas assez de voix pour se devoiler. + reveal_note = ( + f" ; devoilement DEMANDE ({reveal_acts}) mais INERTE — " + "le script ne porte pas au moins 2 voix" + ) browser_capture = ( f"LIVREE : {capture_result['final_mp4']} " f"({capture_result['mp4_bytes']} octets, video webm " f"{capture_result['video_bytes']} + wav {capture_result['wav_bytes']})" + f"{reveal_note}" ) if tts_tracks: # Etape 6 : mix final (wav navigateur + voix TTS retardees, @@ -1293,6 +1313,183 @@ def _resolve_capture_seconds( return duration_seconds +def _lines_with_voices(strudel_script: str, kept: int) -> str: + """Rend le script en ne conservant que les ``kept`` premieres voix. + + Une « voix » est une ligne ``$: …`` (forme produite par + :func:`compose_strudel`). Les autres lignes — commentaire d'en-tete, + ``setcps``, commentaire de fade-out — sont conservees **a leur place**, + dans leur ordre d'origine : le script n'est jamais reorganise, seule + une queue de voix est retiree. Consequence verifiable : + ``_lines_with_voices(script, n_voices) == script`` au caractere pres. + + L'ordre est preserve (et non regroupe en tete/voix/queue) pour que + l'etat final du devoilement soit **byte-identique** au script joue — + c'est la condition pour que l'export WAV qui suit rende l'oeuvre + entiere et non un prefixe. + """ + out: List[str] = [] + seen = 0 + for line in strudel_script.split("\n"): + if line.lstrip().startswith("$:"): + if seen < kept: + out.append(line) + seen += 1 + else: + out.append(line) + return "\n".join(out) + + +def plan_code_reveal(strudel_script: str, acts: int) -> List[str]: + """Planifie les etats de code successifs du devoilement (critere 5a). + + Le critere 5a de #15604 demande « le code Strudel en cours d'evolution, + au moins trois etats visibles distincts ». Le mode ``page`` (PR #21045) + rend le code VISIBLE, mais **statique** : le pattern arrive d'un coup + par le hash d'URL de partage. Cette fonction produit les etats + intermediaires — chaque etat ajoute une voix de plus, la premiere + n'en montrant qu'une. + + Parametres : + - ``strudel_script`` : script produit par :func:`compose_strudel`. + - ``acts`` : nombre d'etats demandes ; ``<= 0`` desactive le mecanisme + (rend ``[]``). Le nombre effectif est **plafonne au nombre de voix** : + on ne peut pas montrer plus d'etats distincts qu'il n'y a de voix. + + Retourne une liste d'etats, du plus pauvre au plus riche. Garanties : + - ``etats[0]`` ne porte qu'une voix ; + - ``etats[-1] == strudel_script`` (identite exacte) ; + - chaque etat est un script valide (en-tete et ``setcps`` conserves) ; + - len(etats) == nombre d'actes effectif. + + Un script sans ligne ``$:`` (ou avec une seule) ne se devoile pas par + voix : la fonction rend ``[strudel_script]``, un seul etat. C'est un + contrat honnete — l'appelant voit ``len(etats) < 2`` et le rapporte, + plutot qu'un devoilement silencieusement inerte. + """ + if acts is None or acts <= 0: + return [] + + n_voices = sum( + 1 for line in strudel_script.split("\n") if line.lstrip().startswith("$:") + ) + if n_voices < 2: + return [strudel_script] + + n_acts = min(int(acts), n_voices) + if n_acts < 2: + return [strudel_script] + + states: List[str] = [] + for i in range(n_acts): + # Repartition des voix sur les actes : le premier montre 1 voix, + # le dernier toutes, les intermediaires sont etalees. + kept = 1 + round((n_voices - 1) * i / (n_acts - 1)) + states.append(_lines_with_voices(strudel_script, kept)) + return states + + +def reveal_schedule( + states: List[str], window_seconds: float +) -> List[Tuple[float, str]]: + """Cadence les etats du devoilement sur la fenetre de capture. + + Les etats sont repartis uniformement : le premier a ``t = 0`` (il est + donc visible des la premiere image de la fenetre utile), puis un + changement tous les ``window_seconds / len(states)``. Le dernier etat + occupe le dernier intervalle jusqu'a la fin de la fenetre. + + Retourne une liste ``[(t_secondes, code)]`` strictement croissante en + ``t``. Une fenetre nulle ou negative ne produit qu'un point a ``0.0`` + par etat — le cadencement ne fabrique pas de duree negative. + """ + if not states: + return [] + window = max(0.0, float(window_seconds)) + if len(states) == 1: + return [(0.0, states[0])] + step = window / len(states) + return [(i * step, state) for i, state in enumerate(states)] + + +def _set_editor_code(page: object, code: str) -> str: + """Ecrit ``code`` dans l'editeur du REPL, sans reevaluer le pattern. + + Deux voies, mesurees sur le REPL strudel.cc (CodeMirror 6) : + 1. **API CodeMirror** via la vue exposee sur le DOM — remplacement du + document en une transaction (rapide, aucune simulation clavier) ; + 2. **repli clavier** : clic dans ``.cm-content``, ``Control+A``, + ``insert_text`` — une seule insertion, pas de frappe caracteres + par caracteres (ce qui rendrait la capture lente et fragile). + + Ne JAMAIS envoyer ``Control+Enter`` : la musique doit continuer de + jouer le pattern complet pendant que l'editeur se remplit. L'export + WAV qui suit lit le dernier etat, identique au script entier. + + Retourne le nom de la voie qui a effectivement ecrit (``codemirror`` + ou ``keyboard``) — mesure reportee dans le resultat de capture. + """ + replaced = page.evaluate( + """(code) => { + const el = document.querySelector('.cm-content'); + if (!el) return false; + const host = el.closest('.cm-editor') || el; + const view = el.cmView || host.cmView || (el.cmView && el.cmView.view); + if (!view || typeof view.dispatch !== 'function') return false; + view.dispatch({ + changes: {from: 0, to: view.state.doc.length, insert: code}, + }); + return true; + }""", + code, + ) + if replaced: + return "codemirror" + + editor = page.locator(".cm-content").first + editor.click(timeout=10_000) + page.keyboard.press("Control+A") + page.keyboard.insert_text(code) + return "keyboard" + + +def _drive_code_reveal( + page: object, + states: List[str], + window_seconds: float, + clock: Optional[object] = None, +) -> Dict[str, object]: + """Joue le devoilement pendant la fenetre de capture (mode page). + + Consomme la fenetre ``window_seconds`` en placant chaque etat a son + instant : c'est cette attente qui remplace l'unique + ``wait_for_timeout(capture_seconds)`` du mode statique. Retourne les + instants mesures (horloge reelle) et la voie d'ecriture utilisee — + une trace exploitable sans lecture visuelle. + """ + now = clock or time.monotonic + schedule = reveal_schedule(states, window_seconds) + t_zero = now() + applied: List[float] = [] + method = None + for target, code in schedule: + remaining = target - (now() - t_zero) + if remaining > 0: + page.wait_for_timeout(int(remaining * 1000)) + method = _set_editor_code(page, code) + applied.append(round(now() - t_zero, 3)) + + remaining = float(window_seconds) - (now() - t_zero) + if remaining > 0: + page.wait_for_timeout(int(remaining * 1000)) + + return { + "states": len(schedule), + "applied_at": applied, + "editor_api": method, + } + + def _click_play(page: object, url: str, attempts: int = 3) -> None: """Clic play robuste : re-navigation entre tentatives (etape 4). @@ -1323,6 +1520,7 @@ def capture_repl_session( headless: bool = False, warmup_seconds: float = 5.0, capture_mode: str = "page", + reveal_states: Optional[List[str]] = None, ) -> Dict[str, object]: """Pilote le REPL strudel.cc et capture la session (etape 4, #15604). @@ -1360,6 +1558,14 @@ def capture_repl_session( # (CI) -- sinon la garde meurt en ModuleNotFoundError avant de tirer. if capture_mode not in ("page", "canvas"): raise ValueError(f"capture_mode inconnu : {capture_mode!r} (page|canvas)") + if reveal_states and capture_mode != "page": + # Le devoilement agit sur l'EDITEUR : en mode canvas il ne filme que + # la visualisation, le mecanisme n'aurait aucun effet visible. + # Echec explicite plutot qu'un drapeau silencieusement inerte. + raise ValueError( + "reveal_states exige capture_mode='page' " + f"(recu {capture_mode!r}) — le mode canvas ne filme pas l'editeur" + ) from playwright.sync_api import sync_playwright @@ -1369,6 +1575,7 @@ def capture_repl_session( final_mp4 = output_dir / "capture_final.mp4" url = build_repl_url(pattern) trim_start: Optional[float] = None + reveal: Optional[Dict[str, object]] = None with sync_playwright() as pw: browser = pw.chromium.launch(headless=headless) @@ -1406,7 +1613,14 @@ def capture_repl_session( # Fenetre utile : capture_seconds a partir de maintenant ; # l'attente est consommee ici (la video tourne deja). trim_start = time.monotonic() - t_video_zero - page.wait_for_timeout(int(capture_seconds * 1000)) + if reveal_states: + # Critere 5a : le code se construit par etats successifs + # au lieu d'apparaitre complet des la premiere image. + reveal = _drive_code_reveal( + page, reveal_states, float(capture_seconds) + ) + else: + page.wait_for_timeout(int(capture_seconds * 1000)) else: rec = page.evaluate(_RECORDER_JS, int(capture_seconds * 1000)) video_webm.write_bytes(base64.b64decode(rec["b64"])) @@ -1464,6 +1678,7 @@ def capture_repl_session( "cycles": cycles, "capture_mode": capture_mode, "trim_start": trim_start, + "reveal": reveal, } @@ -1549,6 +1764,16 @@ def _build_arg_parser() -> argparse.ArgumentParser: "(editeur de code + canvas — defaut, fix critere 5a #15604) ; " "'canvas' = recorder in-page historique (canvas seul)", ) + parser.add_argument( + "--reveal-acts", + type=int, + default=0, + help="critere 5a : devoiler le code par etats successifs pendant la " + "capture (0 = desactive, defaut). Chaque etat ajoute une voix ; " + "le nombre effectif est plafonne au nombre de voix du script " + "(ex. --reveal-acts 4 = 1, 2, 3 puis 4 voix). Exige " + "--capture et --capture-mode page (defaut)", + ) parser.add_argument( "--headless", action="store_true", @@ -1590,6 +1815,7 @@ def main(argv: Optional[List[str]] = None) -> int: tts_engine=args.tts_engine, tts_gateway=args.tts_gateway, tts_out_dir=args.tts_out_dir, + reveal_acts=args.reveal_acts, ) except RuntimeError as exc: # Echec explicite (ex. moteur llm sans cle) — pas de fallback diff --git a/scripts/tests/test_livecoding_video_pipeline.py b/scripts/tests/test_livecoding_video_pipeline.py index 2c494b8daac..fa68f81cbf3 100644 --- a/scripts/tests/test_livecoding_video_pipeline.py +++ b/scripts/tests/test_livecoding_video_pipeline.py @@ -43,8 +43,12 @@ _load_env_files, _build_arg_parser, _click_play, + _drive_code_reveal, + _lines_with_voices, _resolve_capture_seconds, + _set_editor_code, build_repl_url, + capture_repl_session, compose_narration, compose_strudel, cycles_for_duration, @@ -52,7 +56,9 @@ mux_ffmpeg, narration_to_json, parse_strudel_highlights, + plan_code_reveal, probe_duration_ffprobe, + reveal_schedule, run_pipeline, style_cps, synthesize_narration_tts, @@ -704,6 +710,7 @@ def test_cli_help_prints(self): capture_output=True, text=True, encoding="utf-8", + errors="replace", ) assert result.returncode == 0 assert "livecoding_video_pipeline" in result.stdout @@ -728,6 +735,7 @@ def test_cli_full_run(self): capture_output=True, text=True, encoding="utf-8", + errors="replace", ) assert result.returncode == 0 assert "BPM=80" in result.stdout @@ -741,6 +749,7 @@ def test_cli_help_documente_la_capture(self): capture_output=True, text=True, encoding="utf-8", + errors="replace", ) assert "--capture" in result.stdout assert "--capture-seconds" in result.stdout @@ -753,6 +762,7 @@ def test_cli_help_documente_la_narration(self): capture_output=True, text=True, encoding="utf-8", + errors="replace", ) assert "--narration-engine" in result.stdout assert "--narration-json" in result.stdout @@ -772,6 +782,7 @@ def test_cli_narration_json(self, tmp_path): capture_output=True, text=True, encoding="utf-8", + errors="replace", ) assert result.returncode == 0 assert "Narration (etape 2" in result.stdout @@ -1103,6 +1114,7 @@ def test_help_documente_le_tts(self): capture_output=True, text=True, encoding="utf-8", + errors="replace", ) assert "--tts-engine" in result.stdout assert "--tts-gateway" in result.stdout @@ -1199,7 +1211,7 @@ def test_run_pipeline_transmet_duration_a_la_capture(self, tmp_path, monkeypatch seen = {} def _fake_capture(pattern, output_dir, capture_seconds, cycles, headless, - capture_mode="page"): + capture_mode="page", reveal_states=None): seen["capture_seconds"] = capture_seconds return { "video_webm": str(tmp_path / "capture.webm"), @@ -1282,3 +1294,293 @@ def test_epuise_leve_la_derniere_erreur(self): _click_play(page, "https://strudel.cc/#HASH", attempts=3) assert page.clicks == 3 assert page.gotos == 2 + + +# --- Critere 5a : devoilement progressif du code (grain successeur) ---------- + + +def _voices_of(script: str): + return [l for l in script.split("\n") if l.lstrip().startswith("$:")] + + +def _other_lines(script: str): + return [l for l in script.split("\n") if not l.lstrip().startswith("$:")] + + +class _FakeEditorPage: + """Page fake pour le devoilement, a horloge VIRTUELLE. + + ``wait_for_timeout`` avance l'horloge de la duree demandee : la cadence + se mesure sans dormir pour de vrai, et les instants d'ecriture sont + deterministes. ``evaluate`` simule la disponibilite de l'API CodeMirror. + """ + + def __init__(self, codemirror_available: bool = True): + self.codemirror_available = codemirror_available + self.now = 0.0 + self.waits_ms = [] + self.js_inserts = [] + self.key_presses = [] + self.keyboard_inserts = [] + self.editor_clicks = 0 + self.keyboard = SimpleNamespace( + press=self._press, insert_text=self._insert_text + ) + + def _press(self, key): + self.key_presses.append(key) + + def _insert_text(self, text): + self.keyboard_inserts.append(text) + + def evaluate(self, js, arg=None): + if arg is not None: + self.js_inserts.append(arg) + return self.codemirror_available + + def locator(self, selector): + outer = self + + class _El: + @staticmethod + def click(timeout=None): + outer.editor_clicks += 1 + + return SimpleNamespace(first=_El()) + + def wait_for_timeout(self, ms): + self.waits_ms.append(ms) + self.now += ms / 1000.0 + + +class TestLinesWithVoices: + """Retirer une queue de voix ne doit jamais reecrire le reste du script.""" + + def test_toutes_les_voix_rendent_le_script_a_l_identique(self): + script = compose_strudel("trance", 180, visuals=True) + assert _lines_with_voices(script, len(_voices_of(script))) == script + + def test_une_voix_conserve_l_entete_et_setcps(self): + script = compose_strudel("trance", 180, visuals=True) + one = _lines_with_voices(script, 1) + assert len(_voices_of(one)) == 1 + assert _voices_of(one)[0] == _voices_of(script)[0] + assert "setcps(" in one + assert _other_lines(one) == _other_lines(script) + + +class TestPlanCodeReveal: + """Plan du devoilement : etats successifs, dernier etat = script entier.""" + + SCRIPT = compose_strudel("trance", 180, voices=4, visuals=True) + + def test_desactive_rend_une_liste_vide(self): + assert plan_code_reveal(self.SCRIPT, 0) == [] + assert plan_code_reveal(self.SCRIPT, -3) == [] + + def test_quatre_actes_sur_quatre_voix_donnent_un_a_quatre(self): + states = plan_code_reveal(self.SCRIPT, 4) + assert len(states) == 4 + assert [len(_voices_of(s)) for s in states] == [1, 2, 3, 4] + + def test_trois_actes_sur_quatre_voix_finissent_sur_le_script_entier(self): + states = plan_code_reveal(self.SCRIPT, 3) + assert len(states) == 3 + assert [len(_voices_of(s)) for s in states] == [1, 3, 4] + assert states[-1] == self.SCRIPT + + def test_les_actes_sont_plafonnes_au_nombre_de_voix(self): + # On ne peut pas montrer plus d'etats distincts qu'il n'y a de voix. + assert len(plan_code_reveal(self.SCRIPT, 99)) == len(_voices_of(self.SCRIPT)) + + def test_un_seul_acte_ne_devoile_rien(self): + assert plan_code_reveal(self.SCRIPT, 1) == [self.SCRIPT] + + def test_chaque_etat_etend_le_precedent_sans_le_reecrire(self): + states = plan_code_reveal(self.SCRIPT, 4) + assert len(set(states)) == 4 + for before, after in zip(states, states[1:]): + assert len(before) < len(after) + # Les voix deja montrees sont un PREFIXE, les autres lignes + # sont identiques : aucun etat ne reorganise le script. + assert _voices_of(after)[: len(_voices_of(before))] == _voices_of(before) + assert _other_lines(before) == _other_lines(after) + + def test_un_script_sans_voix_ne_se_devoile_pas(self): + assert plan_code_reveal("setcps(0.9333)", 4) == ["setcps(0.9333)"] + + +class TestRevealSchedule: + """Cadencement des etats sur la fenetre de capture.""" + + def test_repartition_uniforme_depuis_zero(self): + plan = reveal_schedule(["a", "b", "c"], 60.0) + assert [round(t, 3) for t, _ in plan] == [0.0, 20.0, 40.0] + assert [s for _, s in plan] == ["a", "b", "c"] + + def test_un_seul_etat_reste_a_zero(self): + assert reveal_schedule(["a"], 60.0) == [(0.0, "a")] + + def test_liste_vide(self): + assert reveal_schedule([], 60.0) == [] + + def test_fenetre_negative_ne_fabrique_pas_de_temps_negatif(self): + plan = reveal_schedule(["a", "b"], -10.0) + assert [t for t, _ in plan] == [0.0, 0.0] + + def test_les_instants_sont_strictement_croissants(self): + times = [t for t, _ in reveal_schedule(["a", "b", "c", "d"], 90.0)] + assert times == sorted(times) + assert len(set(times)) == len(times) + + +class TestSetEditorCode: + """Ecriture dans l'editeur : API CodeMirror d'abord, clavier en repli.""" + + CODE = "$: s('bd*4')\n$: note(\"c2\")" + + def test_api_codemirror_utilisee_quand_disponible(self): + page = _FakeEditorPage(codemirror_available=True) + assert _set_editor_code(page, self.CODE) == "codemirror" + assert page.js_inserts == [self.CODE] + assert page.key_presses == [] + assert page.editor_clicks == 0 + + def test_repli_clavier_quand_l_api_est_absente(self): + page = _FakeEditorPage(codemirror_available=False) + assert _set_editor_code(page, self.CODE) == "keyboard" + assert page.editor_clicks == 1 + assert page.key_presses == ["Control+A"] + assert page.keyboard_inserts == [self.CODE] + + def test_ne_reevalue_jamais_le_pattern(self): + # Control+Enter relancerait l'evaluation : la musique doit continuer + # de jouer le pattern complet pendant que l'editeur se remplit. + for available in (True, False): + page = _FakeEditorPage(codemirror_available=available) + _set_editor_code(page, self.CODE) + assert "Control+Enter" not in page.key_presses + + +class TestDriveCodeReveal: + """Le devoilement consomme la fenetre et place chaque etat a son instant.""" + + def test_etats_places_aux_instants_du_plan(self): + page = _FakeEditorPage() + result = _drive_code_reveal(page, ["a", "b", "c"], 60.0, clock=lambda: page.now) + assert result["states"] == 3 + assert result["applied_at"] == [0.0, 20.0, 40.0] + assert result["editor_api"] == "codemirror" + assert page.js_inserts == ["a", "b", "c"] + + def test_la_fenetre_est_entierement_consommee(self): + page = _FakeEditorPage() + _drive_code_reveal(page, ["a", "b", "c"], 60.0, clock=lambda: page.now) + assert sum(page.waits_ms) == 60_000 + + def test_repli_clavier_traverse_tous_les_etats(self): + page = _FakeEditorPage(codemirror_available=False) + result = _drive_code_reveal(page, ["a", "b"], 30.0, clock=lambda: page.now) + assert result["editor_api"] == "keyboard" + assert page.keyboard_inserts == ["a", "b"] + assert sum(page.waits_ms) == 30_000 + + +class TestCaptureRevealContract: + """Le devoilement exige le mode page — echec explicite, pas un drapeau inerte.""" + + def test_refuse_le_mode_canvas(self, tmp_path): + with pytest.raises(ValueError, match="reveal_states exige capture_mode='page'"): + capture_repl_session( + "pattern", + tmp_path, + capture_seconds=1, + capture_mode="canvas", + reveal_states=["a", "b"], + ) + + def test_mode_inconnu_toujours_refuse(self, tmp_path): + with pytest.raises(ValueError, match="capture_mode inconnu"): + capture_repl_session("pattern", tmp_path, capture_mode="video") + + def test_sans_devoilement_aucune_garde(self, tmp_path): + # reveal_states=None : le mode canvas reste accepte (comportement + # historique) — la garde ne mord que sur le mecanisme nouveau. + with pytest.raises(Exception) as exc: + capture_repl_session("pattern", tmp_path, capture_seconds=1, + capture_mode="video") + assert isinstance(exc.value, ValueError) + + +class TestCliRevealActs: + def test_defaut_desactive(self): + assert _build_arg_parser().parse_args([]).reveal_acts == 0 + + def test_valeur_transmise(self): + assert _build_arg_parser().parse_args(["--reveal-acts", "3"]).reveal_acts == 3 + + +class TestRunPipelineRevealWiring: + """L'orchestrateur transmet bien les etats — le drapeau ne se perd pas.""" + + @staticmethod + def _fake_capture(tmp_path): + def _fake(**kwargs): + return { + "video_webm": str(tmp_path / "capture.webm"), + "audio_wav": str(tmp_path / "capture.wav"), + "final_mp4": str(tmp_path / "capture_final.mp4"), + "video_bytes": 11, + "wav_bytes": 22, + "mp4_bytes": 33, + "cycles": 1, + "capture_mode": kwargs.get("capture_mode", "page"), + "trim_start": 0.5, + "reveal": { + "states": 3, + "applied_at": [0.0, 20.0, 40.0], + "editor_api": "codemirror", + }, + } + return _fake + + def test_les_etats_atteignent_la_capture(self, monkeypatch, tmp_path): + import scripts.livecoding_video_pipeline as mod + + seen = {} + + def _spy(**kwargs): + seen.update(kwargs) + return self._fake_capture(tmp_path)(**kwargs) + + monkeypatch.setattr(mod, "capture_repl_session", _spy) + result = run_pipeline( + style_name="trance", + duration_seconds=60, + output_path=str(tmp_path / "out.mp4"), + capture=True, + capture_seconds=60, + reveal_acts=3, + ) + assert len(seen["reveal_states"]) == 3 + assert seen["reveal_states"][-1] == result["strudel_script"] + assert "devoilement 3 etats" in result["browser_capture"] + + def test_desactive_transmet_une_liste_vide(self, monkeypatch, tmp_path): + import scripts.livecoding_video_pipeline as mod + + seen = {} + + def _spy(**kwargs): + seen.update(kwargs) + return self._fake_capture(tmp_path)(**kwargs) + + monkeypatch.setattr(mod, "capture_repl_session", _spy) + run_pipeline( + style_name="trance", + duration_seconds=60, + output_path=str(tmp_path / "out.mp4"), + capture=True, + capture_seconds=60, + ) + assert seen["reveal_states"] == [] From be9451bc5f4932db281a629c153054344986abf4 Mon Sep 17 00:00:00 2001 From: Jean-Sylvain Boige Date: Sun, 25 Oct 2026 19:24:59 +0100 Subject: [PATCH 2/2] Fix(genai,#15604): ancrer le code devoile a la colonne 0 (mesure de capture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesure de la premiere capture reelle (2026-10-25) : le devoilement fonctionnait — 4 etats appliques a t=0.117/6.08/12.08/18.1 s — mais l'editeur laissait le curseur en fin de document. CodeMirror defilait alors horizontalement et la ligne 1 sortait par la gauche : sur la frame a t=2 s, le code affiche etait tronque (`...niers cycles`), donc illisible pour un spectateur. Correction : ramener le curseur au debut apres ecriture — `selection: {anchor: 0}` + `scrollIntoView` sur la voie API, `Control+Home` sur la voie clavier. Un deplacement de curseur n'est pas une reevaluation du pattern (jamais `Control+Enter`). Controle apres correction, meme instrument (frames extraites du mp4 produit par une seconde capture) : t=2 s -> lignes 1-4, une voix (`$: s('bd*4').gain(0.9)`) t=20 s -> lignes 1-7, quatre voix (jusqu'a `room(0.6).gain(0.2)`) Les deux frames sont ancrees a la colonne 0. Mesure reportee aussi dans la docstring : la voie API CodeMirror n'a pas mordu sur strudel.cc (aucune vue exposee sur `.cm-content`, `EditorView` non global dans le bundle) ; le repli clavier a porte les 4 etats dans les deux captures. La voie API reste un chemin opportuniste, jamais silencieux. Tests : un cas de plus (le rappel de curseur), 120 passed. See #15604 Co-Authored-By: Claude Sonnet 5.5 --- scripts/livecoding_video_pipeline.py | 16 ++++++++++++++++ scripts/tests/test_livecoding_video_pipeline.py | 10 +++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/livecoding_video_pipeline.py b/scripts/livecoding_video_pipeline.py index 9b5fe4c6715..3308b157770 100644 --- a/scripts/livecoding_video_pipeline.py +++ b/scripts/livecoding_video_pipeline.py @@ -1422,10 +1422,24 @@ def _set_editor_code(page: object, code: str) -> str: ``insert_text`` — une seule insertion, pas de frappe caracteres par caracteres (ce qui rendrait la capture lente et fragile). + MESURE du 2026-10-25 (deux captures reelles) : la voie 1 n'a pas + mordu sur strudel.cc — ``.cm-content`` n'expose pas de vue et + ``EditorView`` n'est pas global dans le bundle de la page. Le repli + clavier a porte les 4 etats dans les deux captures. La voie 1 est + conservee comme chemin opportuniste (les internes du DOM changent + d'une version de CodeMirror a l'autre) et son echec est rapporte + dans le resultat de capture (``editor_api``), jamais silencieux. + Ne JAMAIS envoyer ``Control+Enter`` : la musique doit continuer de jouer le pattern complet pendant que l'editeur se remplit. L'export WAV qui suit lit le dernier etat, identique au script entier. + Le curseur est ensuite ramene au debut du document (selection a 0, + ou ``Control+Home``). Sans ce rappel, CodeMirror laisse le curseur + en fin de document et defile horizontalement : mesure sur la capture + du 2026-10-25, la ligne 1 sortait par la gauche et le code affiche + etait tronque. Un deplacement de curseur n'est pas une reevaluation. + Retourne le nom de la voie qui a effectivement ecrit (``codemirror`` ou ``keyboard``) — mesure reportee dans le resultat de capture. """ @@ -1439,6 +1453,7 @@ def _set_editor_code(page: object, code: str) -> str: view.dispatch({ changes: {from: 0, to: view.state.doc.length, insert: code}, }); + view.dispatch({selection: {anchor: 0}, scrollIntoView: true}); return true; }""", code, @@ -1450,6 +1465,7 @@ def _set_editor_code(page: object, code: str) -> str: editor.click(timeout=10_000) page.keyboard.press("Control+A") page.keyboard.insert_text(code) + page.keyboard.press("Control+Home") return "keyboard" diff --git a/scripts/tests/test_livecoding_video_pipeline.py b/scripts/tests/test_livecoding_video_pipeline.py index fa68f81cbf3..76500afe773 100644 --- a/scripts/tests/test_livecoding_video_pipeline.py +++ b/scripts/tests/test_livecoding_video_pipeline.py @@ -1450,9 +1450,17 @@ def test_repli_clavier_quand_l_api_est_absente(self): page = _FakeEditorPage(codemirror_available=False) assert _set_editor_code(page, self.CODE) == "keyboard" assert page.editor_clicks == 1 - assert page.key_presses == ["Control+A"] + assert page.key_presses == ["Control+A", "Control+Home"] assert page.keyboard_inserts == [self.CODE] + def test_ramene_le_curseur_au_debut_apres_ecriture(self): + # Sans ce rappel, CodeMirror laisse le curseur en fin de document et + # defile horizontalement : mesure du 2026-10-25, la ligne 1 sortait + # par la gauche et le code affiche etait tronque. + page = _FakeEditorPage(codemirror_available=False) + _set_editor_code(page, self.CODE) + assert page.key_presses[-1] == "Control+Home" + def test_ne_reevalue_jamais_le_pattern(self): # Control+Enter relancerait l'evaluation : la musique doit continuer # de jouer le pattern complet pendant que l'editeur se remplit.