さて。
生放送動画(配信映像+コメントの動画)を投稿するにあたり、自分がやったことを、ログ残しがてら、ぶっちゃかす。
各々の細かいツールの仕様や操作等々は省くのであしからず。
##なんでこんなややこしいことを…?
「生放送動画なんてタイムシフトの画面をキャプチャするんが手っ取り早いやん?」
「投コメでコメント再現した方が文字列データとしても残るで?」
「探したら確かそういう動画を出力するツールあったと思うんやけど…」
ごもっとも。
理由を挙げるとしたら…
「コメントの流し方(アニメーション)を制御したかった」になる。
「興味半分で作ってみたかった」とも言う。
具体的には、表示時間や配置位置をコントロールできるようにしたかった。
ニコニコのコメントは3秒で、画面右から左に、画面上から順に流れる仕様、とのこと。
これを少なからず、もっとゆっくり、動画へのコメントと被らないよう、画面の下から順に流れるようにしたかった。
あとは、今回はシステムコメント排除ぐらいしかしてないけども…
既存の配置や配色設定、あるいはこっちで定義した特定文字列やユーザーを検出して、表示方法を切り替えるってのも、できる状態にしておきたかった。
「自分のコメントは動画化するときには含めて欲しくない」とか要望があっても、一応対応できる。
他、お遊び定義を入れて遊んでもいいかな?とも思う。
ついでに自分でテロップ用の文字列情報作って流し込めば、別に生放送動画じゃなくても、テロップ機能として使える。
アニメーション方法の定義を自作でするわけだから、好きなようにできるし、まあ後々自作動画で何かしたいときに使えるでしょう。
ま。そんなかんじ。
#動画投下までの手順&解説
1. NiconicoLiveEncoderで放送映像をローカル保存
2. NiconamaCommentViewerでコメントをファイル保存
3. AfterEffectsで動画とコメントを読み込んでエクスプレッションでコメントを合成&必要あれば編集
4. MediaEncoderでレンダリング
5. ニコ動に投下
##1. NiconicoLiveEncoderで放送映像をローカル保存
「配信時に録画を行う」設定を入れておけば、勝手に保存される。
ここで保存される映像は、実際に配信したタイムシフトのような映像…ではなく、ツール側が配信するために取り込んだ大元の映像なので、
例え放送準備中の段階であっても、ツールの「配信開始」ボタンを押した瞬間から、ぜーんぶ保存される。
ツール側の不良がない限りは、ニコ生側の配信関連サーバーエラーによる画面真っ暗期間も関係ない。
細かいところを気にしだすといろいろ融通は利かないのかもしれないが、便利。
##2. NiconamaCommentViewerでコメントをファイル保存
接続して表示した生放送コメントを「名前を付けて保存」すれば、xml形式で保存化される。使うコメントのデータはこれ。
一応「テキスト形式で保存」もしておく。こっちの方が情報量は少なくて見やすいので、こっちでコメントされた再生時間を確認する。
最初はテキスト形式の方を元データとして読み込んで解析&使ってたけども、1コメントの情報の区切り文字がタブだったり改行だったりで、コメントそのものにソレを使われたら1コメント分の解析ができなくなってアウトだなと思ったから、やめた。
細かい解析処理は3で。
##3. AfterEffectsで動画とコメントを読み込んでエクスプレッションでコメントを合成&必要あれば編集
本題はここ。
先に軽く説明すると『エクスプレッション』はAfterEffectsで使えるスクリプト(プログラム)のこと。
実際はJavaScriptだったりするけど…もうね。JavaScriptって環境によって言語使用変えたい放題だから、わけわかんないよね…
まあ、それはさておき。
処理の概要は…
- 保存しておいたコメントのxmlデータをresouceという『テキストレイヤーに』貼り付ける
- 処理本体となるエクスプレッションのソースコードをfunctionという『テキストレイヤーに』書く
- intermediateというテキストレイヤーのソーステキストのエクスプレッションで、中間処理結果が出るように『functionレイヤーに書いた処理をeval()で実行』
- 各種コメントを表示するためのテキストレイヤーのソーステキスト&位置のエクスプレッションで『functionレイヤーに書いた処理をeval()で実行』してコメントとして表示
『テキストレイヤーにプログラムのソースコードを書いて実行する』という
「普段AfterEffectsを使ってる人でも、こんな使い方しないだろう!?」みたいな、実はとんでもないことをしている。
「テキストレイヤーをデータ格納場所として使う方法、かなり便利だよね」と言いつつ乱用した結果がこれだよ。
非表示にしてもデータは参照できるし、適当な位置に適当に書ける感じ。適当すぎる。
AfterEffectsらしからぬ画面のスクショも張っておこう。
resourceレイヤーには、以下のエクスプレッション制御エフェクトのスライダーもかけてある。
TEXT_HEIGHT :コメント1行の高さ ※行間を開けたかったらここで設定
TEXT_WIDTH :コメント1文字の幅 ※下の方に余談あり
VIEW_DURATION:1コメントの表示時間
OFFSET_MINUTE:解析&表示するコメントの時間のオフセット ※コンポジションMAX尺が3時間で、6時間分のコメントを表示切替するために用意
各種クソコードもぶっちゃかすんだぜ。ヒューきったねぇー。
なんでもいいけど『データ』ってレイヤー名…ってかコンポジション名、すげぇブサイクだなwwwいいんだけどさ。
見返すと気になるところいろいろあるなぁ…まあいいんだけど(適当すぎ
var TextData = function( lineIndex, viewTime, viewText ) {
this.lineIndex = lineIndex;
this.viewTime = viewTime;
this.viewText = viewText;
};
var FUNCTION = {
makeData: function()
{
resourceLayer = thisComp.layer("resource");
originalStr = resourceLayer.text.sourceText.valueAtTime(0);
liveStartTime = parseInt(originalStr.match(/<StartTime>(\d+)<\/StartTime>/)[1]);
textStr = originalStr.match(/<chat.+<\/chat>/)[0];
viewDuration = resourceLayer.effect("VIEW_DURATION")(1).valueAtTime(0);
offsetTime = -resourceLayer.effect("OFFSET_MINUTE")(1).valueAtTime(0) * 60 - liveStartTime;
result = Array();
reg = RegExp("date=\"(\\d+?)\".*?>(.+?)<\/chat>", "g");
while( (targetList = reg.exec(textStr)) !== null ) {
if( targetList[2][0] == '\/' ) continue;
viewStartTime = parseInt(targetList[1]) + offsetTime;
if( viewStartTime < 0 ) continue;
lineIndex = 0;
if( result.length > 0 ) {
dataIndex = result.length;
isRewrite = false;
for( i = result.length - 1; i >= 0; i-- ) {
if( viewStartTime > result[i].viewTime + viewDuration ) break;
dataIndex = i;
lineIndex = Math.max(lineIndex, result[dataIndex].lineIndex + 1);
}
for( ; dataIndex < result.length; dataIndex++ ) {
diffTimeRatio = (viewStartTime - result[dataIndex].viewTime) / viewDuration;
textRatio = (result[dataIndex].viewText.length) / 40;
if( diffTimeRatio - textRatio > 0.5 ) {
lineIndex = Math.min(lineIndex, result[dataIndex].lineIndex);
isRewrite = true;
break;
}
}
if( isRewrite && lineIndex > 0 ) lineIndex = 0;
}
result.push(new TextData(lineIndex, viewStartTime, targetList[2]));
}
resultStr = "";
for( i = 0; i < result.length; i++ ) {
if( result[i].lineIndex < 0 ) continue;
resultStr += result[i].lineIndex.toString() + "," + result[i].viewTime.toString() + "," + result[i].viewText.toString() + "\r";
}
return resultStr;
},
getViewText: function()
{
dataComp = thisComp.layer("データ").source;
textList = dataComp.layer("intermediate").text.sourceText.valueAtTime(0).split("\r");
baseIndex = thisComp.layer("base").index;
dataIndex = index - baseIndex - 1;
if(textList == null || dataIndex >= textList.length) return "";
viewDuration = dataComp.layer("resource").effect("VIEW_DURATION")(1).valueAtTime(0);
maxLineNum = thisComp.numLayers - baseIndex;
for( ; dataIndex < textList.length; dataIndex += maxLineNum ) {
dataList = textList[dataIndex].match(/(\d+?),(\d+?),(.+)/);
if( dataList == null ) return "";
viewTime = parseInt(dataList[2]);
if( time < viewTime ) break;
if( time >= viewTime + viewDuration ) continue;
return dataList[3];
}
return "";
},
getViewPos: function()
{
dataComp = thisComp.layer("データ").source;
textList = dataComp.layer("intermediate").text.sourceText.valueAtTime(0).split("\r");
baseIndex = thisComp.layer("base").index;
dataIndex = index - baseIndex - 1;
if(textList == null || dataIndex >= textList.length) return [0, 0];
resourceLayer = dataComp.layer("resource");
viewDuration = resourceLayer.effect("VIEW_DURATION")(1).valueAtTime(0);
maxLineNum = thisComp.numLayers - baseIndex;
for( ; dataIndex < textList.length; dataIndex += maxLineNum ) {
dataList = textList[dataIndex].match(/(\d+?),(\d+?),(.+)/);
if( dataList == null) return [0, 0];
viewTime = parseInt(dataList[2]);
if( time < viewTime ) break;
if( time >= viewTime + viewDuration ) continue;
targetIndex = parseInt(dataList[1]);
viewRatio = 1 - (time - viewTime) / viewDuration;
textWidth = dataList[3].length * resourceLayer.effect("TEXT_WIDTH")(1).valueAtTime(0);
textHeight = resourceLayer.effect("TEXT_HEIGHT")(1).valueAtTime(0);
return [viewRatio * (thisComp.width + textWidth) - textWidth, textHeight * (thisComp.numLayers - targetIndex - baseIndex)];
}
return [0, 0];
}
};
eval(thisComp.layer("function").text.sourceText.value);
FUNCTION.makeData();
eval(thisComp.layer("データ").source.layer("function").text.sourceText.valueAtTime(0));
FUNCTION.getViewText();
eval(thisComp.layer("データ").source.layer("function").text.sourceText.valueAtTime(0));
FUNCTION.getViewPos();
###【余談】
テキストレイヤーの現在の高さ&幅ってエクスプレッション側から取れないみたいね。
「sampleImage使ってピクセル走査して幅を調べたわ」って人がいて…おぉう…ってなった。
そこまですれば確かに厳密な幅が取れるだろうけど、今回レンダリング時間もそんなにかけたくないし、処理の軽量化かねて自分で幅を定義&算出することにした。
とはいえ等幅フォントじゃないし、大体なんだけどね…
処理速度がレンダリング時間にダイレクトアタックなのも悩みどころだったね。
軽く検証してみたら、正規表現での字句解析がゲロ重だったから、極力軽くなるように解析対象文字列数を減らしたり実行回数を減らしたりした。
その関係で、実は途中で結構デカめのリファクタリングもした。
具体的には…
元々、中間データとして『現在表示するコメントを表示順で出力』していたんだけども、これだと毎時間元データを参照&解析しなきゃいけない状態だった。
これを、1フレーム目の時点で全コメントを走査して『表示する時間と表示インデックスを出力』するようにした。
もひとつおまけに、プリコンポジット化してデュレーションを1にしたものを、レイヤーとして配置&タイムリマップで1フレーム目で停止させる、まで徹底してみた。
一応これで毎フレーム中間データを出力する処理が走ることはなくなったし、軽くはなったっぽい。
他にあったことと言えば…
6時間の動画にもなってくると、読み込み時にAfterEffectsに
『After Effects エラー: オーバーフロー比分母変換( 17 、 18 )』
つって、怒られてね。
Media Encoderで、
音だけの6時間分aacファイル、
映像だけの1時間分mp4ファイル×複数、
を出力しなおして、それをAfterEffectsで編集した。
音ズレもしてたから映像側をタイムリマップで無理矢理調整したりもした。
結果的に時々映像にノイズが入っちゃってたので、それはそれで別途反省。
##4. MediaEncoderでレンダリング
前までAfterEffectsからそのままmp4を出力できてたんだけどね。
CCになってからか、めんどくさいことに、できなくなってるんだよね。
ということで、あまり使ったことのないMediaEncoderのお世話になった。
AtferEffectsで作業しててもレンダリングを進めてくれるから、作業の手を止めなくて済んだね。
まあでも多分、ちゃんとした方法でレンダリングをマルチタスク化するとか、無理矢理別タスクのAfterEffectsを立ち上げて動かすとか、贅沢なので言えばレンダリングマシンを用意するとか、レンダリングの効率化自体はいろいろ方法があると思うから、自分がやった方法は微妙なんだろうなとも思う。
3Dバリバリでもないし、今のところそこまで苦労したことはないから、まあいいんだけど…
##5. ニコ動に投下
そのまんま。出力結果をニコ動にダンクシュート。
ざっと、以上。
かなり概要すぎて、伝えるための情報になってないけど…まあ、自分用のメモって意味合いの方が強いから、いっか(酷