動画のプレイリストの内容をスプレッドシートで管理する

はじめに

※※※信頼されたドメインは動的に作成できるようになるらしいのでAWS部分内容を割愛しました※※※
※※※上記機能実装後AWSを挟まない形で実装しなおす予定※※※

本記事はVRChat内の動画プレイリストの内容をスプレッドシートで管理する方法についての解説です。
対象のVideoPlayerはKineL式VideoPlayerです。

スプレッドシートの内容はAPIを通じて公開されることになります。
また、スプレッドシートのみでは解決できない部分があるため、サーバサイドとしてAWSを利用しています。

アーキテクチャ図
スプレッドシートイメージ

スプレッドシートイメージ

このようなスプレッドシートが
スプレッドシートイメージ

スプレッドシートイメージ

こんな感じでVRChat内で表示されるようになります。
VRChatイメージ

VRChatイメージ

利用ツール、技術など

  • 言語
    • Python、C#(Udon)
  • バックエンド
    • Googleスプレッドシート、AWS
  • その他
    • Unity、Serverless Framework

概念さえ理解できてれば、PythonやAWSの知識は必要なく他のもので代用は利くので必須じゃないです。

手順

1. スプレッドシートの作成

以下をAppsScriptで作成します。
デプロイしてURLを取得しておきます。
https://script.google.com/macros/s/AKhogehohehogehohehogehohehogehohe/exec
のAKhogehohehogehohehogehohehogehohe部分が分かれば大丈夫です。
詳細なスプレッドシートの内容は本記事では割愛します。

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
41
42
43
44
45
46
47
48
function doGet(e) {
const mode = (typeof e === "undefined" || typeof e.parameter.mode === "undefined")?"NONE":e.parameter.mode;
const sheet = SpreadsheetApp.getActive().getSheetByName("main");
var res;
if(mode == "titles") res = getAllTitles(sheet);// Create
if(mode == "url") res = getURL(sheet, e.parameter.id);
if(mode == "NONE") res = JSON.stringify(getAll(sheet), null, 2);
console.log(res);
return ContentService.createTextOutput(res).setMimeType(ContentService.MimeType.JSON);
}

function getAllTitles(sheet){
console.log("getaAllTitles!!");
const start_row = 2,start_col = 1 // 開始行,開始列 --> A1なら(1,1)、A2なら(2,1)
const last_col = 1;
const last_row = 21; // 変なとこに変な値とかあるとおっもくなるので決め打つ
const datas = sheet.getRange(start_row, start_col, last_row, last_col).getValues();
var list = "";
for(let a of datas){
console.log(a);
for(let b of a){
if(b != ""){
if(list != "") list += ",";
list += b;
}
}
}
return list;
}

function getURL(sheet, id){
console.log("getTitleForID!!");
values = sheet.getDataRange().getValues();
return values[id][1]
}

function getAll(sheet) {
console.log("getTitleForID!!");
const rows = sheet.getDataRange().getValues();
const keys = rows.splice(0, 1)[0];
return rows.map(row => {
const obj = {};
row.map((item, index) => {
obj[String(keys[index])] = String(item);
});
return obj;
});
}

「はじめに」の画像にあるようなスプレッドシートを入力します。
A2からA11までに動画のタイトルを入力します。
B2からB11までに動画のURLを入力します。

2. AWSリソースの作成

以下の2つのLambdaを作成しています。作成済みで利用できるものを用意してるため割愛して大丈夫です。
https://github.com/aki-lua87/vrc_video_playlist_server/blob/master/src/lambda/get_ss_video/handler.py
https://github.com/aki-lua87/vrc_video_playlist_server/blob/master/src/lambda/get_ss_video_titles/handler.py

3. Unity側の実装

Unityでスクリプトを作成

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
using System.Globalization;
using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDKBase;
using VRC.Udon;
using VRC.Udon.Common.Interfaces;
using VRC.SDK3.StringLoading;

public class VideoTitleLoader : UdonSharpBehaviour
{
[SerializeField] private GameObject targetPlaylist;
[SerializeField] private VRCUrl stringURL;

// NOTE: KVPPlaylist/Canvas/PlaylistImpl/Scroll View/Viewport/Content
// NOTE: Video (0)/Description.Text.text

public void LoadString()
{
VRCStringDownloader.LoadUrl(stringURL, (IUdonEventReceiver)this);
}

public override void OnStringLoadSuccess(IVRCStringDownload download)
{
Debug.Log("VideoTitleLoader: OnstringLoadSuccess");
var titlesString = download.Result;
var titles = titlesString.Split(',');
// text1.text = titles[0];
// contentを取得
var content = targetPlaylist.transform.Find("KVPPlaylist/Canvas/PlaylistImpl/Scroll View/Viewport/Content");
// Debug.Log(content.gameObject.name);
// 配下のvideo(N)を取得
var videos = GetChildren(content);
// Debug.Log(videos[0].name);
Debug.Log("titlesString" + titlesString);
for (var i = 0; i < videos.Length; i++)
{
if (titles.Length < i)
{
break;
}
var descriptionText = videos[i].Find("Description").gameObject.GetComponent<Text>();
descriptionText.text = titles[i];
Debug.Log(videos[i].name + " => " + titles[i]);
if (titles[i].Length < 25)
{
descriptionText.fontSize = 40;
}
else
{
descriptionText.fontSize = 23;
}
}
}

public override void OnStringLoadError(IVRCStringDownload result)
{
Debug.Log($"VideoTitleLoader: OnStringLoadError {result.ErrorCode} {result.Url} {result.Error}");
}

// parent直下の子オブジェクトをforループで取得する
private Transform[] GetChildren(Transform parent)
{
// 子オブジェクトを格納する配列作成
var children = new Transform[parent.childCount];
// 0~個数-1までの子を順番に配列に格納
for (var i = 0; i < children.Length; ++i)
{
children[i] = parent.GetChild(i);
}
// 子オブジェクトが格納された配列
return children;
}
}

GameObjectにVideoTitleLoaderをアタッチ、targetPlaylistにはプレイリストのGameObjectをアタッチします。
URL欄には以下を入力します。AKhogehohehogehohehogehohehogehoheはデプロイしたスプレッドシートのURLの一部です。
https://vrc.akakitune87.net/ss/titles?ssid=AKhogehohehogehohehogehohehogehohe

Unityイメージ

Unityイメージ

プレイリストのURLを入力

Unityイメージ

Unityイメージ

タイトルは何でもいいです
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=1
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=2
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=3
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=4
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=5
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=6
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=7
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=8
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=9
https://vrc.akakitune87.net/ss/video?ssid=AKhogehohehogehohehogehohehogehohe&n=10

プレイリストのタブを修正

Unityイメージ

Unityイメージ

KineLVP Playlist(tab)/KinelVP TabList Impl/Tablist/Scroll View/Viewport/Content[プレイリスト名]/Dummy
のインスペクタよりOnValueChangedに画像のようにスクリプトをアタッチします。

Unityイメージ

Unityイメージ

アタッチするスクリプトは「 Unityでスクリプトを作成」で作成したものです。

4. VRChat内で確認

  • 当該のタブを選択すると、スプレッドシートの内容が表示されることを確認
  • プレイリスト押下でスプレッドシートで入力した動画が再生されることを確認

おわりに

弊環境では問題なく動いてますが複数環境での動作確認はしていません、問題などありましたらご報告いただけると助かります。
上記システムを利用する際は自己責任でお願いします。

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アーキテクチャ

チャンネル登録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アーキテクチャ

動画取得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アーキテクチャ

動画リスト作成/取得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

終わりに

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

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

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

以上です。