Chienomi

Mewduct 〜 事前生成戦略仕立てYouTube的OSS

開発::util

MewductはYouTubeのような動画配信サイトを構成するためのソフトウェアである。

2025年の末のほうでプロジェクトは始まっていたのだが、空っぽのうちにスターがついてびびっていた。

その最大の特徴はサーバーサイドアプリケーションなしで動作すること。 クライアントサイドアプリケーションとウェブサーバーによる静的配信のみで成立する。

ちなみに、SPAというわけでもなく、ホームページ、ユーザーページ、動画ページの3つが別々になっているシンプルなスタイル。これはなんていうんだろうね。

動機

以前言ったように、私はYouTubeに対する信用を完全に失っており、もうYouTubeへの投稿をやめている。 これは、アップロードしたコンテンツに対する経緯があまりにも欠けているためだ。

そこでYouTubeの代替プラットフォームを探したのだが、率直に言って「ない」。 基本的には有料プラットフォームだけだし、クリエイターとしてコンテンツでマネタイズすることにフォーカスしすぎていて、自分のやりたいこととは全く合わない。

じゃあセルフホストで……と思ったのだけど、それはとてもしんどい。 PeerTubeでもMediaCMSでも、構築も重いし、なんといっても動画エンコードをサーバーでやるのでサービスを提供するサーバーと動画エンコードを共存させるのは不合理だし、サーバーインスタンスで特に高価なメモリをめちゃくちゃ要求するというのはつらすぎる。

だから手元でエンコードしてアップロードするタイプのものを探したのだけど、見つからなかった。

そこで作ることにした。

どうせなら余計なものを削ぎ落としてシンプルに、軽量に、速く。 構成しやすく。柔軟に扱えるように。 そして、安心感。

というわけではるかみismのあふれる作品になった。

名前の由来

YouTubeに由来していて、You部分は音で、Tube部分は「管」に対する意味でもじった名前。つまり、NewPipeと同じ。

でもMewduct、ちゃんと猫とダクトで意味つながっていてよくない?

機敏で小さいから通れる道って意味にもなるし、ねずみは通れないって意味にもなるし。

あと、狭いところをくぐりぬける猫ってかわいいし。

意外と難しくない

「動画配信サイトを静的に」というとものすごく感じるかも知れないけど、実はあまり難しくない。

というのも、動画配信サイトの場合、リクエストに対して返すべきものはだいたい決まっていて、動画は事前にエンコードしたものを配信することになるし、ユーザーページのコンテンツもコンテンツ自体が更新されなければ一定。

だから動的に応答を生成しなければいけない要素は少なくて、再生数とコメントといいねの数なんかは動的生成のほうがいいかなぁと思うけど、動的生成でなければならないわけじゃない。 Mewductの場合はユーザートラッキングをしない方向なので、ユーザーにログインを要求しないから、再生数、コメント、いいねといった機能を標準ではサポートしない。 (プラグイン的にサポートされるが、Mewductはアプリケーションサーバーを持っていないので叩く先は自分で用意する必要がある。)

動画配信を考えたときには基本的に動画はそのまま読み込むだけ、DOMを組み立てたり属性値を設定したりする必要はあるけど、それはクライアントサイドで可能なことなのでJSONを配信すれば良く、そのJSONも事前に確定できる。

つまり、動画配信サイト、結構事前生成向きなのだ。

難しい部分としては複数の解像度の動画を切り替えて扱えるようにするというあたりは専用の仕組みが必要だけれど、そこはPlyrを使った。

となるとあとは動画を複数のフォーマットにエンコードする仕組み、それらを適切に配置する仕組み、JSONデータの組み立てあたりを考える必要がある。

実際はこれらのコードを書くよりも、「何が必要でどのようなルールで配置するか」を決めるほうが時間がかかった。

だから全体的に技術的には簡単……と言いたいところだが、微妙にマニアックなことをしている。

基本構造と戦略

すごく単純にいうと、動画ページを作るということに関しては、エンコード済み動画があればそれだけで簡単に実現できる。

Plyrを使う前提ならソースに複数解像度のエンコード済み動画を用意すればいい。 それだけでベタ書きでそれっぽいページが作れる。

