井戸端用メモ
井戸端用に現在のコードの元になっている発想と、問題点について書きます:
疑問点、ご指摘は井戸端の元ページにお書きいただくか、下記リンクからプロジェクトにご参加いただき、このページに直接追記していただければ幸いです:
私がこのアプリの中で達成しようとしているのは、以下の項目が技術的に可能かどうかの実験です。
特定の時間における動画のフレームをcanvasに書き出すことができるか。
特定の時間にシークしても再生を問題なく持続できるか。
videoのplay()を開始したあと、アプリ上の再生フレームと、videoの再生時間を同期できるか。
井戸端での記述をみるに、タイミングをあわせるのが難しかったみたいです?
特定の時間における動画や、その他の要素のスナップショットをレイヤー順に合成して連番画像を作り、エンコードするという見通しです。
なので、動画のフレームを画像として取得するのは、今後動画編集アプリを作成するうえで絶対に外せない仕組みです。
概念の説明
Project
Project(プロジェクト)とは、読み込む動画(入力動画)の情報と、その表示方法を保持するものです。
以下の構造を持ちます。
動画のパス(この実装ではパスではなく、Blobそのものを保持していますが、本質的には変わりません)
この実装ではvideoという名前のstateで保持しています
Entityの配列
FPS
Entity
Entityとは、動画(などの、Projectに登場する要素)の表示方法を規定するオブジェクトです。
0202-animationでは、Entityは拡張可能なものとして定義していますが、このアプリでは動画エンティティに限定して実装しています。 なぜ<video>要素を使用するか?
目的
特定の時間における動画のフレーム画像を取得する
動画に対してこの操作を行う効率的な方法を検討しており、<video>要素を使用したのはそれらのアプローチのうちの1つです
以下の経緯で<video>要素を使用しています
結果
原因はまだ調べていません
2. MP4の構造を調べる
MP4は構造上、フレーム単位ではなく、チャンクという単位で情報を保存しています
このため、フレーム単位で画像を解凍するのではなく、秒単位でチャンクをデコードする方向で検討するようになりました
しかし、たとえばH.264デコーダはチャンク内の画像をそのまま返すのではなく、フレームの差分画像を返すと思われます
この差分画像の管理が困難だと考え、動画のフレーム取得は自分で実装するのではなく、既存の動画プレイヤーに行わせるほうがシンプルだという結論に至りました
この点、ChatGPTの回答がはっきりしないので自分の認識が誤っているかもしれません
実際の内容
特定フレームの画像を取得するのが目的なので、アプリに次の状態をもたせます:
プロジェクトのfps(projectFPS)
プロジェクト内の入力動画が可変フレームレートである可能性があるのに対し、プロジェクトのFPSは固定です。
現在再生中フレーム(currentPlayFrame)
フレーム単位で再生ヘッドの位置を表したものです。
現在再生時間(playTime)
秒単位で再生ヘッドの位置を表したものです。
currentPlayFrameとplayTimeから、次のように計算されます:
currentPlayFrame / playTime
Entityの配列(entities)
プロジェクト内に存在するEntity、すなわち入力動画の表示方法を表すオブジェクトの配列です。
Entityは次の属性を持ちます:
content
startTime
動画が、全体のうちどの部分から再生されるかを秒単位で表します。
例えば、startTime = 5であれば、その動画は5秒時点から再生が始まります。
timeline
start
プロジェクトにおいて、動画が登場するタイミングをフレーム単位で表します。
例えば、start = 1000 であれば、その動画は1000フレーム目から登場します。
end
プロジェクトにおいて、動画が退場するタイミングをフレーム単位で表します。
現在再生対象Entity(currentPlayEntity)
コード上では、entitiesへのインデクス currentPlayEntityIndex として表現しています。
本当はこの部分は entities のuseStateを切り出して抽象化したほうがいいと思います・・・ごめんなさい
方法
1. フレームを描画します。
具体的にはvideo要素の制御(controllVideo)とcanvasへの描画を行います。
2. 現在再生中フレーム(currentPlayFrame)を1加算します。
2. (1000 / projectFPS)ミリ秒待機します。
3. 1.を呼び出します。
この方法のために、setTimeout()の再帰的呼び出しが必要です。
video要素の制御
1. 現在再生中フレームと、currentPlayEntity.timeline.start を比較します。
すなわち、現在再生中フレームが、現在再生対象Entityが登場するタイミングになったかどうか。
一致したら、video要素の currentTime を currentPlayEntity.content.startTime にセットします
副作用1
次に、video要素をplayします
副作用2
非同期処理
2. 現在再生中フレームと、currentPlayEntity.timeline.end を比較します。
現在再生対象Entityが退場するタイミングになったかどうか。
一致したら、video要素の currentTime を currentPlayEntity.content.endTime にセットします
副作用3
次に、currentPlayEntityIndexを1加算します。
したがって、再生対象は次のEntityに移ることになります。
コメントアウト済のコードについて
video要素の制御をする関数(controllVideo)を呼び出す方法を3つ考え、うち2つはコメントアウトしました。
前提
controllVideoは非同期メソッド HTMLMediaElement.play()から制御が返ってくるのを待つ必要があります。
理由は、play メソッドを呼び出してから実際に再生が始まるまでにはデコード処理が挟まりラグが生じるからです。
このラグは、体験的に1秒以上になります。
play メソッドが完了する前に次のフレームの処理を開始した場合、entitiesの内容によってはエラーが生じます。
The play() request was interrupted by a call to pause()
1つめ
useInterval
react-use に含まれています
この方法では、delay引数 に 1000 / projectFPS ミリ秒を指定して、フレームを一定間隔で処理します
問題点
useInterval / setInterval は(ほぼ)必ず等間隔でコールバックを呼び出すので、関数内でplay メソッドが終わるのを待てない
考えたこと
controllVideo の呼び出しが完了してから再帰的にcontrollVideoを呼び出せばよい
2つめ
useTimeoutFn
改善点
controllVideoの処理を待てる
問題点
useTimeouFnはフックなので、多重に呼び出すことができない
3つめ
setTimeout
問題点
?
フレームが指定したより速いスピードで増加していきます
この増加は60fpsの場合 1 / 60秒 = 16ミリ秒という間隔なので、setTimeoutの精度との関係も気になります
useEffect内でsetTimeoutを呼び出しているから問題がない、と発想し現在の実装に至りますが、この実装が根本的に誤っている可能性もあります
補足
コード中に isVideoPlaying という状態がありますが、これは省略できるかもしれません
HTMLMediaElementの属性から計算できると思います
ただ、Video要素は ref として保存しており、refの属性が変更されても再レンダリングが発生しない可能性を考え、state として保持しています
再レンダリングされないと、CSSに変更が反映されないため困ります
まとめ
controllVideoの呼び出しの3つめ(現在)の実装が誤っている可能性を疑っています。