diff --git a/tests/snippets/_screenshot.py b/tests/snippets/_screenshot.py index eb35628..b68579a 100644 --- a/tests/snippets/_screenshot.py +++ b/tests/snippets/_screenshot.py @@ -15,15 +15,27 @@ mode chains THIS instead, so the pass/fail gate stays fast and untouched by capt The snippet has already run (it is an earlier --exec, sharing this interpreter and engine state), so its scene is live and inspectable here. -Per-snippet parameters arrive by environment, set by tools/generate_snippet_shots.py, -which parses each snippet's `# mcrf:` header: +Per-snippet parameters arrive by environment, set by tools/generate_snippet_shots.py +from its OVERRIDES table: MCRF_SHOT_OUT output PNG path (required) - MCRF_SHOT_SETUP_STEPS step(0.016) calls before capture (default 3) + MCRF_SHOT_SETUP_STEPS static-capture frames, step(0.016) each (default 3) + MCRF_SHOT_AT explicit capture time in seconds (overrides auto-derivation) + MCRF_SHOT_FRACTION fraction of an animation's duration to capture at (default 0.6) -Phase 1 implements STATIC capture only: advance a few frames so timers/layout settle, -then screenshot. Animation target-time (`shot_at`) and scripted interaction (`action`) -are later phases; a snippet that needs them is not chained here yet. +CAPTURE TIME. When to grab the frame is decided in this order: + 1. MCRF_SHOT_AT set -> step to exactly that sim time. Used for effects the + animation list can't see -- timer-driven pulses/flashes + (they're Timers, not .animate() calls) and fades whose + 60%-of-duration frame is nearly blank. + 2. active .animate() found -> step to FRACTION * (longest active animation's duration), + so the capture lands mid-effect, not on the opening frame. + Fully automatic: the engine reports each animation's + duration via mcrfpy.animations (#381 Phase 2). + 3. otherwise (STATIC) -> step MCRF_SHOT_SETUP_STEPS frames and capture. + +Scripted interaction (`action`) is a later phase; a snippet needing it gets whichever of +the above applies for now. """ import os @@ -32,6 +44,8 @@ import sys import mcrfpy from mcrfpy import automation +DT = 0.016 + FAIL = "SHOT_FAIL" OK = "SHOT_OK" @@ -45,17 +59,27 @@ out = os.environ.get("MCRF_SHOT_OUT") if not out: fail("MCRF_SHOT_OUT not set") -setup_steps = int(os.environ.get("MCRF_SHOT_SETUP_STEPS", "3")) +# Decide how many frames to advance before capturing (see the module docstring). This +# must be computed BEFORE stepping: mcrfpy.animations reports full durations only while +# elapsed is still ~0, i.e. before the clock has run. +def resolve_steps(): + at = os.environ.get("MCRF_SHOT_AT") + if at is not None: + return max(1, round(float(at) / DT)) # 1. explicit time + durations = [a.duration for a in mcrfpy.animations if not a.is_complete] + if durations: + fraction = float(os.environ.get("MCRF_SHOT_FRACTION", "0.6")) + return max(1, round(fraction * max(durations) / DT)) # 2. mid-animation + return int(os.environ.get("MCRF_SHOT_SETUP_STEPS", "3")) # 3. static -# The snippet built its scene at import. Advance a few frames so anything that only -# resolves under the clock -- a first timer fire that fills a caption, a layout pass, -# an animation's opening frames -- has settled before we capture. The headless -# screenshot forces its own synchronous renderScene() (Automation.cpp #153), so a pure -# static scene would capture correctly with zero steps; the steps are for the snippets -# that need the clock, and cost ~nothing. -for _ in range(setup_steps): +# The snippet built its scene at import. Advance the resolved number of frames so the +# capture lands where we want it -- a first timer fire that fills a caption, a layout +# pass, or mid-animation. The headless screenshot forces its own synchronous +# renderScene() (Automation.cpp #153), so a pure static scene captures correctly even +# at the 3-step floor; the steps are for whatever needs the clock. +for _ in range(resolve_steps()): try: - mcrfpy.step(0.016) + mcrfpy.step(DT) except Exception as exc: # noqa: BLE001 -- a raise under the clock is a real failure import traceback traceback.print_exc() diff --git a/tools/generate_snippet_shots.py b/tools/generate_snippet_shots.py index 91044ad..3d0187e 100644 --- a/tools/generate_snippet_shots.py +++ b/tools/generate_snippet_shots.py @@ -20,15 +20,20 @@ at import); _screenshot.py advances a few frames and saves the PNG. Output lands gitignored SHOT_DIR -- the images belong in the doc-site repo, not this one; the site build pulls them from here. -CAPTURE MODEL (Phase 1: STATIC only) -Every snippet defaults to a static capture: step a few frames, then screenshot. That is -correct for the ~180 snippets whose visual is fully drawn at load. The exceptions live -in OVERRIDES below: - * noshot=True -- no meaningful single frame (API/audio/text-only demos); skip. - * setup_steps=N -- needs more frames than default (e.g. a first timer fire that - fills a caption at t>=0.1s). -Animation target-time (shot_at) and scripted interaction (action) are later phases and -are not captured yet; snippets needing them get a default static frame for now. +CAPTURE MODEL +Every snippet defaults to a static capture: step a few frames, then screenshot -- correct +for the ~180 snippets whose visual is fully drawn at load. Snippets driven by .animate() +are handled automatically by _screenshot.py: it reads mcrfpy.animations and captures at +60% of the longest animation's duration, so no per-snippet entry is needed for those. +The remaining exceptions live in OVERRIDES below: + * noshot=True -- no meaningful single frame (API/audio/text-only demos); skip. + * setup_steps=N -- needs more static frames than default (e.g. a first timer fire that + fills a caption at t>=0.1s). + * shot_at=SECS -- explicit capture time, for effects the animation list can't see: + timer-driven pulses/flashes (Timers, not .animate()) and fades whose + 60%-of-duration frame is nearly blank. +Scripted interaction (action) is a later phase; snippets needing it get whichever of the +above applies for now. """ import argparse @@ -66,6 +71,22 @@ OVERRIDES = { # placeholder text at the default 3 steps (~0.048s). 85: {"setup_steps": 12}, # entity_visibility status caption 91: {"setup_steps": 12}, # metrics_display labels + # shot_at: timer-driven effects (not in mcrfpy.animations, so auto-derivation can't + # see them) and fade-to-invisible animations whose 60% frame is nearly blank. + 46: {"shot_at": 1.6}, # timer ticker -- past a few 500ms fires + 47: {"shot_at": 2.3}, # timer once=True at 2000ms -- show the fired state + 75: {"shot_at": 3.5}, # message log Timer 1000ms -- accumulate a few lines + 79: {"shot_at": 0.5}, # pulse Timer 50ms -- a representative phase + 129: {"shot_at": 1.0}, # Scene.update orbiter (not .animate) -- offset visible + 132: {"shot_at": 0.75}, # color pulse peak + 133: {"shot_at": 0.75}, + 134: {"shot_at": 0.75}, + 135: {"shot_at": 0.75}, + 136: {"shot_at": 0.75}, + 137: {"shot_at": 0.1}, # damage flash -- flash-on frame + 138: {"shot_at": 0.1}, + 139: {"shot_at": 0.1}, + 227: {"shot_at": 0.4}, # caption fade-out (2.0s) -- early, before it vanishes } @@ -95,6 +116,8 @@ def capture(path, env_base): env = env_base.copy() env["MCRF_SHOT_OUT"] = out env["MCRF_SHOT_SETUP_STEPS"] = str(cfg.get("setup_steps", DEFAULT_SETUP_STEPS)) + if "shot_at" in cfg: + env["MCRF_SHOT_AT"] = str(cfg["shot_at"]) try: proc = subprocess.run(