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
This commit is contained in:
John McCardle 2026-07-15 21:09:16 -04:00
commit 2f937dba91
2 changed files with 71 additions and 24 deletions

View file

@ -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()

View file

@ -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(