Chienomi

LWMP & XXWMPアップデート : サムネイル, 動画プレイヤーライブラリ

開発::util

地道に開発が進められているLocalWebMediaPlayer & XXWMPだが、ここ最近のアップデートを紹介しよう。

実は割と使い勝手に手が入っていて、かなり魅力的に仕上がっているはずだ。

まずソフトウェアについて

LocalWebMediaPlayer (LWMP)XXWMPはコードのかなりの部分を共有しているが、立ち位置の全く異なるソフトウェアである。

まず先にLWMPがあった。 LWMPはローカルネットワーク内のPC上のメディアファイルをスマートフォンで閲覧・観賞するためのソフトウェアである。 ベースとなるファイルブラウザに、動画プレイヤー、音楽プレイヤー、画像ビューワ、ブックリーダー(マンガ表示対応)、テキストビューワの機能を搭載している。 PCとスマートフォンに対応。特にブックリーダーは作り込んだ結果使い勝手がかなり良かったので、Neutralinojsを用いてPCアプリにしたKolmicsに派生している。

色々なソフトウェアや方法を試した上で「自作するしかない」とたどり着いたことから、このソフトウェアを使うようになって体験はかなり向上した。

ソフトウェアはLighttpdに依存しており、Lighttpd+CGIで動作する。 ファイル配信自体はアプリケーションを介さずLighttpdが直接配信。

LWMPはローカルネットワーク内で動作することを前提としており、自身はユーザーの概念がなく認証機能をもたないが、LighttpdでIPアドレス制限やBASIC認証をかけることは可能。

Lighttpdはユーザー空間にある設定ファイルで簡単に衝突せずに起動できるため、マルチインスタンス起動に向いている。 複数のディレクトリを配信したい場合が多いため、構成次第で複数のサーバーで複数のインスタンスを動かす、というような場合もある。

XXWMPはLWMPのインターネット版として位置づけられているが、こちらはプライベートメディアサーバー/プライベートクラウドなどでサーバーのファイルシステム上に置かれたメディアファイルに、より良いインターフェイスでアクセスできることを想定している。 実際に私が使って不満を持ったものとしては、NextCloudやSeafileのセルフホストを想定している。

この用途とインターネット版であることから、XXWMPはマルチユーザー対応であり、認証もちゃんと実装している。 マルチユーザー対応は認証だけの話ではなく、プライベートクラウド側のユーザーのドライブに対応させるためのものでもある。 構成も異なり、アプリケーションサーバーとして動作し、NginxまたはCaddyを前段に置くことが想定されている。

実はサーバー側はauth_requestまたはforward_authを活用、認証自体に独自実装の公開鍵認証を使用、ウェブアプリケーションフレームワークにはRodaを採用、とドキュメントの乏しい技術の利用例としてかなり参考になるような作りになっている。

現在は共通化が進んで特にフロントエンドコード(main.js)はかなり近いものになっているが、これはmain.jsの共通化という観点があるからそうなっているという側面もある。 というのも、通信部分にLWMPはfetchwrapperを使っているのに対し、XXWMPは認証のハンドリングを含むオリジナルの実装になっていたりする。共通化できない部分をmain.jsの外に出して差分を減らしていたりもするのだ。

このように両者は結構明確に別ソフトウェアにする必要性を持っている。 ただし、特にクライアントの新機能は両方で使えることが多く、ポートされるケースが多い。

最新版のスクリーンショット

動画プレイヤー
サムネイル (画像)
サムネイル (音楽)

新機能解説

サムネイル機能

動画/画像/音楽ファイルのサムネイル機能が追加された。

交差オブザーバーを用いてサムネイルをロードし、ロードできれば置き換える、という方式。 サムネイル自体は事前に生成しておく必要がある。

なお、サムネイル機能追加に伴ってメタデータ機能もデータの持ち方が変更されている。

XXWMPにメタデータとサムネイル機能

LWMPには既にあったメタデータ機能と、新規追加されたサムネイル機能がXXWMPにも移植された。

メタデータやサムネイルの生成は特に確認もなしにImageMagickやffmpegを用いるため、自分専用の環境などソースの出どころが明確な場合のみに使用することが強く推奨されている。

除外機能

特にLWMPではインスタンスの性質として特定のLWMPでは扱わない形式のファイルが含まれる場合がある。

LWMPはLWMPが扱えないファイルは表示しないので通常は問題ないのだが、主にJSONファイルがゴミになってしまうことがある問題に対応し、suffixで一致するものをファイルリストから除外する機能を追加した。

これは.info.jsonを除外することをメインに想定している。

動画プレイヤーライブラリ

