ブラウザーの中で動画を 1 フレームずつ取り出して加工する処理を書くとき、流し込みすぎを防ぐ目印としてまず目に入るのが decodeQueueSize です。この値が大きくなったら待つ。そう書いておけばメモリは膨らまない、と私は理解していました。半分しか合っていませんでした。この値が数えているのは「まだデコードしていない分」だけです。デコードが済んで自分のコードに渡されたあと、処理の順番を待っているフレームは、どこにも数えられていません。
言葉を先にそろえておきます。デコードは、圧縮された動画データを 1 枚ずつの画に戻す作業です。VideoDecoder はその作業を自分のコードから回すためのブラウザーの API で、戻ってきた 1 枚が VideoFrame です。フル HD の 1 枚は、allocationSize() で読むと 3,110,400 バイト、約 3.1 MB ありました。使い終わったら close() を呼んで手放します。キューは順番待ちの列のことです。後ろの工程が詰まっているときに前の工程を待たせる仕組みを、バックプレッシャーと呼びます。朝の駅でたとえるなら、decodeQueueSize は改札の手前に並んでいる人数です。改札を抜けてホームで電車を待っている人数は、改札機には分かりません。ホームの混み具合は、ホームの側で数えるしかないわけです。
ペース制御なしでは未処理フレームが 1421 枚溜まった
業務の画面にこの種の処理を入れるなら、影響がどこまで広がるかを先に知っておきたいので、サンプルで試しました。ブラウザーは Chromium 149 です。機械は Apple M4、メモリ 16 GB の macOS です。サンプル動画は自分で撮影したものではありません。ネットで見つけた海の波の動画を ffmpeg 7.1 で H.264 の MP4 に変換し、同じ映像を繰り返しつないで 60 秒に伸ばしました。1920×1080 px、1500 フレームです。MP4 から圧縮データを取り出すのには mp4box.js 0.5.2 を使い、それを VideoDecoder に渡します。1 フレームごとの処理は、小さな canvas に描いて 1 ピクセル読み、10 ms 待ってから close() するだけです。デコードより処理のほうが遅い状況を、わざと作りました。
受け取る側はこう書いています。デコード済みのフレームは、いったん自前の配列に並べます。
const waiting = []; // デコード済みで未処理
const videoDecoder = new VideoDecoder({
output: (img) => waiting.push(img),
error: error => console.error(error),
});
最初は、1500 フレーム分の圧縮データを待たずに全部 decode() へ渡しました。decodeQueueSize のピークは 1000、自前の配列 waiting のほうは 1421 まで伸びました。フレームは 1500 枚すべて出力されていて、エラーもコンソールの警告も出ていません。動くことは動くので、数えていなければ気づかないままです。メモリの側では、システムのグラフィックバッファが最大で 1488 個増えました。グラフィックバッファは、macOS が画像データを置いておく OS 側の領域で、IOSurface と呼ばれます。その個数を処理の前後で数えました。システム全体のメモリ使用量は最大で 3752 MB 増えています。ただしこの読みは揺れが大きく、同じ条件でも 2 回測ると 1 GB 以上ずれることがあるので、目安として見てください。一方で、ブラウザーのプロセスのメモリは 0 MB の増加でした。プロセスのメモリだけを眺めていても、この 1421 枚は見えません。
図で見てほしいのは赤い線の形です。開始から 1 秒ほどでピークに届き、そこから 20 秒かけて 0 まで下りていきます。1500 枚のうち 1421 枚が、早い段階で処理待ちに回っています。あとの時間はずっと、溜まったフレームを 1 枚ずつ片づけているだけです。
decodeQueueSize だけで待っても未処理は 1401 枚溜まった
次に試したのは、冒頭に書いた decodeQueueSize だけを見る書き方です。decode() を呼ぶ手前で、この値が 8 を超えていたら待ちます。waiting の長さは見ません。1 フレームごとの処理は先ほどと同じで、10 ms 待ってから close() します。decodeQueueSize のピークは 9 に収まりました。ところが waiting のピークは 1401 で、グラフィックバッファの増分は最大 1549 個です。何も制御しなかった回の 1421 枚、1488 個とほとんど変わりません。Chrome 154 でも同じ傾向で、順に 9、1410 枚、1457 個でした。改札の手前の列は短いのに、ホームには人があふれている状態です。
列を 2 本とも見て待つと未処理は 17 枚で止まった
最後に、待つ条件を 2 つにしました。改札の手前の列にあたる decodeQueueSize と、ホームにあたる waiting.length です。
for (const packet of packets) {
while (videoDecoder.decodeQueueSize > 8 || waiting.length > 4) {
await wait(2);
}
videoDecoder.decode(packet);
}
列は 2 本とも短くなりました。decodeQueueSize のピークは 9、waiting のピークは 17 です。グラフィックバッファの増分は最大 29 個で、システム全体のメモリ増分は最大 165 MB です。9 という値は「8 を超えていたら待つ」と書いた結果で、9 個目までは入ります。waiting はしきい値の 4 に対して 17 まで行きました。なぜ 13 枚も上に振れるのかは調べていません。全フレームを処理し終えるまでの時間は、制御なしが 20.3 秒、decodeQueueSize だけを見た回が 19.9 秒、両方見た回が 18.9 秒でした。どれも近い値で、1 回ずつ測っただけなので、どれが速いとは言えません。Chrome 154 でも、制御なしでは未処理のピークが 1430 でグラフィックバッファは 1487 個増え、両方見ると 17 と 33 個でした。
close したあともバッファの数はすぐには減らなかった
制御なしの回には、もう 1 つ気になる読みがありました。処理が進むにつれて、未処理のフレームは 1403 から 4 まで減っていきます。1 枚処理するたびに close() も呼んでいます。それでもグラフィックバッファの数は減りません。1430 から 1490 のあたりにとどまり、ブラウザーを終了するまで戻りませんでした。理由は調べていません。分かったのは、溜めてから片づけても、この計測の間は数が減らなかったということです。それなら最初から溜めないほうを選びます。
自分の検証ページは 10 ms 待つだけの軽い処理です。もっと重い処理を挟む実物でも同じ読みになるのか、すでにあるフレーム単位の処理でも同じ数え方をしてみました。私が使ったのは、ブラウザーの中で画像や動画を処理する ImgIng(https://imging.ai/ )の、動画の背景を切り抜く機能(Beta)です。デスクトップの Chrome と Edge を優先してサポートするとされている機能で、試したのは Chromium 149 だけです。同じ 1500 フレームのサンプルを通して 2 秒ごとに数えたところ、処理中のグラフィックバッファの増分は −2 から 79 の間でした。中央値は 26 で、処理が進んでも増えていきません。ただ、サンプルは波の映像です。切り抜く対象になる被写体が写っていません。ここから言えるのはメモリの読みだけで、切り抜きの出来については何も言えません。中でどう制御しているのかも見ていません。
上の画像は別の動画のプレビューです。波の動画には切り抜く対象がないため、8 秒の路上ギター演奏の映像(Isak Zvegelj さん撮影、Wikimedia Commons、CC BY 3.0)を使いました。左が元のフレーム、右が切り抜き後で、バッファの数値はこの動画では取り直していません。
こちらは線が右上がりになっていないことだけ見てもらえれば足ります。最初の山が 79 個で、開始から 40 秒ほどたったあとは最後までほぼ平らです。
範囲も書いておきます。測ったのは Apple M4 の 1 台だけです。IOSurface は macOS での呼び方で、ほかの OS でフレームがどこに置かれるのかは確かめていません。ハードウェアデコードとソフトウェアデコードの区別もしていません。サンプルは同じ映像の繰り返しなので、システム全体のメモリの読みは、本物の長い動画より低く出ている可能性があります。コーデックも H.264 しか試していません。
いまは VideoDecoder を使うコードを書くとき、先に数える場所を 2 つ用意しています。output で受け取ったフレームの数を自分で持ち、decode() の手前でその数と decodeQueueSize の両方を確かめます。動かしたあとは、プロセスのメモリではなく、グラフィックバッファの個数を処理の前後で比べます。macOS なら ioclasscount IOSurface で数えられます。
サンプルの波の映像は、Wikimedia Commons にある David Shay(דוד שי)さんの動画(CC BY-SA 4.0)をもとにしました。


