Skip to content

Environment renderings

metrana.log_rendering records reinforcement-learning environment video. Each frame is appended to a per-(env_id, episode) 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)

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")

When the (env_id, episode) pair changes, the open encoder for that env is closed and a new one is opened for the next episode — so each episode of each environment becomes its own MP4, written under the configured output directory.

Frames route to encoder threads by env_id: every frame for a given env 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). When that cap is reached, frames for a new env are subject to the configured backpressure/drop behaviour 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): 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 Behaviour
"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(
workspace="my-workspace", project="my-project", run="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(
workspace="my-workspace", project="my-project", run="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")

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")

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)

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.