VRChat + AWS Serverless で何かやりたかった話

はじめに

本記事は VRChat Advent Calendar 2021 の 7日目の記事です。

みなさんVRChatを利用する際にYoutubeの動画をよく見られますよね。
例えば「特定のYoutuberの最新の動画を見たい」といった場合、直接URLを入力したり
ワールド作成者であれば、プレイリストシステムを内蔵しているVideoPlayerに入力して
その度再アップロードをする、みたいになるのではないでしょうか。

正直、煩雑ではないでしょうか。

それを解消できないかと思い同一URLから常に最新の動画を参照出来れば
解決できるのではないかと思い
仕組みを以下のような感じで考えました。

今回はそれの紹介と実装についての解説です。

アーキテクチャイメージ

利用ツール、技術など

  • 言語
    • Python
  • サーバ環境
    • AWS
  • デプロイツール
    • Serverless Framework

表示する動画情報にはYoutubeのRSSを利用しております。

この記事での技術要素

  • Pythonでの画像生成
  • Pythonでの画像からの動画作成
  • API Gateway + Lambdaでの動画のレスポンス
  • ServerlessFrameworkで非LambdaProxyなAPIの作成

※なんとなく作成しててしんどかった部分になります

各機能紹介

API/バックエンド処理について

特段面白いことはやってない部分は基本的にソースは割愛します。

1.チャンネル登録API

チャンネル登録APIアーキテクチャ

内容としてはチャンネルIDをPOSTで受け取って存在確認。
存在していなければエラー
存在していればDBに登録しクライアントにチャンネル名を返します。

