2025, Nov 16 09:01

Почему распаковка dict в списке ломает concatenate_videoclips в MoviePy

Разбираем ошибку AttributeError в MoviePy при склейке: распаковка словаря (*dict) в список передается как целые числа. Покажем воспроизведение и два исправления.

Незаметная ловушка Python в MoviePy: при распаковке словаря в список, который затем передаётся в concatenate_videoclips, в него могут попасть целые числа вместо клипов. В результате во время выполнения появляется AttributeError, указывающий на MoviePy, хотя истинная причина кроется в том, как формируется последовательность.

Как воспроизвести проблему

Ниже приведён фрагмент, который создаёт титульную карточку, подхватывает изображения из каталога, добавляет простые затемнения и пытается склеить всё в единое видео. Ошибка возникает в момент распаковки словаря в список клипов.

from moviepy.editor import *
from moviepy.config import change_settings
from pathlib import Path
import os
import traceback

change_settings({"IMAGEMAGICK_BINARY": r"C:\\Program Files\\ImageMagick-7.1.2-Q16-HDRI\\magick.exe"})


def looks_like_image(fp):
    """
    Checks if a file is likely an image based on its extension.
    """
    valid_ext = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'}
    _, ext = os.path.splitext(fp)
    return ext.lower() in valid_ext


clip_map = {}
user_tag = "user_"
assets_dir = Path(r"C:/Users/User/Development/moviepy/images")
idx = 0
for p in assets_dir.iterdir():
    if p.is_file():
        if os.path.isfile(p) and looks_like_image(p):
            clip_map[idx] = ImageClip(str(p)).set_duration(2)
            clip_map[idx] = clip_map[idx].fx(vfx.fadeout, 1)
            clip_map[idx] = clip_map[idx].fx(vfx.fadein, 1)
            idx += 1
        else:
            print(f"'{p}' is not an image file (or doesn't exist).")


audio_track = AudioFileClip(r'C:\\Users\\User\\Development\\moviepy\\audio\\KYJellyBabies-AccessDenied.mp3')
title_clip = TextClip(
    "In Loving memory of \nJoe Blocks",
    fontsize=70,
    color='white',
    font='Arial',
    bg_color='transparent'
).set_pos('center').set_duration(5)

combined_clips = [
    title_clip,
    *clip_map,  # <-- проблема: разворачивает КЛЮЧИ словаря
]

try:
    final_cut = concatenate_videoclips(combined_clips, method='compose')
    final_cut.audio = audio_track.subclip(0, final_cut.duration)
    final_cut.write_videofile('final_video.mp4', fps=30)
except Exception as err:
    traceback.print_exc()

Во время выполнения это проявляется так:

AttributeError: 'int' object has no attribute 'duration'

Что на самом деле происходит

Оператор распаковки * ведёт себя по-разному для разных контейнеров. В Python запись *dict_obj возвращает не значения, а ключи. В нашем случае ключи — это целые числа, добавленные в процессе нумерации. Эти числа распаковываются в список клипов и передаются в MoviePy. Функция concatenate_videoclips ожидает, что каждый элемент — объект, похожий на клип, с атрибутом duration. У целого числа такого атрибута нет, отсюда и AttributeError.

Два простых исправления

Если вам действительно нужен словарь, распаковывайте его значения, а не ключи. Это минимальное изменение, которое сохраняет остальной процесс без изменений.

combined_clips = [
    title_clip,
    *clip_map.values(),  # разворачивает объекты ImageClip, а не ключи словаря
]

Либо стройте список с самого начала. Так семантика согласуется с тем, как результат будет использоваться, и вы избегаете случайного разворачивания ключей.

from moviepy.editor import *
from moviepy.config import change_settings
from pathlib import Path
import os
import traceback

change_settings({"IMAGEMAGICK_BINARY": r"C:\\Program Files\\ImageMagick-7.1.2-Q16-HDRI\\magick.exe"})


def looks_like_image(fp):
    valid_ext = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'}
    _, ext = os.path.splitext(fp)
    return ext.lower() in valid_ext


clip_list = []  # используйте список и добавляйте клипы по мере работы
user_tag = "user_"
assets_dir = Path(r"C:/Users/User/Development/moviepy/images")
for p in assets_dir.iterdir():
    if p.is_file():
        if os.path.isfile(p) and looks_like_image(p):
            clip_list.append(
                ImageClip(str(p))
                .set_duration(2)
                .fx(vfx.fadeout, 1)
                .fx(vfx.fadein, 1)
            )
        else:
            print(f"'{p}' is not an image file (or doesn't exist).")


audio_track = AudioFileClip(r'C:\\Users\\User\\Development\\moviepy\\audio\\KYJellyBabies-AccessDenied.mp3')
title_clip = TextClip(
    "In Loving memory of \nJoe Blocks",
    fontsize=70,
    color='white',
    font='Arial',
    bg_color='transparent'
).set_pos('center').set_duration(5)

combined_clips = [
    title_clip,
    *clip_list,
]

try:
    final_cut = concatenate_videoclips(combined_clips, method='compose')
    final_cut.audio = audio_track.subclip(0, final_cut.duration)
    final_cut.write_videofile('final_video.mp4', fps=30)
except Exception as err:
    traceback.print_exc()

Почему эта деталь важна

API вроде MoviePy предполагают, что в передаваемых последовательностях лежат медиа-объекты с ожидаемыми атрибутами и методами. Случайная примесь скаляров из-за распаковки словаря через * ломает эти ожидания и переносит сбой в код библиотеки, из-за чего ошибка выглядит не по адресу. Понимание того, как Python распаковывает итерируемые объекты, помогает избежать таких скрытых несоответствий типов, особенно в пайплайнах, где элементы последовательно компонуются и трансформируются.

Итоги

Перед склейкой явно определяйте, какую последовательность вы собираете. Если используете словарь, там, где нужны объекты, распаковывайте .values(). Если доступ по ключам не требуется, лучше сразу используйте список и добавляйте в него ImageClip по мере генерации. И если что-то пошло не так, полный traceback поможет быстро найти место, где в конвейер попал не-клип. Контроль над тем, что именно вы распаковываете, сэкономит время при следующей сборке клипов в MoviePy.