実際に必要なのは、複数解像度へのエンコードをある程度自動化すること。 そして、ページで動的にロードできることだ。

だから動画をエンコードして、動画情報のJSONを組み立てる、という手順にした。

基本的にこれらの手順は固定されているのでひとつのプログラムにしてもいいのだけれど、私は1手順ごとに細かく分けたCLIツールを用意することにした。 そうすれば、後述するようにサーバーにやらせるタイミングに選択の余地が生まれるからだ。

分離した手順としては

  • ソース動画から複数解像度の配信用動画を生成する
  • 動画を配信ディレクトリ以下に配置し、動画メタデータのJSONを作る
  • ユーザーの動画ディレクトリ(=JSONファイル)を更新する
  • ホームの動画ディレクトリ(=JSONファイル)を更新する

の4手順。

細かいテクニック

字幕言語の表示問題

Plyrの字幕はHTML5ビデオのcaptionにそのまま流しているので、仕様がcaptionに準じている。 だからsrclangは言語コードになる(例: ja)ので、この情報は必要になる。 このため、Mewductでは字幕ファイルにcaptions.ja.vttのようなファイル名をつけることを要求する。

が、labelという値があり、これが字幕の選択肢の文字列として使われる。 これをjaとかにするのはさすがに違う。

そこでこれを適切な文字列(例: Japanese)とかにしたいのだが、Ruby単体でそれをうまいことやるのは……難しい。 iso-639ライブラリの導入を要求できれば話が早いのだが、MewductはRubyのスクリプトを一部含んでいるだけで、ソフトウェアとしてはRubyで動くアプリケーションというわけではないので、ちょっと難しい。

そこで、標準機能で利用可能なNode.jsでもやる、という方向にした。

converted_captions = []
case @lang_method
when "gem"
  captions.each do |cp|
    converted_captions.push({
      "kind" => "captions",
      "label" => ISO639.find(cp[])&.english_name || "Unknwon",
      "srclang" => cp["code"],
      "src" => cp["srcpath"]
    })
  end
when "node"
  IO.popen(["node", "#{__dir__}/mewduct-codeenglishname.js"], "w+") do |io|
    io.write JSON.dump captions
    io.close_write
    converted_captions = JSON.load io.read
  end
end

requireできるかどうかの確認、node -vが呼べるかどうかの確認で方法を選択している。

ちなみに、これらの実行結果は等価ではないけど、labelの値だし、Mewductを構成するユーザーの環境によって変わるだけなので良しとしている。

const data = JSON.parse(require('fs').readFileSync(0, "utf8"))

const result = []
for (const i of data) {
  result.push({
    kind: "captions",
    label: new Intl.DisplayNames([i.code], {type: "language"}).of(i.code),
    srclang: i.code,
    src: i.srcpath
  })
}

console.log(JSON.stringify(result))

JavaScriptのほうは例外処理書いたほうがいいかも。

サムネイル生成

thumbnailビデオフィルタの存在を初めて知った。

# Creating thumbnail
ffmpeg -i "$source_file" -vf "thumbnail=1800" -frames:v 1 "$outdir/thumbnail.webp"

変化の大きさで選択するみたいなロジックもあるのだけど、これは私は違うと思う。

だって、映像作品で強く見せたいシーンは全体のスピードの中でじっくりになるのでは? そして、仮に該当シーンだったとしても、切り替わった直後が見せたい画面ではないのでは?

ここらへんのことをちゃんとロジックとして落とし込んでも良かったのだけど、ffmpeg側の改良がそのまま享受できるという意味で素直にビデオフィルタを使った。

サイズ可変カード

最初、flexで300pxのカードを並べる方式をとっていたのだけど、これは折り返しは自動でしてくれるものの、300pxが収まらない部分がそのまま余白になる。

余白を潰したかったので、gridに変えて

#CardBox, .card_box {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px,1fr));
  width: 100%;
  max-width: 100%;
}

なかなかスパイシーだと思う。 ……もしかしたらよく知られたテクニックだったりするのかな。

グレー

