たくさんの自由帳
Androidのお話
たくさんの自由帳
文字数(だいたい) : 21736
目次
本題
自分 Android ユーザー、エンジニアなんですけど
完成品
そもそも逆再生の動画って難しいんですか?
今日の動画ファイルが再生できる仕組み
コーデック
音声の場合
コンテナ
デコーダー・エンコーダー
むずかしいくね
付録 video タグや MSE との違いは?
作戦
WebM コンテナフォーマットのパーサとミキサーが必要
Kotlin でできた WebM 読み書き処理をブラウザで使う
WebM 読み書きライブラリ メイドイン Kotlin Wasm
環境
今回使う WebM ファイルを用意する
逆再生よりまず普通に再生してみる
適当なコンポーネントを作った
WebM 読み書きを用意
再生関数の中身
WebM のパース部分
WebM からデコーダー起動に必要な値を取ってる部分
WebCodecs デコーダー起動している部分
時間通りに再生する処理
とりあえず再生してみる
思ったよりシンプル?
本題の逆再生を作っていきたい
逆再生に使う関数をインポートして解析
逆再生 プレビュー Canvas
WebM 書き込みをつくる
映像トラックを逆にする処理
映像エンコーダー、デコーダーを用意し Promise にする
動画フレームを逆から取得してエンコードし直す処理
映像側リソース解放
音声トラックを逆にする処理
音声エンコーダー・デコーダーを用意し Promise にする
ユーティリティ関数
全部の音声をデコードする処理
音声を逆さまにする処理
音声をエンコード
音声のリソース解放
上記2つの関数を呼び出す
WebM を完成させて保存する
逆再生の動画を作ってみる
完成品
ソースコード
WebCodecs API よくある質問
MP4 を扱うには
PCM を再生したい
エンコードした音声が速い
canvas に描画してるけど超速い
AudioEncoder のコールバックが呼ばれない
TypeError: Failed to construct 'AudioData': data is too small
突然動かなくなった
VideoDecoder のコールバックが呼ばれない
Firefox Encoder 系が動かない
おまけ
画面が固まる
おわりに1
おわりに2
おわりに3
おわりに4
どうもこんばんわ。ハッピーウィークエンド攻略しました。こんかいのHOOKにはお姉さんヒロインがいます!!
かわいくて声もよかった。こはるさんがかわいかった!のでおすすめです。
"文字列リテラル"になってる(?)
共通が長め + 前作より過程が楽しめます!。前作が分割だったからそりゃそうかもしれないけど
終わり方がHOOKだ!!
ゆきさん!!声がかわいい
フライパン
かわいい
がーん
ひなたさん面白くてよかった。あとえちえち。
いっぱいスクショあります
(笑)読み上げてて笑った
ここすk
!?!?
がるるるる
敵ぃ←かわいい
あきなさん!!!色々と・・・
ここすき
大人組の会話、こはるさんの方はイベント絵だったのでこっちで
あ!!
おすすめはこはるさんルートです。あらあ
おでかけ・・!
!!!
かみのけ!!!おろしてる!
くろまきー用こはるさんもあります(?)
かわい~~~
!!!!!
いまなら!
あと!!全員にひとりのしーんがある!!!
今回はブラウザの中で完結する、逆再生の動画を作るWebサイトを作ろうと思います。WebCodecs APIっていう、映像・音声コーデックのエンコーダー/デコーダーを直接利用することが出来るブラウザ由来のAPIがあります。今回はそれらを使うことで低レイヤーな動画処理をしようかと。
作ろうと思いますというか、前に作ったWebMのWebサイトにしれっと追加しているので、それの詳細記事になるんですが・・・
Androidのエンコード / デコード APIであるMediaCodecを使って逆再生の動画を作る。
こっちはAndroidにあるコンテナフォーマット読み書きAPI (MediaMuxer)を利用するため、mp4もwebmもいけるし、10 ビット HDR動画もいけます。
これのWebM ファイルを逆再生して保存するで、逆再生の動画を作ることが出来ます。
利用するためには WebM ファイル(映像コーデック:VP9 + 音声コーデック:Opus)である必要があります。それが厳しい場合はAndroid ユーザー・エンジニア向けに案内したアプリを使ってください。
ffmpegで変換するかMediaRecorder APIで画面録画する事ですね。WebM (VP9+Opus)ファイルが手に入ります。
というのも、このWebCodecs API、名前の通りにデコーダー・エンコーダーのAPIしか無く、WebM / mp4等のコンテナフォーマットへ書き込むAPIが存在しないんですよね。
コンテナフォーマットを読み書きするとなると、仕様が公開されてて、MediaRecorder APIでも使われている実績があるWebMを採用するしか無く、それに引きずられて映像コーデックは VP9、音声コーデックは Opus、、、と決まるわけです。
(AV1 / VP8でも良いけど・・・)
詳しくは後述します!!!
あとAndroidエンジニアの誘導のくだりの続きですが、当時はWebCodecs APIがChrome 系列でしか実装されてなかったはず。
がAndroid版を作ったあとくらいにFirefoxでも実装されたため、Webサイト版が作れます!!SafariはAppleユーザーじゃないんで知らないです。
ですが、今回作るやつ、なんかFirefoxでは動きませんでした(?)
よく見るかなと思います。逆再生の動画。あれって難しいのという話。
<video src="test.mp4" reversed={true}></video>上記のreversedパラメータなんて存在しないんですが、その理由とそもそも難しいんか?って話をするために、世の中の動画ファイルの話をします。AndroidのMediaCodecの記事で何回か言ったような気がするけど再掲しておきます。。。
今日のオンライン会議から、今晩のおかずまでを支えている動画の仕組みをば。WebCodecs APIといいMediaCodec APIの立ち位置がわからないよって人向け。
AVC(H.264)、HEVC(H.265)、VP9、AV1とかが映像コーデック、AAC、Opusが音声コーデックの種類ですね。
コーデックというのが映像や音声を小さくするためのアルゴリズムのことです。
そして、このアルゴリズムを動かして、映像・音声を圧縮するのがエンコーダーで、逆に解凍するのがデコーダーです。GPUの中にあります。ない場合はCPUがやります。
なんで圧縮なんかしているのかと言うとクソデカファイルになってしまうからです。
例えばフルHDの解像度では1920x1080=2073600ピクセル分のデータが必要で、その上、1ピクセルは4バイト(32ビット)を消費するので、1920x1080x4=8294400バイト必要です。
単位を変換すると8.29MBになります。
(4バイトの出どころさんは、カラーコードの#000000から#FFFFFFFFを表現するため。RGBAがそれぞれ8ビット使う(#00から#FF))
(余談ですが10 ビット HDR動画はRGBで10ビット使い、アルファチャンネルが残りの2ビット使っている。)
これが映像の一枚分のデータ。
よく見る動画ファイルは、30fpsや60fps、120fpsの場合が多いため、8.29(MB)x30(fps)にすれば1秒で30fpsの動画が出来ます。明らかに動画サイズが大きすぎる。
これをいい感じに小さくするのがコーデックの役割。
コーデックはいくつかの方法を利用してファイルサイズを小さくします。この中に逆再生ができなくなる理由があります。
というのも、動画の時間が増えていく方向にしか再生ができないという制約があります。
コーデックさんは、前の画像と変化しないところは保存せず、変化したところだけを保存するそうです。30fpsなら30枚分をまるまる保存しなくて済むので合理的に見えます。
ただ、これだと前の画像は前の前の画像に依存してて、その前はさらに前の画像に依存してて、と一生シーク操作が出来なくなってしまいます。
これを解決するために、キーフレームという画像が定期的にエンコーダーからでてきます。
これは前の画像に依存しない画像のため、シークする際はこのキーフレームまで戻ったあと、狙いの時間まで画像を進めればよいわけです。
つまり逆再生は難しい!!!
音声の場合も同様にエンコードされていますが、これを全部デコードしたところでそこまでデータ量が大きくならないと思います。(大きいけど)
詳しく話すと、大抵の音声ファイルは1秒間に48_000回記録します。これがサンプリングレートと呼ばれているものです。
次に、一回の記録で16ビット使います。これをビット深度とか言います。ロスレス配信気になってる人!
最後に、左右で違う音を出すために二倍の容量を利用します。なので一回で32ビットですね。これをチャンネル数とか言います。
全部掛け算すると、1秒間に1_536_000ビット、バイトに変換するために8で割ると192_000バイトしか使いません。1秒で。
なので、音声ファイルの逆再生に関しては、一回全部デコードしたあと、後ろからデータをエンコーダーに渡していけばいいんじゃないかと思っています。
MP4やWebMといったのはコンテナフォーマットの種類のことです。
ちなみにMP4の前世がMOV形式です。iPhoneの動画ファイルがMOVだとしても、これを思い出して.mp4に拡張子を書き換えるだけで動く。
雑な絵ですが動画ファイルの中身、WebMがこんなのでほかでもそうなんじゃないかな。
雑と言ってもそんなに大外れな解釈ではないはずだ、が、
エンコードした音声・映像を入れるための箱です。mp4とかwebmとか言われているやつです。
なので、動画プレイヤーや、<video> タグはまずこのコンテナフォーマットをバラバラにするわけです。
パースすると大まかに3つの塊が現れるかと思います。
WebCodecsもMediaCodecsもですが、再生のためにまずは動画情報の中身が必要になります。これらのクラスの初期化に必要なので。WebMの場合はこんな感じになってて、mp4でも同様だと思いますが、
Opusなら固有のデータ?)映像や音声などの、中にはいっているデータの種類をトラックとよんだりします。映像トラック、音声トラックのように。
動画情報をもとにWebCodecs APIをセットアップすると、ついに再生の準備が出来ました。エンコードされたデータを順番にデコーダーに入れるだけです。
WebCodecs APIのデコーダーはVideoFrameをコールバック関数で受け取ることが出来て、これをCanvasに書けば、WebCodecs APIで動画再生(映像のみ)が可能になる!!
同様にエンコーダーにVideoFrameや<canvas>を入れればエンコード済みデータが取得できる。これをWebMとかにルール通り保存すれば、動画プレイヤーで再生することが出来る。
まあどっちかというと私の説明が怪しい。
つまるところ、WebCodecs APIとかMediaCodec APIとかってのは、この圧縮・解凍を行うエンコーダー・デコーダーを指しているわけ、。
で、それとは別にWebCodecs APIで再生するためにはWebMを解析する(デマルチプレクサ、パーサー)が必要。
パースした結果を渡すことで再生できるので。。。
そして逆再生は一筋縄ではいかないということも。
<video>タグを自前で作り直すポテンシャルがあり、MSEと違ってコンテナフォーマットに縛られない。
<video> | Media Source Extensions | WebCodecs |
|---|---|---|
srcからロードする機能 | ロードする箇所は自前で作成可能(生配信など) | ロードする箇所は自前で作成可能 |
| コンテナフォーマットを解析する機能 | コンテナフォーマットは規定の物を利用(fmp4、webm) | コンテナフォーマットも自力で解析するので好きにできる |
| デコーダーに入れて画面に表示する機能 | デコーダーに入れて画面に表示する機能 | デコーダーに入れるまでは自前で作る必要 |
UIを提供する機能 | UIを提供する機能 | <canvas>に書けば最低限画面に表示できる |
iOS対応 | iOS非対応(iPad OSのみ対応、謎) | 知らない。? |
映像トラックに関しては一回一回キーフレームまで戻ってフレームを取得するしかない。
音声トラックは先述の通り一回すべてデコードして、後ろからエンコーダーに突っ込む作戦。
それよりも問題はWebMに書き込む処理なんだよ
というわけで、WebCodecs APIは映像・音声のエンコーダー・デコーダーのAPIしか存在しないので、自分で作るかライブラリを入れるかする必要があり。
こんかいは自作!!!WebMの仕様はインターネットで見ることが出来るので誰でもパーサー(デマルチプレクサ)を作ることが出来ます!
WebCodecs API が難しいうんぬんも、コンテナフォーマット読み書きを用意するほうがよっぽど難しい!!!
どうでもいいですが、mp4の仕様はお金を払えば見れるはず(ISOなんとか)だが、仮に買ったとてそもそも英語がわからない。。。。
ここの説明は余談な感じです。のと前ブログで書いたので省きます!!
自作したと言ってもKotlinで書いたので、これをブラウザ JavaScriptに持ってくる必要があります。
幸いなことにKotlin MultiplatformはKotlin/Wasmをターゲットにできて、かつ、npmライブラリ吐き出す機能があるため、Kotlinで書いた関数をWasmの力でJavaScript/TypeScriptから呼び出すことが出来る!!!!
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 + ViteのSPAの構成でも良いんですが、今回はNext.jsにします。TypeScriptとTailwindCSSがすぐ使えるので。まあNext.js使いますが大多数を"use client"にするので、やってることはReact + ViteのSPAと大体同じという。。。
| なまえ | あたい |
|---|---|
| 利用する動画 | WebM コンテナフォーマット形式(映像コーデックVP9、音声コーデックOpus) |
| Next.js | 16.1.0 (クライアントコンポーネントだけ使います) |
| WebM 読み書き | 自前のものを(先述) |
| CSS | TailwindCSS |
また、今回はWebM + VP9 + Opusの動画でやります。
エンコーダー・デコーダーを起動する際に、なんのコーデックを使っているかで分岐するべきなのだが、今回は決め打ちしています。運用でカバー
先述の通りで、JavaScriptのMediaRecorder APIで作ったもので良いです。ffmpegで変換しても良いですが。
ま何でも良いのでWebMコンテナフォーマット形式(映像コーデックVP9、音声コーデックOpus)を守ってくれれば、多分今回作るWebサイトは動きます。
今回は、わたしのサイトで画面録画してWebMファイルを取得したものを使おうと思います。これ↑
中身はgetDisplayMedia()で画面録画用のデータをもらい、MediaRecorder APIでWebMに保存する処理をやってるだけ。ブラウザ内部で完結しています。
とりあえず画面に映像トラックだけ表示する例をやってみようと思った。
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>
)
}Kotlin Multiplatformで作った読み書き関数をWasmにして呼び出しています。
読み書きに何を採用するかによって違うと思う。。。
今回は自前のを入れます...
npm install takusan23/himari-webm-kotlin-multiplatform-npm-librarystartVideoPlay()関数の全貌です。詳細はこのあとすぐ!
/** 再生を始める */
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のパーサーというか読み書きライブラリ次第なのであんまり話してもあれですが。
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側気になる方はここです。バイト配列をいじくり回してます・・・
// 動画の縦横サイズを出す
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 をいい感じに 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()で映像デコーダーのインスタンスが取得できます。
デコード結果を取得できるコールバックと、エラー時に呼ばれるコールバックを取ります。映像デコーダーの場合はoutputでVideoFrameのオブジェクトをもらうことが出来ます。
ただ、コールバックだとちょっと扱いにくいので、コールバック呼び出しを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から取り出す際に取得できるので、それを渡せばおっけー
まああとは、順番にデコーダーに入れています。ただ、これを適当に入れると、動画が超高速で再生されてしまいます。
例えば、30fpsであれば33msの間はそのフレームを表示し続ける必要があります。が、デコーダーはそれよりも圧倒的に早い時間でデコードし終えるので、待ってから次のデータを入れる処理が必要になります。
そのためにはPromiseでsetTimeout的な、待つ処理的なのをかけると良い感じに解決しそうですよね。
ここで、先述のデコーダーのコールバックをPromiseにしたものが役に立つわけです。
デコーダーに入れる前に、awaitVideoFrameOutput()を呼び出す。これは、outputCallbackコールバック関数を作り直し、それが呼び出されるまではresolve()しないようなPromiseを作成します。
次に、VideoDecoder#decode()を呼び出しデコードを行う。目論見通りならoutput: (videoFrame) => { }コールバック関数が呼ばれるはず。
この中でoutputCallbackが呼び出され、Promiseがresolved状態になる。
すると、await outputPromiseの部分が呼び出しから戻って来て、VideoFrameが取得できる。
これをCanvasに書けば画面に表示することが出来る。
最後に、フレームの時間だけ待つsetTimeoutをPromiseにして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>
)
}ちゃんと動画が流れています!!
一時停止やシークや再生速度変更をやろうとすると一気にしんどくなると思います!!!!!!!!
それに映像だけだし。音声トラックも再生するとなると再生位置を同期させる必要があって・・・
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今回は音ありの動画を考慮するため、hasAudioTrackをtrue/falseで持っています。!!でtruthyな値をBooleanに変換する技、たまに使いたくなる。double exclamation markとか言う技らしい。
再生と同様に、映像・音声トラックの情報と、エンコード済みデータを取り出しています。音声エンコーダー・デコーダーにはサンプリングレートとチャンネル数(ステレオだと思いますが)が必要なので。
逆再生エンコードしてる?進捗を見るために。なお音声の処理は特に用意してないので進捗を見ることは出来ないです。
// 進捗具合を canvas に描画
const ctx = canvasRef.current?.getContext('2d')ここが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()
}VideoEncoderとVideoDecoderを作ります。
エンコーダーの方のコールバックは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()
}もう映像と同じです。コールバックで返ってくる値が違うくらい。
デコーダーからは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にあるchunked、lodashにある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っていう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に逆にしたPCM(number[])を、formatはよく分からず使ってるFloat32、timestampは時間です。マイクロ秒です。numberOfFramesはdata内のサンプリング数です。右,左で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()映像トラックを逆にする関数、音声トラックを逆にする関数をそれぞれ呼び出します。
直列にしているのですが、WebCodecs API自体はコールバックのAPIなので並列にも出来るかもしれません。ただ自作のWebM書き込みが並列で行えるかは自信ないので(お前が書いただろうに)
// それぞれの処理を待つ
await processVideoTrack()
await processAudioTrack()関数自体をそれぞれ分けたの、名前が被るかなーくらいだったんですけど、外に出しちゃうとデバッグしにくいから中が良いと思う。どっちか片方のトラックで試せる。
WebM書き込みライブラリではこの辺の処理につながります。HimariWebmKotlinMultiplatform/library/src/wasmJsMain/kotlin/io/github/takusan23/himariwebmkotlinmultiplatform/ExportMuxer.kt at 7618dbb04bc5d92247b12ab863e36d049b35cbeb · takusan23/HimariWebmKotlinMultiplatform
Contribute to takusan23/HimariWebmKotlinMultiplatform development by creating an account on GitHub.
https://github.com/takusan23/HimariWebmKotlinMultiplatform/blob/7618dbb04bc5d92247b12ab863e36d049b35cbeb/library/src/wasmJsMain/kotlin/io/github/takusan23/himariwebmkotlinmultiplatform/ExportMuxer.kt
HimariWebmKotlinMultiplatform/library/src/commonMain/kotlin/io/github/takusan23/himariwebmkotlinmultiplatform/HimariWebmBuilder.kt at 7618dbb04bc5d92247b12ab863e36d049b35cbeb · takusan23/HimariWebmKotlinMultiplatform
Contribute to takusan23/HimariWebmKotlinMultiplatform development by creating an account on GitHub.
https://github.com/takusan23/HimariWebmKotlinMultiplatform/blob/7618dbb04bc5d92247b12ab863e36d049b35cbeb/library/src/commonMain/kotlin/io/github/takusan23/himariwebmkotlinmultiplatform/HimariWebmBuilder.kt
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の方はこっち
でしゃばってると詳しい人に見つかりそうなので大人しくします
H264とAACをサポートしているか確認(してるの?見てない)して、あとはMP4の読み書きライブラリを入れるなりすれば出来るんじゃない?
WebAudioAPI?で、PCM 音声を再生する方法が使えるはず。
ダウンロード出来るならffplayやAudacityでも見れるはず(ただしサンプリングレート、チャンネル数、ビット深度は指定する必要あり)
サンプリングレート・チャンネル数・ビット深度のどれかを間違えている?
30fpsなら33ms(1000ms / 30)、60fpsなら16ms(1000ms / 60)、setTimeout()で待つ必要があります。(フレームを出し続ける必要があります)
少なくとも20ミリ秒分のPCMデータがないと何もでてこなくなる?
エラー通りで、numberOfFrameとnumberOfChannelsを掛け算した値よりも、data: のFloat32Arrayの件数が少ない
ChromeやFirefoxを一旦閉じると良さそう
妥協案としてソフトウェアデコーダーを指定すると良いかも。効率はすごく悪くなるけど。。。
videoDecoder.configure({
codec: 'vp09.00.10.08',
codedHeight: 1080,
codedWidth: 1920,
hardwareAcceleration: 'prefer-software' // これ
})ソフトウェア、つまりCPUで処理するのでGPUは遊ぶことになります。
私も知りたい
MediaRecorder APIや無効な動画を吐き出したり、WebCodecs APIからコールバックが呼ばれなくなったら一回Chromeを全部閉じると良いかもしれません。
リロードしまくってるとなんか壊れる?
ちなみにWebWorkerを使わずに作ってしまったので、メインスレッドが固まるので、画面がガタガタになります。WebCodecs API自体はコールバックで作られているためメインスレッドでも問題ないでしょうが、WebM読み書きはメインスレッドでやっているのでそこが多分重たい・・・
caniuseにある通り、Android Chromeでも動きます。ただ、WebCodecs APIは(も)もれなくSecure Contextsを必要とします。
よってlocalhostでは動きますが、スマホからアクセスするためのローカルIPアドレス指定では動きません。is not definedエラーになるんじゃないでしょうか。
どうすればいいのかもぱっとは知らないです
WebCodecs API、Android ブラウザだと十中八九MediaCodec APIを使って実装されてると思うので、なんかよくわからないMediaCodecのエラーに苦しみそう。GPU違いで端末を揃えてチャレンジだ!
Androidで動かないなら、とりあえず縦横サイズが16で割り切れる動画で試してみるとか、1080p、2160pみたいなメジャーな解像度でも試してみる等やってみてください。
動画編集でWebCodecs APIを使いたいならまだ厳しい。WebM等のコンテナフォーマットを読み書きする処理を自前で作るorライブラリを入れるのどっちがが必要。まじでエンコーダー・デコーダーしかない。
このネタは会社のLTでネタにしたやつです。どこかは内緒で :pray: