ffmpegをRustから子プロセス実行して、それっぽくmp4エンコーダを書く

この記事は[Rust Advent Calendar 2019](https://qiita.com/advent-calendar/2019/rust)の5日目の記事です。

 + 4日目はこちら→[crates.ioにクレートを公開するまで - Qiita](https://qiita.com/tasshi/items/169c372b5230e0bd8b14)
 + 6日目はこちら→[itertoolsの紹介 | κeenのHappy Hacκing Blog](https://keens.github.io/blog/2019/12/06/itertoolsnoshoukai/)

こんにちは、@tasshiです。
5日目が忘れ去られていたので代打で小ネタを書きます。

# 概要
子プロセスでffmpegを起動して、コマ送り画像をmp4ファイルに変換します。

サンプルコードはこちら
https://github.com/tasshi-playground/rust-encode-mp4-with-ffmpeg-wrapper

# 背景
### Rustでmp4のエンコードがしたかった
Rustで画像の配列を生成して、mp4の動画ファイルに変換しようと思いました。
時系列に並んだアメダスの画像とか、そういうやつです。

Rustには[ffmpeg-sys](https://crates.io/crates/ffmpeg-sys)を筆頭として、ffmpegのFFIクレートがいくつかありますから、それを使えば簡単にできるはずでした。

### ffmpeg-sysがビルドできない
```
error: aborting due to 77 previous errors

Some errors have detailed explanations: E0277, E0369.
For more information about an error, try `rustc --explain E0277`.
error: could not compile `ffmpeg-sys`.
```

[ffmpeg-sys](https://crates.io/crates/ffmpeg-sys)がビルドできませんでした。lib***系を何か入れればよかったのかもしれません。
しかし色々試す前に気が変わっていました。

**「ffmpegでmp4に変換するだけだったら、FFIじゃなくてもよくない？？？？」**

# それっぽくffmpegエンコーダをつくろう

FFIでライブラリAPIを使うのは諦めて、子プロセスとしてffmpegを実行します。

参考記事：[cairoのrustバインディングとffmpegで2Dアニメ作成ツールを作ってみた - Qiita](https://qiita.com/tetsu_koba/items/5304ba0e674ed312f7ea)

## 画像の配列をmp4に変換する関数を作る

参考記事を元に`Vec<image::DynamicImage>`からmp4ファイルを作る関数を書いてみます。

```rust:main.rs
use image::{DynamicImage, GenericImageView};
use std::io::Write;
use std::process::{Command, Stdio};

fn encode_frames_to_mp4(name: &str, frames: &[DynamicImage]) {
    // ffmpegを実行するためにコマンドを組み立てる
    let command = |width, height, output| {
        format!(
        "ffmpeg -f rawvideo -pix_fmt rgba -s {width}x{height} -i - -pix_fmt yuv420p -vcodec libx264 -movflags faststart {output:?}",
        width=width, height=height, output=output
    )
    };

    let (width, height) = frames.first().unwrap().dimensions();

    // ffmpegを実行
    let mut ffmpeg = Command::new("/bin/sh")
        .args(&["-c", &command(width, height, name)])
        .stdin(Stdio::piped())
        .spawn()
        .unwrap();

    {
        // 標準入力にフレームのピクセルを流し込む
        let stdin = ffmpeg.stdin.as_mut().unwrap();
        for frame in frames {
            stdin.write_all(&frame.raw_pixels()).unwrap();
        }
        // ここでスコープを抜けるのでstdinがdropされる
    }

    // ffmpeg側の終了を待つ
    ffmpeg.wait().unwrap();
}
```

### ffmpegの実行について

ffmpegの実行コマンドとオプションは以下のとおりです。
`width`, `height`, `output`のみ無名関数内で後から代入します。

```sh:コマンド
$ ffmpeg -f rawvideo -pix_fmt rgba -s {width}x{height} -i - -pix_fmt yuv420p -vcodec libx264 -movflags faststart {output:?}
```

|オプション名|意味|
|---|---|
|-f rawvideo|入力フォーマットに固定長（ピクセル数xカラー）の画像生データを指定|
|-pix_fmt rgba|入力のカラーフォーマットにRGBAを指定 |
|-s {width}x{height}| 入力の幅（width）と高さ(height)を指定|
|-i -|入力のソースに標準入力を指定|
|-pix_fmt yuv420p|出力のカラーフォーマットに`yuv420p`を指定|
| -vcodec libx264|出力のコーデックにh264を指定|
|-movflags faststart|プログレッシブダウンロード対応にする（メタデータがファイル先頭になる）|

参考記事：[それFFmpegで出来るよ！ - Qiita](https://qiita.com/cha84rakanal/items/e84fe4eb6fbe2ae13fd8#%E3%82%B1%E3%83%BC%E3%82%B93%E9%80%A3%E7%95%AA%E7%94%BB%E5%83%8F%E3%81%8B%E3%82%89%E5%8B%95%E7%94%BB%E3%82%92%E4%BD%9C%E3%82%8A%E3%81%9F%E3%81%84)
参考記事：[プログレッシブダウンロード対応の MP4ファイル を作成する :Tips & FAQ | arbk-works Blog](https://aruo.net/arbk/blog/article/techclip_how2make_mp4_video_file_for_progressive_download_with_ffmpeg_or_mp4box)

## 使い勝手をよくする
これで連番画像を入力すると動画のmp4ファイルが保存される関数ができました。
しかし、このままだと画像をすべて配列に保存してから変換する必要があるため、いくつか問題があります。

 + メモリ使用量が画像の枚数に応じて増加する
 + イテレータによる逐次処理を生かせない

これらを解決するために、子プロセスの開始・終了と、画像の変換処理とを別の関数に分離します。

### Encoder構造体の定義

[image::gif::Encoder](https://docs.rs/image/0.23.0-preview.0/image/gif/struct.Encoder.html)を参考にして、mp4::Encoder構造体とそのメソッドを作ります。

参考記事：

```rust:mp4.rs
use crate::error::Error;

use image::{DynamicImage, GenericImageView};
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};

/// MP4 encoder.
pub struct Encoder<P: AsRef<Path>> {
    p: P,
    ffmpeg: std::process::Child,
    width: u32,
    height: u32,
    framerate: u32,
}

impl<P: AsRef<Path>> Encoder<P> {
    /// Creates a new MP4 encoder.
    pub fn new(path: P, width: u32, height: u32, framerate: u32) -> Result<Encoder<P>, Error> {
        let name = path.as_ref();

        // ffmpegを実行するためにコマンドを組み立てる
        let command = |width, height, framerate, output| {
            format!(
            "ffmpeg -framerate {framerate} -f rawvideo -pix_fmt rgba -s {width}x{height} -i - -pix_fmt yuv420p -vcodec libx264 -movflags faststart {output:?}",
            width=width, height=height, framerate=framerate, output=output
        )
        };

        // ffmpegを実行
        let ffmpeg = Command::new("/bin/sh")
            .args(&["-c", &command(width, height, framerate, name)])
            .stdin(Stdio::piped())
            .spawn()?;

        // 返り値のEncoder構造体は、実行中のffmpegプロセスのハンドラなどを含む
        Ok(Encoder {
            p: path,
            ffmpeg: ffmpeg,
            width: width,
            height: height,
            framerate: framerate,
        })
    }

    /// Encodes a frame.
    pub fn encode(&mut self, frame: &DynamicImage) -> Result<(), Error> {
        let (width, height) = frame.dimensions();

        // 入力画像のサイズがEncoderに登録されたサイズと異なる場合はエラーを返す
        if (width, height) != (self.width, self.height) {
            Err(Error::from(std::io::Error::new(
                std::io::ErrorKind::Other,
                "Invalid image size",
            )))
        } else {
            let stdin = match self.ffmpeg.stdin.as_mut() {
                Some(stdin) => Ok(stdin),
                None => Err(std::io::Error::new(
                    std::io::ErrorKind::Other,
                    "cannot start ffmpeg",
                )),
            }?;

            // 標準入力にフレームのピクセルを流し込む
            stdin.write_all(&frame.raw_pixels())?;
            Ok(())
        }
    }

    /// Creates a current MP4 encoder.
    pub fn close(&mut self) -> Result<(), Error> {
        // ここで明示的にstdinをdropする
        drop(&self.ffmpeg.stdin);
        // ffmpeg側の終了を待つ
        self.ffmpeg.wait()?;
        Ok(())
    }
}
```

主な変更は以下のとおりです。エラーハンドリングはFailureで適当にやっています。

 + Encoder構造体の定義
 + `encode_frames_to_mp4()`をEncoderのメソッド`new()`, `encode`, `close()`に分離
 + ffmpegのオプションにframerateを追加
 + 出力ファイル名`path`の型をジェネリクスで`P: AsRef<Path>`に変更

`new`メソッドでは、Encoder構造体に、ffmpegを実行中の子プロセスのハンドラを格納しています。
`encode`メソッドでは、selfからハンドラを取り出して標準入力に画像を書き込んでいます。
`close`メソッドでは、標準入力を開放し、子プロセスの終了を待ちます。
元のプログラムではスコープを利用して子プロセスの標準入力を開放していましたが、このプログラムではdropを用いて明示的に開放しています。

これでmp4へのエンコーディングをイテレータで回せるようになりました。

# デモ
[Ferris](https://rustacean.net/)が横にずれていくアニメーションを生成しながらmp4で出力しましょう。

画像は[rustacean.net](https://rustacean.net/)からお借りします。

![rustacean-orig-noshadow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214775/ddf9478b-ee14-e600-a8c4-52a6b22aa9c1.png)
[rustacean-orig-noshadow.png - Rustacean.net: Home of Ferris the Crab](https://rustacean.net/assets/rustacean-orig-noshadow.png)

## プログラム

```rust:main.rs
use image::{DynamicImage, GenericImage, GenericImageView};
use mp4encoder::mp4::Encoder;

fn main() {
    let path = "./rustacean-orig-noshadow.png";
    let ferris = image::open(path).expect("failed to open the file");
    // オリジナルは大きいので縮小する
    let ferris = ferris.resize_exact(256, 256, image::imageops::FilterType::Lanczos3);

    // 256 x 256
    let (width, height) = ferris.dimensions();
    let framerate = 64;

    // エンコーダの開始
    let mut mp4_encoder = Encoder::new("output.mp4", width, height, framerate).unwrap();

    // 作業用の画像
    let mut shifted = DynamicImage::new_rgba8(width, height);

    for i in 0..256 {
        // ferris君をずらしながらshiftedにコピー
        for (x, y, pixel) in ferris.pixels() {
            shifted.put_pixel((x + i) % width, y, pixel);
        }
        // shiftedをエンコード
        mp4_encoder.encode(&shifted).unwrap();
    }

    // エンコーダを終了
    mp4_encoder.close().unwrap();
}
```

ループ内ではFerrisを指定ピクセルずつズラした画像を生成し、そのままmp4へのエンコードを行います。

## 実行結果

### 生成されたmp4の動画（をffmpegでgifに変換したもの）
mp4のままだとQiitaに載せれないんですよね、、、（企画倒れ）
期待したとおり、蟹歩きのFerrisが生成されています。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214775/5168255e-4e4c-5f13-170a-e527c0236ee4.gif)

### 標準出力

ざっくり眺めると`frame=...`のところで、全部で256フレームがエンコードされたことがわかりますね。
フレームレートはリリースビルドだと終了が速すぎて0になっていました（開発用ビルドだと46）。

```sh:開発ビルドの標準出力（一部抜粋）
frame=  256 fps= 46 q=-1.0 Lsize=      46kB time=00:00:03.95 bitrate=  94.4kbits/s speed=0.713x 
```

```sh:output
❯ rm -f ./output.mp4 &&  cargo run --release
    Finished release [optimized] target(s) in 0.15s
     Running `target/release/mp4encoder`
ffmpeg version 4.2.1 Copyright (c) 2000-2019 the FFmpeg developers
  built with Apple clang version 11.0.0 (clang-1100.0.33.8)
  configuration: --prefix=/usr/local/Cellar/ffmpeg/4.2.1_2 --enable-shared --enable-pthreads --enable-version3 --enable-avresample --cc=clang --host-cflags='-I/Library/Java/JavaVirtualMachines/adoptopenjdk-13.jdk/Contents/Home/include -I/Library/Java/JavaVirtualMachines/adoptopenjdk-13.jdk/Contents/Home/include/darwin -fno-stack-check' --host-ldflags= --enable-ffplay --enable-gnutls --enable-gpl --enable-libaom --enable-libbluray --enable-libmp3lame --enable-libopus --enable-librubberband --enable-libsnappy --enable-libtesseract --enable-libtheora --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libx264 --enable-libx265 --enable-libxvid --enable-lzma --enable-libfontconfig --enable-libfreetype --enable-frei0r --enable-libass --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librtmp --enable-libspeex --enable-libsoxr --enable-videotoolbox --disable-libjack --disable-indev=jack
  libavutil      56. 31.100 / 56. 31.100
  libavcodec     58. 54.100 / 58. 54.100
  libavformat    58. 29.100 / 58. 29.100
  libavdevice    58.  8.100 / 58.  8.100
  libavfilter     7. 57.100 /  7. 57.100
  libavresample   4.  0.  0 /  4.  0.  0
  libswscale      5.  5.100 /  5.  5.100
  libswresample   3.  5.100 /  3.  5.100
  libpostproc    55.  5.100 / 55.  5.100
Input #0, rawvideo, from 'pipe:':
  Duration: N/A, start: 0.000000, bitrate: 134217 kb/s
    Stream #0:0: Video: rawvideo (RGBA / 0x41424752), rgba, 256x256, 134217 kb/s, 64 tbr, 64 tbn, 64 tbc
Stream mapping:
  Stream #0:0 -> #0:0 (rawvideo (native) -> h264 (libx264))
[libx264 @ 0x7f9a78802400] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2
[libx264 @ 0x7f9a78802400] profile High, level 2.1
[libx264 @ 0x7f9a78802400] 264 - core 155 r2917 0a84d98 - H.264/MPEG-4 AVC codec - Copyleft 2003-2018 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=6 lookahead_threads=1 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00
Output #0, mp4, to 'output.mp4':
  Metadata:
    encoder         : Lavf58.29.100
    Stream #0:0: Video: h264 (libx264) (avc1 / 0x31637661), yuv420p, 256x256, q=-1--1, 64 fps, 16384 tbn, 64 tbc
    Metadata:
      encoder         : Lavc58.54.100 libx264
    Side data:
      cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: -1
[mp4 @ 0x7f9a78801200] Starting second pass: moving the moov atom to the beginning of the file
frame=  256 fps=0.0 q=-1.0 Lsize=      46kB time=00:00:03.95 bitrate=  94.4kbits/s speed=  11x    
video:42kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 9.135594%
[libx264 @ 0x7f9a78802400] frame I:2     Avg QP:22.02  size:  6161
[libx264 @ 0x7f9a78802400] frame P:74    Avg QP:20.47  size:   253
[libx264 @ 0x7f9a78802400] frame B:180   Avg QP:20.93  size:    61
[libx264 @ 0x7f9a78802400] consecutive B-frames:  1.2% 14.8%  1.2% 82.8%
[libx264 @ 0x7f9a78802400] mb I  I16..4: 25.4% 26.2% 48.4%
[libx264 @ 0x7f9a78802400] mb P  I16..4:  0.3%  0.9%  0.2%  P16..4: 13.4%  2.3%  1.2%  0.0%  0.0%    skip:81.7%
[libx264 @ 0x7f9a78802400] mb B  I16..4:  0.0%  0.0%  0.0%  B16..8: 15.3%  0.3%  0.0%  direct: 0.0%  skip:84.3%  L0:34.9% L1:65.0% BI: 0.0%
[libx264 @ 0x7f9a78802400] 8x8 transform intra:39.9% inter:31.8%
[libx264 @ 0x7f9a78802400] coded y,uvDC,uvAC intra: 27.2% 50.4% 37.6% inter: 0.3% 1.5% 1.0%
[libx264 @ 0x7f9a78802400] i16 v,h,dc,p: 25% 25% 23% 27%
[libx264 @ 0x7f9a78802400] i8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 15% 13% 66%  1%  1%  1%  0%  1%  2%
[libx264 @ 0x7f9a78802400] i4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 30% 21% 23%  6%  4%  6%  3%  5%  2%
[libx264 @ 0x7f9a78802400] i8c dc,h,v,p: 55% 21% 15%  8%
[libx264 @ 0x7f9a78802400] Weighted P-Frames: Y:0.0% UV:0.0%
[libx264 @ 0x7f9a78802400] ref P L0: 82.5%  2.0% 11.9%  3.6%
[libx264 @ 0x7f9a78802400] ref B L0: 92.0%  7.3%  0.7%
[libx264 @ 0x7f9a78802400] ref B L1: 97.7%  2.3%
[libx264 @ 0x7f9a78802400] kb/s:84.07
```

# まとめ
Rustからffmpegを子プロセスとして呼び出す方法を紹介しました。
正攻法ではありませんが、用途によっては十分役に立つと思うので、ぜひ試してみてください。

# 余談
## H264について
H.264を利用するにはMPEG-LAにライセンス料を支払う必要があるそうです。
[ffmpegをビジネスで利用したときに特許侵害になる可能性 | blog.tai2.net](https://blog.tai2.net/mpegla_and_ffmpeg.html)

そのため外部に公開するプログラムの場合は、openh264対応のffmpegを使うのが好ましそうです。
(openh264の実行ファイルの利用にかぎり、ライセンス料を払わなくて良い)
[OpenH264とは何か？ | meteor Tech Blog](https://blog.meteor.ne.jp/2018/04/open-h264.html)
[ライセンス的にクリーンなH.264動画エンコードのやり方 - Qiita](https://qiita.com/engineer/items/54e33bdacc173c84cc8a)

※こちらの記事によると、`年間100,000以内の製品であれば、ライセンス料金がかからない`らしいです。
[H.264のライセンス料 | netanote.com](https://www.netanote.com/2019/06/h-264%E3%81%AE%E3%83%A9%E3%82%A4%E3%82%BB%E3%83%B3%E3%82%B9%E6%96%99/)

## ffcli
[ffcli](https://crates.io/crates/ffcli)というffmpegのコマンドを組み立てるクレートもありました。
これと組み合わせるとより柔軟にオプションを指定できるかもしれないです。

[darkskygit/ffcli.rs: A ffmpeg cli args builder](https://github.com/darkskygit/ffcli.rs)