たくさんの自由帳

WebCodecs で逆再生の動画を作る

投稿日 : | 0 日前

文字数(だいたい) : 21736

目次

どうもこんばんわ。ハッピーウィークエンド攻略しました。こんかいのHOOKにはお姉さんヒロインがいます!!
かわいくて声もよかった。こはるさんがかわいかった!のでおすすめです。

感想

"文字列リテラル"になってる(?)

共通が長め + 前作より過程が楽しめます!。前作が分割だったからそりゃそうかもしれないけど
終わり方がHOOKだ!!

感想

ゆきさん!!声がかわいい
フライパン

感想

かわいい

感想

がーん

感想

ひなたさん面白くてよかった。あとえちえち。
いっぱいスクショあります

感想

(笑)読み上げてて笑った

感想

感想

ここすk

感想

!?!?

感想

がるるるる

感想

敵ぃ←かわいい

感想

あきなさん!!!色々と・・・

感想

ここすき

感想

大人組の会話、こはるさんの方はイベント絵だったのでこっちで

感想

あ!!

感想

おすすめはこはるさんルートです。あらあ

感想

おでかけ・・!

感想

!!!

感想

かみのけ!!!おろしてる!

感想

くろまきー用こはるさんもあります(?)

感想

かわい~~~

感想

感想

!!!!!

感想

いまなら!

感想

あと!!全員にひとりのしーんがある!!!

本題

今回はブラウザの中で完結する、逆再生の動画を作るWebサイトを作ろうと思います。
WebCodecs APIっていう、映像・音声コーデックエンコーダー/デコーダーを直接利用することが出来るブラウザ由来のAPIがあります。今回はそれらを使うことで低レイヤーな動画処理をしようかと。

webm.negitoro.dev

作ろうと思いますというか、前に作ったWebMWebサイトしれっと追加しているので、それの詳細記事になるんですが・・・

自分 Android ユーザー、エンジニアなんですけど

Androidエンコード / デコード APIであるMediaCodecを使って逆再生の動画を作る。

こっちはAndroidにあるコンテナフォーマット読み書きAPI (MediaMuxer)を利用するため、mp4webmもいけるし、10 ビット HDR動画もいけます。

完成品

これのWebM ファイルを逆再生して保存するで、逆再生の動画を作ることが出来ます。
利用するためには WebM ファイル(映像コーデック:VP9 + 音声コーデック:Opus)である必要があります。それが厳しい場合はAndroid ユーザー・エンジニア向けに案内したアプリを使ってください。

というのも、このWebCodecs API名前の通りデコーダー・エンコーダーAPIしか無く、WebM / mp4等のコンテナフォーマットへ書き込むAPIが存在しないんですよね。
コンテナフォーマットを読み書きするとなると、仕様が公開されてて、MediaRecorder APIでも使われている実績があるWebMを採用するしか無く、それに引きずられて映像コーデックは VP9音声コーデックは Opus、、、と決まるわけです。
AV1 / VP8でも良いけど・・・)

詳しくは後述します!!!

あとAndroidエンジニアの誘導のくだりの続きですが、当時はWebCodecs APIChrome 系列でしか実装されてなかったはず。
Android版を作ったあとくらいにFirefoxでも実装されたため、Webサイト版が作れます!!SafariAppleユーザーじゃないんで知らないです。

ですが、今回作るやつ、なんかFirefoxでは動きませんでした(?)

そもそも逆再生の動画って難しいんですか?

よく見るかなと思います。逆再生の動画。あれって難しいのという話。

<video src="test.mp4" reversed={true}></video>

上記のreversedパラメータなんて存在しないんですが、その理由とそもそも難しいんか?って話をするために、世の中の動画ファイルの話をします。
AndroidMediaCodecの記事で何回か言ったような気がするけど再掲しておきます。。。

今日の動画ファイルが再生できる仕組み

今日のオンライン会議から、今晩のおかずまでを支えている動画の仕組みをば。
WebCodecs APIといいMediaCodec APIの立ち位置がわからないよって人向け。

流れ

コーデック

AVC(H.264)HEVC(H.265)VP9AV1とかが映像コーデック、AACOpusが音声コーデックの種類ですね。

コーデックというのが映像や音声を小さくするためのアルゴリズムのことです。
そして、このアルゴリズムを動かして、映像・音声を圧縮するのがエンコーダーで、逆に解凍するのがデコーダーです。GPUの中にあります。ない場合はCPUがやります。

なんで圧縮なんかしているのかと言うとクソデカファイルになってしまうからです。

例えばフルHDの解像度では1920x1080=2073600ピクセル分のデータが必要で、その上、1ピクセル4バイト(32ビット)を消費するので、1920x1080x4=8294400バイト必要です。
単位を変換すると8.29MBになります。
4バイトの出どころさんは、カラーコードの#000000から#FFFFFFFFを表現するため。RGBAがそれぞれ8ビット使う(#00から#FF))
(余談ですが10 ビット HDR動画はRGB10ビット使い、アルファチャンネルが残りの2ビット使っている。)

これが映像の一枚分のデータ
よく見る動画ファイルは、30fps60fps120fpsの場合が多いため、8.29(MB)x30(fps)にすれば1秒30fpsの動画が出来ます。明らかに動画サイズが大きすぎる。

これをいい感じに小さくするのがコーデックの役割。

コーデックはいくつかの方法を利用してファイルサイズを小さくします。この中に逆再生ができなくなる理由があります。
というのも、動画の時間が増えていく方向にしか再生ができないという制約があります。

コーデックさんは、前の画像と変化しないところは保存せず、変化したところだけを保存するそうです。30fpsなら30枚分をまるまる保存しなくて済むので合理的に見えます。
ただ、これだと前の画像は前の前の画像に依存してて、その前はさらに前の画像に依存してて、と一生シーク操作が出来なくなってしまいます。

Imgur

これを解決するために、キーフレームという画像が定期的にエンコーダーからでてきます。
これは前の画像に依存しない画像のため、シークする際はこのキーフレームまで戻ったあと、狙いの時間まで画像を進めればよいわけです。

Imgur

つまり逆再生は難しい!!!

音声の場合

音声の場合も同様にエンコードされていますが、これを全部デコードしたところでそこまでデータ量が大きくならないと思います。(大きいけど)

詳しく話すと、大抵の音声ファイルは1秒間に48_000回記録します。これがサンプリングレートと呼ばれているものです。
次に、一回の記録で16ビット使います。これをビット深度とか言います。ロスレス配信気になってる人!
最後に、左右で違う音を出すために二倍の容量を利用します。なので一回で32ビットですね。これをチャンネル数とか言います。

全部掛け算すると、1秒間に1_536_000ビット、バイトに変換するために8で割ると192_000バイトしか使いません。1秒で。

なので、音声ファイルの逆再生に関しては、一回全部デコードしたあと、後ろからデータをエンコーダーに渡していけばいいんじゃないかと思っています。

コンテナ

MP4WebMといったのはコンテナフォーマットの種類のことです。
ちなみにMP4の前世がMOV形式です。iPhoneの動画ファイルがMOVだとしても、これを思い出して.mp4に拡張子を書き換えるだけで動く。

雑な絵ですが動画ファイルの中身、WebMがこんなのでほかでもそうなんじゃないかな。

動画ファイルの中身

雑と言ってもそんなに大外れな解釈ではないはずだ、が、

webm_spec

