Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions scripts/livecoding_video_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
117 changes: 117 additions & 0 deletions scripts/tests/test_livecoding_video_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
STYLES,
StrudelStyle,
_load_env_files,
_build_arg_parser,
_click_play,
_resolve_capture_seconds,
build_repl_url,
compose_narration,
compose_strudel,
Expand Down Expand Up @@ -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
Loading