Create video with Moviepy

起因

最近在学 scrapy 爬虫,顺手从微博上爬了张子枫和谭松韵的微博相片。然后就想,要不就顺手用爬的相片做个视频出来?

需求&思路

我的思路是这样子的:

  1. 这个视频是 1080p 30fps;
  2. 我要给这个视频选一首好听的 BGM,然后每一幅相片都按 BGM 的节拍来显示;
  3. 视频长度和 BGM 长度匹配;
  4. 获取相片并将相片分辨率处理成和视频一样;
  5. 按节拍创建剪辑片段;
  6. 合并剪辑,生成视频

准备工作

  1. 由于我机器上使用的是 python 3 的环境,所以需要有 python 3。
  2. 建议在 python 的虚拟环境中运行这个项目。
  3. 在虚拟环境中安装 moviepy librosa click三个包。
  4. 如果要加字幕,还需要安装 imagemagick
  5. 如果需要对字幕做分词处理,再安装一个 jieba

我用的是 macbook,所有的命令都是在 macos 环境中执行。linux 的过程大同小异,windows 下需要自行搞定 python。

先分析一下最终的目录结构和文件

1
2
3
4
5
6
7
8
9
10
11
.
├── ./createVideoWithMoviepy.py # 这篇要讲的主要的东西
├── ./dist
│   └── ./dist/1353112775.mp4 # 最后生成的视频,脚本会自动创建
├── ./src
│   ├── ./src/font # 如果要加字幕,这里放字体
│   ├── ./src/images -> ~/.../idols/images_origin # 资源源文件目录。这里是一个软链接,下文会解释
│   ├── ./src/imgs # 处理过的图片资源目录,脚本会自动创建
│   ├── ./src/music # BGM 目录
│   └── ./src/subtitles # 如果有字幕,字幕的纯文本文档放在这里
└── ./utils.py # 一些工具脚本

假设 python3 环境已经存在。

1
2
# 创建项目目录
mkdir -p ./{moviepy/dist,moviepy/src/imgs,moviepy/src/music}

可有注意到,这里我没有创建moviepy/src/images这个目录?由于我爬取的相片在另外一个项目目录里idols/images_origin,所以这里将创建一个软链接来指向源目录。

1
2
3
4
# 进入 src 目录并观察创建软链接前后,目录里的变化
cd moviepy/src && ll
ln -s ~/idols/images_origin images
ll

目录有了,接下来准备开发环境。

1
2
3
4
5
6
7
8
9
10
11
# 先确保在项目根目录。约定:以下操作,均约定在我们的 moviepy 项目目录中进行。
cd moviepy

# 创建虚拟环境。由于我的系统中有 python2 和 python3。
python3 -m venv venv

# 激活虚拟环境
. venv/bin/activate

# 如果需要退出虚拟环境,使用下面的指令
# deactivate

接下来,安装我们的依赖moviepy librosa click,另外,可能会需要处理图片,需要额外安装PIL

1
2
3
pip install moviepy librosa click
# 额外安装 PIL
pip install pillow

最后,创建我们的两个脚本文件

1
touch createVideoWithMoviepy.py utils.py

正式开始

获取创建视频的一些参数

:param width: 生成视频的宽度 default: 1920
:param height: 生成视频的高度 default: 1080
:param images_origin: 源图片的路径
:param origin_target_dir: 将源路径替换为目标路径。
这个项目的源路径是爬虫下载图片的路径,最后还要把下载的图片处理成和视频一样的大小存放在目标目录
default: (‘./src/images’, ‘./src/imgs’)
:param music: 背景音乐路径
:param fps: 视频的帧率 default: 30
:param output: 生成视频的文件名

如此,我们先来定义要获取的参数。这里用到了Click,它以简单的交互方式,帮助我在命令行模式下获取需要的参数值。

# createVideoWithMoviepy.py @python3
1
2
3
4
5
6
7
8
9
10
11
# -*- coding: utf-8 -*-
import click

