Skip to content

Environment renderings

metrana.log_rendering records reinforcement-learning environment video. Each frame is appended to a per-(env_id, episode, name) H.264 .mp4, encoded on background encoder threads so it never blocks the training loop. Finalized videos are then uploaded to the portal (by default), where they surface in the environment views alongside your metrics.

metrana.log_rendering(frame, rl_step, episode, env_id=None, *, name=None)

frame is a uint8 NumPy array:

  • (H, W, 3) — RGB
  • (H, W) or (H, W, 1) — grayscale

Width and height must be even (a libx264 yuv420p requirement).

for t in range(episode_len):
frame = env.render() # uint8 (H, W, 3)
metrana.log_rendering(frame, rl_step=rl_step, episode=episode, env_id="env0")

Pass name to record several distinct videos of the same episode — for example, one per agent’s view when two agents play the same game. Each distinct name becomes its own video, and the name is used verbatim as the video’s display name in the portal:

for t in range(episode_len):
for agent in ("agent_1", "agent_2"):
frame = env.render(agent=agent)
metrana.log_rendering(frame, rl_step=rl_step, episode=episode, env_id="env0", name=agent)

The name identifies the video, so it is fixed for as long as the video is open: logging with a different name always starts a different video. Without a name, the env records a single video named <env>/episode_<n>. A name must be 1–256 characters and is keyword-only.

Each (env_id, episode, name) gets its own encoder and its own MP4, written under the configured output directory. An environment’s videos advance through episodes together: the first frame of a new episode on any of an env’s videos closes every one of its videos still on an earlier episode (so a video that stops receiving frames — say, an agent that finished early — is still finalized and uploaded promptly). Frames that arrive afterwards for an earlier episode are dropped with a warning, since reopening that episode would overwrite its finished video.

Frames route to encoder threads by env_id: every frame for a given env — across all its named videos — is encoded in order by a single thread, while different envs encode in parallel (libx264 releases the GIL). The number of encoder threads is rendering_num_encoder_threads (default min(4, cpu_count)); the number of encoders open at once across all threads is capped globally by rendering_max_concurrent_encoders (default 16). Every named video of every env holds its own encoder, so two agents’ views across eight envs need 16 slots. When the cap is reached, frames for a new video are subject to the configured backpressure/drop behavior until a slot frees.

Finalized renderings are handed to a background upload stage — the training and encode threads are never blocked on the network. Each video runs the full create → upload → commit flow, and uploads are idempotent per (run, env_id, episode, name): a resumed or re-run job reuses the existing video instead of creating a duplicate. Uploading authenticates with the run’s key from init().

upload_renderings on init() chooses what gets uploaded:

upload_renderings Behavior
"all" / True (default) Upload every finalized rendering.
"triggered" Upload only renderings that accumulated a trigger event (see marking events); discard the rest locally.
"off" / False Encode to local disk only — never upload.
metrana.init(
organization="my-org", workspace_name="my-workspace", project_name="my-project", run_name="run-1",
upload_renderings="triggered", # only ship episodes worth watching
)

Renderings are never dropped because uploads lag: the backlog accumulates on disk (finalized files awaiting upload), and a one-shot warning fires past rendering_upload_warn_threshold advising you to raise rendering_upload_concurrency. By default the local .mp4 is deleted once its video commits (rendering_delete_after_upload), so long multi-environment runs don’t fill the disk.

