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:
parent
cf9512e128
commit
2f937dba91
2 changed files with 71 additions and 24 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue