Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scala+FFmpegでのモナディックな動画合成を支える技術

ZMMに、背景画像の代わりに動画を流す機能を先日実装した。まだバージョンをリリースしたわけではないし動作は完璧ではないが、ひとまず動くという段階にまで持っていくことができた。もともとはキャラクターの立ち絵と字幕の後ろには背景画像を表示する機能しか想定していなかったが、ScalaとCatsの抽象化能力の高さにより、あまり大規模な変更を加えずに(といってもそこそこの規模だったが)動画を再生する機能を追加できた。この記事では、既にあった背景画像機能に加え、どのように背景に動画を流す機能を実装したかを紹介する。

youtu.be

おおまかには、

  • 透過PNGから透過情報付き動画を生成する
  • 透過情報付き動画と背景動画とを合成する

というステップを行った。

ZMM

ZMMとは、解説動画を生成するためのツールである。XMLで原稿を記述することであまり労力をかけずに動画を生成できるのが売りで、自動的な字幕表示、立ち絵とキャラクターの管理、数式やコードスニペットの表示機能を備えた、中機能な動画生成ソリューションを目指している。

www.3qe.us

ZMMは一般的な解説動画同様に、キャラクターの立ち絵、字幕、背景画像を基本3点セットとして画像をレンダリングし、そのかたわらVOICEVOXなどの音声バックエンドを利用して音声合成を行い、最終的に1本の動画を生成する。

背景画像

ZMMは画像レンダリングエンジンとしてChromium/Firefoxのヘッドレスモードを利用している。ブラウザはヘッドレスでスクリーンショットを生成できるので、実は画像生成の面倒なことはブラウザに任せて、自分はHTMLを綺麗に出力すれば良いという構成にしている。似たようなアプローチで動画を生成するツールは他にもあるようだが、自分の構成が一番シンプルであろうと思う。

さて前提として、動画の一部に背景画像を設定するためには、以下の情報が揃っていなければ動画としてきちんと成立させられない。

  • どの画像を表示するのか
  • どこからどこまでの時間軸で表示するのか

こうした情報はZMM内部ではコンテキストという概念で一元化され、XMLの構造を反映したツリー状の構造で管理されている。最終的に動画にする際、このツリーは継承関係を整理した上で動画の時間軸に一直線上に並べられ、レンダリングが行われる。 各ノードに割り当てられる時間はVOICEVOXの音声合成結果を参照するようにすることで、画像を何秒割り当てればよいかが分かる仕組みだ。

背景画像のコンテキストもこの過程を経てノードに割り当てられ、キャラクターの背景に画像が表示されるようになる。

背景動画

さて、背景動画をレンダリングしたい場合は話が複雑になる。背景画像の場合はHTML上で画像を指定すれば全ての工程をブラウザが行ってくれるが、ブラウザは動画をレンダリングする機能は持たないので、動画を生成するためにはffmpegの手を借りなければならない。一方、ffmpegはHTMLのレンダリングなんか知らんという感じなので、動画合成のためには以下のステップを踏む必要がある:

  1. キャラクターと字幕といった前景だけをレンダリングする
  2. いい感じにffmpegで背景動画と合成する
  3. 最終的に1本の動画になる

ただし、ここではffmpegを複数回起動する手間や動画管理の手間を省くため、具体的に以下の手法で前景と背景動画を合成した:

  1. キャラクターと字幕といった前景だけをレンダリングする
  2. 前景だけの動画を生成する(VOICEVOXの音声もここに含まれる)
  3. 前景の尺と同じ長さで、背景だけの動画を生成する
  4. 背景動画の上に前景動画を合成する

これから各ステップを説明する。

前景だけをレンダリングする

前景だけレンダリングするためには、CSSのbackground-imageを空白にすれば良いのだが、それだけだと背景は白くなってしまう。後程背景と合成する都合上、何らかの方法で透過情報を埋め込まなくてはならない。

今回自分はChromiumのスクリーンショットの背景色を指定するワザを使い、透過情報を画像に埋め込んだ。

Chromiumはヘッドレスモードでスクリーンショットを撮影する際、背景色を指定できる。実はこの色指定にはアルファチャンネルを含めることができて、これに00000000を指定するとrgba(0,0,0,0)を指定したのと同じになり背景が透過したPNGファイルを生成してくれる。

