yyhhyy's blog

yyhhyy

解决Moviepy剪辑视频画面卡帧,但有声的问题

2024-08-01

1. 起因

最近在做视频批量化处理,调研要么用moviepy,要么用ffmpeg。后面选择用moviepy,因为上手更简单一点。

但是在根据逻辑(删除视频的前两秒和后两秒,只保留中间部分)进行裁剪时,发现只要经过裁剪的视频,都会出现一个问题: 前1s左右的画面能而动,但是后面的视频画面不动,但是声音继续播放

这是非常致命的,这相当于就是把整个视频毁了,因此需要排查解决。

2. 思考过程

先简单看一下我写的代码

        # 根据逻辑裁剪视频
        if duration >= 6:
            logger.info(f"视频 {video_file} 大于6秒,前后裁剪2秒。")
            # 计算裁剪的起始和结束时间
            start_time = 2
            end_time = duration - 2
            new_clip = clip.subclip(start_time, end_time)
            # 处理裁剪后还是大于10s的视频
            if new_clip.duration >= 10:
                print(f"视频 {video_file} 大于10秒,加速1.2倍。")
                new_clip = speedx(new_clip, factor=1.2)

        else:
            # 小于6秒的视频不进行裁剪
            new_clip = clip

        # 保存裁剪后的视频
        new_clip.write_videofile(video_file, remove_temp=True, fps=clip.fps)

可以看到 有两个地方会涉及到视频的处理:

  1. 视频时长 duration >= 6 大于等于6s

  2. 裁剪后的时长 new.clip.duration >= 10 还是大于等于10s,那么就对视频进行加速处理

2.1 思考一

因为出现“卡帧”的情况最早是挺随机的,只在后面加速视频的时候出现,我就先删除这部分逻辑,也就仅保留

         # 根据逻辑裁剪视频
        if duration >= 6:
            logger.info(f"视频 {video_file} 大于6秒,前后裁剪2秒。")
            # 计算裁剪的起始和结束时间
            start_time = 2
            end_time = duration - 2
            new_clip = clip.subclip(start_time, end_time)

        else:
            # 小于6秒的视频不进行裁剪
            new_clip = clip

        # 保存裁剪后的视频
        new_clip.write_videofile(video_file, remove_temp=True, fps=clip.fps)

但是发现,我这么改了以后,之前裁剪的视频,也一样会出现“卡帧”的情况。

因此,这部分肯定不是问题所在。

2.2 思考二

后面我就在想,是不是moviepy这个库的的问题,于是我就想,直接换成ffmpeg来处理

        cmd = [
            "ffmpeg",
            "-y",  # 自动确认覆盖输出文件
            "-i",
            input_file,
            "-ss",
            str(2),  # 起始时间
            "-t",
            str(duration - 4),  # 持续时间
            "-c:v",
            "-c:a",
            "-strict",
            output_file,
        ]

此时,我以为解决了,满怀信心的运行,发现出来的视频还是一样的卡帧= =

至此,陷入了一个死循环,本身我是知道moviepy底层其实调用的也是ffmpeg,我以为是moviepy封装ffmpeg的时候导致的一些问题,但是现在看来不是,因为我直接使用ffmpeg,还是一样的问题。因此,可以断定,问题出在ffmpeg上面。

开始寻找解决方案,功夫不负有心人,终于看到了一篇blog!并且其附带了一个视频~ 因此,在看了blog和视频后,终于终于解决方案出来了!

3.解决方案

重点如下:

因为I帧的关系,视频解码时从I帧开始的,如果你的开始时间点不是I帧,则只先解码音频,等到下一个I帧时间点时,开始播放视频,之前卡的那个画面也是下一个I帧。可以用aegisub看I帧,知道前后I帧的位置,决定从哪个I帧开始截取,或者把-ss写在-i前面,但此时不能用-to,只能用-t。

讲这么多,其实我也不懂前半段句是什么意思。后面简单看了看文档,总结概括如下:

视频帧类型:
在视频压缩中,帧被分为不同类型,主要有三种:I帧(关键帧)、P帧(预测帧)和B帧(双向预测帧)。
I帧是自包含的帧,不依赖于其他帧的信息,是视频解码的起点和参考点。P帧和B帧则依赖于其他帧的信息。

视频解码的开始:
当解码器开始解码视频流时,它必须从一个I帧开始。如果你指定的起始时间点不是一个I帧,解码器会等待直到下一个I帧出现才开始解码视频部分。
在等待期间,音频部分会继续解码和播放,这会导致画面停滞在上一个I帧的图像。

简单来说 I帧非常重要 裁剪必须是他起手,但是在我这种设定下,很明显 不好实现。

再看后半句,总结了一下:

要避免卡帧,需要确保 -ss 参数指定的时间点正好是一个I帧的位置。
可以通过使用 aegisub 或者查阅视频信息来确定合适的起始时间点。 
如果无法确保,可以通过调整 -ss 和 -t 参数的组合来控制视频裁剪的时长,以确保视频从一个完整的I帧开始解码。

我并不想去确认关键帧的位置,因为他相对来说 比较麻烦。

一开始的指令就是 把 -ss 放在 -t 前面 发现其实并不管用,因此 这个方法只能算放弃了= =

但是但是,在这个方法的启发下,我突然意识到,如果保存的时候,我重新编码,是不是就能解决

因此,最终的解决方案如下:

        # 导出处理后的视频(重新编码)
        new_clip.write_videofile(
            output_file,
            codec="libx264",
            audio_codec="aac",
            temp_audiofile="temp-audio.m4a",
            remove_temp=True,
            fps=clip.fps,
            preset="medium",  # 可以根据需要调整,如 "fast", "slow" 等
            ffmpeg_params=["-crf", "0"],  # 控制质量,值越低质量越高,0表示完整保留视频画质,不压缩
        )

终于终于,解决了!!!!

其实核心在

  1. crf 是ffmpeg中控制视频编码质量的参数,0 表示无损编码。当设置为0时,每一帧都会被认为是关键帧(I帧),因为无损编码要求每一帧都能独立解码,不依赖于其他帧的信息。

  2. 当调用 write_videofile 并指定 codec="libx264"audio_codec="aac" 时,moviepy 将使用 libx264 编码器重新对视频进行编码,同时将音频编码为 AAC 格式。

  3. 在重新编码过程中,ffmpeg会生成新的关键帧序列,确保新输出的视频从一个I帧开始,避免了旧视频中可能存在的不完整的关键帧问题。

到这里所有问题都解决啦,但是其中还有需要注意点

虽然这种方法解决了卡帧问题,但重新编码可能会导致一定的质量损失,并且会消耗更多的处理时间和计算资源。因此,建议在选择重新编码作为解决方案时,权衡好输出质量和性能需求。