@click.command()
@click.option('--width', prompt='Width', default=1920, help='The width of video clips')
@click.option('--height', prompt='Height', default=1080, help='The height of video clips')
@click.option('--images_origin', prompt='images file', default='./src/images', help='The source images path')
@click.option('--origin_target_dir', prompt='replace origin dir to target dir', default=('./src/images', './src/imgs'), help='how replace origin dir to target dir')
@click.option('--music', prompt='Music file', default='./src/music/1302.mp3', help='The music file')
@click.option('--fps', prompt='video fps ', default=30, help='The output video fps')
@click.option('--output', prompt='Output file', default='./dist/1353112775.mp4', help='The output file name')

获取相片并调整相片大小

# createVideoWithMoviepy.py @python3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# -*- coding: utf-8 -*-
print('>>>>>>>>>>>>>>开始加载库>>>>>>>>>>>>>>')
import os
import math
import click
# 导入两个工具函数来处理图片,这里不展开说明,具体看源码
from utils import resizeImage, readDir

def main(width, height, images_origin, origin_target_dir, music, fps, output):
# 从源目录读取且经处理过的图片,将会保存到这个目录中,为了方便处理,这里尽量和源目录的结构保持一致
target_images_dir = images_origin.replace(origin_target_dir[0], origin_target_dir[-1])

# 如果目标目录不存在,则从源目录读取图片并进行处理,最后保存到目标目录
if not os.path.exists(target_images_dir):
filesPath = readDir(images_origin)
print('>>已载入文件 %s 个' % len(filesPath))
print('>>>>>>>>>>>>>>开始调整图片大小>>>>>>>>>>>>>>')
for img in filesPath:
resizeImage(img, origin_target_dir, (width, height))

# 从目标目录中读取图片路径到一个列表里
print('>>>>>>>>>>>>>>开始读取调整过的图片>>>>>>>>>>>>>>')
filesPath = readDir(images_origin.replace(origin_target_dir[0], origin_target_dir[-1]))
print('>>已载入文件 %s 个' % len(filesPath))

# 如果文件列表长度为 0,则退出整个程序
if len(filesPath) == 0:
print('>>请检查源目录 %s 下是否有图片。并且删除 %s 目录' % (images_origin, origin_target_dir[-1]))
os._exit(0)

使用 librosa 分析 BGM 的节拍

# createVideoWithMoviepy.py @python3
1
2
3
4
5
6
7
import librosa

print('>>>>>>>>>>>>>>开始分析节拍>>>>>>>>>>>>>>')
y, sr = librosa.load(music, sr=None)
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
beat_times = list(librosa.frames_to_time(beats, sr=sr))
beat_times.append(beat_times[-1] + 1) # 增加一个空节拍点位,先记着有这么一回事,下面会讲

创建视频剪辑(Clip)前的一些小准备

先简单说一下视频/动画的小原理,我们看到的每一秒画面,一般都是由很多帧组成的。在这个项目里,一秒画面有 30 帧(30fps)。而一个动作的连续画面,至少要有头尾两个动作关键帧。但是现在只有单幅的相片,表现不出一个完整的动作,所以我们最终的成品会是一个幻灯片。😅

到这里就大概清楚了,如果要让一幅照片播放一秒,那就需要在这一秒里插入 30 帧。因为是单幅,所以这 30 帧就是这幅照片重复 30 次。

项目要求我们按照节拍来显示相片,但是两个节拍之间的间隔可能不到一秒,所以相片的帧数可能有多有少,不一定是 30 帧。 而且一秒的画面停留时间太短,也就是一闪而过的效果,根本看不清,那怎么办呢?

