【FFMPEG+Python】標準入出力だけでなんとかしたかった。

今までの記事はどれもこれも毛巣洞の日記だったので、たまには技術的なメモというか日記でも載せてみようと思います。本当にただのメモで、特にオチもなければ役に立つ情報、解説なんてものはまったく存在しないのでご了承いただければと。


FFMPEGで標準入出力を使いたい

始めに言っておきますと、僕はできる限りRAMを使用して処理を進めたい派です。異論は認めます。
一時ファイルをHDDなりSSDなりに書き出して、あとから消すという処理はできるだけ避けたいのです。いろいろな理由がありますが、一番の理由は「仮にファイルを作ってそのあと消そうとしてなんかエラーが起きたら残骸が残る」のが嫌だということです。ここからも僕がいかにプログラムでエラーチェックや例外処理をサボって勢いで仕上げているかがわかりますね。

上記を解決する方法として、Linuxの場合はtmpfsという所謂RAMディスクに相当する一時保管場所を作成する機能があります。たいていの場合/var/tmpとかに存在するのでこちらを使えばいいと思いましたが、Windowsはこういう機能が存在しません。サードパーティーソフトウェアを使うのは嫌です。
そこで、標準入出力というものが登場します。

標準入出力は、プログラムが起動すると毎回(ほぼ)必ず作成されるストリームの出入り口です。3つが指定され、それぞれ「標準入力」「標準出力」「標準エラー出力」の3つになります。標準エラーってなんだよって話ですがそういう名前なのでそういうものだと思っておきましょう。それぞれ、stdin stdout stderrで表します。

コマンドを入力する(キーボードで打ち込んだり、前回のコマンドを「パイプ」で繋げて次に渡すなど)際に使用されるのが標準入力。プログラムが何か吐き出す場所が標準出力。エラーが起きた場合は標準エラー出力に出力されます。んで、デフォルトでは標準出力も標準エラー出力もコンソールに吐き出すように設定されています。これをリダイレクトでファイルに吐き出すようにすることもできますが、その辺まで説明していると本題にたどり着けないのでこの辺にしておきます。

FFMPEGは動画編集ソフトです(語弊があります)。コマンドライン上でいろいろなことができます。僕はよくエンコードとかに使用している程度です。ただこれ、実は標準入力にデータを流し込むとそのデータを直接エンコードしてくれるという結構よさげな機能があります。ちなみに出力も標準出力に出すことができます。デフォルトはコンソールにつながっているのでおびただしい量の謎の文字列が大量に出力されることになりますが(バイナリですからね)。

そんな標準入出力を使用してPythonと連携させていたら、厄介な出来事が発生したのでメモとして残しておきます。ようやく本題です。なんとここまでで既に1000文字を超えています。

やりたいこと(本題)

Pythonを使って動画の自動生成を行いたく思っています。実はそれに適したライブラリが既にあります。なんとこちらFFMPEGも内部で使用しているのでこれを使えばやりたいことはできます。

しかしなんかつい最近、Ver2.0になったらしくレガシーなVer1.0との互換性が失われました。いつの間にか以前のドキュメントが死んで新しいデザインになっています。

元々はこれを使っていましたが結局1フレームごとに画像を生成しているような使い方だったので、それならもう1フレームずつPillowで画像生成してFFMPEGに全部投げてしまえばいいのでは?ということから、自前で作成することになりました。車輪の再発明ですが気にしないでください。

いろいろやってみる

何はともあれやってみます。FFMPEGは、「RAWVIDEO」という形式があり、こちらを利用するとなんと生のデータを画像として読み込ませることができます。ただしフォーマットを指定してあげる必要があり、あとサイズも必要です。フォーマットはいくつかありますが、今回はオーソドックスな「RGB24」を選択します。これにより、各色8ビットの画素データをバイト列で流し込めば勝手にエンコードしてくれるろいう夢のような物語です。

Pillowを使用して1フレームごとの画像を生成する関数を作る

Pillowは便利なライブラリです。画像編集に特化したライブラリです。
詳しくは割愛しますが、このインスタンスは内部でRGB24の形式のndarrayを格納しており、tobytesメソッドを使用することでバイト列に変換することができます。なので1フレームごとに同サイズのImageインスタンスを作成すればOKです。早速やってみます。

from PIL import Image

def make_frames():
    for i in range(256):
        img = Image.new("RGB", (1280, 720), (i, i, i))
        yield img

コードに色分け機能ないんですかね。
とりあえずこんな感じでジェネレーターを作成しました。インスタンスの生成は重いので全部リストで持たせるとメモリパンクしそうなのと、標準入出力の関係でメモリを多用するので少しでも節約したほうがいいかなってことでジェネレーターにしています。結局全部回すのであまり意味ないかもしれませんが、せっかくなので使いたかったんです。それが真の理由です。人間は覚えたてのものをとりあえず使いたくなります。僕は国家です。

上記でやっていることは、1280x720のフレームを作成して1フレームごとに黒から徐々に白になっていくだけのよくわからんものです。まあ本当はここにテキストを入れたりアフィン変換したりリサイズしたり透過合成したりといろいろやるんですが、今回は端折っています。ここが悩みどころではないんだもん。

FFMPEGの標準入力にぶち込む

subprocessを使用して直接FFMPEGを呼び出すのもいいんですが、一応Pythonラッパーのffmpeg-pythonというライブラリが存在するのでこちらを使わせていただきましょう。使い方のドキュメントが「詳しくは本家を見てね」「本家に使い方が乗っているよ」と半ば説明放棄状態になっていますが、まあ概ねはわかります。パイプラインで繋げていくだけです。

# 既に定義されているものとするよ、make_framesは。
 
import ffmpeg
process = ffmpeg.input("pipe:", format="rawvideo", pix_fmt="rgb24", s=f"1280x720", r=60, rtbufsize="2048M")
process = process.output("test.mp4", r=60, format="mp4", movflags="frag_keyframe+empty_moov")
process = process.run_async(pipe_stdin=True, quiet=True)

FFMPEGの細かい使い方は割愛しますが、processの最初の行で標準入力を使うように指示しています。ファイル名のところに「pipe:」とするとOKです。標準入力の場合はデータのフォーマットとデータ型を教える必要があるので、「rawvideo」と「rgb24」を指定しています。これにより、生データとしてRGB24形式のデータを送ればFFMPEGでエンコしてくれます。

そして出力はとりあえずtest.mp4に書き出すようにしています。libx264を使用しているため、とりあえずmovflagsにシーク困難な場合でもできるようなフラグを入れておきます。この意味はググってください。なくても動きます、今は。

そしてrun_asyncで非同期実行を行います。裏ではsubprocessが立ち上がり、pipe_stdinをTrueにしているのでprocess.stdinでサブプロセスの標準入力にアクセスすることができます。そうすればあとはやることは一つです。

標準入力にぶち込めぶち込め~!

思いっきり標準入力にぶち込んでいきます。

# ぶち込め標準入力へ
for frame in make_frames():
    process.stdin.write(frame.tobytes())
process.stdin.close() # これをしないとffmpegが終わりません

これだけです。ジェネレーターなのでfor文で直接回せます。
受け取ったframe(Imageインスタンス)をtobytes()を使用してバイト列に変換し、標準入力に流しています。これだけです。

実際に実行してみると、確かに動画は出来上がります。Noteは動画を載せられないようなので想像してください。できていますので。

とまあ、このような使い方で、Pillowのフレームを一度連番画像として書き出すことなく直接エンコードできるという仕組みになっています。非常に便利です。

で、ここからが詰まったことです。

受け取りもメモリ上で行いたい

今回は「test.mp4」というファイルに出力しました。しかし、僕はこれすら納得がいきません。自動生成においてこのmp4ファイルをさらに別の場所で加工するなどといった場合は、一瞬ながらtest.mp4ができてしまうのです。これでは一時ファイルと変わりません。なので、エンコード結果を標準出力に流していくことで一時ファイルの出力ファイルすら作らせないということを行います。ここでハマります(予告)

コードを変える

変えるのはプロセスの部分です。以下のようになります。

# 既に定義されているものとするよ、make_framesは。
 
import ffmpeg
process = ffmpeg.input("pipe:", format="rawvideo", pix_fmt="rgb24", s=f"1280x720", r=60, rtbufsize="2048M")
process = process.output("pipe:", r=60, format="mp4", movflags="frag_keyframe+empty_moov")
process = process.run_async(pipe_stdin=True, pipe_stdout=True, quiet=True)

はい。outputのファイル名も「pipe:」にしてしまいます。こうすることで、標準出力にパイプが接続されます。また、run_asyncでpipe_stdoutをTrueにしておくことで、subprocessのstdoutを使用することができるようになります。これを忘れるとNoneになって怒られるので、忘れずにつけましょう。

標準出力を受け取る

出しっぱなしだと困ってしまうのでPython側で受け取ります。とりあえずフレームを全部書き出し終わったら標準出力を読み込み、変数にバイト列を格納しておきましょう。

# ぶち込め標準入力へ
for frame in make_frames():
    process.stdin.write(frame.tobytes())
process.stdin.close() # これをしないとffmpegが終わりません
process.wait() # ffmpegのエンコが終わるまで待ちます

# 読み込め標準出力を
output = process.stdout.read()

process.waitでFFMPEGのエンコを終了するまで待ちます。非同期で実行しているので、まだエンコが終わっていないのに標準出力を読み込もうとすることを阻止するためです。

FFMPEGが終了したら、readで標準出力の内容を読み込み、すべてoutputに入れます。こうすることで、バイト列として生データを取得することができます。なおRAMが足りないとOutOfMemoryで落ちるので注意。まあ今回の題材がRAMをフルに使ってやるものなので、そもそも大容量のRAMを積んでいないと話になりませんが。

実行してみますと、なんと129フレーム目くらいで止まります。ハングアップします。FFMPEGも終わりませんし、Pythonも終わる気配が見えません。仕方なしにctrl+Cで強制終了しました。

何が起きているのか、察しのいい方はお判りでしょう。そうです。「デッドロック」が発生していました。

うわーん、バイナリが詰まっています!!

標準入出力は処理の速度を向上させるために通常はバッファリングされています。OSによってまちまちですが、僕が操作していたUbuntuに関しては標準入出力のバッファサイズは32MiB程度となっていました。もちろん1パイプごとではなく、合計です。

さて、今のユースケースでは標準入力でフレームのデータを次々に渡し、標準出力でエンコードしたデータを書きだしていました。そんでもって、標準出力の読出しは標準入力が終了してから行うようにしていました。
FFMPEGは標準入力で1フレームのデータを検出すると逐次読み込んでいくので、標準入力が詰まることはまずありません(よほど高速で出力しなければ)。ですが、標準出力は詰まります。標準出力の読出しを行う前に、標準入力+標準出力の合計がバッファサイズである32MiBをオーバーしてしまい、これ以上書き込めなくなります。ここで、PythonのプロセスとFFMPEGのプロセスは以下のようにしてバッファが空くのを待ちます。

  1. Pythonは標準入力に書き込みたいですが、標準入力に空きがありません。なので、標準入力あるいは標準出力が空くのを待って、書き込みを続けます。

  2. FFMPEGは標準出力に書き込みたいですが、標準出力に空きがありません。なので、標準入力あるいは標準出力が空くのを待って、書き込みを続けます。標準入力の内容は不正なデータになっているので(まだ書き込み途中なので)読み出すことはできません。

  3. デッドロックが発生し、処理がハングアップします。

一度発生するとPythonプロセスを強制終了しないことには何もできなくなってしまうので(Pythonプロセスを終わらせれば子プロセスなのでFFMPEGも強制終了します)、これはどうにかしたいです。というわけで対処を行っていきます。

