diff --git a/scripts/livecoding_video_pipeline.py b/scripts/livecoding_video_pipeline.py index 7c728df97c..b609a8cc06 100644 --- a/scripts/livecoding_video_pipeline.py +++ b/scripts/livecoding_video_pipeline.py @@ -767,7 +767,7 @@ def run_pipeline( playwright_url: str = "https://strudel.cc/", ffmpeg_loudnorm_lufs: float = -14.0, capture: bool = False, - capture_seconds: int = 30, + capture_seconds: Optional[int] = None, headless: bool = False, narration_engine: str = "template", narration_json_path: Optional[str] = None, @@ -806,6 +806,11 @@ def run_pipeline( dicts segments), 'tts', 'browser_capture', 'visualizer', 'final_mix', 'verdict'. """ + # Defaut coherent : sans --capture-seconds explicite, la capture suit + # --duration (mesure c.1528 : le defaut fige 30 tronquait une demo + # de 180 s a 30,9 s avec 4/11 segments mixes). + capture_seconds = _resolve_capture_seconds(capture_seconds, duration_seconds) + strudel = compose_strudel( style_name=style_name, duration_seconds=duration_seconds, @@ -1229,6 +1234,43 @@ def mix_final_ffmpeg( return cmd +def _resolve_capture_seconds( + capture_seconds: Optional[int], duration_seconds: int +) -> int: + """Defaut coherent de la duree de capture (etape 4, #15604). + + Sans valeur explicite, la capture suit ``duration_seconds`` : le + defaut fige 30 tronquait silencieusement une demo de 180 s a + 30,9 s (mesure c.1528, 4/11 segments mixes). Une valeur explicite + (capture plus courte que la musique) reste respectee. + """ + if capture_seconds is not None: + return capture_seconds + return duration_seconds + + +def _click_play(page: object, url: str, attempts: int = 3) -> None: + """Clic play robuste : re-navigation entre tentatives (etape 4). + + Mesure c.1528 : le clic play echoue par timeout ~1 run sur 3 + (``Locator.click: Timeout 10000ms``) — etat de page variable au + chargement du hash strudel. Un re-chargement de l'URL de partage + recharge le REPL avec le pattern et suffit a retablir le clic. + La derniere tentative echouee remonte telle quelle (echec explicite, + pas de fallback silencieux). Orchestration pure : le verdict de + capture reste porte par le checksum canvas en aval. + """ + for attempt in range(1, attempts + 1): + try: + page.get_by_role("button", name="play").first.click(timeout=10_000) + return + except Exception: + if attempt == attempts: + raise + page.goto(url) + page.wait_for_timeout(5000) + + def capture_repl_session( pattern: str, output_dir: Path, @@ -1269,7 +1311,7 @@ def capture_repl_session( try: page.goto(url) page.wait_for_timeout(5000) - page.get_by_role("button", name="play").first.click(timeout=10_000) + _click_play(page, url) page.wait_for_timeout(int(warmup_seconds * 1000)) h1 = page.evaluate(_CANVAS_CHECKSUM_JS) @@ -1389,14 +1431,16 @@ def _build_arg_parser() -> argparse.ArgumentParser: parser.add_argument( "--capture-seconds", type=int, - default=30, - help="duree de capture video en secondes (default: 30)", + default=None, + help="duree de capture video en secondes (default: suit --duration " + "— le defaut fige 30 tronquait la demo, mesure c.1528)", ) parser.add_argument( "--headless", action="store_true", - help="lancer Chromium headless (DECONSEILLE : le canvas REPL ne " - "s'anime pas sans fenetre compositée — mesure c.580)", + help="lancer Chromium headless (mesure c.1528 : capture 180 s " + "verifiee headless, le checksum canvas en aval reste le " + "garde contre un canvas immobile — cf mesure c.580 anterieure)", ) parser.add_argument( "--narration-engine", diff --git a/scripts/tests/test_livecoding_video_pipeline.py b/scripts/tests/test_livecoding_video_pipeline.py index 8ab44bde08..6784698f46 100644 --- a/scripts/tests/test_livecoding_video_pipeline.py +++ b/scripts/tests/test_livecoding_video_pipeline.py @@ -41,6 +41,9 @@ STYLES, StrudelStyle, _load_env_files, + _build_arg_parser, + _click_play, + _resolve_capture_seconds, build_repl_url, compose_narration, compose_strudel, @@ -1074,3 +1077,117 @@ def _probe_fail(media): fc = cmd[cmd.index("-filter_complex") + 1] assert "st=87.850" in fc # 90.85 - 3.0 assert cmd[cmd.index("-t") + 1] == "90.850" + + +class TestResolveCaptureSeconds: + """Defaut coherent capture/duration (mesure c.1528 : demo 180 s + tronquee a 30,9 s, 4/11 segments mixes, sans drapeau explicite).""" + + def test_none_suit_duration(self): + assert _resolve_capture_seconds(None, 180) == 180 + + def test_explicite_respecte(self): + """Une capture plus courte que la musique reste legale + (verification rapide, capture de test).""" + assert _resolve_capture_seconds(45, 180) == 45 + + +class TestCliCaptureSecondsDefault: + def test_parser_defaut_none(self): + """Le parser ne fixe plus 30 : None traverse jusqu'a la + resolution dans run_pipeline, la capture reste coherente avec + --duration (cycles, narration, mix).""" + args = _build_arg_parser().parse_args(["--duration", "90"]) + assert args.capture_seconds is None + assert args.duration == 90 + + def test_run_pipeline_transmet_duration_a_la_capture(self, tmp_path, monkeypatch): + """Wiring : sans capture_seconds explicite, capture_repl_session + recoit duration_seconds.""" + import scripts.livecoding_video_pipeline as pl + + seen = {} + + def _fake_capture(pattern, output_dir, capture_seconds, cycles, headless): + seen["capture_seconds"] = capture_seconds + 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": 1, + "wav_bytes": 1, + "mp4_bytes": 1, + "cycles": cycles, + } + + monkeypatch.setattr(pl, "capture_repl_session", _fake_capture) + pl.run_pipeline( + style_name="trance", + duration_seconds=90, + output_path=str(tmp_path / "out.mp4"), + capture=True, + headless=True, + ) + assert seen["capture_seconds"] == 90 + + +class _FakePlayPage: + """Page fake minimale pour l'orchestration _click_play : le clic + echoue les ``fail_first`` premiers essais puis reussit. Precedent + _FakeClient (LLM) : on valide la SEQUENCE retry, pas un verdict de + capture — la capture reelle reste provee par l'execution documentee + dans le body de la PR.""" + + def __init__(self, fail_first: int = 0): + self.clicks = 0 + self.gotos = 0 + self.waits = 0 + self.last_url = None + self._fail_first = fail_first + outer = self + + class _First: + @staticmethod + def click(timeout=None): + outer.clicks += 1 + if outer.clicks <= outer._fail_first: + raise RuntimeError(f"Locator.click: Timeout {timeout}ms") + + self._first = _First() + + def get_by_role(self, role, name=None): + return SimpleNamespace(first=self._first) + + def goto(self, url): + self.gotos += 1 + self.last_url = url + + def wait_for_timeout(self, ms): + self.waits += 1 + + +class TestClickPlayRetry: + """Retry du clic play (timeout flaky 1 run sur 3, mesure c.1528) : + re-navigation de l'URL de partage entre tentatives, echec explicite + au dernier essai.""" + + def test_succes_immediat_sans_reload(self): + page = _FakePlayPage(fail_first=0) + _click_play(page, "https://strudel.cc/#HASH") + assert page.clicks == 1 + assert page.gotos == 0 + + def test_timeout_puis_succes_apres_reload(self): + page = _FakePlayPage(fail_first=1) + _click_play(page, "https://strudel.cc/#HASH") + assert page.clicks == 2 + assert page.gotos == 1 + assert page.last_url == "https://strudel.cc/#HASH" + assert page.waits == 1 # attente post-navigation avant le 2e essai + + def test_epuise_leve_la_derniere_erreur(self): + page = _FakePlayPage(fail_first=99) + with pytest.raises(RuntimeError, match="Timeout 10000ms"): + _click_play(page, "https://strudel.cc/#HASH", attempts=3) + assert page.clicks == 3 + assert page.gotos == 2