Readroom についての雑記

Readroom-りどるーむ-

Readroom

Readroom

※※※この記事にはネタバレが含まれます※※※

はじめに

Readroom
りどるーむ

謎解きも出来るChillワールド、だと思います。

ワールド名は”Riddle” + “Room” から来ています。
単純にRiddleRoomでも良かったのですが、明らかに謎解きワールドっぽい名前になるのは避けてひとひねりしてます。
(あくまでもChillワールドなので)

一説によると「Read the room」で「空気を読む」という意味になるようで、謎解きであることを読み取るというニュアンスがあるとか

また別の説では各開発者の名前のイニシャル

R: ヒャクアシさんの別名義のイニシャル
e: 円フツさんのイニシャル
a: aki_lua87のイニシャル
d: らぃむさんのIDのイニシャル(dimebag29)

だとか。


元々は「ギミック製作者何人かで謎解きっぽいの作って持ち寄れば謎解きワールドができるんじゃね?」みたいな雑談から始まったような気がします。

Kano’s Lake をみんなで解いた際にぽっと話が出始め
個人的にもApril Waters を訪れた際に「和み雑談する人」と「謎解きを進める人」が共生できるのいいなぁと思っていたので
Chillワールド+謎ギミックみたいな方針で作成が始まりました。

まぁ、Chillワールド成分は全部円フツさんにやってもらったわけですが
まぁ、めっちゃいいワールドになっててすっげえなと思いました。
個人的好きポイントは寝室のスライドドア閉めるやつです。

以下、aki_lua87作成部分。

謎1「神社」

神社

神社


利用アセット

神社は別ワールドでフラグを立てることで回答が可能になるというギミックを用いた謎解き(?)です。

神社自体は特に何か意味がある物ではなく単純に景色のプラトーが埼玉って聞いてなんとなく武蔵一之宮氷川神社が浮かんだので神社にしました。

最初はホラーワールド風にして1人で来た人を恐怖のドン底に落としてやろうかとも思ったのですが
Chillの部分が想像以上のハイクオリティに仕上がっていくのを見て流石に壊すのは如何かと思い変更しました。

入室上限が1人なのはその名残でもあり、インスタンスに人を残すメッセージでもあったのですがあまり有効には働かず失敗だったかな。

折角部屋っぽいので「ベランダから見えるランドマークポイントに訪れる。」というようなフレーバーもあります。
ベランダからレイを飛ばしてワールドに入るみたいなとこも気に入ってます。
(が意外と知られて無い機能だったかもしれない)

謎自体は「ワールドを跨いだ謎解き」「答えが毎回変化する謎解き」になります。

  1. ワールドA(Readroom)にてStringLoaderをコール、インスタンス固有のコードを受け取る
  2. インスタンス固有コードは同期変数で同期
  3. ワールドB(神社)にて固有コードより推測される解除コードを入力しStringLoaderで送信
  4. ワールドAの解答入力時に解除コード入力済みかのフラグをStringLoaderで確認し解除済みであればクリア

となります。

草案図

草案図

インスタンスの固有コードの概念については「謎解きワールドで謎の答えを教えてもらう」のカウンターにならないかと考えて「答えじゃなくて必ず解き方を教えてもらわないと解けない」を目指しました。
まぁこれ自体はStringLoader使わずにワールド内で生成しても馴染事は出来ますが。
Webを使う利点はチェックすることで被り無しで発行できることですかね・・・

ただいろいろミスリードさせてしまったみたいで…ちょっと混乱を招いちゃったかなとは思ってます。
ほかの謎はギミック自体が謎であるのに対してこれはギミック自体は初見だとギミックでもなんでもないのでちゃんと謎解きに落とし込むまで練り上げる必要があったかなとも。

StringLoader呼び出し用のVRCURLはプログラム上で動的に作成はできないので
予め発行可能な固有コード全通り分のURLを生成して変数で管理してます。

結果としてトータルで10000ケースでかつ状態管理用に数パターン必要なためVRCURLを管理するだけの数万行のU#が生まれてしまいました。

正直受け入れられるかも含めてプロトタイピングみたいなところもあり細かいところが雑なのですが
まぁこの時はこれでうまくいくと思っておりました。
がリリース後ちょっと色々問題が発生して・・・


アクシデント1 リソース枯渇

これは見積もりと設計が甘い話。

ワールドBでの解除コードは容易に復号できる形でURLに辿り着きたかったのでそのまま入力値≒固有コードでやってたのですが
想定を大幅に超える方々にワールドを遊んでいただいたことで用意された固有コードをほとんど食い潰す自体に陥りました。

固有コードを食い潰した状態だと当人としては間違った解除コードを入力しているにもかかわらず
他インスタンスの解除コードと一致してしまった結果、ワールドBを間違えた解除コードで突破可能になってしまってました。
(他人に解かれる可能性はあるとは思ってましたがまぁ1000コード程度なら偶然当たることも無いだろうとタカを括ってましたが、誕生日のパラドクスじゃないですが母数が増えるほど当たってしまうのでそもそもそれ自体が間違い)