対処:同時にやる

Pythonはフレームを書きだし切るまでとにかく標準入力に出力し続けてしまいます。FFMPEGも標準入力からのエンコデータを素直に標準出力に出力し続けてしまいます。FFMPEGは完成されたバイナリで、何か操作するようなコマンドラインオプションも特に存在しないようなので、自前で改造してビルドしない限りFFMPEG側で対処するのは困難でしょう、というかそもそもあれライセンスが厳しすぎるのでまず無理でしょう。詳しくは見ていませんが。

となるとPython側でどうにかする必要がありますが、簡単です。フレーム書き出しながら同時に標準出力を読み取っていけばいいのです。なんでこんなことにすぐ気が付かなかったんだろう、JavaでもInputStreamとかはreadループで表現しているというのに。
さあ、標準出力を都度読み取るようにしてみよう!(なおこの時点でフレーム1枚のデータが32MiBを超える場合はそもそもアウトになりますがまあそんなことはないです)

1280 x 720のRGB24フォーマットを想定すると、
1280 x 720 x 3 = 2,764,800Byte ≒ 2.63MiB
1フレーム当たりこの程度なので問題ありません。

計算式

1フレームごとに標準出力も読み込んであげる

# ぶち込め標準入力へ、そして読み込め標準出力を!
output = b""
for frame in make_frames():
    process.stdin.write(frame.tobytes())
    output += process.stdin.read()

process.stdin.close() # これをしないとffmpegが終わりません
process.wait() # ffmpegのエンコが終わるまで待ちます

かんぺき~! これでうまくいきますね!

……そう思っていた国家が過去にいたようです。

うわーん、悪化しました!!!!!!!!

なんとこの状態で実行すると、1フレーム書き出してそのまま硬直してしまいます。悪化しました。
どうしてこうなってしまったのでしょうか。ここで、また標準入出力の仕様について知っておく必要があります。

そう、標準出力からデータを読み取る場合、標準出力になにもデータが存在しないと、データが現れるまで処理がブロックされます。

なので標準出力の読み取り部分でブロックされ、誰も何も書き込まないのでハングアップしてしまっていたわけですね。うん。無理じゃん。どうするのよ。

対処:ブロッキングしなければいいじゃないか

なら標準出力のブロッキングを阻止すればいい。浅はかな国家はそれでいくことにしました。バッファサイズを変更するのは難しいですが、ブロッキングの阻止はPython側で行うことができます。しかしこの方法では大変残念なことにWindowsが脱落します。よって、ここから先の方法はLinux(僕の動作環境はUbuntuです)でしか対応できないことをご了承ください。ご了承できない? もうあきらめましょう。

fcntlシステムコールを呼び出す

Linuxではシステムコールを使って直接OSの挙動を変えることができます。Python側でいじくれるのはプロセスに関する設定で、この中に標準入出力のノンブロッキングを行えるシステムコールが存在します。

といっても僕はそこまでシステムコールに詳しいわけではないので、とりあえずサンプルコードにあった以下のコードをそのまま使用しました。

flag = fcntl.fcntl(process.stdout.fileno(), fcntl.F_GETFL)
fcntl.fcntl(process.stdout.fileno(), fcntl.F_SETFL, flag | os.O_NONBLOCK)

まあ何をやっているかしっかり解説できる自信はありません。これで標準出力のブロッキングを阻止することができます。実際にこれを読み出す前に指定してみると……

TypeError 訳:Noneにreadは使えんて

Why? Noneって何よ。

ノンブロッキングパイプラインはNoneを返してきやがる

タイトルの通り。FFMPEGが出力している間、その中身を読み出すことができないのでシステムコールが失敗し、readメソッドの結果がNoneになることがあります。全部読み切ってなんもストリームがない場合は空のバイト列を返すので判別できますが、Noneが返ってくるなんて仕様マニュアルに書いていましたか?

オブジェクトがノンブロッキングモードで、1 バイトも読み込めなければ、None が返されます。