1
2
3
4
5
6
7
8
9
10
11
12
13
post_yt_channel_regist:
handler: src/lambda/post_yt_channel_regist/handler.main
package:
include:
- src/lambda/post_yt_channel_regist/handler.py
exclude:
- src/**
environment:
VIDEO_TABLE: ${self:custom.video_table}
events:
- http:
path: /video/yt/channel/regist
method: post

もともとは登録用Webサイトを作成してブラウザで登録する感じを考えていたのですが
ブラウザでURL生成->Unityでプレイリストにひたすらコピペがしんどすぎたので
UnityのEditor拡張を作成してそれで登録+自動でプレイリストの中身を挿入するようにしました。

Hoshino Labs.様が公開しているiwaSync3 メディアプレイヤー
および
KineL様が公開しているKineL式VideoPlayer (SDK3)
向けに作成しております。

作成したEditor拡張は以下で公開してます。

iwaSync3用
https://aki-lua87.booth.pm/items/3443008

KineL式用
https://aki-lua87.booth.pm/items/3271864

2.登録動画更新バッチ

登録動画更新バッチアーキテクチャ

毎時起動しDBにある動画URLのデータを更新してます。
更新時に目次作成フラグをクリアし、後述の動画リスト作成/取得APIでコール時に最新の動画データで目次を作り直すようにしてます。

1
2
3
4
5
6
7
8
9
10
11
12
batch_yt_channel_video_update:
handler: src/lambda/batch_yt_channel_video_update/handler.main
timeout: 600
package:
include:
- src/lambda/batch_yt_channel_video_update/handler.py
exclude:
- src/**
environment:
VIDEO_TABLE: ${self:custom.video_table}
events:
- schedule: cron(30 * * * ? *)

3.動画取得API

動画取得APIアーキテクチャ

プレイリストに登録されてVRChatからコールされるAPIです。
実際に本APIでYoutubeの動画を返しているわけではなくDBに登録されている
動画のURLにリダイレクトするようにレスポンスを返しています。
また、VRChatからの動画呼び出しを見ているとGETではなくまずはHEADがコールされており、HEADリダイレクトでYoutubeに飛ばしてあげることで動画の再生を確認できたのでこの方式にしてます。
(※PC版での挙動です。再生されないという報告があったのでQuestではまた違うかもしれません。。。)

1
2
3
4
5
6
7
8
9
10
11
12
13
get_yt_video:
handler: src/lambda/get_yt_video/handler.main
package:
include:
- src/lambda/get_yt_video/handler.py
exclude:
- src/**
environment:
VIDEO_TABLE: ${self:custom.video_table}
events:
- http:
path: /videos/yt/ch/{channel_id}
method: head

リダイレクト部分

1
2
3
4
5
6
7
8
9
return {
'headers': {
"Content-type": "text/html; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"location": urls[n]['urls']
},
'statusCode': 302,
'body': "",
}

4.動画リスト作成/取得API

動画リスト作成/取得APIアーキテクチャ

目次の部分です。

多分一番重い部分です。

Unityでのアップロード時には動画の内容が確定していないため、1~15の番号を振ってアップロードしてますが、これではどれが何の動画かわからないためそれらを参照できるように本機能を作成しました、

Pillowで画像を生成し、OpenCVで動画にしています。
動画の生成タイミングとしては、動画更新後の最初のリクエスト時です。

動画取得APIとは違いGETで返してますがLambda+API Gatewayで動画データを返すやつをServerlessFrameworkでやってみたかったのでこうなってます。
ちゃんと考えるのであればS3のパブリック公開やCloudFrontなどを利用してそこにリダイレクトさせるのがいいかもしれません。
特にCloudFrontは12か月の無料期間の制限や無料枠自体の拡充されたのもあるためゆくゆくは移行したいですね
参考: AWS 無料利用枠のデータ転送量の拡大

あとは、少々見づらかったりほかの動画を見ながらの確認ができないため、文字列をべた書きした画像を表示させるのではなく別途動画+色情報などを利用した情報の受け渡しをしてもっと見やすい媒体でVRC上で表示するのもアリかなとも考えてますが正直実装がしんどそうなのと、別途VideoPlayerの配置が必要でLateLimitの制限を気にする必要があることや、既存の動画プレイヤーに簡単に追加できる機能ではないことからいったんは現状の仕組みを採用してます。

動画+色情報での情報の受け渡しについて丁度VRChat Advent Calendar 2021 day 3の記事にて神城アオイ様が書かれていましたので紹介させていただきます。
https://kamishirolab.com/archives/215
【VRCAdvent Calendar】VRChatへ外部から文字列を渡すよ(C# only) - 神城研究室

4-1. 画像生成部分

ベースとなる画像をLambda関数に含めてアップロードしてそれに文字を挿入しています。
(現在は真っ白ですがなんかイラストとかあるものに変えたいな)
日本語フォントが必要だったため漫画用フォント「新コミック体」を利用させていただいてます。
文字が見えなかったり上下幅を超えたり左右幅を超えたりしたのを雑にパラメータ調整しまくった結果結構限界ハードコーディングになっちゃってますね。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def create_image(video_list):
image = Image.open('./images/template169.jpg')
header1 = '現在のプレイリストの動画'
line_pos = 5
add_text_to_image(image, header1, './font/f910-shin-comic-2.04.otf',
75, textRGB, line_pos, 125, 20000)
if len(video_list) == 0:
print('[INFO] No Video data')
return
print(video_list)
line_pos = line_pos + 20
font_size = 34
str_max_count = 62
for i in range(len(video_list)):
line_pos = line_pos + 50
titles = video_list[i]['titles']
text = f'{i+1}: ' + titles
# 文字数が横枠超えるので改行
if len(text) > str_max_count:
add_text(
image, text[:str_max_count], './font/f910-shin-comic-2.04.otf', font_size, textRGB, line_pos, 90, 20000)
line_pos = line_pos + 35
add_text(
image, ' '+text[str_max_count:], './font/f910-shin-comic-2.04.otf', font_size, textRGB, line_pos, 95, 20000)
continue
add_text(image, text, './font/f910-shin-comic-2.04.otf',
font_size, textRGB, line_pos, 90, 20000)
# 画像を保存
image.save(local_imege_path)

def add_text(img, text, font_path, font_size, font_color, height, width, max_length=1000):
position = (width, height)
font = ImageFont.truetype(font_path, font_size)
draw = ImageDraw.Draw(img)
if draw.textsize(text, font=font)[0] > max_length:
while draw.textsize(text + '…', font=font)[0] > max_length:
text = text[:-1]
text = text + '…'
draw.text(position, text, font_color, font=font)
return img

4-2. 動画生成部分

OpenCVで画像を動画に変更しています。当初は1フレームの動画を生成していたのですが、昨今の主要な動画プレイヤーが自動で次の動画を再生や自動リピートなど高機能になっていたため現在は1秒1フレームで10秒の動画を作成しています。
作成した動画はこの後S3に保存してます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def create_one_frame_video(input_imege, output_video):
# OpenCV設定
fourcc = cv2.VideoWriter_fourcc('m', 'p', '4', 'v')
video = cv2.VideoWriter(output_video, fourcc, frame_rate, (width, height))
for idx in range(10):
# イメージデータの領域確保
img = cv2.imread(input_imege)
img = cv2.resize(img, (width, height))
video.write(img)
# OpenCVでMP4描写
img = cv2.imread(input_imege)
img = cv2.resize(img, (width, height))
# ローカルに書き込み
video.write(img)
video.release()

4-3. 動画返却部分

直接動画をレスポンスしてます。

handler関数のreturnで以下関数をコールしてます。
Proxyに慣れるとちょっと新鮮ですね。

1
2
3
4
5
6
7
8
def get_s3_video(bucket_name, channel_id):
path = 'yt/channel/'+channel_id+'.mp4'
print('getS3Video', bucket_name, path)
bucket = s3.Bucket(bucket_name)
obj = bucket.Object(path)
response = obj.get()
body = response['Body'].read()
return base64.b64encode(body)

以下は HikakinTV のYoutubeチャンネルを例に作成した動画です、こんな感じのが流れます。

4-4. ServerlessFramework定義

ポイントとしては動画を返すため、eventsで非LambdaProxyとなるようにAPIを設定しなければいけません。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
get_yt_video_list:
handler: src/lambda/get_yt_video_list/handler.main
timeout: 30
package:
include:
- src/lambda/get_yt_video_list/handler.py
exclude:
- src/**
environment:
S3_BUCKET: ${self:custom.bucket}
VIDEO_TABLE: ${self:custom.video_table}
events:
- http:
integration: lambda
path: /videos/yt/vlist/{channel_id}
method: get
response:
headers:
Content-Type: "'binary/octet-stream'"
contentHandling: CONVERT_TO_BINARY

終わりに

以上、サービスおよびその仕組みの紹介でした。
前述のエディタ拡張から簡単に利用できるかと思いますのでよろしければ使ってみてください。

以前とあるワールドで動画のプレイリストを見かけてこれを閃きノリと勢いで実装した感じになります。
(すばらしい動画プレイヤーシステムを作っていただきありがとうございます・・・!)

自室ワールドでぼんやりながめてたらなんか思ったより便利な気がしてきて知人にも勧められたため今回アドベントカレンダーの記事として執筆させていただきました。
突貫で作ったので技術検証や実装に置いてかなり甘い部分がありますがご了承ください。

以上です。