blog.3qe.us

これにより、後程背景と合成可能な形で前景画像をレンダすることができた。

背景が透過した前景画像が生成された

ちなみにFirefoxではこの手法が通用しないので、背景色として#ff00ffなどの特定の色を使い、ffmpeg側で透過させるといった迂回路を使わなければ前景と背景とを合成させられない上、特定の色しか透過できないので仕上がりは汚くなるし、半分透過といったこともできなくなってしまう。したがってFirefoxでは動画合成はうまくいかない。

前景だけの動画を生成する

さて今度は前景画像を合わせて前景動画を生成しなければならないが、いつも通りの手法で合成するとMP4ファイルの色空間がYUVなせいで透過情報が失われてしまう。MP4ファイルには透明度情報を記録することができないので、もしやるなら色情報だけのMP4ファイルと透過度情報だけのMP4ファイルを生成しなければならない。

ここで自分は面白いテクを発明した。MKV形式を使うのだ。MKV形式はあまり聞かないかもしれないが、MP4と同じ動画コンテナフォーマットの一つだ。MKVには複数の動画ストリームを格納可能という特性があり、自分はここに着目した。通常の色情報を格納した動画ストリームと透明度情報を格納した動画ストリーム、そしてVOICEVOXが生成した音声ストリームを、一挙に1つのMKVファイルに格納すればファイルは1つで済むのだ。

この動作を行うために、ffmpegのalphaextract フィルタを利用した。このフィルタは処理中の動画ストリーム(ここではPNGファイルが流れてくる)から透明度情報を取り出し、これをグレースケールに変換してくれるのだ。あとは、元の動画ストリームとまとめて格納すれば良い。同時に1つのストリームを2回使うことはできないので、splitフィルタを使って元動画ストリームを分け、片方はalphaextractを適用し、もう片方はそのままで格納するという手法とした。

通常の色情報だけ格納したビデオストリームと・・・

alphaextractにより透過度情報だけが抽出されたグレースケールのビデオストリームが生成される

これにより、色情報+透明度情報+音声情報を格納した単独のMKVファイルが生成できた。

フィルタグラフはこんな感じ。

split[img][img2];[img2]alphaextract,scale=1920:1080[alpha];[img]scale=1920:1080[scaledimg]

これでscaledimgalphaとを-map '[scaledimg]' -map '[alpha]'と書いてMKVファイルに押し込める。

前景の尺と同じ長さで背景だけの動画を生成する

ここは簡単。前景と背景の尺の長さを合わせれば、あとは重ねるだけなので合成を簡単にすることができる。尺を合わせるために、前景動画を生成したのと同じ要領で背景動画を生成する。画像を集めて前景動画を生成したのとは対照的に、動画を集めて背景動画を生成する。背景動画が設定されていない箇所には適当なパディング用にテスト映像を流しておいている。

背景動画の上に前景動画を合成する

ここが面白いところ。既に前景動画と背景動画のためのMKVファイルが用意されているので、あとは合成するだけで良い。ただし、透過度情報は別ストリームに分離されているので、事前に復元しておく。alphamergeフィルタを使うことで、alphasplitで分離した透過度情報を復元できる。

次にoverlayフィルタを使うことでストリーム同士を合成する。透過度情報が復元されているので正しく前景と背景が合成され、最終的な動画が完成する。

フィルタグラフはこんな感じ。

nullsrc=s=1920x1080:r=30:d=60[nullsrc]; [0:a][1:a]amix=normalize=0[a]; [nullsrc][1:v]overlay=x=0:y=0[paddedbase]; [0:0][0:1]alphamerge[overlayv]; [paddedbase][overlayv]overlay=x=0:y=0:eof_action=pass:shortest=0:repeatlast=1[outv0]

これで-map '[outv0]' -map '[a]'と書いて最終的な合成結果の動画が得られる。

まとめ

透過度付きのPNGをffmpegを通して動画化し、これを別の動画に合成する手法を紹介した。

モナディック要素はここを見てください。解説動画作成ツールをScalaで作成している話 / Creating an explainer video maker in Scala

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?