Python公式ドキュメント

しっかり書いていますね。普段からきちんとマニュアルやドキュメントは見ないといけません。このことからも、前文のようにいかに適当な感じでプログラミングをしているかがバレバレです。

対処:Noneだったら無視すればよろし

Noneの場合は読み取れていないのでその先の処理をスキップしてしまえばいいのです。なので、単純にループの中にNoneなら追加しない設定を一つ加えればいい話です。

# ぶち込め標準入力へ、そして読み込め標準出力を!
output = b""
for frame in make_frames():
    process.stdin.write(frame.tobytes())
    b = process.stdin.read()
    if b is not None:
        output += b

process.stdin.close() # これをしないとffmpegが終わりません
process.wait() # ffmpegのエンコが終わるまで待ちます

bというtmp変数が出てきてしまったのであまり美しくないですが、こうするしか方法がありません。
こうすることで、ついにハングアップしないまま正常にバイト列を取得することができました。

なお、バイト列のままでは意味不明なので、実際に確認したい場合はffplayなどにバイト列を垂れ流すか、自前で解釈するか、中身をBytesIOとかにラップしてOpenCVとかで読み込んでみるとかすればいいと思います。特に後者は結構やるのではないでしょうか。

僕の場合はめんどくさかったので以下のようにしてチェックしました。

with open("test.mp4", "wb") as f:
    f.write(output)

ならはじめっからファイルに出力しろや

ごもっともです。

最後に

というわけで、FFMPEGを使用して標準入出力だけでどうにかする方法を試行錯誤した際、いろいろ躓いたことがあったのでメモとして残してみました。もちろん冒頭にも説明したとおり、これは僕の備忘録のようなもので、解説をしたり誰かに向けて方法を共有しているとか教えているとかそういったものではありません。なのでこれの通りにやってうまくいかなくても知ったこっちゃありませんので、そこはご了承ください。

というか、通常は一時ファイルでも何でも作ってあとからunlinkとかなりで消せばいい話なので、こんなことをしたいと思うのは相当な変人です。僕は国家なので変人ではないということは伝えておきます。

ただ、一時ファイルって結構作りたくないと思うんですけどね。Windowsの方については途中で投げ出してしまうことになり申し訳ありませんが、あの時に理解できない方はブラウザバックしてくださいといったような気もしなくもなくもなくもなくもやくもみたまないのできっと、ここを読んでいる方々はきっと文句を言わないと信じております。ちなみにここまででコーディングの文字数も含めると実に9000文字を超えているとのことですが、皆様一字一句逃さずに読めたのでしょうか。短編小説が書けそうなレベルで文字数を超越していますが、これを打っている僕の指は震えが止まりません。よくこんなにキーストロークをできたなと思うばかりです。一応この文章は頭の中で考えたことをそのまま吐き出しているようなものなので、30分くらいでこの量を仕上げています。論文を書く能力はありません。

さて、最後にと言いながらこれ以上長くなりすぎると読む気もなくしますし、もう本題は終わっているので、ここでいっちょ〆させていただきます。ご拝読ありがとうございました。銀連先生の次回作にご期待ください。


………え? 目次になんかまだいるって?

あ、はい。おまけとしてよくわかんない現象を載せておきます。


【おまけ】未解決:やっぱり止まる

いや解決していないんかーい!というツッコミはなしで。
なぜか条件不明ですが途中で止まってしまうときがあります。途中で止まるときは毎回ほぼ同じ個所で止まりますが、再現性がなくよくわからない感じです。以下に、推測した条件を載せます。

変数名によって止まる

正直どうでもいい変数なので、aと名付けていました。非常に良くないコーディングをしているという指摘はなしで、以下のようなコードを書いていました(実際はいろいろ書いていますが途中端折っています)

まずこのような関数を宣言しています(画面サイズの長辺に収まるようにリサイズした結果を返すもの)