Pass rendering_storage="s3://<bucket>/<prefix>" to keep the video bytes in an S3 bucket you own. Each finalized rendering is uploaded to that bucket with your own AWS credentials (resolved from the ambient boto3 chain — env vars, shared config, or instance/role — never sent to Metrana) and recorded on the video API as a reference. Only metadata (the s3:// URI, digest, size, duration) reaches Metrana; the managed bucket never receives the bytes.

metrana.init(
organization="my-org", workspace_name="my-workspace", project_name="my-project", run_name="run-1",
rendering_storage="s3://my-rl-videos/metrana", # your bucket
)

Objects are keyed by video id at <prefix>/<workspace>/videos/<video_id>.mp4. This path needs the s3 extra (pip install 'metrana[s3]'); a bad URI or a missing extra fails fast in init(). Tagging, event marking, and "triggered" mode all work identically — only where the bytes live changes.

tag_rendering attaches a workspace video tag to a rendering while it is still encoding — its video may not exist yet. The tag is buffered against (env_id, episode) and applied (created-or-got by name) once the video commits.

if info["is_record"]:
metrana.tag_rendering("personal-best", episode=episode, env_id="env0")

By default the tag applies to every video of the episode, including named videos that only start later in it. Pass name to tag just one:

metrana.tag_rendering("winner", episode=episode, env_id="env0", name="agent_2")

It’s a no-op when uploading is disabled (upload_renderings="off"), since there’s no video to tag.

mark_rendering_event records an event occurrence on the rendering at the current video frame — call it on each frame the condition holds. Contiguous marks extend one occurrence; a gap closes it and opens the next. The occurrences are buffered and applied against the committed video (event type created-or-got by condition_name).

for t in range(episode_len):
frame, info = env.render(), env.step(...)
metrana.log_rendering(frame, rl_step=rl_step, episode=episode, env_id="env0")
if info["collision"]:
metrana.mark_rendering_event("collision", episode=episode, env_id="env0")

With several named videos, a mark without a name lands on every video of the episode that has frames so far, each at its own current frame (a mark before any of them has a frame lands at frame 0 on all of them). A video whose first frame comes after the mark doesn’t get it, so log every video’s frame for the step before marking. Pass name to mark just one:

if info["agent_1_hit"]:
metrana.mark_rendering_event("hit", episode=episode, env_id="env0", name="agent_1")

Set trigger=True to mark an occurrence as interesting. Under upload_renderings="triggered", a rendering uploads only if it accumulated at least one trigger; everything else is discarded locally — the way to keep just the episodes you care about:

if info["goal_reached"]:
metrana.mark_rendering_event("goal", episode=episode, env_id="env0", trigger=True)

A trigger is per video: an unnamed trigger uploads every video it marked, a named one uploads only that video.

Like tag_rendering, marking is a no-op when uploading is disabled.

Renderings are configured on init():

Argument Default Purpose
rendering_output_dir None Where encoded MP4s are written.
rendering_fps 30 Output frame rate.
rendering_num_encoder_threads min(4, cpu_count) Encoder threads. Each env is pinned to one thread; more threads = more envs encoding in parallel.
rendering_max_concurrent_encoders 16 Global cap on encoders open at once, across all threads.
rendering_queue_max_size None Bound on each encoder thread’s frame queue.
upload_renderings True "all"/True, "triggered", or "off"/False (see above).
rendering_upload_concurrency 4 Videos uploaded in parallel.
rendering_upload_warn_threshold 100 Pending-upload backlog depth at which a one-shot warning fires.
rendering_delete_after_upload True Delete the local .mp4 once its video commits. Does not apply to non-triggered renderings under upload_renderings="triggered" — those are always discarded (see above).
rendering_upload_close_timeout None Seconds to drain pending uploads on close(). None waits indefinitely.
rendering_storage None s3://bucket/prefix to store bytes in your own bucket (needs the s3 extra).
skip_drain_render_on_close None If True, close() drops queued frames instead of encoding them. None reads METRANA_SKIP_DRAIN_RENDER_ON_CLOSE.
rendering_close_timeout None How long close() waits to drain queued frames.

Frame-encoding and upload errors are governed by rendering_error_strategy ("silent", "warn" (default), "raise_on_log", "raise_on_close") — a separate knob from the engine’s delivery policy. It controls only the Python rendering pipeline; metric/attribute delivery is governed by delivery_policy (see Guarantees & retries). An upload failure leaves the local file in place for a later/manual retry rather than losing it.

By default close() drains and encodes any queued frames, then drains pending uploads, before returning. Frame encoding and upload can lag well behind logging, so this can add real time to a close (bounded by rendering_close_timeout for the encode drain and rendering_upload_close_timeout for the upload drain).

If you’d rather not wait on rendering frames at close, set init(skip_drain_render_on_close=True) (or the METRANA_SKIP_DRAIN_RENDER_ON_CLOSE env var). It drops only the queued frames — your metric and attribute delivery still gets close()’s full window, so you skip the video tail without risking metric loss.