LWMP/XXWMPはメディア再生自体はブラウザ任せにするというのが基本方針だが、方向性が近い軽量な動画プレイヤーライブラリを利用可能にした。

サポートされたのはVidstack, vLiteJS, Plyr, Fluid Playerである。 いずれも設定した場合にのみロードされ、ロードはCDNから行う。

正直なところ、ブラウザ組み込みのプレイヤーがそれなりに機能性を持っていることもあり、これらのライブラリをロードすることで視聴体験が良くなるかというと相当怪しい。 ただ最も大きな効果としては、送り戻しのホットキーが有効になることだろう。

技術解説

サムネイルロード

これくらいブラウザでやってほしい機能のひとつ。

/media/a/b/c.jpgのサムネイルは/thumb/a/b/c.jpg.thumb.webpになるというルールを持っている。

まず、アイコンは普通にSVGのアイコンでロードしておく。で、こんな交差オブザーバーを用意して

// Lazy image load observer
const thumbnailObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target
      const thumb = img.dataset.thumbnail
      const temp_img = new Image()
      temp_img.onload = () => {
        img.src = thumb
        img.className = "thumbnail"
      }
      temp_img.onerror = () => {
        console.warn('Thumbnail load failed for:', thumb)
      }
      temp_img.src = thumb
      observer.unobserve(img)
    }
  })
}, {})

サムネイルが有効で、対応するファイルタイプである場合はサムネイルのパスとクラスをセット

