McRogueFace/tests/snippets/_screenshot.py
John McCardle 2f937dba91 feat(docs): animation target-time capture — Phase 2 (#381)
_screenshot.py now decides capture time in three tiers: an explicit
MCRF_SHOT_AT wins; else if mcrfpy.animations reports active .animate()
animations, capture at 60% of the longest one's duration (fully automatic
via the engine's animation introspection); else fall back to the static
setup-steps path. So the ~50 .animate()-driven snippets land mid-effect
with zero per-snippet tuning.

The orchestrator's OVERRIDES table gains shot_at entries for the effects
the animation list can't see -- timer-driven pulses/flashes (Timers, not
animations) and a fade-to-invisible caption whose 60% frame is nearly blank.

Steady-state stable: 272 captured, byte-identical across repeated full runs.
A rare first-fire flake in two timer-driven snippets (048, 191) traced to a
genuine headless-clock bug, filed as #383 (Timer epoch reads wall clock, not
simulation_time); not a screenshot-tooling defect.

Addresses #381.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTh2ZW7bd3XSd9qK86Z2CE
2026-07-15 21:09:16 -04:00

98 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""
Screenshot harness -- chained as the LAST --exec, AFTER a documentation snippet, to
capture a rendered preview of the scene the snippet built:
./mcrogueface --headless \
--exec tests/snippets/_seed.py \
--exec tests/snippets/001_hello_frame.py \
--exec tests/snippets/_screenshot.py
This is the screenshot-mode counterpart to _harness.py. The normal test suite chains
_harness.py (pass/fail: does the snippet run and leave a populated scene?). Screenshot
mode chains THIS instead, so the pass/fail gate stays fast and untouched by capture.
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
from its OVERRIDES table:
MCRF_SHOT_OUT output PNG path (required)
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)
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
import sys
import mcrfpy
from mcrfpy import automation
DT = 0.016
FAIL = "SHOT_FAIL"
OK = "SHOT_OK"
def fail(msg):
print(f"{FAIL}: {msg}")
sys.exit(1)
out = os.environ.get("MCRF_SHOT_OUT")
if not out:
fail("MCRF_SHOT_OUT not set")
# 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 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(DT)
except Exception as exc: # noqa: BLE001 -- a raise under the clock is a real failure
import traceback
traceback.print_exc()
fail(f"step() raised {type(exc).__name__}: {exc}")
scene = mcrfpy.current_scene
if scene is None:
fail("snippet left no active scene (mcrfpy.current_scene is None)")
if len(scene.children) == 0:
fail(f"snippet activated scene {scene.name!r} but added nothing to it")
if automation.screenshot(out) is not True:
fail(f"screenshot({out!r}) returned falsy -- capture failed")
print(f"{OK}: {out} scene={scene.name!r} children={len(scene.children)}")
sys.exit(0)