エンコードした音声・映像を入れるための箱です。mp4とかwebmとか言われているやつです。
なので、動画プレイヤーや、<video> タグはまずこのコンテナフォーマットをバラバラにするわけです。

パースすると大まかに3つの塊が現れるかと思います。

  • 動画情報
  • エンコードされた音声トラックのデータ
  • エンコードされた映像トラックのデータ

WebCodecsMediaCodecsもですが、再生のためにまずは動画情報の中身が必要になります。これらのクラスの初期化に必要なので。
WebMの場合はこんな感じになってて、mp4でも同様だと思いますが、

  • 動画の長さ
  • 映像情報
    • コーデックの名前
    • 動画の縦横サイズ
    • SDR なのか HDR なのか
  • 音声情報
    • コーデックの名前
    • サンプリングレート
    • チャンネル数(モノラル・ステレオ)
    • Opusなら固有のデータ?)
  • エンコードされたデータの保存位置
    • 0秒 -> 200バイト目
    • 繰り返し...

映像や音声などの、中にはいっているデータの種類をトラックとよんだりします。
映像トラック、音声トラックのように。

デコーダー・エンコーダー

動画情報をもとにWebCodecs APIをセットアップすると、ついに再生の準備が出来ました。
エンコードされたデータを順番にデコーダーに入れるだけです。

WebCodecs APIのデコーダーはVideoFrameをコールバック関数で受け取ることが出来て、これをCanvasに書けば、WebCodecs APIで動画再生(映像のみ)が可能になる!!
同様にエンコーダーにVideoFrame<canvas>を入れればエンコード済みデータが取得できる。これをWebMとかにルール通り保存すれば、動画プレイヤーで再生することが出来る。

むずかしいくね

まあどっちかというと私の説明が怪しい。

つまるところ、WebCodecs APIとかMediaCodec APIとかってのは、この圧縮・解凍を行うエンコーダー・デコーダーを指しているわけ、。
で、それとは別にWebCodecs APIで再生するためにはWebMを解析する(デマルチプレクサ、パーサー)が必要。
パースした結果を渡すことで再生できるので。。。

そして逆再生は一筋縄ではいかないということも。

付録 video タグや MSE との違いは?

<video>タグを自前で作り直すポテンシャルがあり、MSEと違ってコンテナフォーマットに縛られない。

<video>Media Source ExtensionsWebCodecs
srcからロードする機能ロードする箇所は自前で作成可能(生配信など)ロードする箇所は自前で作成可能
コンテナフォーマットを解析する機能コンテナフォーマットは規定の物を利用(fmp4webmコンテナフォーマットも自力で解析するので好きにできる
デコーダーに入れて画面に表示する機能デコーダーに入れて画面に表示する機能デコーダーに入れるまでは自前で作る必要
UIを提供する機能UIを提供する機能<canvas>に書けば最低限画面に表示できる
iOS対応iOS非対応iPad OSのみ対応、謎)知らない。?

作戦

映像トラックに関しては一回一回キーフレームまで戻ってフレームを取得するしかない。
音声トラックは先述の通り一回すべてデコードして、後ろからエンコーダーに突っ込む作戦。

後ろからフレームを撮る

それよりも問題はWebMに書き込む処理なんだよ

WebM コンテナフォーマットのパーサとミキサーが必要

というわけで、WebCodecs API映像・音声エンコーダー・デコーダーAPIしか存在しないので、自分で作るかライブラリを入れるかする必要があり。

こんかいは自作!!!
WebMの仕様はインターネットで見ることが出来るので誰でもパーサー(デマルチプレクサ)を作ることが出来ます!

WebCodecs API が難しいうんぬんも、コンテナフォーマット読み書きを用意するほうがよっぽど難しい!!!

どうでもいいですが、mp4の仕様はお金を払えば見れるはず(ISOなんとか)だが、仮に買ったとてそもそも英語がわからない。。。。

Kotlin でできた WebM 読み書き処理をブラウザで使う

ここの説明は余談な感じです。のと前ブログで書いたので省きます!!

自作したと言ってもKotlinで書いたので、これをブラウザ JavaScriptに持ってくる必要があります。
幸いなことにKotlin MultiplatformKotlin/Wasmをターゲットにできて、かつ、npmライブラリ吐き出す機能があるため、Kotlinで書いた関数をWasmの力でJavaScript/TypeScriptから呼び出すことが出来る!!!!

WebM 読み書きライブラリ メイドイン Kotlin Wasm

Kotlin Multiplatformのコードをnpm i出来るようにしました。
私のWebM 読み書きライブラリを使いたいという人は多分存在しないと思いますが、もし使う場合は、npmレジストリには存在しないので、GitHubからnpm iしてください・・・

npm install takusan23/himari-webm-kotlin-multiplatform-npm-library

今回はこのライブラリを使ってWebMを解析し、WebCodecs APIでデコードとエンコードをし、このライブラリで再度WebMを組み立てるという魂胆でいます。

環境

WebCodecs APIを使いたいだけなので、React + ViteSPAの構成でも良いんですが、今回はNext.jsにします。
TypeScriptTailwindCSSがすぐ使えるので。まあNext.js使いますが大多数を"use client"にするので、やってることはReact + ViteSPAと大体同じという。。。

