Skip to content

[BUG FIX] Fix 'gs.tools.animate' dropping last frame by migrating from MoviePy to PyAV.#2705

Open
vlordier wants to merge 3 commits intoGenesis-Embodied-AI:mainfrom
vlordier:fix/animate-use-pyav-v2
Open

[BUG FIX] Fix 'gs.tools.animate' dropping last frame by migrating from MoviePy to PyAV.#2705
vlordier wants to merge 3 commits intoGenesis-Embodied-AI:mainfrom
vlordier:fix/animate-use-pyav-v2

Conversation

@vlordier
Copy link
Copy Markdown

Summary

Replaces the moviepy-based implementation in gs.tools.animate() with PyAV, which is already used by VideoFileWriter in the recorder system. Keeps moviepy as a fallback when av is not installed.

Root cause

In moviepy ≥ 2.x, ImageSequenceClip.write_videofile() silently drops the last frame. PyAV's explicit stream.encode(None) flush guarantees all buffered frames are written before the container is closed.

Changes

  • genesis/utils/tools.pyanimate() now uses PyAV when available:
    • Calls stream.encode(None) to flush all buffered frames before close
    • Strips alpha channel (RGBA → RGB) before encoding so 4-channel inputs (e.g. PNGs loaded via PIL.Image.open) don't fail the yuv420p libx264 path
    • Wraps the encode loop in try/finally so the file handle is always closed even on encode errors
    • Falls back to moviepy when av is not installed

Note: this is a clean replacement for PR #2704, which was inadvertently opened against a feature branch and included unrelated terrain + sensor changes in the diff. That PR is now closed.

Test plan

  • Record N frames with cam.start_recording() / cam.stop_recording() and confirm the output video contains exactly N frames
  • Verify that RGBA frames (4-channel) are encoded without error
  • Verify fallback works when av is uninstalled

Closes #1635

Copilot AI review requested due to automatic review settings April 13, 2026 13:36
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b569b3229

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread genesis/utils/tools.py

container = av.open(filename, mode="w")
try:
stream = container.add_stream("libx264", rate=fps)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fallback to moviepy when PyAV encode setup fails

animate() now enters the PyAV path whenever import av succeeds, but it hard-codes container.add_stream("libx264", ...) and only falls back on ImportError. In environments where PyAV is installed without an available libx264 encoder (a common FFmpeg build difference), add_stream raises an FFmpeg/ValueError and the function aborts instead of using the intended moviepy fallback. This turns a recoverable configuration mismatch into a runtime failure for recording/export workflows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Updates gs.tools.animate() to avoid the moviepy≥2.x “last frame dropped” behavior by using PyAV when available, while retaining moviepy as a fallback for environments without av.

Changes:

  • Implement PyAV-based MP4 encoding in animate() with explicit encoder flush (stream.encode(None)).
  • Add RGBA→RGB handling to avoid libx264/yuv420p failures on 4-channel inputs.
  • Ensure the output container is closed via try/finally, with a moviepy fallback when av is missing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread genesis/utils/tools.py
Comment on lines +33 to +48
try:
import av

first = imgs[0]
if not isinstance(first, np.ndarray):
first = np.array(first)
# Strip alpha channel if present; libx264 only accepts RGB or grayscale.
if first.ndim == 3 and first.shape[2] == 4:
first = first[..., :3]
height, width = first.shape[:2]
is_color = first.ndim == 3 and first.shape[2] == 3

container = av.open(filename, mode="w")
try:
stream = container.add_stream("libx264", rate=fps)
stream.width = width
Comment thread genesis/utils/tools.py Outdated
Comment on lines +62 to +68
if not isinstance(img, np.ndarray):
img = np.array(img)
img = img.astype(np.uint8)
# Strip alpha channel for consistency with `first`.
if img.ndim == 3 and img.shape[2] == 4:
img = img[..., :3]
buf[: img.shape[0], : img.shape[1]] = img
Comment thread genesis/utils/tools.py
Comment on lines +45 to +52
container = av.open(filename, mode="w")
try:
stream = container.add_stream("libx264", rate=fps)
stream.width = width
stream.height = height
stream.pix_fmt = "yuv420p"
stream.codec_context.options = {"preset": "ultrafast"}