さらに二次被害として解除コードの利用回数に制限を設けていたため
偶然にも解かれてしまったインスタンスが進行不能になってしまうということにも。

誰かが総当たり試みると終わってしまうので、そもそも仕組みがよくなかったです。

問題発生後に急遽解除コードのパターンを1万通りから100万通りに拡張しました。
(ギミックの根幹を変えるわけにはいかないので、偶然の一致の抑制+総当たりに挑む気力を無くさせるの効果を期待)

固有コードは前述のURLの関係で拡張できなかったため多少無理やりですが、現在遊んでいるワールドに影響がないようにワールドA,ワールドB,StringLoader受付サーバを更新する必要があり少し大変でした。。。
(気づいた方もいるとは思いますが、途中でDescriptionが変わったり、問題の数字の桁が増えたのはそのためです)


アクシデント2 VRChat外部よりアクセス

こちらは設計とセキュリティが甘い話。

今回用意していたURLはクエリパラメータで固有コードを判別し処理していたのですが
固有コードに特に暗号化も設けず、またサーバサイドもリクエストのチェックなど実施せず全て受け付けるようにしていました。

なので・・・といいますか、VRChat外部より固有コード発行および固有コードを使った解除を網羅的に呼び出されてしまう事態が発生しました。
サーバは受け付けたもの全て処理するので、不正なレコードをたくさん作られてしまいました。(テヘペロ

これは前述のリソース枯渇問題にも影響し、固有コード枯渇や進行不能インスタンスを作り出すことになってしまいました。

当然といえば当然ですがStringLoaderで状態を管理する場合はパラメータの難読化やアクセスを弾くような仕組みが必須ですね。
後付けでやりましたが最初からやっとけよという話。


順位システム

初日クリアインスタンスにて撮影、なんかバグって鍋の順位が出てない

初日クリアインスタンスにて撮影、なんかバグって鍋の順位が出てない

5日目クリアインスタンスにて撮影、本当に滅茶苦茶解かれたと思う

5日目クリアインスタンスにて撮影、本当に滅茶苦茶解かれたと思う

謎ではないですが自分が担当した部分なので。

遊んでくださった方は体感したかと思います。このワールドには「順位」があります。
各問題ごとのクリア順位と全体のクリア順位のやつです。

神社が「ワールドを跨いだ」ギミックだとするとこちらは「インスタンスを跨いだ」ギミックのようにイメージしてます。
単純に数字をインクリメントしるだけですが……

インスタンスをチームのような概念と考え他のチームと「リアルタイムで競い合える」もしくは「1位を目指す!!!」のようなモチベーションになってほしくて作りました。
(いい感じのUIにしてくれたのは円フツさんです。マジで感謝)

元々「インスタンスを跨いでゲームをする」みたいな概念はたまに考えてて ワールド間対戦リバーシ のようなものを作ったこともありました。
(まぁこれは対戦相手がリアルタイムでいないと遊ぶことすらできないのでよっぽど流行らないと成り立たない)

順位を表示すること自体は結構成功だったかなと思ってます。
Twitter(現X)に順位のスクショを載せてくれる方も結構いて嬉しかったですね。
(なんかバグって表示されて無かったりも多々あったようですが……)


その他

実はキューリストにURL入れるとタイトルに変換される

URLを入れてしばらくするとこんな感じに、ちょっと便利

URLを入れてしばらくするとこんな感じに、ちょっと便利


自作ギミックとかではなく Iwasync のキューリストと YTTL を繋げてるだけです。



おわりに

制作物の8割くらいは円フツさんなのでだいぶ何もやってないですが
やってて楽しいワールド制作でした。

色々問題もありましたが、たくさんに人に遊んで頂いてよかったです。

あと鍋は俺もこんなん解けるわけねえだろってずっと思ってます。

readroom2とかは多分ないです。

おわり

VRCPortalMarkerで壊れたポータルが出る

備忘録

事象

VRCPortalMarkerを使ってポータルを作成したところ、入ることができないポータルが生成された。
見た目もなんかおかしい。

サムネは見えるがポータルとしての機能がない。

原因

InstantiateしたオブジェクトからVRCPortalMarkerのPrefabをInstantiateするとこうなってしまう。
今回はStringLoaderと組み合わせてポータルの動的生成を行うために抽象クラスにポータルを囲うオブジェクトとVRCPortalMarkerの参照を渡したオブジェクトAを作成し、管理クラスからオブジェクトAをInstantiateしオブジェクトAからVRCPortalMarkerをInstantiateするようなことを試していたためこの事象に遭遇した。

解決策

単純にVRCPortalMarkerだけを元から配置してあるプログラムからInstantiateする分は問題なく動作するため、参照ではなく実態を持たせる、もしくは予め配置しておくなどで解決できる。

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

はじめに

※※※信頼されたドメインは動的に作成できるようになるらしいので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

終わりに

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

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

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

以上です。