なまえあたい
利用する動画WebM コンテナフォーマット形式(映像コーデックVP9、音声コーデックOpus
Next.js16.1.0 (クライアントコンポーネントだけ使います)
WebM 読み書き自前のものを(先述)
CSSTailwindCSS

また、今回はWebM + VP9 + Opusの動画でやります。
エンコーダー・デコーダーを起動する際に、なんのコーデックを使っているかで分岐するべきなのだが、今回は決め打ちしています。運用でカバー

今回使う WebM ファイルを用意する

先述の通りで、JavaScriptMediaRecorder APIで作ったもので良いです。ffmpegで変換しても良いですが。
ま何でも良いのでWebMコンテナフォーマット形式(映像コーデックVP9、音声コーデックOpus)を守ってくれれば、多分今回作るWebサイトは動きます。

今回は、わたしのサイトで画面録画してWebMファイルを取得したものを使おうと思います。これ↑
中身はgetDisplayMedia()で画面録画用のデータをもらい、MediaRecorder APIWebMに保存する処理をやってるだけ。ブラウザ内部で完結しています。

逆再生よりまず普通に再生してみる

とりあえず画面に映像トラックだけ表示する例をやってみようと思った。

適当なコンポーネントを作った

WebCodecsVideoPlayer.tsxを作りました。
動画を流すための<canvas>、動画を選ぶ<input type="file">と、startVideoPlay()関数で再生が始まる的なやつです。

めんどうなのでhooksにはしてないです。

'use client'

export default function WebCodecsVideoPlayer() {
    const canvasRef = useRef<HTMLCanvasElement>(null)

    /** 再生を始める */
    async function startVideoPlay(file?: File) {
        if (!file) return
    }

    return (
        <div className="flex flex-col space-y-2 border rounded-md border-l-blue-600 p-4 items-start">

            <h2 className="text-2xl text-blue-600">
                WebCodecs で動画再生
            </h2>

            <canvas
                ref={canvasRef}
                width={640}
                height={320} />

            <input
                className="p-1 border rounded-md border-l-blue-600"
                accept="video/webm"
                type="file"
                onChange={(ev) => startVideoPlay(ev.currentTarget.files?.[0])} />
        </div>
    )
}

WebM 読み書きを用意

Kotlin Multiplatformで作った読み書き関数をWasmにして呼び出しています。
読み書きに何を採用するかによって違うと思う。。。

今回は自前のを入れます...

npm install takusan23/himari-webm-kotlin-multiplatform-npm-library

再生関数の中身

startVideoPlay()関数の全貌です。詳細はこのあとすぐ!

/** 再生を始める */
async function startVideoPlay(file?: File) {
    if (!file) return
    if (!canvasRef.current) return

    // Kotlin Multiplatform で出来たライブラリをロード
    // 絶対クライアントが良いので動的ロードする
    const {
        parseWebm,
        getVideoHeightFromWebmParseResult,
        getVideoWidthFromWebmParseResult,
        getVideoEncodeDataFromWebmParseResult,
        getTimeFromEncodeData,
        getEncodeDataFromEncodeData,
        isKeyFrameFromEncodeData
    } = await import("himari-webm-kotlin-multiplatform")

    // WebM をパース
    const arrayBuffer = await file.arrayBuffer()
    const intArray = new Int8Array(arrayBuffer)
    const parseRef = parseWebm(intArray as any)

    // 動画の縦横サイズを出す
    const videoHeight = Number(getVideoHeightFromWebmParseResult(parseRef))
    const videoWidth = Number(getVideoWidthFromWebmParseResult(parseRef))

    // 映像トラックのエンコード済みデータの配列
    // TODO 全部メモリに乗せることになってる
    const videoTrackEncodeDataList = Array.from(getVideoEncodeDataFromWebmParseResult(parseRef) as any).map((ref) => ({
        time: getTimeFromEncodeData(ref),
        encodeData: getEncodeDataFromEncodeData(ref),
        isKeyFrame: isKeyFrameFromEncodeData(ref)
    }))

    // とりあえず Canvas に書く
    const ctx = canvasRef.current.getContext('2d')

    // WebCodecs をいい感じに Promise にする
    let outputCallback: ((videoFrame: VideoFrame) => void) = () => { }

    // outputCallback() 呼び出しまで待機する Promise
    function awaitVideoFrameOutput() {
        return new Promise<VideoFrame>((resolve) => {
            outputCallback = (videoFrame) => {
                resolve(videoFrame)
            }
        })
    }

    // WebCodecs インスタンスを作成
    const videoDecoder = new VideoDecoder({
        error: (err) => {
            alert('WebCodecs API でエラーが発生しました')
        },
        output: (videoFrame) => {
            outputCallback(videoFrame)
        }
    })

    // セットアップ
    videoDecoder.configure({
        codec: 'vp09.00.10.08', // コーデックは VP9 固定にする...
        codedHeight: videoHeight,
        codedWidth: videoWidth
    })

    // 開始時間を控えておく
    const startTime = Date.now()

    // 映像トラックのエンコード済みデータを得る
    for (const encodeData of videoTrackEncodeDataList) {

        // 順番にデコーダーに入れていく
        const videoChunk = new EncodedVideoChunk({
            data: new Int8Array(encodeData.encodeData as any).buffer as any,
            timestamp: Number(encodeData.time) * 1_000,
            type: encodeData.isKeyFrame ? 'key' : 'delta'
        })

        // デコーダーに入れる
        const outputPromise = awaitVideoFrameOutput()
        videoDecoder.decode(videoChunk)

        // output = () => { } が呼び出されるのを待つ
        const videoFrame = await outputPromise

        // Canvas にかく
        ctx?.drawImage(videoFrame, 0, 0, canvasRef.current.width, canvasRef.current.height)
        videoFrame.close()

        // 次のループに進む前に delay を入れる。30fps なら 33ms は待つ必要があるので
        // タイムスタンプと再生開始時間を足した時間が、今のフレームを出し続ける時間
        const delayMs = (startTime + (videoFrame.timestamp / 1_000)) - Date.now()
        await new Promise((resolve) => setTimeout(resolve, delayMs))
    }

    // おしまい
    videoDecoder.close()
}

WebM のパース部分

ここはWebMのパーサーというか読み書きライブラリ次第なのであんまり話してもあれですが。

if (!file) return
if (!canvasRef.current) return

// Kotlin Multiplatform で出来たライブラリをロード
// 絶対クライアントが良いので動的ロードする
const {
    parseWebm,
    getVideoHeightFromWebmParseResult,
    getVideoWidthFromWebmParseResult,
    getVideoEncodeDataFromWebmParseResult,
    getTimeFromEncodeData,
    getEncodeDataFromEncodeData,
    isKeyFrameFromEncodeData
} = await import("himari-webm-kotlin-multiplatform")

// WebM をパース
const arrayBuffer = await file.arrayBuffer()
const intArray = new Int8Array(arrayBuffer)
const parseRef = parseWebm(intArray as any)

先頭のif早期 returnで値があることを確認するためです。関係ないね
その後の、await import("himari-webm-kotlin-multiplatform")ですが、これがKotlin Multiplatformで書かれた関数をインポートしている部分です。
なんか絶対クライアント側でロードしないとうまく行かなかった。

その後はarrayBuffer()WebMファイルのバイト配列を取得し、これをKotlinで書かれた処理に渡すという。
Kotlin側気になる方はここです。バイト配列をいじくり回してます・・・

WebM からデコーダー起動に必要な値を取ってる部分

// 動画の縦横サイズを出す
const videoHeight = Number(getVideoHeightFromWebmParseResult(parseRef))
const videoWidth = Number(getVideoWidthFromWebmParseResult(parseRef))

// 映像トラックのエンコード済みデータの配列
// TODO 全部メモリに乗せることになってる
const videoTrackEncodeDataList = Array.from(getVideoEncodeDataFromWebmParseResult(parseRef) as any).map((ref) => ({
    time: getTimeFromEncodeData(ref),
    encodeData: getEncodeDataFromEncodeData(ref),
    isKeyFrame: isKeyFrameFromEncodeData(ref)
}))

このへんです。
なんでやたらめったらparseRefを引数にした関数呼び出しを行っているかと言うと、Kotlin/Wasm側にしかデータクラスの値が無くて、データクラスからそれぞれの値を取得するための関数を呼び出しているためです。

TypeScript側からだとparseRefは何も意味をなさないオブジェクトなのですが、これをKotlin/Wasm側へ渡すと、Kotlin/Wasmの世界で持っている参照に変換(ここではデータクラスの参照に変換)出来る。
これを使って、データクラスのそれぞれの値を返す関数をその都度呼び出している。もしかするとKotlin/JSならそんな事しなくても良かった可能性が?

この辺もKotlin側みたい人は!!!

WebCodecs デコーダー起動している部分

// WebCodecs をいい感じに Promise にする
let outputCallback: ((videoFrame: VideoFrame) => void) = () => { }

// outputCallback() 呼び出しまで待機する Promise
function awaitVideoFrameOutput() {
    return new Promise<VideoFrame>((resolve) => {
        outputCallback = (videoFrame) => {
            resolve(videoFrame)
        }
    })
}

// WebCodecs インスタンスを作成
const videoDecoder = new VideoDecoder({
    error: (err) => {
        alert('WebCodecs API でエラーが発生しました')
    },
    output: (videoFrame) => {
        outputCallback(videoFrame)
    }
})

// セットアップ
videoDecoder.configure({
    codec: 'vp09.00.10.08', // コーデックは VP9 固定にする...
    codedHeight: videoHeight,
    codedWidth: videoWidth
})

new VideoDecoder()で映像デコーダーのインスタンスが取得できます。
デコード結果を取得できるコールバックと、エラー時に呼ばれるコールバックを取ります。映像デコーダーの場合はoutputVideoFrameのオブジェクトをもらうことが出来ます。

ただ、コールバックだとちょっと扱いにくいので、コールバック呼び出しをPromiseにするための関数がlet outputCallbackと、awaitVideoFrameOutput()です。
詳しい話はこれを使うときに!!

時間通りに再生する処理

// 開始時間を控えておく
const startTime = Date.now()

// 映像トラックのエンコード済みデータを得る
for (const encodeData of videoTrackEncodeDataList) {

    // 順番にデコーダーに入れていく
    const videoChunk = new EncodedVideoChunk({
        data: new Int8Array(encodeData.encodeData as any).buffer as any,
        timestamp: Number(encodeData.time) * 1_000,
        type: encodeData.isKeyFrame ? 'key' : 'delta'
    })

    // デコーダーに入れる
    const outputPromise = awaitVideoFrameOutput()
    videoDecoder.decode(videoChunk)

    // output = () => { } が呼び出されるのを待つ
    const videoFrame = await outputPromise

    // Canvas にかく
    ctx?.drawImage(videoFrame, 0, 0, canvasRef.current.width, canvasRef.current.height)
    videoFrame.close()

    // 次のループに進む前に delay を入れる。30fps なら 33ms は待つ必要があるので
    // タイムスタンプと再生開始時間を足した時間が、今のフレームを出し続ける時間
    const delayMs = (startTime + (videoFrame.timestamp / 1_000)) - Date.now()
    await new Promise((resolve) => setTimeout(resolve, delayMs))
}

// おしまい
videoDecoder.close()

デコーダーに渡す際はEncodedVideoChunk形式にする必要があるみたいです。
エンコード済みのバイト配列、再生時間(マイクロ秒です!!)、キーフレームかどうかを指定する必要があります。これらはWebMから取り出す際に取得できるので、それを渡せばおっけー

webm_encoded_detail

まああとは、順番にデコーダーに入れています。ただ、これを適当に入れると、動画が超高速で再生されてしまいます。
例えば、30fpsであれば33msの間はそのフレームを表示し続ける必要があります。が、デコーダーはそれよりも圧倒的に早い時間でデコードし終えるので、待ってから次のデータを入れる処理が必要になります。

そのためにはPromisesetTimeout的な、待つ処理的なのをかけると良い感じに解決しそうですよね。

ここで、先述のデコーダーのコールバックをPromiseにしたものが役に立つわけです。
デコーダーに入れる前に、awaitVideoFrameOutput()を呼び出す。これは、outputCallbackコールバック関数を作り直し、それが呼び出されるまではresolve()しないようなPromiseを作成します。
次に、VideoDecoder#decode()を呼び出しデコードを行う。目論見通りならoutput: (videoFrame) => { }コールバック関数が呼ばれるはず。
この中でoutputCallbackが呼び出され、Promiseresolved状態になる。

すると、await outputPromiseの部分が呼び出しから戻って来て、VideoFrameが取得できる。
これをCanvasに書けば画面に表示することが出来る。

最後に、フレームの時間だけ待つsetTimeoutPromiseにしてawaitしたら、1フレームの再生は成功。
これをエンコード済みデータがある限り繰り返せば、動画プレイヤーになります!

とりあえず再生してみる

今作ったコンポーネントをpage.tsx等で画面に表示していきます。

import WebCodecsVideoPlayer from "./WebCodecsVideoPlayer";

export default function Home() {
  return (
    <div className="flex flex-col p-2 space-y-2">
      <h1 className="text-4xl">WebCodecs + WebM サイト</h1>

      <WebCodecsVideoPlayer />
    </div>
  )
}

ちゃんと動画が流れています!!

webcodecs_play_on_chrome

webcodecs_play_on_firefox

思ったよりシンプル?

一時停止シーク再生速度変更をやろうとすると一気にしんどくなると思います!!!!!!!!
それに映像だけだし。音声トラックも再生するとなると再生位置を同期させる必要があって・・・

本題の逆再生を作っていきたい

ReverseVideoMaker.tsxを作りました。
まずは見た目を。今どこやってるかを表示できる<canvas>を置きました。色同じだとあれなので赤色にした。

'use client'

import { useRef } from "react"

export default function ReverseVideoMaker() {
    const canvasRef = useRef<HTMLCanvasElement>(null)

    /** 処理を開始する */
    async function process(file?: File) {
        if (!file) return
        if (!canvasRef.current) return

        // TODO この後すぐ
    }

    return (
        <div className="flex flex-col space-y-2 border rounded-md border-l-red-600 p-4 items-start">

            <h2 className="text-2xl text-red-600">
                WebCodecs で逆再生動画を作る
            </h2>

            <canvas
                ref={canvasRef}
                width={640}
                height={360} />

            <input
                className="p-1 border rounded-md border-l-blue-600"
                accept="video/webm"
                type="file"
                onChange={(ev) => process(ev.currentTarget.files?.[0])} />
        </div>
    )
}

逆再生に使う関数をインポートして解析

毎度のごとく申し訳ないと思っております。WebMコンテナをパースする部分が自作なので、パット見で何だか分からない関数だらけなの。
かつKotlin Multiplatform(Kotlin/Wasm)でややこしい。。。

// クライアントでロードする
const {
    getEncodeDataFromEncodeData,
    getTimeFromEncodeData,
    getVideoEncodeDataFromWebmParseResult,
    getVideoHeightFromWebmParseResult,
    getVideoWidthFromWebmParseResult,
    isKeyFrameFromEncodeData,
    parseWebm,
    createMuxerWebm,
    setAudioTrack,
    setVideoTrack,
    writeAudioTrack,
    writeVideoTrack,
    muxerBuild,
    getAudioCodecFromWebmParseResult,
    getAudioSamplingRateFromWebmParseResult,
    getAudioChannelCountFromWebmParseResult,
    getAudioEncodeDataFromWebmParseResult
} = await import("himari-webm-kotlin-multiplatform")

// WebM をパース
const arrayBuffer = await file.arrayBuffer()
const intArray = new Int8Array(arrayBuffer)
const parseRef = parseWebm(intArray as any)

// 音無しの動画にも対応するため
const hasAudioTrack = !!getAudioCodecFromWebmParseResult(parseRef)

// エンコードされたデータを取得する
// TODO メモリに優しくない
const videoTrackEncodeDataList = Array.from(getVideoEncodeDataFromWebmParseResult(parseRef) as any).map((ref) => ({
    time: getTimeFromEncodeData(ref),
    encodeData: getEncodeDataFromEncodeData(ref),
    isKeyFrame: isKeyFrameFromEncodeData(ref)
}))
const audioTrackEncodeDataList =
    hasAudioTrack
        ? Array.from(getAudioEncodeDataFromWebmParseResult(parseRef) as any).map((ref) => ({
            time: getTimeFromEncodeData(ref),
            encodeData: getEncodeDataFromEncodeData(ref),
            isKeyFrame: isKeyFrameFromEncodeData(ref)
        }))
        : []

// 時間やデコーダー起動に必要な値を出す
const durationMs = videoTrackEncodeDataList[videoTrackEncodeDataList.length - 1].time
const videoHeight = Number(getVideoHeightFromWebmParseResult(parseRef))
const videoWidth = Number(getVideoWidthFromWebmParseResult(parseRef))
const samplingRateOrNull =
    hasAudioTrack
        ? Number(getAudioSamplingRateFromWebmParseResult(parseRef))
        : null
const channelCountOrNull =
    hasAudioTrack
        ? Number(getAudioChannelCountFromWebmParseResult(parseRef))
        : null

今回は音ありの動画を考慮するため、hasAudioTracktrue/falseで持っています。!!truthyな値をBooleanに変換する技、たまに使いたくなる。double exclamation markとか言う技らしい。
再生と同様に、映像・音声トラックの情報と、エンコード済みデータを取り出しています。音声エンコーダー・デコーダーにはサンプリングレートチャンネル数(ステレオだと思いますが)が必要なので。

逆再生 プレビュー Canvas

逆再生エンコードしてる?進捗を見るために。なお音声の処理は特に用意してないので進捗を見ることは出来ないです。

// 進捗具合を canvas に描画
const ctx = canvasRef.current?.getContext('2d')

WebM 書き込みをつくる

ここがWebMを書き込むための処理を初期化している部分。
WebMファイルに限らず、コンテナフォーマットは動画情報(映像情報+音声情報+etc)と、エンコード済みデータ(時間と共に増えていく)の2つに大別することが出来ます。
ので、おそらく他のWebM書き込みライブラリだとしても、まず最初に映像トラック・音声トラックの情報を入れると思います。

// WebM に書き込むクラス作成 + 映像トラック追加 + 音声トラック追加
const muxerRef = createMuxerWebm()
setVideoTrack(muxerRef, videoWidth, videoHeight)
if (hasAudioTrack && samplingRateOrNull && channelCountOrNull) {
    setAudioTrack(muxerRef, samplingRateOrNull, channelCountOrNull)
}

映像トラックを逆にする処理

関数の中に関数を書いて複雑になってきた。関数の中に関数を定義したので、これまでに作った変数は引き続き参照できます。
関数に分けたのは変数名の名前が被るから。でもifとかでスコープがすぐ切れるのであんまり意味なかったかも。

/** 映像トラックを逆からデコードしてエンコードする処理 */
async function processVideoTrack() {
    // WebCodecs のコールバックを Promise にする
    let decoderOutput: ((videoFrame: VideoFrame) => void) = () => { }
    let encoderOutput: ((chunk: EncodedVideoChunk) => void) = () => { }

    // 映像エンコーダー・デコーダー用意
    const videoDecoder = new VideoDecoder({
        error: (err) => { alert('映像デコーダーでエラーが発生しました') },
        output: (videoFrame) => { decoderOutput(videoFrame) }
    })
    videoDecoder.configure({
        codec: 'vp09.00.10.08',
        codedHeight: videoHeight,
        codedWidth: videoWidth
    })
    const videoEncoder = new VideoEncoder({
        error: (err) => { alert('映像エンコーダーでエラーが発生しました') },
        output: (chunk) => { encoderOutput(chunk) }
    })
    videoEncoder.configure({
        codec: 'vp09.00.10.08',
        height: videoHeight,
        width: videoWidth,
        framerate: 30
    })

    // コールバックを Proimise にする関数
    function awaitDecoderOutput() {
        return new Promise<VideoFrame>((resolve) => {
            decoderOutput = (videoFrame) => {
                resolve(videoFrame)
            }
        })
    }
    function awaitEncoderOutput() {
        return new Promise<EncodedVideoChunk>((resolve) => {
            encoderOutput = (chunk) => {
                resolve(chunk)
            }
        })
    }

    // エンコードされているデータを逆からデコードして、エンコーダーに突っ込む
    // 単位はマイクロ秒
    for (let frameIndex = videoTrackEncodeDataList.length - 1; frameIndex >= 0; frameIndex--) {
        const encodeChunkData = videoTrackEncodeDataList[frameIndex]
        const frameMs = Number(encodeChunkData.time)

        // キーフレームじゃない場合はキーフレームまで戻る
        if (!encodeChunkData.isKeyFrame) {
            const keyFrameIndex = videoTrackEncodeDataList.findLastIndex((chunk) => chunk.isKeyFrame && chunk.time < frameMs)
            for (let iFrameIndex = keyFrameIndex; iFrameIndex < frameIndex; iFrameIndex++) {
                const iFrameChunk = videoTrackEncodeDataList[iFrameIndex]
                const iFrameVideoChunk = new EncodedVideoChunk({
                    data: new Int8Array(iFrameChunk.encodeData as any).buffer as any,
                    timestamp: (durationMs - Number(iFrameChunk.time)) * 1_000,
                    type: iFrameChunk.isKeyFrame ? 'key' : 'delta'
                })
                // デコーダー出力の Promise を待つが特に使わずに close()
                const promise = awaitDecoderOutput()
                videoDecoder.decode(iFrameVideoChunk)
                const unUseVideoFrame = await promise
                unUseVideoFrame?.close()
            }
        }

        // 戻ったのででデコード
        const videoChunk = new EncodedVideoChunk({
            data: new Int8Array(encodeChunkData.encodeData as any).buffer as any,
            timestamp: (durationMs - Number(encodeChunkData.time)) * 1_000,
            type: encodeChunkData.isKeyFrame ? 'key' : 'delta'
        })

        // Promise を作ってデコーダーに入れた後 await 待つ
        const videoFramePromise = awaitDecoderOutput()
        videoDecoder.decode(videoChunk)
        const videoFrame = await videoFramePromise

        // プレビュー、アスペクト比を保持して拡大縮小
        // https://stackoverflow.com/questions/23104582/
        if (canvasRef.current) {
            var hRatio = canvasRef.current.width / videoFrame.displayWidth
            var vRatio = canvasRef.current.height / videoFrame.displayHeight
            var ratio = Math.min(hRatio, vRatio)
            ctx?.drawImage(videoFrame, 0, 0, videoFrame.displayWidth, videoFrame.displayHeight, 0, 0, videoFrame.displayWidth * ratio, videoFrame.displayHeight * ratio)
        }

        // 逆からとったフレームをエンコーダーに入れて待つ
        const chunkPromise = awaitEncoderOutput()
        videoEncoder.encode(videoFrame)
        const chunk = await chunkPromise

        // エンコード結果を WebM に書き込む
        const frameData = new Uint8Array(chunk.byteLength)
        chunk.copyTo(frameData)
        writeVideoTrack(muxerRef, frameData as any, chunk.timestamp / 1_000, chunk.type === "key", false)
        videoFrame.close()
    }

    // リソース解放
    videoDecoder.close()
    videoEncoder.close()
}

映像エンコーダー、デコーダーを用意し Promise にする

VideoEncoderVideoDecoderを作ります。
エンコーダーの方のコールバックはEncodedVideoChunkがもらえます。これをWebMに書き込む。
デコーダーの方はVideoFrameが取得できます。これをエンコーダーに入れてエンコードしたり、<canvas>に書くことが出来る。

同様にPromiseにします。
ループ内でエンコーダー、デコーダーから出てくるまでawaitするような処理が書きたくて!!

どこかで書いたかもしれないですが、面倒なのでVP9コーデック決め打ちです。

// WebCodecs のコールバックを Promise にする
let decoderOutput: ((videoFrame: VideoFrame) => void) = () => { }
let encoderOutput: ((chunk: EncodedVideoChunk) => void) = () => { }

// 映像エンコーダー・デコーダー用意
const videoDecoder = new VideoDecoder({
    error: (err) => { alert('映像デコーダーでエラーが発生しました') },
    output: (videoFrame) => { decoderOutput(videoFrame) }
})
videoDecoder.configure({
    codec: 'vp09.00.10.08',
    codedHeight: videoHeight,
    codedWidth: videoWidth
})
const videoEncoder = new VideoEncoder({
    error: (err) => { alert('映像エンコーダーでエラーが発生しました') },
    output: (chunk) => { encoderOutput(chunk) }
})
videoEncoder.configure({
    codec: 'vp09.00.10.08',
    height: videoHeight,
    width: videoWidth,
    framerate: 30
})

// コールバックを Proimise にする関数
function awaitDecoderOutput() {
    return new Promise<VideoFrame>((resolve) => {
        decoderOutput = (videoFrame) => {
            resolve(videoFrame)
        }
    })
}
function awaitEncoderOutput() {
    return new Promise<EncodedVideoChunk>((resolve) => {
        encoderOutput = (chunk) => {
            resolve(chunk)
        }
    })
}

動画フレームを逆から取得してエンコードし直す処理

真髄の部分です。この画像と同じです。

ぎゃくにする手順

ループを逆に回して、ifでキーフレーム以外なら最寄りのキーフレームまで戻って、欲しいフレームが取得できたら、エンコーダーに入れて、WebMに書き込む。
終わったら次のループ。。。。する処理。

おまけ程度に<canvas>に進行中のフレームを表示するように。あとキーフレームまで戻った処理のフレームは使わないので何もせずにclose()しています。

// エンコードされているデータを逆からデコードして、エンコーダーに突っ込む
// 単位はマイクロ秒
for (let frameIndex = videoTrackEncodeDataList.length - 1; frameIndex >= 0; frameIndex--) {
    const encodeChunkData = videoTrackEncodeDataList[frameIndex]
    const frameMs = Number(encodeChunkData.time)

    // キーフレームじゃない場合はキーフレームまで戻る
    if (!encodeChunkData.isKeyFrame) {
        const keyFrameIndex = videoTrackEncodeDataList.findLastIndex((chunk) => chunk.isKeyFrame && chunk.time < frameMs)
        for (let iFrameIndex = keyFrameIndex; iFrameIndex < frameIndex; iFrameIndex++) {
            const iFrameChunk = videoTrackEncodeDataList[iFrameIndex]
            const iFrameVideoChunk = new EncodedVideoChunk({
                data: new Int8Array(iFrameChunk.encodeData as any).buffer as any,
                timestamp: (durationMs - Number(iFrameChunk.time)) * 1_000,
                type: iFrameChunk.isKeyFrame ? 'key' : 'delta'
            })
            // デコーダー出力の Promise を待つが特に使わずに close()
            const promise = awaitDecoderOutput()
            videoDecoder.decode(iFrameVideoChunk)
            const unUseVideoFrame = await promise
            unUseVideoFrame?.close()
        }
    }

    // 戻ったのででデコード
    const videoChunk = new EncodedVideoChunk({
        data: new Int8Array(encodeChunkData.encodeData as any).buffer as any,
        timestamp: (durationMs - Number(encodeChunkData.time)) * 1_000,
        type: encodeChunkData.isKeyFrame ? 'key' : 'delta'
    })

    // Promise を作ってデコーダーに入れた後 await 待つ
    const videoFramePromise = awaitDecoderOutput()
    videoDecoder.decode(videoChunk)
    const videoFrame = await videoFramePromise

    // プレビュー、アスペクト比を保持して拡大縮小
    // https://stackoverflow.com/questions/23104582/
    if (canvasRef.current) {
        var hRatio = canvasRef.current.width / videoFrame.displayWidth
        var vRatio = canvasRef.current.height / videoFrame.displayHeight
        var ratio = Math.min(hRatio, vRatio)
        ctx?.drawImage(videoFrame, 0, 0, videoFrame.displayWidth, videoFrame.displayHeight, 0, 0, videoFrame.displayWidth * ratio, videoFrame.displayHeight * ratio)
    }

    // 逆からとったフレームをエンコーダーに入れて待つ
    const chunkPromise = awaitEncoderOutput()
    videoEncoder.encode(videoFrame)
    const chunk = await chunkPromise

    // エンコード結果を WebM に書き込む
    const frameData = new Uint8Array(chunk.byteLength)
    chunk.copyTo(frameData)
    writeVideoTrack(muxerRef, frameData as any, chunk.timestamp / 1_000, chunk.type === "key", false)
    videoFrame.close()
}

映像側リソース解放

おつ ノシ

// リソース解放
videoDecoder.close()
videoEncoder.close()

音声トラックを逆にする処理

こっちは作戦を変えて、一度にすべてデコードしきって、そのあと逆さまにして、エンコーダーに突っ込むことにします。
これも解説していきます。

/** 音声トラックをデコードしたあと、逆からエンコードする処理 */
async function processAudioTrack() {
    // 音声トラックがない場合は return
    if (!hasAudioTrack) return
    if (!samplingRateOrNull) return
    if (!channelCountOrNull) return

    // VideoCodec コールバック
    let decoderOutput: (audioData: AudioData) => void = () => { }
    let encoderOutput: (chunk: EncodedAudioChunk) => void = () => { }

    // エンコーダー・デコーダー用意
    const audioDecoder = new AudioDecoder({
        error: (err) => { alert('音声デコーダーでエラーが発生しました') },
        output: (audioData) => { decoderOutput(audioData) }
    })
    audioDecoder.configure({
        codec: 'opus',
        sampleRate: samplingRateOrNull,
        numberOfChannels: channelCountOrNull
    })
    const audioEncoder = new AudioEncoder({
        error: (err) => { alert('音声エンコーダーでエラーが発生しました') },
        output: (chunk, metadata) => { encoderOutput(chunk) }
    })
    audioEncoder.configure({
        codec: 'opus',
        sampleRate: samplingRateOrNull,
        numberOfChannels: channelCountOrNull
    })

    // コールバックを Promise にする
    function awaitDecoderOutput() {
        return new Promise<AudioData>((resolve) => {
            decoderOutput = (audioData) => {
                resolve(audioData)
            }
        })
    }
    function awaitEncoderOutput() {
        return new Promise<EncodedAudioChunk>((resolve) => {
            encoderOutput = (chunk) => {
                resolve(chunk)
            }
        })
    }

    /**
     * [1,2,3,4,5,6] を size の数で二重の配列にする
     * 2 の場合は [[1,2],[3,4],[5,6]] する
     */
    function chunked<T>(origin: T[], size: number) {
        return origin
            .map((_, i) => i % size === 0 ? origin.slice(i, i + size) : null)
            .filter((nullabeList) => nullabeList !== null)
    }

    // 音声の場合はとりあえずすべてのデータをデコードしちゃう
    const decodeAudioList: AudioData[] = []
    for (const audioChunk of audioTrackEncodeDataList) {
        const encodeChunk = new EncodedAudioChunk({
            data: new Int8Array(audioChunk.encodeData as any).buffer as any,
            timestamp: Number(audioChunk.time) * 1_000,
            type: audioChunk.isKeyFrame ? 'key' : 'delta' // 音声は常にキーフレームかも
        })
        const audioDataPromise = awaitDecoderOutput()
        audioDecoder.decode(encodeChunk)

        // 一旦配列に。後で配列で使うので close() しない
        const audioData = await audioDataPromise
        decodeAudioList.push(audioData)
    }

    // デコードした音声データ(PCM)を逆にする。まずは全部くっつけて一つの配列に
    const pcmDataList = decodeAudioList
        .map((audioData) => {
            const float32Array = new Float32Array(audioData.numberOfFrames * audioData.numberOfChannels)
            audioData.copyTo(float32Array, { planeIndex: 0 })
            return Array.from(float32Array)
        })
        .flat()

    // 次に reverse() するのだが、このときチャンネル数を考慮する必要がある
    // 2チャンネルなら [[右,左],[右,左],[右,左],] のように、ペアにした後に reverse() する必要がある
    // flat() で戻す
    const channelReversePcmList = chunked(pcmDataList, channelCountOrNull).reverse().flat()

    // WebM に一度に書き込むサイズ、どれくらいが良いのか知らないので 20ms 間隔にしてみた
    // 10ms とか 2ms とかの小さすぎるとエンコーダーに入れても何もでてこなくなる
    // 暗黙的に最小バッファサイズ的なのが?あるはず?
    const ENCODE_FRAME_SIZE = ((48_000 * channelCountOrNull) / 1_000) * 20
    const chunkedPcmList = chunked(channelReversePcmList, ENCODE_FRAME_SIZE)

    // for でエンコーダーへ
    let timestamp = 0
    for (const pcmList of chunkedPcmList) {
        // エンコードする
        const reversePcm = new AudioData({
            data: Float32Array.of(...pcmList),
            format: 'f32',
            timestamp: timestamp * 1_000,
            numberOfFrames: (pcmList.length / channelCountOrNull), // [右,左,右,左] になっているので、フレーム数を数える時は 右左 のペアで
            numberOfChannels: channelCountOrNull,
            sampleRate: 48_000
        })
        timestamp += ENCODE_FRAME_SIZE
        const chunkPromise = awaitEncoderOutput()
        audioEncoder.encode(reversePcm)
        const chunk = await chunkPromise

        // WebM に入れる
        const frameData = new Uint8Array(chunk.byteLength)
        chunk.copyTo(frameData)
        writeAudioTrack(muxerRef, frameData as any, chunk.timestamp / 1_000, chunk.type === "key")
    }

    // リソース解放
    decodeAudioList.forEach((audioData) => audioData.close())
    audioDecoder.close()
    audioEncoder.close()
}

音声エンコーダー・デコーダーを用意し Promise にする

もう映像と同じです。コールバックで返ってくる値が違うくらい。
デコーダーからはAudioDataがもらえます。これがPCMと呼ばれるやつになるかな。
エンコーダーからもらえるEncodedAudioChunkでは、映像と同じようにWebMに書き込むために使います。

// 音声トラックがない場合は return
if (!hasAudioTrack) return
if (!samplingRateOrNull) return
if (!channelCountOrNull) return

// VideoCodec コールバック
let decoderOutput: (audioData: AudioData) => void = () => { }
let encoderOutput: (chunk: EncodedAudioChunk) => void = () => { }

// エンコーダー・デコーダー用意
const audioDecoder = new AudioDecoder({
    error: (err) => { alert('音声デコーダーでエラーが発生しました') },
    output: (audioData) => { decoderOutput(audioData) }
})
audioDecoder.configure({
    codec: 'opus',
    sampleRate: samplingRateOrNull,
    numberOfChannels: channelCountOrNull
})
const audioEncoder = new AudioEncoder({
    error: (err) => { alert('音声エンコーダーでエラーが発生しました') },
    output: (chunk, metadata) => { encoderOutput(chunk) }
})
audioEncoder.configure({
    codec: 'opus',
    sampleRate: samplingRateOrNull,
    numberOfChannels: channelCountOrNull
})

// コールバックを Promise にする
function awaitDecoderOutput() {
    return new Promise<AudioData>((resolve) => {
        decoderOutput = (audioData) => {
            resolve(audioData)
        }
    })
}
function awaitEncoderOutput() {
    return new Promise<EncodedAudioChunk>((resolve) => {
        encoderOutput = (chunk) => {
            resolve(chunk)
        }
    })
}

ユーティリティ関数

コメントに書いてあるような動作をする関数です。Kotlinにあるchunkedlodashにあるchunkみたいな。

/**
 * [1,2,3,4,5,6] を size の数で二重の配列にする
 * 2 の場合は [[1,2],[3,4],[5,6]] する
 */
function chunked<T>(origin: T[], size: number) {
    return origin
        .map((_, i) => i % size === 0 ? origin.slice(i, i + size) : null)
        .filter((nullabeList) => nullabeList !== null)
}

元ネタはすたっくおーばーふろー。

全部の音声をデコードする処理

とりあえずすべてデコードします。OpusをデコードしたのでPCMが取得できます。
終わったら一旦配列に入れます。PCMくらいなら全部メモリに乗せても行けるはず(なおメモリ高騰)

// 音声の場合はとりあえずすべてのデータをデコードしちゃう
const decodeAudioList: AudioData[] = []
for (const audioChunk of audioTrackEncodeDataList) {
    const encodeChunk = new EncodedAudioChunk({
        data: new Int8Array(audioChunk.encodeData as any).buffer as any,
        timestamp: Number(audioChunk.time) * 1_000,
        type: audioChunk.isKeyFrame ? 'key' : 'delta' // 音声は常にキーフレームかも
    })
    const audioDataPromise = awaitDecoderOutput()
    audioDecoder.decode(encodeChunk)

    // 一旦配列に。後で配列で使うので close() しない
    const audioData = await audioDataPromise
    decodeAudioList.push(audioData)
}

音声を逆さまにする処理

音声を逆にすると言っても、いくつか考慮する必要があり、クソ長読む気を起こさせないコメントが書いてあります。

まずはAudioDataから実際のバイト配列を得ます。number[]が欲しい。copyTo()AudioDataからバイト配列PCMですね。取得できます。
このままではFloat32Array[]の配列になるので、Array.fromでなんかいい感じにします。

次に、ついに逆にする処理です。真骨頂ですね。
さっき作ったchunked()を呼び出して、チャンネル数でグループ化した配列にします。ステレオなら2chですので、[[1,2],[3,4]]となります。
なぜこれが必要かと言うと、音声データ(PCM)は2chの場合右,左,右,左,,,と言った感じに並んでいるためで、右と左をワンセットにした状態でひっくり返す必要があるためです。
これでひっくり返して、flat()すると期待通りPCMがひっくり返る。

最後に、一度にエンコーダーに入れるサイズを計算し、それでもう一回グループ化します。
というのも、映像の場合はVideoFrameを作って入れるだけなので、エンコーダーに入れるサイズに関して考えることはないと思います。 一方、音声の場合はある程度まとまった時間(20msとか)ごとに分けて入れて上げる必要があります。世の中のWebMファイルがそうしている。

mkvtoolnix

これはMKVToolNixっていうWebMをパースして表示してくれるガチ有能GUIアプリ(なんと日本語対応)

// デコードした音声データ(PCM)を逆にする。まずは全部くっつけて一つの配列に
const pcmDataList = decodeAudioList
    .map((audioData) => {
        const float32Array = new Float32Array(audioData.numberOfFrames * audioData.numberOfChannels)
        audioData.copyTo(float32Array, { planeIndex: 0 })
        return Array.from(float32Array)
    })
    .flat()

// 次に reverse() するのだが、このときチャンネル数を考慮する必要がある
// 2チャンネルなら [[右,左],[右,左],[右,左],] のように、ペアにした後に reverse() する必要がある
// flat() で戻す
const channelReversePcmList = chunked(pcmDataList, channelCountOrNull).reverse().flat()

// WebM に一度に書き込むサイズ、どれくらいが良いのか知らないので 20ms 間隔にしてみた
// 10ms とか 2ms とかの小さすぎるとエンコーダーに入れても何もでてこなくなる
// 暗黙的に最小バッファサイズ的なのが?あるはず?
const ENCODE_FRAME_SIZE = ((samplingRateOrNull * channelCountOrNull) / 1_000) * 20
const chunkedPcmList = chunked(channelReversePcmList, ENCODE_FRAME_SIZE)

音声をエンコード

映像の逆と同じ。もう音声のデータ(PCM)しか持っていないため、AudioDataを作るところから始まります。
dataに逆にしたPCMnumber[])を、formatはよく分からず使ってるFloat32timestampは時間です。マイクロ秒です。
numberOfFramesdata内のサンプリング数です。右,左1サンプルなのでチャンネル数で割れば良いはず。numberOfChannelsはチャンネル数です。
sampleRateはサンプリングレートです。

