From 448e8b887665946f5aa369edd315d2443047932a Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Wed, 4 Feb 2026 17:52:12 -0600 Subject: [PATCH] fix(blocks): strip chapter metadata before MoviePy opens files MoviePy 2.x crashes with IndexError when parsing video/audio files with embedded chapter metadata (moviepy#2419). Add strip_chapters_inplace() which runs `ffmpeg -map_chapters -1 -codec copy` to remove chapters without re-encoding before any VideoFileClip/AudioFileClip call. Applied to all video blocks: clip, concat, text_overlay, narration, add_audio, loop, and duration. Co-Authored-By: Claude Opus 4.5 --- .../backend/backend/blocks/video/_utils.py | 49 +++++++++++++++++++ .../backend/backend/blocks/video/add_audio.py | 2 + .../backend/backend/blocks/video/clip.py | 3 +- .../backend/backend/blocks/video/concat.py | 18 +++++-- .../backend/backend/blocks/video/duration.py | 4 +- .../backend/backend/blocks/video/loop.py | 2 + .../backend/backend/blocks/video/narration.py | 3 +- .../backend/blocks/video/text_overlay.py | 3 +- 8 files changed, 76 insertions(+), 8 deletions(-) diff --git a/autogpt_platform/backend/backend/blocks/video/_utils.py b/autogpt_platform/backend/backend/blocks/video/_utils.py index e49d4552d9..832c19d134 100644 --- a/autogpt_platform/backend/backend/blocks/video/_utils.py +++ b/autogpt_platform/backend/backend/blocks/video/_utils.py @@ -1,6 +1,12 @@ """Shared utilities for video blocks.""" +from __future__ import annotations + +import logging import os +import subprocess + +logger = logging.getLogger(__name__) def get_video_codecs(output_path: str) -> tuple[str, str]: @@ -32,3 +38,46 @@ def get_video_codecs(output_path: str) -> tuple[str, str]: } return codec_map.get(ext, ("libx264", "aac")) + + +def strip_chapters_inplace(video_path: str) -> None: + """Strip chapter metadata from a media file in-place using ffmpeg. + + MoviePy 2.x crashes with IndexError when parsing files with embedded + chapter metadata (https://github.com/Zulko/moviepy/issues/2419). + This strips chapters without re-encoding. + + Args: + video_path: Absolute path to the media file to strip chapters from. + """ + base, ext = os.path.splitext(video_path) + tmp_path = base + ".tmp" + ext + try: + result = subprocess.run( + [ + "ffmpeg", + "-y", + "-i", + video_path, + "-map_chapters", + "-1", + "-codec", + "copy", + tmp_path, + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + logger.warning( + "ffmpeg chapter strip failed (rc=%d): %s", + result.returncode, + result.stderr, + ) + return + os.replace(tmp_path, video_path) + except FileNotFoundError: + logger.warning("ffmpeg not found; skipping chapter strip") + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) diff --git a/autogpt_platform/backend/backend/blocks/video/add_audio.py b/autogpt_platform/backend/backend/blocks/video/add_audio.py index 9d66b86888..a542cbead7 100644 --- a/autogpt_platform/backend/backend/blocks/video/add_audio.py +++ b/autogpt_platform/backend/backend/blocks/video/add_audio.py @@ -6,6 +6,7 @@ import tempfile from moviepy.audio.io.AudioFileClip import AudioFileClip from moviepy.video.io.VideoFileClip import VideoFileClip +from backend.blocks.video._utils import strip_chapters_inplace from backend.data.block import ( Block, BlockCategory, @@ -76,6 +77,7 @@ class AddAudioToVideoBlock(Block): audio_abspath = os.path.join(abs_temp_dir, local_audio_path) # 2) Load video + audio with moviepy + strip_chapters_inplace(video_abspath) video_clip = VideoFileClip(video_abspath) audio_clip = AudioFileClip(audio_abspath) # Optionally scale volume diff --git a/autogpt_platform/backend/backend/blocks/video/clip.py b/autogpt_platform/backend/backend/blocks/video/clip.py index 6170881758..29cd84c78b 100644 --- a/autogpt_platform/backend/backend/blocks/video/clip.py +++ b/autogpt_platform/backend/backend/blocks/video/clip.py @@ -5,7 +5,7 @@ from typing import Literal from moviepy.video.io.VideoFileClip import VideoFileClip -from backend.blocks.video._utils import get_video_codecs +from backend.blocks.video._utils import get_video_codecs, strip_chapters_inplace from backend.data.block import ( Block, BlockCategory, @@ -89,6 +89,7 @@ class VideoClipBlock(Block): clip = None subclip = None try: + strip_chapters_inplace(video_abspath) clip = VideoFileClip(video_abspath) subclip = clip.subclipped(start_time, end_time) video_codec, audio_codec = get_video_codecs(output_abspath) diff --git a/autogpt_platform/backend/backend/blocks/video/concat.py b/autogpt_platform/backend/backend/blocks/video/concat.py index d1af16e8a5..99e14a9ba7 100644 --- a/autogpt_platform/backend/backend/blocks/video/concat.py +++ b/autogpt_platform/backend/backend/blocks/video/concat.py @@ -6,7 +6,7 @@ from moviepy import concatenate_videoclips from moviepy.video.fx import CrossFadeIn, CrossFadeOut, FadeIn, FadeOut from moviepy.video.io.VideoFileClip import VideoFileClip -from backend.blocks.video._utils import get_video_codecs +from backend.blocks.video._utils import get_video_codecs, strip_chapters_inplace from backend.data.block import ( Block, BlockCategory, @@ -53,8 +53,13 @@ class VideoConcatBlock(Block): categories={BlockCategory.MULTIMEDIA}, input_schema=self.Input, output_schema=self.Output, - test_input={"videos": ["/tmp/a.mp4", "/tmp/b.mp4"]}, - test_output=[("video_out", str), ("total_duration", float)], + test_input={ + "videos": ["/tmp/a.mp4", "/tmp/b.mp4"], + }, + test_output=[ + ("video_out", str), + ("total_duration", float), + ], test_mock={ "_concat_videos": lambda *args: 20.0, "_store_input_video": lambda *args, **kwargs: "test.mp4", @@ -89,13 +94,18 @@ class VideoConcatBlock(Block): transition: str, transition_duration: int, ) -> float: - """Concatenate videos. Extracted for testability.""" + """Concatenate videos. Extracted for testability. + + Returns: + Total duration of the concatenated video. + """ clips = [] faded_clips = [] final = None try: # Load clips for v in video_abspaths: + strip_chapters_inplace(v) clips.append(VideoFileClip(v)) if transition == "crossfade": diff --git a/autogpt_platform/backend/backend/blocks/video/duration.py b/autogpt_platform/backend/backend/blocks/video/duration.py index 79c3de765e..9b5d7d8206 100644 --- a/autogpt_platform/backend/backend/blocks/video/duration.py +++ b/autogpt_platform/backend/backend/blocks/video/duration.py @@ -3,6 +3,7 @@ from moviepy.audio.io.AudioFileClip import AudioFileClip from moviepy.video.io.VideoFileClip import VideoFileClip +from backend.blocks.video._utils import strip_chapters_inplace from backend.data.block import ( Block, BlockCategory, @@ -59,7 +60,8 @@ class MediaDurationBlock(Block): execution_context.graph_exec_id, local_media_path ) - # 2) Load the clip + # 2) Strip chapters to avoid MoviePy crash, then load the clip + strip_chapters_inplace(media_abspath) if input_data.is_video: clip = VideoFileClip(media_abspath) else: diff --git a/autogpt_platform/backend/backend/blocks/video/loop.py b/autogpt_platform/backend/backend/blocks/video/loop.py index 7b7c08b3e3..08199788fc 100644 --- a/autogpt_platform/backend/backend/blocks/video/loop.py +++ b/autogpt_platform/backend/backend/blocks/video/loop.py @@ -6,6 +6,7 @@ from typing import Optional from moviepy.video.fx.Loop import Loop from moviepy.video.io.VideoFileClip import VideoFileClip +from backend.blocks.video._utils import strip_chapters_inplace from backend.data.block import ( Block, BlockCategory, @@ -71,6 +72,7 @@ class LoopVideoBlock(Block): input_abspath = get_exec_file_path(graph_exec_id, local_video_path) # 2) Load the clip + strip_chapters_inplace(input_abspath) clip = VideoFileClip(input_abspath) # 3) Apply the loop effect diff --git a/autogpt_platform/backend/backend/blocks/video/narration.py b/autogpt_platform/backend/backend/blocks/video/narration.py index d6ba6392be..599827b2b7 100644 --- a/autogpt_platform/backend/backend/blocks/video/narration.py +++ b/autogpt_platform/backend/backend/blocks/video/narration.py @@ -14,7 +14,7 @@ from backend.blocks.elevenlabs._auth import ( ElevenLabsCredentials, ElevenLabsCredentialsInput, ) -from backend.blocks.video._utils import get_video_codecs +from backend.blocks.video._utils import get_video_codecs, strip_chapters_inplace from backend.data.block import ( Block, BlockCategory, @@ -150,6 +150,7 @@ class VideoNarrationBlock(Block): original = None try: + strip_chapters_inplace(video_abspath) video = VideoFileClip(video_abspath) narration_original = AudioFileClip(audio_abspath) narration_scaled = narration_original.with_volume_scaled(narration_volume) diff --git a/autogpt_platform/backend/backend/blocks/video/text_overlay.py b/autogpt_platform/backend/backend/blocks/video/text_overlay.py index 0d9d8c2298..1906daa6b2 100644 --- a/autogpt_platform/backend/backend/blocks/video/text_overlay.py +++ b/autogpt_platform/backend/backend/blocks/video/text_overlay.py @@ -6,7 +6,7 @@ from typing import Literal from moviepy import CompositeVideoClip, TextClip from moviepy.video.io.VideoFileClip import VideoFileClip -from backend.blocks.video._utils import get_video_codecs +from backend.blocks.video._utils import get_video_codecs, strip_chapters_inplace from backend.data.block import ( Block, BlockCategory, @@ -117,6 +117,7 @@ class VideoTextOverlayBlock(Block): final = None txt_clip = None try: + strip_chapters_inplace(video_abspath) video = VideoFileClip(video_abspath) txt_clip = TextClip(