Skip to content

Add autogeneration of doc media - #146

Merged
amacati merged 3 commits into
mainfrom
feat.doc-media-gen
Oct 28, 2026
Merged

amacati merged 3 commits into
mainfrom
feat.doc-media-gen

Conversation

@ratheron

@ratheron ratheron commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

We previously has some selected media in the docs for some of the examples, but

  1. We had to manually add the ones where we wanted the docs to have it
  2. For the ones we added the media, we carried the media around in the git history

This PR let's you define what output of the example to automatically capture and put into the docs, i.e., console output, renders, and plots can be automatically captured and added in the correct location.

Closes #140

@ratheron
ratheron requested a review from amacati as a code owner October 28, 2026 14:06
Comment thread .github/workflows/docs.yml Resolved
Comment thread docs/gen_drone_renders.py Resolved
Comment thread docs/capture_example.py
Comment on lines +141 to +187
def render(
sim: Sim,
mode: str | None = "human",
world: int = 0,
camera: int | str = -1,
cam_config: dict | None = None,
width: int = 1920,
height: int = 1080,
) -> np.ndarray | None:
if mode != "human":
return sim_render(sim, mode, world, camera, cam_config, width, height)
if "video" not in kinds:
return None
if docs_camera is not None:
camera = docs_camera
elif docs_cam_config:
camera = -1
cam_config = CAM_CONFIG | (cam_config or {}) | (docs_cam_config or {})
if sim.viewer is None:
videos.append(Video(out / f"video-{len(videos) + 1:02d}.mp4"))
if docs_geomgroup is not None:
sim_render(sim, "rgb_array", world, camera, cam_config, WIDTH, HEIGHT)
sim.viewer.viewer.vopt.geomgroup[:] = [
group in docs_geomgroup for group in range(mujoco.mjNGROUP)
]
rendered = videos[-1].add(
float(sim.time[world, 0]),
lambda: sim_render(sim, "rgb_array", world, camera, cam_config, WIDTH, HEIGHT),
)
if not rendered: # Only a render clears the markers, so drop those of skipped frames
sim.viewer.viewer._markers.clear()

def func_animation(self: animation.FuncAnimation, fig: plt.Figure, *args: Any, **kwargs: Any):
func_animation_init(self, fig, *args, **kwargs)
animations[fig] = self

def show(*args: Any, **kwargs: Any):
if "plot" in kinds:
for num in plt.get_fignums():
fig = plt.figure(num)
save_figure(fig, out / f"plot-{next(plot_ids):02d}", animations.get(fig))
plt.close("all")

sys.argv = [str(example), *(["--plot"] if "plot" in kinds else [])]
spec = importlib.util.spec_from_file_location("__main__", example)
module = importlib.util.module_from_spec(spec)
sys.modules["__main__"] = module

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get what you are trying to do, and I'm not saying this can easily be done otherwise, but this is some voodoo-level stuff. We could also maybe render as RGB in the examples, that would mean one more argument that is always "human" by default, and then you only have to wrap sim.render() with a minimal wrapper.

Comment thread docs/capture_example.py Outdated
Comment on lines +36 to +93
class Video:
"""H.264 video of rendered frames, resampled to real time from the sim time of each frame.

A video of a single frame is saved as png.
"""

def __init__(self, path: Path):
"""Create the video, the file is written on the second frame."""
self.path = path
self.writer = None
self.first = None
self.n_frames = 0
self.start = None
self.offset = 0.0
self.last = 0.0

def add(self, t: float, render: Callable[[], np.ndarray]) -> bool:
"""Render and write a frame at sim time ``t`` if the video needs one."""
if t + self.offset < self.last: # The sim was reset
self.offset = self.last + 1 / FPS - t
t = self.last = t + self.offset
if self.start is None:
self.start = t
if t < self._next():
return False
frame = np.ascontiguousarray(render())
while self._next() <= t:
self._write(frame)
self.n_frames += 1
return True

def close(self):
"""Finish writing the video."""
if self.writer is not None:
self.writer.close()
elif self.first is not None:
iio.imwrite(self.path.with_suffix(".png"), self.first)

def _write(self, frame: np.ndarray):
if self.first is None:
self.first = frame
return
if self.writer is None:
self.writer = imageio_ffmpeg.write_frames(
str(self.path),
(frame.shape[1], frame.shape[0]),
fps=FPS,
codec="libx264",
quality=None,
macro_block_size=2,
output_params=["-crf", CRF, "-preset", "slow", "-movflags", "+faststart"],
)
self.writer.send(None)
self.writer.send(self.first)
self.writer.send(frame)

def _next(self) -> float:
return self.start + self.n_frames / FPS - 1e-9

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to do this on our own? There is nothing in PIL or another Python package that we can use?

@amacati
amacati merged commit cc98938 into main Oct 28, 2026
6 checks passed
@amacati
amacati deleted the feat.doc-media-gen branch October 30, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Autogeneration of example docs media

2 participants