エンコーダーに入れてでてきたら同様にWebMに書き込みます。

// for でエンコーダーへ
let timestamp = 0
for (const pcmList of chunkedPcmList) {
    // エンコードする
    const reversePcm = new AudioData({
        data: Float32Array.of(...pcmList),
        format: 'f32',
        timestamp: timestamp * 1_000,
        numberOfFrames: (pcmList.length / channelCountOrNull), // [右,左,右,左] になっているので、フレーム数を数える時は 右左 のペアで
        numberOfChannels: channelCountOrNull,
        sampleRate: samplingRateOrNull
    })
    timestamp += ENCODE_FRAME_SIZE
    const chunkPromise = awaitEncoderOutput()
    audioEncoder.encode(reversePcm)
    const chunk = await chunkPromise

    // WebM に入れる
    const frameData = new Uint8Array(chunk.byteLength)
    chunk.copyTo(frameData)
    writeAudioTrack(muxerRef, frameData as any, chunk.timestamp / 1_000, chunk.type === "key")
}

音声のリソース解放

AudioDataをここまで付き合わせたので破棄します。

// リソース解放
decodeAudioList.forEach((audioData) => audioData.close())
audioDecoder.close()
audioEncoder.close()

上記2つの関数を呼び出す

映像トラックを逆にする関数、音声トラックを逆にする関数をそれぞれ呼び出します。
直列にしているのですが、WebCodecs API自体はコールバックのAPIなので並列にも出来るかもしれません。ただ自作のWebM書き込みが並列で行えるかは自信ないので(お前が書いただろうに)

