FFmpegの動画スライス手法を調査した
NTT東日本の中村です。
フォトグラメトリやディープラーニングの素材集め、ラズパイでの利用等で動画から静止画を切り出したいケースなどにFFmpegを使い、動画から静止画をスライス(切り出し)したいことが多々あると思うのですが、コマンドによりスライス速度が大幅に異なるようです。
調査の前提
「FFmpegで、動画から一定の秒数毎に静止画をスライスする手法」で、より高速な手法を調査するのが目的です。
今回テストを行った環境です。
- EC2(t3.medium)
- AMI:al2023-ami-2023.4.20240429.0-kernel-6.1-x86_64
- FFmpeg
[ec2-user@ip-172-31-41-66 videoCutter]$ ffmpeg --version ffmpeg version 7.0.1-static https://johnvansickle.com/ffmpeg/ Copyright (c) 2000-2024 the FFmpeg developers built with gcc 8 (Debian 8.3.0-6) configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gmp --enable-libgme --enable-gray --enable-libaom --enable-libfribidi --enable-libass --enable-libvmaf --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libdav1d --enable-libxvid --enable-libzvbi --enable-libzimg
FFmpegのスライス方法
そもそも動画から静止画を切り出すのもFFmpegの機能の一つですが、オプションにより様々な切り出し方を選択できます。 手法を3つほどご紹介します。
-filter:vのスライス
webで調べると、最も良く出てくる手法です。ワンライナーで実行できるので簡単です。 ここでは、秒間30フレームの動画を、10秒毎に静止画でスライスし、連番でjpg出力しています。
ffmpeg -i test.mp4 -filter:v fps=1/10 ./output1/frame_%04d.jpg
-filter:vのオプションは、動画の特定のタイミングをフィルタリングして処理を行うので、コマンドを実行すると、バックグラウンドで動画の頭からスキャンしつつ、フィルタリング条件のタイミングでスキャンを行います。 フィルタリングの1フレームを10秒と定義し、10秒ごとに静止画を出力し、出力フォーマットは連番としています。
最初の切り出しタイミングは指定秒数の半分から始まり、5秒目になります。
問題無くスライスはできるのですが、スライス完了までにかなりの時間が掛かるのがネックです。
-ssのシークによるスライス
FFmpegについてもう少し調べると、動画をシークして切り出した方が高速化出来るような記事や投稿があります。
ffmpeg -ss 5 -i test.mp4 -frames:v 1 frame_0001.jpg ffmpeg -ss 15 -i test.mp4 -frames:v 1 frame_0002.jpg ffmpeg -ss 25 -i test.mp4 -frames:v 1 frame_0003.jpg ffmpeg -ss 35 -i test.mp4 -frames:v 1 frame_0004.jpg ffmpeg -ss 45 -i test.mp4 -frames:v 1 frame_0005.jpg ffmpeg -ss 45 -i test.mp4 -frames:v 1 frame_0006.jpg
-ssの後に秒数を指定することで、ピンポイントで動画の編集地点を指定して静止画をスライスするため、動画のスキャンを行うステップが無く、高速になります。 -frames:v 1は、指定の地点から1枚だけビデオフレームを抽出します。 このコマンドを、スライスの枚数だけ、個別に実行します。
動画スキャンのステップが無くなるため、非常に高速になります。
-ssのシーク+入力データのキャッシュによるスライス
シークによるスライスを、一つのコマンドでまとめて実行する試みです。
このIssueはfluent-ffmpegという、nodeJsでFFmpegを簡単に使用するためのライブラリのものですが、内部で動作するffMpegで、高速でスナップショットを取る方法が議論されていました。
入力データを再利用するために、複数のI/Oをワンライナーで実行し、入力データ読み込みのオーバーヘッドを減らしています。
同様のスライスを再現すると、このようになります。
ffmpeg -ss 5 -i test.mp4 -frames:v 1 -map 0:v:0 frame_0001.jpg \ -ss 15 -i test.mp4 -frames:v 1 -map 1:v:0 frame_0002.jpg \ -ss 25 -i test.mp4 -frames:v 1 -map 2:v:0 frame_0003.jpg \ -ss 35 -i test.mp4 -frames:v 1 -map 3:v:0 frame_0004.jpg \ -ss 45 -i test.mp4 -frames:v 1 -map 4:v:0 frame_0005.jpg \ -ss 55 -i test.mp4 -frames:v 1 -map 5:v:0 frame_0006.jpg
-mapというオプションを追加し、「n番目のvideo streamを、0番目の出力(各I/Oの出力ファイル名)にマッピングする」処理を追加しています。 各コマンドで、毎回入力ファイル名は書く必要があるようです。 I/Oは幾つも連結することがありますが、コマンドを増やすとメモリが圧迫されるため、処理を行う端末の性能に合わせ、「幾つまでコマンドを連結するか」調整が必要です。
下の例では、3つのI/Oを連結したFFmpegコマンドを、2セットで実行しています。
調査してみた
短い動画、長い動画の2つを用意し、上記の3つの手法で速度の違いを調査しました。
スクリプト
10秒毎にスライスを行う設定で、3種類のスライスをテストします。
- シークを使用したテストを行う場合、事前に動画の長さを取得し、何枚の静止画をスライスするかを逆算する必要があるため、ffprobeで動画の長さを取得しています。
- テスト環境でのTest3の入力キャッシュは、同時に列挙するI/Oの数を、4K動画を処理する際、処理落ちしない7に調整しています。 ここはCPU、メモリ、動画の大きさにも関係するようです。
import fs from "fs"; import { test2 } from "./test/test2"; import { test1 } from "./test/test1"; import { clearOutputFolder, createOutputFolder } from "./test/common"; import { test3 } from "./test/test3"; export const perSecond = 10; export const localVideoKey = "test_long.mp4"; export const timeList: string[] = []; function main() { clearOutputFolder(); createOutputFolder(); // Test1 -filter:vのスライス test1(); // Test2 -ssのシークによるスライス test2(); // Test3 -ssのシーク+入力データのキャッシュによるスライス test3(); // output result timeList.map((e) => console.log(e)); } main();
import { spawnSync } from "child_process"; import { localVideoKey, perSecond, timeList } from "../loopSlice"; function generateStaticImage(localVideoKey: string): boolean { const command = "ffmpeg"; const args = [ "-i", localVideoKey, "-filter:v", `fps=1/${perSecond}`, "./output1/frame_%04d.jpg", ]; console.log(`${command} ${args.join(" ")}`); const { status, error } = spawnSync(command, args, { // stdio: "inherit", }); if (error) { console.error(`Error generating image: ${error}`); return false; } return status === 0; } export function test1() { timeList.push(`test1 開始: ${new Date().toISOString()}`); generateStaticImage(localVideoKey); timeList.push(`test1 終了: ${new Date().toISOString()}`); }
import { spawnSync } from "child_process"; import { getVideoDuration } from "./common"; import { localVideoKey, perSecond, timeList } from "../loopSlice"; function generateStaticImage( timestamp: number, localVideoKey: string, outputFileName: string, ): boolean { const command = "ffmpeg"; const args = [ "-ss", timestamp.toString(), "-i", localVideoKey, "-frames:v", "1", // "-map", // 設定しても、有意な差が発生しない // "0:v:0", outputFileName, ]; const { status, error } = spawnSync(command, args, { // stdio: "inherit", }); if (error) { console.error(`Error generating image: ${error}`); return false; } console.log(`generated: ${timestamp} seconds: ${outputFileName}`); return status === 0; } export function test2() { timeList.push(`test2 開始: ${new Date().toISOString()}`); //ビデオの秒数取得 const videoDuration = getVideoDuration(localVideoKey); console.log(`Video duration: ${videoDuration} seconds`); try { for (let i = 0; i * perSecond <= videoDuration; i++) { const outputFileName = `output2/frame_${("0000" + (i + 1)).slice( -4, )}.jpg`; const timestamp = i * perSecond; const success = generateStaticImage( timestamp, localVideoKey, outputFileName, ); if (!success) { // ffmpegの処理が失敗した場合、終了 throw new Error("FFmpeg processing failed"); } } console.log("変換完了"); } catch (e) { console.log(e); } timeList.push(`test2 終了: ${new Date().toISOString()}`); }
import { spawnSync } from "child_process"; import { getVideoDuration } from "./common"; import { localVideoKey, perSecond, timeList } from "../loopSlice"; function generateStaticImage(parameterStringList: string[][]): boolean { const command = "ffmpeg"; const args = parameterStringList.flat(2); console.log(`${command} ${args.join(" ")}`); const { status, error } = spawnSync(command, ["-nostdin", "-y", ...args], { // stdio: "inherit", }); if (error) { console.error(`Error generating image: ${error}`); return false; } return status === 0; } export function test3() { const divider = 7; timeList.push(`test3 開始: ${new Date().toISOString()}`); //ビデオの秒数取得 const videoDuration = getVideoDuration(localVideoKey); console.log(`Video duration: ${videoDuration} seconds`); try { const parameterStringList: string[][] = []; for (let i = 0; i * perSecond <= videoDuration; i++) { const outputFileName = `output3/frame_${("0000" + (i + 1)).slice( -4, )}.jpg`; parameterStringList.push( `-ss ${i * perSecond} -i ${localVideoKey} -frames:v 1 -map ${ i % divider }:v:0 ${outputFileName}`.split(" "), ); } for (let i = 0; i < parameterStringList.length; i += divider) { const success = generateStaticImage( parameterStringList.slice(i, i + divider), ); if (!success) { // ffmpegの処理が失敗した場合、終了 throw new Error("FFmpeg processing failed"); } } console.log("変換完了"); } catch (e) { console.log(e); } timeList.push(`test3 終了: ${new Date().toISOString()}`); }
import { spawnSync } from "child_process"; export function getVideoDuration(localVideoKey: string): number { const command = "ffprobe"; const args = [ "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", localVideoKey, ]; const { stdout, stderr } = spawnSync(command, args); if (stderr.length > 0) { console.error(`Error getting video duration: ${stderr.toString()}`); return 0; } return parseFloat(stdout.toString()); } export function clearOutputFolder() { ["output1", "output2", "output3"].forEach((folderPath) => { const command = "rm"; const args = ["-rf", folderPath]; const { stdout, stderr } = spawnSync(command, args); if (stderr.length > 0) { console.error(`Error clearing folder: ${stderr.toString()}`); } }); } export function createOutputFolder() { ["output1", "output2", "output3"].forEach((folderPath) => { const command = "mkdir"; const args = ["-p", folderPath]; const { stdout, stderr } = spawnSync(command, args); }); }
短い動画(HD動画、80MB)
3分のストップウォッチ動画を用意しました。 10秒ごとにスライスを行い、18枚程度の静止画を出力します。
- 3分
- 1280x720
- h264 60FPS
- 80MB
ffprobe version 7.0.1-static https://johnvansickle.com/ffmpeg/ Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'test.mp4': Metadata: major_brand : mp42 minor_version : 1 compatible_brands: isommp41mp42 creation_time : 2024-06-14T01:37:37.000000Z Duration: 00:03:00.20, start: 0.000000, bitrate: 3593 kb/s Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1280x720, 3588 kb/s, SAR 1:1 DAR 16:9, 59.94 fps, 59.94 tbr, 60k tbn (default) Metadata: creation_time : 2024-06-14T01:37:37.000000Z handler_name : Core Media Video vendor_id : [0][0][0][0]
手法1,2,3を実施して、経過時間をチェックします。
$ npx ts-node loopSlice.ts
実行結果
3つのスライス手法を5回ずつ検討し、平均を求めました。
-filter:vのスライス
N回目 | 開始 | 終了 | 経過秒数 |
---|---|---|---|
1 | 2024-06-16T23:29:33.777Z | 2024-06-16T23:29:52.957Z | 19.180 |
2 | 2024-06-16T23:31:28.508Z | 2024-06-16T23:31:48.279Z | 19.771 |
3 | 2024-06-16T23:32:02.909Z | 2024-06-16T23:32:22.978Z | 20.069 |
4 | 2024-06-16T23:32:56.283Z | 2024-06-16T23:33:15.980Z | 19.697 |
5 | 2024-06-16T23:36:18.692Z | 2024-06-16T23:36:36.736Z | 18.044 |
-ssのシークによるスライス
N回目 | 開始 | 終了 | 経過秒数 |
---|---|---|---|
1 | 2024-06-16T23:29:52.957Z | 2024-06-16T23:29:55.017Z | 2.060 |
2 | 2024-06-16T23:31:48.279Z | 2024-06-16T23:31:50.279Z | 2.000 |
3 | 2024-06-16T23:32:22.979Z | 2024-06-16T23:32:24.886Z | 1.907 |
4 | 2024-06-16T23:33:15.980Z | 2024-06-16T23:33:17.872Z | 1.892 |
5 | 2024-06-16T23:36:36.737Z | 2024-06-16T23:36:38.610Z | 1.873 |
-ssのシーク+入力データのキャッシュによるスライス
N回目 | 開始 | 終了 | 経過秒数 |
---|---|---|---|
1 | 2024-06-16T23:29:55.017Z | 2024-06-16T23:29:56.727Z | 1.710 |
2 | 2024-06-16T23:31:50.279Z | 2024-06-16T23:31:51.923Z | 1.644 |
3 | 2024-06-16T23:32:24.886Z | 2024-06-16T23:32:26.496Z | 1.610 |
4 | 2024-06-16T23:33:17.872Z | 2024-06-16T23:33:19.531Z | 1.659 |
5 | 2024-06-16T23:36:38.611Z | 2024-06-16T23:36:40.115Z | 1.504 |
平均秒数の比較
テスト | 平均経過秒数 |
---|---|
-filter:v | 19.152 |
-ss | 1.958 |
-ss + キャッシュ | 1.625 |
filter:vのスライスに比べて、ssによるスライスが非常に高速に完了することが分かります。 最も早いのが-ss + キャッシュのオプションを使用した場合、という結果になりました。
長い動画(4K動画、15GB)
15GBの4K撮影動画を用意し、同様にスライスをテストしました。 10秒ごとにスライスを行い、48枚程度の静止画を出力します。
- 8分
- 3840x2160(4K)
- h264 30fps
- 15GB
ffprobe version 7.0.1-static https://johnvansickle.com/ffmpeg/ Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'test_long.mp4': Metadata: major_brand : isom minor_version : 512 compatible_brands: isomiso2avc1mp41 encoder : Lavf57.25.100 Duration: 00:08:05.13, start: 0.000000, bitrate: 259875 kb/s Stream #0:0[0x1](und): Video: h264 (Main) (avc1 / 0x31637661), yuv420p(tv, bt709/bt709/unknown, progressive), 3840x2160, 259752 kb/s, SAR 1:1 DAR 16:9, 30 fps, 30 tbr, 15360 tbn (default) Metadata: handler_name : VideoHandler vendor_id : [0][0][0][0] Stream #0:1[0x2](und): Audio: aac (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 117 kb/s (default) Metadata: handler_name : SoundHandler vendor_id : [0][0][0][0]
実行結果
同様に5回ずつテストし、平均を求めました。
-filter:vのスライス
N回目 | 開始 | 終了 | 経過秒数 |
---|---|---|---|
1 | 2024-06-16T23:52:06.953Z | 2024-06-17T00:08:52.191Z | 1005.238 |
2 | 2024-06-17T00:12:37.449Z | 2024-06-17T00:29:29.718Z | 1012.269 |
3 | 2024-06-17T00:41:10.096Z | 2024-06-17T00:58:24.651Z | 1034.555 |
4 | 2024-06-17T01:01:07.195Z | 2024-06-17T01:17:51.453Z | 1004.258 |
5 | 2024-06-17T01:22:27.818Z | 2024-06-17T01:39:26.730Z | 1018.912 |
-ssのシークによるスライス
N回目 | 開始 | 終了 | 経過秒数 |
---|---|---|---|
1 | 2024-06-17T00:08:52.200Z | 2024-06-17T00:09:33.189Z | 40.989 |
2 | 2024-06-17T00:29:29.718Z | 2024-06-17T00:30:12.995Z | 43.277 |
3 | 2024-06-17T00:58:24.651Z | 2024-06-17T00:59:05.963Z | 41.312 |
4 | 2024-06-17T01:17:51.453Z | 2024-06-17T01:18:33.246Z | 41.793 |
5 | 2024-06-17T01:39:26.730Z | 2024-06-17T01:40:07.610Z | 40.88 |
-ssのシーク+入力データのキャッシュによるスライス
N回目 | 開始 | 終了 | 経過秒数 |
---|---|---|---|
1 | 2024-06-17T00:11:37.996Z | 2024-06-17T00:12:16.699Z | 38.703 |
2 | 2024-06-17T00:30:12.995Z | 2024-06-17T00:30:50.497Z | 37.502 |
3 | 2024-06-17T00:59:05.963Z | 2024-06-17T00:59:43.787Z | 37.824 |
4 | 2024-06-17T01:18:33.246Z | 2024-06-17T01:19:10.513Z | 37.267 |
5 | 2024-06-17T01:40:07.611Z | 2024-06-17T01:40:44.962Z | 37.351 |
平均秒数の比較
テスト | 平均経過秒数 |
---|---|
-filter:v | 1015.0464 |
-ss | 41.6502 |
-ss +キャッシュ | 37.7294 |
こちらでも、-ss+キャッシュによるスライスが、最も速度が向上することが分かりました。
まとめ
- 動画から連続した静止画をスライスする場合、filter:vオプションではなく、ssオプションで編集位置を指定してスライスすると、高速化できる
- 入力データをキャッシュすることで、更に高速化が望める
- キャッシュする手法はマシンの性能により、チューニングを行う必要がある
Lambda等でスライスを行い、実行時間を気にする際、この手法の選択で実行時間が20倍近く向上するために、是非覚えておきたい手法だと思いました。 (最も、10GBを超えた動画は、そのままではLambdaでは取り扱えなさそうですが・・・)