タブの中で動画を並列にデコードする
Video2Any には、ほかのすべてを決めてしまう制約がひとつあります。動画が端末から出ないことです。アップロードしないので、作業をするサーバーもない。だからデコードも比較も描画も、UI を動かし続けなければならないブラウザのタブの中で起きます。
それに実際どれだけかかるのか、そしてサイトの残りが何でできているのかを書きます。
デマルチプレクサは自分で書くしかない
WebCodecs は VideoDecoder をくれます。エンコードされたチャンクをフレームに変えるものです。ただしチャンクはくれません。ブラウザ自身は MP4 パーサーを持っていて、`<video>` はそれで動いているのに、それを渡す API はありません。だからコンテナは自分で読みます。ボックスをたどり、moov を見つけ、サンプルテーブルを読み、EncodedVideoChunk を1つずつデコーダーに渡す。
これには副産物があります。コンテナを自分で読んでいるので、5分無駄にする前に「このファイルは開けません」と理由つきで言えます。ProRes は編集用のフォーマットで、どのブラウザもデコードしません。そしてそれが分かるのは moov を読んだときで、ProRes の moov はファイルの先頭ではなく末尾にあることが多い。うちのプローブが両端を走査しているのはそのためです。
2段階なのは、メモリのせい
素直にやるなら全フレームをデコードして比較します。92分・25fpsの録画は138,000フレーム。1080p でそれをタブが抱えるのは無理です。
なので2パスに分けます。スイープはサンプル点だけをデコードし、各サンプルを160×90で保持します。動画全体でもピクセルは数十MB、それでいてスライドの違いは分かる大きさです。検出器が選び終えてから、キャプチャが全解像度のフレームを取りに戻ります。選ばれたものだけを。1パス目には900サンプルの上限があり、これは知っておく価値のある妙な結果を生みます。29分の動画と92分の動画で、かかる時間はほぼ同じです。コストは分ではなくサンプル単位だからです。
チャンクごとにワーカー1つ、デコーダーも1つずつ
動画は時間で分割され、各チャンクは自前の VideoDecoder を持つワーカーに渡されます。プールは min(4, コア数 / 2)。半分しか使わないのは、タブが UI も動かしているからで、4 で頭打ちにしている理由は次の節です。
各チャンクは自分の範囲の少し手前から走査します。最初のフレームを判断するための比較対象が要るからで、そのあと自分の範囲外は捨てます。スライドは最後にまとめてではなく、キャプチャが1枚終わるたびに流れ出します。
分割の代償
2つあり、そこが面白いところです。各チャンクは自分の区間だけでしきい値を較正するので、同じ動画を4分割したときと8分割したときで違うスライド集ができます。つまり**結果がマシンのコア数に左右される**。もう1つ、各チャンクは自分の中でしか重複を落とさないので、会議が何度も戻る同じ画は、登場したチャンクの数だけ残ります。29分の録画で9枚中3枚が重複でした。
重複は直しました。判定をメインスレッドに移し、スライドが届くたびに比較します。較正のほうは直していません。全体版を書いてはみたのですが、結果は悪くなりました。同じ会議で ffmpeg が本物のカットを7つ数えるのに対し、チャンクごとの較正は6枚、全体較正は4枚。だからプールは4のままです。チャンクを増やせば約25%速くなりますが、答えは変わってしまうので。
読み終わるまで、1枚も出せない
しきい値は動画全体のフレーム間差分の分布から決まります。だから最後のフレームをサンプリングし終えるまで、最初の1枚すら選べません。92分の録画ならスイープに52秒、最初のスライドが出るのは50秒目です。
これは性能の問題ではなく、並列度をいくら上げても消えません。フィードバックの問題です。そして解決策は、ただのパーセンテージだった場所に「動画を読み込み中 · 45:46 / 92:08」と書くことでした。
周辺のスタック
API は Cloudflare Workers と Hono、アカウントとメタデータは D1、本当に保存が要るわずかなものは R2。フロントは React 19 と TanStack Router、Tailwind v4、better-auth。8言語、それぞれに URL プレフィックスとルーターの basepath があります。
サイトはサーバーレンダリングではなく**プリレンダリングされた SPA** です。公開ページはすべてデプロイ時に作られた静的 HTML のスナップショットで、だからマーケティングページはキャッシュが効いて速く、コンバーター自体はサーバーをまったく必要としません。例外はひとつだけ。`/app` 配下は静的なシェルなので、エディタはあなたのアカウントについて何も知らない状態で起動し、問い合わせるしかなかった。答えが返る前に作業を始めたことが、ある有料会員が無料プランの30分制限を食らった原因です。ワーカーはそのページを返す時点でセッションを持っているので、プランをドキュメントに書き込み、最初のレンダーで分かるようにしました。これらの応答は private でキャッシュしません。ほかのページの公開キャッシュはそのままです。
なぜそこまでするのか
サーバーを立てれば、ここに書いたことの大半は消えます。ただしそれは、誰かが変換する講義も、社内会議も、診療の録画も、すべてこちらのマシンを通るということです。この製品が存在する理由は、それが起きないことにあります。
制約こそが機能です。上に書いたのは、その値段です。