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 <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-02-04 17:52:12 -06:00
parent 059c94afac
commit 448e8b8876
8 changed files with 76 additions and 8 deletions

View File

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

View File

@@ -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

View File

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

View File

@@ -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":

View File

@@ -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:

View File

@@ -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

View File

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

View File

@@ -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(