diff --git a/guidebot_recorder/guide/capture.py b/guidebot_recorder/guide/capture.py
index 7dc91ea..e8cf4ee 100644
--- a/guidebot_recorder/guide/capture.py
+++ b/guidebot_recorder/guide/capture.py
@@ -57,7 +57,7 @@
def scenario_resolve_url(scenario, url: str | None) -> str:
"""Resolve a possibly-relative navigate URL against the scenario base_url.
- Mirrors ``render._resolve_url`` (render.py:1474-1478): relative URLs are
+ Mirrors ``_resolve_url`` in ``recorder/render/reuse.py``: relative URLs are
joined onto ``config.base_url`` with ``urljoin`` only when a base is
configured; an absolute URL, or the absence of a base, passes through
unchanged. ``url`` is defensively allowed to be ``None`` (navigate steps
diff --git a/guidebot_recorder/guide/guide.py b/guidebot_recorder/guide/guide.py
index df0dfce..2b90e45 100644
--- a/guidebot_recorder/guide/guide.py
+++ b/guidebot_recorder/guide/guide.py
@@ -58,7 +58,7 @@ async def run_guide(
scan_for_blockers(flat, compiled.actions)
cfg = scenario.config
- # Same context recipe as run_render (render.py ~1705-1734), minus
+ # Same context recipe as run_render (recorder/render/_run.py), minus
# record_video_dir/record_video_size: PDF capture never records video.
context = await browser.new_context(
viewport={"width": cfg.viewport.width, "height": cfg.viewport.height},
diff --git a/guidebot_recorder/models/scenario.py b/guidebot_recorder/models/scenario.py
index d55aee4..91cc5d2 100644
--- a/guidebot_recorder/models/scenario.py
+++ b/guidebot_recorder/models/scenario.py
@@ -556,7 +556,8 @@ def select_mode(step: Step, cfg: Config) -> str:
Lives with the two models it reads rather than with either phase that
dispatches on it: it is a pure lookup over ``Step`` and ``Config``, and
- homing it in ``compile.py`` made ``render.py`` import the compiler for it.
+ homing it in the compile package made the render package import the compiler
+ for it.
"""
if step.select is not None and step.select.mode is not None:
diff --git a/guidebot_recorder/recorder/recorder.py b/guidebot_recorder/recorder/recorder.py
index e1b2496..19082b7 100644
--- a/guidebot_recorder/recorder/recorder.py
+++ b/guidebot_recorder/recorder/recorder.py
@@ -81,8 +81,9 @@ class SelectDriveError(RuntimeError):
be rewritten.
The render layer catches this and re-raises it as a ``RenderError`` carrying
- the index of the failing step. ``RenderError`` lives in ``render.py``, which
- imports this module, so it cannot be raised from here without a cycle.
+ the index of the failing step. ``RenderError`` lives in
+ ``recorder/render/errors.py``, whose package imports this module, so it cannot
+ be raised from here without a cycle.
"""
def __init__(self, message: str, *, reason: str = UNDRIVABLE) -> None:
diff --git a/guidebot_recorder/recorder/render.py b/guidebot_recorder/recorder/render.py
deleted file mode 100644
index 739834a..0000000
--- a/guidebot_recorder/recorder/render.py
+++ /dev/null
@@ -1,3016 +0,0 @@
-"""The `render` phase — deterministic replay + film assembly (§8/§9).
-
-Phase 0: pre-synthesize every configured narration track into the cache.
-Render: 0×LLM, fresh browser, single pass; narration drives the pace.
-Assembly: Playwright video + language audio beds (ffmpeg), approximate sync (K2).
-
-Resolved actions are read from the separate ``*.compiled.yaml`` sidecar.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import math
-import os
-import shutil
-import sys
-import tempfile
-import time
-from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
-from dataclasses import dataclass
-from importlib.resources import as_file, files
-from pathlib import Path
-from urllib.parse import urljoin
-
-from playwright.async_api import (
- Browser,
- Frame,
- Page,
- Video,
-)
-from playwright.async_api import (
- Error as PlaywrightError,
-)
-from playwright.async_api import (
- TimeoutError as PlaywrightTimeoutError,
-)
-from tqdm import tqdm
-
-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,
- Fingerprint,
- PendingAction,
-)
-from guidebot_recorder.models.compiled import CompiledAction
-from guidebot_recorder.models.config import (
- ChromeConfig,
- SoundConfig,
- TtsConfig,
- Viewport,
- config_hash,
-)
-from guidebot_recorder.models.scenario import FlatStep, Scenario, Step, WaitUntil, select_mode
-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, SelectDriveError
-from guidebot_recorder.recorder.session import ensure_session
-from guidebot_recorder.resolver.reasoner import Reasoner
-from guidebot_recorder.resolver.resolution import (
- ResolvedTarget,
- TargetAbsent,
- compiled_from,
- heuristic_expect,
- resolve_step_target,
- step_instruction,
-)
-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 (
- CACHE_SCHEMA_VERSION,
- Segment,
- TtsCache,
- TtsProvider,
- cache_key,
-)
-from guidebot_recorder.video.audiobed import Placed, build_audio_bed
-
-# `probe_duration` is a test seam: the mux facade withholds it and the call must
-# stay late-bound, so its defining module is imported below instead. Aliased
-# because `probe` is already used as a local name elsewhere in this module.
-from guidebot_recorder.video.mux import (
- FadeSpec,
- MuxAudioTrack,
- compose_popup_video,
- detect_content_crop,
- mux_audio_tracks,
-)
-from guidebot_recorder.video.mux import probe as mux_probe
-from guidebot_recorder.video.sfx import build_sfx_bed, mix_sfx_into_bed
-from guidebot_recorder.video.timeline import (
- TimeEdit,
- Timeline,
- apply_time_edits,
- assert_recording_fps,
- frames_to_seconds,
- probe_frame_count,
- seconds_to_frames,
-)
-
-_POPUP_DETECTION_SECONDS = 1.0
-_POPUP_QUIESCENCE_SECONDS = 0.1
-_VIDEO_POSTROLL_SECONDS = 0.1
-_TTS_CONCURRENCY = 8
-#: how often a pending gate is re-resolved while its wait window is still open
-_PENDING_POLL_SECONDS = 0.25
-# Each worker can own a full ffmpeg process. Keep the pool below both the host's
-# CPU count and a conservative process ceiling instead of scaling with languages.
-_AUDIO_BED_CONCURRENCY = max(1, min(4, os.cpu_count() or 1))
-
-
-class RenderError(RuntimeError):
- """A step needs (re-)compile: missing action or mismatched identity."""
-
-
-class _OptionalAbsent(Exception):
- """The element an *optional* step or branch gate stands for is simply not there.
-
- Deliberately narrow, and deliberately not a :class:`RenderError`: it is raised
- only from the four signals the design admits as "absent" (a timed-out cached
- ``waitFor``, a ``no_action``/``no_handle`` verdict, an elapsed poll window, a
- failed ``reuse_is_valid``). Everything else — an ambiguous description, a click
- that fails on a resolved target, a navigation error — keeps propagating and
- fails the render, so ``optional`` cannot decay into ``except Exception: pass``.
- """
-
-
-#: A slide card's on-screen content, as consumed by ``SlideOverlay.show``/``.ensure``.
-Card = dict[str, str | None]
-
-
-@dataclass(slots=True)
-class _PageObservation:
- opened_at: float
- video: Video | None
- closed_at: float | None = None
- visual_prime: asyncio.Task[float | None] | None = None
-
-
-@dataclass(slots=True)
-class _PopupSession:
- page: Page
- video: Video
- opened_at: float
- visual_ready_delay: float = 0.0
- closed_at: float | None = None
- close_handled: bool = False
- main_cursor_pos: tuple[float, float] | None = None
- """Where the main window's cursor stood before the popup took it over.
-
- :attr:`Overlay.pos` is shared by every page, so centring the cursor in the
- popup overwrites the opener's. Restored on close.
- """
- is_blank_tab: bool = False
- """Whether this window is a real browser tab rather than a popup window.
-
- True when the opener never called ``window.open`` at all — the
- ``target="_blank"`` case. Decided at furnishing time via
- :func:`_popup_window_opened`, because the crop chain's verdict does not
- exist until the recording is over.
-
- Do **not** substitute ``popup_crop is None`` for this. That is a weaker
- signal: a *featureless* ``window.open`` whose page paints a full-bleed
- background also declines every crop level, and it is a genuine floating
- window that must keep the ``float`` presentation (pinned by
- ``test_full_bleed_featureless_popup_renders_uncropped``).
- """
- wants_bar: bool = False
- """Whether this window shows the legacy in-DOM address bar.
-
- True only for a real ``target="_blank"`` tab — a browser tab with no address
- bar reads as a rendering fault. Every other popup stays bare and is framed by
- the compositor instead. Decided at open time from
- :func:`_popup_window_opened`, because the crop chain's verdict does not exist
- until the recording is over, and the bar is painted DOM that would corrupt
- crop levels 2 and 3.
- """
- window_size: tuple[int, int] | None = None
- """The popup's real window size in CSS px, or ``None`` when unknown.
-
- Playwright gives every page in the context the *same* recording canvas (see
- ``record_video_size`` below), so the popup's MP4 is main-viewport sized no
- matter how small a window the site asked for. This is the geometry the
- compositor crops that canvas back down to.
- """
- viewport: tuple[int, int] | None = None
- """The popup page's own layout viewport in CSS px, or ``None`` if unknown.
-
- Not the same thing as :attr:`window_size` (which the site *asked* for) nor as
- the recording canvas: it is what the popup actually got, and it is the unit
- levels 1 and 2 measure in. Paired with the canvas it lets
- :func:`_recording_scale` convert those measurements into recording pixels.
- """
- content_box: tuple[int, int, int, int] | None = None
- """The popup's painted content as ``(width, height, x, y)``, or ``None``.
-
- Level 2 of the crop chain — what a featureless ``window.open(url, name)``
- leaves us with. Measured while the popup page is alive: the recording
- outlives the page, its DOM does not.
- """
- content_box_probe: asyncio.Task[tuple[int, int, int, int] | None] | None = None
- """The in-flight measurement, until :func:`_settle_popup_content_box` reaps it.
-
- Kept pending on purpose: starting it when the popup opens and collecting it
- at the end of the run keeps its cost out of the recorded timeline.
- """
-
-
-# The site's own ``window.open(url, name, "width=...,height=...")`` request is
-# the most deterministic statement of how big the popup window really is, and
-# Chromium honours it: a popup opened with size features gets exactly that
-# viewport, *not* the context's (verified — ``new_context(viewport=...)`` does
-# not override the request; only a featureless ``window.open`` inherits the
-# context viewport, which is what levels 2 and 3 exist for). It is read here
-# rather than from the popup because it is available the moment the popup
-# opens, before its document has committed. Record the request on the *opener*
-# frame; the popup is matched to it right after it opens.
-#
-# Note the unit: this is the popup's viewport in **CSS px**, which is only the
-# same as its size in the *recording* when the compositor draws at scale 1. See
-# :func:`_recording_scale` for the conversion and for why headed renders differ.
-_POPUP_REQUEST_KEY = "__guidebot_popup_request"
-
-# Set unconditionally the moment ``window.open`` runs, features or not. This is
-# what tells a real ``target="_blank"`` tab (no call at all) apart from a
-# featureless ``window.open(url, name)`` (a call that leaves ``KEY`` at
-# ``null``, exactly like never having been called) — a distinction
-# ``_POPUP_REQUEST_KEY`` alone cannot make. See ``_popup_window_opened``.
-_POPUP_OPENED_KEY = "__guidebot_popup_opened"
-
-# How long the opener's frames get to answer the geometry probe. They read a
-# value the page already wrote synchronously, so a healthy frame answers in
-# milliseconds; the budget exists purely to survive frames that can never
-# answer at all (see ``_scan_frames_for_window_request``).
-_POPUP_REQUEST_LOOKUP_TIMEOUT = 2.0
-
-# Absolute ceiling on the lookup, cleanup included. The scan bounds itself, so
-# reaching this means the bounding itself misbehaved; it exists so that no page
-# can ever hang a render here.
-_POPUP_REQUEST_HARD_TIMEOUT = 5.0
-
-_POPUP_REQUEST_SCRIPT = f"""
-(() => {{
- const KEY = "{_POPUP_REQUEST_KEY}";
- const OPENED = "{_POPUP_OPENED_KEY}";
- if (Object.prototype.hasOwnProperty.call(window, KEY)) return;
- const native = window.open;
- if (typeof native !== "function") return;
- window[KEY] = null;
- window[OPENED] = false;
- // Captured before chrome.js shadows ``top`` (frame-bust neutralization), so
- // this is the *real* top document even from inside the shell's site iframe.
- // Publishing the record there lets the Python side read one frame instead of
- // interrogating every ad iframe on the page.
- const realTop = window.top;
- const parse = (features) => {{
- if (typeof features !== "string") return null;
- const sizes = {{}};
- for (const part of features.split(",")) {{
- const eq = part.indexOf("=");
- if (eq < 0) continue;
- const value = Number(part.slice(eq + 1).trim());
- if (Number.isFinite(value)) sizes[part.slice(0, eq).trim().toLowerCase()] = value;
- }}
- const width = sizes.width !== undefined ? sizes.width : sizes.innerwidth;
- const height = sizes.height !== undefined ? sizes.height : sizes.innerheight;
- if (!(width > 0) || !(height > 0)) return null;
- return {{ width: Math.round(width), height: Math.round(height) }};
- }};
- window.open = function (...args) {{
- window[OPENED] = true;
- try {{
- if (realTop && realTop !== window) realTop[OPENED] = true;
- }} catch (e) {{}}
- const requested = parse(args[2]);
- if (requested) {{
- window[KEY] = requested;
- // Best effort: a genuinely cross-origin opener frame cannot write to the
- // top document, and that is fine — the Python side falls back to a
- // bounded per-frame scan.
- try {{
- if (realTop && realTop !== window) realTop[KEY] = requested;
- }} catch (e) {{}}
- }}
- return native.apply(this, args);
- }};
-}})();
-"""
-
-
-def _parse_window_request(requested: object) -> tuple[int, int] | None:
- """Validate one ``window.open`` geometry record read back from the page."""
- if not isinstance(requested, dict):
- return None
- width, height = requested.get("width"), requested.get("height")
- if not isinstance(width, int | float) or not isinstance(height, int | float):
- return None
- if width <= 0 or height <= 0:
- return None
- return int(width), int(height)
-
-
-def _discard_pending(task: asyncio.Future) -> None:
- """Cancel a lookup we no longer need, and never orphan its exception.
-
- A cancelled ``Frame.evaluate`` can still settle later — typically with
- ``Frame was detached`` — and an unread exception on an abandoned future is
- what produces asyncio's "Future exception was never retrieved" noise.
- Retrieving it in a done-callback keeps that silent.
- """
-
- if task.done():
- # Already settled, so there is nothing to cancel — but a probe that
- # raised and was never read is orphaned just the same. Read it.
- if not task.cancelled():
- task.exception()
- return
- task.cancel()
- task.add_done_callback(lambda done: done.cancelled() or done.exception())
-
-
-async def _frame_window_request(frame: Frame) -> tuple[int, int] | None:
- """Read one frame's ``window.open`` record, or ``None`` if it cannot answer."""
-
- try:
- requested = await frame.evaluate(f"() => window.{_POPUP_REQUEST_KEY} || null")
- except PlaywrightError:
- # The frame navigated away, detached, or never ran the init script.
- return None
- return _parse_window_request(requested)
-
-
-async def _scan_frames_for_window_request(opener: Page) -> tuple[int, int] | None:
- """Ask every frame for its record *concurrently* and return the first answer.
-
- Concurrency is the point, not an optimisation. ``Frame.evaluate`` accepts no
- timeout, and an ad-heavy page routinely carries iframes whose document never
- commits an execution context — evaluating on one of those never returns.
- Asking the frames one after another therefore lets a single dead ad iframe
- block the render forever (and it did).
-
- Asking them all at once is only half the fix, though: waiting for *all* the
- probes means a dead frame still costs the whole
- ``_POPUP_REQUEST_LOOKUP_TIMEOUT`` even when a healthy frame answered in
- milliseconds — and because this runs on every popup open, that budget shows
- up in the finished film as a dead pause on the popup. So the scan settles
- the moment it has a usable answer and abandons the rest. The budget survives
- unchanged as what it was always meant to be: the ceiling for the case where
- *nobody* ever answers, after which the caller falls back to the content
- bounding box (and then to cropdetect).
-
- Which answer wins when several frames have one: the probes are examined in a
- fixed priority order — the top document first, then the remaining frames
- newest-first — and the highest-priority *already-settled* answer is taken.
- That is safe because the answers are not really independent. The init script
- republishes every record on the real top document, so a same-origin page has
- one record read back from several places; a cross-origin opener frame that
- could not republish is instead the only frame holding one, and the top
- answers ``None``. Genuinely conflicting records would need two frames to
- each call ``window.open`` with different sizes before this runs, and the
- scan runs immediately after the popup opens — newest-first is then exactly
- the record that opened it. Worst case the crop rect is one popup stale,
- which the downstream fallbacks already tolerate.
- """
-
- top = opener.main_frame
- frames = [top, *(frame for frame in reversed(opener.frames) if frame is not top)]
- tasks = [asyncio.ensure_future(_frame_window_request(frame)) for frame in frames]
- loop = asyncio.get_running_loop()
- deadline = loop.time() + _POPUP_REQUEST_LOOKUP_TIMEOUT
- try:
- pending = set(tasks)
- while pending:
- remaining = deadline - loop.time()
- if remaining <= 0:
- break
- # Wake on every settled probe rather than on the slowest one. A
- # frame that raises simply settles without an answer: it retires
- # from ``pending`` and the others carry on.
- _, pending = await asyncio.wait(
- pending, timeout=remaining, return_when=asyncio.FIRST_COMPLETED
- )
- for task in tasks:
- if task.done() and not task.cancelled() and task.exception() is None:
- if (size := task.result()) is not None:
- return size
- return None
- finally:
- for task in tasks:
- _discard_pending(task)
-
-
-async def _popup_window_request(opener: Page) -> tuple[int, int] | None:
- """Return the window size the *opener* asked ``window.open`` for, if any.
-
- Bounded twice over, because this runs at the worst possible moment — right
- after a popup opens, while the opener is at its busiest — and a render that
- hangs here is unrecoverable. An unknown size is by contrast harmless: it
- means no crop, i.e. the full-canvas behaviour that predates popup cropping.
- So the lookup always terminates, and says so when it gives up.
- """
-
- lookup = asyncio.ensure_future(_scan_frames_for_window_request(opener))
- try:
- size = await asyncio.wait_for(asyncio.shield(lookup), _POPUP_REQUEST_HARD_TIMEOUT)
- except TimeoutError:
- # ``shield`` means the timeout does not itself cancel the lookup, so it
- # is still ours to dispose of cleanly.
- _discard_pending(lookup)
- tqdm.write(
- "OSTRZEŻENIE: odczyt rozmiaru okna popupu nie zakończył się w "
- f"{_POPUP_REQUEST_HARD_TIMEOUT:g}s — popup bez przycięcia",
- file=sys.stderr,
- )
- return None
- if size is None:
- tqdm.write(
- "OSTRZEŻENIE: żadna ramka nie podała rozmiaru okna z window.open() "
- "— popup bez przycięcia",
- file=sys.stderr,
- )
- return size
-
-
-async def _frame_window_opened(frame: Frame) -> bool | None:
- """Read one frame's "was window.open called" flag, or ``None`` if it cannot answer."""
-
- try:
- return bool(await frame.evaluate(f"() => window.{_POPUP_OPENED_KEY} === true"))
- except PlaywrightError:
- # The frame navigated away, detached, or never ran the init script.
- return None
-
-
-async def _scan_frames_for_window_opened(opener: Page) -> bool | None:
- """Ask every frame, concurrently, whether it recorded a ``window.open`` call.
-
- Mirrors ``_scan_frames_for_window_request``: the ``OPENED`` flag is mirrored
- to the real top document only as a same-origin optimisation (see
- ``_POPUP_REQUEST_SCRIPT``), so a call made from a genuinely cross-origin
- frame — routine for the shell's site iframe — never reaches that mirror,
- and the top frame alone cannot be trusted to answer. Every frame is asked
- directly instead, concurrently and for the same reason as the geometry
- scan: a dead ad iframe whose document never commits an execution context
- must not block the others, or the render, forever.
-
- Returns ``True`` the moment any frame reports the flag set, ``False`` if
- every frame answered and none did, or ``None`` if no frame could answer at
- all (the opener navigated away or died) — callers must treat ``None`` as
- unknown, not as "no popup".
- """
-
- top = opener.main_frame
- frames = [top, *(frame for frame in reversed(opener.frames) if frame is not top)]
- tasks = [asyncio.ensure_future(_frame_window_opened(frame)) for frame in frames]
- try:
- await asyncio.wait(tasks, timeout=_POPUP_REQUEST_LOOKUP_TIMEOUT)
- answered = False
- for task in tasks:
- if task.done() and not task.cancelled() and task.exception() is None:
- result = task.result()
- if result is True:
- return True
- if result is False:
- answered = True
- return False if answered else None
- finally:
- for task in tasks:
- _discard_pending(task)
-
-
-async def _popup_window_opened(page: Page) -> bool:
- """Whether this document called ``window.open`` at all, features or not.
-
- ``_popup_window_request`` answers "what geometry did the site ask for", and
- returns ``None`` both for a featureless ``window.open(url, name)`` and for a
- window this document never opened. Only the second is a ``target="_blank"``
- tab, and telling them apart is possible *while the popup is alive* — unlike
- the crop chain, whose verdict arrives after the recording is finished.
-
- Read per frame, not just the top document: the init script's mirror onto
- the real top document (``realTop[OPENED] = true``) is only a same-origin
- optimisation — it throws and is swallowed for a genuinely cross-origin
- frame, which is routine for the shell's site iframe. Reading only the top
- frame therefore misses exactly that call and misreports a real popup as a
- ``target="_blank"`` tab. ``_scan_frames_for_window_opened`` asks every
- frame instead, exactly as ``_popup_window_request`` already does for the
- geometry record.
-
- Bounded twice over, exactly like ``_popup_window_request``: this runs at
- the same worst possible moment — right after a popup opens, while the
- opener is at its busiest — and a render that hangs here is just as
- unrecoverable. An unknown answer is by contrast harmless: it means
- "assume a popup", i.e. the fail-safe direction that never mounts an
- address bar on uncertainty. So the lookup always terminates, and says so
- when it gives up.
- """
-
- def _assume_opened() -> bool:
- tqdm.write(
- "OSTRZEŻENIE: żadna ramka strony otwierającej nie potwierdziła wywołania "
- "window.open() — zakładam otwarty popup (bez paska adresu)",
- file=sys.stderr,
- )
- return True
-
- lookup = asyncio.ensure_future(_scan_frames_for_window_opened(page))
- try:
- opened = await asyncio.wait_for(asyncio.shield(lookup), _POPUP_REQUEST_HARD_TIMEOUT)
- except TimeoutError:
- # ``shield`` means the timeout does not itself cancel the lookup, so it
- # is still ours to dispose of cleanly.
- _discard_pending(lookup)
- tqdm.write(
- "OSTRZEŻENIE: sprawdzenie wywołania window.open() nie zakończyło się w "
- f"{_POPUP_REQUEST_HARD_TIMEOUT:g}s — zakładam otwarty popup (bez paska adresu)",
- file=sys.stderr,
- )
- return True
- except PlaywrightError:
- # An opener that navigated or died reports nothing; treat it as "unknown",
- # which keeps today's bare-popup behaviour rather than mounting a bar.
- return _assume_opened()
- if opened is None:
- return _assume_opened()
- return opened
-
-
-#: A content bounding box at least this fraction of the viewport in *both*
-#: dimensions is refused as degenerate. ``body``'s children can trivially fill
-#: the frame (a ``100vw/100vh`` wrapper, a full-bleed background), in which case
-#: the box merely restates the context viewport this whole mechanism exists to
-#: correct, and level 3 has to decide instead.
-POPUP_BBOX_DEGENERATE_RATIO = 0.98
-
-#: How long the popup's content measurement gets to come back. The walk is
-#: bounded in the page (see ``_POPUP_CONTENT_BOX_SCRIPT``), so a healthy popup
-#: answers in single-digit milliseconds; the budget exists for the same reason
-#: the ``window.open`` lookup has one — ``Page.evaluate`` accepts no timeout, and
-#: a document that never commits an execution context never answers at all.
-_POPUP_CONTENT_BOX_TIMEOUT = 2.0
-
-#: Level 2: measure the popup's painted content from inside the popup document.
-#: Deliberately skips ``documentElement`` and ``body`` — both are laid out
-#: against the *context* viewport, so both report the very number being
-#: corrected. Only ``body``'s children are measured, and a child that paints
-#: nothing itself (a transparent layout wrapper) is descended into rather than
-#: counted, so a full-width wrapper around a small dialog does not inflate the
-#: result. Guidebot's own injected overlays (``data-guidebot-*``) are skipped:
-#: the cursor lives in the popup too and would stretch the box to wherever it
-#: happens to sit. A document that paints its own page background declines
-#: outright — see ``paintsPage`` below.
-_POPUP_CONTENT_BOX_SCRIPT = """
-() => {
- const body = document.body;
- if (!body) return null;
- const vw = Math.round(window.innerWidth);
- const vh = Math.round(window.innerHeight);
- if (!(vw > 0) || !(vh > 0)) return null;
- // A popup that paints its own page background is full-bleed by construction:
- // the background covers the whole window, so the union of body's children is
- // only the *ink* on top of it and cropping to that would cut the popup's
- // background away. ``body``/``documentElement`` are skipped everywhere else in
- // this walk precisely because their box is the context viewport — but whether
- // they PAINT is a different question, and the honest answer here is "decline".
- // An unstyled document leaves both transparent (the white is the canvas, not a
- // background), so ordinary dialogs are unaffected.
- const paintsPage = (el) => {
- if (!el) return false;
- const style = getComputedStyle(el);
- const bg = style.backgroundColor;
- if (bg && bg !== "transparent" && !/^rgba\\(0,\\s*0,\\s*0,\\s*0\\)$/.test(bg)) return true;
- return Boolean(style.backgroundImage) && style.backgroundImage !== "none";
- };
- if (paintsPage(document.documentElement) || paintsPage(body)) return null;
- const MAX_DEPTH = 12;
- const MAX_NODES = 1200;
- // The walk runs on the popup's main thread while the popup is on camera, so
- // it is bounded by wall clock as well as by node count: whatever the DOM
- // looks like, it cannot cost the recording more than this. Overrunning
- // abandons the measurement (partial unions are worse than none — they would
- // crop away real content), and level 3 decides instead.
- const BUDGET_MS = 20;
- const deadline = performance.now() + BUDGET_MS;
- let overran = false;
- const REPLACED = new Set([
- "IMG", "SVG", "CANVAS", "VIDEO", "IFRAME", "INPUT", "TEXTAREA",
- "SELECT", "BUTTON", "OBJECT", "EMBED", "HR",
- ]);
- let visited = 0;
- let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
- const add = (rect) => {
- if (!rect) return;
- const l = Math.max(0, rect.left);
- const t = Math.max(0, rect.top);
- const r = Math.min(vw, rect.right);
- const b = Math.min(vh, rect.bottom);
- if (!(r > l) || !(b > t)) return;
- if (l < left) left = l;
- if (t < top) top = t;
- if (r > right) right = r;
- if (b > bottom) bottom = b;
- };
- const isOverlay = (el) => {
- for (const attr of el.attributes || []) {
- if (attr.name.startsWith("data-guidebot")) return true;
- }
- return false;
- };
- const paints = (el, style) => {
- if (REPLACED.has(el.tagName)) return true;
- const bg = style.backgroundColor;
- if (bg && bg !== "transparent" && !/^rgba\\(0,\\s*0,\\s*0,\\s*0\\)$/.test(bg)) return true;
- if (style.backgroundImage && style.backgroundImage !== "none") return true;
- if (style.boxShadow && style.boxShadow !== "none") return true;
- for (const side of ["Top", "Right", "Bottom", "Left"]) {
- if (parseFloat(style["border" + side + "Width"]) > 0) return true;
- }
- return false;
- };
- const walk = (el, depth) => {
- if (overran) return;
- if (visited++ > MAX_NODES || performance.now() > deadline) {
- overran = true;
- return;
- }
- if (isOverlay(el)) return;
- const style = getComputedStyle(el);
- if (style.display === "none" || style.visibility === "hidden") return;
- if (Number(style.opacity) === 0) return;
- if (paints(el, style) || depth >= MAX_DEPTH) {
- add(el.getBoundingClientRect());
- return;
- }
- let descended = false;
- for (const node of el.childNodes) {
- if (node.nodeType === Node.ELEMENT_NODE) {
- walk(node, depth + 1);
- descended = true;
- } else if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim()) {
- // Measure the glyphs, not the block box the text sits in: a
is as
- // wide as its container, its text usually is not.
- const range = document.createRange();
- range.selectNodeContents(node);
- add(range.getBoundingClientRect());
- descended = true;
- }
- }
- if (!descended) add(el.getBoundingClientRect());
- };
- for (const child of body.children) walk(child, 1);
- if (overran) return null;
- if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
- if (!(right > left) || !(bottom > top)) return null;
- return {
- x: left,
- y: top,
- width: right - left,
- height: bottom - top,
- viewportWidth: vw,
- viewportHeight: vh,
- };
-}
-"""
-
-
-def _parse_content_box(measured: object) -> tuple[int, int, int, int] | None:
- """Validate and gate one content bounding box read back from a popup.
-
- Returns ``(width, height, x, y)`` in recording pixels, or ``None`` when the
- measurement is unusable — non-finite, empty, or *degenerate*: at least
- :data:`POPUP_BBOX_DEGENERATE_RATIO` of the viewport in **both** dimensions,
- which means the content genuinely is full-bleed and the box says nothing the
- uncropped canvas did not already say. The origin rounds down and the far
- edges round up, so rounding never shaves a painted pixel.
- """
- if not isinstance(measured, dict):
- return None
- values: list[float] = []
- for key in ("x", "y", "width", "height", "viewportWidth", "viewportHeight"):
- value = measured.get(key)
- if isinstance(value, bool) or not isinstance(value, int | float):
- return None
- if not math.isfinite(float(value)):
- return None
- values.append(float(value))
- x, y, width, height, viewport_width, viewport_height = values
- if width <= 0 or height <= 0 or viewport_width <= 0 or viewport_height <= 0:
- return None
- if (
- width >= viewport_width * POPUP_BBOX_DEGENERATE_RATIO
- and height >= viewport_height * POPUP_BBOX_DEGENERATE_RATIO
- ):
- return None
- left, top = math.floor(x), math.floor(y)
- return (
- math.ceil(x + width) - left,
- math.ceil(y + height) - top,
- max(0, left),
- max(0, top),
- )
-
-
-async def _popup_content_box(popup: Page) -> tuple[int, int, int, int] | None:
- """Measure the popup's painted content while the page is still alive.
-
- Level 2 of the crop chain, used when the site opened the popup without size
- features. A closed page, a navigation race or a hostile document degrades to
- ``None`` (fall through to level 3) rather than failing the render.
- """
- try:
- measured = await popup.evaluate(_POPUP_CONTENT_BOX_SCRIPT)
- except PlaywrightError:
- return None
- return _parse_content_box(measured)
-
-
-def _start_popup_content_box(popup: Page) -> asyncio.Task[tuple[int, int, int, int] | None]:
- """Begin measuring the popup off the render's critical path.
-
- The measurement has to happen while the popup's DOM exists, and the DOM dies
- long before composition — but awaiting it where it starts would put its
- latency *inside the recorded timeline*, right at the moment the popup appears
- on camera. So it is started here and collected later
- (:func:`_settle_popup_content_box`), by which time it has long since
- finished: the render does not block on it at all.
- """
-
- return asyncio.ensure_future(_popup_content_box(popup))
-
-
-async def _settle_popup_content_box(popup: _PopupSession) -> None:
- """Collect the pending measurement, bounded, before the context goes away.
-
- Must run while the browser context is still open — a closed context can only
- answer with "target closed". Bounded for the same reason the ``window.open``
- lookup is: ``Page.evaluate`` never times out on its own, and no popup may
- hang a render. Giving up costs the crop, not the video.
- """
-
- probe = popup.content_box_probe
- if probe is None:
- return
- popup.content_box_probe = None
- try:
- popup.content_box = await asyncio.wait_for(
- asyncio.shield(probe), _POPUP_CONTENT_BOX_TIMEOUT
- )
- except TimeoutError:
- # ``shield`` keeps the timeout from cancelling the probe itself, so it is
- # still ours to dispose of without orphaning its exception.
- _discard_pending(probe)
- tqdm.write(
- "OSTRZEŻENIE: pomiar zawartości popupu nie zakończył się w "
- f"{_POPUP_CONTENT_BOX_TIMEOUT:g}s — przycięcie z heurystyki obrazu",
- file=sys.stderr,
- )
- except PlaywrightError:
- # The popup closed under the probe: level 3 decides instead.
- popup.content_box = None
-
-
-def _page_viewport(page: Page) -> tuple[int, int] | None:
- """*page*'s layout viewport as a plain pair, or ``None`` when unset."""
- viewport = page.viewport_size
- if viewport is None:
- return None
- return viewport["width"], viewport["height"]
-
-
-def _log_popup_crop(level: str, crop: tuple[int, int, int, int] | None, verbose: bool) -> None:
- """Say which fallback level framed the popup — silent degradation is a bug."""
- if not verbose:
- return
- if crop is None:
- tqdm.write(f" ⤷ kadr popupu: {level} nic nie wykrył — pełne płótno nagrania")
- return
- width, height, x, y = crop
- tqdm.write(f" ⤷ kadr popupu: {level} → {width}x{height}+{x}+{y}")
-
-
-#: Backing scales a compositor plausibly renders a window at. Headless Chromium
-#: draws at 1; a headed window inherits its screen's scale factor, which is 2 on
-#: every shipping HiDPI display and 3 only on the densest mobile-class panels.
-#: Used to sanity-check a *measured* recording scale, never to pick one.
-_PLAUSIBLE_BACKING_SCALES = (1, 2, 3)
-
-#: How far a measured recording scale may sit from the nearest scale the fit
-#: rule can produce. Wide enough for the recorder's even-pixel snapping (a 597px
-#: window records as 596), far too narrow to admit an unrelated rect.
-_RECORDING_SCALE_TOLERANCE = 0.02
-
-
-def _recording_scale(
- measured: tuple[int, int, int, int] | None,
- viewport: tuple[int, int] | None,
- canvas: tuple[int, int] | None,
-) -> float | None:
- """How many recording pixels the popup got per CSS pixel, or ``None``.
-
- Playwright records every page in a context onto one canvas of
- ``record_video_size``, and fits each page's *compositor* frame into it
- preserving aspect ratio, anchored top-left, padding the rest. That frame is
- in **device** pixels: a headed browser on a HiDPI screen composites at the
- screen's backing scale, so a 500x670 popup arrives as 1000x1340 and is fitted
- into a 1376x800 canvas at 0.597 — leaving the window occupying 597x800
- *recording* pixels, not 500x670. Headless composites at 1 and the same popup
- records 1:1, which is why this only ever bit headed renders.
-
- The backing scale is not observable from the page (``devicePixelRatio``
- reports the emulated 1) nor from ``screenshot(scale="device")``, so it is
- read back off the recording: *measured* is the popup's rendered rect as
- trimmed out of the padding, and the scale is its width over the viewport's.
-
- The measurement is a pixel heuristic, so it is only believed when it looks
- like a fitted window: anchored at the origin, aspect preserved, and within
- :data:`_RECORDING_SCALE_TOLERANCE` of a scale the fit rule can actually
- produce for some plausible backing scale. Anything else — a page whose own
- background matched the padding colour, ink mistaken for a window — returns
- ``None``, i.e. "assume 1:1", which is the behaviour that predates this.
- """
- if measured is None or viewport is None or canvas is None:
- return None
- width, height, x, y = measured
- viewport_width, viewport_height = viewport
- if (x, y) != (0, 0) or viewport_width <= 0 or viewport_height <= 0:
- return None
- if width <= 0 or height <= 0:
- return None
- scale = width / viewport_width
- if abs(height / viewport_height - scale) > _RECORDING_SCALE_TOLERANCE * scale:
- return None # not a uniformly scaled window
- fitted = [
- min(backing, canvas[0] / viewport_width, canvas[1] / viewport_height)
- for backing in _PLAUSIBLE_BACKING_SCALES
- ]
- if not any(
- abs(scale - candidate) <= _RECORDING_SCALE_TOLERANCE * candidate for candidate in fitted
- ):
- return None
- return scale
-
-
-def _scale_rect(rect: tuple[int, int, int, int], scale: float) -> tuple[int, int, int, int]:
- """Map a CSS-pixel ``(width, height, x, y)`` into recording pixels.
-
- The origin floors and the far edges ceil, so the rounding can only ever add
- a fringe pixel of padding — never shave one off the popup's own ink.
- """
- width, height, x, y = rect
- left, top = math.floor(x * scale), math.floor(y * scale)
- return (
- math.ceil((x + width) * scale) - left,
- math.ceil((y + height) * scale) - top,
- left,
- top,
- )
-
-
-def _resolve_popup_crop(
- *,
- window_size: tuple[int, int] | None,
- content_box: tuple[int, int, int, int] | None,
- popup_video: Path,
- verbose: bool,
- viewport: tuple[int, int] | None = None,
- canvas: tuple[int, int] | None = None,
-) -> tuple[tuple[int, int, int, int] | None, str]:
- """Pick the popup crop rect from the best available witness.
-
- Three levels, most deterministic first, returned with the name of the one
- that answered so the log can say which framed the video:
-
- 1. ``window.open`` size features — what the site literally asked for.
- 2. the popup's content bounding box — measured in the page, gated by
- :data:`POPUP_BBOX_DEGENERATE_RATIO`.
- 3. ``cropdetect`` over the recording — a pixel heuristic, last resort.
-
- Levels 1 and 2 speak **CSS pixels** and the crop is applied to the
- *recording*, which are not the same unit — see :func:`_recording_scale`. The
- same padding-trimming pass that backs level 3 supplies the conversion factor
- whenever there is a *viewport* and a *canvas* to correct between; without
- them, or when the measurement does not look like a fitted window, the rects
- pass through as-is (1:1, which is what headless produces).
-
- All three declining yields ``(None, "none")``: the pre-crop full-canvas
- filtergraph, byte for byte.
- """
- css_evidence = window_size is not None or content_box is not None
- measured = (
- detect_content_crop(popup_video)
- if not css_evidence or (viewport is not None and canvas is not None)
- else None
- )
- if window_size is not None or content_box is not None:
- scale = _recording_scale(measured, viewport, canvas)
- if verbose and scale is not None and abs(scale - 1.0) > _RECORDING_SCALE_TOLERANCE:
- tqdm.write(f" ⤷ nagranie popupu w skali {scale:.3f}× — kadr przeliczony")
- if window_size is not None:
- # The measured rect *is* the whole window in recording pixels, so
- # prefer it over rescaling the request: it needs no rounding and
- # cannot leave a seam of padding along the right or bottom edge.
- crop = measured if scale is not None else (window_size[0], window_size[1], 0, 0)
- assert crop is not None
- _log_popup_crop("window.open", crop, verbose)
- return crop, "window.open"
- assert content_box is not None
- crop = content_box if scale is None else _scale_rect(content_box, scale)
- _log_popup_crop("bbox", crop, verbose)
- return crop, "bbox"
- _log_popup_crop("cropdetect", measured, verbose)
- return (measured, "cropdetect") if measured is not None else (None, "none")
-
-
-def _popup_fills_canvas(popup_crop: tuple[int, int, int, int] | None, viewport: Viewport) -> bool:
- """Whether the popup window occupies the entire recording canvas.
-
- ``None`` is the ``_blank`` tab case: every crop level declined, so no witness
- could name a window smaller than the recording. An explicit rect covering the
- canvas at the origin says the same thing positively — a ``window.open`` that
- asked for the full viewport.
-
- The distinction matters because ``float`` insets the popup at
- :attr:`PopupConfig.scale`; applied to a full viewport that reads as a shrunken
- clone of the page rather than as a separate window.
-
- ``viewport`` here is *not* CSS pixels despite the name: ``cfg.viewport``'s
- width/height are passed verbatim as ``record_video_size`` when the browser
- context is created (see ``render.py`` around the ``browser.new_context`` call),
- so it already is the canvas measured in recording pixels — the same unit
- ``popup_crop`` is in. No CSS-to-recording-pixel conversion belongs here.
- """
-
- if popup_crop is None:
- return True
- width, height, x, y = popup_crop
- return (x, y) == (0, 0) and (width, height) == (viewport.width, viewport.height)
-
-
-@dataclass(slots=True)
-class _TtsWork:
- text: str
- config: TtsConfig
- destinations: list[tuple[str, int]]
-
-
-def _active_page(main_page: Page, popup: _PopupSession | None) -> Page:
- if main_page.is_closed():
- raise RenderError("główne okno zostało zamknięte podczas render")
- if popup is not None and not popup.page.is_closed():
- return popup.page
- return main_page
-
-
-def _expect_chrome(chrome: Chrome | None, bare_popups: bool) -> bool:
- """Whether the legacy in-DOM chrome bar (``[data-guidebot-chrome]``) is expected.
-
- The bar is a context-wide init script, so ``bare_popups`` (floating) cannot
- suppress it on the popup alone — it suppresses it on *every* top-level
- non-shell document, including the main window's transient ``about:blank``
- warm-up before it becomes the shell. So the legacy bar is expected only when
- chrome is enabled and popups are not bare. The main window's real chrome is
- the shell (``install_shell`` / the shell branch of :func:`_ensure_visuals`),
- which is independent of this flag; the cursor overlay is always expected.
- """
-
- return chrome is not None and not bare_popups
-
-
-def _unexpected_pages(
- observed_pages: dict[Page, _PageObservation],
- main_page: Page,
- popup: _PopupSession | None,
-) -> list[Page]:
- """Observed pages outside the deterministic main + one-popup contract.
-
- The event-backed list deliberately retains pages that already closed, so an
- unexpected page cannot evade validation by opening and closing between steps.
- """
-
- expected_popup = popup.page if popup is not None else None
- return [page for page in observed_pages if page is not main_page and page is not expected_popup]
-
-
-def _sync_popup_close(
- popup: _PopupSession | None,
- observed_pages: dict[Page, _PageObservation],
- anchor: float,
-) -> None:
- if popup is None or popup.closed_at is not None:
- return
- observation = observed_pages.get(popup.page)
- if observation is not None and observation.closed_at is not None:
- popup.closed_at = max(popup.opened_at, observation.closed_at - anchor)
-
-
-async def _wait_for_render_popup(
- observed_pages: dict[Page, _PageObservation],
- known_pages: set[Page],
- started_at: float,
- timeout: float = _POPUP_DETECTION_SECONDS,
-) -> list[Page]:
- """Return pages opened inside the actual-click discovery window."""
-
- deadline = started_at + timeout
- while True:
- candidates = [
- page
- for page, observation in observed_pages.items()
- if page not in known_pages and started_at <= observation.opened_at <= deadline
- ]
- if candidates:
- await asyncio.sleep(_POPUP_QUIESCENCE_SECONDS)
- return sorted(
- (
- page
- for page, observation in observed_pages.items()
- if page not in known_pages and started_at <= observation.opened_at <= deadline
- ),
- key=lambda page: observed_pages[page].opened_at,
- )
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- return []
- await asyncio.sleep(min(0.05, remaining))
-
-
-def navigate_pill_mode(chrome: ChromeConfig, type_override: bool | None) -> str:
- """Select the main-window address-bar behavior for a ``navigate`` step.
-
- Returns one of ``"choreograph"`` (pointer → click → focus → natural type),
- ``"type"`` (typed pill only, no pointer), or ``"instant"`` (no typing). The
- caller still gates on ``chrome.enabled`` and ``show_url``.
- """
-
- animate = chrome.type_on_navigate if type_override is None else type_override
- if not animate:
- return "instant"
- return "choreograph" if chrome.interact_on_navigate else "type"
-
-
-def _is_shell_page(page: Page) -> bool:
- """True when ``page`` is the main-window shell (served from the sentinel origin)."""
-
- return page.url.startswith(SHELL_URL)
-
-
-#: Desktop-opener beats, in ms. The window growth (`_DESKTOP_OPEN_MS`) is the one
-#: the eye actually reads; the rest are short settles that keep the double-click
-#: legible without dragging the opener out.
-_DESKTOP_SETTLE_MS = 260
-_DESKTOP_DOUBLE_CLICK_GAP_MS = 130
-_DESKTOP_PRE_OPEN_MS = 220
-_DESKTOP_OPEN_MS = 760
-
-
-async def _play_desktop_opener(
- desktop: DesktopOverlay,
- overlay: Overlay,
- page: Page,
- payload: dict[str, str],
- *,
- hold: float,
- settle_ms: float,
- reveal: Callable[[], Awaitable[None]],
- on_click: Callable[[str], None] | None = None,
-) -> None:
- """Paint a desktop, arc the cursor to its icon, double-click, open the window.
-
- The visual half of a :class:`~guidebot_recorder.models.scenario.Desktop` step.
- Ends on the revealed underlay (the chrome shell) — *reveal* is called with the
- faux window still covering it, so hiding the desktop overlay uncovers a live
- browser rather than a blank frame. ``on_click`` (when given) stamps a click
- SFX at each of the two clicks.
-
- Fails loud if the icon cannot be located: the desktop was just painted, so a
- missing icon is a real overlay bug, not a case to paper over.
- """
- await desktop.show(page, payload)
- await overlay.show(page)
- center = await desktop.icon_center(page)
- if center is None:
- raise RenderError("nie udało się zlokalizować ikony pulpitu po jej narysowaniu")
- await overlay.move_to(page, center[0], center[1])
- await page.wait_for_timeout(max(settle_ms, _DESKTOP_SETTLE_MS))
- # Double-click: two ripples a beat apart, each with its own click SFX.
- await overlay.ripple(page)
- if on_click is not None:
- on_click("click")
- await page.wait_for_timeout(_DESKTOP_DOUBLE_CLICK_GAP_MS)
- await overlay.ripple(page)
- if on_click is not None:
- on_click("click")
- await page.wait_for_timeout(_DESKTOP_PRE_OPEN_MS)
- await desktop.open_window(page, _DESKTOP_OPEN_MS)
- await page.wait_for_timeout(_DESKTOP_OPEN_MS)
- # Uncover the live browser: show the underlay first, THEN drop the overlay, so
- # the reveal never flashes a blank document between the two.
- await reveal()
- await desktop.hide(page)
- if hold > 0:
- await page.wait_for_timeout(int(hold * 1000))
-
-
-async def _ensure_visuals(
- page: Page,
- overlay: Overlay,
- chrome: Chrome | None,
- *,
- expect_chrome: bool | None = None,
-) -> None:
- """Restore both DOM overlays in one browser task to avoid a partial frame.
-
- In the shell (main render window) the invariant is reworded: the site iframe
- and the shell bar live in the shell document (the framed site can no longer
- touch them), and the cursor is restored on the shell page. The pill URL is
- deliberately *not* resynced here — that would flip it to the shell sentinel
- URL; the pill is sourced from the site frame only on navigate steps.
-
- ``expect_chrome`` defaults to ``chrome is not None``; pass ``False`` for a
- bare (floating) popup so the chrome bar/API is not demanded or asserted while
- the cursor is still ensured.
- """
-
- if expect_chrome is None:
- expect_chrome = chrome is not None
-
- if chrome is not None and _is_shell_page(page):
- await chrome.ensure_shell(page)
- await overlay.ensure(page)
- return
-
- # The common path checks controller readiness and mounts both layers in one
- # browser task. A missing controller returns without painting; after Python
- # reinjects it, the same task is rerun in strict mode against the current
- # document. This retains page-replacement safety without paying two evaluate
- # round-trips when the init scripts are already alive.
- ensure_script = """async ([x, y, expectChrome, url, strict]) => {
- const cursor = window.__guidebot_cursor;
- const chrome = window.__guidebot_chrome;
- const cursorReady = !!cursor && ["ensure", "moveTo"].every(
- name => typeof cursor[name] === "function"
- );
- const chromeReady = !expectChrome || (
- !!chrome && ["ensure", "setUrl"].every(
- name => typeof chrome[name] === "function"
- )
- );
- if (!cursorReady || !chromeReady) {
- if (strict) {
- if (!cursorReady) {
- throw new Error("guidebot cursor API is unavailable after injection");
- }
- throw new Error("guidebot chrome API is unavailable after injection");
- }
- return {cursor: cursorReady, chrome: chromeReady, mounted: false};
- }
- if (expectChrome) {
- chrome.ensure(url);
- }
- cursor.ensure();
- await cursor.moveTo(x, y, 0);
- return {cursor: true, chrome: true, mounted: true};
- }"""
- args = [overlay.pos[0], overlay.pos[1], expect_chrome, page.url, False]
- try:
- readiness = await page.evaluate(ensure_script, args)
- except PlaywrightError as exc:
- # A prior click can trigger a navigation/reload the recorder never asked
- # for — e.g. a cookie banner whose accept runs `window.location.reload()`,
- # or a link that leaves for an external login. The next step's visual
- # mount then races the in-flight document swap and the context is
- # destroyed. Wait for the replacement document and retry once, re-reading
- # the URL for the fresh context. Mirrors the opener-settle retry in
- # `_prepare_main_after_popup_close`. A closed page is a real failure.
- if page.is_closed():
- raise RenderError("okno zamknęło się w trakcie montażu warstw wizualnych") from exc
- await page.wait_for_load_state()
- args[3] = page.url
- readiness = await page.evaluate(ensure_script, args)
- if readiness.get("mounted"):
- return
- # Context init scripts normally make both APIs available. Repair a missing
- # controller first; the retry still mounts both layers atomically.
- if chrome is not None and expect_chrome and not readiness.get("chrome"):
- await chrome.ensure(page)
- if not readiness.get("cursor"):
- await overlay.ensure(page)
- # A repair can race a navigation/document replacement. Read the URL again
- # for the strict mount instead of reusing the pre-repair snapshot.
- args[3] = page.url
- args[-1] = True
- await page.evaluate(ensure_script, args)
-
-
-async def _prime_visuals(
- page: Page,
- overlay: Overlay,
- chrome: Chrome | None,
- *,
- expect_chrome: bool | None = None,
- timeout: float = _POPUP_DETECTION_SECONDS,
-) -> float | None:
- """Mount visual layers from the page event, before its first useful frame.
-
- Chromium can replace a freshly opened ``about:blank`` document without
- rerunning init-script timers. Keep priming until the document root and both
- layers stay stable for one quiescence window, then force a captured frame.
-
- ``expect_chrome`` defaults to ``chrome is not None``; pass ``False`` for a
- bare (floating) popup so the prime loop does not wait for a
- ``[data-guidebot-chrome]`` bar that never mounts (the cursor is still
- required).
- """
-
- if expect_chrome is None:
- expect_chrome = chrome is not None
-
- deadline = time.monotonic() + timeout
- marker = f"{time.monotonic_ns()}-{id(page)}"
- stable_since: float | None = None
- status_script = """([token, expectChrome]) => {
- const root = document.documentElement;
- if (!root) return {ready: false};
- const sameRoot = root.__guidebotVisualPrime === token;
- root.__guidebotVisualPrime = token;
- return {
- ready: true,
- sameRoot,
- cursor: !!document.querySelector("[data-guidebot-cursor]"),
- chrome: !expectChrome || !!document.querySelector("[data-guidebot-chrome]"),
- };
- }"""
- while not page.is_closed():
- try:
- status = await page.evaluate(status_script, [marker, expect_chrome])
- now = time.monotonic()
- complete = (
- isinstance(status, dict)
- and status.get("ready") is True
- and status.get("sameRoot") is True
- and status.get("cursor") is True
- and status.get("chrome") is True
- )
- if not complete:
- await _ensure_visuals(page, overlay, chrome, expect_chrome=expect_chrome)
- stable_since = now
- elif stable_since is None:
- stable_since = now
- elif now - stable_since >= _POPUP_QUIESCENCE_SECONDS:
- await page.screenshot()
- final_status = await page.evaluate(status_script, [marker, expect_chrome])
- if (
- isinstance(final_status, dict)
- and final_status.get("sameRoot") is True
- and final_status.get("cursor") is True
- and final_status.get("chrome") is True
- ):
- return time.monotonic()
- stable_since = None
- except PlaywrightError:
- # A navigation may replace the execution context between the page
- # event and injection. Retry only inside the bounded prime window.
- if page.is_closed():
- return None
- stable_since = None
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise RenderError("nie udało się zainicjować warstw wizualnych nowej strony")
- await asyncio.sleep(min(0.01, remaining))
- return None
-
-
-async def _prepare_main_after_popup_close(
- page: Page,
- overlay: Overlay,
- chrome: Chrome | None,
- settle_ms: float,
- restore_cursor_to: tuple[float, float] | None = None,
-) -> None:
- """Let opener navigation settle before touching its execution context again.
-
- This is the single funnel for "the popup is gone, the main window is active
- again", so it is also where the cursor handed over to the popup by
- :func:`_hand_cursor_to_popup` is handed back.
-
- ``restore_cursor_to`` undoes that hand-over's side effect: centring the
- cursor in the popup moved the *shared* :attr:`Overlay.pos`, so without this
- the reappearing main-window cursor would jump to wherever the popup's centre
- happened to be rather than the control it last used.
- """
-
- if restore_cursor_to is not None:
- overlay.pos = restore_cursor_to
- await page.bring_to_front()
- await Recorder(page, None, settle_ms=settle_ms).apply_readiness("none")
- try:
- await page.wait_for_load_state()
- await _ensure_visuals(page, overlay, chrome)
- except PlaywrightError as exc:
- if page.is_closed():
- raise RenderError("główne okno zamknęło się po zamknięciu popupu") from exc
- # A navigation can destroy the context between the load-state check and
- # cursor restoration. Wait for the replacement document and retry once.
- await page.wait_for_load_state()
- await _ensure_visuals(page, overlay, chrome)
- await overlay.show(page)
-
-
-async def _hand_cursor_to_popup(main_page: Page, popup: _PopupSession, overlay: Overlay) -> None:
- """Move the cursor into the popup, centred, for as long as it is on screen.
-
- The cursor is a DOM element injected into every top-level document, so a
- popup mounts its *own* instance — leaving two cursors alive at once. The
- compositor keeps the main window's video visible around/behind the popup
- (and fully visible for a floating popup), so the stale main-window cursor
- would be recorded next to the live one. Only the active window paints a
- cursor; :func:`_prepare_main_after_popup_close` reverses this.
-
- ``hide`` is a per-page call on purpose — a context-wide init-script flag
- (like ``barePopups``) cannot target one window.
-
- The popup's own cursor is then parked at the centre of its viewport and
- revealed. Without this it would inherit :attr:`Overlay.pos` — coordinates
- from the *main* window, typically the opener control in a corner — and stay
- invisible there until the first action moved it. ``ms=0`` because a glide
- would be a slide in from wherever the other window's cursor happened to be,
- which reads as motion that never occurred.
- """
-
- if main_page.is_closed():
- return
- try:
- await overlay.hide(main_page)
- except PlaywrightError:
- # The opener can navigate/close under us while the popup is opening;
- # the loop's own lifecycle checks report that authoritatively.
- if not main_page.is_closed():
- raise
- # Remembered so closing the popup does not leave the main window's cursor
- # parked at the popup's centre: ``Overlay.pos`` is shared across pages.
- popup.main_cursor_pos = overlay.pos
- viewport = popup.page.viewport_size
- if viewport is None or popup.page.is_closed():
- return
- try:
- await overlay.move_to(popup.page, viewport["width"] / 2, viewport["height"] / 2, ms=0)
- await overlay.show(popup.page)
- except PlaywrightError:
- # A popup that dies while being furnished is the loop's business, not
- # the cursor's; losing the centring costs a cosmetic, never the render.
- if not popup.page.is_closed():
- raise
-
-
-def _narration(step: Step) -> str | None:
- return step.narration()
-
-
-def _stamp_frame(anchor: float, *, not_before: int = 0) -> int:
- """Stamp "now" as a recording frame index, never earlier than *not_before*.
-
- Every audio placement is a wall-clock reading quantised onto the 25fps grid,
- and every such reading is later mapped through :meth:`Timeline.to_virtual`,
- which shifts a stamp past a freeze only when the freeze sits STRICTLY before
- it — a stamp exactly AT its own freeze point must stay put, because that is
- where the narration the freeze exists for begins.
-
- That rule is right, but it makes the grid unforgiving: a freeze recorded at
- frame ``F`` and a later event whose reading also rounds to ``F`` (the work
- in between took less than 40ms — a couple of CDP round-trips easily fits)
- are indistinguishable, so the later event maps to the START of the hold and
- fires up to a whole narration early. Nothing bounds that gap: ``settle``
- separates the step's own start from ``F``, not ``F`` from what follows it.
-
- So stamps are made monotonic against the freezes already emitted: once a
- freeze exists at ``F``, everything stamped afterwards is at least ``F + 1``
- and therefore lands after the hold. The cost is at most one frame (40ms) of
- placement error on an event that genuinely happened within the same frame,
- which is below the resolution the axis can represent at all.
- """
- return max(seconds_to_frames(time.monotonic() - anchor), not_before)
-
-
-async def _pace_narration(
- segments: list[Segment],
- *,
- anchor: float,
- hold_frame: bool,
- settle: float,
- edits: list[TimeEdit],
- not_before: int = 0,
-) -> int | None:
- """Pace one shared visual step by its longest configured narration.
-
- With ``hold_frame`` the wall clock only pays ``settle`` seconds — enough for
- entry animations triggered by this step to finish — and the rest of the
- voice-over becomes a held frame inserted in post. The settle comes *out of*
- the narration, not on top of it, so the finished film keeps the exact pacing
- it had when the renderer slept through the whole thing.
-
- Returns the recording frame the freeze was stamped at, or ``None`` when no
- freeze was recorded. *not_before* is the earliest frame this freeze may be
- stamped at — see :func:`_stamp_frame`; freezes are stamped through the same
- monotonic rule as everything else so a later freeze can never precede an
- earlier one on the recording axis.
- """
-
- if not segments:
- return None
- duration = max(segment.duration for segment in segments)
-
- if not hold_frame:
- await asyncio.sleep(duration)
- return None
-
- real = min(settle, duration)
- await asyncio.sleep(real)
-
- remaining = duration - real
- if remaining <= 0:
- return None
- at = _stamp_frame(anchor, not_before=not_before)
- edits.append(TimeEdit(at=at, kind="freeze", frames=seconds_to_frames(remaining)))
- return at
-
-
-def _build_timeline(edits: Iterable[TimeEdit], *, source_frames: int) -> Timeline:
- """Coalesce collected edits onto the frame grid, then validate them.
-
- ``Timeline`` is deliberately strict — it rejects two edits on one frame, and
- anything at or past the end of the recording — because those are nonsense as
- a *model*. They are not nonsense as *observations*: a freeze recorded near
- the end can round past the 0.1s postroll and land at or beyond the last
- frame, and the clamp that pulls it back can then collide with a freeze
- already sitting there. Either would otherwise blow up after the entire
- recording is finished, losing the render.
-
- (``_stamp_frame`` now keeps freezes at least a frame apart as they are
- emitted, so two *unclamped* freezes can no longer share a frame. The merge
- still has to exist for the clamped case, and is kept general rather than
- special-cased to it.)
-
- So the collected list is reconciled here, where the observations are:
-
- * an ``at`` at or beyond the end clamps to the last real frame — there is no
- later frame to hold, and the film must still gain those frames;
- * freezes sharing a frame merge by SUMMING their lengths, because two steps
- that both want the picture held at frame N mean the film holds frame N for
- the total of both. The film comes out exactly as long as the narration
- asked for, which is the invariant that matters.
- """
- merged: dict[int, TimeEdit] = {}
- passthrough: list[TimeEdit] = []
- for edit in edits:
- if edit.kind != "freeze":
- passthrough.append(edit)
- continue
- at = min(edit.at, source_frames - 1)
- previous = merged.get(at)
- frames = edit.frames + (previous.frames if previous else 0)
- merged[at] = TimeEdit(at=at, kind="freeze", frames=frames)
- return Timeline.build([*merged.values(), *passthrough], source_frames=source_frames)
-
-
-def _apply_timeline_edits(source: Path, timeline: Timeline, dest: Path) -> None:
- """Apply *timeline* to *source*, then verify the result against the model.
-
- Everything downstream trusts ``Timeline.virtual_duration``: the audio beds
- are built to that length and ``mux_audio_tracks`` is handed the same number
- as its ``video_duration``, so its duration guard compares the model against
- itself and can never catch a model/file disagreement. This is the one place
- the model meets the file, so the check is exact — both sides are integer
- frame counts, and a difference of even one frame means the filtergraph did
- something other than what was modelled.
- """
- apply_time_edits(source, timeline, dest)
- produced = probe_frame_count(dest)
- if produced != timeline.virtual_frames:
- raise RenderError(
- f"time-edit stage produced {produced} frames but the timeline models "
- f"{timeline.virtual_frames} — audio would be written at the wrong length"
- )
-
-
-async def _presynthesize_narration(
- steps: Sequence[Step],
- configs: list[TtsConfig],
- cache: TtsCache,
- provider: TtsProvider,
- *,
- on_progress: Callable[[int], None] | None = None,
-) -> dict[str, dict[int, Segment]]:
- """Synthesize unique cache entries concurrently and map them to every step.
-
- Repeated narration can resolve to the same on-disk cache path. Deduplicating
- by the canonical cache key before scheduling prevents concurrent writers to
- that path and avoids duplicate provider calls on a cold cache.
- """
-
- segments: dict[str, dict[int, Segment]] = {tts.lang: {} for tts in configs}
- by_key: dict[str, _TtsWork] = {}
- for index, step in enumerate(steps):
- canonical_text = _narration(step)
- if canonical_text is None:
- continue
- for track_index, tts in enumerate(configs):
- text = canonical_text if track_index == 0 else step.translations[tts.lang]
- key = cache_key(
- text,
- tts,
- provider.adapter_version,
- CACHE_SCHEMA_VERSION,
- )
- work = by_key.get(key)
- if work is None:
- work = _TtsWork(text=text, config=tts, destinations=[])
- by_key[key] = work
- work.destinations.append((tts.lang, index))
-
- semaphore = asyncio.Semaphore(_TTS_CONCURRENCY)
-
- async def synthesize(work: _TtsWork) -> None:
- async with semaphore:
- segment = await cache.get_or_synth(work.text, work.config, provider)
- for language, index in work.destinations:
- segments[language][index] = segment
- if on_progress is not None:
- on_progress(len(work.destinations))
-
- results = await asyncio.gather(
- *(synthesize(work) for work in by_key.values()),
- return_exceptions=True,
- )
- # Wait for every started cache writer before propagating an error; otherwise
- # a sibling task could keep writing after Phase 0 has already returned.
- for result in results:
- if isinstance(result, BaseException):
- raise result
- return segments
-
-
-async def _mux_tracks_for_timeline(
- configs: list[TtsConfig],
- placed_by_language: dict[str, list[Placed]],
- total: float,
- work: Path,
- *,
- sfx_bed: Path | None = None,
-) -> list[MuxAudioTrack]:
- """Build one full-length bed per language in deterministic stream order.
-
- When *sfx_bed* is set, narration is rendered to a temp name first, then the
- shared SFX bed is mixed into the final `bed-.wav` so ``bed-*.wav`` keeps
- 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"
- )
-
- semaphore = asyncio.Semaphore(_AUDIO_BED_CONCURRENCY)
-
- def build_track(index: int, tts: TtsConfig) -> MuxAudioTrack:
- bed = work / f"bed-{tts.mp4_language()}.wav"
- if sfx_bed is not None:
- narr = work / f"narr-{tts.mp4_language()}.wav"
- build_audio_bed(placed_by_language[tts.lang], total, narr)
- mix_sfx_into_bed(narr, sfx_bed, bed, total) # bed = narration + SFX
- else:
- build_audio_bed(placed_by_language[tts.lang], total, bed)
- return MuxAudioTrack(
- path=bed,
- language=tts.mp4_language(),
- title=tts.title or tts.lang,
- default=index == 0,
- )
-
- async def build_bounded(index: int, tts: TtsConfig) -> MuxAudioTrack:
- async with semaphore:
- worker = asyncio.create_task(asyncio.to_thread(build_track, index, tts))
- try:
- return await asyncio.shield(worker)
- except asyncio.CancelledError:
- # Cancelling an asyncio wrapper cannot stop a running thread (or
- # its ffmpeg child). Keep the staging directory alive until that
- # worker has actually returned, with caller cancellation primary.
- while not worker.done():
- try:
- await asyncio.shield(worker)
- except asyncio.CancelledError:
- continue
- if not worker.cancelled():
- try:
- worker.result()
- except BaseException:
- pass
- 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
-
-
-def _publish_render_artifacts(
- staged_mp4: Path,
- tracks: list[MuxAudioTrack],
- work: Path,
- out_mp4: Path,
-) -> None:
- """Commit the new master and complete bed set, rolling back publish errors."""
-
- backup = Path(tempfile.mkdtemp(prefix=".audio-beds-backup-", dir=work))
- published: list[Path] = []
- try:
- for current in list(work.glob("bed-*.wav")):
- os.replace(current, backup / current.name)
- for track in tracks:
- destination = work / track.path.name
- os.replace(track.path, destination)
- published.append(destination)
- # The master is the commit point: until this atomic replace succeeds, the
- # previous MP4 remains in place and any bed publication error is rolled back.
- os.replace(staged_mp4, out_mp4)
- except BaseException:
- for destination in published:
- destination.unlink(missing_ok=True)
- for previous in backup.glob("bed-*.wav"):
- os.replace(previous, work / previous.name)
- raise
- finally:
- shutil.rmtree(backup, ignore_errors=True)
-
-
-async def _assemble_audio_tracks(
- video: Path,
- configs: list[TtsConfig],
- placed_by_language: dict[str, list[Placed]],
- total: float,
- work: Path,
- out_mp4: Path,
- *,
- preencoded: bool = False,
- sound: SoundConfig | None = None,
- sfx_offsets: list[tuple[str, float]] | None = None,
- fade: FadeSpec | None = None,
-) -> None:
- """Stage a complete bed set, mux atomically, then publish durable WAVs.
-
- When *sound* is enabled and *sfx_offsets* is non-empty, the shared SFX bed is
- built ONCE in staging (from the packaged click/key assets) and mixed into every
- language's narration bed via `_mux_tracks_for_timeline`.
- """
-
- with tempfile.TemporaryDirectory(prefix=".audio-beds-", dir=work) as staging:
- staged_mp4 = Path(staging) / f"{out_mp4.stem}.mp4"
- sfx_bed = None
- if sound is not None and sound.enabled and sfx_offsets:
- sfx_bed = Path(staging) / "sfx-bed.wav"
- sfx_pkg = files("guidebot_recorder.sfx")
- with (
- as_file(sfx_pkg.joinpath("click.wav")) as cp,
- as_file(sfx_pkg.joinpath("key.wav")) as kp,
- ):
- build_sfx_bed(
- sfx_offsets,
- total,
- sfx_bed,
- click_path=Path(cp),
- key_path=Path(kp),
- gain_db=sound.volume,
- )
- tracks = await _mux_tracks_for_timeline(
- configs,
- placed_by_language,
- total,
- Path(staging),
- sfx_bed=sfx_bed,
- )
- mux_audio_tracks(
- video,
- tracks,
- staged_mp4,
- preencoded=preencoded,
- video_duration=total,
- fade=fade,
- )
- _publish_render_artifacts(staged_mp4, tracks, work, out_mp4)
-
-
-def _resolve_url(scenario: Scenario, url: str) -> str:
- base = scenario.config.base_url
- if base and not url.startswith(("http://", "https://")):
- return urljoin(base, url)
- return url
-
-
-def _compiled_from(step: Step) -> str:
- """What ``compile`` froze this step's fingerprint against.
-
- A thin alias, deliberately not a second implementation: render's copy of
- this rule used to be a verbatim duplicate of the compiler's, so extending
- one side (with the per-step ``select.mode``) would have made every sidecar
- look stale to the other.
- """
-
- try:
- return compiled_from(step)
- except ValueError as exc:
- raise ValueError(f"krok {step.command_kind()} nie wymaga cachedAction") from exc
-
-
-def _compiled_action_is_current(
- step: Step, action: CompiledAction | None, scenario_hash: str
-) -> bool:
- """Check source/config fingerprints before replaying frozen behavior."""
-
- if not step.requires_target():
- return action is None
- if action is None:
- return False
- kind = step.command_kind()
- expected_state = step.wait.state if isinstance(step.wait, WaitUntil) else None
- if isinstance(action, PendingAction):
- # Nothing was frozen yet, so there is no action/expect to cross-check —
- # only that the placeholder still stands for *this* step and config.
- fingerprint = action.fingerprint
- return (
- fingerprint.compiler_version == COMPILER_VERSION
- and fingerprint.command_kind == kind
- and fingerprint.compiled_from == _compiled_from(step)
- and fingerprint.config_hash == scenario_hash
- and fingerprint.state == expected_state
- )
- expected_action = {
- "click": "click",
- "hover": "hover",
- "enterText": "type",
- "select": "select",
- "highlight": "highlight",
- "wait": "waitFor",
- }.get(kind)
- if expected_action is not None and action.action != expected_action:
- return False
- fingerprint = action.fingerprint
- if not (
- fingerprint.compiler_version == COMPILER_VERSION
- and fingerprint.command_kind == kind
- and fingerprint.compiled_from == _compiled_from(step)
- and fingerprint.config_hash == scenario_hash
- and fingerprint.state == expected_state
- and fingerprint.expect == action.expect
- ):
- return False
- return not (
- kind == "teach"
- and action.action == "type"
- and (action.input_text is None or action.input_text not in step.teach)
- )
-
-
-async def _resolve_pending_target(
- root: Page | Frame,
- step: Step,
- kind: str,
- reasoner: Reasoner,
-) -> ResolvedTarget:
- """Resolve a :class:`PendingAction` against the live page, polling while it may still appear.
-
- A gate (`wait: {until: ...}`) gets the whole configured wait window, retried on
- an interval: the canonical gating element — a cookie banner — is injected after
- a delay, and a single snapshot would report a spurious absence and silently
- delete the branch from the video. Any other optional step has no wait window,
- so it is resolved exactly once.
-
- Raises :class:`_OptionalAbsent` when the window closes on an "absent" verdict;
- every other resolver failure propagates out of ``resolve_step_target``.
- """
-
- window = step.wait.timeout if isinstance(step.wait, WaitUntil) else 0.0
- deadline = time.monotonic() + window
- while True:
- result = await resolve_step_target(root, step, kind, reasoner)
- if isinstance(result, ResolvedTarget):
- return result
- assert isinstance(result, TargetAbsent)
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise _OptionalAbsent(f"{step_instruction(step)!r}: {result.reason} ({result.message})")
- await asyncio.sleep(min(_PENDING_POLL_SECONDS, remaining))
-
-
-def _freeze_resolved(
- step: Step,
- kind: str,
- resolved: ResolvedTarget,
- expect: str,
- scenario_hash: str,
-) -> CachedAction:
- """Build the ``CachedAction`` that replaces a pending entry in the sidecar.
-
- ``opens_popup`` stays false by construction: a click resolved at render time
- carries no popup observation from compile, and the render popup contract is
- what fails loudly if one opens anyway (a documented limitation of branches).
- """
-
- return CachedAction(
- action=resolved.action,
- target=resolved.target,
- identity=resolved.identity,
- expect=expect,
- state=resolved.state,
- input_text=resolved.input_text,
- fingerprint=Fingerprint(
- command_kind=kind,
- compiled_from=_compiled_from(step),
- expect=expect,
- config_hash=scenario_hash,
- state=resolved.state,
- ),
- )
-
-
-async def run_render(
- path: Path | str,
- out_mp4: Path | str,
- tts_provider: TtsProvider,
- cache_dir: Path | str,
- browser: Browser,
- *,
- env: Mapping[str, str] | None = None,
- timeout: float = 30.0,
- pause_on_error: bool = False,
- verbose: bool = False,
- hold_frame: bool | None = None,
- hold_frame_settle: float | None = None,
- 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 {}),
- )
- # 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 `_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 _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 _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 _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,
- 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"
- _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 _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
- ),
- )
-
-
-async def _render_step(
- page: Page,
- recorder: Recorder,
- overlay: Overlay,
- chrome: Chrome | None,
- scenario: Scenario,
- step: Step,
- kind: str,
- index: int,
- cached: CompiledAction | None,
- anchor: float,
- observed_pages: dict[Page, _PageObservation],
- ensure_card: Callable[[Page], Awaitable[None]],
- *,
- entry: FlatStep | None = None,
- total: int = 0,
- sensitive: Iterable[str] = (),
- expect_chrome: bool | None = None,
- resolved: ResolvedTarget | None = None,
- optional: bool = False,
- scenario_hash: str = "",
- on_resolved: Callable[[int, CachedAction], None] | None = None,
-) -> _PopupSession | None:
- """Replay one flat step.
-
- ``resolved`` carries a target the caller resolved in place for a
- :class:`PendingAction`; this call performs it, derives its ``expect`` the way
- ``compile`` does (URL before vs after) and hands the frozen action back through
- ``on_resolved`` so the sidecar stops being pending.
-
- ``entry`` (plus ``total`` i ``sensitive``) służy wyłącznie diagnostyce:
- komunikaty błędów wskazują `plik:linia` i cytują fragment YAML-a. Wszystkie
- trzy są keyword-only i mają wartości domyślne — pozycje argumentów pozostają
- 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)
- # 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
-
- if kind == "say":
- 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
- # `_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
- return opened
-
-
-async def _prepare_popup(
- page: Page,
- overlay: Overlay,
- chrome: Chrome | None,
- *,
- expect_chrome: bool | None = None,
- mount_bar: bool = False,
-) -> bool:
- """Prepare a new page; translate close races into lifecycle state.
-
- ``expect_chrome`` defaults to ``chrome is not None``; pass ``False`` for a
- bare (floating) popup so the chrome bar is not mounted on it (the cursor is
- still ensured).
-
- ``mount_bar`` forces the legacy bar onto this one page even when the
- context-wide script is bare — a real ``target="_blank"`` tab is a browser
- tab and reads as a rendering fault without an address bar.
- """
-
- if expect_chrome is None:
- expect_chrome = chrome is not None
- if page.is_closed():
- return False
- try:
- await page.bring_to_front()
- await page.wait_for_load_state()
- await overlay.ensure(page)
- if chrome is not None and mount_bar:
- await chrome.install_bar(page)
- elif chrome is not None and expect_chrome:
- await chrome.ensure(page)
- except PlaywrightError:
- if page.is_closed():
- return False
- raise
- return not page.is_closed()
diff --git a/guidebot_recorder/recorder/render/__init__.py b/guidebot_recorder/recorder/render/__init__.py
new file mode 100644
index 0000000..7e715ff
--- /dev/null
+++ b/guidebot_recorder/recorder/render/__init__.py
@@ -0,0 +1,160 @@
+"""The `render` phase — deterministic replay + film assembly (§8/§9).
+
+Phase 0: pre-synthesize every configured narration track into the cache.
+Render: 0×LLM, fresh browser, single pass; narration drives the pace.
+Assembly: Playwright video + language audio beds (ffmpeg), approximate sync (K2).
+
+Resolved actions are read from the separate ``*.compiled.yaml`` sidecar.
+
+**This module is a facade and holds no logic.** It re-exports the names other
+packages and the tests use, so ``from guidebot_recorder.recorder.render import ...``
+keeps working after the split into submodules:
+
+ errors.py RenderError, _OptionalAbsent — imported by everything, so
+ extracted first and left with zero internal imports
+ constants.py the two timing budgets with more than one reader
+ tasks.py disposing of asyncio probes the render no longer needs
+ popup_detect.py what the opener asked ``window.open`` for, and whether it
+ called it at all
+ popup_crop.py the three-level crop chain and its CSS→recording conversion
+ popup_session.py the one-main-window-plus-one-popup contract and its records
+ pages.py which page is live and what should be painted on it
+ visuals.py mounting, priming and handing over the injected layers
+ narration.py the narration clock: synthesis, pacing, frame stamping
+ 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)
+
+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
+(``_apply_timeline_edits``, ``_assemble_audio_tracks``, ``_pace_narration``,
+``_publish_render_artifacts``, ``_render_step``) used to be imported *from here*.
+Re-exporting any of them would let ``monkeypatch.setattr(render_module,
+"_pace_narration", ...)`` succeed while reaching nobody — the consumer lives in a
+submodule and resolves the name from *its own* globals — and a silently dead patch
+leaves a test green while it checks nothing. Withholding turns the same mistake
+into an immediate ``AttributeError``/``ImportError``, at collection time. Do **not**
+"fix" that by adding the missing re-export; patch (and import from) the submodule
+whose globals the consumer reads::
+
+ monkeypatch.setattr(render_module.narration, "_pace_narration", fake)
+ monkeypatch.setattr(render_module.popup_crop, "detect_content_crop", fake)
+ monkeypatch.setattr("guidebot_recorder.recorder.render.audio.os.replace", fake)
+
+Which submodule that is depends on where the name is *defined*, and the two cases
+are opposites:
+
+* defined **inside** the package (``_render_step``, ``_pace_narration``,
+ ``_AUDIO_BED_CONCURRENCY``, …) — consumers call it through the module object, so
+ the patch goes on the **defining** submodule and nobody may name-import it;
+* defined **outside** it (``Recorder``, ``compose_popup_video``,
+ ``probe_frame_count``, …) — the consumer keeps ``from X import name``, so the
+ patch goes on the **consuming** submodule, and the name-import *is* the seam.
+
+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.
+
+The submodules are re-exported as *modules* for exactly that reason, using the
+redundant-alias form. The private helpers below (``_PopupSession``,
+``_build_timeline``, ``_parse_content_box``, …) look like they break the
+withholding rule — tests reach them through this facade. They do not: none of them
+is a seam, because nothing patches them. Nor can one quietly become one, which is
+what makes leaving them re-exported safe — the moment a test writes
+``monkeypatch.setattr(render_module, "_build_timeline", ...)`` the seam scan picks
+the name up and ``test_facade_withholds_every_patched_name`` fails on this very
+re-export, before the dead patch can leave anything green.
+"""
+
+from __future__ import annotations
+
+from . import _run as _run
+from . import _step as _step
+from . import audio as audio
+from . import constants as constants
+from . import errors as errors
+from . import narration as narration
+from . import pages as pages
+from . import popup_crop as popup_crop
+from . import popup_detect as popup_detect
+from . import popup_session as popup_session
+from . import reuse as reuse
+from . import tasks as tasks
+from . import timeline as timeline
+from . import visuals as visuals
+from ._run import run_render
+from .audio import (
+ _mux_tracks_for_timeline as _mux_tracks_for_timeline,
+)
+from .errors import RenderError
+from .narration import (
+ _presynthesize_narration as _presynthesize_narration,
+)
+from .pages import (
+ _expect_chrome as _expect_chrome,
+)
+from .pages import navigate_pill_mode
+from .popup_crop import POPUP_BBOX_DEGENERATE_RATIO
+from .popup_crop import (
+ _parse_content_box as _parse_content_box,
+)
+from .popup_crop import (
+ _popup_content_box as _popup_content_box,
+)
+from .popup_crop import (
+ _popup_fills_canvas as _popup_fills_canvas,
+)
+from .popup_crop import (
+ _resolve_popup_crop as _resolve_popup_crop,
+)
+from .popup_crop import (
+ _settle_popup_content_box as _settle_popup_content_box,
+)
+from .popup_crop import (
+ _start_popup_content_box as _start_popup_content_box,
+)
+from .popup_detect import (
+ _POPUP_REQUEST_SCRIPT as _POPUP_REQUEST_SCRIPT,
+)
+from .popup_detect import (
+ _parse_window_request as _parse_window_request,
+)
+from .popup_detect import (
+ _popup_window_opened as _popup_window_opened,
+)
+from .popup_detect import (
+ _popup_window_request as _popup_window_request,
+)
+from .popup_session import (
+ _PopupSession as _PopupSession,
+)
+from .reuse import (
+ _compiled_action_is_current as _compiled_action_is_current,
+)
+from .reuse import (
+ _compiled_from as _compiled_from,
+)
+from .timeline import (
+ _build_timeline as _build_timeline,
+)
+from .visuals import (
+ _ensure_visuals as _ensure_visuals,
+)
+from .visuals import (
+ _hand_cursor_to_popup as _hand_cursor_to_popup,
+)
+from .visuals import (
+ _prime_visuals as _prime_visuals,
+)
+
+__all__ = [
+ "POPUP_BBOX_DEGENERATE_RATIO",
+ "RenderError",
+ "navigate_pill_mode",
+ "run_render",
+]
diff --git a/guidebot_recorder/recorder/render/_run.py b/guidebot_recorder/recorder/render/_run.py
new file mode 100644
index 0000000..f9d7fd9
--- /dev/null
+++ b/guidebot_recorder/recorder/render/_run.py
@@ -0,0 +1,941 @@
+"""``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.
+"""
+
+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 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
+
+_VIDEO_POSTROLL_SECONDS = 0.1
+
+
+#: A slide card's on-screen content, as consumed by ``SlideOverlay.show``/``.ensure``.
+Card = dict[str, str | None]
+
+
+async def run_render(
+ path: Path | str,
+ out_mp4: Path | str,
+ tts_provider: TtsProvider,
+ cache_dir: Path | str,
+ browser: Browser,
+ *,
+ env: Mapping[str, str] | None = None,
+ timeout: float = 30.0,
+ pause_on_error: bool = False,
+ verbose: bool = False,
+ hold_frame: bool | None = None,
+ hold_frame_settle: float | None = None,
+ 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 {}),
+ )
+ # 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,
+ 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
+ ),
+ )
diff --git a/guidebot_recorder/recorder/render/_step.py b/guidebot_recorder/recorder/render/_step.py
new file mode 100644
index 0000000..b3a2ae9
--- /dev/null
+++ b/guidebot_recorder/recorder/render/_step.py
@@ -0,0 +1,345 @@
+"""``_render_step``: replay exactly one flat step. Phase 3 decomposes it.
+
+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.
+
+``_render_step`` is a test seam: defined here, called through this module object
+from :mod:`~guidebot_recorder.recorder.render._run`.
+
+``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.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from collections.abc import Awaitable, Callable, Iterable
+
+from playwright.async_api import (
+ Error as PlaywrightError,
+)
+from playwright.async_api import Page
+from playwright.async_api import (
+ TimeoutError as PlaywrightTimeoutError,
+)
+
+from guidebot_recorder.chrome import Chrome
+from guidebot_recorder.diagnostics import step_banner
+from guidebot_recorder.models.action import CachedAction, PendingAction
+from guidebot_recorder.models.compiled import CompiledAction
+from guidebot_recorder.models.scenario import FlatStep, Scenario, Step, WaitUntil, select_mode
+from guidebot_recorder.overlay import overlay as overlay_module
+from guidebot_recorder.recorder import recorder as recorder_module
+from guidebot_recorder.recorder.recorder import SelectDriveError
+from guidebot_recorder.resolver.resolution import ResolvedTarget, heuristic_expect
+from guidebot_recorder.resolver.validate import reuse_is_valid
+from guidebot_recorder.selects import SelectsNotReadyError
+
+from .errors import RenderError, _OptionalAbsent
+from .narration import _narration
+from .pages import navigate_pill_mode
+from .popup_crop import _page_viewport, _start_popup_content_box
+from .popup_detect import _popup_window_request
+from .popup_session import _PageObservation, _PopupSession, _wait_for_render_popup
+from .reuse import _freeze_resolved, _resolve_url
+from .visuals import _ensure_visuals
+
+
+async def _render_step(
+ page: Page,
+ recorder: recorder_module.Recorder,
+ overlay: overlay_module.Overlay,
+ chrome: Chrome | None,
+ scenario: Scenario,
+ step: Step,
+ kind: str,
+ index: int,
+ cached: CompiledAction | None,
+ anchor: float,
+ observed_pages: dict[Page, _PageObservation],
+ ensure_card: Callable[[Page], Awaitable[None]],
+ *,
+ entry: FlatStep | None = None,
+ total: int = 0,
+ sensitive: Iterable[str] = (),
+ expect_chrome: bool | None = None,
+ resolved: ResolvedTarget | None = None,
+ optional: bool = False,
+ scenario_hash: str = "",
+ on_resolved: Callable[[int, CachedAction], None] | None = None,
+) -> _PopupSession | None:
+ """Replay one flat step.
+
+ ``resolved`` carries a target the caller resolved in place for a
+ :class:`PendingAction`; this call performs it, derives its ``expect`` the way
+ ``compile`` does (URL before vs after) and hands the frozen action back through
+ ``on_resolved`` so the sidecar stops being pending.
+
+ ``entry`` (plus ``total`` i ``sensitive``) służy wyłącznie diagnostyce:
+ komunikaty błędów wskazują `plik:linia` i cytują fragment YAML-a. Wszystkie
+ trzy są keyword-only i mają wartości domyślne — pozycje argumentów pozostają
+ 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)
+ # 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
+
+ if kind == "say":
+ 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
+ return opened
diff --git a/guidebot_recorder/recorder/render/audio.py b/guidebot_recorder/recorder/render/audio.py
new file mode 100644
index 0000000..d03c952
--- /dev/null
+++ b/guidebot_recorder/recorder/render/audio.py
@@ -0,0 +1,214 @@
+"""Building the audio beds and publishing the finished artifacts.
+
+One full-length bed per configured language, muxed onto the video and then
+committed together with the master MP4. Publication is a two-phase commit around
+``os.replace``: the existing beds are moved aside, the new ones are put in place,
+and the master replace is the commit point — anything that fails rolls the
+previous bed set back.
+
+That ``os.replace`` is itself a test seam, patched as
+``guidebot_recorder.recorder.render.audio.os.replace`` — through *this* module's
+``os`` global, because this is the module that performs the rename. The path must
+name this module for the same reason every other seam does: it is the globals the
+consumer reads at call time.
+
+The remaining seams are ``build_audio_bed``, ``build_sfx_bed`` and
+``mux_audio_tracks`` (defined outside the package, name-imported here, patched on
+this module) and ``_AUDIO_BED_CONCURRENCY``, ``_publish_render_artifacts`` and
+``_assemble_audio_tracks``, which are defined here. The first two are read out of
+this module's own globals; ``_assemble_audio_tracks`` is called through this module
+object from :mod:`~guidebot_recorder.recorder.render._run`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import shutil
+import tempfile
+from importlib.resources import as_file, files
+from pathlib import Path
+
+from guidebot_recorder.models.config import SoundConfig, TtsConfig
+from guidebot_recorder.video.audiobed import Placed, build_audio_bed
+from guidebot_recorder.video.mux import FadeSpec, MuxAudioTrack, mux_audio_tracks
+from guidebot_recorder.video.sfx import build_sfx_bed, mix_sfx_into_bed
+
+from .errors import RenderError
+
+# Each worker can own a full ffmpeg process. Keep the pool below both the host's
+# CPU count and a conservative process ceiling instead of scaling with languages.
+_AUDIO_BED_CONCURRENCY = max(1, min(4, os.cpu_count() or 1))
+
+
+async def _mux_tracks_for_timeline(
+ configs: list[TtsConfig],
+ placed_by_language: dict[str, list[Placed]],
+ total: float,
+ work: Path,
+ *,
+ sfx_bed: Path | None = None,
+) -> list[MuxAudioTrack]:
+ """Build one full-length bed per language in deterministic stream order.
+
+ When *sfx_bed* is set, narration is rendered to a temp name first, then the
+ shared SFX bed is mixed into the final `bed-.wav` so ``bed-*.wav`` keeps
+ 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"
+ )
+
+ semaphore = asyncio.Semaphore(_AUDIO_BED_CONCURRENCY)
+
+ def build_track(index: int, tts: TtsConfig) -> MuxAudioTrack:
+ bed = work / f"bed-{tts.mp4_language()}.wav"
+ if sfx_bed is not None:
+ narr = work / f"narr-{tts.mp4_language()}.wav"
+ build_audio_bed(placed_by_language[tts.lang], total, narr)
+ mix_sfx_into_bed(narr, sfx_bed, bed, total) # bed = narration + SFX
+ else:
+ build_audio_bed(placed_by_language[tts.lang], total, bed)
+ return MuxAudioTrack(
+ path=bed,
+ language=tts.mp4_language(),
+ title=tts.title or tts.lang,
+ default=index == 0,
+ )
+
+ async def build_bounded(index: int, tts: TtsConfig) -> MuxAudioTrack:
+ async with semaphore:
+ worker = asyncio.create_task(asyncio.to_thread(build_track, index, tts))
+ try:
+ return await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ # Cancelling an asyncio wrapper cannot stop a running thread (or
+ # its ffmpeg child). Keep the staging directory alive until that
+ # worker has actually returned, with caller cancellation primary.
+ while not worker.done():
+ try:
+ await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ continue
+ if not worker.cancelled():
+ try:
+ worker.result()
+ except BaseException:
+ pass
+ 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
+
+
+def _publish_render_artifacts(
+ staged_mp4: Path,
+ tracks: list[MuxAudioTrack],
+ work: Path,
+ out_mp4: Path,
+) -> None:
+ """Commit the new master and complete bed set, rolling back publish errors."""
+
+ backup = Path(tempfile.mkdtemp(prefix=".audio-beds-backup-", dir=work))
+ published: list[Path] = []
+ try:
+ for current in list(work.glob("bed-*.wav")):
+ os.replace(current, backup / current.name)
+ for track in tracks:
+ destination = work / track.path.name
+ os.replace(track.path, destination)
+ published.append(destination)
+ # The master is the commit point: until this atomic replace succeeds, the
+ # previous MP4 remains in place and any bed publication error is rolled back.
+ os.replace(staged_mp4, out_mp4)
+ except BaseException:
+ for destination in published:
+ destination.unlink(missing_ok=True)
+ for previous in backup.glob("bed-*.wav"):
+ os.replace(previous, work / previous.name)
+ raise
+ finally:
+ shutil.rmtree(backup, ignore_errors=True)
+
+
+async def _assemble_audio_tracks(
+ video: Path,
+ configs: list[TtsConfig],
+ placed_by_language: dict[str, list[Placed]],
+ total: float,
+ work: Path,
+ out_mp4: Path,
+ *,
+ preencoded: bool = False,
+ sound: SoundConfig | None = None,
+ sfx_offsets: list[tuple[str, float]] | None = None,
+ fade: FadeSpec | None = None,
+) -> None:
+ """Stage a complete bed set, mux atomically, then publish durable WAVs.
+
+ When *sound* is enabled and *sfx_offsets* is non-empty, the shared SFX bed is
+ built ONCE in staging (from the packaged click/key assets) and mixed into every
+ language's narration bed via `_mux_tracks_for_timeline`.
+ """
+
+ with tempfile.TemporaryDirectory(prefix=".audio-beds-", dir=work) as staging:
+ staged_mp4 = Path(staging) / f"{out_mp4.stem}.mp4"
+ sfx_bed = None
+ if sound is not None and sound.enabled and sfx_offsets:
+ sfx_bed = Path(staging) / "sfx-bed.wav"
+ sfx_pkg = files("guidebot_recorder.sfx")
+ with (
+ as_file(sfx_pkg.joinpath("click.wav")) as cp,
+ as_file(sfx_pkg.joinpath("key.wav")) as kp,
+ ):
+ build_sfx_bed(
+ sfx_offsets,
+ total,
+ sfx_bed,
+ click_path=Path(cp),
+ key_path=Path(kp),
+ gain_db=sound.volume,
+ )
+ tracks = await _mux_tracks_for_timeline(
+ configs,
+ placed_by_language,
+ total,
+ Path(staging),
+ sfx_bed=sfx_bed,
+ )
+ mux_audio_tracks(
+ video,
+ tracks,
+ staged_mp4,
+ preencoded=preencoded,
+ video_duration=total,
+ fade=fade,
+ )
+ _publish_render_artifacts(staged_mp4, tracks, work, out_mp4)
diff --git a/guidebot_recorder/recorder/render/constants.py b/guidebot_recorder/recorder/render/constants.py
new file mode 100644
index 0000000..0f41e30
--- /dev/null
+++ b/guidebot_recorder/recorder/render/constants.py
@@ -0,0 +1,14 @@
+"""Timing budgets with more than one reader.
+
+Only the shared ones live here. A budget read by a single module stays next to the
+code it bounds, and that is not a stylistic choice: three of them
+(``_POPUP_REQUEST_LOOKUP_TIMEOUT``, ``_POPUP_CONTENT_BOX_TIMEOUT``,
+``_AUDIO_BED_CONCURRENCY``) are test seams, and a seam must be defined in the same
+module whose globals its reader resolves at call time — otherwise the patch lands
+on one module and the read happens in another.
+"""
+
+from __future__ import annotations
+
+_POPUP_DETECTION_SECONDS = 1.0
+_POPUP_QUIESCENCE_SECONDS = 0.1
diff --git a/guidebot_recorder/recorder/render/errors.py b/guidebot_recorder/recorder/render/errors.py
new file mode 100644
index 0000000..96e312c
--- /dev/null
+++ b/guidebot_recorder/recorder/render/errors.py
@@ -0,0 +1,25 @@
+"""The two exceptions the render phase raises — and nothing else.
+
+Extracted first and deliberately import-free. :class:`RenderError` is raised from
+almost every other module in this package, so leaving it beside ``run_render``
+would have made :mod:`~guidebot_recorder.recorder.render._run` a dependency of the
+whole package and closed a total import cycle.
+"""
+
+from __future__ import annotations
+
+
+class RenderError(RuntimeError):
+ """A step needs (re-)compile: missing action or mismatched identity."""
+
+
+class _OptionalAbsent(Exception):
+ """The element an *optional* step or branch gate stands for is simply not there.
+
+ Deliberately narrow, and deliberately not a :class:`RenderError`: it is raised
+ only from the four signals the design admits as "absent" (a timed-out cached
+ ``waitFor``, a ``no_action``/``no_handle`` verdict, an elapsed poll window, a
+ failed ``reuse_is_valid``). Everything else — an ambiguous description, a click
+ that fails on a resolved target, a navigation error — keeps propagating and
+ fails the render, so ``optional`` cannot decay into ``except Exception: pass``.
+ """
diff --git a/guidebot_recorder/recorder/render/narration.py b/guidebot_recorder/recorder/render/narration.py
new file mode 100644
index 0000000..2492caa
--- /dev/null
+++ b/guidebot_recorder/recorder/render/narration.py
@@ -0,0 +1,172 @@
+"""The narration clock: synthesis up front, pacing and stamping during the run.
+
+Phase 0 (:func:`_presynthesize_narration`) turns every configured track into cache
+entries before a browser exists, so a TTS failure can never land mid-recording.
+During the run :func:`_pace_narration` spends the voice-over — as real wall clock,
+or as a freeze inserted in post — and :func:`_stamp_frame` is the single rule that
+quantises "now" onto the 25fps recording grid and keeps those stamps monotonic
+against the freezes already emitted.
+
+The freezes this module *observes* are reconciled into a model by
+:mod:`~guidebot_recorder.recorder.render.timeline`. The two are kept apart because
+this side observes and that side models.
+
+``_pace_narration`` is a test seam: defined here, called through this module object
+from :mod:`~guidebot_recorder.recorder.render._run`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass
+
+from guidebot_recorder.models.config import TtsConfig
+from guidebot_recorder.models.scenario import Step
+from guidebot_recorder.tts.base import (
+ CACHE_SCHEMA_VERSION,
+ Segment,
+ TtsCache,
+ TtsProvider,
+ cache_key,
+)
+from guidebot_recorder.video.timeline import TimeEdit, seconds_to_frames
+
+_TTS_CONCURRENCY = 8
+
+
+@dataclass(slots=True)
+class _TtsWork:
+ text: str
+ config: TtsConfig
+ destinations: list[tuple[str, int]]
+
+
+def _narration(step: Step) -> str | None:
+ return step.narration()
+
+
+def _stamp_frame(anchor: float, *, not_before: int = 0) -> int:
+ """Stamp "now" as a recording frame index, never earlier than *not_before*.
+
+ Every audio placement is a wall-clock reading quantised onto the 25fps grid,
+ and every such reading is later mapped through :meth:`Timeline.to_virtual`,
+ which shifts a stamp past a freeze only when the freeze sits STRICTLY before
+ it — a stamp exactly AT its own freeze point must stay put, because that is
+ where the narration the freeze exists for begins.
+
+ That rule is right, but it makes the grid unforgiving: a freeze recorded at
+ frame ``F`` and a later event whose reading also rounds to ``F`` (the work
+ in between took less than 40ms — a couple of CDP round-trips easily fits)
+ are indistinguishable, so the later event maps to the START of the hold and
+ fires up to a whole narration early. Nothing bounds that gap: ``settle``
+ separates the step's own start from ``F``, not ``F`` from what follows it.
+
+ So stamps are made monotonic against the freezes already emitted: once a
+ freeze exists at ``F``, everything stamped afterwards is at least ``F + 1``
+ and therefore lands after the hold. The cost is at most one frame (40ms) of
+ placement error on an event that genuinely happened within the same frame,
+ which is below the resolution the axis can represent at all.
+ """
+ return max(seconds_to_frames(time.monotonic() - anchor), not_before)
+
+
+async def _pace_narration(
+ segments: list[Segment],
+ *,
+ anchor: float,
+ hold_frame: bool,
+ settle: float,
+ edits: list[TimeEdit],
+ not_before: int = 0,
+) -> int | None:
+ """Pace one shared visual step by its longest configured narration.
+
+ With ``hold_frame`` the wall clock only pays ``settle`` seconds — enough for
+ entry animations triggered by this step to finish — and the rest of the
+ voice-over becomes a held frame inserted in post. The settle comes *out of*
+ the narration, not on top of it, so the finished film keeps the exact pacing
+ it had when the renderer slept through the whole thing.
+
+ Returns the recording frame the freeze was stamped at, or ``None`` when no
+ freeze was recorded. *not_before* is the earliest frame this freeze may be
+ stamped at — see :func:`_stamp_frame`; freezes are stamped through the same
+ monotonic rule as everything else so a later freeze can never precede an
+ earlier one on the recording axis.
+ """
+
+ if not segments:
+ return None
+ duration = max(segment.duration for segment in segments)
+
+ if not hold_frame:
+ await asyncio.sleep(duration)
+ return None
+
+ real = min(settle, duration)
+ await asyncio.sleep(real)
+
+ remaining = duration - real
+ if remaining <= 0:
+ return None
+ at = _stamp_frame(anchor, not_before=not_before)
+ edits.append(TimeEdit(at=at, kind="freeze", frames=seconds_to_frames(remaining)))
+ return at
+
+
+async def _presynthesize_narration(
+ steps: Sequence[Step],
+ configs: list[TtsConfig],
+ cache: TtsCache,
+ provider: TtsProvider,
+ *,
+ on_progress: Callable[[int], None] | None = None,
+) -> dict[str, dict[int, Segment]]:
+ """Synthesize unique cache entries concurrently and map them to every step.
+
+ Repeated narration can resolve to the same on-disk cache path. Deduplicating
+ by the canonical cache key before scheduling prevents concurrent writers to
+ that path and avoids duplicate provider calls on a cold cache.
+ """
+
+ segments: dict[str, dict[int, Segment]] = {tts.lang: {} for tts in configs}
+ by_key: dict[str, _TtsWork] = {}
+ for index, step in enumerate(steps):
+ canonical_text = _narration(step)
+ if canonical_text is None:
+ continue
+ for track_index, tts in enumerate(configs):
+ text = canonical_text if track_index == 0 else step.translations[tts.lang]
+ key = cache_key(
+ text,
+ tts,
+ provider.adapter_version,
+ CACHE_SCHEMA_VERSION,
+ )
+ work = by_key.get(key)
+ if work is None:
+ work = _TtsWork(text=text, config=tts, destinations=[])
+ by_key[key] = work
+ work.destinations.append((tts.lang, index))
+
+ semaphore = asyncio.Semaphore(_TTS_CONCURRENCY)
+
+ async def synthesize(work: _TtsWork) -> None:
+ async with semaphore:
+ segment = await cache.get_or_synth(work.text, work.config, provider)
+ for language, index in work.destinations:
+ segments[language][index] = segment
+ if on_progress is not None:
+ on_progress(len(work.destinations))
+
+ results = await asyncio.gather(
+ *(synthesize(work) for work in by_key.values()),
+ return_exceptions=True,
+ )
+ # Wait for every started cache writer before propagating an error; otherwise
+ # a sibling task could keep writing after Phase 0 has already returned.
+ for result in results:
+ if isinstance(result, BaseException):
+ raise result
+ return segments
diff --git a/guidebot_recorder/recorder/render/pages.py b/guidebot_recorder/recorder/render/pages.py
new file mode 100644
index 0000000..30174ce
--- /dev/null
+++ b/guidebot_recorder/recorder/render/pages.py
@@ -0,0 +1,66 @@
+"""Which page is live, and what is expected to be painted on it.
+
+Four small stateless questions the render loop and one step handler ask about a
+page: which of main/popup is currently active, whether the legacy in-DOM chrome
+bar should be there, whether a page is the shell, and how a ``navigate`` step
+should drive the address pill.
+
+They sit together because they are pure predicates over already-known state — no
+browser round-trip, nothing to await — which is what keeps them out of both
+:mod:`~guidebot_recorder.recorder.render.popup_session` (records and lifecycle)
+and :mod:`~guidebot_recorder.recorder.render.visuals` (mounting layers).
+"""
+
+from __future__ import annotations
+
+from playwright.async_api import Page
+
+from guidebot_recorder.chrome import SHELL_URL, Chrome
+from guidebot_recorder.models.config import ChromeConfig
+
+from .errors import RenderError
+from .popup_session import _PopupSession
+
+
+def _active_page(main_page: Page, popup: _PopupSession | None) -> Page:
+ if main_page.is_closed():
+ raise RenderError("główne okno zostało zamknięte podczas render")
+ if popup is not None and not popup.page.is_closed():
+ return popup.page
+ return main_page
+
+
+def _expect_chrome(chrome: Chrome | None, bare_popups: bool) -> bool:
+ """Whether the legacy in-DOM chrome bar (``[data-guidebot-chrome]``) is expected.
+
+ The bar is a context-wide init script, so ``bare_popups`` (floating) cannot
+ suppress it on the popup alone — it suppresses it on *every* top-level
+ non-shell document, including the main window's transient ``about:blank``
+ warm-up before it becomes the shell. So the legacy bar is expected only when
+ chrome is enabled and popups are not bare. The main window's real chrome is
+ the shell (``install_shell`` / the shell branch of
+ ``visuals._ensure_visuals``), which is independent of this flag; the cursor
+ overlay is always expected.
+ """
+
+ return chrome is not None and not bare_popups
+
+
+def navigate_pill_mode(chrome: ChromeConfig, type_override: bool | None) -> str:
+ """Select the main-window address-bar behavior for a ``navigate`` step.
+
+ Returns one of ``"choreograph"`` (pointer → click → focus → natural type),
+ ``"type"`` (typed pill only, no pointer), or ``"instant"`` (no typing). The
+ caller still gates on ``chrome.enabled`` and ``show_url``.
+ """
+
+ animate = chrome.type_on_navigate if type_override is None else type_override
+ if not animate:
+ return "instant"
+ return "choreograph" if chrome.interact_on_navigate else "type"
+
+
+def _is_shell_page(page: Page) -> bool:
+ """True when ``page`` is the main-window shell (served from the sentinel origin)."""
+
+ return page.url.startswith(SHELL_URL)
diff --git a/guidebot_recorder/recorder/render/popup_crop.py b/guidebot_recorder/recorder/render/popup_crop.py
new file mode 100644
index 0000000..8eb0576
--- /dev/null
+++ b/guidebot_recorder/recorder/render/popup_crop.py
@@ -0,0 +1,452 @@
+"""Framing a popup: the three-level crop chain and its unit conversion.
+
+A popup records onto the *main* window's canvas with filler around its real
+window, so composition has to crop it back down. Three witnesses, most
+deterministic first:
+
+1. the ``window.open`` size features — read in
+ :mod:`~guidebot_recorder.recorder.render.popup_detect`, while the opener lives;
+2. the popup's own painted content box — measured inside the page, here;
+3. ``cropdetect`` over the finished recording — a pixel heuristic, last resort.
+
+The three are one story ("most trustworthy first") and stay in one module for that
+reason. Levels 1 and 2 speak CSS pixels while the crop is applied to the
+recording, and those are the same unit only when the compositor draws at scale 1;
+:func:`_recording_scale` and :func:`_scale_rect` are that conversion, which is why
+this module owns both the measurement and the arithmetic.
+
+Two test seams are patched on *this* module: ``detect_content_crop`` (level 3),
+name-imported below because this is the module whose globals the consumer reads,
+and ``_POPUP_CONTENT_BOX_TIMEOUT``, defined and read here for the same reason.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import math
+import sys
+from pathlib import Path
+
+from playwright.async_api import (
+ Error as PlaywrightError,
+)
+from playwright.async_api import Page
+from tqdm import tqdm
+
+from guidebot_recorder.models.config import Viewport
+from guidebot_recorder.video.mux import detect_content_crop
+
+from .popup_session import _PopupSession
+from .tasks import _discard_pending
+
+#: A content bounding box at least this fraction of the viewport in *both*
+#: dimensions is refused as degenerate. ``body``'s children can trivially fill
+#: the frame (a ``100vw/100vh`` wrapper, a full-bleed background), in which case
+#: the box merely restates the context viewport this whole mechanism exists to
+#: correct, and level 3 has to decide instead.
+POPUP_BBOX_DEGENERATE_RATIO = 0.98
+
+
+#: How long the popup's content measurement gets to come back. The walk is
+#: bounded in the page (see ``_POPUP_CONTENT_BOX_SCRIPT``), so a healthy popup
+#: answers in single-digit milliseconds; the budget exists for the same reason
+#: the ``window.open`` lookup has one — ``Page.evaluate`` accepts no timeout, and
+#: a document that never commits an execution context never answers at all.
+_POPUP_CONTENT_BOX_TIMEOUT = 2.0
+
+
+#: Level 2: measure the popup's painted content from inside the popup document.
+#: Deliberately skips ``documentElement`` and ``body`` — both are laid out
+#: against the *context* viewport, so both report the very number being
+#: corrected. Only ``body``'s children are measured, and a child that paints
+#: nothing itself (a transparent layout wrapper) is descended into rather than
+#: counted, so a full-width wrapper around a small dialog does not inflate the
+#: result. Guidebot's own injected overlays (``data-guidebot-*``) are skipped:
+#: the cursor lives in the popup too and would stretch the box to wherever it
+#: happens to sit. A document that paints its own page background declines
+#: outright — see ``paintsPage`` below.
+_POPUP_CONTENT_BOX_SCRIPT = """
+() => {
+ const body = document.body;
+ if (!body) return null;
+ const vw = Math.round(window.innerWidth);
+ const vh = Math.round(window.innerHeight);
+ if (!(vw > 0) || !(vh > 0)) return null;
+ // A popup that paints its own page background is full-bleed by construction:
+ // the background covers the whole window, so the union of body's children is
+ // only the *ink* on top of it and cropping to that would cut the popup's
+ // background away. ``body``/``documentElement`` are skipped everywhere else in
+ // this walk precisely because their box is the context viewport — but whether
+ // they PAINT is a different question, and the honest answer here is "decline".
+ // An unstyled document leaves both transparent (the white is the canvas, not a
+ // background), so ordinary dialogs are unaffected.
+ const paintsPage = (el) => {
+ if (!el) return false;
+ const style = getComputedStyle(el);
+ const bg = style.backgroundColor;
+ if (bg && bg !== "transparent" && !/^rgba\\(0,\\s*0,\\s*0,\\s*0\\)$/.test(bg)) return true;
+ return Boolean(style.backgroundImage) && style.backgroundImage !== "none";
+ };
+ if (paintsPage(document.documentElement) || paintsPage(body)) return null;
+ const MAX_DEPTH = 12;
+ const MAX_NODES = 1200;
+ // The walk runs on the popup's main thread while the popup is on camera, so
+ // it is bounded by wall clock as well as by node count: whatever the DOM
+ // looks like, it cannot cost the recording more than this. Overrunning
+ // abandons the measurement (partial unions are worse than none — they would
+ // crop away real content), and level 3 decides instead.
+ const BUDGET_MS = 20;
+ const deadline = performance.now() + BUDGET_MS;
+ let overran = false;
+ const REPLACED = new Set([
+ "IMG", "SVG", "CANVAS", "VIDEO", "IFRAME", "INPUT", "TEXTAREA",
+ "SELECT", "BUTTON", "OBJECT", "EMBED", "HR",
+ ]);
+ let visited = 0;
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
+ const add = (rect) => {
+ if (!rect) return;
+ const l = Math.max(0, rect.left);
+ const t = Math.max(0, rect.top);
+ const r = Math.min(vw, rect.right);
+ const b = Math.min(vh, rect.bottom);
+ if (!(r > l) || !(b > t)) return;
+ if (l < left) left = l;
+ if (t < top) top = t;
+ if (r > right) right = r;
+ if (b > bottom) bottom = b;
+ };
+ const isOverlay = (el) => {
+ for (const attr of el.attributes || []) {
+ if (attr.name.startsWith("data-guidebot")) return true;
+ }
+ return false;
+ };
+ const paints = (el, style) => {
+ if (REPLACED.has(el.tagName)) return true;
+ const bg = style.backgroundColor;
+ if (bg && bg !== "transparent" && !/^rgba\\(0,\\s*0,\\s*0,\\s*0\\)$/.test(bg)) return true;
+ if (style.backgroundImage && style.backgroundImage !== "none") return true;
+ if (style.boxShadow && style.boxShadow !== "none") return true;
+ for (const side of ["Top", "Right", "Bottom", "Left"]) {
+ if (parseFloat(style["border" + side + "Width"]) > 0) return true;
+ }
+ return false;
+ };
+ const walk = (el, depth) => {
+ if (overran) return;
+ if (visited++ > MAX_NODES || performance.now() > deadline) {
+ overran = true;
+ return;
+ }
+ if (isOverlay(el)) return;
+ const style = getComputedStyle(el);
+ if (style.display === "none" || style.visibility === "hidden") return;
+ if (Number(style.opacity) === 0) return;
+ if (paints(el, style) || depth >= MAX_DEPTH) {
+ add(el.getBoundingClientRect());
+ return;
+ }
+ let descended = false;
+ for (const node of el.childNodes) {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ walk(node, depth + 1);
+ descended = true;
+ } else if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim()) {
+ // Measure the glyphs, not the block box the text sits in: a is as
+ // wide as its container, its text usually is not.
+ const range = document.createRange();
+ range.selectNodeContents(node);
+ add(range.getBoundingClientRect());
+ descended = true;
+ }
+ }
+ if (!descended) add(el.getBoundingClientRect());
+ };
+ for (const child of body.children) walk(child, 1);
+ if (overran) return null;
+ if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
+ if (!(right > left) || !(bottom > top)) return null;
+ return {
+ x: left,
+ y: top,
+ width: right - left,
+ height: bottom - top,
+ viewportWidth: vw,
+ viewportHeight: vh,
+ };
+}
+"""
+
+
+def _parse_content_box(measured: object) -> tuple[int, int, int, int] | None:
+ """Validate and gate one content bounding box read back from a popup.
+
+ Returns ``(width, height, x, y)`` in recording pixels, or ``None`` when the
+ measurement is unusable — non-finite, empty, or *degenerate*: at least
+ :data:`POPUP_BBOX_DEGENERATE_RATIO` of the viewport in **both** dimensions,
+ which means the content genuinely is full-bleed and the box says nothing the
+ uncropped canvas did not already say. The origin rounds down and the far
+ edges round up, so rounding never shaves a painted pixel.
+ """
+ if not isinstance(measured, dict):
+ return None
+ values: list[float] = []
+ for key in ("x", "y", "width", "height", "viewportWidth", "viewportHeight"):
+ value = measured.get(key)
+ if isinstance(value, bool) or not isinstance(value, int | float):
+ return None
+ if not math.isfinite(float(value)):
+ return None
+ values.append(float(value))
+ x, y, width, height, viewport_width, viewport_height = values
+ if width <= 0 or height <= 0 or viewport_width <= 0 or viewport_height <= 0:
+ return None
+ if (
+ width >= viewport_width * POPUP_BBOX_DEGENERATE_RATIO
+ and height >= viewport_height * POPUP_BBOX_DEGENERATE_RATIO
+ ):
+ return None
+ left, top = math.floor(x), math.floor(y)
+ return (
+ math.ceil(x + width) - left,
+ math.ceil(y + height) - top,
+ max(0, left),
+ max(0, top),
+ )
+
+
+async def _popup_content_box(popup: Page) -> tuple[int, int, int, int] | None:
+ """Measure the popup's painted content while the page is still alive.
+
+ Level 2 of the crop chain, used when the site opened the popup without size
+ features. A closed page, a navigation race or a hostile document degrades to
+ ``None`` (fall through to level 3) rather than failing the render.
+ """
+ try:
+ measured = await popup.evaluate(_POPUP_CONTENT_BOX_SCRIPT)
+ except PlaywrightError:
+ return None
+ return _parse_content_box(measured)
+
+
+def _start_popup_content_box(popup: Page) -> asyncio.Task[tuple[int, int, int, int] | None]:
+ """Begin measuring the popup off the render's critical path.
+
+ The measurement has to happen while the popup's DOM exists, and the DOM dies
+ long before composition — but awaiting it where it starts would put its
+ latency *inside the recorded timeline*, right at the moment the popup appears
+ on camera. So it is started here and collected later
+ (:func:`_settle_popup_content_box`), by which time it has long since
+ finished: the render does not block on it at all.
+ """
+
+ return asyncio.ensure_future(_popup_content_box(popup))
+
+
+async def _settle_popup_content_box(popup: _PopupSession) -> None:
+ """Collect the pending measurement, bounded, before the context goes away.
+
+ Must run while the browser context is still open — a closed context can only
+ answer with "target closed". Bounded for the same reason the ``window.open``
+ lookup is: ``Page.evaluate`` never times out on its own, and no popup may
+ hang a render. Giving up costs the crop, not the video.
+ """
+
+ probe = popup.content_box_probe
+ if probe is None:
+ return
+ popup.content_box_probe = None
+ try:
+ popup.content_box = await asyncio.wait_for(
+ asyncio.shield(probe), _POPUP_CONTENT_BOX_TIMEOUT
+ )
+ except TimeoutError:
+ # ``shield`` keeps the timeout from cancelling the probe itself, so it is
+ # still ours to dispose of without orphaning its exception.
+ _discard_pending(probe)
+ tqdm.write(
+ "OSTRZEŻENIE: pomiar zawartości popupu nie zakończył się w "
+ f"{_POPUP_CONTENT_BOX_TIMEOUT:g}s — przycięcie z heurystyki obrazu",
+ file=sys.stderr,
+ )
+ except PlaywrightError:
+ # The popup closed under the probe: level 3 decides instead.
+ popup.content_box = None
+
+
+def _page_viewport(page: Page) -> tuple[int, int] | None:
+ """*page*'s layout viewport as a plain pair, or ``None`` when unset."""
+ viewport = page.viewport_size
+ if viewport is None:
+ return None
+ return viewport["width"], viewport["height"]
+
+
+def _log_popup_crop(level: str, crop: tuple[int, int, int, int] | None, verbose: bool) -> None:
+ """Say which fallback level framed the popup — silent degradation is a bug."""
+ if not verbose:
+ return
+ if crop is None:
+ tqdm.write(f" ⤷ kadr popupu: {level} nic nie wykrył — pełne płótno nagrania")
+ return
+ width, height, x, y = crop
+ tqdm.write(f" ⤷ kadr popupu: {level} → {width}x{height}+{x}+{y}")
+
+
+#: Backing scales a compositor plausibly renders a window at. Headless Chromium
+#: draws at 1; a headed window inherits its screen's scale factor, which is 2 on
+#: every shipping HiDPI display and 3 only on the densest mobile-class panels.
+#: Used to sanity-check a *measured* recording scale, never to pick one.
+_PLAUSIBLE_BACKING_SCALES = (1, 2, 3)
+
+#: How far a measured recording scale may sit from the nearest scale the fit
+#: rule can produce. Wide enough for the recorder's even-pixel snapping (a 597px
+#: window records as 596), far too narrow to admit an unrelated rect.
+_RECORDING_SCALE_TOLERANCE = 0.02
+
+
+def _recording_scale(
+ measured: tuple[int, int, int, int] | None,
+ viewport: tuple[int, int] | None,
+ canvas: tuple[int, int] | None,
+) -> float | None:
+ """How many recording pixels the popup got per CSS pixel, or ``None``.
+
+ Playwright records every page in a context onto one canvas of
+ ``record_video_size``, and fits each page's *compositor* frame into it
+ preserving aspect ratio, anchored top-left, padding the rest. That frame is
+ in **device** pixels: a headed browser on a HiDPI screen composites at the
+ screen's backing scale, so a 500x670 popup arrives as 1000x1340 and is fitted
+ into a 1376x800 canvas at 0.597 — leaving the window occupying 597x800
+ *recording* pixels, not 500x670. Headless composites at 1 and the same popup
+ records 1:1, which is why this only ever bit headed renders.
+
+ The backing scale is not observable from the page (``devicePixelRatio``
+ reports the emulated 1) nor from ``screenshot(scale="device")``, so it is
+ read back off the recording: *measured* is the popup's rendered rect as
+ trimmed out of the padding, and the scale is its width over the viewport's.
+
+ The measurement is a pixel heuristic, so it is only believed when it looks
+ like a fitted window: anchored at the origin, aspect preserved, and within
+ :data:`_RECORDING_SCALE_TOLERANCE` of a scale the fit rule can actually
+ produce for some plausible backing scale. Anything else — a page whose own
+ background matched the padding colour, ink mistaken for a window — returns
+ ``None``, i.e. "assume 1:1", which is the behaviour that predates this.
+ """
+ if measured is None or viewport is None or canvas is None:
+ return None
+ width, height, x, y = measured
+ viewport_width, viewport_height = viewport
+ if (x, y) != (0, 0) or viewport_width <= 0 or viewport_height <= 0:
+ return None
+ if width <= 0 or height <= 0:
+ return None
+ scale = width / viewport_width
+ if abs(height / viewport_height - scale) > _RECORDING_SCALE_TOLERANCE * scale:
+ return None # not a uniformly scaled window
+ fitted = [
+ min(backing, canvas[0] / viewport_width, canvas[1] / viewport_height)
+ for backing in _PLAUSIBLE_BACKING_SCALES
+ ]
+ if not any(
+ abs(scale - candidate) <= _RECORDING_SCALE_TOLERANCE * candidate for candidate in fitted
+ ):
+ return None
+ return scale
+
+
+def _scale_rect(rect: tuple[int, int, int, int], scale: float) -> tuple[int, int, int, int]:
+ """Map a CSS-pixel ``(width, height, x, y)`` into recording pixels.
+
+ The origin floors and the far edges ceil, so the rounding can only ever add
+ a fringe pixel of padding — never shave one off the popup's own ink.
+ """
+ width, height, x, y = rect
+ left, top = math.floor(x * scale), math.floor(y * scale)
+ return (
+ math.ceil((x + width) * scale) - left,
+ math.ceil((y + height) * scale) - top,
+ left,
+ top,
+ )
+
+
+def _resolve_popup_crop(
+ *,
+ window_size: tuple[int, int] | None,
+ content_box: tuple[int, int, int, int] | None,
+ popup_video: Path,
+ verbose: bool,
+ viewport: tuple[int, int] | None = None,
+ canvas: tuple[int, int] | None = None,
+) -> tuple[tuple[int, int, int, int] | None, str]:
+ """Pick the popup crop rect from the best available witness.
+
+ Three levels, most deterministic first, returned with the name of the one
+ that answered so the log can say which framed the video:
+
+ 1. ``window.open`` size features — what the site literally asked for.
+ 2. the popup's content bounding box — measured in the page, gated by
+ :data:`POPUP_BBOX_DEGENERATE_RATIO`.
+ 3. ``cropdetect`` over the recording — a pixel heuristic, last resort.
+
+ Levels 1 and 2 speak **CSS pixels** and the crop is applied to the
+ *recording*, which are not the same unit — see :func:`_recording_scale`. The
+ same padding-trimming pass that backs level 3 supplies the conversion factor
+ whenever there is a *viewport* and a *canvas* to correct between; without
+ them, or when the measurement does not look like a fitted window, the rects
+ pass through as-is (1:1, which is what headless produces).
+
+ All three declining yields ``(None, "none")``: the pre-crop full-canvas
+ filtergraph, byte for byte.
+ """
+ css_evidence = window_size is not None or content_box is not None
+ measured = (
+ detect_content_crop(popup_video)
+ if not css_evidence or (viewport is not None and canvas is not None)
+ else None
+ )
+ if window_size is not None or content_box is not None:
+ scale = _recording_scale(measured, viewport, canvas)
+ if verbose and scale is not None and abs(scale - 1.0) > _RECORDING_SCALE_TOLERANCE:
+ tqdm.write(f" ⤷ nagranie popupu w skali {scale:.3f}× — kadr przeliczony")
+ if window_size is not None:
+ # The measured rect *is* the whole window in recording pixels, so
+ # prefer it over rescaling the request: it needs no rounding and
+ # cannot leave a seam of padding along the right or bottom edge.
+ crop = measured if scale is not None else (window_size[0], window_size[1], 0, 0)
+ assert crop is not None
+ _log_popup_crop("window.open", crop, verbose)
+ return crop, "window.open"
+ assert content_box is not None
+ crop = content_box if scale is None else _scale_rect(content_box, scale)
+ _log_popup_crop("bbox", crop, verbose)
+ return crop, "bbox"
+ _log_popup_crop("cropdetect", measured, verbose)
+ return (measured, "cropdetect") if measured is not None else (None, "none")
+
+
+def _popup_fills_canvas(popup_crop: tuple[int, int, int, int] | None, viewport: Viewport) -> bool:
+ """Whether the popup window occupies the entire recording canvas.
+
+ ``None`` is the ``_blank`` tab case: every crop level declined, so no witness
+ could name a window smaller than the recording. An explicit rect covering the
+ canvas at the origin says the same thing positively — a ``window.open`` that
+ asked for the full viewport.
+
+ The distinction matters because ``float`` insets the popup at
+ :attr:`PopupConfig.scale`; applied to a full viewport that reads as a shrunken
+ clone of the page rather than as a separate window.
+
+ ``viewport`` here is *not* CSS pixels despite the name: ``cfg.viewport``'s
+ width/height are passed verbatim as ``record_video_size`` when the browser
+ context is created (see ``_run``, around the ``browser.new_context`` call),
+ so it already is the canvas measured in recording pixels — the same unit
+ ``popup_crop`` is in. No CSS-to-recording-pixel conversion belongs here.
+ """
+
+ if popup_crop is None:
+ return True
+ width, height, x, y = popup_crop
+ return (x, y) == (0, 0) and (width, height) == (viewport.width, viewport.height)
diff --git a/guidebot_recorder/recorder/render/popup_detect.py b/guidebot_recorder/recorder/render/popup_detect.py
new file mode 100644
index 0000000..c33273e
--- /dev/null
+++ b/guidebot_recorder/recorder/render/popup_detect.py
@@ -0,0 +1,337 @@
+"""What the opener asked ``window.open`` for, and whether it called it at all.
+
+Two questions, both answered by an init script that wraps ``window.open`` on every
+document, and both read back from the *opener* while it is still alive:
+
+* the requested window geometry — level 1 of the popup crop chain, whose
+ remaining levels are in :mod:`~guidebot_recorder.recorder.render.popup_crop`;
+* whether ``window.open`` ran at all — which is what tells a real
+ ``target="_blank"`` browser tab from a featureless ``window.open(url, name)``.
+
+Both lookups are bounded twice over, because they run at the worst possible moment
+(right after a popup opens, while the opener is at its busiest) and a render that
+hangs here is unrecoverable.
+
+``_POPUP_REQUEST_LOOKUP_TIMEOUT`` is a test seam and is defined *and* read here, so
+a patch on this module reaches the scans below. Do not move it to
+:mod:`~guidebot_recorder.recorder.render.constants`: that would put the value a
+test replaces in one module and the globals its readers resolve in another.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+
+from playwright.async_api import (
+ Error as PlaywrightError,
+)
+from playwright.async_api import Frame, Page
+from tqdm import tqdm
+
+from .tasks import _discard_pending
+
+# The site's own ``window.open(url, name, "width=...,height=...")`` request is
+# the most deterministic statement of how big the popup window really is, and
+# Chromium honours it: a popup opened with size features gets exactly that
+# viewport, *not* the context's (verified — ``new_context(viewport=...)`` does
+# not override the request; only a featureless ``window.open`` inherits the
+# context viewport, which is what levels 2 and 3 exist for). It is read here
+# rather than from the popup because it is available the moment the popup
+# opens, before its document has committed. Record the request on the *opener*
+# frame; the popup is matched to it right after it opens.
+#
+# Note the unit: this is the popup's viewport in **CSS px**, which is only the
+# same as its size in the *recording* when the compositor draws at scale 1. See
+# ``popup_crop._recording_scale`` for the conversion and for why headed renders
+# differ.
+_POPUP_REQUEST_KEY = "__guidebot_popup_request"
+
+
+# Set unconditionally the moment ``window.open`` runs, features or not. This is
+# what tells a real ``target="_blank"`` tab (no call at all) apart from a
+# featureless ``window.open(url, name)`` (a call that leaves ``KEY`` at
+# ``null``, exactly like never having been called) — a distinction
+# ``_POPUP_REQUEST_KEY`` alone cannot make. See ``_popup_window_opened``.
+_POPUP_OPENED_KEY = "__guidebot_popup_opened"
+
+
+# How long the opener's frames get to answer the geometry probe. They read a
+# value the page already wrote synchronously, so a healthy frame answers in
+# milliseconds; the budget exists purely to survive frames that can never
+# answer at all (see ``_scan_frames_for_window_request``).
+_POPUP_REQUEST_LOOKUP_TIMEOUT = 2.0
+
+
+# Absolute ceiling on the lookup, cleanup included. The scan bounds itself, so
+# reaching this means the bounding itself misbehaved; it exists so that no page
+# can ever hang a render here.
+_POPUP_REQUEST_HARD_TIMEOUT = 5.0
+
+
+_POPUP_REQUEST_SCRIPT = f"""
+(() => {{
+ const KEY = "{_POPUP_REQUEST_KEY}";
+ const OPENED = "{_POPUP_OPENED_KEY}";
+ if (Object.prototype.hasOwnProperty.call(window, KEY)) return;
+ const native = window.open;
+ if (typeof native !== "function") return;
+ window[KEY] = null;
+ window[OPENED] = false;
+ // Captured before chrome.js shadows ``top`` (frame-bust neutralization), so
+ // this is the *real* top document even from inside the shell's site iframe.
+ // Publishing the record there lets the Python side read one frame instead of
+ // interrogating every ad iframe on the page.
+ const realTop = window.top;
+ const parse = (features) => {{
+ if (typeof features !== "string") return null;
+ const sizes = {{}};
+ for (const part of features.split(",")) {{
+ const eq = part.indexOf("=");
+ if (eq < 0) continue;
+ const value = Number(part.slice(eq + 1).trim());
+ if (Number.isFinite(value)) sizes[part.slice(0, eq).trim().toLowerCase()] = value;
+ }}
+ const width = sizes.width !== undefined ? sizes.width : sizes.innerwidth;
+ const height = sizes.height !== undefined ? sizes.height : sizes.innerheight;
+ if (!(width > 0) || !(height > 0)) return null;
+ return {{ width: Math.round(width), height: Math.round(height) }};
+ }};
+ window.open = function (...args) {{
+ window[OPENED] = true;
+ try {{
+ if (realTop && realTop !== window) realTop[OPENED] = true;
+ }} catch (e) {{}}
+ const requested = parse(args[2]);
+ if (requested) {{
+ window[KEY] = requested;
+ // Best effort: a genuinely cross-origin opener frame cannot write to the
+ // top document, and that is fine — the Python side falls back to a
+ // bounded per-frame scan.
+ try {{
+ if (realTop && realTop !== window) realTop[KEY] = requested;
+ }} catch (e) {{}}
+ }}
+ return native.apply(this, args);
+ }};
+}})();
+"""
+
+
+def _parse_window_request(requested: object) -> tuple[int, int] | None:
+ """Validate one ``window.open`` geometry record read back from the page."""
+ if not isinstance(requested, dict):
+ return None
+ width, height = requested.get("width"), requested.get("height")
+ if not isinstance(width, int | float) or not isinstance(height, int | float):
+ return None
+ if width <= 0 or height <= 0:
+ return None
+ return int(width), int(height)
+
+
+async def _frame_window_request(frame: Frame) -> tuple[int, int] | None:
+ """Read one frame's ``window.open`` record, or ``None`` if it cannot answer."""
+
+ try:
+ requested = await frame.evaluate(f"() => window.{_POPUP_REQUEST_KEY} || null")
+ except PlaywrightError:
+ # The frame navigated away, detached, or never ran the init script.
+ return None
+ return _parse_window_request(requested)
+
+
+async def _scan_frames_for_window_request(opener: Page) -> tuple[int, int] | None:
+ """Ask every frame for its record *concurrently* and return the first answer.
+
+ Concurrency is the point, not an optimisation. ``Frame.evaluate`` accepts no
+ timeout, and an ad-heavy page routinely carries iframes whose document never
+ commits an execution context — evaluating on one of those never returns.
+ Asking the frames one after another therefore lets a single dead ad iframe
+ block the render forever (and it did).
+
+ Asking them all at once is only half the fix, though: waiting for *all* the
+ probes means a dead frame still costs the whole
+ ``_POPUP_REQUEST_LOOKUP_TIMEOUT`` even when a healthy frame answered in
+ milliseconds — and because this runs on every popup open, that budget shows
+ up in the finished film as a dead pause on the popup. So the scan settles
+ the moment it has a usable answer and abandons the rest. The budget survives
+ unchanged as what it was always meant to be: the ceiling for the case where
+ *nobody* ever answers, after which the caller falls back to the content
+ bounding box (and then to cropdetect).
+
+ Which answer wins when several frames have one: the probes are examined in a
+ fixed priority order — the top document first, then the remaining frames
+ newest-first — and the highest-priority *already-settled* answer is taken.
+ That is safe because the answers are not really independent. The init script
+ republishes every record on the real top document, so a same-origin page has
+ one record read back from several places; a cross-origin opener frame that
+ could not republish is instead the only frame holding one, and the top
+ answers ``None``. Genuinely conflicting records would need two frames to
+ each call ``window.open`` with different sizes before this runs, and the
+ scan runs immediately after the popup opens — newest-first is then exactly
+ the record that opened it. Worst case the crop rect is one popup stale,
+ which the downstream fallbacks already tolerate.
+ """
+
+ top = opener.main_frame
+ frames = [top, *(frame for frame in reversed(opener.frames) if frame is not top)]
+ tasks = [asyncio.ensure_future(_frame_window_request(frame)) for frame in frames]
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + _POPUP_REQUEST_LOOKUP_TIMEOUT
+ try:
+ pending = set(tasks)
+ while pending:
+ remaining = deadline - loop.time()
+ if remaining <= 0:
+ break
+ # Wake on every settled probe rather than on the slowest one. A
+ # frame that raises simply settles without an answer: it retires
+ # from ``pending`` and the others carry on.
+ _, pending = await asyncio.wait(
+ pending, timeout=remaining, return_when=asyncio.FIRST_COMPLETED
+ )
+ for task in tasks:
+ if task.done() and not task.cancelled() and task.exception() is None:
+ if (size := task.result()) is not None:
+ return size
+ return None
+ finally:
+ for task in tasks:
+ _discard_pending(task)
+
+
+async def _popup_window_request(opener: Page) -> tuple[int, int] | None:
+ """Return the window size the *opener* asked ``window.open`` for, if any.
+
+ Bounded twice over, because this runs at the worst possible moment — right
+ after a popup opens, while the opener is at its busiest — and a render that
+ hangs here is unrecoverable. An unknown size is by contrast harmless: it
+ means no crop, i.e. the full-canvas behaviour that predates popup cropping.
+ So the lookup always terminates, and says so when it gives up.
+ """
+
+ lookup = asyncio.ensure_future(_scan_frames_for_window_request(opener))
+ try:
+ size = await asyncio.wait_for(asyncio.shield(lookup), _POPUP_REQUEST_HARD_TIMEOUT)
+ except TimeoutError:
+ # ``shield`` means the timeout does not itself cancel the lookup, so it
+ # is still ours to dispose of cleanly.
+ _discard_pending(lookup)
+ tqdm.write(
+ "OSTRZEŻENIE: odczyt rozmiaru okna popupu nie zakończył się w "
+ f"{_POPUP_REQUEST_HARD_TIMEOUT:g}s — popup bez przycięcia",
+ file=sys.stderr,
+ )
+ return None
+ if size is None:
+ tqdm.write(
+ "OSTRZEŻENIE: żadna ramka nie podała rozmiaru okna z window.open() "
+ "— popup bez przycięcia",
+ file=sys.stderr,
+ )
+ return size
+
+
+async def _frame_window_opened(frame: Frame) -> bool | None:
+ """Read one frame's "was window.open called" flag, or ``None`` if it cannot answer."""
+
+ try:
+ return bool(await frame.evaluate(f"() => window.{_POPUP_OPENED_KEY} === true"))
+ except PlaywrightError:
+ # The frame navigated away, detached, or never ran the init script.
+ return None
+
+
+async def _scan_frames_for_window_opened(opener: Page) -> bool | None:
+ """Ask every frame, concurrently, whether it recorded a ``window.open`` call.
+
+ Mirrors ``_scan_frames_for_window_request``: the ``OPENED`` flag is mirrored
+ to the real top document only as a same-origin optimisation (see
+ ``_POPUP_REQUEST_SCRIPT``), so a call made from a genuinely cross-origin
+ frame — routine for the shell's site iframe — never reaches that mirror,
+ and the top frame alone cannot be trusted to answer. Every frame is asked
+ directly instead, concurrently and for the same reason as the geometry
+ scan: a dead ad iframe whose document never commits an execution context
+ must not block the others, or the render, forever.
+
+ Returns ``True`` the moment any frame reports the flag set, ``False`` if
+ every frame answered and none did, or ``None`` if no frame could answer at
+ all (the opener navigated away or died) — callers must treat ``None`` as
+ unknown, not as "no popup".
+ """
+
+ top = opener.main_frame
+ frames = [top, *(frame for frame in reversed(opener.frames) if frame is not top)]
+ tasks = [asyncio.ensure_future(_frame_window_opened(frame)) for frame in frames]
+ try:
+ await asyncio.wait(tasks, timeout=_POPUP_REQUEST_LOOKUP_TIMEOUT)
+ answered = False
+ for task in tasks:
+ if task.done() and not task.cancelled() and task.exception() is None:
+ result = task.result()
+ if result is True:
+ return True
+ if result is False:
+ answered = True
+ return False if answered else None
+ finally:
+ for task in tasks:
+ _discard_pending(task)
+
+
+async def _popup_window_opened(page: Page) -> bool:
+ """Whether this document called ``window.open`` at all, features or not.
+
+ ``_popup_window_request`` answers "what geometry did the site ask for", and
+ returns ``None`` both for a featureless ``window.open(url, name)`` and for a
+ window this document never opened. Only the second is a ``target="_blank"``
+ tab, and telling them apart is possible *while the popup is alive* — unlike
+ the crop chain, whose verdict arrives after the recording is finished.
+
+ Read per frame, not just the top document: the init script's mirror onto
+ the real top document (``realTop[OPENED] = true``) is only a same-origin
+ optimisation — it throws and is swallowed for a genuinely cross-origin
+ frame, which is routine for the shell's site iframe. Reading only the top
+ frame therefore misses exactly that call and misreports a real popup as a
+ ``target="_blank"`` tab. ``_scan_frames_for_window_opened`` asks every
+ frame instead, exactly as ``_popup_window_request`` already does for the
+ geometry record.
+
+ Bounded twice over, exactly like ``_popup_window_request``: this runs at
+ the same worst possible moment — right after a popup opens, while the
+ opener is at its busiest — and a render that hangs here is just as
+ unrecoverable. An unknown answer is by contrast harmless: it means
+ "assume a popup", i.e. the fail-safe direction that never mounts an
+ address bar on uncertainty. So the lookup always terminates, and says so
+ when it gives up.
+ """
+
+ def _assume_opened() -> bool:
+ tqdm.write(
+ "OSTRZEŻENIE: żadna ramka strony otwierającej nie potwierdziła wywołania "
+ "window.open() — zakładam otwarty popup (bez paska adresu)",
+ file=sys.stderr,
+ )
+ return True
+
+ lookup = asyncio.ensure_future(_scan_frames_for_window_opened(page))
+ try:
+ opened = await asyncio.wait_for(asyncio.shield(lookup), _POPUP_REQUEST_HARD_TIMEOUT)
+ except TimeoutError:
+ # ``shield`` means the timeout does not itself cancel the lookup, so it
+ # is still ours to dispose of cleanly.
+ _discard_pending(lookup)
+ tqdm.write(
+ "OSTRZEŻENIE: sprawdzenie wywołania window.open() nie zakończyło się w "
+ f"{_POPUP_REQUEST_HARD_TIMEOUT:g}s — zakładam otwarty popup (bez paska adresu)",
+ file=sys.stderr,
+ )
+ return True
+ except PlaywrightError:
+ # An opener that navigated or died reports nothing; treat it as "unknown",
+ # which keeps today's bare-popup behaviour rather than mounting a bar.
+ return _assume_opened()
+ if opened is None:
+ return _assume_opened()
+ return opened
diff --git a/guidebot_recorder/recorder/render/popup_session.py b/guidebot_recorder/recorder/render/popup_session.py
new file mode 100644
index 0000000..e4285cc
--- /dev/null
+++ b/guidebot_recorder/recorder/render/popup_session.py
@@ -0,0 +1,211 @@
+"""The deterministic page contract: one main window, at most one popup.
+
+Holds the two observation records — :class:`_PageObservation` for every page the
+context ever opened, :class:`_PopupSession` for the one popup a render may have —
+and the lifecycle helpers that keep them honest: which pages are outside the
+contract, when the popup really closed, and how a freshly opened page is
+furnished.
+
+The *visual* half of that lifecycle (mounting layers, handing the cursor over and
+back) lives in :mod:`~guidebot_recorder.recorder.render.visuals`, which imports
+this module. The dependency runs one way on purpose; reversing it would close a
+cycle.
+
+``Overlay`` is annotated through its module object rather than name-imported. It
+is a test seam whose patch lands on
+:mod:`~guidebot_recorder.recorder.render._run`, and a bare ``from ... import
+Overlay`` here would be an import-time copy no patch can reach — harmless while
+only an annotation uses it, a silent trap the moment somebody calls it.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from dataclasses import dataclass
+
+from playwright.async_api import (
+ Error as PlaywrightError,
+)
+from playwright.async_api import Page, Video
+
+from guidebot_recorder.chrome import Chrome
+from guidebot_recorder.overlay import overlay as overlay_module
+
+from .constants import _POPUP_DETECTION_SECONDS, _POPUP_QUIESCENCE_SECONDS
+
+
+@dataclass(slots=True)
+class _PageObservation:
+ opened_at: float
+ video: Video | None
+ closed_at: float | None = None
+ visual_prime: asyncio.Task[float | None] | None = None
+
+
+@dataclass(slots=True)
+class _PopupSession:
+ page: Page
+ video: Video
+ opened_at: float
+ visual_ready_delay: float = 0.0
+ closed_at: float | None = None
+ close_handled: bool = False
+ main_cursor_pos: tuple[float, float] | None = None
+ """Where the main window's cursor stood before the popup took it over.
+
+ :attr:`Overlay.pos` is shared by every page, so centring the cursor in the
+ popup overwrites the opener's. Restored on close.
+ """
+ is_blank_tab: bool = False
+ """Whether this window is a real browser tab rather than a popup window.
+
+ True when the opener never called ``window.open`` at all — the
+ ``target="_blank"`` case. Decided at furnishing time via
+ ``popup_detect._popup_window_opened``, because the crop chain's verdict does
+ not exist until the recording is over.
+
+ Do **not** substitute ``popup_crop is None`` for this. That is a weaker
+ signal: a *featureless* ``window.open`` whose page paints a full-bleed
+ background also declines every crop level, and it is a genuine floating
+ window that must keep the ``float`` presentation (pinned by
+ ``test_full_bleed_featureless_popup_renders_uncropped``).
+ """
+ wants_bar: bool = False
+ """Whether this window shows the legacy in-DOM address bar.
+
+ True only for a real ``target="_blank"`` tab — a browser tab with no address
+ bar reads as a rendering fault. Every other popup stays bare and is framed by
+ the compositor instead. Decided at open time from
+ ``popup_detect._popup_window_opened``, because the crop chain's verdict does
+ not exist until the recording is over, and the bar is painted DOM that would
+ corrupt crop levels 2 and 3.
+ """
+ window_size: tuple[int, int] | None = None
+ """The popup's real window size in CSS px, or ``None`` when unknown.
+
+ Playwright gives every page in the context the *same* recording canvas (the
+ ``record_video_size`` handed to ``browser.new_context`` in ``_run``), so the
+ popup's MP4 is main-viewport sized no matter how small a window the site
+ asked for. This is the geometry the compositor crops that canvas back down
+ to.
+ """
+ viewport: tuple[int, int] | None = None
+ """The popup page's own layout viewport in CSS px, or ``None`` if unknown.
+
+ Not the same thing as :attr:`window_size` (which the site *asked* for) nor as
+ the recording canvas: it is what the popup actually got, and it is the unit
+ levels 1 and 2 measure in. Paired with the canvas it lets
+ ``popup_crop._recording_scale`` convert those measurements into recording
+ pixels.
+ """
+ content_box: tuple[int, int, int, int] | None = None
+ """The popup's painted content as ``(width, height, x, y)``, or ``None``.
+
+ Level 2 of the crop chain — what a featureless ``window.open(url, name)``
+ leaves us with. Measured while the popup page is alive: the recording
+ outlives the page, its DOM does not. The chain lives in ``popup_crop``.
+ """
+ content_box_probe: asyncio.Task[tuple[int, int, int, int] | None] | None = None
+ """The in-flight measurement, until ``popup_crop._settle_popup_content_box``
+ reaps it.
+
+ Kept pending on purpose: starting it when the popup opens and collecting it
+ at the end of the run keeps its cost out of the recorded timeline.
+ """
+
+
+def _unexpected_pages(
+ observed_pages: dict[Page, _PageObservation],
+ main_page: Page,
+ popup: _PopupSession | None,
+) -> list[Page]:
+ """Observed pages outside the deterministic main + one-popup contract.
+
+ The event-backed list deliberately retains pages that already closed, so an
+ unexpected page cannot evade validation by opening and closing between steps.
+ """
+
+ expected_popup = popup.page if popup is not None else None
+ return [page for page in observed_pages if page is not main_page and page is not expected_popup]
+
+
+def _sync_popup_close(
+ popup: _PopupSession | None,
+ observed_pages: dict[Page, _PageObservation],
+ anchor: float,
+) -> None:
+ if popup is None or popup.closed_at is not None:
+ return
+ observation = observed_pages.get(popup.page)
+ if observation is not None and observation.closed_at is not None:
+ popup.closed_at = max(popup.opened_at, observation.closed_at - anchor)
+
+
+async def _wait_for_render_popup(
+ observed_pages: dict[Page, _PageObservation],
+ known_pages: set[Page],
+ started_at: float,
+ timeout: float = _POPUP_DETECTION_SECONDS,
+) -> list[Page]:
+ """Return pages opened inside the actual-click discovery window."""
+
+ deadline = started_at + timeout
+ while True:
+ candidates = [
+ page
+ for page, observation in observed_pages.items()
+ if page not in known_pages and started_at <= observation.opened_at <= deadline
+ ]
+ if candidates:
+ await asyncio.sleep(_POPUP_QUIESCENCE_SECONDS)
+ return sorted(
+ (
+ page
+ for page, observation in observed_pages.items()
+ if page not in known_pages and started_at <= observation.opened_at <= deadline
+ ),
+ key=lambda page: observed_pages[page].opened_at,
+ )
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ return []
+ await asyncio.sleep(min(0.05, remaining))
+
+
+async def _prepare_popup(
+ page: Page,
+ overlay: overlay_module.Overlay,
+ chrome: Chrome | None,
+ *,
+ expect_chrome: bool | None = None,
+ mount_bar: bool = False,
+) -> bool:
+ """Prepare a new page; translate close races into lifecycle state.
+
+ ``expect_chrome`` defaults to ``chrome is not None``; pass ``False`` for a
+ bare (floating) popup so the chrome bar is not mounted on it (the cursor is
+ still ensured).
+
+ ``mount_bar`` forces the legacy bar onto this one page even when the
+ context-wide script is bare — a real ``target="_blank"`` tab is a browser
+ tab and reads as a rendering fault without an address bar.
+ """
+
+ if expect_chrome is None:
+ expect_chrome = chrome is not None
+ if page.is_closed():
+ return False
+ try:
+ await page.bring_to_front()
+ await page.wait_for_load_state()
+ await overlay.ensure(page)
+ if chrome is not None and mount_bar:
+ await chrome.install_bar(page)
+ elif chrome is not None and expect_chrome:
+ await chrome.ensure(page)
+ except PlaywrightError:
+ if page.is_closed():
+ return False
+ raise
+ return not page.is_closed()
diff --git a/guidebot_recorder/recorder/render/reuse.py b/guidebot_recorder/recorder/render/reuse.py
new file mode 100644
index 0000000..e4016d1
--- /dev/null
+++ b/guidebot_recorder/recorder/render/reuse.py
@@ -0,0 +1,177 @@
+"""The compiled-sidecar contract, as render reads it.
+
+What ``compile`` froze and what render is allowed to replay: the fingerprint check
+(:func:`_compiled_action_is_current`), in-place resolution of a still-pending entry
+against the live page (:func:`_resolve_pending_target`), and the ``CachedAction``
+that replaces it on disk (:func:`_freeze_resolved`).
+
+Deliberately free of browser choreography — it decides *whether* a step may run as
+frozen, never *how* it runs. :func:`_compiled_from` is a thin alias over the
+compiler's own rule rather than a second implementation: render's copy used to be
+a verbatim duplicate, so extending one side would have made every sidecar look
+stale to the other.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from urllib.parse import urljoin
+
+from playwright.async_api import Frame, Page
+
+from guidebot_recorder.models.action import (
+ COMPILER_VERSION,
+ CachedAction,
+ Fingerprint,
+ PendingAction,
+)
+from guidebot_recorder.models.compiled import CompiledAction
+from guidebot_recorder.models.scenario import Scenario, Step, WaitUntil
+from guidebot_recorder.resolver.reasoner import Reasoner
+from guidebot_recorder.resolver.resolution import (
+ ResolvedTarget,
+ TargetAbsent,
+ compiled_from,
+ resolve_step_target,
+ step_instruction,
+)
+
+from .errors import _OptionalAbsent
+
+#: how often a pending gate is re-resolved while its wait window is still open
+_PENDING_POLL_SECONDS = 0.25
+
+
+def _resolve_url(scenario: Scenario, url: str) -> str:
+ base = scenario.config.base_url
+ if base and not url.startswith(("http://", "https://")):
+ return urljoin(base, url)
+ return url
+
+
+def _compiled_from(step: Step) -> str:
+ """What ``compile`` froze this step's fingerprint against.
+
+ A thin alias, deliberately not a second implementation: render's copy of
+ this rule used to be a verbatim duplicate of the compiler's, so extending
+ one side (with the per-step ``select.mode``) would have made every sidecar
+ look stale to the other.
+ """
+
+ try:
+ return compiled_from(step)
+ except ValueError as exc:
+ raise ValueError(f"krok {step.command_kind()} nie wymaga cachedAction") from exc
+
+
+def _compiled_action_is_current(
+ step: Step, action: CompiledAction | None, scenario_hash: str
+) -> bool:
+ """Check source/config fingerprints before replaying frozen behavior."""
+
+ if not step.requires_target():
+ return action is None
+ if action is None:
+ return False
+ kind = step.command_kind()
+ expected_state = step.wait.state if isinstance(step.wait, WaitUntil) else None
+ if isinstance(action, PendingAction):
+ # Nothing was frozen yet, so there is no action/expect to cross-check —
+ # only that the placeholder still stands for *this* step and config.
+ fingerprint = action.fingerprint
+ return (
+ fingerprint.compiler_version == COMPILER_VERSION
+ and fingerprint.command_kind == kind
+ and fingerprint.compiled_from == _compiled_from(step)
+ and fingerprint.config_hash == scenario_hash
+ and fingerprint.state == expected_state
+ )
+ expected_action = {
+ "click": "click",
+ "hover": "hover",
+ "enterText": "type",
+ "select": "select",
+ "highlight": "highlight",
+ "wait": "waitFor",
+ }.get(kind)
+ if expected_action is not None and action.action != expected_action:
+ return False
+ fingerprint = action.fingerprint
+ if not (
+ fingerprint.compiler_version == COMPILER_VERSION
+ and fingerprint.command_kind == kind
+ and fingerprint.compiled_from == _compiled_from(step)
+ and fingerprint.config_hash == scenario_hash
+ and fingerprint.state == expected_state
+ and fingerprint.expect == action.expect
+ ):
+ return False
+ return not (
+ kind == "teach"
+ and action.action == "type"
+ and (action.input_text is None or action.input_text not in step.teach)
+ )
+
+
+async def _resolve_pending_target(
+ root: Page | Frame,
+ step: Step,
+ kind: str,
+ reasoner: Reasoner,
+) -> ResolvedTarget:
+ """Resolve a :class:`PendingAction` against the live page, polling while it may still appear.
+
+ A gate (`wait: {until: ...}`) gets the whole configured wait window, retried on
+ an interval: the canonical gating element — a cookie banner — is injected after
+ a delay, and a single snapshot would report a spurious absence and silently
+ delete the branch from the video. Any other optional step has no wait window,
+ so it is resolved exactly once.
+
+ Raises :class:`~guidebot_recorder.recorder.render.errors._OptionalAbsent` when
+ the window closes on an "absent" verdict; every other resolver failure
+ propagates out of ``resolve_step_target``.
+ """
+
+ window = step.wait.timeout if isinstance(step.wait, WaitUntil) else 0.0
+ deadline = time.monotonic() + window
+ while True:
+ result = await resolve_step_target(root, step, kind, reasoner)
+ if isinstance(result, ResolvedTarget):
+ return result
+ assert isinstance(result, TargetAbsent)
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise _OptionalAbsent(f"{step_instruction(step)!r}: {result.reason} ({result.message})")
+ await asyncio.sleep(min(_PENDING_POLL_SECONDS, remaining))
+
+
+def _freeze_resolved(
+ step: Step,
+ kind: str,
+ resolved: ResolvedTarget,
+ expect: str,
+ scenario_hash: str,
+) -> CachedAction:
+ """Build the ``CachedAction`` that replaces a pending entry in the sidecar.
+
+ ``opens_popup`` stays false by construction: a click resolved at render time
+ carries no popup observation from compile, and the render popup contract is
+ what fails loudly if one opens anyway (a documented limitation of branches).
+ """
+
+ return CachedAction(
+ action=resolved.action,
+ target=resolved.target,
+ identity=resolved.identity,
+ expect=expect,
+ state=resolved.state,
+ input_text=resolved.input_text,
+ fingerprint=Fingerprint(
+ command_kind=kind,
+ compiled_from=_compiled_from(step),
+ expect=expect,
+ config_hash=scenario_hash,
+ state=resolved.state,
+ ),
+ )
diff --git a/guidebot_recorder/recorder/render/tasks.py b/guidebot_recorder/recorder/render/tasks.py
new file mode 100644
index 0000000..9ff10c1
--- /dev/null
+++ b/guidebot_recorder/recorder/render/tasks.py
@@ -0,0 +1,30 @@
+"""Disposing of asyncio work the render no longer needs.
+
+Every bounded probe in this package — the two ``window.open`` lookups, the popup
+content-box measurement — can outlive the answer it was started for. They share
+one disposal rule rather than each inventing its own, which is why it lives here
+and not next to any one of them.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+
+def _discard_pending(task: asyncio.Future) -> None:
+ """Cancel a lookup we no longer need, and never orphan its exception.
+
+ A cancelled ``Frame.evaluate`` can still settle later — typically with
+ ``Frame was detached`` — and an unread exception on an abandoned future is
+ what produces asyncio's "Future exception was never retrieved" noise.
+ Retrieving it in a done-callback keeps that silent.
+ """
+
+ if task.done():
+ # Already settled, so there is nothing to cancel — but a probe that
+ # raised and was never read is orphaned just the same. Read it.
+ if not task.cancelled():
+ task.exception()
+ return
+ task.cancel()
+ task.add_done_callback(lambda done: done.cancelled() or done.exception())
diff --git a/guidebot_recorder/recorder/render/timeline.py b/guidebot_recorder/recorder/render/timeline.py
new file mode 100644
index 0000000..2f3910e
--- /dev/null
+++ b/guidebot_recorder/recorder/render/timeline.py
@@ -0,0 +1,88 @@
+"""From observed freezes to a validated model, and from the model to the file.
+
+:func:`_build_timeline` reconciles what the run *observed* — freezes stamped by
+:func:`~guidebot_recorder.recorder.render.narration._stamp_frame` — into something
+``Timeline`` will accept as a *model*. :func:`_apply_timeline_edits` is then the
+one place that model meets the file it describes, and checks the two against each
+other exactly, in integer frames.
+
+Three test seams land on this module. ``apply_time_edits`` and
+``probe_frame_count`` are defined outside the package and name-imported here on
+purpose: this is the module whose globals the consumer reads, so this is where the
+patch belongs. ``probe_frame_count`` has a second consumer in
+:mod:`~guidebot_recorder.recorder.render._run`, so replacing it takes **two** patch
+lines. ``_apply_timeline_edits`` is the third — defined here, called through this
+module object from ``_run``.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from pathlib import Path
+
+from guidebot_recorder.video.timeline import (
+ TimeEdit,
+ Timeline,
+ apply_time_edits,
+ probe_frame_count,
+)
+
+from .errors import RenderError
+
+
+def _build_timeline(edits: Iterable[TimeEdit], *, source_frames: int) -> Timeline:
+ """Coalesce collected edits onto the frame grid, then validate them.
+
+ ``Timeline`` is deliberately strict — it rejects two edits on one frame, and
+ anything at or past the end of the recording — because those are nonsense as
+ a *model*. They are not nonsense as *observations*: a freeze recorded near
+ the end can round past the 0.1s postroll and land at or beyond the last
+ frame, and the clamp that pulls it back can then collide with a freeze
+ already sitting there. Either would otherwise blow up after the entire
+ recording is finished, losing the render.
+
+ (``narration._stamp_frame`` now keeps freezes at least a frame apart as they
+ are emitted, so two *unclamped* freezes can no longer share a frame. The merge
+ still has to exist for the clamped case, and is kept general rather than
+ special-cased to it.)
+
+ So the collected list is reconciled here, where the observations are:
+
+ * an ``at`` at or beyond the end clamps to the last real frame — there is no
+ later frame to hold, and the film must still gain those frames;
+ * freezes sharing a frame merge by SUMMING their lengths, because two steps
+ that both want the picture held at frame N mean the film holds frame N for
+ the total of both. The film comes out exactly as long as the narration
+ asked for, which is the invariant that matters.
+ """
+ merged: dict[int, TimeEdit] = {}
+ passthrough: list[TimeEdit] = []
+ for edit in edits:
+ if edit.kind != "freeze":
+ passthrough.append(edit)
+ continue
+ at = min(edit.at, source_frames - 1)
+ previous = merged.get(at)
+ frames = edit.frames + (previous.frames if previous else 0)
+ merged[at] = TimeEdit(at=at, kind="freeze", frames=frames)
+ return Timeline.build([*merged.values(), *passthrough], source_frames=source_frames)
+
+
+def _apply_timeline_edits(source: Path, timeline: Timeline, dest: Path) -> None:
+ """Apply *timeline* to *source*, then verify the result against the model.
+
+ Everything downstream trusts ``Timeline.virtual_duration``: the audio beds
+ are built to that length and ``mux_audio_tracks`` is handed the same number
+ as its ``video_duration``, so its duration guard compares the model against
+ itself and can never catch a model/file disagreement. This is the one place
+ the model meets the file, so the check is exact — both sides are integer
+ frame counts, and a difference of even one frame means the filtergraph did
+ something other than what was modelled.
+ """
+ apply_time_edits(source, timeline, dest)
+ produced = probe_frame_count(dest)
+ if produced != timeline.virtual_frames:
+ raise RenderError(
+ f"time-edit stage produced {produced} frames but the timeline models "
+ f"{timeline.virtual_frames} — audio would be written at the wrong length"
+ )
diff --git a/guidebot_recorder/recorder/render/visuals.py b/guidebot_recorder/recorder/render/visuals.py
new file mode 100644
index 0000000..e44571a
--- /dev/null
+++ b/guidebot_recorder/recorder/render/visuals.py
@@ -0,0 +1,355 @@
+"""Mounting, priming and handing over the injected visual layers.
+
+Everything that puts guidebot's own pixels on a page and keeps them there: the
+desktop-opener choreography, the atomic cursor+chrome mount
+(:func:`_ensure_visuals`), the bounded prime loop that survives Chromium replacing
+a fresh ``about:blank`` document, and the two halves of the popup cursor
+hand-over.
+
+:func:`_prepare_main_after_popup_close` lives here rather than in
+:mod:`~guidebot_recorder.recorder.render.popup_session` for two reasons: it is the
+exact reverse of :func:`_hand_cursor_to_popup` and reads three lines of popup
+state, and putting it beside the session records would close an import cycle (this
+module already needs those records for its own signatures).
+
+Two test seams meet here. ``Recorder`` is name-imported because
+:func:`_prepare_main_after_popup_close` constructs one and a patch on *this*
+module is what has to reach it; the same class is also constructed in
+:mod:`~guidebot_recorder.recorder.render._run`, so replacing it takes **two** patch
+lines, not one. ``_prepare_main_after_popup_close`` itself is the other seam —
+defined here, called through this module object from ``_run``.
+
+``Overlay``, by contrast, is annotated through its module object: it is a seam
+patched on ``_run``, nothing here calls it, and a name-import would be an
+unreachable copy.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from collections.abc import Awaitable, Callable
+
+from playwright.async_api import (
+ Error as PlaywrightError,
+)
+from playwright.async_api import Page
+
+from guidebot_recorder.chrome import Chrome
+from guidebot_recorder.desktop import DesktopOverlay
+from guidebot_recorder.overlay import overlay as overlay_module
+from guidebot_recorder.recorder.recorder import Recorder
+
+from .constants import _POPUP_DETECTION_SECONDS, _POPUP_QUIESCENCE_SECONDS
+from .errors import RenderError
+from .pages import _is_shell_page
+from .popup_session import _PopupSession
+
+#: Desktop-opener beats, in ms. The window growth (`_DESKTOP_OPEN_MS`) is the one
+#: the eye actually reads; the rest are short settles that keep the double-click
+#: legible without dragging the opener out.
+_DESKTOP_SETTLE_MS = 260
+_DESKTOP_DOUBLE_CLICK_GAP_MS = 130
+_DESKTOP_PRE_OPEN_MS = 220
+_DESKTOP_OPEN_MS = 760
+
+
+async def _play_desktop_opener(
+ desktop: DesktopOverlay,
+ overlay: overlay_module.Overlay,
+ page: Page,
+ payload: dict[str, str],
+ *,
+ hold: float,
+ settle_ms: float,
+ reveal: Callable[[], Awaitable[None]],
+ on_click: Callable[[str], None] | None = None,
+) -> None:
+ """Paint a desktop, arc the cursor to its icon, double-click, open the window.
+
+ The visual half of a :class:`~guidebot_recorder.models.scenario.Desktop` step.
+ Ends on the revealed underlay (the chrome shell) — *reveal* is called with the
+ faux window still covering it, so hiding the desktop overlay uncovers a live
+ browser rather than a blank frame. ``on_click`` (when given) stamps a click
+ SFX at each of the two clicks.
+
+ Fails loud if the icon cannot be located: the desktop was just painted, so a
+ missing icon is a real overlay bug, not a case to paper over.
+ """
+ await desktop.show(page, payload)
+ await overlay.show(page)
+ center = await desktop.icon_center(page)
+ if center is None:
+ raise RenderError("nie udało się zlokalizować ikony pulpitu po jej narysowaniu")
+ await overlay.move_to(page, center[0], center[1])
+ await page.wait_for_timeout(max(settle_ms, _DESKTOP_SETTLE_MS))
+ # Double-click: two ripples a beat apart, each with its own click SFX.
+ await overlay.ripple(page)
+ if on_click is not None:
+ on_click("click")
+ await page.wait_for_timeout(_DESKTOP_DOUBLE_CLICK_GAP_MS)
+ await overlay.ripple(page)
+ if on_click is not None:
+ on_click("click")
+ await page.wait_for_timeout(_DESKTOP_PRE_OPEN_MS)
+ await desktop.open_window(page, _DESKTOP_OPEN_MS)
+ await page.wait_for_timeout(_DESKTOP_OPEN_MS)
+ # Uncover the live browser: show the underlay first, THEN drop the overlay, so
+ # the reveal never flashes a blank document between the two.
+ await reveal()
+ await desktop.hide(page)
+ if hold > 0:
+ await page.wait_for_timeout(int(hold * 1000))
+
+
+async def _ensure_visuals(
+ page: Page,
+ overlay: overlay_module.Overlay,
+ chrome: Chrome | None,
+ *,
+ expect_chrome: bool | None = None,
+) -> None:
+ """Restore both DOM overlays in one browser task to avoid a partial frame.
+
+ In the shell (main render window) the invariant is reworded: the site iframe
+ and the shell bar live in the shell document (the framed site can no longer
+ touch them), and the cursor is restored on the shell page. The pill URL is
+ deliberately *not* resynced here — that would flip it to the shell sentinel
+ URL; the pill is sourced from the site frame only on navigate steps.
+
+ ``expect_chrome`` defaults to ``chrome is not None``; pass ``False`` for a
+ bare (floating) popup so the chrome bar/API is not demanded or asserted while
+ the cursor is still ensured.
+ """
+
+ if expect_chrome is None:
+ expect_chrome = chrome is not None
+
+ if chrome is not None and _is_shell_page(page):
+ await chrome.ensure_shell(page)
+ await overlay.ensure(page)
+ return
+
+ # The common path checks controller readiness and mounts both layers in one
+ # browser task. A missing controller returns without painting; after Python
+ # reinjects it, the same task is rerun in strict mode against the current
+ # document. This retains page-replacement safety without paying two evaluate
+ # round-trips when the init scripts are already alive.
+ ensure_script = """async ([x, y, expectChrome, url, strict]) => {
+ const cursor = window.__guidebot_cursor;
+ const chrome = window.__guidebot_chrome;
+ const cursorReady = !!cursor && ["ensure", "moveTo"].every(
+ name => typeof cursor[name] === "function"
+ );
+ const chromeReady = !expectChrome || (
+ !!chrome && ["ensure", "setUrl"].every(
+ name => typeof chrome[name] === "function"
+ )
+ );
+ if (!cursorReady || !chromeReady) {
+ if (strict) {
+ if (!cursorReady) {
+ throw new Error("guidebot cursor API is unavailable after injection");
+ }
+ throw new Error("guidebot chrome API is unavailable after injection");
+ }
+ return {cursor: cursorReady, chrome: chromeReady, mounted: false};
+ }
+ if (expectChrome) {
+ chrome.ensure(url);
+ }
+ cursor.ensure();
+ await cursor.moveTo(x, y, 0);
+ return {cursor: true, chrome: true, mounted: true};
+ }"""
+ args = [overlay.pos[0], overlay.pos[1], expect_chrome, page.url, False]
+ try:
+ readiness = await page.evaluate(ensure_script, args)
+ except PlaywrightError as exc:
+ # A prior click can trigger a navigation/reload the recorder never asked
+ # for — e.g. a cookie banner whose accept runs `window.location.reload()`,
+ # or a link that leaves for an external login. The next step's visual
+ # mount then races the in-flight document swap and the context is
+ # destroyed. Wait for the replacement document and retry once, re-reading
+ # the URL for the fresh context. Mirrors the opener-settle retry in
+ # `_prepare_main_after_popup_close`. A closed page is a real failure.
+ if page.is_closed():
+ raise RenderError("okno zamknęło się w trakcie montażu warstw wizualnych") from exc
+ await page.wait_for_load_state()
+ args[3] = page.url
+ readiness = await page.evaluate(ensure_script, args)
+ if readiness.get("mounted"):
+ return
+ # Context init scripts normally make both APIs available. Repair a missing
+ # controller first; the retry still mounts both layers atomically.
+ if chrome is not None and expect_chrome and not readiness.get("chrome"):
+ await chrome.ensure(page)
+ if not readiness.get("cursor"):
+ await overlay.ensure(page)
+ # A repair can race a navigation/document replacement. Read the URL again
+ # for the strict mount instead of reusing the pre-repair snapshot.
+ args[3] = page.url
+ args[-1] = True
+ await page.evaluate(ensure_script, args)
+
+
+async def _prime_visuals(
+ page: Page,
+ overlay: overlay_module.Overlay,
+ chrome: Chrome | None,
+ *,
+ expect_chrome: bool | None = None,
+ timeout: float = _POPUP_DETECTION_SECONDS,
+) -> float | None:
+ """Mount visual layers from the page event, before its first useful frame.
+
+ Chromium can replace a freshly opened ``about:blank`` document without
+ rerunning init-script timers. Keep priming until the document root and both
+ layers stay stable for one quiescence window, then force a captured frame.
+
+ ``expect_chrome`` defaults to ``chrome is not None``; pass ``False`` for a
+ bare (floating) popup so the prime loop does not wait for a
+ ``[data-guidebot-chrome]`` bar that never mounts (the cursor is still
+ required).
+ """
+
+ if expect_chrome is None:
+ expect_chrome = chrome is not None
+
+ deadline = time.monotonic() + timeout
+ marker = f"{time.monotonic_ns()}-{id(page)}"
+ stable_since: float | None = None
+ status_script = """([token, expectChrome]) => {
+ const root = document.documentElement;
+ if (!root) return {ready: false};
+ const sameRoot = root.__guidebotVisualPrime === token;
+ root.__guidebotVisualPrime = token;
+ return {
+ ready: true,
+ sameRoot,
+ cursor: !!document.querySelector("[data-guidebot-cursor]"),
+ chrome: !expectChrome || !!document.querySelector("[data-guidebot-chrome]"),
+ };
+ }"""
+ while not page.is_closed():
+ try:
+ status = await page.evaluate(status_script, [marker, expect_chrome])
+ now = time.monotonic()
+ complete = (
+ isinstance(status, dict)
+ and status.get("ready") is True
+ and status.get("sameRoot") is True
+ and status.get("cursor") is True
+ and status.get("chrome") is True
+ )
+ if not complete:
+ await _ensure_visuals(page, overlay, chrome, expect_chrome=expect_chrome)
+ stable_since = now
+ elif stable_since is None:
+ stable_since = now
+ elif now - stable_since >= _POPUP_QUIESCENCE_SECONDS:
+ await page.screenshot()
+ final_status = await page.evaluate(status_script, [marker, expect_chrome])
+ if (
+ isinstance(final_status, dict)
+ and final_status.get("sameRoot") is True
+ and final_status.get("cursor") is True
+ and final_status.get("chrome") is True
+ ):
+ return time.monotonic()
+ stable_since = None
+ except PlaywrightError:
+ # A navigation may replace the execution context between the page
+ # event and injection. Retry only inside the bounded prime window.
+ if page.is_closed():
+ return None
+ stable_since = None
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise RenderError("nie udało się zainicjować warstw wizualnych nowej strony")
+ await asyncio.sleep(min(0.01, remaining))
+ return None
+
+
+async def _prepare_main_after_popup_close(
+ page: Page,
+ overlay: overlay_module.Overlay,
+ chrome: Chrome | None,
+ settle_ms: float,
+ restore_cursor_to: tuple[float, float] | None = None,
+) -> None:
+ """Let opener navigation settle before touching its execution context again.
+
+ This is the single funnel for "the popup is gone, the main window is active
+ again", so it is also where the cursor handed over to the popup by
+ :func:`_hand_cursor_to_popup` is handed back.
+
+ ``restore_cursor_to`` undoes that hand-over's side effect: centring the
+ cursor in the popup moved the *shared* :attr:`Overlay.pos`, so without this
+ the reappearing main-window cursor would jump to wherever the popup's centre
+ happened to be rather than the control it last used.
+ """
+
+ if restore_cursor_to is not None:
+ overlay.pos = restore_cursor_to
+ await page.bring_to_front()
+ await Recorder(page, None, settle_ms=settle_ms).apply_readiness("none")
+ try:
+ await page.wait_for_load_state()
+ await _ensure_visuals(page, overlay, chrome)
+ except PlaywrightError as exc:
+ if page.is_closed():
+ raise RenderError("główne okno zamknęło się po zamknięciu popupu") from exc
+ # A navigation can destroy the context between the load-state check and
+ # cursor restoration. Wait for the replacement document and retry once.
+ await page.wait_for_load_state()
+ await _ensure_visuals(page, overlay, chrome)
+ await overlay.show(page)
+
+
+async def _hand_cursor_to_popup(
+ main_page: Page, popup: _PopupSession, overlay: overlay_module.Overlay
+) -> None:
+ """Move the cursor into the popup, centred, for as long as it is on screen.
+
+ The cursor is a DOM element injected into every top-level document, so a
+ popup mounts its *own* instance — leaving two cursors alive at once. The
+ compositor keeps the main window's video visible around/behind the popup
+ (and fully visible for a floating popup), so the stale main-window cursor
+ would be recorded next to the live one. Only the active window paints a
+ cursor; :func:`_prepare_main_after_popup_close` reverses this.
+
+ ``hide`` is a per-page call on purpose — a context-wide init-script flag
+ (like ``barePopups``) cannot target one window.
+
+ The popup's own cursor is then parked at the centre of its viewport and
+ revealed. Without this it would inherit :attr:`Overlay.pos` — coordinates
+ from the *main* window, typically the opener control in a corner — and stay
+ invisible there until the first action moved it. ``ms=0`` because a glide
+ would be a slide in from wherever the other window's cursor happened to be,
+ which reads as motion that never occurred.
+ """
+
+ if main_page.is_closed():
+ return
+ try:
+ await overlay.hide(main_page)
+ except PlaywrightError:
+ # The opener can navigate/close under us while the popup is opening;
+ # the loop's own lifecycle checks report that authoritatively.
+ if not main_page.is_closed():
+ raise
+ # Remembered so closing the popup does not leave the main window's cursor
+ # parked at the popup's centre: ``Overlay.pos`` is shared across pages.
+ popup.main_cursor_pos = overlay.pos
+ viewport = popup.page.viewport_size
+ if viewport is None or popup.page.is_closed():
+ return
+ try:
+ await overlay.move_to(popup.page, viewport["width"] / 2, viewport["height"] / 2, ms=0)
+ await overlay.show(popup.page)
+ except PlaywrightError:
+ # A popup that dies while being furnished is the loop's business, not
+ # the cursor's; losing the centring costs a cosmetic, never the render.
+ if not popup.page.is_closed():
+ raise
diff --git a/guidebot_recorder/resolver/validate.py b/guidebot_recorder/resolver/validate.py
index 981ccb1..4890e77 100644
--- a/guidebot_recorder/resolver/validate.py
+++ b/guidebot_recorder/resolver/validate.py
@@ -428,8 +428,9 @@ async def reuse_is_valid(page: Page | Frame, cached: CachedAction) -> bool:
"""Validate a cached target and its independent, frozen identity.
Thin boolean wrapper around :func:`reuse_failure`, kept for its existing
- callers (``recorder/render.py`` and ``recorder/compile/step.py``), which only
- need a yes/no answer and must not observe any behavior change.
+ callers (``recorder/render/_run.py``, ``recorder/render/_step.py`` and
+ ``recorder/compile/step.py``), which only need a yes/no answer and must not
+ observe any behavior change.
"""
return await reuse_failure(page, cached) is None
diff --git a/guidebot_recorder/video/mux/crop.py b/guidebot_recorder/video/mux/crop.py
index 9b2746e..01df2b3 100644
--- a/guidebot_recorder/video/mux/crop.py
+++ b/guidebot_recorder/video/mux/crop.py
@@ -232,10 +232,10 @@ def detect_teardown_tail(
a headed render — the page content is unchanged, but it arrives smaller, so
Playwright's padding grows and the fixed crop — sized from the *stable* part
of the recording, see
- :func:`~guidebot_recorder.recorder.render._recording_scale` — starts exposing
- filler along its right and bottom edges. In a held-open composite that reads
- as the popup abruptly shrinking against a grey slab for the last second of
- the film.
+ :func:`~guidebot_recorder.recorder.render.popup_crop._recording_scale` — starts
+ exposing filler along its right and bottom edges. In a held-open composite
+ that reads as the popup abruptly shrinking against a grey slab for the last
+ second of the film.
Detection samples the 2x2 block just inside *crop*'s far corner across every
frame: while the window fills the crop that block is page content, and the
diff --git a/tests/_seam_guard.py b/tests/_seam_guard.py
index 0d56e13..132d199 100644
--- a/tests/_seam_guard.py
+++ b/tests/_seam_guard.py
@@ -39,6 +39,15 @@
guard hardcoding the first mode would flag dozens of *correct* patch sites and push
the tests into patching shared packages globally.
+``recorder.render`` also adds a third shape the first two packages never had: a
+seam one module *deeper* than the package. ``_publish_render_artifacts`` calls
+``os.replace(...)``, so the name resolved at call time is ``replace`` on the ``os``
+object in :mod:`...render.audio`'s globals, and the honest patch target is the
+dotted ``...render.audio.os.replace``. That is the same late-binding rule, one
+level out, so :meth:`SeamGuard._module_chain_offender` accepts it — but only when
+every step of the chain really resolves to a module reachable from the package, so
+an attribute snapshot cannot disguise itself as a module path.
+
**Liveness must be completeness, not non-emptiness.** ``assert SEAMS`` is
all-or-nothing: the moment one file adopts a recognised idiom the scan goes
non-empty and the other eighty sites are silently unscanned, leaving every
@@ -237,13 +246,42 @@ def assert_scan_complete(self) -> None:
assert not offenders, "\n".join(offenders)
assert self.seams, f"no monkeypatch targets found on {self.package} under {self.tests_root}"
+ def _module_chain_offender(self, name: str, owner: str, text: str) -> str | None:
+ """Judge a ``.[...]`` target, e.g. ``audio.os``.
+
+ A seam can live one module deeper than the package: ``_publish_render_artifacts``
+ calls ``os.replace(...)``, so the name the consumer resolves at call time is
+ ``replace`` on the ``os`` object held in :mod:`...render.audio`'s globals. That is
+ the same late-binding rule this guard exists for, one level further out, and the
+ patch is legitimate exactly when every step of the chain really is a module
+ reachable from the package — anything else is an attribute snapshot dressed up as
+ a module path.
+ """
+ head, _, tail = owner.partition(".")
+ if head not in self.submodules:
+ return f"{name!r} is patched on {text!r}, which is not a submodule of {self.package}"
+ target = getattr(self.module, head, None)
+ for step in tail.split("."):
+ target = getattr(target, step, None)
+ if not isinstance(target, ModuleType):
+ return (
+ f"{name!r} is patched on {text!r}, whose {step!r} is not a module; "
+ f"patch the submodule whose globals the consumer reads, so the patch "
+ f"and the call site land on the same module object"
+ )
+ if not hasattr(target, name):
+ return f"{name!r} is patched on {text!r}, which does not define it"
+ return None
+
def _target_offender(self, name: str, owner: str, text: str) -> str | None:
- if owner == FACADE or "." in owner:
+ if owner == FACADE:
return (
f"{name!r} is patched on {text!r}; patch it on the submodule whose globals "
f"the consumer reads, so the patch and the call site land on the same "
f"module object"
)
+ if "." in owner:
+ return self._module_chain_offender(name, owner, text)
if owner not in self.submodules:
return f"{name!r} is patched on {text!r}, which is not a submodule of {self.package}"
if not hasattr(getattr(self.module, owner, None), name):
diff --git a/tests/integration/test_shell_render.py b/tests/integration/test_shell_render.py
index 4d065ed..28cc316 100644
--- a/tests/integration/test_shell_render.py
+++ b/tests/integration/test_shell_render.py
@@ -27,7 +27,7 @@
async def _setup_shell(browser, chrome_config: ChromeConfig) -> tuple[Page, Frame, Chrome]:
- """Reproduce render.py's main-window setup and return (shell page, site frame)."""
+ """Reproduce ``render/_run.py``'s main-window setup; return (shell page, site frame)."""
context = await browser.new_context(
viewport=VIEWPORT,
@@ -37,9 +37,9 @@ async def _setup_shell(browser, chrome_config: ChromeConfig) -> tuple[Page, Fram
overlay = Overlay(CursorConfig())
slide = SlideOverlay()
chrome = Chrome(chrome_config)
- # Mirror render.py's init-script order: cursor (overlay) -> slide -> chrome,
+ # Mirror render/_run.py's init-script order: cursor (overlay) -> slide -> chrome,
# so slide.js's isTop guard reads the real window.top before chrome.js
- # shadows it (see render.py's cursor/slide-before-chrome contract comment).
+ # shadows it (see render/_run.py's cursor/slide-before-chrome contract comment).
await overlay.install_context(context)
await slide.install_context(context)
await chrome.install_context(context)
@@ -116,7 +116,7 @@ async def test_no_second_bar_or_cursor_inside_the_framed_site(browser) -> None:
# itself — real Chromium already makes chrome.js's window.top shadowing a
# no-op for cross-origin frames, so this passes regardless of init-script
# order; the order contract (cursor/overlay -> slide -> chrome, see
- # render.py) is separately covered by a registration-order spy test in
+ # render/_run.py) is separately covered by a registration-order spy test in
# tests/unit/recorder/test_render.py.
leaked = await site_frame.evaluate(
"""() => ({
diff --git a/tests/unit/recorder/test_render.py b/tests/unit/recorder/test_render.py
index 4f346a0..bda86ef 100644
--- a/tests/unit/recorder/test_render.py
+++ b/tests/unit/recorder/test_render.py
@@ -31,11 +31,8 @@
_POPUP_REQUEST_SCRIPT,
POPUP_BBOX_DEGENERATE_RATIO,
RenderError,
- _apply_timeline_edits,
- _assemble_audio_tracks,
_build_timeline,
_mux_tracks_for_timeline,
- _pace_narration,
_parse_content_box,
_parse_window_request,
_popup_content_box,
@@ -43,10 +40,18 @@
_popup_window_opened,
_popup_window_request,
_prime_visuals,
- _publish_render_artifacts,
_resolve_popup_crop,
run_render,
)
+
+# Withheld from the facade on purpose (they are test seams), so they are imported
+# from the submodule that defines them. See the render package docstring.
+from guidebot_recorder.recorder.render.audio import (
+ _assemble_audio_tracks,
+ _publish_render_artifacts,
+)
+from guidebot_recorder.recorder.render.narration import _pace_narration
+from guidebot_recorder.recorder.render.timeline import _apply_timeline_edits
from guidebot_recorder.resolver.reasoner import ReasonerResult
from guidebot_recorder.scenario.compiled import compiled_path, load_compiled, write_compiled
from guidebot_recorder.scenario.loader import ScenarioValidationError, load_scenario
@@ -397,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 render.py's context init-script ordering contract.
+ """Locks in the context init-script ordering contract of ``render/_run.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;
@@ -506,14 +511,17 @@ async def test_render_passes_the_configured_open_hold_to_the_recorder(tmp_path,
)
holds: list[float | None] = []
- original_recorder = render_module.Recorder
+ original_recorder = render_module._run.Recorder
class SpyRecorder(original_recorder): # type: ignore[misc, valid-type]
def __init__(self, *args, **kwargs):
holds.append(kwargs.get("open_hold_ms"))
super().__init__(*args, **kwargs)
- monkeypatch.setattr(render_module, "Recorder", SpyRecorder)
+ # `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.visuals, "Recorder", SpyRecorder)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -555,7 +563,7 @@ async def observe_narration_wait(segments: list[Segment], **kwargs) -> None:
await _pace_narration(segments, **kwargs)
monkeypatch.setattr(
- "guidebot_recorder.recorder.render._pace_narration",
+ "guidebot_recorder.recorder.render.narration._pace_narration",
observe_narration_wait,
)
@@ -788,7 +796,7 @@ def interrupt_at_master(source, destination):
raise KeyboardInterrupt
real_replace(source, destination)
- monkeypatch.setattr("guidebot_recorder.recorder.render.os.replace", interrupt_at_master)
+ monkeypatch.setattr("guidebot_recorder.recorder.render.audio.os.replace", interrupt_at_master)
with pytest.raises(KeyboardInterrupt):
_publish_render_artifacts(
@@ -833,8 +841,8 @@ def staged_mux(video, tracks, destination, *, preencoded=False, video_duration=N
raise RuntimeError("mux failed")
destination.write_bytes(b"new master")
- monkeypatch.setattr("guidebot_recorder.recorder.render.build_audio_bed", staged_bed)
- monkeypatch.setattr("guidebot_recorder.recorder.render.mux_audio_tracks", staged_mux)
+ monkeypatch.setattr("guidebot_recorder.recorder.render.audio.build_audio_bed", staged_bed)
+ monkeypatch.setattr("guidebot_recorder.recorder.render.audio.mux_audio_tracks", staged_mux)
with pytest.raises(RuntimeError, match="failed"):
await _assemble_audio_tracks(
@@ -1086,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.Recorder):
+ class EarlyWindowRecorder(R._run.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
@@ -1096,7 +1104,10 @@ async def click(self, target, *, before_click=None):
await early_window.value
await super().click(target, before_click=before_click)
- monkeypatch.setattr(R, "Recorder", EarlyWindowRecorder)
+ # `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.visuals, "Recorder", EarlyWindowRecorder)
with pytest.raises(RenderError, match="przed akcją click"):
await run_render(path, tmp_path / "out.mp4", FakeTts(), tmp_path / "cache", browser)
@@ -1142,7 +1153,7 @@ async def test_render_fails_when_popup_closes_during_narration(tmp_path, monkeyp
# every phase it can land in (during the open, during the visual mount,
# during the narration). The bet is what made this test flaky on CI; the
# phase this test is about is the one it now pins.
- real_pace_narration = R._pace_narration
+ real_pace_narration = R.narration._pace_narration
async def close_popup_during_narration(*args, **kwargs):
for context in browser.contexts:
@@ -1151,7 +1162,7 @@ async def close_popup_during_narration(*args, **kwargs):
await open_page.close()
return await real_pace_narration(*args, **kwargs)
- monkeypatch.setattr(R, "_pace_narration", close_popup_during_narration)
+ monkeypatch.setattr(R.narration, "_pace_narration", close_popup_during_narration)
with pytest.raises(RenderError, match="asynchronicznie podczas narracji"):
await run_render(path, tmp_path / "out.mp4", FakeTts(), tmp_path / "cache", browser)
@@ -1180,18 +1191,21 @@ async def test_render_wires_viewport_and_typing_animation(tmp_path, monkeypatch)
overlay_viewports: list = []
recorder_kwargs: list = []
- class SpyOverlay(R.Overlay):
+ class SpyOverlay(R._run.Overlay):
def __init__(self, cursor=None, viewport=None):
overlay_viewports.append(viewport)
super().__init__(cursor, viewport)
- class SpyRecorder(R.Recorder):
+ class SpyRecorder(R._run.Recorder):
def __init__(self, *a, **k):
recorder_kwargs.append(k)
super().__init__(*a, **k)
- monkeypatch.setattr(R, "Overlay", SpyOverlay)
- monkeypatch.setattr(R, "Recorder", SpyRecorder)
+ monkeypatch.setattr(R._run, "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.visuals, "Recorder", SpyRecorder)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1223,12 +1237,15 @@ async def test_render_respects_typing_animate_false(tmp_path, monkeypatch):
recorder_kwargs: list = []
- class SpyRecorder(R.Recorder):
+ class SpyRecorder(R._run.Recorder):
def __init__(self, *a, **k):
recorder_kwargs.append(k)
super().__init__(*a, **k)
- monkeypatch.setattr(R, "Recorder", SpyRecorder)
+ # `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.visuals, "Recorder", SpyRecorder)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1299,13 +1316,13 @@ async def test_render_with_sound_collects_and_mixes_sfx(tmp_path, monkeypatch):
)
recorded_events: list[list[tuple[str, float]]] = []
- original_build_sfx_bed = R.build_sfx_bed
+ original_build_sfx_bed = R.audio.build_sfx_bed
def spy_build_sfx_bed(events, *args, **kwargs):
recorded_events.append(list(events))
return original_build_sfx_bed(events, *args, **kwargs)
- monkeypatch.setattr(R, "build_sfx_bed", spy_build_sfx_bed)
+ monkeypatch.setattr(R.audio, "build_sfx_bed", spy_build_sfx_bed)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1330,13 +1347,13 @@ async def test_render_sound_off_builds_no_sfx_bed(tmp_path, monkeypatch):
await _compile_sound_scenario(path, " sound: {enabled: false}\n")
calls: list = []
- original_build_sfx_bed = R.build_sfx_bed
+ original_build_sfx_bed = R.audio.build_sfx_bed
def spy_build_sfx_bed(events, *args, **kwargs):
calls.append(events)
return original_build_sfx_bed(events, *args, **kwargs)
- monkeypatch.setattr(R, "build_sfx_bed", spy_build_sfx_bed)
+ monkeypatch.setattr(R.audio, "build_sfx_bed", spy_build_sfx_bed)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1357,13 +1374,13 @@ async def test_render_sound_gates_keys_when_disabled(tmp_path, monkeypatch):
)
recorded_events: list[list[tuple[str, float]]] = []
- original_build_sfx_bed = R.build_sfx_bed
+ original_build_sfx_bed = R.audio.build_sfx_bed
def spy_build_sfx_bed(events, *args, **kwargs):
recorded_events.append(list(events))
return original_build_sfx_bed(events, *args, **kwargs)
- monkeypatch.setattr(R, "build_sfx_bed", spy_build_sfx_bed)
+ monkeypatch.setattr(R.audio, "build_sfx_bed", spy_build_sfx_bed)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1401,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.SlideOverlay):
+ class SpySlide(R._run.SlideOverlay):
async def show(self, page, card):
await super().show(page, card)
slide_events.append(("show", dict(card)))
@@ -1419,7 +1436,7 @@ async def ensure(self, page, card):
)
)
- monkeypatch.setattr(R, "SlideOverlay", SpySlide)
+ monkeypatch.setattr(R._run, "SlideOverlay", SpySlide)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1470,28 +1487,31 @@ 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.SlideOverlay):
+ class SpySlide(R._run.SlideOverlay):
async def hide(self, page):
nonlocal slide_hide_calls
slide_hide_calls += 1
await super().hide(page)
- class SpyOverlay(R.Overlay):
+ class SpyOverlay(R._run.Overlay):
async def show(self, page):
nonlocal overlay_show_calls
overlay_show_calls += 1
await super().show(page)
- class SpyRecorder(R.Recorder):
+ class SpyRecorder(R._run.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, "SlideOverlay", SpySlide)
- monkeypatch.setattr(R, "Overlay", SpyOverlay)
- monkeypatch.setattr(R, "Recorder", SpyRecorder)
+ monkeypatch.setattr(R._run, "SlideOverlay", SpySlide)
+ monkeypatch.setattr(R._run, "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.visuals, "Recorder", SpyRecorder)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1540,21 +1560,21 @@ 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.SlideOverlay):
+ class MidWaitDestroySlide(R._run.SlideOverlay):
async def token(self, page):
if destroyed["value"]:
return 0
return await super().token(page)
- monkeypatch.setattr(R, "SlideOverlay", MidWaitDestroySlide)
+ monkeypatch.setattr(R._run, "SlideOverlay", MidWaitDestroySlide)
- original_wait = R._pace_narration
+ original_wait = R.narration._pace_narration
async def destroy_during_wait(segments, **kwargs):
await original_wait(segments, **kwargs)
destroyed["value"] = True # a navigation replaced the document mid-say
- monkeypatch.setattr(R, "_pace_narration", destroy_during_wait)
+ monkeypatch.setattr(R.narration, "_pace_narration", destroy_during_wait)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1603,7 +1623,7 @@ async def test_slide_after_card_destroyed_during_say_fails_loud(tmp_path, monkey
destroyed = {"value": False}
- class GhostNavSlide(R.SlideOverlay):
+ class GhostNavSlide(R._run.SlideOverlay):
async def show(self, page, card):
await super().show(page, card)
destroyed["value"] = False # a repaint restores a truthy token
@@ -1613,15 +1633,15 @@ async def token(self, page):
return 0
return await super().token(page)
- monkeypatch.setattr(R, "SlideOverlay", GhostNavSlide)
+ monkeypatch.setattr(R._run, "SlideOverlay", GhostNavSlide)
- original_wait = R._pace_narration
+ original_wait = R.narration._pace_narration
async def destroy_during_wait(segments, **kwargs):
await original_wait(segments, **kwargs)
destroyed["value"] = True # a navigation replaced the document mid-say
- monkeypatch.setattr(R, "_pace_narration", destroy_during_wait)
+ monkeypatch.setattr(R.narration, "_pace_narration", destroy_during_wait)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1670,7 +1690,7 @@ async def test_slide_dismiss_fails_loud_when_card_destroyed_after_say(tmp_path,
slide_ref: dict = {}
- class GhostNavSlide(R.SlideOverlay):
+ class GhostNavSlide(R._run.SlideOverlay):
def __init__(self, *a, **k):
super().__init__(*a, **k)
self._ghost = False
@@ -1688,9 +1708,9 @@ async def token(self, page):
return 0
return await super().token(page)
- monkeypatch.setattr(R, "SlideOverlay", GhostNavSlide)
+ monkeypatch.setattr(R._run, "SlideOverlay", GhostNavSlide)
- original_render_step = R._render_step
+ original_render_step = R._step._render_step
async def render_step_spy(*args, **kwargs):
# args[6] is `kind` (see _render_step's signature). A navigation lands
@@ -1701,7 +1721,7 @@ async def render_step_spy(*args, **kwargs):
slide_ref["slide"]._ghost = True
return result
- monkeypatch.setattr(R, "_render_step", render_step_spy)
+ monkeypatch.setattr(R._step, "_render_step", render_step_spy)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1740,12 +1760,12 @@ async def test_intro_enabled_replaces_bootstrap(tmp_path, monkeypatch):
show_calls: list[dict] = []
- class SpySlide(R.SlideOverlay):
+ class SpySlide(R._run.SlideOverlay):
async def show(self, page, card):
show_calls.append(dict(card))
await super().show(page, card)
- monkeypatch.setattr(R, "SlideOverlay", SpySlide)
+ monkeypatch.setattr(R._run, "SlideOverlay", SpySlide)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1781,12 +1801,12 @@ async def test_intro_disabled_bootstrap_unchanged(tmp_path, monkeypatch):
show_calls: list[dict] = []
- class SpySlide(R.SlideOverlay):
+ class SpySlide(R._run.SlideOverlay):
async def show(self, page, card):
show_calls.append(dict(card))
await super().show(page, card)
- monkeypatch.setattr(R, "SlideOverlay", SpySlide)
+ monkeypatch.setattr(R._run, "SlideOverlay", SpySlide)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -1859,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.Overlay):
+ class SpyOverlay(R._run.Overlay):
async def hide(self, page):
events.append(("hide", _role(page)))
await super().hide(page)
@@ -1868,7 +1888,7 @@ async def show(self, page):
events.append(("show", _role(page)))
await super().show(page)
- monkeypatch.setattr(R, "Overlay", SpyOverlay)
+ monkeypatch.setattr(R._run, "Overlay", SpyOverlay)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -2042,13 +2062,13 @@ async def test_run_render_hold_frame_overrides_reach_the_pacing_decision(tmp_pat
)
pacing_kwargs: list[dict] = []
- original = R._pace_narration
+ original = R.narration._pace_narration
async def spy(segments, **kwargs):
pacing_kwargs.append(kwargs)
await original(segments, **kwargs)
- monkeypatch.setattr(R, "_pace_narration", spy)
+ monkeypatch.setattr(R.narration, "_pace_narration", spy)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -2093,13 +2113,13 @@ async def test_run_render_uses_the_scenario_value_when_no_override_is_given(tmp_
)
pacing_kwargs: list[dict] = []
- original = R._pace_narration
+ original = R.narration._pace_narration
async def spy(segments, **kwargs):
pacing_kwargs.append(kwargs)
await original(segments, **kwargs)
- monkeypatch.setattr(R, "_pace_narration", spy)
+ monkeypatch.setattr(R.narration, "_pace_narration", spy)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
@@ -2149,13 +2169,13 @@ async def test_hold_frame_film_matches_the_model_exactly(tmp_path, monkeypatch):
)
seen: list[Timeline] = []
- original = R._apply_timeline_edits
+ original = R.timeline._apply_timeline_edits
def spy(source, timeline, dest):
seen.append(timeline)
return original(source, timeline, dest)
- monkeypatch.setattr(R, "_apply_timeline_edits", spy)
+ monkeypatch.setattr(R.timeline, "_apply_timeline_edits", spy)
out = tmp_path / "out.mp4"
async with async_playwright() as pw:
@@ -2286,13 +2306,13 @@ async def test_smallest_legal_settle_still_renders(tmp_path, monkeypatch):
)
seen: list[Timeline] = []
- original = R._apply_timeline_edits
+ original = R.timeline._apply_timeline_edits
def spy(source, timeline, dest):
seen.append(timeline)
return original(source, timeline, dest)
- monkeypatch.setattr(R, "_apply_timeline_edits", spy)
+ monkeypatch.setattr(R.timeline, "_apply_timeline_edits", spy)
out = tmp_path / "out.mp4"
async with async_playwright() as pw:
@@ -2365,13 +2385,13 @@ async def test_hold_frame_narrations_never_overlap(tmp_path, monkeypatch):
# The offsets actually handed to the audio bed, captured at the boundary
# where the frame axis becomes seconds.
captured: list[list[Placed]] = []
- original = R._assemble_audio_tracks
+ original = R.audio._assemble_audio_tracks
async def spy(video, configs, placed_by_language, total, *args, **kwargs):
captured.extend(placed_by_language.values())
return await original(video, configs, placed_by_language, total, *args, **kwargs)
- monkeypatch.setattr(R, "_assemble_audio_tracks", spy)
+ monkeypatch.setattr(R.audio, "_assemble_audio_tracks", spy)
out = tmp_path / "out.mp4"
async with async_playwright() as pw:
@@ -2398,7 +2418,7 @@ 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.py, the `on_sfx` closure passed to
+ 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
could clamp narration and NOT sound effects and the whole suite would
@@ -2449,22 +2469,22 @@ async def test_sfx_after_a_freeze_never_lands_inside_the_hold(tmp_path, monkeypa
)
narrations: list[list[Placed]] = []
- original_assemble = R._assemble_audio_tracks
+ original_assemble = R.audio._assemble_audio_tracks
async def spy_assemble(video, configs, placed_by_language, total, *args, **kwargs):
narrations.extend(placed_by_language.values())
return await original_assemble(video, configs, placed_by_language, total, *args, **kwargs)
- monkeypatch.setattr(R, "_assemble_audio_tracks", spy_assemble)
+ monkeypatch.setattr(R.audio, "_assemble_audio_tracks", spy_assemble)
sfx_events: list[list[tuple[str, float]]] = []
- original_build_sfx_bed = R.build_sfx_bed
+ original_build_sfx_bed = R.audio.build_sfx_bed
def spy_build_sfx_bed(events, *args, **kwargs):
sfx_events.append(list(events))
return original_build_sfx_bed(events, *args, **kwargs)
- monkeypatch.setattr(R, "build_sfx_bed", spy_build_sfx_bed)
+ monkeypatch.setattr(R.audio, "build_sfx_bed", spy_build_sfx_bed)
out = tmp_path / "out.mp4"
async with async_playwright() as pw:
@@ -2567,8 +2587,11 @@ def test_apply_timeline_edits_rejects_a_file_that_disagrees_with_the_model(
import guidebot_recorder.recorder.render as R
timeline = Timeline.build([TimeEdit(at=10, kind="freeze", frames=25)], source_frames=100)
- monkeypatch.setattr(R, "apply_time_edits", lambda src, tl, out: None)
- monkeypatch.setattr(R, "probe_frame_count", lambda path: 123)
+ 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
+ # 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)
with pytest.raises(RenderError) as excinfo:
_apply_timeline_edits(tmp_path / "src.mp4", timeline, tmp_path / "out.mp4")
@@ -2645,13 +2668,13 @@ async def test_popup_window_request_returns_as_soon_as_one_frame_answers():
assert hanging.entered.is_set(), "test did not exercise the hanging frame"
assert elapsed < 0.5, (
f"lookup waited for the silent frame: took {elapsed:.2f}s "
- f"(budget is {render_module._POPUP_REQUEST_LOOKUP_TIMEOUT:g}s)"
+ f"(budget is {render_module.popup_detect._POPUP_REQUEST_LOOKUP_TIMEOUT:g}s)"
)
async def test_popup_window_request_survives_a_frame_that_raises(monkeypatch):
"""One exploding probe must not abort the scan — other frames may answer."""
- monkeypatch.setattr(render_module, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
+ monkeypatch.setattr(render_module.popup_detect, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
opener = _StubOpener(
[_StubFrame({"width": 420, "height": 300}), _RaisingFrame()],
main_frame=_RaisingFrame(),
@@ -2672,7 +2695,7 @@ async def test_popup_window_request_takes_the_top_document_when_several_answer()
async def test_popup_window_request_answers_despite_a_frame_that_never_answers(monkeypatch):
"""A dead ad iframe must neither hang the lookup nor hide the real answer."""
- monkeypatch.setattr(render_module, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
+ monkeypatch.setattr(render_module.popup_detect, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
hanging = _HangingFrame()
# Reverse order puts the hanging frame ahead of the one holding the answer,
# so a sequential scan would never reach the answer at all.
@@ -2687,7 +2710,7 @@ async def test_popup_window_request_answers_despite_a_frame_that_never_answers(m
async def test_popup_window_request_gives_up_when_only_dead_frames_remain(monkeypatch, capsys):
- monkeypatch.setattr(render_module, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
+ monkeypatch.setattr(render_module.popup_detect, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
opener = _StubOpener([_HangingFrame()], main_frame=_HangingFrame())
started = time.monotonic()
@@ -2700,7 +2723,7 @@ async def test_popup_window_request_gives_up_when_only_dead_frames_remain(monkey
async def test_popup_window_request_abandons_no_orphan_task(monkeypatch):
"""An abandoned probe must not leave an un-awaited exception behind."""
- monkeypatch.setattr(render_module, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
+ monkeypatch.setattr(render_module.popup_detect, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
opener = _StubOpener([_HangingFrame()], main_frame=_HangingFrame())
assert await _popup_window_request(opener) is None
@@ -2722,7 +2745,7 @@ async def test_popup_window_request_abandons_no_orphan_task(monkeypatch):
async def test_popup_window_opened_answers_despite_a_frame_that_never_answers(monkeypatch):
"""A dead ad iframe must neither hang the lookup nor hide the real answer."""
- monkeypatch.setattr(render_module, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
+ monkeypatch.setattr(render_module.popup_detect, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
hanging = _HangingFrame()
# Reverse order puts the hanging frame ahead of the one holding the answer,
# so a sequential scan would never reach the answer at all.
@@ -2737,7 +2760,7 @@ async def test_popup_window_opened_answers_despite_a_frame_that_never_answers(mo
async def test_popup_window_opened_gives_up_when_only_dead_frames_remain(monkeypatch, capsys):
- monkeypatch.setattr(render_module, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
+ monkeypatch.setattr(render_module.popup_detect, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
opener = _StubOpener([_HangingFrame()], main_frame=_HangingFrame())
started = time.monotonic()
@@ -2752,7 +2775,7 @@ async def test_popup_window_opened_gives_up_when_only_dead_frames_remain(monkeyp
async def test_popup_window_opened_abandons_no_orphan_task(monkeypatch):
"""An abandoned probe must not leave an un-awaited exception behind."""
- monkeypatch.setattr(render_module, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
+ monkeypatch.setattr(render_module.popup_detect, "_POPUP_REQUEST_LOOKUP_TIMEOUT", 0.3)
opener = _StubOpener([_HangingFrame()], main_frame=_HangingFrame())
assert await _popup_window_opened(opener) is True
@@ -2944,7 +2967,7 @@ def _popup_with_probe(page):
async def test_settle_popup_content_box_gives_up_on_a_page_that_never_answers(monkeypatch, capsys):
- monkeypatch.setattr(render_module, "_POPUP_CONTENT_BOX_TIMEOUT", 0.3)
+ monkeypatch.setattr(render_module.popup_crop, "_POPUP_CONTENT_BOX_TIMEOUT", 0.3)
page = _HangingPage()
popup = _popup_with_probe(page)
@@ -2959,7 +2982,7 @@ async def test_settle_popup_content_box_gives_up_on_a_page_that_never_answers(mo
async def test_settle_popup_content_box_abandons_no_orphan_task(monkeypatch):
- monkeypatch.setattr(render_module, "_POPUP_CONTENT_BOX_TIMEOUT", 0.3)
+ monkeypatch.setattr(render_module.popup_crop, "_POPUP_CONTENT_BOX_TIMEOUT", 0.3)
popup = _popup_with_probe(_SlowPage())
await render_module._settle_popup_content_box(popup)
@@ -3052,7 +3075,7 @@ def fake_detect(path):
seen.append(Path(path))
return (300, 220, 2, 2)
- monkeypatch.setattr(render_module, "detect_content_crop", fake_detect)
+ monkeypatch.setattr(render_module.popup_crop, "detect_content_crop", fake_detect)
crop, level = _resolve_popup_crop(
window_size=None,
@@ -3068,7 +3091,7 @@ def fake_detect(path):
def test_resolve_popup_crop_without_any_geometry_emits_no_crop(tmp_path, monkeypatch, capsys):
- monkeypatch.setattr(render_module, "detect_content_crop", lambda path: None)
+ monkeypatch.setattr(render_module.popup_crop, "detect_content_crop", lambda path: None)
crop, level = _resolve_popup_crop(
window_size=None,
@@ -3087,7 +3110,7 @@ def test_resolve_popup_crop_does_not_run_cropdetect_when_a_level_answered(tmp_pa
def forbidden(path): # pragma: no cover - failure path
raise AssertionError("cropdetect must stay the last resort")
- monkeypatch.setattr(render_module, "detect_content_crop", forbidden)
+ monkeypatch.setattr(render_module.popup_crop, "detect_content_crop", forbidden)
assert (
_resolve_popup_crop(
@@ -3107,7 +3130,7 @@ def test_resolve_popup_crop_scales_the_window_open_rect_into_recording_pixels(
# backing scale, so Playwright fits a 500x670 window into the 1376x800 canvas
# at 1.19x instead of 1:1 — the CSS rect would cut 96px off the right and
# 130px off the bottom.
- monkeypatch.setattr(render_module, "detect_content_crop", lambda path: (596, 800, 0, 0))
+ monkeypatch.setattr(render_module.popup_crop, "detect_content_crop", lambda path: (596, 800, 0, 0))
crop, level = _resolve_popup_crop(
window_size=(500, 670),
@@ -3124,7 +3147,7 @@ def test_resolve_popup_crop_scales_the_window_open_rect_into_recording_pixels(
def test_resolve_popup_crop_scales_the_content_box_into_recording_pixels(tmp_path, monkeypatch):
- monkeypatch.setattr(render_module, "detect_content_crop", lambda path: (1000, 800, 0, 0))
+ monkeypatch.setattr(render_module.popup_crop, "detect_content_crop", lambda path: (1000, 800, 0, 0))
crop, level = _resolve_popup_crop(
window_size=None,
@@ -3143,7 +3166,7 @@ def test_resolve_popup_crop_scales_the_content_box_into_recording_pixels(tmp_pat
def test_resolve_popup_crop_keeps_css_pixels_when_the_recording_is_unscaled(tmp_path, monkeypatch):
# Headless: the popup records 1:1, so ``detect_content_crop`` finds the whole
# window already trimmed and declines. The CSS rect is then already correct.
- monkeypatch.setattr(render_module, "detect_content_crop", lambda path: None)
+ monkeypatch.setattr(render_module.popup_crop, "detect_content_crop", lambda path: None)
crop, level = _resolve_popup_crop(
window_size=(500, 670),
@@ -3169,7 +3192,7 @@ def test_resolve_popup_crop_keeps_css_pixels_when_the_recording_is_unscaled(tmp_
def test_resolve_popup_crop_refuses_a_measurement_that_contradicts_the_viewport(
tmp_path, monkeypatch, measured
):
- monkeypatch.setattr(render_module, "detect_content_crop", lambda path: measured)
+ monkeypatch.setattr(render_module.popup_crop, "detect_content_crop", lambda path: measured)
crop, level = _resolve_popup_crop(
window_size=(500, 670),
@@ -3190,7 +3213,7 @@ def test_resolve_popup_crop_skips_the_scale_probe_without_a_viewport(tmp_path, m
def forbidden(path): # pragma: no cover - failure path
raise AssertionError("nothing to correct against, so nothing to measure")
- monkeypatch.setattr(render_module, "detect_content_crop", forbidden)
+ monkeypatch.setattr(render_module.popup_crop, "detect_content_crop", forbidden)
crop, _level = _resolve_popup_crop(
window_size=(500, 670),
@@ -3360,13 +3383,13 @@ async def test_close_window_hands_the_cursor_back_to_its_pre_popup_position(tmp_
import guidebot_recorder.recorder.render as R
restored: list[tuple[float, float] | None] = []
- original = R._prepare_main_after_popup_close
+ original = R.visuals._prepare_main_after_popup_close
async def spy(page, overlay, chrome, settle_ms, restore_cursor_to=None):
restored.append(restore_cursor_to)
await original(page, overlay, chrome, settle_ms, restore_cursor_to=restore_cursor_to)
- monkeypatch.setattr(R, "_prepare_main_after_popup_close", spy)
+ monkeypatch.setattr(R.visuals, "_prepare_main_after_popup_close", spy)
path = _write_close_window_scenario(tmp_path)
@@ -3419,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.compose_popup_video
+ original = R._run.compose_popup_video
def spy(*args, **kwargs):
seen.append(kwargs.get("transition"))
return original(*args, **kwargs)
- monkeypatch.setattr(R, "compose_popup_video", spy)
+ monkeypatch.setattr(R._run, "compose_popup_video", spy)
path = _write_close_window_scenario(tmp_path, popup_config=False)
@@ -3487,8 +3510,8 @@ 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.compose_popup_video
- original_edit = R._apply_timeline_edits
+ original_compose = R._run.compose_popup_video
+ original_edit = R.timeline._apply_timeline_edits
def spy_compose(main, popup, dest, *args, **kwargs):
order.append("compose")
@@ -3500,8 +3523,8 @@ def spy_edit(source, timeline, dest):
edited.append((Path(source), Path(dest)))
return original_edit(source, timeline, dest)
- monkeypatch.setattr(R, "compose_popup_video", spy_compose)
- monkeypatch.setattr(R, "_apply_timeline_edits", spy_edit)
+ monkeypatch.setattr(R._run, "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_chrome.py b/tests/unit/recorder/test_render_chrome.py
index 6787af9..a14e006 100644
--- a/tests/unit/recorder/test_render_chrome.py
+++ b/tests/unit/recorder/test_render_chrome.py
@@ -4,7 +4,11 @@
from guidebot_recorder.models.config import ChromeConfig, Config, TtsConfig, Viewport
from guidebot_recorder.models.scenario import Scenario, Step
-from guidebot_recorder.recorder.render import _ensure_visuals, _expect_chrome, _render_step
+from guidebot_recorder.recorder.render import _ensure_visuals, _expect_chrome
+
+# `_render_step` is a test seam, so the facade withholds it: import the module
+# that defines it. See the render package docstring.
+from guidebot_recorder.recorder.render._step import _render_step
def test_expect_chrome_tracks_the_legacy_bar() -> None:
diff --git a/tests/unit/recorder/test_render_highlight_dispatch.py b/tests/unit/recorder/test_render_highlight_dispatch.py
index c568f52..c72eaad 100644
--- a/tests/unit/recorder/test_render_highlight_dispatch.py
+++ b/tests/unit/recorder/test_render_highlight_dispatch.py
@@ -13,7 +13,11 @@
from guidebot_recorder.models.config import Config, HighlightConfig, TtsConfig, Viewport
from guidebot_recorder.models.scenario import ResolvedHighlight, Scenario, Step
from guidebot_recorder.models.target import RoleTarget
-from guidebot_recorder.recorder.render import RenderError, _render_step
+from guidebot_recorder.recorder.render import RenderError
+
+# `_render_step` is a test seam, so the facade withholds it: import the module
+# that defines it. See the render package docstring.
+from guidebot_recorder.recorder.render._step import _render_step
from guidebot_recorder.resolver.resolution import ResolvedTarget
diff --git a/tests/unit/recorder/test_render_optional.py b/tests/unit/recorder/test_render_optional.py
index 0f33460..7ae1ea1 100644
--- a/tests/unit/recorder/test_render_optional.py
+++ b/tests/unit/recorder/test_render_optional.py
@@ -159,7 +159,7 @@ async def observe(segments: list[Segment], **kwargs) -> int | None:
# steps narrate, not how long they hold), so no freeze is emitted.
return None
- monkeypatch.setattr("guidebot_recorder.recorder.render._pace_narration", observe)
+ monkeypatch.setattr("guidebot_recorder.recorder.render.narration._pace_narration", observe)
return waited
@@ -226,14 +226,14 @@ async def test_skipped_branch_drops_its_narration_from_the_audio_bed(
):
import guidebot_recorder.recorder.render as render_module
- original = render_module.build_audio_bed
+ original = render_module.audio.build_audio_bed
placed: list[int] = []
def spy(placements, total, out):
placed.append(len(placements))
return original(placements, total, out)
- monkeypatch.setattr(render_module, "build_audio_bed", spy)
+ monkeypatch.setattr(render_module.audio, "build_audio_bed", spy)
path = tmp_path / "gate.scenario.yaml"
path.write_text(_branch_scenario(PLAIN_PAGE), encoding="utf-8")
@@ -483,13 +483,13 @@ async def test_hold_frame_narrations_inside_taken_branch_never_overlap(
# where the frame axis becomes seconds — same capture point as
# `test_hold_frame_narrations_never_overlap`.
captured: list[list[Placed]] = []
- original = R._assemble_audio_tracks
+ original = R.audio._assemble_audio_tracks
async def spy(video, configs, placed_by_language, total, *args, **kwargs):
captured.extend(placed_by_language.values())
return await original(video, configs, placed_by_language, total, *args, **kwargs)
- monkeypatch.setattr(R, "_assemble_audio_tracks", spy)
+ monkeypatch.setattr(R.audio, "_assemble_audio_tracks", spy)
out = tmp_path / "out.mp4"
await run_render(path, out, TwoSecondTts(), tmp_path / "cache", browser)
diff --git a/tests/unit/recorder/test_render_performance.py b/tests/unit/recorder/test_render_performance.py
index a0dc63b..1a8e012 100644
--- a/tests/unit/recorder/test_render_performance.py
+++ b/tests/unit/recorder/test_render_performance.py
@@ -86,8 +86,8 @@ def fake_build_audio_bed(_placed, _total, out: Path) -> None:
with lock:
active -= 1
- monkeypatch.setattr(render_module, "_AUDIO_BED_CONCURRENCY", 2)
- monkeypatch.setattr(render_module, "build_audio_bed", fake_build_audio_bed)
+ monkeypatch.setattr(render_module.audio, "_AUDIO_BED_CONCURRENCY", 2)
+ monkeypatch.setattr(render_module.audio, "build_audio_bed", fake_build_audio_bed)
tracks = await render_module._mux_tracks_for_timeline(
configs,
@@ -121,14 +121,14 @@ def blocking_build(_placed, _total, out: Path) -> None:
out.write_bytes(b"bed")
finished.set()
- monkeypatch.setattr(render_module, "build_audio_bed", blocking_build)
- monkeypatch.setattr(render_module, "mux_audio_tracks", lambda *args, **kwargs: None)
- monkeypatch.setattr(render_module, "_publish_render_artifacts", lambda *args: None)
+ monkeypatch.setattr(render_module.audio, "build_audio_bed", blocking_build)
+ monkeypatch.setattr(render_module.audio, "mux_audio_tracks", lambda *args, **kwargs: None)
+ monkeypatch.setattr(render_module.audio, "_publish_render_artifacts", lambda *args: None)
work = tmp_path / "work"
work.mkdir()
task = asyncio.create_task(
- render_module._assemble_audio_tracks(
+ render_module.audio._assemble_audio_tracks(
tmp_path / "video.webm",
[tts],
{tts.lang: []},
diff --git a/tests/unit/recorder/test_render_seams.py b/tests/unit/recorder/test_render_seams.py
new file mode 100644
index 0000000..8e6303e
--- /dev/null
+++ b/tests/unit/recorder/test_render_seams.py
@@ -0,0 +1,106 @@
+"""Guard: every ``recorder.render`` test seam must still reach the consumer it patches.
+
+Splitting ``render.py`` (3016 lines) into a package moved every consumer into a
+module of its own. Sixty-four patch sites across four test files aimed at one
+module object; after the split each has to name the submodule whose globals its
+consumer reads at call time, and a patch that names the wrong one rebinds a name
+nobody reads any more.
+
+The invariant, the discovery scan and every check live in :mod:`tests._seam_guard`,
+which this guard shares with ``tests/unit/video/test_mux_seams.py`` and
+``tests/unit/recorder/test_compile_seams.py``. Read that module first; this file
+only says what is specific to ``recorder.render``.
+
+**What is specific to this package: it is the only one with both modes at once.**
+Nine of the nineteen patched names are defined *inside* the package
+(``_render_step``, ``_pace_narration``, ``_apply_timeline_edits``,
+``_assemble_audio_tracks``, ``_publish_render_artifacts``,
+``_prepare_main_after_popup_close``, ``_AUDIO_BED_CONCURRENCY``,
+``_POPUP_CONTENT_BOX_TIMEOUT``, ``_POPUP_REQUEST_LOOKUP_TIMEOUT``) — those are
+called through the module object, patched on the module that *defines* them, and
+may not be name-imported anywhere. The other ten come from outside
+(``Overlay``, ``Recorder``, ``SlideOverlay``, ``compose_popup_video``,
+``detect_content_crop``, ``mux_audio_tracks``, ``build_audio_bed``,
+``build_sfx_bed``, ``apply_time_edits``, ``probe_frame_count``) — those keep
+``from X import name`` in the consuming submodule, which is where the patch has to
+land, and there the name-import **is** the seam. ``video.mux`` has only the first
+mode and ``recorder.compile`` only the second; mixing them up misses silently,
+which is why the shared helper keys every decision on the definition site.
+
+**Two names have two consumers, and one line no longer covers them.**
+``Recorder`` is constructed in ``_run`` (the render loop) *and* in ``visuals``
+(the post-popup-close funnel); ``probe_frame_count`` is read in ``timeline`` (the
+model-versus-file check) *and* in ``_run`` (sizing the timeline). A test that
+patches one of the two leaves the other path unintercepted, still green, and
+checking nothing on that half —
+:meth:`~tests._seam_guard.SeamGuard.assert_multi_consumer_coverage` is what turns
+that from prose into a failure.
+
+**One seam is a module deeper than the package.**
+``_publish_render_artifacts`` commits the master MP4 with ``os.replace``, and the
+rollback test replaces it through ``guidebot_recorder.recorder.render.audio.os.replace``
+— ``audio``'s own ``os`` global, because ``audio`` is the module that performs the
+rename. That is the same late-binding rule one level out, and the shared helper
+accepts it only when every step of the chain really resolves to a module.
+
+Why this matters more here than anywhere else: several of these seams sit on
+paths whose assertions survive a dead patch. Seven tests shrink
+``_POPUP_REQUEST_LOOKUP_TIMEOUT`` to fractions of a second and then assert an
+*upper* bound of several seconds — a patch that reaches nobody makes them slow,
+not red. Two more replace ``detect_content_crop`` with a function that fails the
+test if called, which a dead patch turns into an assertion about nothing.
+
+**The seam list is discovered, not written down here.** It is read out of
+``tests/`` by finding what is actually patched on the render package, so a seam
+added tomorrow is covered the day it appears — a hardcoded list would rot exactly
+when it starts mattering. Needs no browser and no ffmpeg: this only parses source
+and reads the package's runtime attributes.
+"""
+
+from __future__ import annotations
+
+from tests._seam_guard import SeamGuard
+
+PACKAGE_NAME = "guidebot_recorder.recorder.render"
+
+GUARD = SeamGuard.build(PACKAGE_NAME)
+
+
+def test_the_scan_is_complete() -> None:
+ # Non-emptiness is not liveness. One recognised idiom in one file would make
+ # `assert SEAMS` pass while the other sixty sites went unscanned, and every
+ # assertion below would be vacuously true — the failure this guard exists to
+ # catch. So an unclassifiable target that mentions the package is an error.
+ GUARD.assert_scan_complete()
+
+
+def test_every_patch_targets_a_submodule_not_the_facade() -> None:
+ # Patching the facade cannot work: the consumer lives in a submodule and reads
+ # that submodule's globals. Which submodule that is depends on where the name
+ # is defined, and this package has both cases — see the module docstring.
+ GUARD.assert_patch_targets()
+
+
+def test_facade_withholds_every_patched_name() -> None:
+ # Re-exporting a seam would let a patch on the facade succeed and reach
+ # nobody. Withholding turns the same mistake into an AttributeError — or, for
+ # the five names `test_render.py` used to import from the facade, into an
+ # ImportError at collection time, which is louder still.
+ GUARD.assert_facade_withholds()
+
+
+def test_no_seam_is_bound_at_import_time() -> None:
+ # Three ways to snapshot a seam's *value* into the wrong module's globals, all
+ # equally fatal and all invisible to the patch the tests actually write:
+ # `from X import seam`, `alias = X.seam`, and `def f(..., _seam=X.seam)`.
+ # This is also what keeps `Overlay` and `Recorder` out of the submodules that
+ # only need them for a type annotation — they are annotated through the module
+ # object precisely so no unreachable copy exists.
+ GUARD.assert_no_early_binding()
+
+
+def test_multi_consumer_seams_are_patched_on_every_consumer() -> None:
+ # `Recorder` (`_run` + `visuals`) and `probe_frame_count` (`timeline` + `_run`)
+ # each have two consumers after the split. One patch line covers one copy; the
+ # other interception path would disappear with no test turning red.
+ GUARD.assert_multi_consumer_coverage()
diff --git a/tests/unit/recorder/test_render_shell.py b/tests/unit/recorder/test_render_shell.py
index 0e6c7e0..fabae1b 100644
--- a/tests/unit/recorder/test_render_shell.py
+++ b/tests/unit/recorder/test_render_shell.py
@@ -10,7 +10,11 @@
from guidebot_recorder.chrome import SHELL_URL
from guidebot_recorder.models.config import ChromeConfig, Config, TtsConfig, Viewport, site_viewport
from guidebot_recorder.models.scenario import Scenario, Step
-from guidebot_recorder.recorder.render import _render_step, navigate_pill_mode
+from guidebot_recorder.recorder.render import navigate_pill_mode
+
+# `_render_step` is a test seam, so the facade withholds it: import the module
+# that defines it. See the render package docstring.
+from guidebot_recorder.recorder.render._step import _render_step
# --- Config matrix (navigate_pill_mode) ------------------------------------
diff --git a/tests/unit/recorder/test_selects_wiring.py b/tests/unit/recorder/test_selects_wiring.py
index 532e4aa..00ec14b 100644
--- a/tests/unit/recorder/test_selects_wiring.py
+++ b/tests/unit/recorder/test_selects_wiring.py
@@ -43,9 +43,12 @@
RenderError,
_compiled_action_is_current,
_compiled_from,
- _render_step,
run_render,
)
+
+# `_render_step` is a test seam, so the facade withholds it: import the module
+# that defines it. See the render package docstring.
+from guidebot_recorder.recorder.render._step import _render_step
from guidebot_recorder.recorder.session import (
SetupNeedsCompile,
_manual_finish,
diff --git a/tests/unit/test_seam_guard.py b/tests/unit/test_seam_guard.py
index fc06a50..9df3cb7 100644
--- a/tests/unit/test_seam_guard.py
+++ b/tests/unit/test_seam_guard.py
@@ -1,15 +1,15 @@
"""Tests for the seam-guard machinery itself — the parts nothing else exercises yet.
-``tests/unit/video/test_mux_seams.py`` and ``tests/unit/recorder/test_compile_seams.py``
-drive :mod:`tests._seam_scan` and :mod:`tests._seam_guard` against two real
-packages, and between them they
+``tests/unit/video/test_mux_seams.py``, ``tests/unit/recorder/test_compile_seams.py``
+and ``tests/unit/recorder/test_render_seams.py`` drive :mod:`tests._seam_scan` and
+:mod:`tests._seam_guard` against three real packages. The first two, between them,
cover one discovery idiom (``importlib.import_module``), one patch shape
-(``.``) and one consumer per seam. ``recorder.render`` — phase 1c
-— has none of those properties: it is patched through three *other* idioms, most of
-its seams are defined outside the package, and two of them will have two consumers
-after the split. A guard whose new capabilities are never exercised is a guard that
-discovers nothing on the day it matters, so they are exercised here, on synthetic
-input and on the real ``render`` surface.
+(``.``) and one consumer per seam. ``recorder.render`` has none of
+those properties: it is patched through three *other* idioms, most of its seams are
+defined outside the package, two of them have two consumers, and one reaches through
+a stdlib module (``audio.os.replace``). A guard whose capabilities are never
+exercised is a guard that discovers nothing on the day it matters, so the machinery
+itself is exercised here, on synthetic input and on the real ``render`` surface.
Needs nothing but source: no ffmpeg, no browser, no network.
"""
@@ -104,20 +104,23 @@ def test_evidence_carries_the_binding_statement_not_only_the_target_text() -> No
# --------------------------------------------------------------------------- #
-# (f) the render simulation — the capability phase 1c inherits
+# (f) the render surface — the capability phase 1c needed, on the split package
# --------------------------------------------------------------------------- #
RENDER_GUARD = SeamGuard.build(RENDER)
-def test_the_render_surface_is_discovered_before_the_split() -> None:
- # `render.py` is still one module, so `package_dir` is the directory the split
- # will create and holds no files yet. Discovery does not care: it works off the
- # dotted name, which is why this measurement is available *before* 1c starts.
- assert not RENDER_GUARD.package_dir.exists()
+def test_the_whole_render_surface_stays_discoverable() -> None:
+ # Discovery works off the dotted name, which is why this measurement was
+ # available *before* 1c split the file and still is after. `package_dir` is now
+ # the real package and every submodule in it is visible to the structural half.
+ assert RENDER_GUARD.package_dir.is_dir()
+ assert {"_run", "_step", "audio", "narration", "popup_crop", "timeline", "visuals"} <= set(
+ RENDER_GUARD.submodules
+ )
# The old importlib-only scan found 0 of these. Floors, not exact counts —
- # 1c will move sites around and the point is that they stay visible.
+ # later phases will move sites around and the point is that they stay visible.
sites = RENDER_GUARD.own_sites
assert len(sites) >= 60, f"only {len(sites)} render patch sites discovered"
assert len(RENDER_GUARD.seams) >= 19, sorted(RENDER_GUARD.seams)
@@ -130,31 +133,39 @@ def test_the_render_surface_is_discovered_before_the_split() -> None:
def test_no_render_patch_site_anywhere_is_unclassified() -> None:
# The real deliverable for 1c: the list below must stay empty. Anything in it
- # is a patch the guard cannot see, and therefore a seam 1c could break in
- # silence. `completeness_offenders` is what `assert_scan_complete` raises on.
+ # is a patch the guard cannot see, and therefore a seam a later phase could
+ # break in silence. `completeness_offenders` is what `assert_scan_complete`
+ # raises on.
assert RENDER_GUARD.completeness_offenders() == []
def test_render_is_dominated_by_the_mode_mux_does_not_implement() -> None:
- # 10 of the patched names are not defined in render.py at all — they are
- # cross-package name-imports (`Overlay`, `Recorder`, `probe_frame_count`, ...).
- # A mux-shaped guard would call every one of their patch sites a violation.
- imported = {
- name
- for name in RENDER_GUARD.seams
- if name in _name_imports(RENDER_GUARD.source_root / "recorder" / "render.py")
- }
- assert len(imported) >= 10, sorted(imported)
-
-
-def _name_imports(path: Path) -> set[str]:
- tree = ast.parse(path.read_text())
- return {
- alias.asname or alias.name
- for node in ast.walk(tree)
- if isinstance(node, ast.ImportFrom)
- for alias in node.names
- }
+ # 10 of the patched names are not defined in the render package at all — they
+ # are cross-package name-imports (`Overlay`, `Recorder`, `probe_frame_count`,
+ # ...) held by the submodule that consumes them. A mux-shaped guard would call
+ # every one of their patch sites a violation.
+ outside = {name for name in RENDER_GUARD.seams if RENDER_GUARD.consumers.get(name)}
+ assert len(outside) >= 10, sorted(outside)
+ assert not any(RENDER_GUARD.defined_inside(name) for name in outside)
+
+
+def test_render_really_has_the_two_consumer_shape() -> None:
+ # Rule 3 is exercised on synthetic input below because it had no real subject
+ # when it was written. It has one now, and this is what stops the synthetic
+ # test from drifting away from the package it was written for.
+ two = {name for name, homes in RENDER_GUARD.consumers.items() if len(homes) > 1}
+ assert two == {"Recorder", "probe_frame_count"}, sorted(two)
+
+
+def test_a_seam_may_live_one_module_deeper_than_the_package() -> None:
+ # `_publish_render_artifacts` commits the master MP4 with `os.replace`, so the
+ # honest patch target names `audio`'s own `os` global. The chain rule accepts
+ # that; it must still reject a chain whose steps are not modules.
+ assert RENDER_GUARD.patch_owners["replace"] == {"audio.os"}
+ assert RENDER_GUARD._module_chain_offender("replace", "audio.os", "") is None
+ assert "not a module" in str(
+ RENDER_GUARD._module_chain_offender("replace", "audio.RenderError", "")
+ )
# --------------------------------------------------------------------------- #
@@ -165,10 +176,11 @@ def _name_imports(path: Path) -> set[str]:
def _synthetic(consumer_sources: dict[str, str], patched_on: list[str]) -> SeamGuard:
"""A guard over an imaginary package, so rule 3 can be provoked on demand.
- No package in the repo has a two-consumer seam yet — that shape arrives with
- the render split — and a rule that has never fired is a rule nobody has
- checked. Building the dataclass directly keeps the check honest without
- waiting for 1c or writing a throwaway package to disk.
+ ``recorder.render`` now has two of them (``Recorder``, ``probe_frame_count``),
+ but a rule is only checked once it has been made to *fail*, and provoking that
+ on a real package would mean deleting a patch line from a real test. Building
+ the dataclass directly keeps the negative case honest without writing a
+ throwaway package to disk.
"""
files = tuple(
SourceFile(
@@ -233,8 +245,9 @@ def test_patching_every_consumer_satisfies_the_rule() -> None:
def test_a_single_consumer_seam_needs_only_one_patch_line() -> None:
- # Today's shape for both compile seams. The rule must stay quiet here, or it
- # would fire on every correctly-patched package in the repo.
+ # Today's shape for both compile seams and for eight of render's ten
+ # outside-defined ones. The rule must stay quiet here, or it would fire on
+ # every correctly-patched package in the repo.
guard = _synthetic(
{"run": TWO_CONSUMERS["run"], "popup": "\n"},
patched_on=["run"],
diff --git a/tests/unit/video/test_mux_seams.py b/tests/unit/video/test_mux_seams.py
index 7b26ab6..c0973ef 100644
--- a/tests/unit/video/test_mux_seams.py
+++ b/tests/unit/video/test_mux_seams.py
@@ -18,7 +18,7 @@
to its second row for every one of them — consumers are rewired to ``mod.name(...)``
and the tests patch the *defining* module — inside the package and out
(``video/timeline.py``, ``video/sfx.py``, ``video/audiobed.py``,
-``recorder/render.py``), so one patch line covers every consumer of a name.
+``recorder/render/``), so one patch line covers every consumer of a name.
``SeamGuard.assert_patch_targets`` derives that mode from the definition site
rather than assuming it, which is why the sibling package can hold the opposite
rule without a second implementation.