この記事はRust Advent Calendar 2019の5日目の記事です。
- 4日目はこちら→crates.ioにクレートを公開するまで - Qiita
- 6日目はこちら→itertoolsの紹介 | κeenのHappy Hacκing Blog
こんにちは、@tasshiです。
5日目が忘れ去られていたので代打で小ネタを書きます。
概要
子プロセスでffmpegを起動して、コマ送り画像をmp4ファイルに変換します。
サンプルコードはこちら
https://github.com/tasshi-playground/rust-encode-mp4-with-ffmpeg-wrapper
背景
Rustでmp4のエンコードがしたかった
Rustで画像の配列を生成して、mp4の動画ファイルに変換しようと思いました。
時系列に並んだアメダスの画像とか、そういうやつです。
Rustには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がビルドできませんでした。lib***系を何か入れればよかったのかもしれません。
しかし色々試す前に気が変わっていました。
「ffmpegでmp4に変換するだけだったら、FFIじゃなくてもよくない????」
それっぽくffmpegエンコーダをつくろう
FFIでライブラリAPIを使うのは諦めて、子プロセスとしてffmpegを実行します。
参考記事:cairoのrustバインディングとffmpegで2Dアニメ作成ツールを作ってみた - Qiita
画像の配列をmp4に変換する関数を作る
参考記事を元にVec<image::DynamicImage>
からmp4ファイルを作る関数を書いてみます。
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
のみ無名関数内で後から代入します。
$ 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
参考記事:プログレッシブダウンロード対応の MP4ファイル を作成する :Tips & FAQ | arbk-works Blog
使い勝手をよくする
これで連番画像を入力すると動画のmp4ファイルが保存される関数ができました。
しかし、このままだと画像をすべて配列に保存してから変換する必要があるため、いくつか問題があります。
- メモリ使用量が画像の枚数に応じて増加する
- イテレータによる逐次処理を生かせない
これらを解決するために、子プロセスの開始・終了と、画像の変換処理とを別の関数に分離します。
Encoder構造体の定義
image::gif::Encoderを参考にして、mp4::Encoder構造体とそのメソッドを作ります。
参考記事:
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が横にずれていくアニメーションを生成しながらmp4で出力しましょう。
画像はrustacean.netからお借りします。
rustacean-orig-noshadow.png - Rustacean.net: Home of Ferris the Crab
プログラム
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が生成されています。
標準出力
ざっくり眺めるとframe=...
のところで、全部で256フレームがエンコードされたことがわかりますね。
フレームレートはリリースビルドだと終了が速すぎて0になっていました(開発用ビルドだと46)。
frame= 256 fps= 46 q=-1.0 Lsize= 46kB time=00:00:03.95 bitrate= 94.4kbits/s speed=0.713x
❯ 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
そのため外部に公開するプログラムの場合は、openh264対応のffmpegを使うのが好ましそうです。
(openh264の実行ファイルの利用にかぎり、ライセンス料を払わなくて良い)
OpenH264とは何か? | meteor Tech Blog
ライセンス的にクリーンなH.264動画エンコードのやり方 - Qiita
※こちらの記事によると、年間100,000以内の製品であれば、ライセンス料金がかからない
らしいです。
H.264のライセンス料 | netanote.com
ffcli
ffcliというffmpegのコマンドを組み立てるクレートもありました。
これと組み合わせるとより柔軟にオプションを指定できるかもしれないです。