// それぞれの処理を待つ
await processVideoTrack()
await processAudioTrack()

関数自体をそれぞれ分けたの、名前が被るかなーくらいだったんですけど、外に出しちゃうとデバッグしにくいから中が良いと思う。どっちか片方のトラックで試せる。

WebM を完成させて保存する

WebMデータのバイト配列が手に入るので、これをBlobにして、<a> タグを使ってダウンロードする技を使い保存します。

// 書き込みが終わったため WebM ファイルを完成させる
const byteArray = muxerBuild(muxerRef)
const jsByteArray = new Int8Array(byteArray as any)

// ダウンロード
const blob = new Blob([jsByteArray], { type: 'video/webm' })
const blobUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = blobUrl
anchor.download = `webcodecs-reverse-video-${Date.now()}.webm`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()

逆再生の動画を作ってみる

雑ですが解説は以上です。
コンポーネントをpage.tsx等に置いて早速使ってみましょう。

import ReverseVideoMaker from "./ReverseVideoMaker";

export default function Home() {
  return (
    <div className="flex flex-col p-2 space-y-2">
      <h1 className="text-4xl">WebCodecs + WebM サイト</h1>

      <ReverseVideoMaker />
    </div>
  )
}

<input>を押してWebMファイルを選ぶと・・・動いています!