if (currentState.systemInfo.use_thumbnail && ["video", "music", "image"].includes(i.type)) {
  fiii.dataset.thumbnail = `/thumb/${encodeURIComponent(i.path)}.thumb.webp`
  fiii.className = "svgicon lazy-thumb"
} else {

クラスで探してオブザーバーに登録

filelist_div.querySelectorAll('.lazy-thumb').forEach(el => {
  thumbnailObserver.observe(el)
})

load_browserで画面が更新されるときは破棄

thumbnailObserver.disconnect()

「サムネイルが存在するかどうか」をサーバーが確認することはしていない。 これは、ファイルブラウザでの階層移動はわずかでも遅くなると結構な体験の悪化になるからだ。 なので、対応しているファイルタイプであれば問答無用でサムネイルを取りに行く。見つからなければ置き換えられないので、結構な404を発生させるけれどそこは目を瞑れば悪くないとdealだと思う。

ただ、取れないのが分かっているものを何度も取りに行くので、Setで取れなかったものは覚えておくようにしたほうがいいかもしれない。

このあたりの実装も「ローカルネットワーク向け」な部分で、XXWMPのサムネイル機能はセキュリティとは別にこうした面でも考えたほうが良いので慎重になったほうが良い機能になっている。

別の妥協案として、サーバー起動時に一覧をロードしてしまい、オンメモリで返すという方法があるのだけど、これはアプリケーションサーバーであるXXWMPではできるけれど、CGIであるLWMPではできない。

サムネイル生成

LWMPに依存していない、完全に独立したスクリプト。

#!/bin/env ruby
require 'fileutils'
require 'find'
require 'json'

media_dir = ARGV.shift
thumb_dir = ARGV.shift

MEDIA_EXT_VID = %w:.mp4 .mkv .mov .webm .ogv:
MEDIA_EXT_AUD = %w:.mp3 .ogg .oga .opus .m4a .aac .flac .wav:
MEDIA_EXT_IMG = %w:.jpg .jpeg .jfif .pjpeg .pjp .png .webp .avif .bmp .gif:
TARGET_EXT = Set.new(MEDIA_EXT_VID + MEDIA_EXT_AUD + MEDIA_EXT_IMG)

unless media_dir && File.exist?(media_dir) && thumb_dir && File.exist?(thumb_dir)
  abort "create-thumbnail.rb <media_dir> <thumb_dir>"
end

Find.find(media_dir) do |fp|
  rfp = fp[media_dir.length..].sub(%r:^/:, "")
  ext = File.extname(fp)&.downcase
  next unless TARGET_EXT.include?(ext)
  efp = File.expand_path(rfp + ".thumb.webp", thumb_dir)
  next if File.exist?(efp)
  mime = nil
  IO.popen(["file", "-b", "--mime-type", fp]) {|io| mime = io.read.strip.split("/", 2)[0] }
  FileUtils.mkdir_p(File.dirname efp) unless File.exist? File.dirname efp
  case
  when MEDIA_EXT_VID.include?(ext)
    next unless mime == "video"
    system("ffmpeg", "-i", fp, "-vf", "thumbnail=300", "-frames:v", "1", efp)
  when MEDIA_EXT_AUD.include?(ext)
    next unless mime == "audio"
    # Album art exists?
    IO.popen(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,codec_type,disposition=attached_pic", "-of", "json", fp]) do |io|
      res = JSON.parse io.read
      next res["streams"][0]
    end or next
    system("ffmpeg", "-i", fp, "-vf", "scale=300:300:force_original_aspect_ratio=decrease", efp)
  when MEDIA_EXT_IMG.include?(ext)
    next unless mime == "image"
    system("magick", fp, "-resize", "300x300>", "-strip", efp)
  end
end

ビデオ部分はMewductの知見を活かした仕様。

オーディオはffprobeで埋め込みアルバムアートがあるかどうかを確認した上でffmpegで抽出。 「アルバムアートはあるけどContent-Typeがない」という困ったちゃんは「ない」扱いになる。

ここではcover.jpgを探さない仕様だけど、正直アルバムアートに関しては大量の同じファイルが生まれる可能性が結構あるので、仕様を修正したいと思っていて、その後にサポートするかもしれない。

このツールに関しては修正すべき点が色々あるのを認識している。そのうちやる。

プレイヤーライブラリの動的ロード

プレイヤーライブラリの使用はLWMP的にはあまりしたくないことではある。 そもそもLWMPのメディア再生関連の機能は「ブラウザに備わっているものに任せる」という方針なので、コンセプトに反しているのだ。 また、LWMPは外部ライブラリに依存していないのだが、プレイヤーライブラリを入れると外部ライブラリへの依存を発生させてしまう。

だからこそあくまでオプショナルにしておきたいし、特定のものを使うのではなく選択可能にしておきたい。

ちなみに、そこまでして入れる理由は、標準のvideo要素のカーソルキーでの送りは全体時間に対する割合なのでかなり使い勝手が悪く、スマートフォンでの10秒送りみたいなものはないというのが理由。 ここの自前実装を考えてもいいのだけど、動画要素内のタップの対応はちょっと面倒な部分があるのでとりあえずプレイヤーライブラリを使う方針にした。

ロードするかどうかを決定するためにインスタンスの設定が欲しいが、これはサーバーから取得するようになっている。そして、その取得方法はLWMPとXXWMPで異なり、LWMPはmediaplay.rbを叩いたときに一緒に返ってくるが、XXWMPは/configという専用のAPIを使う。

ここでプレイヤーライブラリを使うようにしていたときに動的にロードをかける。 動的なロードの方法自体はimport()を使えば簡単に実現できる。

case "vlitejs":
  import("/videoplayer-vlitejs.js").then(mod => {
    create_videoelem = mod.create_videoelem_vlitejs
  })
  break

が、単にロードするだけで使えるわけではないし、LWMPのソフトウェアとしての実装と矛盾せず、かつ複数のプレイヤーライブラリ間で齟齬が発生しないような互換インターフェイスが必要だ。

このため、まずvideo要素を返す部分をcreate_videoelemとして、audio要素を返す部分をcreate_audioelemとした。

var create_videoelem = create_videoelem_vanilla

そしてデフォルトの構造は用意しておき、最初にデフォルトのものを代入しておく。 サーバーから設定を取得したあと、importするのであればcreate_videoelemを入れ替えれば良い。 ちなみに、これのためにcreate_videoelemvarで宣言しておくと苦労がない。

const create_videoelem_vanilla = function(src, tags=null) {
  const media_div = document.createElement("video")
  media_div.id = "MediaPlayer"
  media_div.classList.add("video_player_box")
  media_div.src = src
  media_div.controls = true
  media_div.preload = "auto"
  media_div.letsPlay = media_div.play
  media_div.updateSrc = (src, tags) => { media_div.src = src }
  return media_div
}

ここで重要な前提として、プレイヤーは初期ページでロードされることはないという点がある。 もし初期ページにプレイヤーが含まれるようであれば、プレイヤーの描画がプレイヤーライブラリのロードが完全に終わってからされるように工夫する必要があるが、今回の場合それは必要ない。

このため、CSSのロードをimport()でロードするライブラリ内でlink要素をhead要素に追加するという方式で行っている。

const css1 = document.createElement("link")
css1.rel = "stylesheet"
css1.href = "https://cdn.vidstack.io/player/theme.css"
const css2 = document.createElement("link")
css2.rel = "stylesheet"
css2.href = "https://cdn.vidstack.io/player/video.css"
const css3 = document.createElement("link")
css3.rel = "stylesheet"
css3.href = "https://cdn.vidstack.io/player/audio.css"
document.head.appendChild(css1)
document.head.appendChild(css2)
document.head.appendChild(css3)

これはJavaScriptの実行が手放されてから解釈されるので、初期ページにプレイヤーがあるとガタつく可能性がある。

さて、create_videoelemだが、インターフェイスとしてvideo相当のHTMLElementを返すことが期待されている。 Vidstackはこの仕様を簡単に満たせるのだが、他のライブラリはvideoの外側にコンテナを作るためうまく動作しない。 そして、典型的には.play()が生えるか生えないかの違いがある。

LWMPの仕様では.play()はプレイリストを登録したときにしか呼ばれない。 これはautoplayがうまく動作しない環境の代替として呼んでいる形である。

しかしVidstackはこの方法でplay()してしまうと、準備ができていないとしてエラーになる。 また、vLiteJSの場合、play()を呼ぶこと自体がうまく動作せず、再生状態の整合性を失ったり、再生ボタンが残ったままになったりする。

このため、「video要素のためにロード後に呼ばれる再生開始処理」はletsPlay()という別メソッドとしてreturnするHTMLElementに生やすようにした。

media_div.letsPlay = media_div.play

また、もともとはvideo.src =の形でソースを更新していたのだが、returnする要素でこの操作ができるとは限らないため、こちらも専用メソッドに。 プレイヤー内にタグを持たせられるもののことも考慮している。

media_div.updateSrc = (src, tags) => { media_div.src = src }

vidstackはmedia-playerという独自の要素でラップする形であり、普通のDOM操作で問題なく動作する。 このためかなり素直なコードになっている。

const create_videoelem_vidstack = function(src, tags=null) {
  const player = document.createElement("media-player")
  player.title = tags?.title
  player.src = src
  const provider = document.createElement("media-provider")
  const layout = document.createElement("media-video-layout")
  player.appendChild(provider)
  player.appendChild(layout)
  player.id = "MediaPlayer"
  player.classList.add("video_player_box")

  player.letsPlay = async function() {
    if (!player.setAttribute.canPlay) {
      await new Promise(resolve => {
        player.addEventListener("can-play", resolve, {once: true})
      })
    }
    return player.play()
  }
  player.updateSrc = function(src, tags=null) {
    player.title = tags?.title
    player.src = src
  }
  return player
}

ところが、vLiteJSはnew Vlitejs()HTMLElementが渡せると書いてあるものの、実際に渡すとinsertBefore()がないとしてエラーになる。 つまり、このHTMLElementには親要素が必要な構造だ。

なので、ダミーの親コンテナを用意し、firstElementChild()を返すことに。 documentFragmentでもいいかもしれないけれど、余計なリスクを避けてdiv要素にした。

const create_videoelem_vlitejs = function(src, tags=null) {
  const player_raw = document.createElement("video")
  const dummy = document.createElement("div")
  dummy.appendChild(player_raw)

  const media_div = new Vlitejs(player_raw, {
    options: {
      volume: true,
      autoHide: true,
    },
    plugins: ["volume-bar", "hotkeys"]
  })
  player_raw.src = src
  player_raw.preload = "auto"
  
  const fec = dummy.firstElementChild
  fec.classList.add("video_player_box")
  fec.letsPlay = async () => { void 0 }
  fec.updateSrc = (src, tags) => { player_raw.src = src }
  fec.id = "MediaPlayer"

  return fec
}

PlyrとFluid Playerもこの方式を踏襲している。 Fluid Playerはこの方法で普通に追加すると0pxのサイズになってしまうため、OptionsでfillToContainer: trueが必要。

CGIとしてのLWMPの今後

正直、LWMPはCGIという鉄板の上にいて、XXWMPはアプリケーションサーバー(Roda, Rack, Puma)という砂の城の上にいるという認識だったにも関わらず、Ruby 4.0でCGIライブラリがナーフされたことによりLWMPのほうが立場が危うい。

LWMPもXXWMPもCGIライブラリを使っているけれど、XXWMPはRuby 4.0のCGIライブラリでもちゃんと機能する。 問題はLWMPのほうで、こちらはRsCGIを使っている…… が、結果的に「外部ライブラリ依存なし」というLWMPの重要なポイントを失った。

RsCGIは極めて薄いライブラリであり、別に組み込んでしまってもいいのだが、初期の想定よりもLighttpdへの依存度が高くなってしまっているという問題もあり、LWMPの将来は割と帰路に立たされている。

XXWMPをベースに自己完結型のアプリケーションサーバーにするという案もあるのだが、メディアファイル配信は本業ウェブサーバーにやらせたい気持ちが強い。 しかしCaddy+アプリケーションサーバーとかやると、LWMPの立ち位置的には重い。

今のところCGIという身軽さを活かしていきたいとは考えているのだが、かなり悩ましいところだ。

おまけ

この記事を書いているタイミングでVidstackがCDNからロードできなくなり、機能しなくなった。

外部ライブラリはこれだから……