FFmpegの動画スライス手法を調査した

2024.06.17

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、メモリ、動画の大きさにも関係するようです。

loopSlice.ts

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();

test1.ts

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()}`);
}

test2.ts

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()}`);
}

test3.ts

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()}`);
}

common.ts

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では取り扱えなさそうですが・・・)