#createVideoWithMoviepy.py @python3
1
2
3
4
5
6
7
8
9
10
11
clips = [] # 创建一个空的 list,用来存放所有的剪辑
audio_time = librosa.get_duration(filename=music)
print('>>音频时长(s):%f >>节拍数量:%s' % (audio_time, len(beat_times)))
'''
计算节拍数量和相片数量的差值比例,以计算需要在每幅相片后补足多少帧,
这里计算的差值,是为了让相片能补足节拍的数量,多跨几个节拍,让每一幅相片停留更长时间。
另外,这个算法有 bug,下面再说。
'''
interval = math.ceil(abs( len(beat_times) / len(filesPath) ))
filesPath = [f for f in filesPath for i in range( interval )]
print('>>新的文件列表长度:%s' % len(filesPath))

按节拍插入帧

# createVideoWithMoviepy.py @python3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
print('>>>>>>>>>>>>>>开始按节拍生成视频帧>>>>>>>>>>>>>>')
for index, beat_time in enumerate(beat_times[:-1]):
if index >= len( filesPath):
print('>>图片数量不足以匹配节拍,中止匹配。输出的视频后段可能会出现黑屏。')
print('>>图片数量:{0} >节拍数量:{1}'.format(len( filesPath), len(beat_times)))
break
print(f'{index + 1}/{len(beat_times)}>>{ filesPath[index]}')
'''
说下 time_deff 的作用:
因为每个节拍间的间隔时间非常短,所以计算节拍间的间隔再乘以帧率,
得出这幅画面在这个节拍应该停留的时间
'''`
time_diff = math.modf(beat_time - beat_times[index -1])
time_diff = math.ceil(time_diff[0]*10) if (time_diff[0] * 10) > time_diff[-1] else math.ceil(time_diff[-1])
image_clip = (ImageClip(filesPath[index], duration=abs(time_diff)*fps)
.set_fps(abs(time_diff)*fps)
.set_start(beat_time)
.set_end(beat_times[index + 1])) #还记得上面增加的那个空节拍点位么?需要给最后一帧一个结束时间,所以这里增加了一个空点位来处理
image_clip = image_clip.set_pos('center')
# 两幅不同的相片间的过渡方式,duration 的值可以随意更改,不过可能会增加视频压制的时间
if index % interval == 0:
image_clip = image_clip.fx(vfx.fadein, duration=0.5)
clips.append(image_clip)

合并剪辑,生成视频

终于到最后一步了!

# createVideoWithMoviepy.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
print('>>>>>>>>>>>>>>开始合并剪辑,生成视频>>>>>>>>>>>>>>')
final_clip = CompositeVideoClip(clips) # 合并所有视频剪辑
audio_clip = AudioFileClip(music) # 创建一个音频剪辑
final_video = final_clip.set_audio(audio_clip) # 加入音频剪辑
# 最后写入视频文件,moviepy 会调用 ffmpeg 来压制最后的视频,没有这个包,也得装上
final_video.write_videofile(
output, # 输出路径和文件名
fps=fps, # 帧率
codec='mpeg4', # 视频编码
# ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo
preset='medium', # 压缩速度
audio_codec="libmp3lame", # 音频编码,适用 .mp3
threads=4, # 压缩线程
bitrate ='6000k') # 视频比特率,1080p 30fps 的动态比特率是 4000k~6000k

一个问题

还记得上面提到的那个 bug 吗?
这里面有一些问题,如果图片数量多于节拍数?如果补齐节拍数量后的图片 list length 多于节拍数?
如何确定匹配 bgm 节拍的最佳图片数量和帧率?

总结

这个脚本问题还是挺多的,但是完成了我最初的要求。唯一不满意的就是这是一个加了 BGM 的幻灯片,我下次还是爬视频吧。

moviepy 做逐帧动画不是强项,效率低于 opencv,但是如果想要批量修改一些比较相似的视频剪辑,还是可以用的。这货有一个大缺点,就是太费内存了,文档也不够友好。但是,好用就够了。

librosa 这个库也很强大,能够各种分析音频,对声音领域有要求的小伙伴可以试试。

大家关心的源码,Create video with Moviepy

在 vue-element-admin 怎样动态生成路由配置?

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×