前書き的なもの
以前より以下の記事で分かるように自前のシステムだけでEPGStationで録画したアニメをニコニコ実況のコメントと共に楽しめるように奮闘してきた。
ニコニコ実況(生放送)のコメントをNodejsで取得してみた
EPGStation連動でニコニコ実況(生放送)のコメントを自動取得できるようにしてみた
それがとりあえずではあるが何とか形になってしばらく運用して問題も無いまま、記事にするのも忘れていたので今更ながらに奮闘した成果を一部の方向けに共有出来ればと残して置く。
ちなみに今回のコメント再生の動作確認環境はChrome、Fire TV Stick 4K、iOS Safariのみです。
さらに言うと、コメント再生はJavascript実装なので動画の全画面再生時にはコメント表示されません。
構成
以前の記事 EPGStation連動でニコニコ実況(生放送)のコメントを自動取得できるようにしてみた で使用したコメント自動取得とは別に、EPGStationで録画・エンコードしたmp4を直接配信するサーバーをpm2で立ち上げて視聴ページにニコニコ実況を保存したjsonからコメントを流すjavascriptを仕込む形で実現。
参考に適当なアニメの程よくコメントが騒がしい1場面
(怒られない様にメインキャラのいない場面でなるべく低画質になってます)
実際の様子を確認できるように以下の自前公開サーバーに適当な録画からCMとコメントを切り出した1分程のデモを用意しました。
(映像は原型が分からない様にモザイク処理し音声はミュートしてあります)
*プレイヤー画面全体で画面クリックによる再生停止に対応しました。
*デモにてコメントが流れない場合は、コメントjsonが読み込まれていない可能性があるのでリロードしてページを読み込み直してください。
↓以下がpm2で実行しているmp4を直接配信しているサーバーのスクリプト
使用しているnodeモジュールは「date-utils」と「express」のみ。
const express = require("express");
const app = express();
const port = 18080;
const fs = require('fs');
require('date-utils');
app.get('/', (req, res) =>{
res.send(g_main_html());
});
app.get('/player', (req, res) =>{
file_name = req.query.file;
come_file = req.query.come;
//let readstream = fs.createReadStream('../EPGStation/recorded/'+file_name);
//readstream.pipe(res);
video_html = '<head>\n<title>'+file_name+'</title>\n<meta charset="UTF-8">\n<!-- Player -->\n<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/clappr/0.2.86/clappr.min.js"></script>\n<link rel="stylesheet" href="content/mp4sv.css">\n<script>\nlet jsonData;\nlet xhr = new XMLHttpRequest();\nxhr.open("get", "/come_json/'+encodeURIComponent(come_file)+'");\nxhr.send();\nxhr.onreadystatechange = () => {\n if (xhr.readyState === 4 && xhr.status === 200){\n jsonData = xhr.responseText.split(/\\n/);\n jsonObject = JSON.parse(jsonData[0]);\n console.log(jsonObject);\n }\n else{\n jsonData = "none";\n }\n}\nxhr.onload = () => {\n if(xhr.status === 404){\n jsonData = "none";\n }\n}\n</script>\n</head>\n<body>\n<div class="v_box">\n<div id="player">\n</div>\n<div id="coment" class="c_box">\n</div>\n</div>\n<script>\nlet player = new Clappr.Player({\n source: "/video/'+encodeURIComponent(file_name)+'",\n parentId: "#player",width: "100%",height: "100%",\n playback: {\n playInline: true,\n }\n});\n</script>\n<script type="text/javascript" src="content/mp4sv.js"></script>\n</body>'
res.send(video_html);
console.log(file_name);
});
app.use('/video', express.static('/home/pi/node/EPGStation/recorded/'))
app.use('/come_json', express.static('/home/pi/node/nodejs/files/'))
app.use('/content',express.static('/home/pi/node/mp4sv/content'))
app.listen(port, () => {
console.log(`listening at http://localhost:${port}`);
});
function g_main_html(){
snd_html = '<html>\n<head>\n<title>EPGStation Recorded /</title>\n</head>\n<body>\n<h1>EPGStation Recorded /</h1>\n<hr>\n<pre>\n';
let v_list = [];
v_list = fs.readdirSync('/home/pi/node/EPGStation/recorded');
c_list = fs.readdirSync('/home/pi/node/nodejs/files');
for(let k in v_list){
let json_file;
for(let v in c_list){
if(v_list[k].slice( 21 ).slice( 0, -4 ).replace(/\?/g,'?').replace(/\!/g,'!').replace(/\:/g,':') == c_list[v].slice( 8 ).slice( 0, -5 )){
json_file = c_list[v];
break;
}
}
if(!json_file){
snd_html += '<a>[none]</a><a href="/player?file='+encodeURIComponent(v_list[k])+'">'+v_list[k]+'</a>\n';
}
else{
snd_html += '<a href="/come_json/'+encodeURIComponent(json_file)+'">[json]</a><a href="/player?file='+encodeURIComponent(v_list[k])+'&come='+encodeURIComponent(json_file)+'">'+v_list[k]+'</a>\n';
}
}
snd_html += '</pre>\n<hr>\n</body>\n</html>';
return snd_html;
}
mp4サーバーの動きとしてはブラウザでアクセス時にEPGstationの録画ファイルを一覧で列挙し、また保存されたコメントjsonが存在するかどうかを表示。
UI等は面倒だったのでとりあえず動けばヨシ!状態。
一覧ページで動画ファイルをクリックすると動画の再生ページへ飛び、mp4ファイルとコメントファイルが存在すればコメントjsonファイルも読み込まれる。
ちなみにプレイヤーはClappr playerを利用したプレイヤーだけのシンプル仕様
↓以下が再生ページに仕込んでるコメント再生用javascriptとcss
let come_cont = 1;
let come_run = 0;
let try_ch= [0,0,0];
let come_layer = 7;//Max_11
let come_slot = 10;
let come_drop = 1;
let come_wait = 8;
let come_char_wait = 0.25;
let come_offset = 4;
let come_timer = Array(come_layer*10+1);
let come_slot_end = Array(come_slot);
come_timer.fill(-11);
come_slot_end.fill(0);
come_slot_end[0] = 1;//0参照防止
for(let gen_layer = 0; gen_layer < come_layer; gen_layer++){
let inst_slot = "";
for(let gen_slot = 1; gen_slot <= come_slot; gen_slot++){
inst_slot += " <div id=\"coment"+(gen_layer*10+(gen_slot))+"\"><p> </p></div>\n";
}
document.getElementById("coment").innerHTML += "<div class=\"come_"+gen_layer+"\">\n"+inst_slot+"</div>";
}
document.querySelector("video").addEventListener("play", (event) => {
console.log("Anime_Running");
come_run = 1;
for(let anipuse in document.getElementsByTagName("p")){
document.getElementsByTagName("p")[anipuse].style.animationPlayState = "running";
console.log("p["+anipuse+"]:Anime_Running");
}
});
document.querySelector("video").addEventListener("pause", (event) => {
console.log("Anime_Pause");
come_run = 0;
for(let anipuse in document.getElementsByTagName("p")){
document.getElementsByTagName("p")[anipuse].style.animationPlayState = "paused";
console.log("p["+anipuse+"]:Anime_Pause");
}
});
document.querySelector("video").addEventListener("seeked", (event) => {
console.log("Anime_Pause");
for(let anipuse in come_timer){
document.getElementById("coment"+anipuse).innerHTML = "";
come_timer[anipuse] = -11;
console.log("Seeked :All Comennt Slot Clear");
}
come_slot_end.fill(0);
come_slot_end[0] = 1;//0参照防止
let seeked_pos = document.querySelector("video").currentTime;
come_cont = 1;
while(true){
if(seeked_pos <= (((JSON.parse(jsonData[come_cont]).chat.date * 1000)-JSON.parse(jsonData[0]).StartTime)/1000)+come_offset){
break;
}
else{
come_cont++;
}
}
});
setTimeout(function() {
while(jsonData == void 0){
console.log("json read wait.");
}
if(jsonData != "none"){
setInterval(function() {
try{
while((document.querySelector("video").currentTime) >= (((JSON.parse(jsonData[come_cont]).chat.date * 1000)-JSON.parse(jsonData[0]).StartTime)/1000)+come_offset){
if(come_run){
for(let t = 1; t <= (come_layer*come_slot); t++){
if((document.querySelector("video").currentTime - come_timer[t]) >= come_wait ){
//1文字 0.25秒
try_ch[0] = t;
try_ch[1] = ((t%come_slot)+(come_slot_end[t%come_slot]*come_slot));
try_ch[2] = come_slot_end[t%come_slot]*come_slot;
if((document.querySelector("video").currentTime - come_timer[((t%come_slot)+(come_slot_end[t%come_slot]*come_slot))] >= document.getElementById("coment"+((t%come_slot)+(come_slot_end[t%come_slot]*come_slot))).textContent.length*come_char_wait)){
let come_slot_number = t;
let f_color = "non_color";
let style_option = "";
if(JSON.parse(jsonData[come_cont]).chat.mail != void 0){
let come_cmd = (JSON.parse(jsonData[come_cont]).chat.mail).split(/\s/);
for(let cmd in come_cmd){
if(/\b(white|red|pink|orange|yellow|green|cyan|blue|purple|black)\b/.test(come_cmd[cmd])){
if(f_color=="non_color"){
f_color = come_cmd[cmd];
}
else{
f_color += " "+come_cmd[cmd];
}
}
else if(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(come_cmd[cmd])){
style_option = " style=\"color: "+come_cmd[cmd]+";\"";
}
else if(/\b(shita)\b/.test(come_cmd[cmd])){
come_slot_number = come_slot;
if(f_color=="non_color"){
f_color = come_cmd[cmd]
}
else{
f_color += " "+come_cmd[cmd]
}
}
else{
console.log(come_cmd[cmd]);
}
}
}
document.getElementById("coment"+come_slot_number).innerHTML = "<p class=\""+f_color+"\""+style_option+">"+JSON.parse(jsonData[come_cont]).chat.content+"</p>";
come_timer[come_slot_number] = document.querySelector("video").currentTime;
come_slot_end[come_slot_number%come_slot] = come_slot_number/come_slot|0;
come_drop = 0;
break;
}
}
}
come_cont++;
if((document.querySelector("video").currentTime - come_timer[come_slot]) >= come_wait ){
document.getElementById("coment"+come_slot).innerHTML = "";
}
if(come_drop == 1){console.log("coment Drop!!");}
come_drop = 1;
}
else {
break;
}
}
}
catch(e){
console.log(try_ch);
}
},100);
}
else{
console.log("json none read.");
}
},100);
.v_box {
position: relative;
}
.c_box {
position: absolute;
top: 0;
left: 0;
margin : 1px auto;
width : 100%;
color: #FFF;
font-size: 260%;
font-weight: bold;
font-family: sans-serif;
-webkit-text-stroke: 1px #000;
text-align : center;
}
.c_box p{
margin: 0;
display : inline-block;
padding-left: 100%;
white-space : nowrap;
line-height : 1em;
animation : scrollcome 8s linear;
}
@keyframes scrollcome{
0% { transform: translateX(0)}
100% { transform: translateX(-100%)}
}
.come_0 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 11;
}
.come_1 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 10;
}
.come_2 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 9;
}
.come_3 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 8;
}
.come_4 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 7;
}
.come_5 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 6;
}
.come_6 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 5;
}
.come_7 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 4;
}
.come_8 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 3;
}
.come_9 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 2;
}
.come_10 {
position: absolute;
top: 0;
left: 0;
margin : 1% auto;
width : 100%;
overflow : hidden;
z-index: 1;
}
.non_color {
color: #FFF;
-webkit-text-stroke: 1px #000;
}
.white {
color: #bbb;
-webkit-text-stroke: 1px #000;
}
.red {
color: #c00000;
-webkit-text-stroke: 1px #000;
}
.pink {
color: #cc6664;
-webkit-text-stroke: 1px #000;
}
.orange{
color: #ce9c00;
-webkit-text-stroke: 1px #000;
}
.yellow {
color: #bb0;
-webkit-text-stroke: 1px #000;
}
.green {
color: #01cc00;
-webkit-text-stroke: 1px #000;
}
.cyan {
color: #00cece;
-webkit-text-stroke: 1px #000;
}
.blue {
color: #0000ce;
-webkit-text-stroke: 1px #FFF;
}
.purple {
color: #9b00cc;
-webkit-text-stroke: 1px #FFF;
}
.black {
color: #000;
-webkit-text-stroke: 1px #FFF;
}
.white2 {
color: #a5a679;
-webkit-text-stroke: 1px #000;
}
.red2 {
color: #a60025;
-webkit-text-stroke: 1px #000;
}
.pink2 {
color: #cc1ea5;
-webkit-text-stroke: 1px #000;
}
.orange2 {
color: #ce5200;
-webkit-text-stroke: 1px #000;
}
.yellow2 {
color: #7c7c00;
-webkit-text-stroke: 1px #000;
}
.green2 {
color: #00a54e;
-webkit-text-stroke: 1px #000;
}
.cyan2 {
color: #00a3a4;
-webkit-text-stroke: 1px #000;
}
.blue2 {
color: #2278cc;
-webkit-text-stroke: 1px #FFF;
}
.purple2 {
color: #5221a6;
-webkit-text-stroke: 1px #FFF;
}
.black2 {
color: #525252;
-webkit-text-stroke: 1px #FFF;
}
.shita {
margin: 0;
display : inline-block;
padding: 0 !important;
white-space : nowrap;
line-height : 1em;
text-align : center;
animation: none !important;
transform: none !important;
}
コメント再生用javascriptはコメントjsonを先頭から読み込んで、コメント投稿時間のUNIX TIMEを放送開始時間と動画の再生時間を利用して比較しAnimationの流れる文字で描写。
コメント数が多すぎて詰まり処理しきれない場合やコメント以外のデータ等はブラウザが固まってしまうので潔く放棄してますので悪しからず...。
動画再生枠内にてコメントが流れる1行をスロットとして定義し10スロットを用意、さらに10スロットを1レイヤーとして複数レイヤーを用意し重ねることでAnimationを利用しつつ1行に複数のコメントを流せるように設計。
また、コメントがなるべくぶつからない様に描写したコメントを簡易的に時間ベースで個別管理もしています。
コメントコマンドに色指定などが存在する場合はHTMLに挿入する<P>タグに事前定義した色のクラスを付与するか、直接カラーコードの場合はスタイル属性にて色指定。
初めの方に追加されているイベントリスナーは動画の再生、一時停止に追従してコメントの再生や一時停止、さらに動画シーク時にコメントをリセットしてシーク後の再生時間に合わせてコメント描写を再開する為に利用。
あと録画時のプリギャップ(放送開始4秒前から録画など)に対応するためにcome_offset変数でオフセット値も用意してますが、環境に合わせてセットすれば以降は変更の必要ないかなと。
後書き的なもの
全体的に継接ぎ感満載で汚いスクリプトですがとりあえず動き、コメント付きで録画が見られるようになっただけで満足しちゃったので、今後は問題点や改良点が見つかる度にちょくちょくいじりながらインターフェースも作っていこうかと。