an.bench.capture

Render one corpus fixture into a throwaway copy, and hand back its artifacts.

Two things this module exists to get right, both of which produce plausible numbers when got wrong:

Render into a copy. The render path mutates the project directory — scene mtimes, the decisions log, .an/render_work — so rendering in place makes the git sha in the ledger filename a lie about the tree that produced the row.

Do not inherit a stale render. shutil.copytree of a developer’s checkout would carry .an/render_work and output/ across, frames/ is never cleared, and ffmpeg’s image2 demuxer reads the contiguous frame_%06d.png run from 0 — so a longer previous render is silently appended to this one. Every encode-side metric pairs source frame i with decoded frame i, so that appends garbage to one leg and shifts nothing on the other. artifacts/ is deliberately kept except for one subdirectory: it holds the audio cache, whose warm/cold state is recorded rather than destroyed — but artifacts/shots is mall["shots"], the previous render’s per-shot mp4s, and this module’s whole promise is that nothing of a previous render crosses. It is gitignored, so it does not reproduce on a clean checkout: a per-developer landmine, in the module whose docstring says the opposite.

exception an.bench.capture.CaptureError[source]

A capture could not produce something the metrics need.

an.bench.capture.FRAME_PNG_GLOB: str = 'frame_*.png'

How a shot’s frames are named on disk. One constant rather than the literal repeated at each glob site.

exception an.bench.capture.GitStatusUnavailable[source]

git status did not answer, so “the tree is clean” is not known.

Separated from an empty result on purpose. git status failing prints nothing to stdout, so check=False turned every failure into “no dirty paths” — indistinguishable from a clean tree, and silently so. Measured: a concurrent git in a linked worktree of this repo takes index.lock, git status exits nonzero with empty stdout, and test_a_capture_leaves_the_repository_untouched fails with [] != […] — an assertion about the capture, pointing at nothing, in a run that has been green fifty times. A check that could not run is not evidence that nothing is wrong.

an.bench.capture.IGNORED_ON_COPY: tuple[str, ...] = ('.an', 'output', '.anima')

they are the previous render’s output, and one of them silently extends this one’s frame sequence. Matched on the basename, at any depth — that is exactly what shutil.ignore_patterns does, and it is why artifacts/shots cannot be spelled here. See IGNORED_RELPATHS_ON_COPY.

Type:

Copied for the render, but never these

an.bench.capture.IGNORED_RELPATHS_ON_COPY: tuple[str, ...] = ('artifacts/shots',)

Excluded by their path relative to the project root, POSIX-spelled. mall["shots"] is <project>/artifacts/shots, and artifacts/ itself is kept on purpose — it holds the audio cache, whose warm/cold state this module records rather than destroys.

Neither spelling belongs in IGNORED_ON_COPY, and both fail silently. shutil.ignore_patterns returns a closure handed the NAMES inside one directory, which it fnmatch.filter``s so ``"artifacts/shots" can never match anything (no name contains a separator) and a bare "shots" would delete every directory of that name anywhere in the tree, a character rig’s included.

an.bench.capture.RENDER_WORK_RELPATH: str = '.an/render_work'

Where the renderer leaves its per-shot working tree inside the project.

class an.bench.capture.SceneCapture(name: str, source: str, prepared: bool, project_dir: ~pathlib.Path, mp4: ~pathlib.Path, shots: list[~an.bench.capture.ShotCapture], resolution: tuple[int, int], fps: int, duration: float, n_declared_entity_refs: int, visual_kinds: set[str], asset_resolution: list[dict], audio_cache: str, wall_seconds: float, determinism: dict = <factory>)[source]

One fixture’s whole render.

class an.bench.capture.ShotCapture(shot_id: str, frames_dir: Path, scene_json: dict, runtime_dir: Path, frame_count: int, duration: float = 0.0, frame_sizes: tuple[tuple[int, int], ...] = ())[source]

One rendered shot’s artifacts.

duration: float

The shot’s declared duration, from the IR rather than from the staged scene, so the expected frame count is derived from the same number the renderer used.

frame_sizes: tuple[tuple[int, int], ...]

The distinct pixel sizes actually on disk, from each PNG’s IHDR. The independent half of a pair whose other half — SceneCapture.resolution — comes from the staged scene’s meta and never from a file. Empty only when the shot wrote no frames.

an.bench.capture.capture_fixture(name: str, fixture: Fixture, *, repo_root: Path, keep_render: Path | None = None) SceneCapture[source]

Render fixture in a throwaway copy and return its artifacts.

The copy lives until the caller is done with it — the metrics read the frames — so this is a context-free function that leaves the tree in place and hands back the path. captured() is the scoped form.

an.bench.capture.cleanup(capture: SceneCapture) None[source]

Remove a capture’s throwaway tree.

an.bench.capture.dirty_paths(repo_root: Path) list[str][source]

git status –porcelain lines, so a capture can prove it touched nothing.

Raises GitStatusUnavailable when git does not answer, rather than reporting a clean tree it never observed.

an.bench.capture.distinct_png_sizes(frames_dir: Path) tuple[tuple[int, int], ...][source]

Every distinct (width, height) among a shot’s frame PNGs, sorted.

Read from each file’s IHDR — 24 bytes per frame — so reading all of them costs nothing and catches what sampling one would miss: a sequence whose size changes partway through, which is what a half-applied supersample produces.

Recorded here, enforced elsewhere. Rendering a fixture at a size the scene does not declare is a legitimate thing to do — misc/bench/wave3_ab.py patches runtime.js to resolution: k, autoDensity: false and drives capture_fixture() directly to measure the supersample — so this module reports what it saw and an.bench.run is where the bench’s invariant is asserted.

an.bench.capture.expected_frame_count(duration: float, fps: int) int[source]

The renderer’s own frame-count expression, reused rather than restated.

max(1, int(round(duration * fps))) — and Python 3’s round is banker’s rounding, so math.ceil or int(x + 0.5) silently disagrees on every half-frame duration.

>>> expected_frame_count(2.5, 24)
60
>>> expected_frame_count(0.0, 24)
1
an.bench.capture.stage_copy(fixture_dir: Path, base: Path) Path[source]

Copy a fixture into base, leaving the previous render behind.

Split out of capture_fixture() so the exclusion is testable without rendering anything — which matters, because the failure it prevents is silent. frames/ is never cleared and ffmpeg’s image2 demuxer reads the contiguous frame_%06d.png run from 0, so a longer previous render is appended to this one’s source leg and to nothing else.

Two kinds of exclusion, because one kind cannot say both things: IGNORED_ON_COPY by basename at any depth, and IGNORED_RELPATHS_ON_COPY by path from the project root — which is the only way to drop artifacts/shots while keeping artifacts/audio.