ところどころに出てくるグレーだけれど、実はライトテーマとダークテーマで共通。 例えば

#UserDescriptionText {
  background-color: #66666629;
}

みたいなことをしている。

どっちでもちょうどいい色を探すのはちょっとしたテクニック。

SPAのパスパラメータ

MewductはSPAではないのだけれど、やっていることはSPAに近い。 その中で問題なのがパスパラメータつきでのページ遷移。

ユーザーページは/user.html/<user_id>、再生ページは/play.html/<user_id>/<media_id>というURLになっている。

開発のときはWebrickを使っていてこうしたURLが許容されるのだが、主要なウェブサーバーはこのパスが通らない。

このため工夫が必要。実際の設定の仕方はREADME参照。

サーバーリソースの節約

README記載のとおり、どこをサーバーにやらせるかにかなり幅がある。

サービスとして展開したいみたいことを考える人はアップロードフックみたいなのを考えなければならないが、基本的な使い方としては動画変換はローカルでやって、サーバーはそれを取り込むという形にするのが基本。 最終的には静的ファイルとして配信できる形になるのでシングルユーザーなら単なるミラーでもいいというのも特徴だ。

これは、プログラムを組み合わせることでウェブサービスに展開したりとか、ユーザーの事情に合わせて柔軟な運用ができるように。 今はメモリが高いので、「ローカルでは安いメモリだけどサーバーだと高い」とは言い切れなくなってしまったが、それでも動画変換を回せるだけのリソースをウェブサーバーインスタンスに持たせるのはしんどい話だ。

Mewductの4手順を考えると、まずソース動画をアップロードして変換からサーバーでやるのは一般的なやり方と変わらない。 一般的にはアップロード手順がウェブで、その裏で自動的にエンコードが開始されるだろうけど、それがrsyncになり、コマンド実行する必要がある程度の話だ。

これだとあまりメリットがないから、この方法を取る人は少ないと思う。

手元でエンコードするとvideooutというディレクトリにそのままサーバーにアップロードできるデータができる。 ただ、もともとが単一ファイルであり、動画のメタデータ(タイトルとか)を入れたいという関係上、単純にこれをアップロードすればいいかというとそんなことはなく、基本的にvideoout/titlemeta.yamlを編集した上でサーバーに取り込む前提になっている。 もちろん、その編集をWebUIでやりたい人はそういうWebUIを作れば良い。

このvideooutをサーバーにアップロードし、サーバーで配信ディレクトリ以下に配置するという手順を取るのが最もスタンダードだろう。 この取り込み時はmvされるので、ディスク的な無駄も発生しない。

さらなる方法としてSSHFSがある。 SSHFSでサーバーの公開ディレクトリをマウントすれば、処理そのものは全部ローカルでやってしまうことができる。 この方法は一連の操作をするユーザーが公開ディレクトリへのRW権限を持っている必要があるから、シングルユーザーインスタンス向き。

もっと単純な方法でやりたい人は、ローカルに公開ディレクトリを構成してミラーしてしまえばいい。 これは他の人が更新したものを上書きしてしまうから、完全にシングルユーザーインスタンス専用。

本質は簡単な話さ

動画配信サイトを作ろうとしたときに、ウェブアプリケーションを構成しなければならないと考えるのはちょっと近視眼的すぎる発想だ。 本質を考えるなら、不可欠な要素から数え上げればいい。

動画配信サイトにまず必要なのはなにか。 動画データだ。

あとはすべてがオプショナルだ。 どうあろうとしているのかということを考えていれば自ずとエッセンシャルで必要なものは分かってくるはずだ。

はるかみの動画はMewductへ

動画変換の手間もあるし、ソース動画がちゃんと残っているかの問題もあのですぐに揃うかどうかはなんとも言えないけど、今後アップする動画はMewductで公開する予定。 これは、はるかみ☆でぃじっとチャンネルのキーボード動画と、Harukamy Playsチャンネルのゲーム動画が中心。

Chienomiの動画コンテンツとしての動画は、今後はやらない予定だ。 これは手間とビュー数の問題、あとそもそも動画はちゃんと理解しようとする層が少ないので報われないのが辛いから。