Comment thread genesis/utils/tools.py Outdated
Comment on lines +53 to +70
fmt = "rgb24" if is_color else "gray8"
video_frame = av.VideoFrame(width, height, fmt)
frame_plane = video_frame.planes[0]
if is_color:
buf = np.asarray(memoryview(frame_plane)).reshape((-1, frame_plane.line_size // 3, 3))
else:
buf = np.asarray(memoryview(frame_plane)).reshape((-1, frame_plane.line_size))

for img in imgs:
if not isinstance(img, np.ndarray):
img = np.array(img)
img = img.astype(np.uint8)
# Strip alpha channel for consistency with `first`.
if img.ndim == 3 and img.shape[2] == 4:
img = img[..., :3]
buf[: img.shape[0], : img.shape[1]] = img
for packet in stream.encode(video_frame):
container.mux(packet)
Comment thread genesis/utils/tools.py Outdated
Comment on lines +77 to +82
except ImportError:
from moviepy import ImageSequenceClip

clip = ImageSequenceClip(imgs, fps=fps)
clip.write_videofile(filename, fps=fps, logger=None, codec="libx264", preset="ultrafast")

Comment thread genesis/utils/tools.py
Comment on lines +33 to +35
try:
import av

@vlordier vlordier force-pushed the fix/animate-use-pyav-v2 branch from 0b569b3 to 7085bc7 Compare April 13, 2026 13:43
@vlordier
Copy link
Copy Markdown
Author

Self-review findings and fixes

A thorough review uncovered one additional bug in the initial implementation (now fixed in the latest push):

Bug: line_size // channels reshape fails for non-aligned widths

The original PyAV implementation pre-allocated a frame buffer using:

buf = np.asarray(memoryview(frame_plane)).reshape((-1, frame_plane.line_size // 3, 3))

PyAV pads row strides to alignment boundaries. For example, width=100line_size=320 bytes. 320 // 3 = 106, but there are only 100 pixels per row, making the reshape fail with cannot reshape array of size 25600 into shape (106, 3).

Fix

Replaced the manual buffer approach with av.VideoFrame.from_ndarray(), which handles strides internally:

frame = av.VideoFrame.from_ndarray(img, format=fmt)
for packet in stream.encode(frame):
    container.mux(packet)

Also changed "gray8""gray" (the canonical PyAV format name for grayscale).


Test results (18/18 pass)

TEST 1: RGB – all N frames written (core regression for #1635)
  PASS  RGB – all frames written: expected 30, got 30
TEST 2: RGBA ndarray (4-channel)
  PASS  RGBA ndarray – no crash
  PASS  RGBA ndarray – count: expected 30, got 30
TEST 3: RGBA PIL Image (png-with-alpha)
  PASS  RGBA PIL – no crash
  PASS  RGBA PIL – count: expected 30, got 30
TEST 4: Grayscale (H×W)
  PASS  Grayscale – no crash
  PASS  Grayscale – count: expected 30, got 30
TEST 5: PIL RGB (non-ndarray)
  PASS  PIL RGB – no crash
  PASS  PIL RGB – count: expected 30, got 30
TEST 6: Non-aligned width (100px) – line_size padding regression
  PASS  Non-aligned width – no crash
  PASS  Non-aligned width – count: expected 30, got 30
TEST 7: try/finally – container closed on error
  PASS  Error path – no leaked fd
  PASS  Error path – fd count: leaked 0 fds
TEST 8: Empty list
  PASS  Empty – no crash
  PASS  Empty – no file
TEST 9: Mixed RGB + RGBA
  PASS  Mixed RGB/RGBA – no crash
  PASS  Mixed RGB/RGBA – count: expected 10, got 10
TEST 10: Typical HD resolution (720p)
  PASS  HD 1280×720 – count: expected 30, got 30

Results: 18 passed, 0 failed

moviepy >= 2.x drops the last frame when writing videos via
ImageSequenceClip.write_videofile(). Replace with PyAV which correctly
flushes all buffered frames via stream.encode(None) before closing.

Additional improvements over the first attempt:
- Strip alpha channel (RGBA → RGB) so libx264/yuv420p encoding doesn't
  fail on 4-channel inputs (e.g. PNG frames from PIL.Image.open)
- Wrap the encode loop in try/finally so the container file handle is
  always closed, even if an encode error occurs mid-stream
- Fall back to moviepy when av is not installed

Fixes Genesis-Embodied-AI#1635
@vlordier vlordier force-pushed the fix/animate-use-pyav-v2 branch from 7085bc7 to 3598cd2 Compare April 13, 2026 17:20
@vlordier
Copy link
Copy Markdown
Author

Second review pass — all comments addressed, full test suite passes

Changes since last push

Comment Fix
Codex P1: fallback only on ImportError, not on codec-unavailable Added "libx264" not in av.codecs_available guard before opening the container; raises ImportError to trigger the fallback with a clear warning
Copilot: fps may be float/numpy scalar fps = max(1, int(round(fps))) normalises before use in both PyAV and moviepy paths; fps=0 is clamped to 1
Copilot: libx264 not compiled into PyAV build Same codec-availability check above
Copilot: silent moviepy fallback hides last-frame drop gs.logger.warning() emitted in the except ImportError branch explaining the risk and recommending pip install av
Copilot: float/bool inputs silently truncated Documented in the docstring (Float arrays are not supported; convert to uint8 before calling)
Copilot: no unit test in the PR Addressed below

Static analysis

ruff check genesis/utils/tools.py  →  All checks passed
ty   check genesis/utils/tools.py  →  0 new errors (2 pre-existing Logger | None
                                       unresolved-attributes, 1 optional import)

Test results — 22 / 22 pass

T1:  RGB – all N frames (core fix for #1635)       PASS frame count: 30/30
T2:  RGBA ndarray (4-channel)                      PASS no crash, 30/30
T3:  RGBA PIL Image (png-with-alpha)               PASS no crash, 30/30
T4:  Grayscale (H×W)                               PASS no crash, 30/30
T5:  PIL RGB inputs                                PASS no crash, 30/30
T6:  Non-aligned width (100px) – line_size bug     PASS no crash, 30/30
T7:  Float fps (29.97)                             PASS no crash, 30/30
T8:  fps=0 clamped to 1                            PASS no crash, 5/5
T9:  try/finally – container closed on error       PASS 0 fd leaks
T10: Empty list                                    PASS no crash, no file
T11: Mixed RGB + RGBA in same list                 PASS no crash, 10/10
T12: libx264 unavailable → warning emitted         PASS warning text confirmed
T13: HD 720p (1280×1080)                           PASS 30/30

Note: T12 additionally confirms the moviepy fallback warning text is emitted correctly when libx264 is absent from the PyAV build. The moviepy call itself was not exercised (not installed in test env), which is expected.

- Reject non-uint8 inputs (float/bool) with a clear ValueError and
  conversion hint instead of silently truncating values
- Validate per-frame shape/channel consistency; raise ValueError
  immediately on mismatch to fail fast rather than producing
  corrupted output
- Add unit tests for: RGB frame count, grayscale frame count, float
  rejection, shape mismatch rejection, RGBA alpha stripping
…de errors

Only ImportError was caught previously; non-import av failures (e.g.
av.FFmpegError, RuntimeError from OS/codec issues) would propagate as
unhandled exceptions instead of triggering the moviepy fallback.

Catch all Exception from the av block, but re-raise ValueError so that
our own input-validation errors (bad dtype, shape mismatch) still
propagate clearly to the caller.

Add two more unit tests:
- av RuntimeError triggers moviepy fallback (Codex P1 concern)
- ValueError from dtype validation propagates through the av block
Copy link
Copy Markdown
Collaborator

@duburcqa duburcqa left a comment

Choose a reason for hiding this comment

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

  • Leverage the existing VideoFileWriter infrastructure available in genesis/recorders/file_writers.py instead of duplicating everything
  • Remove fallback to moviepy and accompanying unit test
  • Remove tests checking failure mode that are impossible (test_animate_rejects_mismatched_frame_shape, test_animate_rgba_strips_alpha, test_animate_value_error_propagates_through_av_block)

@duburcqa duburcqa changed the title fix: replace moviepy with PyAV in animate() to prevent last frame drop [BUG FIX] Fix last frame drop by migrating 'gs.tools.animate' from MoviePy to PyAV. Apr 15, 2026
@duburcqa duburcqa changed the title [BUG FIX] Fix last frame drop by migrating 'gs.tools.animate' from MoviePy to PyAV. [BUG FIX] Fix 'gs.tools.animate' dropping last frame by migrating from MoviePy to PyAV. Apr 15, 2026
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.

[Bug]: Missing frames in cam.render()

3 participants