diff --git a/guidebot_recorder/recorder/render/__init__.py b/guidebot_recorder/recorder/render/__init__.py index 7e715ff..3693eae 100644 --- a/guidebot_recorder/recorder/render/__init__.py +++ b/guidebot_recorder/recorder/render/__init__.py @@ -24,9 +24,21 @@ timeline.py observed freezes → validated model → the edited file audio.py audio beds, muxing, and the two-phase artifact publish reuse.py the compiled-sidecar contract as render reads it - _step.py _render_step (opaque; phase 3 decomposes it) - _run.py run_render (opaque; phase 3 decomposes it — still >600 lines, - which is expected and is *not* a sign the cleanup is done) + +Phase 3 added the four that carry ``run_render``'s three lifetimes, plus the two +that spend them:: + + plan.py _RenderPlan — everything decided before a browser exists + stage.py _Stage — what is on screen now, and the ONE order the + role-gated init scripts may be registered in + clock.py _Clock — the recording axis, and why ``on_sfx`` is a bound + method rather than a value + loop.py replaying one flat step; the absence probe is a seam, not a + comment + post.py recording -> composed -> virtual -> mastered, in that order + and no other + _step.py _render_step — two dispatches on two different keys + _run.py run_render — nothing but the order the phases run in It also, on purpose, **withholds every name the tests patch**. Nineteen names plus ``os.replace`` are this package's test seams, and five of them @@ -56,9 +68,11 @@ Ten of the nineteen are the second kind, which is why this package's guard cannot be the one ``video.mux`` uses. Two names have consumers in two submodules at once — -``Recorder`` (``visuals`` and ``_run``) and ``probe_frame_count`` (``timeline`` and -``_run``) — and each needs **two** patch lines; one line silently stops covering -the other path. ``tests/unit/recorder/test_render_seams.py`` enforces all of it. +``Recorder`` (``visuals`` and ``loop``) and ``probe_frame_count`` (``timeline`` and +``post``) — and each needs **two** patch lines; one line silently stops covering +the other path. ``_run`` itself holds **no** seam any more: every one of them moved +to the submodule that constructs or calls it. +``tests/unit/recorder/test_render_seams.py`` enforces all of it. The submodules are re-exported as *modules* for exactly that reason, using the redundant-alias form. The private helpers below (``_PopupSession``, @@ -76,14 +90,19 @@ from . import _run as _run from . import _step as _step from . import audio as audio +from . import clock as clock from . import constants as constants from . import errors as errors +from . import loop as loop from . import narration as narration from . import pages as pages +from . import plan as plan from . import popup_crop as popup_crop from . import popup_detect as popup_detect from . import popup_session as popup_session +from . import post as post from . import reuse as reuse +from . import stage as stage from . import tasks as tasks from . import timeline as timeline from . import visuals as visuals diff --git a/guidebot_recorder/recorder/render/_run.py b/guidebot_recorder/recorder/render/_run.py index f9d7fd9..3b12c6d 100644 --- a/guidebot_recorder/recorder/render/_run.py +++ b/guidebot_recorder/recorder/render/_run.py @@ -1,112 +1,44 @@ -"""``run_render``: the whole render pass, top to bottom. Phase 3 decomposes it. - -Deliberately one opaque module, and deliberately **still over the 600-line limit** -— phase 1 split the file, it did not decompose this function. ``run_render`` -carries cyclomatic complexity 97 and three interleaved lifetimes (the frozen plan, -the recording clock, what is currently on screen); turning those into three state -objects is phase 3's job. Moving it verbatim is what keeps this diff reviewable and -keeps the two orderings below provably untouched. - -**Two orderings in here are load-bearing.** - -* ``cursor.js`` / ``slide.js`` / ``desktop.js`` MUST be registered before - ``chrome.js``. Each decides its role by reading the real ``window.top``, and - ``chrome.js`` is what shadows ``top`` (frame-bust neutralization); a layer - registered after it would misidentify as the top window and mount inside the - framed site. The long comment beside the ``install_context`` calls is the - contract, and ``test_render.py`` asserts the registration order with - ``DesktopOverlay`` included. -* Popup composition MUST run before time editing. Popups are composed on the - *recording* axis (their ``opened_at``/``closed_at`` are raw wall clock); time - editing is what moves narration and SFX onto the *virtual* axis. Swapping them - yields a film of the right length with the popup in the wrong place, and - ``test_popup_is_composed_before_time_editing_and_feeds_it`` (phase 0) asserts - both the call order and that the edit consumes the compositor's output. - -Every test seam this function drives is called through a module object — -``narration._pace_narration``, ``timeline_module._apply_timeline_edits``, -``audio._assemble_audio_tracks``, ``_step._render_step``, -``visuals._prepare_main_after_popup_close`` — so a patch on the defining submodule -lands on the globals read here. The seams defined *outside* the package -(``Overlay``, ``SlideOverlay``, ``Recorder``, ``compose_popup_video``, -``probe_frame_count``) are name-imported instead, which makes *this* module their -patch target; ``Recorder`` and ``probe_frame_count`` have a second consumer inside -the package and therefore need two patch lines each. - -``timeline`` is imported as ``timeline_module`` because ``timeline`` is already a -local name in ``run_render`` — the same reason ``mux_probe`` is aliased below. +"""``run_render``: the whole render pass, top to bottom — and nothing else. + +Phase 3 turned the three interleaved lifetimes this function used to carry into +three objects, and what is left here is the order the phases run in: + +* :mod:`~guidebot_recorder.recorder.render.plan` — + :class:`~guidebot_recorder.recorder.render.plan._RenderPlan`, frozen: + everything decided before a browser exists; +* :mod:`~guidebot_recorder.recorder.render.stage` — + :class:`~guidebot_recorder.recorder.render.stage._Stage`: what is on screen now, + and the one order the init scripts may be registered in; +* :mod:`~guidebot_recorder.recorder.render.clock` — + :class:`~guidebot_recorder.recorder.render.clock._Clock`: the recording axis, + and the reason ``on_sfx`` is a bound method; +* :mod:`~guidebot_recorder.recorder.render.loop` — replaying the steps, with the + absence probe split from the narration so the ordering is a seam; +* :mod:`~guidebot_recorder.recorder.render.post` — recording -> composed -> + virtual -> mastered, in that order and no other. + +Both load-bearing orderings therefore live with the code that performs them, not +in this file: the role-gated init scripts in ``stage``, popup composition before +time editing in ``post``. This module holds **no test seam at all** — every one of +them moved to the submodule that constructs or calls it — which is what makes it +short enough to read in one screen. """ from __future__ import annotations -import asyncio -import time from collections.abc import Mapping from pathlib import Path -from playwright.async_api import Browser, Frame, Page -from tqdm import tqdm +from playwright.async_api import Browser -from guidebot_recorder.chrome import SHELL_URL, Chrome -from guidebot_recorder.chrome.framing import install_framing -from guidebot_recorder.desktop import DesktopOverlay, resolve_icon -from guidebot_recorder.diagnostics import step_banner -from guidebot_recorder.models.action import COMPILER_VERSION, CachedAction, PendingAction -from guidebot_recorder.models.config import config_hash -from guidebot_recorder.models.scenario import FlatStep -from guidebot_recorder.overlay.overlay import Overlay -from guidebot_recorder.recorder._debug import ( - pause_for_inspection, - redact_exception, - scenario_sensitive_values, -) -from guidebot_recorder.recorder.recorder import Recorder -from guidebot_recorder.recorder.session import ensure_session from guidebot_recorder.resolver.reasoner import Reasoner -from guidebot_recorder.resolver.resolution import ResolvedTarget -from guidebot_recorder.resolver.validate import reuse_is_valid -from guidebot_recorder.scenario.compiled import compiled_path, load_compiled, write_compiled -from guidebot_recorder.scenario.loader import load_scenario, scenario_env_references -from guidebot_recorder.selects import SelectsNotReadyError, install_selects -from guidebot_recorder.slide import SlideOverlay -from guidebot_recorder.tts.base import Segment, TtsCache, TtsProvider -from guidebot_recorder.video.audiobed import Placed -from guidebot_recorder.video.mux import FadeSpec, compose_popup_video - -# `probe_duration` is a test seam: the mux facade withholds it and the call must -# stay late-bound, so its defining module is imported here instead. Aliased -# because `probe` is already used as a local name elsewhere in this module. -from guidebot_recorder.video.mux import probe as mux_probe -from guidebot_recorder.video.timeline import ( - TimeEdit, - assert_recording_fps, - frames_to_seconds, - probe_frame_count, -) - -from . import _step, audio, narration, visuals -from . import timeline as timeline_module -from .errors import RenderError, _OptionalAbsent -from .narration import _narration, _presynthesize_narration, _stamp_frame -from .pages import _active_page, _expect_chrome -from .popup_crop import _popup_fills_canvas, _resolve_popup_crop, _settle_popup_content_box -from .popup_detect import _POPUP_REQUEST_SCRIPT, _popup_window_opened -from .popup_session import ( - _PageObservation, - _PopupSession, - _prepare_popup, - _sync_popup_close, - _unexpected_pages, -) -from .reuse import _compiled_action_is_current, _resolve_pending_target -from .timeline import _build_timeline -from .visuals import _ensure_visuals, _hand_cursor_to_popup, _play_desktop_opener, _prime_visuals +from guidebot_recorder.tts.base import TtsProvider -_VIDEO_POSTROLL_SECONDS = 0.1 - - -#: A slide card's on-screen content, as consumed by ``SlideOverlay.show``/``.ensure``. -Card = dict[str, str | None] +from .clock import _Clock +from .loop import _LoopOptions, _run_steps +from .plan import _prepare_render +from .post import _publish_film +from .stage import _open_stage async def run_render( @@ -125,817 +57,29 @@ async def run_render( dump_timeline: bool = False, reasoner: Reasoner | None = None, ) -> None: - path = Path(path) - out_mp4 = Path(out_mp4) - out_mp4.parent.mkdir(parents=True, exist_ok=True) - - scenario = load_scenario(path, env) - sensitive_values = scenario_sensitive_values(scenario, scenario_env_references(path, env)) - cfg = scenario.config - # Caller-side overrides (the CLI flags). ``None`` means "use whatever the - # scenario configured" — the scenario is loaded here, so an override applied - # to a Config built by the caller would be discarded. - if hold_frame is not None: - cfg.hold_frame_for_narration = hold_frame - if hold_frame_settle is not None: - cfg.hold_frame_settle = hold_frame_settle - audio_configs = [cfg.tts, *cfg.audio_tracks] - providers = {tts.provider for tts in audio_configs} - if len(providers) != 1: - raise RenderError( - "jeden render obsługuje obecnie jeden provider TTS; " - f"skonfigurowano: {', '.join(sorted(providers))}" - ) - - cpath = compiled_path(path) - try: - compiled = load_compiled(cpath) - except FileNotFoundError as exc: - raise RenderError(f"brak pliku compiled ({cpath.name}) — uruchom `compile`") from exc - if compiled.source != path.name: - raise RenderError( - f"compiled pochodzi z innego scenariusza ({compiled.source}) — uruchom `compile`" - ) - # Flat indexing: a `when:` block contributes its synthetic gate step followed by - # its children, so `actions`, narration segments and every `krok {index}` message - # index the same linear execution order. - flat = scenario.flat_steps() - flat_steps = [entry.step for entry in flat] - if len(compiled.actions) != len(flat): - raise RenderError("compiled niezgodny z liczbą kroków — uruchom `compile`") - if compiled.compiler_version != COMPILER_VERSION or any( - action is not None and action.fingerprint.compiler_version != COMPILER_VERSION - for action in compiled.actions - ): - raise RenderError("compiled ma starszą wersję — uruchom `compile`") - scenario_hash = config_hash(cfg) - - def step_message( - entry: FlatStep, entry_index: int, message: str, *, warning: bool = False - ) -> str: - """Komunikat kroku z `plik:linia` i fragmentem YAML; sekrety zredagowane.""" - - return step_banner( - index=entry_index, - total=len(flat), - location=entry.location, - source=scenario.source, - message=message, - warning=warning, - sensitive=sensitive_values, - ) - - for index, (entry, action) in enumerate(zip(flat, compiled.actions, strict=True)): - if not _compiled_action_is_current(entry.step, action, scenario_hash): - raise RenderError( - step_message(entry, index, "compiled jest nieaktualny — uruchom `compile`") - ) - if isinstance(action, PendingAction) and entry.branch is None and not entry.step.optional: - # A pending entry is only ever written for a branch (gate + children) - # or an `optional: true` step; anywhere else the sidecar is corrupt. - raise RenderError( - step_message( - entry, index, "wpis oczekujący na kroku obowiązkowym — uruchom `compile`" - ) - ) - - # Desktop icons are resolved here, before recording: an unknown built-in or a - # missing file is an authoring error and must fail loud up front, not after - # minutes of render. Relative icon paths resolve against the scenario file's - # directory. Keyed by flat-step index for the render loop to read back. - desktop_payloads: dict[int, dict[str, str]] = {} - for index, step in enumerate(flat_steps): - if step.desktop is not None: - desktop_payloads[index] = { - "color": cfg.desktop.color, - "label": step.desktop.label, - **resolve_icon(step.desktop, base_dir=path.parent), - } - - # --- Faza 0: pre-synteza całej narracji (fail-loud przed nagrywaniem) --- - cache = TtsCache(cache_dir) - narration_count = sum(_narration(step) is not None for step in flat_steps) - presynth = tqdm( - total=narration_count * len(audio_configs), - desc="tts", - unit="segment", - disable=not verbose, - ) - try: - segments = await _presynthesize_narration( - flat_steps, - audio_configs, - cache, - tts_provider, - on_progress=presynth.update, - ) - finally: - presynth.close() - - # --- Render z nagrywaniem wideo (viewport z config — patrz compile) --- - work = out_mp4.parent / ".guidebot_video" / out_mp4.stem - work.mkdir(parents=True, exist_ok=True) - # The context viewport and video size stay at the configured dimensions so the - # output MP4 keeps its size and popups are geometrically untouched; the shell - # shrinks only the site iframe interior (see compile / site_viewport). - # Both settings are context-level, so a popup also records onto a - # main-viewport-sized canvas with filler around its real window. That is - # corrected in post (``compose_popup_video(popup_crop=...)``), never here: - # shrinking the recording would also shrink the main window's frame. - # - # Pre-recording setup: when the target declares ``config.setup`` its login - # steps were removed, so the recording context must start already logged in. - # ``ensure_session`` establishes/reuses the prepared session on separate, - # non-recording contexts *before* this line, so the login can never reach the - # film (spec: "Target render"). - setup_state = ( - await ensure_session(browser, Path(path), Path(".guidebot/sessions"), env, timeout=timeout) - if cfg.setup is not None - else None - ) - context = await browser.new_context( - viewport={"width": cfg.viewport.width, "height": cfg.viewport.height}, - locale=cfg.locale, - record_video_dir=str(work), - record_video_size={"width": cfg.viewport.width, "height": cfg.viewport.height}, - **({"storage_state": setup_state} if setup_state is not None else {}), - **({"bypass_csp": True, "service_workers": "block"} if cfg.chrome.enabled else {}), + plan = await _prepare_render( + path, + out_mp4, + tts_provider, + cache_dir, + env=env, + hold_frame=hold_frame, + hold_frame_settle=hold_frame_settle, + verbose=verbose, ) - # Independent of the role-gating order below (it only wraps ``window.open``), - # but registered first so it wraps the *native* function on every document. - await context.add_init_script(script=_POPUP_REQUEST_SCRIPT) - overlay = Overlay(cfg.cursor, cfg.viewport) - # Role-gating contract: cursor.js, slide.js and desktop.js MUST be registered - # before chrome.js. Inside the site iframe, each of them decides its role by - # reading the real ``window.top`` (cursor.js to skip mounting a duplicate - # cursor, slide.js's ``isTop`` guard to skip installing - # ``window.__guidebot_slide``, desktop.js likewise); chrome.js is what - # shadows ``top`` (frame-bust neutralization). If any of these init scripts - # ran after chrome.js, it would read the shadowed ``top``, misidentify as the - # top window, and mount inside the frame. - # - # selects.js reads ``top`` too but is deliberately NOT part of that contract: - # its only test is ``isTop && origin === SHELL_ORIGIN``, and chrome.js - # shadows ``top`` solely inside framed documents, whose origin is never the - # shell's — so the shim reaches the same verdict on either side of chrome.js. - # It is registered here anyway, next to the overlays it sits beside; nothing - # downstream may rely on that position. See the role-gating comment at the - # top of ``selects/selects.js``. - await overlay.install_context(context) - slide = SlideOverlay() - await slide.install_context(context) - # Same role-gating rationale as slide.js (isTop guard): must be registered - # before chrome.js so it reads the real ``window.top`` and never mounts the - # desktop inside the framed site. - desktop = DesktopOverlay(config={"background": cfg.desktop.color}) - await desktop.install_context(context) - # The DOM select shim — one of the three contexts that drive pages (spec §1), - # and the reason the recording shows an option list at all. ``None`` under - # ``selects.mode: native``, which keeps the page's own control. - selects = await install_selects(context, cfg) - # Composited popups (float or slide) render bare (no in-DOM chrome bar); the - # compositor frames them in post. This flips the chrome.js popup-site branch - # off and gates the fail-loud "expect chrome" checks on popup pages below. - bare_popups = cfg.popup.is_bare - chrome = Chrome(cfg.chrome, bare_popups=bare_popups) if cfg.chrome.enabled else None - if chrome is not None: - await chrome.install_context(context) - # Strip X-Frame-Options / CSP frame-ancestors so arbitrary sites frame. - await install_framing(context, shell_origin=SHELL_URL) - - # --- Slide card state ----------------------------------------------------- - # `card_active`/`active_card` track whether a slide card currently owns the - # screen (painted either by a `slide` step or the auto-intro below). When no - # card is ever painted (no `slide` steps, `intro.enabled=False`), these stay - # False/None for the whole render and every helper below is a pure pass- - # through to today's `_ensure_visuals` — i.e. byte-identical back-compat. - card_active = False - active_card: Card | None = None - - async def _chrome_hide(pg: Page) -> None: - if chrome is not None: - await chrome.hide(pg) - - async def _chrome_show(pg: Page) -> None: - if chrome is not None: - await chrome.show(pg) - - async def _assert_card_alive(pg: Page) -> None: - """Fail loud when a navigation destroyed the card mid-say. - - A fresh, tokenless document (``slide.token`` falsy) means the picture - on screen is no longer the card the narration/scenario describes — - never narrate over — or silently dismiss — the wrong picture. - """ - if not await slide.token(pg): - raise RenderError("karta slajdu zniknęła po nawigacji — narracja nad złym obrazem") - - async def _ensure_card(pg: Page) -> None: - """Card-aware replacement for `_ensure_visuals`: re-mount the active - card (rebuild-from-missing only; a live card's content is untouched) - and re-assert the hidden cursor/chrome layers. - """ - await _assert_card_alive(pg) - assert active_card is not None # guaranteed by the card_active invariant - await slide.ensure(pg, active_card) - await overlay.hide(pg) - await _chrome_hide(pg) - - observed_pages: dict[Page, _PageObservation] = {} - - def observe_page(candidate: Page) -> None: - if candidate in observed_pages: - return - # Bare (floating) popups carry no legacy chrome bar; nor does the main - # window's about:blank warm-up under that flag. Prime against the cursor - # only, or the prime loop deadlocks waiting for a bar that never mounts. - expect_chrome = _expect_chrome(chrome, bare_popups) - observation = _PageObservation( - opened_at=time.monotonic(), - video=candidate.video, - visual_prime=asyncio.create_task( - _prime_visuals(candidate, overlay, chrome, expect_chrome=expect_chrome) - ), - ) - observed_pages[candidate] = observation - - def mark_closed(_: Page, observed: _PageObservation = observation) -> None: - if observed.closed_at is None: - observed.closed_at = time.monotonic() - - candidate.on("close", mark_closed) - - context.on("page", observe_page) - page = await context.new_page() - observe_page(page) - page.set_default_timeout(timeout * 1000) - main_observation = observed_pages[page] - if main_observation.visual_prime is not None: - await main_observation.visual_prime - video = page.video - if video is None: # pragma: no cover - record_video_dir makes this invariant true - await context.close() - raise RenderError("Playwright nie udostępnił nagrania głównego okna") - - # Chromium's screencast may not emit a first frame for a pristine about:blank - # page. A scenario can narrate for several seconds before its first navigate; - # anchoring at the Page event would then put that narration on a timeline the - # WebM never encoded. Paint a neutral document, force one captured frame, and - # only then establish the shared narration/window clock. The tiny warm-up is - # bounded pre-roll; it avoids losing an arbitrarily long opening narration. - # With chrome enabled the neutral document IS the shell (bar + empty iframe), - # so the recording opens on the browser chrome rather than a bare white page. - # Auto-intro (`cfg.intro.enabled`) replaces this neutral document with a - # title card instead — render-only, so `intro.enabled=False` keeps today's - # bootstrap byte-identical. - site_frame: Frame | None = None - if chrome is not None: - site_frame = await chrome.install_shell(page) - elif not cfg.intro.enabled: - await page.set_content("") - if cfg.intro.enabled: - active_card = { - "title": cfg.title, - "subtitle": cfg.intro.subtitle, - "notes": cfg.intro.notes, - } - await slide.show(page, active_card) - await overlay.hide(page) - await _chrome_hide(page) - card_active = True - await _ensure_visuals(page, overlay, chrome) - await page.screenshot() - await page.wait_for_timeout(100) - anchor = time.monotonic() - - # Audio placements are collected as recording-axis FRAMES, not seconds: the - # grid is what `Timeline` reasons on, and quantising once at the moment of - # observation is what lets `_stamp_frame` keep them monotonic against the - # freezes. Seconds reappear only at the very end, when the audio bed is built. - sfx_events: list[tuple[str, int]] = [] - # Freezes recorded while rendering, on the *recording* axis. Applied to the - # video — and used to remap every audio offset — once the loop is done. - time_edits: list[TimeEdit] = [] - # Frame of the most recent freeze, or -1 before any. Read by `_stamp_frame` - # so nothing is ever stamped inside a hold that was already recorded. - last_freeze_frame = -1 - - def sfx_sink(kind: str) -> None: - sfx_events.append((kind, _stamp_frame(anchor, not_before=last_freeze_frame + 1))) - - placed_by_language: dict[str, list[tuple[Segment, int]]] = { - tts.lang: [] for tts in audio_configs - } - popup: _PopupSession | None = None - popup_open_at_end = False - - #: branch whose gate turned out to be absent — every step of it is skipped - skipped_branch: int | None = None - - def note_skip(entry: FlatStep, entry_index: int, reason: str, *, gate: bool) -> None: - """Odnotuj pominięty krok opcjonalny — banner z `plik:linia`.""" - - what = "bramka" if gate else "krok opcjonalny" - tqdm.write(step_message(entry, entry_index, f"{what} pominięty — {reason}", warning=True)) - - def persist_resolved(entry_index: int, resolved_action: CachedAction) -> None: - """Fold a render-time resolution back into the sidecar (full atomic rewrite).""" - - compiled.actions[entry_index] = resolved_action - write_compiled(cpath, compiled) - - bar = tqdm(total=len(flat), desc="render", unit="krok", disable=not verbose) - try: - for index, entry in enumerate(flat): - step = entry.step - if skipped_branch is not None and entry.branch == skipped_branch: - # The gate never showed: the branch's children never run, and their - # narration is removed from the timeline rather than left as silence - # (segments are placed per index, so never placing them removes them). - bar.update(1) - continue - skipped_branch = None - _sync_popup_close(popup, observed_pages, anchor) - if popup is not None and popup.page.is_closed() and not popup.close_handled: - raise RenderError("popup zamknął się poza obsługiwaną akcją scenariusza") - if _unexpected_pages(observed_pages, page, popup): - raise RenderError( - step_message(entry, index, "nieoczekiwany popup — uruchom `compile --force`") - ) - kind = step.command_kind() - if verbose: - tqdm.write(f"[{index + 1}/{len(flat)}] {kind}") - - active_page = _active_page(page, popup) - await active_page.bring_to_front() - # Card-aware visual prep, ahead of the narration block: a `slide` - # step paints (replacing any prior card); a `say` step keeps a live - # card up while it narrates; any other step dismisses the card - # first (asserting it survived, fail-loud) before its normal - # `_ensure_visuals`. With no card ever painted this is exactly - # today's unconditional `_ensure_visuals` call (back-compat). - if kind == "desktop": - assert step.desktop is not None # guaranteed by command_kind() - if card_active: - await _assert_card_alive(active_page) - await slide.hide(active_page) - card_active = False - active_card = None - - async def _reveal_shell(pg: Page = active_page) -> None: - await _chrome_show(pg) - - await _play_desktop_opener( - desktop, - overlay, - active_page, - desktop_payloads[index], - hold=step.desktop.hold, - settle_ms=cfg.cursor.settle, - reveal=_reveal_shell, - on_click=(sfx_sink if cfg.sound.enabled else None), - ) - # The opener ends on the revealed chrome shell — normal visible - # state, so from here it is exactly the no-card path (card_active - # stays False). - elif kind == "slide": - assert step.slide is not None # guaranteed by command_kind() - if card_active: - # Fail loud before repainting: a slide following a say whose - # card was destroyed mid-narration must NOT silently swap in a - # fresh card over the wrong page (mirrors the generic dismiss - # branch's token assert below). - await _assert_card_alive(active_page) - await slide.hide(active_page) - await overlay.show(active_page) - await _chrome_show(active_page) - active_card = { - "title": step.slide.title, - "subtitle": step.slide.subtitle, - "notes": step.slide.notes, - } - await slide.show(active_page, active_card) - await overlay.hide(active_page) - await _chrome_hide(active_page) - card_active = True - elif kind == "say" and card_active: - await _ensure_card(active_page) - elif card_active: - await _assert_card_alive(active_page) - await slide.hide(active_page) - await overlay.show(active_page) - await _chrome_show(active_page) - card_active = False - active_card = None - await _ensure_visuals( - active_page, - overlay, - chrome, - expect_chrome=_expect_chrome(chrome, bare_popups), - ) - else: - await _ensure_visuals( - active_page, - overlay, - chrome, - expect_chrome=_expect_chrome(chrome, bare_popups), - ) - - # --- absence probe / in-place resolution, ahead of the narration ---- - # An optional step that turns out to be absent must not narrate first - # and only then do nothing, so everything decidable before the action — - # a stale frozen target, an unresolvable pending entry — is decided - # here. A cached gate is the exception: its `waitFor` IS the action, so - # it stays in `_step._render_step` (a synthetic gate step never - # narrates). - # - # `optional` marks the only two places absence is tolerated: a branch - # gate and an `optional: true` step. A *child* of an entered branch is - # not optional — the branch demonstrably happened, so anything failing - # inside it is a real regression (§5 of the design). Its pending entry - # is still resolved here; only the verdict on absence differs. - optional = entry.is_gate or step.optional - resolved: ResolvedTarget | None = None - cached = compiled.actions[index] - # The site iframe for the main window, the page itself for popups / - # chrome-disabled renders — never the shell document, which the shim - # deliberately skips. - probe_root: Page | Frame = ( - site_frame if active_page is page and site_frame is not None else active_page - ) - if selects is not None and step.requires_target(): - # Readiness barrier, the mirror of compile's: both the in-place - # resolution below and the frozen-target check inside - # ``_render_step`` must see the shimmed DOM, or render would drive - # a page compile never resolved against. Any navigation that led - # here has settled — it was an earlier step. - try: - await selects.wait_ready(probe_root) - except SelectsNotReadyError as exc: - # The barrier sits outside every per-step ``except`` in this - # loop, so without this the one failure that stops a render - # before its step even begins would be the only one to reach - # the author with no file, no line and no YAML fragment. - raise RenderError(step_message(entry, index, str(exc))) from exc - if step.requires_target() and (optional or isinstance(cached, PendingAction)): - try: - if isinstance(cached, PendingAction): - if reasoner is None: - raise _OptionalAbsent( - "brak dostępnego reasonera, a krok nie został skompilowany " - "(pending) — zainstaluj `codex`, aby rozwiązać go na miejscu" - ) - resolved = await _resolve_pending_target(probe_root, step, kind, reasoner) - elif isinstance(cached, CachedAction) and cached.action != "waitFor": - if not await reuse_is_valid(probe_root, cached): - raise _OptionalAbsent("zamrożony namiar nie pasuje do strony") - except _OptionalAbsent as absent: - if not optional: - raise RenderError(step_message(entry, index, str(absent))) from None - note_skip(entry, index, str(absent), gate=entry.is_gate) - if entry.is_gate: - skipped_branch = entry.branch - bar.update(1) - continue - except Exception as exc: - # Everything the resolver rejects for a reason other than - # absence — `multiple_actions` above all — is an authoring bug - # and fails the render, exactly as in the action loop below. - safe_message = redact_exception(exc, sensitive_values) - if verbose: - tqdm.write(f" ✗ {type(exc).__name__}: {safe_message}") - if pause_on_error: - await pause_for_inspection( - _active_page(page, popup), - "render", - index, - kind, - exc, - sensitive_values, - total=len(flat), - location=entry.location, - source=scenario.source, - ) - raise RenderError(f"{type(exc).__name__}: {safe_message}") from None - - step_segments: list[Segment] = [] - # Recording-axis frame: the mapping onto the finished film needs the - # complete edit list, which does not exist until the loop ends. - narration_frame = _stamp_frame(anchor, not_before=last_freeze_frame + 1) - for tts in audio_configs: - seg = segments[tts.lang].get(index) - if seg is not None: - placed_by_language[tts.lang].append((seg, narration_frame)) - step_segments.append(seg) - if step_segments: - # One picture timeline: the action waits for the longest language, - # while shorter tracks naturally contain silence before the action. - emitted = await narration._pace_narration( - step_segments, - anchor=anchor, - hold_frame=cfg.hold_frame_for_narration, - settle=cfg.hold_frame_settle, - edits=time_edits, - not_before=narration_frame, - ) - if emitted is not None: - last_freeze_frame = emitted - - _sync_popup_close(popup, observed_pages, anchor) - if popup is not None and popup.page.is_closed() and not popup.close_handled: - raise RenderError("popup zamknął się asynchronicznie podczas narracji") - active_page = _active_page(page, popup) - if _unexpected_pages(observed_pages, page, popup): - raise RenderError( - step_message(entry, index, "nieoczekiwany popup — uruchom `compile --force`") - ) - await active_page.bring_to_front() - # Card-aware post-narration re-assert: a navigation that destroyed the - # card DURING the narration wait (a say/slide over a live card) must - # fail loud here — this is the checkpoint that catches a mid-wait - # destruction even when the say is the LAST step (the loop still fully - # processes that step before exiting). When no card is active this is - # exactly today's unconditional `_ensure_visuals` (back-compat). - if card_active: - await _ensure_card(active_page) - else: - await _ensure_visuals( - active_page, - overlay, - chrome, - expect_chrome=( - popup.wants_bar - if popup is not None and active_page is popup.page - else _expect_chrome(chrome, bare_popups) - ), - ) - if isinstance(cached, CachedAction) and cached.opens_popup and popup is not None: - raise RenderError("v1 obsługuje co najwyżej jeden popup w całej sesji") - if kind == "closeWindow" and popup is None: - raise RenderError(step_message(entry, index, "closeWindow bez otwartego okna")) - # Main window drives the site iframe (a Frame); popups drive the page. - on_shell = active_page is page and site_frame is not None - recorder = Recorder( - active_page, - overlay, - settle_ms=cfg.cursor.settle, - frame=site_frame if on_shell else None, - type_delay_ms=(cfg.typing.speed if cfg.typing.animate else None), - type_jitter_ms=cfg.typing.jitter_ms, - type_max_delay_factor=cfg.typing.max_delay_factor, - on_sfx=(sfx_sink if cfg.sound.enabled else None), - # How long the unfurled option list is held before the cursor - # sets off towards the chosen row. Render is the only phase that - # animates a `select:` step, so this is the one place the - # configured value can take effect at all. - open_hold_ms=cfg.selects.open_hold_ms, - ) - try: - opened = await _step._render_step( - active_page, - recorder, - overlay, - chrome, - scenario, - step, - kind, - index, - cached, - anchor, - observed_pages, - _ensure_card, - entry=entry, - total=len(flat), - sensitive=sensitive_values, - expect_chrome=( - popup.wants_bar - if popup is not None and active_page is popup.page - else _expect_chrome(chrome, bare_popups) - ), - resolved=resolved, - optional=optional, - scenario_hash=scenario_hash, - on_resolved=persist_resolved, - ) - if opened is not None: - popup = opened - popup.page.set_default_timeout(timeout * 1000) - popup.is_blank_tab = not await _popup_window_opened(page) - popup.wants_bar = chrome is not None and popup.is_blank_tab - prepared = await _prepare_popup( - popup.page, - overlay, - chrome, - expect_chrome=_expect_chrome(chrome, bare_popups) or popup.wants_bar, - mount_bar=popup.wants_bar, - ) - _sync_popup_close(popup, observed_pages, anchor) - if not prepared: - raise RenderError("popup zamknął się podczas otwierania") - # The popup now owns the cursor (it mounted its own); stop - # painting a second one in the main window behind it. - await _hand_cursor_to_popup(page, popup, overlay) - if page.is_closed(): - raise RenderError("główne okno zostało zamknięte podczas render") - _sync_popup_close(popup, observed_pages, anchor) - if popup is not None and popup.page.is_closed(): - if not popup.close_handled: - if opened is not None or kind in {"say", "navigate", "wait", "slide"}: - raise RenderError( - "popup zamknął się asynchronicznie poza obsługiwaną akcją" - ) - popup.close_handled = True - await visuals._prepare_main_after_popup_close( - page, - overlay, - chrome, - cfg.cursor.settle, - restore_cursor_to=popup.main_cursor_pos, - ) - if _unexpected_pages(observed_pages, page, popup): - raise RenderError( - step_message( - entry, index, "nieoczekiwany popup — uruchom `compile --force`" - ) - ) - except _OptionalAbsent as absent: - # Only a cached gate reaches here (its `waitFor` timed out); every - # other absence signal was already settled by the probe above. - note_skip(entry, index, str(absent), gate=entry.is_gate) - if entry.is_gate: - skipped_branch = entry.branch - except Exception as exc: - safe_message = redact_exception(exc, sensitive_values) - if verbose: - tqdm.write(f" ✗ {type(exc).__name__}: {safe_message}") - if pause_on_error: - debug_page = _active_page(page, popup) - await pause_for_inspection( - debug_page, - "render", - index, - kind, - exc, - sensitive_values, - total=len(flat), - location=entry.location, - source=scenario.source, - ) - raise RenderError(f"{type(exc).__name__}: {safe_message}") from None - bar.update(1) - # Force a bounded final frame after narration/action completion. Without - # this post-roll, a static last page can leave the VFR recording a fraction - # shorter than the audio timeline and make the final syllable trimmable. - await asyncio.sleep(_VIDEO_POSTROLL_SECONDS) - postroll_page = _active_page(page, popup) - await postroll_page.screenshot() - _sync_popup_close(popup, observed_pages, anchor) - if page.is_closed(): - raise RenderError("główne okno zostało zamknięte na końcu scenariusza") - if _unexpected_pages(observed_pages, page, popup): - raise RenderError("nieoczekiwany popup na końcu scenariusza") - if popup is not None and popup.page.is_closed() and not popup.close_handled: - raise RenderError("popup zamknął się asynchronicznie na końcu scenariusza") - finally: - bar.close() - _sync_popup_close(popup, observed_pages, anchor) - if popup is not None and popup.closed_at is None: - popup_open_at_end = True - popup.closed_at = max(popup.opened_at, time.monotonic() - anchor) - if popup is not None: - # Last moment the popup's DOM can still answer: the context (and with - # it every page) is closed a few lines below. The probe was started - # when the popup opened, so this normally settles instantly. - await _settle_popup_content_box(popup) - prime_tasks = [ - observation.visual_prime - for observation in observed_pages.values() - if observation.visual_prime is not None - ] - for task in prime_tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*prime_tasks, return_exceptions=True) - await context.close() - - sfx_frames: list[tuple[str, int]] = [] - if cfg.sound.enabled: - for kind, frame in sfx_events: - if kind == "click" and not cfg.sound.click: - continue - if kind == "key" and not cfg.sound.keys: - continue - if frame < 0: - raise RenderError(f"ujemna klatka SFX ({frame}) — błąd zegara renderu") - sfx_frames.append((kind, frame)) - - main_webm = Path(await video.path()) - if popup is None: - source_video = main_webm - preencoded = False - else: - popup_webm = Path(await popup.video.path()) - # The popup recorded onto the main window's canvas; crop it back to its - # real window so float frames that and not a viewport-sized rectangle of - # filler. Three levels, best evidence first; all declining -> today's - # full canvas. - popup_crop, _crop_level = _resolve_popup_crop( - window_size=popup.window_size, - content_box=popup.content_box, - popup_video=popup_webm, + stage = await _open_stage(browser, plan, env=env, timeout=timeout) + # Audio placements are collected as recording-axis FRAMES, not seconds — see + # `clock.py` for why, and for why `note_sfx` is handed over as a bound method. + clock = _Clock.started(stage.anchor, plan.audio_configs) + await _run_steps( + plan, + stage, + clock, + _LoopOptions( + timeout=timeout, + pause_on_error=pause_on_error, verbose=verbose, - viewport=popup.viewport, - canvas=(cfg.viewport.width, cfg.viewport.height), - ) - # A real browser TAB that fills the canvas is not a floating popup: - # `slide` is the full-frame presentation by design and ignores - # `popup_crop`, while `float` would inset a whole viewport and read as a - # shrunken clone of the page. Gated on `is_blank_tab`, not on the crop - # alone — a featureless `window.open` painting a full-bleed background - # also declines every crop level, yet is a genuine floating window that - # must keep `float`. Only `float` is overridden: an author who asked for - # `cut` gets the hard cut they asked for. - transition = cfg.popup.effective_transition - if ( - transition == "float" - and popup.is_blank_tab - and _popup_fills_canvas(popup_crop, cfg.viewport) - ): - transition = "slide" - if verbose: - tqdm.write("popup wypełnia kadr — wymuszam przejście `slide` zamiast `float`") - closed_at = mux_probe.probe_duration(main_webm) if popup_open_at_end else popup.closed_at - assert closed_at is not None - composite = work / f"{out_mp4.stem}.composite.mp4" - compose_popup_video( - main_webm, - popup_webm, - composite, - popup.opened_at, - closed_at, - visual_ready_delay=popup.visual_ready_delay, - transition=transition, - slide_ms=cfg.popup.slide_ms, - scale=cfg.popup.scale, - corner_radius=cfg.popup.corner_radius, - shadow=cfg.popup.shadow, - backdrop_dim=cfg.popup.backdrop_dim, - backdrop_blur=cfg.popup.backdrop_blur, - open_ms=cfg.popup.open_ms, - close_ms=cfg.popup.close_ms, - hold_open_at_end=popup_open_at_end, - popup_crop=popup_crop, - ) - source_video = composite - preencoded = True - - # Time editing runs AFTER popup composition: popups are composed on the - # recording axis (their opened_at/closed_at are raw wall clock) and must stay - # there. Only what is consumed downstream — narration and SFX — moves onto - # the virtual axis. - timeline = _build_timeline(time_edits, source_frames=probe_frame_count(source_video)) - if dump_timeline: - out_mp4.with_suffix(".timeline.json").write_text(timeline.to_json(), encoding="utf-8") - if not timeline.is_empty: - assert_recording_fps(source_video) - edited = work / f"{out_mp4.stem}.timeline.mp4" - timeline_module._apply_timeline_edits(source_video, timeline, edited) - source_video = edited - preencoded = True - - # Taken from the model rather than probed, which is what makes the audio and - # video axes agree by construction. - total = timeline.virtual_duration - # The one place frames become seconds: mapped on the grid, then converted. - placed_tracks = { - lang: [ - Placed(segment=seg, offset=frames_to_seconds(timeline.to_virtual(frame))) - for seg, frame in placed - ] - for lang, placed in placed_by_language.items() - } - sfx_offsets = [ - (kind, frames_to_seconds(timeline.to_virtual(frame))) for kind, frame in sfx_frames - ] - - await audio._assemble_audio_tracks( - source_video, - audio_configs, - placed_tracks, - total, - work, - out_mp4, - preencoded=preencoded, - sound=cfg.sound, - sfx_offsets=sfx_offsets, - fade=( - FadeSpec( - fade_in=cfg.fade.fade_in, - fade_out=cfg.fade.fade_out, - color=cfg.fade.color, - audio=cfg.fade.audio, - ) - if cfg.fade.enabled - else None + reasoner=reasoner, ), ) + await _publish_film(plan, stage, clock, dump_timeline=dump_timeline) diff --git a/guidebot_recorder/recorder/render/_step.py b/guidebot_recorder/recorder/render/_step.py index b3a2ae9..5b679a0 100644 --- a/guidebot_recorder/recorder/render/_step.py +++ b/guidebot_recorder/recorder/render/_step.py @@ -1,20 +1,37 @@ -"""``_render_step``: replay exactly one flat step. Phase 3 decomposes it. +"""``_render_step``: replay exactly one flat step. -Deliberately one opaque module holding one function. ``_render_step`` is two -dispatches on two different keys — the scenario ``kind`` and the sidecar -``cached.action``, in a many-to-many relation — and phase 1 moved it verbatim -rather than guessing at a decomposition. Its cyclomatic complexity (49) is -unchanged and is phase 3's subject; the leading underscore in the module name says -the same thing. +**This is two dispatches on two different keys, and they are many-to-many.** + +* :func:`_replay_scenario_kind` dispatches on the scenario ``kind`` — + ``say``/``desktop``/``slide``/``closeWindow``/``navigate``/``wait``/``scroll``. + Every one of those is complete in itself and there is no sidecar action to run. +* :func:`_replay_action` dispatches on the sidecar's ``cached.action`` — + ``click``/``hover``/``type``/``select``/``highlight``/``waitFor`` — for the + steps the first dispatch did *not* answer. + +The relation between the two keys is not a function in either direction: a +``teach`` step freezes to any of the actions, and ``wait`` splits across both +depending on :meth:`Step.requires_target`. A single registry keyed on ``kind`` +would therefore be structurally wrong — each handler would have to repeat the +frozen-action guards that sit *between* the dispatches. So the shape here is two +short ``if`` chains of one-line delegations, with :class:`_Replay` carrying the +context so a handler takes one argument instead of eight. ``say``, which needs +nothing at all, stays a two-line ``return`` rather than an empty implementation of +a uniform protocol. + +**The second dispatch deliberately has no ``else``.** An unknown sidecar action +does nothing and raises nothing — today's behaviour, preserved verbatim through +this decomposition. It is a latent bug and it is tracked in the design's backlog; +fixing it is a behaviour change and belongs in its own commit with its own test. ``_render_step`` is a test seam: defined here, called through this module object -from :mod:`~guidebot_recorder.recorder.render._run`. +from :mod:`~guidebot_recorder.recorder.render.loop`. ``Overlay`` and ``Recorder`` are annotated through their module objects rather than name-imported. Both are seams patched elsewhere — ``Recorder`` on -:mod:`~guidebot_recorder.recorder.render.visuals` and ``_run``, ``Overlay`` on -``_run`` — and a bare name-import here would be an import-time copy no patch -reaches. +:mod:`~guidebot_recorder.recorder.render.visuals` and ``loop``, ``Overlay`` on +:mod:`~guidebot_recorder.recorder.render.stage` — and a bare name-import here +would be an import-time copy no patch reaches. """ from __future__ import annotations @@ -22,11 +39,12 @@ import asyncio import time from collections.abc import Awaitable, Callable, Iterable +from dataclasses import dataclass from playwright.async_api import ( Error as PlaywrightError, ) -from playwright.async_api import Page +from playwright.async_api import Frame, Page from playwright.async_api import ( TimeoutError as PlaywrightTimeoutError, ) @@ -53,6 +71,398 @@ from .visuals import _ensure_visuals +@dataclass(frozen=True, slots=True) +class _Replay: + """One step's replay context: everything the handlers below would otherwise take. + + Frozen, and built once at the top of :func:`_render_step` — including + :attr:`pages_before`, which must be sampled *before* anything touches the + page, or a popup that opened during the visual mount would look like one the + click opened. + """ + + page: Page + recorder: recorder_module.Recorder + overlay: overlay_module.Overlay + chrome: Chrome | None + scenario: Scenario + step: Step + kind: str + index: int + anchor: float + observed_pages: dict[Page, _PageObservation] + ensure_card: Callable[[Page], Awaitable[None]] + entry: FlatStep | None + total: int + sensitive: Iterable[str] + expect_chrome: bool + optional: bool + scenario_hash: str + pages_before: set[Page] + action_frame: Page | Frame + """Locators/navigation/reuse run against this: the site iframe for the main + window (a Frame distinct from the shell page), the page itself for popups / + chrome-disabled renders.""" + on_shell: bool + + def message(self, message: str) -> str: + """Komunikat kroku z `plik:linia` i fragmentem YAML; sekrety zredagowane.""" + + return step_banner( + index=self.index, + total=self.total, + location=self.entry.location if self.entry is not None else None, + source=self.scenario.source, + message=message, + sensitive=self.sensitive, + ) + + +@dataclass(slots=True) +class _ClickWatch: + """When the click really started, plus the guard that no popup preceded it. + + :meth:`before_click` is handed to ``Recorder.click`` as a **bound method**, so + it reads the live page set at the instant the pointer goes down rather than a + snapshot taken when this object was built. + """ + + ctx: _Replay + started_at: float | None = None + + def before_click(self) -> None: + if any(candidate not in self.ctx.pages_before for candidate in self.ctx.observed_pages): + raise RenderError(self.ctx.message("popup otworzył się przed akcją click")) + self.started_at = time.monotonic() + + +# --- dispatch A: the scenario `kind` ----------------------------------------- # + + +async def _replay_slide(ctx: _Replay) -> None: + assert ctx.step.slide is not None # guaranteed by command_kind() + if _narration(ctx.step) is not None: + # The loop already waited out the narration before calling us (one + # picture timeline); re-assert the card and force a captured frame. + await ctx.ensure_card(ctx.page) + await ctx.page.screenshot() + return + # No `say` on this slide: hold the card ourselves, SPA-safe — re-assert + # on a short cadence rather than a single blind sleep, so a same- + # document rewrite mid-hold is repaired (and a real navigation still + # fails loud via `ensure_card`'s token check). + deadline = time.monotonic() + ctx.step.slide.hold + while True: + await ctx.ensure_card(ctx.page) + remaining = deadline - time.monotonic() + if remaining <= 0: + return + await asyncio.sleep(min(0.1, remaining)) + + +async def _navigate_on_shell(ctx: _Replay, url: str, *, show_url: bool, mode: str) -> None: + """Main window: the pill lives in the shell. + + The choreography/typed animation runs before goto; the truthful site URL + (after redirects) is reflected once the iframe has loaded. + """ + + if show_url and mode in ("choreograph", "type"): + await ctx.chrome.type_url( + ctx.page, + ctx.overlay, + url, + seed=f"{url}:{ctx.index}", + choreograph=(mode == "choreograph"), + on_sfx=ctx.recorder.on_sfx, + ) + await ctx.recorder.navigate(url) + if show_url: + await ctx.chrome.set_url_shell(ctx.page, ctx.action_frame.url) + + +async def _navigate_in_page(ctx: _Replay, url: str, *, show_url: bool, mode: str) -> None: + """Popup / chrome-disabled: legacy in-DOM pill on the page itself. + + An instant update happens after goto so redirects are reflected; the animated + variant is typed before goto. A bare (floating) popup has no legacy bar/API + (chrome.js bailed on barePopups), so gate the pill on ``expect_chrome`` — + otherwise chrome.set_url would evaluate an undefined ``window.__guidebot_chrome`` + and throw an opaque TypeError. + """ + + if show_url and ctx.expect_chrome and mode != "instant": + await ctx.chrome.set_url(ctx.page, url, animate=True) + await ctx.recorder.navigate(url) + if show_url and ctx.expect_chrome and mode == "instant": + await ctx.chrome.set_url(ctx.page, ctx.page.url, animate=False) + + +async def _replay_navigate(ctx: _Replay) -> None: + source_url = ctx.step.navigate_url() + assert source_url is not None # guaranteed by command_kind() + url = _resolve_url(ctx.scenario, source_url) + chrome_cfg = ctx.scenario.config.chrome + show_url = ctx.chrome is not None and chrome_cfg.show_url + mode = navigate_pill_mode(chrome_cfg, ctx.step.navigate_type_override()) + if ctx.on_shell: + await _navigate_on_shell(ctx, url, show_url=show_url, mode=mode) + else: + await _navigate_in_page(ctx, url, show_url=show_url, mode=mode) + await _ensure_visuals(ctx.page, ctx.overlay, ctx.chrome, expect_chrome=ctx.expect_chrome) + + +async def _replay_scenario_kind(ctx: _Replay) -> bool: + """Steps the scenario ``kind`` answers on its own. True when the step is done. + + False means "this one is carried by the sidecar", and the second dispatch — + plus the frozen-action guards between them — takes over. + """ + + if ctx.kind == "say": + return True + if ctx.kind == "desktop": + # The opener's whole choreography (paint, cursor arc, double-click, window + # growth) already ran in the card block before narration; nothing is left + # to do in the action phase. Mirrors the visual-only `slide`/`say` returns. + return True + if ctx.kind == "slide": + await _replay_slide(ctx) + return True + if ctx.kind == "closeWindow": + # The loop's popup-lifecycle check sees the closed page next and runs + # `visuals._prepare_main_after_popup_close` with the saved cursor position. + # Do not duplicate that here: calling the funnel without + # `restore_cursor_to` leaves the main window's cursor at the popup's centre. + await ctx.page.close() + return True + if ctx.kind == "navigate": + await _replay_navigate(ctx) + return True + if ctx.kind == "wait" and not ctx.step.requires_target(): + await ctx.recorder.wait_seconds(float(ctx.step.wait)) + return True + if ctx.kind == "scroll": + await ctx.recorder.scroll(ctx.step.scroll_config()) + return True + return False + + +# --- between the dispatches: which frozen action is being replayed ------------ # + + +async def _identify_action( + ctx: _Replay, cached: CompiledAction | None, resolved: ResolvedTarget | None +) -> CachedAction: + if resolved is not None: + # Resolved in place a moment ago against this very frame: it is live by + # construction, and `expect` is only knowable after the action has run. + return _freeze_resolved(ctx.step, ctx.kind, resolved, "none", ctx.scenario_hash) + if cached is None: + raise RenderError(ctx.message("brak cachedAction — uruchom `compile`")) + if isinstance(cached, PendingAction): # pragma: no cover - prologue rejects these + raise RenderError(ctx.message("nierozwiązany wpis oczekujący — uruchom `compile`")) + if cached.action != "waitFor" and not await reuse_is_valid(ctx.action_frame, cached): + raise RenderError(ctx.message("niezgodna tożsamość — uruchom `compile --force`")) + return cached + + +async def _frozen_action( + ctx: _Replay, cached: CompiledAction | None, resolved: ResolvedTarget | None +) -> CachedAction: + """The action to replay — freshly resolved or frozen — once it has been vetted.""" + + action = await _identify_action(ctx, cached, resolved) + if action.opens_popup and action.action != "click": + raise RenderError(ctx.message("tylko click może otworzyć popup")) + return action + + +# --- dispatch B: the sidecar `cached.action` ---------------------------------- # + + +async def _await_popup_page(ctx: _Replay, click_started_at: float) -> Page: + """The one page the click opened, inside the actual-click discovery window.""" + + popup_pages = await _wait_for_render_popup( + ctx.observed_pages, + ctx.pages_before, + click_started_at, + ) + if not popup_pages: + raise RenderError( + ctx.message("oczekiwany popup nie otworzył się — uruchom `compile --force`") + ) + if len(popup_pages) != 1: + raise RenderError("v1 obsługuje dokładnie jeden popup w sesji") + popup_page = popup_pages[0] + if await popup_page.opener() is not ctx.page: + raise RenderError("nowa strona nie jest popupem aktywnego okna") + return popup_page + + +async def _adopt_popup(ctx: _Replay, popup_page: Page) -> _PopupSession: + """Turn the freshly opened page into the session record post-production reads.""" + + observation = ctx.observed_pages.get(popup_page) + if observation is None: # defensive fallback; context event is the primary path + observation = _PageObservation( + opened_at=time.monotonic(), + video=popup_page.video, + closed_at=time.monotonic() if popup_page.is_closed() else None, + ) + ctx.observed_pages[popup_page] = observation + visual_ready_at = ( + await observation.visual_prime if observation.visual_prime is not None else None + ) + popup_video = observation.video or popup_page.video + if popup_video is None: # pragma: no cover - context recording is enabled + raise RenderError("Playwright nie udostępnił nagrania popupu") + opened_at = max(0.0, observation.opened_at - ctx.anchor) + closed_at = ( + max(opened_at, observation.closed_at - ctx.anchor) + if observation.closed_at is not None + else None + ) + return _PopupSession( + page=popup_page, + video=popup_video, + opened_at=opened_at, + visual_ready_delay=( + max(0.0, visual_ready_at - observation.opened_at) + if visual_ready_at is not None + else 0.0 + ), + closed_at=closed_at, + # Read from the opener while it is still alive. + window_size=await _popup_window_request(ctx.page), + # Available the instant the popup opens, and authoritative: a popup opened + # with size features reports that size, a featureless one reports the + # context viewport it inherited (which is exactly the case levels 2 and 3 + # exist for). + viewport=_page_viewport(popup_page), + # Level 2, started (not awaited) now because by composition time the popup + # page is closed. Only the *painted* content can be measured: a featureless + # popup's layout viewport is the context's, so innerWidth/outerWidth/ + # clientWidth all restate the oversized number instead of the real window. + # Awaiting it here would spend its latency on camera; it is reaped before + # the context closes instead. + content_box_probe=_start_popup_content_box(popup_page), + ) + + +async def _replay_click(ctx: _Replay, cached: CachedAction) -> _PopupSession | None: + watch = _ClickWatch(ctx) + await ctx.recorder.click(cached.target, before_click=watch.before_click) + if not cached.opens_popup: + return None + if watch.started_at is None: # pragma: no cover - Recorder invariant + raise RenderError("wewnętrzny błąd obserwacji akcji click") + popup_page = await _await_popup_page(ctx, watch.started_at) + return await _adopt_popup(ctx, popup_page) + + +async def _replay_type(ctx: _Replay, cached: CachedAction) -> None: + input_text = ctx.step.enter_text.text if ctx.step.enter_text is not None else cached.input_text + if input_text is None: + raise RenderError(ctx.message("brak zamrożonego tekstu — uruchom `compile`")) + await ctx.recorder.enter_text(cached.target, input_text) + + +async def _replay_select(ctx: _Replay, cached: CachedAction) -> None: + if ctx.step.select is None: + raise RenderError(ctx.message("brak opcji dla akcji select — uruchom `compile`")) + try: + await ctx.recorder.select( + cached.target, + ctx.step.select.option, + native=select_mode(ctx.step, ctx.scenario.config) == "native", + ) + except (SelectDriveError, SelectsNotReadyError) as exc: + # No silent fallback to ``select_option``: that would restore exactly + # the invisible magic this feature removes, and unobservably. The + # banner is what makes the loud failure legible — it names the line + # of the scenario the author has to edit, not just the widget. + raise RenderError(ctx.message(str(exc))) from exc + + +async def _replay_highlight(ctx: _Replay, cached: CachedAction) -> None: + if ctx.step.highlight is None: + raise RenderError( + ctx.message( + "sidecar mówi `highlight`, a krok scenariusza nim nie jest " + "— uruchom `compile --force`" + ) + ) + await ctx.recorder.highlight( + cached.target, ctx.step.highlight.resolved(ctx.scenario.config.highlight) + ) + + +async def _replay_wait_for(ctx: _Replay, cached: CachedAction) -> None: + timeout = ctx.step.wait.timeout if isinstance(ctx.step.wait, WaitUntil) else 10.0 + try: + await ctx.recorder.wait_for(cached.target, cached.state or "visible", timeout) + except PlaywrightTimeoutError as exc: + # The one absence signal a frozen gate can give: its wait window + # elapsed. On a required step the timeout still fails the render. + if not ctx.optional: + raise + raise _OptionalAbsent(f"upłynął czas oczekiwania ({timeout}s)") from exc + + +async def _replay_action(ctx: _Replay, cached: CachedAction) -> _PopupSession | None: + """Replay the sidecar's frozen action. Returns the popup a click opened, if any. + + No ``else``: an unknown action does nothing and raises nothing. That is + today's behaviour and it is preserved deliberately — see the module docstring. + """ + + if cached.action == "click": + return await _replay_click(ctx, cached) + if cached.action == "hover": + await ctx.recorder.hover(cached.target) + elif cached.action == "type": + await _replay_type(ctx, cached) + elif cached.action == "select": + await _replay_select(ctx, cached) + elif cached.action == "highlight": + await _replay_highlight(ctx, cached) + elif cached.action == "waitFor": + await _replay_wait_for(ctx, cached) + return None + + +# --- after the action --------------------------------------------------------- # + + +async def _finish_step( + ctx: _Replay, + cached: CachedAction, + resolved: ResolvedTarget | None, + url_before: str, + on_resolved: Callable[[int, CachedAction], None] | None, +) -> None: + """Derive ``expect``, hand a fresh resolution back, then wait for readiness.""" + + expect = cached.expect + if resolved is not None: + # Mirror compile: the action reveals whether it navigated, and only then + # is the entry complete enough to replace the pending one on disk. + url_after = ctx.action_frame.url if not ctx.page.is_closed() else url_before + expect = heuristic_expect(url_before, url_after) + cached = _freeze_resolved(ctx.step, ctx.kind, resolved, expect, ctx.scenario_hash) + if on_resolved is not None: + on_resolved(ctx.index, cached) + + if not ctx.page.is_closed(): + try: + await ctx.recorder.apply_readiness(expect) + except PlaywrightError: + if not ctx.page.is_closed(): + raise + + async def _render_step( page: Page, recorder: recorder_module.Recorder, @@ -89,257 +499,39 @@ async def _render_step( nietknięte, a bez nich banner degraduje się do samego numeru kroku. """ - def step_message(message: str) -> str: - """Komunikat kroku z `plik:linia` i fragmentem YAML; sekrety zredagowane.""" - - return step_banner( - index=index, - total=total, - location=entry.location if entry is not None else None, - source=scenario.source, - message=message, - sensitive=sensitive, - ) - - if expect_chrome is None: - expect_chrome = chrome is not None - pages_before_prepare = set(observed_pages) + action_frame = getattr(recorder, "frame", recorder.page) + ctx = _Replay( + page=page, + recorder=recorder, + overlay=overlay, + chrome=chrome, + scenario=scenario, + step=step, + kind=kind, + index=index, + anchor=anchor, + observed_pages=observed_pages, + ensure_card=ensure_card, + entry=entry, + total=total, + sensitive=sensitive, + expect_chrome=(chrome is not None) if expect_chrome is None else expect_chrome, + optional=optional, + scenario_hash=scenario_hash, + pages_before=set(observed_pages), + action_frame=action_frame, + on_shell=action_frame is not recorder.page, + ) # Both visual layers can be removed by an SPA without a navigation. Check # them before every recorded step, including narration-only and timed waits. # ``expect_chrome`` is False when ``page`` is a bare (floating) popup. - await _ensure_visuals(page, overlay, chrome, expect_chrome=expect_chrome) - - # Locators/navigation/reuse run against the recorder's frame: the site iframe - # for the main window (a Frame distinct from the shell page), the page itself - # for popups / chrome-disabled renders. - action_frame = getattr(recorder, "frame", recorder.page) - on_shell = action_frame is not recorder.page + await _ensure_visuals(page, overlay, chrome, expect_chrome=ctx.expect_chrome) - if kind == "say": + if await _replay_scenario_kind(ctx): return None - if kind == "desktop": - # The opener's whole choreography (paint, cursor arc, double-click, window - # growth) already ran in the card block before narration; nothing is left - # to do in the action phase. Mirrors the visual-only `slide`/`say` returns. - return None - if kind == "slide": - assert step.slide is not None # guaranteed by command_kind() - if _narration(step) is not None: - # The loop already waited out the narration before calling us (one - # picture timeline); re-assert the card and force a captured frame. - await ensure_card(page) - await page.screenshot() - return None - # No `say` on this slide: hold the card ourselves, SPA-safe — re-assert - # on a short cadence rather than a single blind sleep, so a same- - # document rewrite mid-hold is repaired (and a real navigation still - # fails loud via `ensure_card`'s token check). - deadline = time.monotonic() + step.slide.hold - while True: - await ensure_card(page) - remaining = deadline - time.monotonic() - if remaining <= 0: - return None - await asyncio.sleep(min(0.1, remaining)) - if kind == "closeWindow": - # The loop's popup-lifecycle check sees the closed page next and runs - # `visuals._prepare_main_after_popup_close` with the saved cursor position. - # Do not - # duplicate that here: calling the funnel without `restore_cursor_to` - # leaves the main window's cursor at the popup's centre. - await page.close() - return None - if kind == "navigate": - source_url = step.navigate_url() - assert source_url is not None # guaranteed by command_kind() - url = _resolve_url(scenario, source_url) - chrome_cfg = scenario.config.chrome - show_url = chrome is not None and chrome_cfg.show_url - mode = navigate_pill_mode(chrome_cfg, step.navigate_type_override()) - - if on_shell: - # Main window: the pill lives in the shell. The choreography/typed - # animation runs before goto; the truthful site URL (after redirects) - # is reflected once the iframe has loaded. - if show_url and mode in ("choreograph", "type"): - await chrome.type_url( - page, - overlay, - url, - seed=f"{url}:{index}", - choreograph=(mode == "choreograph"), - on_sfx=recorder.on_sfx, - ) - await recorder.navigate(url) - if show_url: - await chrome.set_url_shell(page, action_frame.url) - else: - # Popup / chrome-disabled: legacy in-DOM pill on the page itself. An - # instant update happens after goto so redirects are reflected; the - # animated variant is typed before goto. A bare (floating) popup has - # no legacy bar/API (chrome.js bailed on barePopups), so gate the pill - # on ``expect_chrome`` — otherwise chrome.set_url would evaluate an - # undefined ``window.__guidebot_chrome`` and throw an opaque TypeError. - if show_url and expect_chrome and mode != "instant": - await chrome.set_url(page, url, animate=True) - await recorder.navigate(url) - if show_url and expect_chrome and mode == "instant": - await chrome.set_url(page, page.url, animate=False) - await _ensure_visuals(page, overlay, chrome, expect_chrome=expect_chrome) - return None - if kind == "wait" and not step.requires_target(): - await recorder.wait_seconds(float(step.wait)) - return None - if kind == "scroll": - await recorder.scroll(step.scroll_config()) - return None - - url_before = action_frame.url - if resolved is not None: - # Resolved in place a moment ago against this very frame: it is live by - # construction, and `expect` is only knowable after the action has run. - cached = _freeze_resolved(step, kind, resolved, "none", scenario_hash) - elif cached is None: - raise RenderError(step_message("brak cachedAction — uruchom `compile`")) - elif isinstance(cached, PendingAction): # pragma: no cover - prologue rejects these - raise RenderError(step_message("nierozwiązany wpis oczekujący — uruchom `compile`")) - elif cached.action != "waitFor" and not await reuse_is_valid(action_frame, cached): - raise RenderError(step_message("niezgodna tożsamość — uruchom `compile --force`")) - assert isinstance(cached, CachedAction) - if cached.opens_popup and cached.action != "click": - raise RenderError(step_message("tylko click może otworzyć popup")) - - opened: _PopupSession | None = None - if cached.action == "click": - click_started_at: float | None = None - - def mark_click_started() -> None: - nonlocal click_started_at - if any(candidate not in pages_before_prepare for candidate in observed_pages): - raise RenderError(step_message("popup otworzył się przed akcją click")) - click_started_at = time.monotonic() - - await recorder.click(cached.target, before_click=mark_click_started) - if cached.opens_popup: - if click_started_at is None: # pragma: no cover - Recorder invariant - raise RenderError("wewnętrzny błąd obserwacji akcji click") - popup_pages = await _wait_for_render_popup( - observed_pages, - pages_before_prepare, - click_started_at, - ) - if not popup_pages: - raise RenderError( - step_message("oczekiwany popup nie otworzył się — uruchom `compile --force`") - ) - if len(popup_pages) != 1: - raise RenderError("v1 obsługuje dokładnie jeden popup w sesji") - popup_page = popup_pages[0] - if await popup_page.opener() is not page: - raise RenderError("nowa strona nie jest popupem aktywnego okna") - - observation = observed_pages.get(popup_page) - if observation is None: # defensive fallback; context event is the primary path - observation = _PageObservation( - opened_at=time.monotonic(), - video=popup_page.video, - closed_at=time.monotonic() if popup_page.is_closed() else None, - ) - observed_pages[popup_page] = observation - visual_ready_at = ( - await observation.visual_prime if observation.visual_prime is not None else None - ) - popup_video = observation.video or popup_page.video - if popup_video is None: # pragma: no cover - context recording is enabled - raise RenderError("Playwright nie udostępnił nagrania popupu") - opened_at = max(0.0, observation.opened_at - anchor) - closed_at = ( - max(opened_at, observation.closed_at - anchor) - if observation.closed_at is not None - else None - ) - opened = _PopupSession( - page=popup_page, - video=popup_video, - opened_at=opened_at, - visual_ready_delay=( - max(0.0, visual_ready_at - observation.opened_at) - if visual_ready_at is not None - else 0.0 - ), - closed_at=closed_at, - # Read from the opener while it is still alive. - window_size=await _popup_window_request(page), - # Available the instant the popup opens, and authoritative: a - # popup opened with size features reports that size, a featureless - # one reports the context viewport it inherited (which is exactly - # the case levels 2 and 3 exist for). - viewport=_page_viewport(popup_page), - # Level 2, started (not awaited) now because by composition time - # the popup page is closed. Only the *painted* content can be - # measured: a featureless popup's layout viewport is the - # context's, so innerWidth/outerWidth/clientWidth all restate the - # oversized number instead of the real window. Awaiting it here - # would spend its latency on camera; it is reaped before the - # context closes instead. - content_box_probe=_start_popup_content_box(popup_page), - ) - elif cached.action == "hover": - await recorder.hover(cached.target) - elif cached.action == "type": - input_text = step.enter_text.text if step.enter_text is not None else cached.input_text - if input_text is None: - raise RenderError(step_message("brak zamrożonego tekstu — uruchom `compile`")) - await recorder.enter_text(cached.target, input_text) - elif cached.action == "select": - if step.select is None: - raise RenderError(step_message("brak opcji dla akcji select — uruchom `compile`")) - try: - await recorder.select( - cached.target, - step.select.option, - native=select_mode(step, scenario.config) == "native", - ) - except (SelectDriveError, SelectsNotReadyError) as exc: - # No silent fallback to ``select_option``: that would restore exactly - # the invisible magic this feature removes, and unobservably. The - # banner is what makes the loud failure legible — it names the line - # of the scenario the author has to edit, not just the widget. - raise RenderError(step_message(str(exc))) from exc - elif cached.action == "highlight": - if step.highlight is None: - raise RenderError( - step_message( - "sidecar mówi `highlight`, a krok scenariusza nim nie jest " - "— uruchom `compile --force`" - ) - ) - await recorder.highlight(cached.target, step.highlight.resolved(scenario.config.highlight)) - elif cached.action == "waitFor": - timeout = step.wait.timeout if isinstance(step.wait, WaitUntil) else 10.0 - try: - await recorder.wait_for(cached.target, cached.state or "visible", timeout) - except PlaywrightTimeoutError as exc: - # The one absence signal a frozen gate can give: its wait window - # elapsed. On a required step the timeout still fails the render. - if not optional: - raise - raise _OptionalAbsent(f"upłynął czas oczekiwania ({timeout}s)") from exc - expect = cached.expect - if resolved is not None: - # Mirror compile: the action reveals whether it navigated, and only then - # is the entry complete enough to replace the pending one on disk. - url_after = action_frame.url if not page.is_closed() else url_before - expect = heuristic_expect(url_before, url_after) - cached = _freeze_resolved(step, kind, resolved, expect, scenario_hash) - if on_resolved is not None: - on_resolved(index, cached) - - if not page.is_closed(): - try: - await recorder.apply_readiness(expect) - except PlaywrightError: - if not page.is_closed(): - raise + url_before = ctx.action_frame.url + frozen = await _frozen_action(ctx, cached, resolved) + opened = await _replay_action(ctx, frozen) + await _finish_step(ctx, frozen, resolved, url_before, on_resolved) return opened diff --git a/guidebot_recorder/recorder/render/audio.py b/guidebot_recorder/recorder/render/audio.py index d03c952..e503190 100644 --- a/guidebot_recorder/recorder/render/audio.py +++ b/guidebot_recorder/recorder/render/audio.py @@ -41,6 +41,57 @@ _AUDIO_BED_CONCURRENCY = max(1, min(4, os.cpu_count() or 1)) +def _assert_narration_fits( + configs: list[TtsConfig], + placed_by_language: dict[str, list[Placed]], + total: float, +) -> None: + """No track may run past the picture. Checked before a single bed is built.""" + + for tts in configs: + for placement in placed_by_language[tts.lang]: + if placement.offset + placement.segment.duration > total: + raise RenderError( + f"narracja {tts.lang} wykracza poza nagranie wideo — render przerwany" + ) + + +async def _gather_tracks(tasks: list[asyncio.Task[MuxAudioTrack]]) -> list[MuxAudioTrack]: + """Collect every bed, draining the workers if the caller is cancelled. + + Moved out of :func:`_mux_tracks_for_timeline` verbatim. Cancelling an asyncio + wrapper cannot stop a running thread or its ffmpeg child, and the staging + ``TemporaryDirectory`` is unwound by the caller the moment this returns — so + the shield/drain below is what keeps ffmpeg from writing into a deleted path. + Do not tidy it. + """ + + gathered = asyncio.gather(*tasks, return_exceptions=True) + try: + results = await asyncio.shield(gathered) + except asyncio.CancelledError: + # Do not start queued ffmpeg work after cancellation, but let workers + # already inside to_thread finish before TemporaryDirectory can unwind. + for task in tasks: + task.cancel() + while not gathered.done(): + try: + await asyncio.shield(gathered) + except asyncio.CancelledError: + continue + if not gathered.cancelled(): + gathered.result() + raise + tracks: list[MuxAudioTrack] = [] + # gather preserves config order. It also waits for all ffmpeg workers before + # an error leaves the staging directory, avoiding writes into deleted paths. + for result in results: + if isinstance(result, BaseException): + raise result + tracks.append(result) + return tracks + + async def _mux_tracks_for_timeline( configs: list[TtsConfig], placed_by_language: dict[str, list[Placed]], @@ -56,12 +107,7 @@ async def _mux_tracks_for_timeline( naming ``_publish_render_artifacts`` relies on. """ - for tts in configs: - for placement in placed_by_language[tts.lang]: - if placement.offset + placement.segment.duration > total: - raise RenderError( - f"narracja {tts.lang} wykracza poza nagranie wideo — render przerwany" - ) + _assert_narration_fits(configs, placed_by_language, total) semaphore = asyncio.Semaphore(_AUDIO_BED_CONCURRENCY) @@ -102,30 +148,7 @@ async def build_bounded(index: int, tts: TtsConfig) -> MuxAudioTrack: raise tasks = [asyncio.create_task(build_bounded(index, tts)) for index, tts in enumerate(configs)] - gathered = asyncio.gather(*tasks, return_exceptions=True) - try: - results = await asyncio.shield(gathered) - except asyncio.CancelledError: - # Do not start queued ffmpeg work after cancellation, but let workers - # already inside to_thread finish before TemporaryDirectory can unwind. - for task in tasks: - task.cancel() - while not gathered.done(): - try: - await asyncio.shield(gathered) - except asyncio.CancelledError: - continue - if not gathered.cancelled(): - gathered.result() - raise - tracks: list[MuxAudioTrack] = [] - # gather preserves config order. It also waits for all ffmpeg workers before - # an error leaves the staging directory, avoiding writes into deleted paths. - for result in results: - if isinstance(result, BaseException): - raise result - tracks.append(result) - return tracks + return await _gather_tracks(tasks) def _publish_render_artifacts( diff --git a/guidebot_recorder/recorder/render/clock.py b/guidebot_recorder/recorder/render/clock.py new file mode 100644 index 0000000..579af73 --- /dev/null +++ b/guidebot_recorder/recorder/render/clock.py @@ -0,0 +1,134 @@ +"""The recording axis: freezes, and everything placed against them. + +The third of the three lifetimes ``run_render`` used to interleave. A +:class:`_Clock` starts when the first frame is captured and collects three things, +all in recording-axis FRAMES rather than seconds — the grid is what +:class:`~guidebot_recorder.video.timeline.Timeline` reasons on, and quantising once +at the moment of observation is what lets :func:`_stamp_frame` keep placements +monotonic against the freezes. Seconds reappear only at the very end, when the +audio bed is built. + +**Why ``last_freeze_frame`` is a field and ``note_sfx`` is a bound method.** + +``on_sfx`` is handed to :class:`~guidebot_recorder.recorder.recorder.Recorder` and +fires one call frame *down*, inside ``_render_step``, at whatever instant the +click or keystroke happens. It has to read ``last_freeze_frame`` **as of that +instant** — a freeze emitted by the narration of the same step is exactly what it +must be clamped past. ``run_render`` used to get that from a closure over a local; +passing the value into a step function instead would break it **silently**: every +length check still passes (``probe_frame_count == virtual_frames``, the mux +duration guard, the per-track overrun check), because a collapsed placement does +not change how long the film is. Only the *position* of sounds and voice-overs +moves. + +A bound method is that closure, by construction: ``clock.note_sfx`` re-reads +``self`` at call time, so there is no value to pass and nothing to get stale. +Three tests assert the placements this protects — +``test_hold_frame_narrations_never_overlap``, +``test_sfx_after_a_freeze_never_lands_inside_the_hold`` (``test_render.py``) and +``test_hold_frame_narrations_inside_taken_branch_never_overlap`` +(``test_render_optional.py``) — and their docstrings say why every other guard in +the suite stays green while offsets collapse. + +``_pace_narration`` is a test seam and is called through the ``narration`` module +object, so a patch on its defining module reaches this call. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from guidebot_recorder.models.config import Config, SoundConfig, TtsConfig +from guidebot_recorder.tts.base import Segment +from guidebot_recorder.video.timeline import TimeEdit + +from . import narration +from .errors import RenderError +from .narration import _stamp_frame + + +@dataclass +class _Clock: + """The recording axis and everything placed on it. One reader, one writer.""" + + anchor: float + placed_by_language: dict[str, list[tuple[Segment, int]]] + sfx_events: list[tuple[str, int]] = field(default_factory=list) + time_edits: list[TimeEdit] = field(default_factory=list) + """Freezes recorded while rendering, on the *recording* axis. Applied to the + video — and used to remap every audio offset — once the loop is done.""" + last_freeze_frame: int = -1 + """Frame of the most recent freeze, or -1 before any. + + Read by :meth:`stamp` so nothing is ever stamped inside a hold that was + already recorded. This class is its only reader and its only writer, which is + what makes the monotonicity a property of the type rather than of a comment. + """ + + @classmethod + def started(cls, anchor: float, audio_configs: list[TtsConfig]) -> _Clock: + return cls(anchor=anchor, placed_by_language={tts.lang: [] for tts in audio_configs}) + + def stamp(self) -> int: + """"Now", as a recording frame, never inside a freeze already emitted.""" + + return _stamp_frame(self.anchor, not_before=self.last_freeze_frame + 1) + + def note_sfx(self, kind: str) -> None: + """``Recorder(on_sfx=...)``. A bound method on purpose — see the module docstring.""" + + self.sfx_events.append((kind, self.stamp())) + + def place_narration( + self, index: int, audio_configs: list[TtsConfig], segments: dict[str, dict[int, Segment]] + ) -> tuple[list[Segment], int]: + """Place step *index*'s narration on every track, at one shared frame. + + Returns the segments that were placed (empty when this step is silent) and + the frame they were placed at — the caller passes it back as + :meth:`pace`'s ``not_before``. The frame is taken once, before the tracks + are visited, so every language starts at the same instant. + """ + + frame = self.stamp() + placed: list[Segment] = [] + for tts in audio_configs: + segment = segments[tts.lang].get(index) + if segment is not None: + self.placed_by_language[tts.lang].append((segment, frame)) + placed.append(segment) + return placed, frame + + async def pace(self, segments: list[Segment], cfg: Config, *, not_before: int) -> None: + """Spend the step's voice-over, recording the freeze it may have emitted. + + One picture timeline: the action waits for the longest language, while + shorter tracks naturally contain silence before the action. + """ + + emitted = await narration._pace_narration( + segments, + anchor=self.anchor, + hold_frame=cfg.hold_frame_for_narration, + settle=cfg.hold_frame_settle, + edits=self.time_edits, + not_before=not_before, + ) + if emitted is not None: + self.last_freeze_frame = emitted + + def sfx_frames(self, sound: SoundConfig) -> list[tuple[str, int]]: + """The SFX the configuration actually wants, on the recording axis.""" + + if not sound.enabled: + return [] + frames: list[tuple[str, int]] = [] + for kind, frame in self.sfx_events: + if kind == "click" and not sound.click: + continue + if kind == "key" and not sound.keys: + continue + if frame < 0: + raise RenderError(f"ujemna klatka SFX ({frame}) — błąd zegara renderu") + frames.append((kind, frame)) + return frames diff --git a/guidebot_recorder/recorder/render/loop.py b/guidebot_recorder/recorder/render/loop.py new file mode 100644 index 0000000..87ca2c3 --- /dev/null +++ b/guidebot_recorder/recorder/render/loop.py @@ -0,0 +1,506 @@ +"""The render loop: one flat step at a time, against the plan, the stage and the clock. + +This is where the three lifetimes meet, so nothing new is stored here — every +phase below reads :class:`~guidebot_recorder.recorder.render.plan._RenderPlan` +(what was decided), mutates +:class:`~guidebot_recorder.recorder.render.stage._Stage` (what is on screen) and +:class:`~guidebot_recorder.recorder.render.clock._Clock` (where things land on the +recording axis). :class:`_StepCtx` bundles the three plus the step under work, so +the phases take one argument instead of nine. + +**The absence probe runs before the narration, and that is now a seam rather than +a comment.** An optional step that turns out to be absent must not narrate first +and only then do nothing, so :func:`_probe_absence` — "does this step happen at +all" — is a separate function from :func:`_narrate` and :func:`_perform` — "make +it happen". Everything decidable without acting (a stale frozen target, an +unresolvable pending entry) is decided in the first; a cached gate is the one +exception, because its ``waitFor`` *is* the action, so it stays inside +``_step._render_step`` and comes back out as ``_OptionalAbsent``. Both absences +land in :func:`_note_absent`. + +``Recorder`` is a test seam: name-imported here because this module constructs +one, so a patch on *this* module is what has to reach it. The same class is also +constructed in :mod:`~guidebot_recorder.recorder.render.visuals`, so replacing it +takes **two** patch lines. ``_render_step`` and ``_prepare_main_after_popup_close`` +are seams called through their module objects. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from functools import partial +from typing import NoReturn + +from playwright.async_api import Frame, Page +from tqdm import tqdm + +from guidebot_recorder.models.action import CachedAction, PendingAction +from guidebot_recorder.models.compiled import CompiledAction +from guidebot_recorder.models.scenario import FlatStep, Step +from guidebot_recorder.recorder._debug import pause_for_inspection, redact_exception +from guidebot_recorder.recorder.recorder import Recorder +from guidebot_recorder.resolver.reasoner import Reasoner +from guidebot_recorder.resolver.resolution import ResolvedTarget +from guidebot_recorder.resolver.validate import reuse_is_valid +from guidebot_recorder.selects import SelectsNotReadyError + +from . import _step, visuals +from .clock import _Clock +from .errors import RenderError, _OptionalAbsent +from .plan import _RenderPlan +from .popup_detect import _popup_window_opened +from .popup_session import _PopupSession, _prepare_popup +from .reuse import _resolve_pending_target +from .stage import _close_stage, _Stage +from .visuals import _hand_cursor_to_popup, _play_desktop_opener + +#: Force a bounded final frame after narration/action completion. Without this +#: post-roll, a static last page can leave the VFR recording a fraction shorter +#: than the audio timeline and make the final syllable trimmable. +_VIDEO_POSTROLL_SECONDS = 0.1 + + +@dataclass(frozen=True, slots=True) +class _LoopOptions: + """The caller's knobs, unchanged for the whole run.""" + + timeout: float + pause_on_error: bool + verbose: bool + reasoner: Reasoner | None + + +@dataclass(frozen=True, slots=True) +class _StepCtx: + """One flat step and the three objects every phase of it needs.""" + + plan: _RenderPlan + stage: _Stage + clock: _Clock + opts: _LoopOptions + entry: FlatStep + index: int + step: Step + kind: str + cached: CompiledAction | None + optional: bool + """Whether absence is tolerated here. + + The only two places it is: a branch gate and an ``optional: true`` step. A + *child* of an entered branch is NOT optional — the branch demonstrably + happened, so anything failing inside it is a real regression (§5 of the + design). Its pending entry is still resolved; only the verdict on absence + differs. + """ + + @classmethod + def of( + cls, + plan: _RenderPlan, + stage: _Stage, + clock: _Clock, + opts: _LoopOptions, + entry: FlatStep, + index: int, + ) -> _StepCtx: + return cls( + plan=plan, + stage=stage, + clock=clock, + opts=opts, + entry=entry, + index=index, + step=entry.step, + kind=entry.step.command_kind(), + cached=plan.compiled.actions[index], + optional=entry.is_gate or entry.step.optional, + ) + + def message(self, text: str) -> str: + return self.plan.step_message(self.entry, self.index, text) + + +def _note_absent(ctx: _StepCtx, absent: _OptionalAbsent) -> int | None: + """Record a tolerated absence; return the branch whose children to skip. + + Both absence signals end here — the one the probe raises before the step + narrates, and the one a cached gate's timed-out ``waitFor`` raises from inside + ``_render_step``. + """ + + ctx.plan.note_skip(ctx.entry, ctx.index, str(absent), gate=ctx.entry.is_gate) + return ctx.entry.branch if ctx.entry.is_gate else None + + +async def _fail_step(ctx: _StepCtx, exc: BaseException) -> NoReturn: + """The one funnel for "this step failed": redact, report, optionally pause. + + Everything the resolver rejects for a reason other than absence — + ``multiple_actions`` above all — is an authoring bug and fails the render, + exactly like a failure of the action itself. + """ + + safe_message = redact_exception(exc, ctx.plan.sensitive_values) + if ctx.opts.verbose: + tqdm.write(f" ✗ {type(exc).__name__}: {safe_message}") + if ctx.opts.pause_on_error: + await pause_for_inspection( + ctx.stage.active_page, + "render", + ctx.index, + ctx.kind, + exc, + ctx.plan.sensitive_values, + total=ctx.plan.total, + location=ctx.entry.location, + source=ctx.plan.scenario.source, + ) + raise RenderError(f"{type(exc).__name__}: {safe_message}") from None + + +def _assert_pages_intact(ctx: _StepCtx, closed_message: str) -> None: + """The deterministic page contract, re-checked before the step begins.""" + + ctx.stage.sync_popup_close() + if ctx.stage.popup_closed_unhandled(): + raise RenderError(closed_message) + if ctx.stage.unexpected_pages(): + raise RenderError(ctx.message("nieoczekiwany popup — uruchom `compile --force`")) + + +async def _prepare_visuals(ctx: _StepCtx) -> Page: + """Card-aware visual prep, ahead of the narration block. + + A ``slide`` step paints (replacing any prior card); a ``say`` step keeps a + live card up while it narrates; any other step dismisses the card first + (asserting it survived, fail-loud) before its normal ``_ensure_visuals``. With + no card ever painted this is exactly today's unconditional ``_ensure_visuals`` + call (back-compat). + """ + + stage = ctx.stage + step = ctx.step + active_page = stage.active_page + await active_page.bring_to_front() + if ctx.kind == "desktop": + assert step.desktop is not None # guaranteed by command_kind() + if stage.card is not None: + await stage.hide_card(active_page) + await _play_desktop_opener( + stage.desktop, + stage.overlay, + active_page, + ctx.plan.desktop_payloads[ctx.index], + hold=step.desktop.hold, + settle_ms=ctx.plan.cfg.cursor.settle, + reveal=partial(stage.chrome_show, active_page), + on_click=(ctx.clock.note_sfx if ctx.plan.cfg.sound.enabled else None), + ) + # The opener ends on the revealed chrome shell — normal visible state, so + # from here it is exactly the no-card path (`stage.card` stays None). + elif ctx.kind == "slide": + assert step.slide is not None # guaranteed by command_kind() + if stage.card is not None: + # Fail loud before repainting: a slide following a say whose card was + # destroyed mid-narration must NOT silently swap in a fresh card over + # the wrong page (`reveal_page` asserts the token, exactly like the + # generic dismiss branch below). + await stage.reveal_page(active_page) + await stage.show_card( + active_page, + { + "title": step.slide.title, + "subtitle": step.slide.subtitle, + "notes": step.slide.notes, + }, + ) + elif ctx.kind == "say" and stage.card is not None: + await stage.ensure_card(active_page) + elif stage.card is not None: + await stage.reveal_page(active_page) + await stage.ensure_visuals(active_page, expect_chrome=stage.expect_chrome) + else: + await stage.ensure_visuals(active_page, expect_chrome=stage.expect_chrome) + return active_page + + +async def _wait_selects_ready(ctx: _StepCtx, probe_root: Page | Frame) -> None: + """Readiness barrier for the DOM select shim, the mirror of compile's. + + Both the in-place resolution below and the frozen-target check inside + ``_render_step`` must see the shimmed DOM, or render would drive a page + compile never resolved against. Any navigation that led here has settled — it + was an earlier step. + """ + + if ctx.stage.selects is None or not ctx.step.requires_target(): + return + try: + await ctx.stage.selects.wait_ready(probe_root) + except SelectsNotReadyError as exc: + # The barrier sits outside every per-step ``except`` in the loop, so + # without this the one failure that stops a render before its step even + # begins would be the only one to reach the author with no file, no line + # and no YAML fragment. + raise RenderError(ctx.message(str(exc))) from exc + + +async def _resolve_in_place(ctx: _StepCtx, probe_root: Page | Frame) -> ResolvedTarget | None: + """Resolve a pending entry, or re-verify a frozen one. Raises on absence.""" + + cached = ctx.cached + if isinstance(cached, PendingAction): + if ctx.opts.reasoner is None: + raise _OptionalAbsent( + "brak dostępnego reasonera, a krok nie został skompilowany " + "(pending) — zainstaluj `codex`, aby rozwiązać go na miejscu" + ) + return await _resolve_pending_target(probe_root, ctx.step, ctx.kind, ctx.opts.reasoner) + if isinstance(cached, CachedAction) and cached.action != "waitFor": + if not await reuse_is_valid(probe_root, cached): + raise _OptionalAbsent("zamrożony namiar nie pasuje do strony") + return None + + +async def _probe_absence(ctx: _StepCtx, active_page: Page) -> ResolvedTarget | None: + """Does this step happen at all? Decided **before** a single word is narrated. + + Raises :class:`_OptionalAbsent` when the answer is "no" and that is tolerated, + :class:`RenderError` when it is not. A cached gate is the one case that cannot + be answered here — its ``waitFor`` IS the action — so it stays in + ``_step._render_step``; a synthetic gate step never narrates anyway. + """ + + stage = ctx.stage + # The site iframe for the main window, the page itself for popups / + # chrome-disabled renders — never the shell document, which the shim + # deliberately skips. + probe_root: Page | Frame = ( + stage.site_frame + if active_page is stage.page and stage.site_frame is not None + else active_page + ) + await _wait_selects_ready(ctx, probe_root) + if not (ctx.step.requires_target() and (ctx.optional or isinstance(ctx.cached, PendingAction))): + return None + try: + return await _resolve_in_place(ctx, probe_root) + except _OptionalAbsent as absent: + if not ctx.optional: + raise RenderError(ctx.message(str(absent))) from None + raise + except Exception as exc: + await _fail_step(ctx, exc) + + +async def _narrate(ctx: _StepCtx) -> Page: + """Spend the step's voice-over, then re-assert the picture it played over. + + A navigation that destroyed the card DURING the narration wait (a say/slide + over a live card) must fail loud here — this is the checkpoint that catches a + mid-wait destruction even when the say is the LAST step (the loop still fully + processes that step before exiting). When no card is active this is exactly + today's unconditional ``_ensure_visuals`` (back-compat). + """ + + stage = ctx.stage + # Recording-axis frame: the mapping onto the finished film needs the complete + # edit list, which does not exist until the loop ends. + step_segments, narration_frame = ctx.clock.place_narration( + ctx.index, ctx.plan.audio_configs, ctx.plan.segments + ) + if step_segments: + await ctx.clock.pace(step_segments, ctx.plan.cfg, not_before=narration_frame) + + stage.sync_popup_close() + if stage.popup_closed_unhandled(): + raise RenderError("popup zamknął się asynchronicznie podczas narracji") + active_page = stage.active_page + if stage.unexpected_pages(): + raise RenderError(ctx.message("nieoczekiwany popup — uruchom `compile --force`")) + await active_page.bring_to_front() + if stage.card is not None: + await stage.ensure_card(active_page) + else: + await stage.ensure_visuals(active_page, expect_chrome=stage.expects_bar(active_page)) + return active_page + + +def _build_recorder(ctx: _StepCtx, active_page: Page) -> Recorder: + """The driver for this step's action, wired to this step's page.""" + + cfg = ctx.plan.cfg + stage = ctx.stage + # Main window drives the site iframe (a Frame); popups drive the page. + on_shell = active_page is stage.page and stage.site_frame is not None + return Recorder( + active_page, + stage.overlay, + settle_ms=cfg.cursor.settle, + frame=stage.site_frame if on_shell else None, + type_delay_ms=(cfg.typing.speed if cfg.typing.animate else None), + type_jitter_ms=cfg.typing.jitter_ms, + type_max_delay_factor=cfg.typing.max_delay_factor, + on_sfx=(ctx.clock.note_sfx if cfg.sound.enabled else None), + # How long the unfurled option list is held before the cursor sets off + # towards the chosen row. Render is the only phase that animates a + # `select:` step, so this is the one place the configured value can take + # effect at all. + open_hold_ms=cfg.selects.open_hold_ms, + ) + + +async def _perform( + ctx: _StepCtx, active_page: Page, resolved: ResolvedTarget | None +) -> _PopupSession | None: + """Replay the step's action. Returns the popup it opened, if any.""" + + stage = ctx.stage + cached = ctx.cached + if isinstance(cached, CachedAction) and cached.opens_popup and stage.popup is not None: + raise RenderError("v1 obsługuje co najwyżej jeden popup w całej sesji") + if ctx.kind == "closeWindow" and stage.popup is None: + raise RenderError(ctx.message("closeWindow bez otwartego okna")) + return await _step._render_step( + active_page, + _build_recorder(ctx, active_page), + stage.overlay, + stage.chrome, + ctx.plan.scenario, + ctx.step, + ctx.kind, + ctx.index, + cached, + stage.anchor, + stage.observed_pages, + stage.ensure_card, + entry=ctx.entry, + total=ctx.plan.total, + sensitive=ctx.plan.sensitive_values, + expect_chrome=stage.expects_bar(active_page), + resolved=resolved, + optional=ctx.optional, + scenario_hash=ctx.plan.scenario_hash, + on_resolved=ctx.plan.persist_resolved, + ) + + +async def _furnish_popup(ctx: _StepCtx, opened: _PopupSession) -> None: + """Adopt a freshly opened popup: decide what it is, mount it, take the cursor.""" + + stage = ctx.stage + stage.popup = opened + opened.page.set_default_timeout(ctx.opts.timeout * 1000) + opened.is_blank_tab = not await _popup_window_opened(stage.page) + opened.wants_bar = stage.chrome is not None and opened.is_blank_tab + prepared = await _prepare_popup( + opened.page, + stage.overlay, + stage.chrome, + expect_chrome=stage.expect_chrome or opened.wants_bar, + mount_bar=opened.wants_bar, + ) + stage.sync_popup_close() + if not prepared: + raise RenderError("popup zamknął się podczas otwierania") + # The popup now owns the cursor (it mounted its own); stop painting a second + # one in the main window behind it. + await _hand_cursor_to_popup(stage.page, opened, stage.overlay) + + +async def _handle_async_popup_close(ctx: _StepCtx, opened: _PopupSession | None) -> None: + """A popup that went away during the action, outside ``closeWindow``.""" + + popup = ctx.stage.popup + if popup is None or not popup.page.is_closed() or popup.close_handled: + return + if opened is not None or ctx.kind in {"say", "navigate", "wait", "slide"}: + raise RenderError("popup zamknął się asynchronicznie poza obsługiwaną akcją") + popup.close_handled = True + await visuals._prepare_main_after_popup_close( + ctx.stage.page, + ctx.stage.overlay, + ctx.stage.chrome, + ctx.plan.cfg.cursor.settle, + restore_cursor_to=popup.main_cursor_pos, + ) + + +async def _settle_popup_lifecycle(ctx: _StepCtx, opened: _PopupSession | None) -> None: + """Reconcile the page contract with whatever the action did to the windows.""" + + stage = ctx.stage + if opened is not None: + await _furnish_popup(ctx, opened) + if stage.page.is_closed(): + raise RenderError("główne okno zostało zamknięte podczas render") + stage.sync_popup_close() + await _handle_async_popup_close(ctx, opened) + if stage.unexpected_pages(): + raise RenderError(ctx.message("nieoczekiwany popup — uruchom `compile --force`")) + + +async def _render_one_step(ctx: _StepCtx) -> int | None: + """Replay one flat step. Returns the branch whose children must be skipped.""" + + _assert_pages_intact(ctx, "popup zamknął się poza obsługiwaną akcją scenariusza") + if ctx.opts.verbose: + tqdm.write(f"[{ctx.index + 1}/{ctx.plan.total}] {ctx.kind}") + active_page = await _prepare_visuals(ctx) + try: + resolved = await _probe_absence(ctx, active_page) + except _OptionalAbsent as absent: + return _note_absent(ctx, absent) + active_page = await _narrate(ctx) + try: + opened = await _perform(ctx, active_page, resolved) + await _settle_popup_lifecycle(ctx, opened) + except _OptionalAbsent as absent: + # Only a cached gate reaches here (its `waitFor` timed out); every other + # absence signal was already settled by the probe above. + return _note_absent(ctx, absent) + except Exception as exc: + await _fail_step(ctx, exc) + return None + + +async def _postroll(stage: _Stage) -> None: + """One last captured frame, then the end-of-scenario page contract.""" + + await asyncio.sleep(_VIDEO_POSTROLL_SECONDS) + await stage.active_page.screenshot() + stage.sync_popup_close() + if stage.page.is_closed(): + raise RenderError("główne okno zostało zamknięte na końcu scenariusza") + if stage.unexpected_pages(): + raise RenderError("nieoczekiwany popup na końcu scenariusza") + if stage.popup_closed_unhandled(): + raise RenderError("popup zamknął się asynchronicznie na końcu scenariusza") + + +async def _run_steps( + plan: _RenderPlan, stage: _Stage, clock: _Clock, opts: _LoopOptions +) -> None: + """Replay every flat step, then close the stage — whatever happened.""" + + #: branch whose gate turned out to be absent — every step of it is skipped + skipped_branch: int | None = None + bar = tqdm(total=plan.total, desc="render", unit="krok", disable=not opts.verbose) + try: + for index, entry in enumerate(plan.flat): + if skipped_branch is not None and entry.branch == skipped_branch: + # The gate never showed: the branch's children never run, and + # their narration is removed from the timeline rather than left as + # silence (segments are placed per index, so never placing them + # removes them). + bar.update(1) + continue + skipped_branch = await _render_one_step( + _StepCtx.of(plan, stage, clock, opts, entry, index) + ) + bar.update(1) + await _postroll(stage) + finally: + bar.close() + await _close_stage(stage) diff --git a/guidebot_recorder/recorder/render/plan.py b/guidebot_recorder/recorder/render/plan.py new file mode 100644 index 0000000..1ed1ef0 --- /dev/null +++ b/guidebot_recorder/recorder/render/plan.py @@ -0,0 +1,303 @@ +"""Everything a render decides **before a browser exists**. + +The first of the three lifetimes ``run_render`` used to interleave. A +:class:`_RenderPlan` is built once, is frozen, and is read by every later phase: +the scenario and its config, the compiled sidecar and the checks that say it may +be replayed at all, the desktop icons resolved up front, and the whole narration +pre-synthesized into the cache. + +The ordering inside :func:`_prepare_render` is load-bearing and is why the +diagnostics half is its own small object. Every sidecar check must fail **before** +pre-synthesis, which spends minutes in a TTS provider; but those checks already +need the ``plik:linia`` banner, and the banner needs nothing the plan does not +already know. :class:`_Banner` is therefore built early, used by the validation, +and then handed to the finished plan — instead of building a half-empty plan or +threading four arguments through every check. + +Icon resolution lives here for the same fail-loud reason it always did: an unknown +built-in or a missing file is an authoring error and must be reported before the +recording starts, not after minutes of render. + +Nothing here is a test seam. ``write_compiled`` is one for ``recorder.compile``, +not for this package, and is name-imported exactly as ``_run`` used to import it. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from tqdm import tqdm + +from guidebot_recorder.desktop import resolve_icon +from guidebot_recorder.diagnostics import step_banner +from guidebot_recorder.models.action import COMPILER_VERSION, CachedAction, PendingAction +from guidebot_recorder.models.compiled import CompiledScenario +from guidebot_recorder.models.config import Config, TtsConfig, config_hash +from guidebot_recorder.models.scenario import FlatStep, Scenario +from guidebot_recorder.recorder._debug import scenario_sensitive_values +from guidebot_recorder.scenario.compiled import compiled_path, load_compiled, write_compiled +from guidebot_recorder.scenario.loader import load_scenario, scenario_env_references +from guidebot_recorder.scenario.source import ScenarioSource +from guidebot_recorder.tts.base import Segment, TtsCache, TtsProvider + +from .errors import RenderError +from .narration import _narration, _presynthesize_narration +from .reuse import _compiled_action_is_current + + +@dataclass(frozen=True, slots=True) +class _Banner: + """The `plik:linia` + YAML-fragment banner every render message is wrapped in. + + Split out of :class:`_RenderPlan` because the sidecar validation needs it + before the plan can exist — see the module docstring. It carries exactly the + three things :func:`~guidebot_recorder.diagnostics.step_banner` cannot derive + from a step: which file, how many steps there are, and which strings must be + redacted. + """ + + source: ScenarioSource | None + total: int + sensitive: tuple[str, ...] + + def message(self, entry: FlatStep, index: int, message: str, *, warning: bool = False) -> str: + """Komunikat kroku z `plik:linia` i fragmentem YAML; sekrety zredagowane.""" + + return step_banner( + index=index, + total=self.total, + location=entry.location, + source=self.source, + message=message, + warning=warning, + sensitive=self.sensitive, + ) + + def note_skip(self, entry: FlatStep, index: int, reason: str, *, gate: bool) -> None: + """Odnotuj pominięty krok opcjonalny — banner z `plik:linia`.""" + + what = "bramka" if gate else "krok opcjonalny" + tqdm.write(self.message(entry, index, f"{what} pominięty — {reason}", warning=True)) + + +@dataclass(frozen=True, slots=True) +class _RenderPlan: + """What the render will replay, decided before the browser opens. + + Frozen: nothing observed during the recording belongs here. ``compiled`` is + the one exception and it is deliberate — :meth:`persist_resolved` folds a + render-time resolution of a *pending* entry back into the sidecar, which is + an edit to the plan's own on-disk source, not recorded state. + """ + + path: Path + out_mp4: Path + work: Path + scenario: Scenario + cfg: Config + banner: _Banner + sensitive_values: tuple[str, ...] + flat: list[FlatStep] + compiled: CompiledScenario + sidecar: Path + audio_configs: list[TtsConfig] + scenario_hash: str + desktop_payloads: dict[int, dict[str, str]] + segments: dict[str, dict[int, Segment]] + verbose: bool + + @property + def total(self) -> int: + return len(self.flat) + + def step_message( + self, entry: FlatStep, index: int, message: str, *, warning: bool = False + ) -> str: + return self.banner.message(entry, index, message, warning=warning) + + def note_skip(self, entry: FlatStep, index: int, reason: str, *, gate: bool) -> None: + self.banner.note_skip(entry, index, reason, gate=gate) + + def persist_resolved(self, index: int, resolved_action: CachedAction) -> None: + """Fold a render-time resolution back into the sidecar (full atomic rewrite).""" + + self.compiled.actions[index] = resolved_action + write_compiled(self.sidecar, self.compiled) + + +def _apply_overrides(cfg: Config, hold_frame: bool | None, hold_frame_settle: float | None) -> None: + """Caller-side overrides (the CLI flags). + + ``None`` means "use whatever the scenario configured" — the scenario is loaded + here, so an override applied to a Config built by the caller would be discarded. + """ + + if hold_frame is not None: + cfg.hold_frame_for_narration = hold_frame + if hold_frame_settle is not None: + cfg.hold_frame_settle = hold_frame_settle + + +def _assert_one_provider(audio_configs: list[TtsConfig]) -> None: + providers = {tts.provider for tts in audio_configs} + if len(providers) != 1: + raise RenderError( + "jeden render obsługuje obecnie jeden provider TTS; " + f"skonfigurowano: {', '.join(sorted(providers))}" + ) + + +def _load_sidecar(path: Path, flat: list[FlatStep]) -> tuple[Path, CompiledScenario]: + """Read ``*.compiled.yaml`` and reject every shape render cannot replay.""" + + cpath = compiled_path(path) + try: + compiled = load_compiled(cpath) + except FileNotFoundError as exc: + raise RenderError(f"brak pliku compiled ({cpath.name}) — uruchom `compile`") from exc + if compiled.source != path.name: + raise RenderError( + f"compiled pochodzi z innego scenariusza ({compiled.source}) — uruchom `compile`" + ) + if len(compiled.actions) != len(flat): + raise RenderError("compiled niezgodny z liczbą kroków — uruchom `compile`") + if compiled.compiler_version != COMPILER_VERSION or any( + action is not None and action.fingerprint.compiler_version != COMPILER_VERSION + for action in compiled.actions + ): + raise RenderError("compiled ma starszą wersję — uruchom `compile`") + return cpath, compiled + + +def _assert_entries_current( + flat: list[FlatStep], compiled: CompiledScenario, scenario_hash: str, banner: _Banner +) -> None: + """Per-entry sidecar checks, in banner form (`plik:linia` + YAML fragment).""" + + for index, (entry, action) in enumerate(zip(flat, compiled.actions, strict=True)): + if not _compiled_action_is_current(entry.step, action, scenario_hash): + raise RenderError( + banner.message(entry, index, "compiled jest nieaktualny — uruchom `compile`") + ) + if isinstance(action, PendingAction) and entry.branch is None and not entry.step.optional: + # A pending entry is only ever written for a branch (gate + children) + # or an `optional: true` step; anywhere else the sidecar is corrupt. + raise RenderError( + banner.message( + entry, index, "wpis oczekujący na kroku obowiązkowym — uruchom `compile`" + ) + ) + + +def _resolve_desktop_payloads( + cfg: Config, flat: list[FlatStep], base_dir: Path +) -> dict[int, dict[str, str]]: + """Desktop icons, resolved before recording so an authoring error fails up front. + + An unknown built-in or a missing file must fail loud here, not after minutes + of render. Relative icon paths resolve against the scenario file's directory. + Keyed by flat-step index for the render loop to read back. + """ + + payloads: dict[int, dict[str, str]] = {} + for index, entry in enumerate(flat): + if entry.step.desktop is not None: + payloads[index] = { + "color": cfg.desktop.color, + "label": entry.step.desktop.label, + **resolve_icon(entry.step.desktop, base_dir=base_dir), + } + return payloads + + +async def _presynthesize( + flat: list[FlatStep], + audio_configs: list[TtsConfig], + cache_dir: Path | str, + tts_provider: TtsProvider, + *, + verbose: bool, +) -> dict[str, dict[int, Segment]]: + """Faza 0: pre-synteza całej narracji (fail-loud przed nagrywaniem).""" + + steps = [entry.step for entry in flat] + cache = TtsCache(cache_dir) + narration_count = sum(_narration(step) is not None for step in steps) + presynth = tqdm( + total=narration_count * len(audio_configs), + desc="tts", + unit="segment", + disable=not verbose, + ) + try: + return await _presynthesize_narration( + steps, + audio_configs, + cache, + tts_provider, + on_progress=presynth.update, + ) + finally: + presynth.close() + + +async def _prepare_render( + path: Path | str, + out_mp4: Path | str, + tts_provider: TtsProvider, + cache_dir: Path | str, + *, + env: Mapping[str, str] | None, + hold_frame: bool | None, + hold_frame_settle: float | None, + verbose: bool, +) -> _RenderPlan: + """Validate everything replayable, resolve the icons, synthesize the voice-over. + + The order is the contract: every sidecar rejection happens before + :func:`_presynthesize` is allowed to spend a minute in a TTS provider. + """ + + path = Path(path) + out_mp4 = Path(out_mp4) + out_mp4.parent.mkdir(parents=True, exist_ok=True) + + scenario = load_scenario(path, env) + sensitive_values = scenario_sensitive_values(scenario, scenario_env_references(path, env)) + cfg = scenario.config + _apply_overrides(cfg, hold_frame, hold_frame_settle) + audio_configs = [cfg.tts, *cfg.audio_tracks] + _assert_one_provider(audio_configs) + + # Flat indexing: a `when:` block contributes its synthetic gate step followed by + # its children, so `actions`, narration segments and every `krok {index}` message + # index the same linear execution order. + flat = scenario.flat_steps() + sidecar, compiled = _load_sidecar(path, flat) + scenario_hash = config_hash(cfg) + banner = _Banner(source=scenario.source, total=len(flat), sensitive=sensitive_values) + _assert_entries_current(flat, compiled, scenario_hash, banner) + + return _RenderPlan( + path=path, + out_mp4=out_mp4, + # The recording/staging directory: everything ffmpeg touches on the way to + # the master lives here, beside the output rather than in a temp root. + work=out_mp4.parent / ".guidebot_video" / out_mp4.stem, + scenario=scenario, + cfg=cfg, + banner=banner, + sensitive_values=sensitive_values, + flat=flat, + compiled=compiled, + sidecar=sidecar, + audio_configs=audio_configs, + scenario_hash=scenario_hash, + desktop_payloads=_resolve_desktop_payloads(cfg, flat, path.parent), + segments=await _presynthesize( + flat, audio_configs, cache_dir, tts_provider, verbose=verbose + ), + verbose=verbose, + ) diff --git a/guidebot_recorder/recorder/render/post.py b/guidebot_recorder/recorder/render/post.py new file mode 100644 index 0000000..4a7f18b --- /dev/null +++ b/guidebot_recorder/recorder/render/post.py @@ -0,0 +1,227 @@ +"""Post-production: the recording becomes a film, in three named stages. + +**Popup composition MUST run before time editing, and the stages are named so +that reads as an ordering rather than as two adjacent paragraphs.** + +* :class:`_RecordedFilm` — straight off Playwright, on the **recording** axis. A + popup's ``opened_at``/``closed_at`` are raw wall clock measured against the same + anchor, so they are only meaningful here. +* :class:`_ComposedFilm` — the popup framed into the main window's picture. Still + the recording axis: composition neither adds nor removes frames. +* :class:`_VirtualFilm` — held frames inserted. This is what moves narration and + SFX onto the **virtual** axis, and it is the last thing that may touch the + picture. + +Swap the two and the film comes out exactly as long as the timeline says, with the +popup at the wrong moment — every downstream guard compares the model against +itself, so all of them stay green. The repo has **no type checker**, so these +classes are readability, not enforcement; the real protection is +``test_popup_is_composed_before_time_editing_and_feeds_it`` (phase 0), which +asserts both the call order and that the edit consumes the compositor's output. + +Two test seams are name-imported here because this module calls them: +``compose_popup_video`` and ``probe_frame_count`` — the latter has a second +consumer in :mod:`~guidebot_recorder.recorder.render.timeline`, so replacing it +takes two patch lines. ``_apply_timeline_edits`` and ``_assemble_audio_tracks`` +are inside-defined seams, called through their module objects. + +``timeline`` is imported as ``timeline_module`` because ``timeline`` is a local +name in the functions below; ``mux_probe`` is aliased for the same reason. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from tqdm import tqdm + +from guidebot_recorder.video.audiobed import Placed +from guidebot_recorder.video.mux import FadeSpec, compose_popup_video + +# `probe_duration` is a test seam: the mux facade withholds it and the call must +# stay late-bound, so its defining module is imported here instead. +from guidebot_recorder.video.mux import probe as mux_probe +from guidebot_recorder.video.timeline import ( + Timeline, + assert_recording_fps, + frames_to_seconds, + probe_frame_count, +) + +from . import audio +from . import timeline as timeline_module +from .clock import _Clock +from .plan import _RenderPlan +from .popup_crop import _popup_fills_canvas, _resolve_popup_crop +from .popup_session import _PopupSession +from .stage import _Stage +from .timeline import _build_timeline + + +@dataclass(frozen=True, slots=True) +class _Film: + """A video file plus whether it has already been through an encoder. + + ``preencoded`` is not cosmetic: it tells the muxer the stream is already in + the output codec and may be copied rather than re-encoded. + """ + + path: Path + preencoded: bool + + +#: Straight off Playwright — recording axis, no popup framing, no held frames. +_RecordedFilm = _Film +#: Popup framed in. Still the recording axis: composition changes no frame count. +_ComposedFilm = _Film +#: Held frames inserted — the virtual axis, and the last edit to the picture. +_VirtualFilm = _Film + + +async def _compose_popup(plan: _RenderPlan, stage: _Stage, film: _RecordedFilm) -> _ComposedFilm: + """Frame the popup into the main window's picture, on the RECORDING axis.""" + + popup = stage.popup + if popup is None: + return film + cfg = plan.cfg + popup_webm = Path(await popup.video.path()) + # The popup recorded onto the main window's canvas; crop it back to its real + # window so float frames that and not a viewport-sized rectangle of filler. + # Three levels, best evidence first; all declining -> today's full canvas. + popup_crop, _crop_level = _resolve_popup_crop( + window_size=popup.window_size, + content_box=popup.content_box, + popup_video=popup_webm, + verbose=plan.verbose, + viewport=popup.viewport, + canvas=(cfg.viewport.width, cfg.viewport.height), + ) + transition = _effective_transition(plan, popup, popup_crop) + closed_at = mux_probe.probe_duration(film.path) if stage.popup_open_at_end else popup.closed_at + assert closed_at is not None + composite = plan.work / f"{plan.out_mp4.stem}.composite.mp4" + compose_popup_video( + film.path, + popup_webm, + composite, + popup.opened_at, + closed_at, + visual_ready_delay=popup.visual_ready_delay, + transition=transition, + slide_ms=cfg.popup.slide_ms, + scale=cfg.popup.scale, + corner_radius=cfg.popup.corner_radius, + shadow=cfg.popup.shadow, + backdrop_dim=cfg.popup.backdrop_dim, + backdrop_blur=cfg.popup.backdrop_blur, + open_ms=cfg.popup.open_ms, + close_ms=cfg.popup.close_ms, + hold_open_at_end=stage.popup_open_at_end, + popup_crop=popup_crop, + ) + return _ComposedFilm(path=composite, preencoded=True) + + +def _effective_transition( + plan: _RenderPlan, popup: _PopupSession, popup_crop: tuple[int, int, int, int] | None +) -> str: + """How the popup is presented, after the full-canvas tab override. + + A real browser TAB that fills the canvas is not a floating popup: `slide` is + the full-frame presentation by design and ignores `popup_crop`, while `float` + would inset a whole viewport and read as a shrunken clone of the page. Gated + on `is_blank_tab`, not on the crop alone — a featureless `window.open` + painting a full-bleed background also declines every crop level, yet is a + genuine floating window that must keep `float`. Only `float` is overridden: an + author who asked for `cut` gets the hard cut they asked for. + """ + + cfg = plan.cfg + transition = cfg.popup.effective_transition + if ( + transition == "float" + and popup.is_blank_tab + and _popup_fills_canvas(popup_crop, cfg.viewport) + ): + transition = "slide" + if plan.verbose: + tqdm.write("popup wypełnia kadr — wymuszam przejście `slide` zamiast `float`") + return transition + + +def _edit_time( + plan: _RenderPlan, clock: _Clock, film: _ComposedFilm, *, dump_timeline: bool +) -> tuple[_VirtualFilm, Timeline]: + """Insert the held frames — the move from the recording axis to the virtual one. + + Runs AFTER popup composition: popups are composed on the recording axis (their + opened_at/closed_at are raw wall clock) and must stay there. Only what is + consumed downstream — narration and SFX — moves onto the virtual axis. + """ + + timeline = _build_timeline(clock.time_edits, source_frames=probe_frame_count(film.path)) + if dump_timeline: + plan.out_mp4.with_suffix(".timeline.json").write_text(timeline.to_json(), encoding="utf-8") + if timeline.is_empty: + return film, timeline + assert_recording_fps(film.path) + edited = plan.work / f"{plan.out_mp4.stem}.timeline.mp4" + timeline_module._apply_timeline_edits(film.path, timeline, edited) + return _VirtualFilm(path=edited, preencoded=True), timeline + + +async def _lay_audio( + plan: _RenderPlan, clock: _Clock, film: _VirtualFilm, timeline: Timeline +) -> None: + """Map every placement onto the virtual axis, then build and publish the beds.""" + + cfg = plan.cfg + # Taken from the model rather than probed, which is what makes the audio and + # video axes agree by construction. + total = timeline.virtual_duration + # The one place frames become seconds: mapped on the grid, then converted. + placed_tracks = { + lang: [ + Placed(segment=seg, offset=frames_to_seconds(timeline.to_virtual(frame))) + for seg, frame in placed + ] + for lang, placed in clock.placed_by_language.items() + } + sfx_offsets = [ + (kind, frames_to_seconds(timeline.to_virtual(frame))) + for kind, frame in clock.sfx_frames(cfg.sound) + ] + await audio._assemble_audio_tracks( + film.path, + plan.audio_configs, + placed_tracks, + total, + plan.work, + plan.out_mp4, + preencoded=film.preencoded, + sound=cfg.sound, + sfx_offsets=sfx_offsets, + fade=( + FadeSpec( + fade_in=cfg.fade.fade_in, + fade_out=cfg.fade.fade_out, + color=cfg.fade.color, + audio=cfg.fade.audio, + ) + if cfg.fade.enabled + else None + ), + ) + + +async def _publish_film( + plan: _RenderPlan, stage: _Stage, clock: _Clock, *, dump_timeline: bool +) -> None: + """Recording -> composed -> virtual -> mastered. The order is the contract.""" + + recorded = _RecordedFilm(path=Path(await stage.video.path()), preencoded=False) + composed = await _compose_popup(plan, stage, recorded) + virtual, timeline = _edit_time(plan, clock, composed, dump_timeline=dump_timeline) + await _lay_audio(plan, clock, virtual, timeline) diff --git a/guidebot_recorder/recorder/render/stage.py b/guidebot_recorder/recorder/render/stage.py new file mode 100644 index 0000000..5337443 --- /dev/null +++ b/guidebot_recorder/recorder/render/stage.py @@ -0,0 +1,456 @@ +"""What is on screen right now: the pages, the injected layers, the popup, the card. + +The second of the three lifetimes ``run_render`` used to interleave. A +:class:`_Stage` exists only while a browser context does, and every question the +render loop asks about "the picture" is a method on it: which page is live, what +should be painted on it, whether the slide card is still the thing the narration +describes, which pages fell outside the one-main-plus-one-popup contract. + +**The registration order of the init scripts is a contract, and this module is +where it is kept.** ``cursor.js``, ``slide.js`` and ``desktop.js`` each decide +their role by reading the *real* ``window.top``; ``chrome.js`` is what shadows it +(frame-bust neutralization). A layer registered after ``chrome.js`` reads the +shadowed ``top``, misidentifies as the top window, and mounts a duplicate cursor +or desktop *inside* the framed site — a defect that is invisible in every test +that only checks lengths and only shows up in the finished film. So the whole +registration is one function, :func:`_install_page_scripts`, whose body *is* the +order, and a layer that is added to it without being declared in +:data:`_ROLE_GATED_LAYERS` raises at render start instead of mounting twice. +``test_render.py`` asserts the resulting call order, ``DesktopOverlay`` included. + +Two test seams live here because their constructors do: ``Overlay`` and +``SlideOverlay`` are name-imported, so a patch on *this* module is what has to +reach them. ``Recorder``'s constructor is in +:mod:`~guidebot_recorder.recorder.render.loop` and ``DesktopOverlay`` is patched +on its own class, not through a render module. +""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from functools import partial +from pathlib import Path + +from playwright.async_api import Browser, BrowserContext, Frame, Page, Video + +from guidebot_recorder.chrome import SHELL_URL, Chrome +from guidebot_recorder.chrome.framing import install_framing +from guidebot_recorder.desktop import DesktopOverlay +from guidebot_recorder.models.config import Config +from guidebot_recorder.overlay.overlay import Overlay +from guidebot_recorder.recorder.session import ensure_session +from guidebot_recorder.selects import Selects, install_selects +from guidebot_recorder.slide import SlideOverlay + +from .errors import RenderError +from .pages import _active_page, _expect_chrome +from .plan import _RenderPlan +from .popup_crop import _settle_popup_content_box +from .popup_detect import _POPUP_REQUEST_SCRIPT +from .popup_session import _PageObservation, _PopupSession, _sync_popup_close, _unexpected_pages +from .visuals import _ensure_visuals, _prime_visuals + +#: A slide card's on-screen content, as consumed by ``SlideOverlay.show``/``.ensure``. +Card = dict[str, str | None] + +#: The role-gated init scripts, in the ONE order that works — see the module +#: docstring. Declared separately from the dict that creates them so that adding a +#: fourth overlay without thinking about ``chrome.js`` is a loud failure at render +#: start rather than a duplicate cursor inside the site iframe. +_ROLE_GATED_LAYERS = ("cursor", "slide", "desktop") + + +def _note_closed(observed: _PageObservation, _page: Page) -> None: + """Record when a page closed, first close wins. + + Module-level, bound per page with :func:`functools.partial`, rather than a + closure created inside the observer: the record is the only thing it needs. + """ + + if observed.closed_at is None: + observed.closed_at = time.monotonic() + + +@dataclass(frozen=True, slots=True) +class _Layers: + """The injected layers, as :func:`_install_page_scripts` leaves them. + + Not a fourth lifetime — it is the return value of the one function that is + allowed to register init scripts, unpacked into the :class:`_Stage` on the + next line. It exists so that function can *be* the ordering contract without + also needing a page that does not exist yet. + """ + + overlay: Overlay + slide: SlideOverlay + desktop: DesktopOverlay + selects: Selects | None + chrome: Chrome | None + bare_popups: bool + + +# NOT ``slots=True``, unlike every other record in this package: ``_Stage.observe`` +# is handed to ``BrowserContext.on("page", ...)`` as a BOUND METHOD, and +# Playwright's event plumbing memoises its wrapper by setting an attribute on the +# method's ``__self__`` — which a slotted instance rejects with a bare +# ``AttributeError`` from inside the event dispatcher. +@dataclass +class _Stage: + """The live picture: which pages exist, what is painted, what owns the screen.""" + + context: BrowserContext + overlay: Overlay + slide: SlideOverlay + desktop: DesktopOverlay + selects: Selects | None + chrome: Chrome | None + bare_popups: bool + observed_pages: dict[Page, _PageObservation] = field(default_factory=dict) + site_frame: Frame | None = None + anchor: float = 0.0 + card: Card | None = None + """The slide card that currently owns the screen, or None when the page does. + + One variable, not a ``(bool, payload)`` pair: the two halves were written + together at every site and the code asserted they agreed, so the only thing a + pair could express was a desync. + """ + popup: _PopupSession | None = None + popup_open_at_end: bool = False + page: Page = field(init=False, repr=False) + """The main window. Assigned by :func:`_open_stage` the moment it is opened. + + Not an ``__init__`` argument, and deliberately not ``None``-able: the stage + has to exist before ``context.new_page()`` so its page observer can be + registered on the context first, and the four lines between the two are the + only place where reading this is a bug — where it raises ``AttributeError`` + instead of quietly handing out a ``None``. + """ + video: Video = field(init=False, repr=False) + """The main window's recording. Assigned with :attr:`page`.""" + + # -- what should be on screen ------------------------------------------- # + + @property + def active_page(self) -> Page: + return _active_page(self.page, self.popup) + + @property + def expect_chrome(self) -> bool: + """Whether the legacy in-DOM bar is expected on an ordinary page here.""" + + return _expect_chrome(self.chrome, self.bare_popups) + + def expects_bar(self, pg: Page) -> bool: + """Same question, answered for one specific page. + + A real ``target="_blank"`` tab carries the legacy bar even when the + context-wide script is bare, so the popup answers for itself. + """ + + if self.popup is not None and pg is self.popup.page: + return self.popup.wants_bar + return self.expect_chrome + + async def ensure_visuals(self, pg: Page, *, expect_chrome: bool | None = None) -> None: + await _ensure_visuals(pg, self.overlay, self.chrome, expect_chrome=expect_chrome) + + async def chrome_hide(self, pg: Page) -> None: + if self.chrome is not None: + await self.chrome.hide(pg) + + async def chrome_show(self, pg: Page) -> None: + if self.chrome is not None: + await self.chrome.show(pg) + + # -- the slide card ------------------------------------------------------ # + + async def assert_card_alive(self, pg: Page) -> None: + """Fail loud when a navigation destroyed the card mid-say. + + A fresh, tokenless document (``slide.token`` falsy) means the picture + on screen is no longer the card the narration/scenario describes — + never narrate over — or silently dismiss — the wrong picture. + """ + if not await self.slide.token(pg): + raise RenderError("karta slajdu zniknęła po nawigacji — narracja nad złym obrazem") + + async def ensure_card(self, pg: Page) -> None: + """Card-aware replacement for `_ensure_visuals`: re-mount the active + card (rebuild-from-missing only; a live card's content is untouched) + and re-assert the hidden cursor/chrome layers. + """ + await self.assert_card_alive(pg) + assert self.card is not None # only ever called on the card-active path + await self.slide.ensure(pg, self.card) + await self.overlay.hide(pg) + await self.chrome_hide(pg) + + async def show_card(self, pg: Page, card: Card) -> None: + """Paint *card* and hide the page's own layers behind it.""" + + self.card = card + await self.slide.show(pg, card) + await self.overlay.hide(pg) + await self.chrome_hide(pg) + + async def hide_card(self, pg: Page) -> None: + """Take the card down, leaving the other layers as the caller found them. + + Asserts the card survived first: a card destroyed by a navigation must + never be silently swapped out from under whatever replaced it. + """ + + await self.assert_card_alive(pg) + await self.slide.hide(pg) + self.card = None + + async def reveal_page(self, pg: Page) -> None: + """Take the card down and give the page back its own visible layers.""" + + await self.hide_card(pg) + await self.overlay.show(pg) + await self.chrome_show(pg) + + # -- page lifecycle ------------------------------------------------------ # + + def observe(self, candidate: Page) -> None: + """Start recording a page's lifetime, and prime its visual layers.""" + + if candidate in self.observed_pages: + return + # Bare (floating) popups carry no legacy chrome bar; nor does the main + # window's about:blank warm-up under that flag. Prime against the cursor + # only, or the prime loop deadlocks waiting for a bar that never mounts. + observation = _PageObservation( + opened_at=time.monotonic(), + video=candidate.video, + visual_prime=asyncio.create_task( + _prime_visuals( + candidate, self.overlay, self.chrome, expect_chrome=self.expect_chrome + ) + ), + ) + self.observed_pages[candidate] = observation + candidate.on("close", partial(_note_closed, observation)) + + def sync_popup_close(self) -> None: + _sync_popup_close(self.popup, self.observed_pages, self.anchor) + + def unexpected_pages(self) -> list[Page]: + return _unexpected_pages(self.observed_pages, self.page, self.popup) + + def popup_closed_unhandled(self) -> bool: + """A popup that went away outside an action the scenario asked for.""" + + popup = self.popup + return popup is not None and popup.page.is_closed() and not popup.close_handled + + +async def _install_page_scripts(context: BrowserContext, cfg: Config) -> _Layers: + """Register every context init script. **The body of this function is the order.** + + ``cursor.js`` / ``slide.js`` / ``desktop.js`` MUST be registered before + ``chrome.js``: inside the site iframe each of them decides its role by reading + the real ``window.top`` (cursor.js to skip mounting a duplicate cursor, + slide.js's ``isTop`` guard to skip installing ``window.__guidebot_slide``, + desktop.js likewise), and ``chrome.js`` is what shadows ``top`` (frame-bust + neutralization). A layer registered after it reads the shadowed ``top``, + misidentifies as the top window, and mounts inside the frame. + + selects.js reads ``top`` too but is deliberately NOT part of that contract: + its only test is ``isTop && origin === SHELL_ORIGIN``, and chrome.js shadows + ``top`` solely inside framed documents, whose origin is never the shell's — so + the shim reaches the same verdict on either side of chrome.js. It is + registered here anyway, next to the overlays it sits beside; nothing + downstream may rely on that position. See the role-gating comment at the top + of ``selects/selects.js``. + """ + + # Independent of the role-gating order below (it only wraps ``window.open``), + # but registered first so it wraps the *native* function on every document. + await context.add_init_script(script=_POPUP_REQUEST_SCRIPT) + role_gated = { + "cursor": Overlay(cfg.cursor, cfg.viewport), + "slide": SlideOverlay(), + "desktop": DesktopOverlay(config={"background": cfg.desktop.color}), + } + # A runtime check, not a comment: a fourth overlay added here without being + # declared in `_ROLE_GATED_LAYERS` — or declared but slipped in after + # chrome.js — stops the render on its first line instead of quietly mounting + # a second cursor inside the site iframe, which only the finished film shows. + if tuple(role_gated) != _ROLE_GATED_LAYERS: + raise RenderError( + "kolejność rejestracji skryptów init jest kontraktem: oczekiwano " + f"{_ROLE_GATED_LAYERS}, jest {tuple(role_gated)} — każda z tych warstw " + "czyta prawdziwe `window.top`, a chrome.js je przesłania" + ) + for layer in role_gated.values(): + await layer.install_context(context) + # The DOM select shim — one of the three contexts that drive pages (spec §1), + # and the reason the recording shows an option list at all. ``None`` under + # ``selects.mode: native``, which keeps the page's own control. + selects = await install_selects(context, cfg) + # Composited popups (float or slide) render bare (no in-DOM chrome bar); the + # compositor frames them in post. This flips the chrome.js popup-site branch + # off and gates the fail-loud "expect chrome" checks on popup pages. + bare_popups = cfg.popup.is_bare + chrome = Chrome(cfg.chrome, bare_popups=bare_popups) if cfg.chrome.enabled else None + if chrome is not None: + await chrome.install_context(context) + # Strip X-Frame-Options / CSP frame-ancestors so arbitrary sites frame. + await install_framing(context, shell_origin=SHELL_URL) + return _Layers( + overlay=role_gated["cursor"], + slide=role_gated["slide"], + desktop=role_gated["desktop"], + selects=selects, + chrome=chrome, + bare_popups=bare_popups, + ) + + +async def _open_context( + browser: Browser, + plan: _RenderPlan, + *, + env: Mapping[str, str] | None, + timeout: float, +) -> BrowserContext: + """The recording context, at the configured viewport. + + The context viewport and video size stay at the configured dimensions so the + output MP4 keeps its size and popups are geometrically untouched; the shell + shrinks only the site iframe interior (see compile / site_viewport). + Both settings are context-level, so a popup also records onto a + main-viewport-sized canvas with filler around its real window. That is + corrected in post (``compose_popup_video(popup_crop=...)``), never here: + shrinking the recording would also shrink the main window's frame. + + Pre-recording setup: when the target declares ``config.setup`` its login + steps were removed, so the recording context must start already logged in. + ``ensure_session`` establishes/reuses the prepared session on separate, + non-recording contexts *before* the context below is created, so the login can + never reach the film (spec: "Target render"). + """ + + cfg = plan.cfg + plan.work.mkdir(parents=True, exist_ok=True) + setup_state = ( + await ensure_session( + browser, Path(plan.path), Path(".guidebot/sessions"), env, timeout=timeout + ) + if cfg.setup is not None + else None + ) + return await browser.new_context( + viewport={"width": cfg.viewport.width, "height": cfg.viewport.height}, + locale=cfg.locale, + record_video_dir=str(plan.work), + record_video_size={"width": cfg.viewport.width, "height": cfg.viewport.height}, + **({"storage_state": setup_state} if setup_state is not None else {}), + **({"bypass_csp": True, "service_workers": "block"} if cfg.chrome.enabled else {}), + ) + + +async def _bootstrap_first_frame(stage: _Stage, plan: _RenderPlan) -> None: + """Paint something, force one captured frame, then start the shared clock. + + Chromium's screencast may not emit a first frame for a pristine about:blank + page. A scenario can narrate for several seconds before its first navigate; + anchoring at the Page event would then put that narration on a timeline the + WebM never encoded. Paint a neutral document, force one captured frame, and + only then establish the shared narration/window clock. The tiny warm-up is + bounded pre-roll; it avoids losing an arbitrarily long opening narration. + With chrome enabled the neutral document IS the shell (bar + empty iframe), + so the recording opens on the browser chrome rather than a bare white page. + Auto-intro (``cfg.intro.enabled``) replaces this neutral document with a + title card instead — render-only, so ``intro.enabled=False`` keeps today's + bootstrap byte-identical. + """ + + cfg = plan.cfg + page = stage.page + if stage.chrome is not None: + stage.site_frame = await stage.chrome.install_shell(page) + elif not cfg.intro.enabled: + await page.set_content("") + if cfg.intro.enabled: + await stage.show_card( + page, + {"title": cfg.title, "subtitle": cfg.intro.subtitle, "notes": cfg.intro.notes}, + ) + await stage.ensure_visuals(page) + await page.screenshot() + await page.wait_for_timeout(100) + stage.anchor = time.monotonic() + + +async def _open_stage( + browser: Browser, + plan: _RenderPlan, + *, + env: Mapping[str, str] | None, + timeout: float, +) -> _Stage: + """Open the recording context, inject every layer, and warm the first frame up.""" + + context = await _open_context(browser, plan, env=env, timeout=timeout) + layers = await _install_page_scripts(context, plan.cfg) + stage = _Stage( + context=context, + overlay=layers.overlay, + slide=layers.slide, + desktop=layers.desktop, + selects=layers.selects, + chrome=layers.chrome, + bare_popups=layers.bare_popups, + ) + context.on("page", stage.observe) + stage.page = await context.new_page() + stage.observe(stage.page) + stage.page.set_default_timeout(timeout * 1000) + main_observation = stage.observed_pages[stage.page] + if main_observation.visual_prime is not None: + await main_observation.visual_prime + video = stage.page.video + if video is None: # pragma: no cover - record_video_dir makes this invariant true + await context.close() + raise RenderError("Playwright nie udostępnił nagrania głównego okna") + stage.video = video + await _bootstrap_first_frame(stage, plan) + return stage + + +async def _close_stage(stage: _Stage) -> None: + """Settle the popup's last measurements, drain the probes, close the context. + + Runs in ``run_render``'s ``finally``, so it also runs on the way out of a + failed render. The popup's content box is measured *here* and not later + because this is the last moment its DOM can still answer — the context, and + with it every page, is closed on the last line. + """ + + stage.sync_popup_close() + popup = stage.popup + if popup is not None and popup.closed_at is None: + stage.popup_open_at_end = True + popup.closed_at = max(popup.opened_at, time.monotonic() - stage.anchor) + if popup is not None: + # Last moment the popup's DOM can still answer: the context (and with it + # every page) is closed a few lines below. The probe was started when the + # popup opened, so this normally settles instantly. + await _settle_popup_content_box(popup) + prime_tasks = [ + observation.visual_prime + for observation in stage.observed_pages.values() + if observation.visual_prime is not None + ] + for task in prime_tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*prime_tasks, return_exceptions=True) + await stage.context.close() diff --git a/tests/unit/recorder/test_render.py b/tests/unit/recorder/test_render.py index bda86ef..4e1fd4b 100644 --- a/tests/unit/recorder/test_render.py +++ b/tests/unit/recorder/test_render.py @@ -402,7 +402,7 @@ async def test_render_produces_mp4_with_audio(tmp_path): async def test_run_render_registers_overlay_then_slide_then_chrome_init_scripts( tmp_path, monkeypatch ): - """Locks in the context init-script ordering contract of ``render/_run.py``. + """Locks in the context init-script ordering contract of ``render/stage.py``. cursor.js, slide.js and desktop.js rely on reading the real ``window.top`` to decide whether they are running in the top document or a framed site; @@ -511,7 +511,7 @@ async def test_render_passes_the_configured_open_hold_to_the_recorder(tmp_path, ) holds: list[float | None] = [] - original_recorder = render_module._run.Recorder + original_recorder = render_module.loop.Recorder class SpyRecorder(original_recorder): # type: ignore[misc, valid-type] def __init__(self, *args, **kwargs): @@ -520,7 +520,7 @@ def __init__(self, *args, **kwargs): # `Recorder` is constructed in two submodules — the render loop and the # post-popup-close funnel — so replacing it takes both lines. - monkeypatch.setattr(render_module._run, "Recorder", SpyRecorder) + monkeypatch.setattr(render_module.loop, "Recorder", SpyRecorder) monkeypatch.setattr(render_module.visuals, "Recorder", SpyRecorder) async with async_playwright() as pw: @@ -1094,7 +1094,7 @@ async def test_render_does_not_attribute_popup_opened_before_actual_click(tmp_pa # attribute the window to the click). early_uri = early.resolve().as_uri() - class EarlyWindowRecorder(R._run.Recorder): + class EarlyWindowRecorder(R.loop.Recorder): async def click(self, target, *, before_click=None): # Awaiting the context's own page event is what makes this # deterministic: the render has *observed* the window by the time @@ -1106,7 +1106,7 @@ async def click(self, target, *, before_click=None): # `Recorder` is constructed in two submodules — the render loop and the # post-popup-close funnel — so replacing it takes both lines. - monkeypatch.setattr(R._run, "Recorder", EarlyWindowRecorder) + monkeypatch.setattr(R.loop, "Recorder", EarlyWindowRecorder) monkeypatch.setattr(R.visuals, "Recorder", EarlyWindowRecorder) with pytest.raises(RenderError, match="przed akcją click"): @@ -1191,20 +1191,20 @@ async def test_render_wires_viewport_and_typing_animation(tmp_path, monkeypatch) overlay_viewports: list = [] recorder_kwargs: list = [] - class SpyOverlay(R._run.Overlay): + class SpyOverlay(R.stage.Overlay): def __init__(self, cursor=None, viewport=None): overlay_viewports.append(viewport) super().__init__(cursor, viewport) - class SpyRecorder(R._run.Recorder): + class SpyRecorder(R.loop.Recorder): def __init__(self, *a, **k): recorder_kwargs.append(k) super().__init__(*a, **k) - monkeypatch.setattr(R._run, "Overlay", SpyOverlay) + monkeypatch.setattr(R.stage, "Overlay", SpyOverlay) # `Recorder` is constructed in two submodules — the render loop and the # post-popup-close funnel — so replacing it takes both lines. - monkeypatch.setattr(R._run, "Recorder", SpyRecorder) + monkeypatch.setattr(R.loop, "Recorder", SpyRecorder) monkeypatch.setattr(R.visuals, "Recorder", SpyRecorder) async with async_playwright() as pw: @@ -1237,14 +1237,14 @@ async def test_render_respects_typing_animate_false(tmp_path, monkeypatch): recorder_kwargs: list = [] - class SpyRecorder(R._run.Recorder): + class SpyRecorder(R.loop.Recorder): def __init__(self, *a, **k): recorder_kwargs.append(k) super().__init__(*a, **k) # `Recorder` is constructed in two submodules — the render loop and the # post-popup-close funnel — so replacing it takes both lines. - monkeypatch.setattr(R._run, "Recorder", SpyRecorder) + monkeypatch.setattr(R.loop, "Recorder", SpyRecorder) monkeypatch.setattr(R.visuals, "Recorder", SpyRecorder) async with async_playwright() as pw: @@ -1418,7 +1418,7 @@ async def test_slide_step_paints_card_and_hides_layers(tmp_path, monkeypatch): slide_events: list[tuple[str, dict]] = [] - class SpySlide(R._run.SlideOverlay): + class SpySlide(R.stage.SlideOverlay): async def show(self, page, card): await super().show(page, card) slide_events.append(("show", dict(card))) @@ -1436,7 +1436,7 @@ async def ensure(self, page, card): ) ) - monkeypatch.setattr(R._run, "SlideOverlay", SpySlide) + monkeypatch.setattr(R.stage, "SlideOverlay", SpySlide) async with async_playwright() as pw: browser = await pw.chromium.launch(headless=True) @@ -1487,30 +1487,30 @@ async def test_teach_or_navigate_after_slide_dismisses_card(tmp_path, monkeypatc overlay_show_calls = 0 dom_state_before_navigate: list[int] = [] - class SpySlide(R._run.SlideOverlay): + class SpySlide(R.stage.SlideOverlay): async def hide(self, page): nonlocal slide_hide_calls slide_hide_calls += 1 await super().hide(page) - class SpyOverlay(R._run.Overlay): + class SpyOverlay(R.stage.Overlay): async def show(self, page): nonlocal overlay_show_calls overlay_show_calls += 1 await super().show(page) - class SpyRecorder(R._run.Recorder): + class SpyRecorder(R.loop.Recorder): async def navigate(self, url): dom_state_before_navigate.append( await self.page.locator("[data-guidebot-slide]").count() ) await super().navigate(url) - monkeypatch.setattr(R._run, "SlideOverlay", SpySlide) - monkeypatch.setattr(R._run, "Overlay", SpyOverlay) + monkeypatch.setattr(R.stage, "SlideOverlay", SpySlide) + monkeypatch.setattr(R.stage, "Overlay", SpyOverlay) # `Recorder` is constructed in two submodules — the render loop and the # post-popup-close funnel — so replacing it takes both lines. - monkeypatch.setattr(R._run, "Recorder", SpyRecorder) + monkeypatch.setattr(R.loop, "Recorder", SpyRecorder) monkeypatch.setattr(R.visuals, "Recorder", SpyRecorder) async with async_playwright() as pw: @@ -1560,13 +1560,13 @@ async def test_navigation_destroying_card_mid_say_fails_loud(tmp_path, monkeypat # check therefore sees a live card; only a post-wait check sees the loss. destroyed = {"value": False} - class MidWaitDestroySlide(R._run.SlideOverlay): + class MidWaitDestroySlide(R.stage.SlideOverlay): async def token(self, page): if destroyed["value"]: return 0 return await super().token(page) - monkeypatch.setattr(R._run, "SlideOverlay", MidWaitDestroySlide) + monkeypatch.setattr(R.stage, "SlideOverlay", MidWaitDestroySlide) original_wait = R.narration._pace_narration @@ -1623,7 +1623,7 @@ async def test_slide_after_card_destroyed_during_say_fails_loud(tmp_path, monkey destroyed = {"value": False} - class GhostNavSlide(R._run.SlideOverlay): + class GhostNavSlide(R.stage.SlideOverlay): async def show(self, page, card): await super().show(page, card) destroyed["value"] = False # a repaint restores a truthy token @@ -1633,7 +1633,7 @@ async def token(self, page): return 0 return await super().token(page) - monkeypatch.setattr(R._run, "SlideOverlay", GhostNavSlide) + monkeypatch.setattr(R.stage, "SlideOverlay", GhostNavSlide) original_wait = R.narration._pace_narration @@ -1690,7 +1690,7 @@ async def test_slide_dismiss_fails_loud_when_card_destroyed_after_say(tmp_path, slide_ref: dict = {} - class GhostNavSlide(R._run.SlideOverlay): + class GhostNavSlide(R.stage.SlideOverlay): def __init__(self, *a, **k): super().__init__(*a, **k) self._ghost = False @@ -1708,7 +1708,7 @@ async def token(self, page): return 0 return await super().token(page) - monkeypatch.setattr(R._run, "SlideOverlay", GhostNavSlide) + monkeypatch.setattr(R.stage, "SlideOverlay", GhostNavSlide) original_render_step = R._step._render_step @@ -1760,12 +1760,12 @@ async def test_intro_enabled_replaces_bootstrap(tmp_path, monkeypatch): show_calls: list[dict] = [] - class SpySlide(R._run.SlideOverlay): + class SpySlide(R.stage.SlideOverlay): async def show(self, page, card): show_calls.append(dict(card)) await super().show(page, card) - monkeypatch.setattr(R._run, "SlideOverlay", SpySlide) + monkeypatch.setattr(R.stage, "SlideOverlay", SpySlide) async with async_playwright() as pw: browser = await pw.chromium.launch(headless=True) @@ -1801,12 +1801,12 @@ async def test_intro_disabled_bootstrap_unchanged(tmp_path, monkeypatch): show_calls: list[dict] = [] - class SpySlide(R._run.SlideOverlay): + class SpySlide(R.stage.SlideOverlay): async def show(self, page, card): show_calls.append(dict(card)) await super().show(page, card) - monkeypatch.setattr(R._run, "SlideOverlay", SpySlide) + monkeypatch.setattr(R.stage, "SlideOverlay", SpySlide) async with async_playwright() as pw: browser = await pw.chromium.launch(headless=True) @@ -1879,7 +1879,7 @@ async def test_render_hands_cursor_over_to_popup_and_back(tmp_path, monkeypatch) def _role(page) -> str: return "popup" if page.url.endswith("popup.html") else "main" - class SpyOverlay(R._run.Overlay): + class SpyOverlay(R.stage.Overlay): async def hide(self, page): events.append(("hide", _role(page))) await super().hide(page) @@ -1888,7 +1888,7 @@ async def show(self, page): events.append(("show", _role(page))) await super().show(page) - monkeypatch.setattr(R._run, "Overlay", SpyOverlay) + monkeypatch.setattr(R.stage, "Overlay", SpyOverlay) async with async_playwright() as pw: browser = await pw.chromium.launch(headless=True) @@ -2418,9 +2418,9 @@ async def test_sfx_after_a_freeze_never_lands_inside_the_hold(tmp_path, monkeypa `test_hold_frame_narrations_never_overlap` proves the narration clamp (`not_before=narration_frame` on the NEXT step's own narration stamp); it - never looks at SFX. `sfx_sink` (render/_run.py, the `on_sfx` closure passed to - `Recorder`) carries the exact same `not_before=last_freeze_frame + 1` - clamp, but nothing previously asserted it does anything — the render + never looks at SFX. `_Clock.note_sfx` (render/clock.py, the bound method + handed to `Recorder(on_sfx=...)`) carries the exact same + `not_before=last_freeze_frame + 1` clamp, but nothing asserts it does anything — the render could clamp narration and NOT sound effects and the whole suite would stay green, since `test_render_with_sound_collects_and_mixes_sfx` only checks the events list is non-empty, never an offset. @@ -2588,10 +2588,10 @@ def test_apply_timeline_edits_rejects_a_file_that_disagrees_with_the_model( timeline = Timeline.build([TimeEdit(at=10, kind="freeze", frames=25)], source_frames=100) monkeypatch.setattr(R.timeline, "apply_time_edits", lambda src, tl, out: None) - # `probe_frame_count` has a second consumer in `_run` (it sizes the timeline + # `probe_frame_count` has a second consumer in `post` (it sizes the timeline # before the edit runs), so replacing it takes both lines. monkeypatch.setattr(R.timeline, "probe_frame_count", lambda path: 123) - monkeypatch.setattr(R._run, "probe_frame_count", lambda path: 123) + monkeypatch.setattr(R.post, "probe_frame_count", lambda path: 123) with pytest.raises(RenderError) as excinfo: _apply_timeline_edits(tmp_path / "src.mp4", timeline, tmp_path / "out.mp4") @@ -3442,13 +3442,13 @@ async def test_a_full_canvas_popup_is_presented_full_frame_not_inset(tmp_path, m import guidebot_recorder.recorder.render as R seen: list[str | None] = [] - original = R._run.compose_popup_video + original = R.post.compose_popup_video def spy(*args, **kwargs): seen.append(kwargs.get("transition")) return original(*args, **kwargs) - monkeypatch.setattr(R._run, "compose_popup_video", spy) + monkeypatch.setattr(R.post, "compose_popup_video", spy) path = _write_close_window_scenario(tmp_path, popup_config=False) @@ -3510,7 +3510,7 @@ async def test_popup_is_composed_before_time_editing_and_feeds_it(tmp_path, monk composed: list[tuple[Path, Path]] = [] edited: list[tuple[Path, Path]] = [] - original_compose = R._run.compose_popup_video + original_compose = R.post.compose_popup_video original_edit = R.timeline._apply_timeline_edits def spy_compose(main, popup, dest, *args, **kwargs): @@ -3523,7 +3523,7 @@ def spy_edit(source, timeline, dest): edited.append((Path(source), Path(dest))) return original_edit(source, timeline, dest) - monkeypatch.setattr(R._run, "compose_popup_video", spy_compose) + monkeypatch.setattr(R.post, "compose_popup_video", spy_compose) monkeypatch.setattr(R.timeline, "_apply_timeline_edits", spy_edit) path = _write_close_window_scenario(tmp_path) diff --git a/tests/unit/recorder/test_render_step_dispatch.py b/tests/unit/recorder/test_render_step_dispatch.py new file mode 100644 index 0000000..5241f05 --- /dev/null +++ b/tests/unit/recorder/test_render_step_dispatch.py @@ -0,0 +1,96 @@ +"""Guard: ``_replay_action`` must handle every action the sidecar can hold. + +``_render_step``'s second dispatch — on ``cached.action`` — **has no ``else``**. +An action it does not recognise does nothing and raises nothing: the step is +silently skipped, the film comes out the right length, and no test in the suite +notices. That is a latent bug, it predates the phase-3 decomposition, and the +design document files it in the backlog because fixing it is a *behaviour* +change: + + docs/superpowers/specs/2026-07-22-code-cleanup-design.md, "Backlog": + "Dyspozytor akcji w `_render_step` nie ma `else` — nieznana akcja sidecara + nie robi nic i nie zgłasza błędu." + +It is unreachable today, and this file says exactly why: ``CachedAction.action`` +is typed ``ActionKind``, a ``Literal`` of six values, and ``CachedAction`` is a +pydantic model, so a sidecar naming anything else is rejected at load. The bug is +therefore not "a render can hit this" but "a **seventh** action can be added to +``ActionKind`` and the dispatch will silently ignore it" — a regression that would +first surface as a step that plays as an empty pause in a finished video. + +So this guard pins the totality of the dispatch instead of changing what happens +at runtime: every member of ``ActionKind`` must be named in ``_replay_action``, +and nothing else may be. Adding a seventh action now fails here, at collection +speed, with the name of the action that has no handler. + +Reads the source with ``ast``; imports nothing but the type. No browser, no +ffmpeg. +""" + +from __future__ import annotations + +import ast +import inspect +from typing import get_args + +from guidebot_recorder.models.action import ActionKind +from guidebot_recorder.recorder.render import _step as step_module + +DISPATCH = "_replay_action" + + +def _dispatch_function() -> ast.AsyncFunctionDef: + tree = ast.parse(inspect.getsource(step_module)) + for node in ast.walk(tree): + if isinstance(node, ast.AsyncFunctionDef) and node.name == DISPATCH: + return node + raise AssertionError( + f"{DISPATCH!r} is gone from render/_step.py. If the sidecar dispatch was " + f"reshaped, this guard has to be reshaped with it — do not delete it: it is " + f"the only thing standing between a seventh ActionKind and a step that " + f"plays as an empty pause." + ) + + +def _handled_actions() -> set[str]: + """The literals ``_replay_action`` compares ``cached.action`` against.""" + + handled: set[str] = set() + for node in ast.walk(_dispatch_function()): + if not isinstance(node, ast.Compare): + continue + left = node.left + if not (isinstance(left, ast.Attribute) and left.attr == "action"): + continue + handled |= { + comparator.value + for comparator in node.comparators + if isinstance(comparator, ast.Constant) and isinstance(comparator.value, str) + } + return handled + + +def test_the_scan_found_the_dispatch() -> None: + # Without this, a rewrite that stopped comparing `cached.action` to string + # literals would make `_handled_actions()` empty and turn the real assertion + # below into "the empty set is missing everything" — noisy, but only by luck. + # An empty scan is a broken guard, not a passing one. + assert _handled_actions(), ( + f"{DISPATCH!r} no longer compares `cached.action` against string literals, " + f"so this guard can no longer see which actions it handles" + ) + + +def test_every_action_kind_has_a_handler() -> None: + declared = set(get_args(ActionKind)) + handled = _handled_actions() + assert declared - handled == set(), ( + f"{sorted(declared - handled)} can appear in a compiled sidecar but " + f"{DISPATCH!r} does not handle it. The dispatch has no `else`, so such a " + f"step would be silently skipped: the render succeeds, the film is the " + f"right length, and the action simply never happens" + ) + assert handled - declared == set(), ( + f"{DISPATCH!r} handles {sorted(handled - declared)}, which `ActionKind` " + f"does not declare — dead branches in the one dispatch that must stay total" + )