def fit(canvas, img):
    """
        黒い余白が出ないように縦横比を保ったままリサイズしたときの幅と高さを返す
        なおこのメソッド自体はリサイズを行わない

        Args:
            canvas: フィットさせるサイズのキャンバス画像。
            img: canvasにフィットするようにリサイズする画像。
        Returns:
            [width, height]: 黒余白が出ないようにフィットさせたリサイズ後の幅と高さ。はみ出ることがあります。
    """
    # 長辺に合わせてズームする
    if img.width > img.height:
        # 横長の場合
        s = [int(canvas.width), int((canvas.width / img.width) * img.height)]
    else:
        # 縦長の場合
        s = [int((canvas.height / img.height) * img.width), int(canvas.height)]
    return s

まあこの中でも「s」とかなんか微妙な変数が登場していますが一旦無視。こちらを使って、以下のようにmake_framesジェネレーター内で使っていました。

z = i / fps # ズーム率
s = fit(img, light)
# ズーム率を掛け合わせ最終出力にする
s = (int(max(1, s[0] * z)), int(max(1, s[1] * z)))

l = light.copy().resize(s)

if i < fps:
    alpha = easing("decel", 0, 255, fps, i, 2)
else:
   alpha = easing("decel", 0, 255, fps, fps - i%fps, 2)

l.putalpha(int(alpha)) # なぜか視覚的な都合からかLinearだとおかしいのでイージング対応
layer.paste(l, (size[0] // 2 - s[0] // 2, size[1] // 2 - s[1] // 2), l) # 中央に貼り付け

まあ何をやっているかはわかんなくてもいいですが(実際はもっとたくさんの変数を宣言し使用しています)、ここで問題なのは「alpha」です。この名前で実行したところ、240フレーム出力するうち123フレーム前後で止まってしまうようになりました。

なぜ? とりあえずalphaを「alpha_」とアンダーバー付けてみて再実行しました。すると動くではありませんか。意味がわかりません。

10回くらい試したところ、alphaだと全部止まり、alpha_だと全部完走します。なんでですか。alphaは別に予約語でも何でもないと思うのですが。

処理内容で止まる?

リサイズをする以下のコードを書いていました(上記とは別)。

img = img.resize((int(max(1, s[0])), int(max(1, s[1])))

sはタプルでリサイズ後のサイズがw,hの順番で入っています。ようはさっきのfit関数と同じような戻り値です。0が返る可能性がある計算をしているので、maxを挟み最低でも1になるようにしています。またリサイズは整数でないとエラーが発生するので(融通利かせてくれてもいいと思うけど)、intでキャストして整数に変換しています。

この処理を入れた瞬間途中でハングします。メモリエラーなのかと思いましたがメモリには空きがありまくっている状態で、強制終了するとやっぱりprocess.stdin.write()の行で止まっています。FFMPEG側で何かエラーでもあったのかと、pipe_stderrをTrueにしてみましたが特にエラーの掃き出しは見られませんでした。これもよくわかんない現象となっています。


わけわかんない、けどなぜか集中してしまう

というわけでよくわからない現象をおまけとして紹介したらついに10000文字を超え、11500文字に到達してしまいました。ま、おまけのほうはいまだ未解決なのでStackOverflowとか見ながら試行錯誤してどうにかしていければいいな、と思っています。そしてこの記事はようやく終わりを告げます。

こんな感じで技術面の記事とかも(一応本職なので)残して行けたらなあと思っています。繰り返しますが誰かに説明するとかそういうつもりは全くないのでご了承ください。少し誰かの役に立つといいな、と。そう思っただけです。

せっかくだから12000文字まで行きたい

あと190文字で12000文字まで到達します。なので少し話がそれますが雑談でも。毛巣洞については現在も痛んだり痛まなかった理を繰り返していますが、比較的落ち着いている状態です。なのでしばらく記事を出すことはないかなと思っています。これはフラグだと思います。

お、あと50文字だ。それじゃあ〆の言葉で。こんな中身のないNoteをお読みいただきありがとうございました!!

いいなと思ったら応援しよう!