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"
+ )