動いてる

保存

なぜかFirefoxでは動きません(?)

完成品

見た目こそ違いますが、中身のコードは同じです。

ソースコード

冒頭のwebm.negitoro.devの方はこっち

WebCodecs API よくある質問

でしゃばってると詳しい人に見つかりそうなので大人しくします

MP4 を扱うには

H264AACをサポートしているか確認(してるの?見てない)して、あとはMP4の読み書きライブラリを入れるなりすれば出来るんじゃない?

PCM を再生したい

WebAudioAPI?で、PCM 音声を再生する方法が使えるはず。
ダウンロード出来るならffplayAudacityでも見れるはず(ただしサンプリングレート、チャンネル数、ビット深度は指定する必要あり)

エンコードした音声が速い

サンプリングレート・チャンネル数・ビット深度のどれかを間違えている?

canvas に描画してるけど超速い

30fpsなら33ms1000ms / 30)、60fpsなら16ms1000ms / 60)、setTimeout()で待つ必要があります。(フレームを出し続ける必要があります)

AudioEncoder のコールバックが呼ばれない

少なくとも20ミリ秒分のPCMデータがないと何もでてこなくなる?

TypeError: Failed to construct 'AudioData': data is too small

エラー通りで、numberOfFramenumberOfChannelsを掛け算した値よりも、data: Float32Arrayの件数が少ない

突然動かなくなった

ChromeFirefoxを一旦閉じると良さそう

VideoDecoder のコールバックが呼ばれない

妥協案としてソフトウェアデコーダーを指定すると良いかも。効率はすごく悪くなるけど。。。

videoDecoder.configure({
    codec: 'vp09.00.10.08',
    codedHeight: 1080,
    codedWidth: 1920,
    hardwareAcceleration: 'prefer-software' // これ
})

ソフトウェア、つまりCPUで処理するのでGPUは遊ぶことになります。

CPU

Firefox Encoder 系が動かない

私も知りたい

おまけ

えらー

MediaRecorder APIや無効な動画を吐き出したり、WebCodecs APIからコールバックが呼ばれなくなったら一回Chromeを全部閉じると良いかもしれません。
リロードしまくってるとなんか壊れる?

画面が固まる

ちなみにWebWorkerを使わずに作ってしまったので、メインスレッドが固まるので、画面がガタガタになります。
WebCodecs API自体はコールバックで作られているためメインスレッドでも問題ないでしょうが、WebM読み書きはメインスレッドでやっているのでそこが多分重たい・・・

おわりに1

caniuseにある通り、Android Chromeでも動きます。ただ、WebCodecs APIは(も)もれなくSecure Contextsを必要とします。
よってlocalhostでは動きますが、スマホからアクセスするためのローカルIPアドレス指定では動きません。is not definedエラーになるんじゃないでしょうか。

webcodecs_android_chrome

webcodecs_android_chrome

どうすればいいのかもぱっとは知らないです

おわりに2

WebCodecs APIAndroid ブラウザだと十中八九MediaCodec APIを使って実装されてると思うので、なんかよくわからないMediaCodecのエラーに苦しみそう。
GPU違いで端末を揃えてチャレンジだ!

Androidで動かないなら、とりあえず縦横サイズが16で割り切れる動画で試してみるとか、1080p2160pみたいなメジャーな解像度でも試してみる等やってみてください。

おわりに3

動画編集でWebCodecs APIを使いたいならまだ厳しい。
WebM等のコンテナフォーマットを読み書きする処理を自前で作るorライブラリを入れるのどっちがが必要。まじでエンコーダー・デコーダーしかない。

おわりに4

このネタは会社のLTでネタにしたやつです。どこかは内緒で :pray: