たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 15286
目次
本題
シークできない
シークできるようにしてみる
先に完成品
WebM について
データの格納方法
例えば
その他の話
WebM に入っているデータ
シークできるようにするには
動画の長さが記録されてない(Duration)
シークのための目印がない(Cue)
シークのための目印が入っていることを知らせることが出来ない(SeekHead)
Cue を先頭に持っていけば良いのでは?
長い!はよ作れ!
環境
ID 一覧を用意する
要素を表現するクラス
EBML や VINT をよしなにやる拡張関数
WebM をパースする処理
WebM をシークできるように組み立て直す
動画の時間だけ入れればシークできるかも(プレイヤー次第、多分良くない)
ここまでのソースコード
Kotlin/JS で使う
Kotlin/JS プロジェクトを作る
実行(ホットリロードあり)
さっき作った WebM をシークできるようにするコードを持ってくる
画面を作る(html を書く)
Kotlin/JS を書く
WebM をシーク可能にする処理
公開する
ホスティングする(インターネットへ公開する)
完成品(再掲)
ソースコード
おまけ
おわりに
メモリ的によろしくないかも
Chrome の作った WebM は途中でぶった斬る?
参考にしました
どうもこんばんわ。
忘年会でお酒飲まずにソフトドリンクだけ飲んでたんですけどやっぱ飲んだほうが得なんかな(??)
JavaScript
にMediaRecorder
とかいうやつがあるんですけど(Android
のやつじゃないです)
これのおかげでJavaScript
を少し書いてブラウザで開くだけで画面録画ができます!
https://takusan.negitoro.dev/posts/javascript_webm_livestreaming/
const MIME_TYPE = 'video/webm; codecs="vp9"'
// 画面をキャプチャーしてくれるやつ
const displayMedia = await navigator.mediaDevices.getDisplayMedia({ audio: { channelCount: 2 }, video: true })
// パソコンの画面を流す
mediaRecorder = new MediaRecorder(displayMedia, { mimeType: MIME_TYPE })
// 録画データが着たら呼ばれる
mediaRecorder.ondataavailable = (ev) => {
chunks.push(ev.data)
}
// 録画開始
mediaRecorder.start(100)
// TODO ダウンロード処理
画面録画がブラウザひとつで出来る一方、お悩みポイントもあって、出来た WebM ファイルがシークが出来ない点です
Firefox
は超優秀ですね、シークが出来ます。
Firefox
は動画の長さを解析する機能があるのでしょうか。
一方、Chrome
とかWindows
の標準プレイヤーとか、その他の動画プレイヤーも再生中ではありますがシークバーが進みません。
というわけで、今回はこのシークできないwebm
をシークできるようにしてみようと思います。
すでに先人がいますが、、、この次くらいにwebm
触る予定なのでちょうどいいです。自分でも作ってみます。
ここで WebM を選んで処理を始めればおけ。
ちょっと待てば勝手にダウンロードがされます。
その前にWebM
について、どの様に保存しているかをさらっっっと
WebM
の中身というかデータの格納方法とかは前も書いたのでそっちも見て
https://takusan.negitoro.dev/posts/video_webm_spec/
また、Matroska
のサブセットなのでそっちも見て
https://www.matroska.org/technical/elements.html
EBML
という仕組みに乗っかってます。
xml
がよく例で出るけど閉じタグは無いので、yaml
とかが近そう。
閉じタグが無いので、要素にはサイズが記録されています。
EBML
はこんな感じの構造が続いています。
+----+----------+------+
| ID | DataSize | Data |
+----+----------+------+
DataSize
の後ろにあるData
の大きさですDataSize
自身も可変長ですDataSize
の分だけデータがありますID
とDataSize
は可変長になっていて、どこにサイズが入っているかというと、
最初のバイトを、16進数から2進数にした後、左から1
がどの位置に入っているか。でサイズが分かるようになっています。
1 の位置 | データの大きさ |
---|---|
1000_0000 | 1 |
0100_0000 | 2 |
0010_0000 | 3 |
0001_0000 | 4 |
0000_1000 | 5 |
0000_0100 | 6 |
0000_0010 | 7 |
0000_0001 | 8 |
ID
とDataSize
はコレに乗っかっているので、自分自身の大きさを含めながらID
/DataSize
としての役割も果たします。すごい!
これをVINT
とか言うらしい。
ちなみにDataSize
は左から最初の1
はVINT
のために1
が立っているので、データの大きさとして使う場合はその最初の1
を無視する必要があります(ビット演算的に言うとフラグを折る)。
あ、ちなみにパースではなく組み立てる際、1
を立てる位置には注意する必要があります。
例えば1バイト
のデータ0100 0000
なら先頭に1
を立てても問題ないですが、
1111 0000
の場合はすでに1
が立っているので1
を立てることは出来ません。代わりに2バイト
に増やして1000 0000 1111 0000
にする必要があると思います。
また、次のような2バイト
のデータ1000 0000 0000 0000
の場合、左から2バイト目に1
を立てたいところですが、この場合も1バイト増やす必要があります。
これで立てると左の最初の1
が2
番目から1
番目になってしまうため、パースに失敗してしまいます。
実装的には先頭の1バイトが、1を立てただけの1バイトよりも大きくなる場合は1バイト足す必要があります。1を立てただけのバイトの方が大きい場合はそのまま立てて良いはず。
例えば、これから追加する動画の時間はこんな感じ
44 89 84 48 b6 c8 60
まずID
を解析します。(これは動画の時間を表しているって答えを言っていますが聞かなかったことに)
44
は2進数にすると0100 0100
ですね。
1
の位置は左から2番目なので、VINT
の表と照らし合わせてID
は2バイト分あることになります。
2バイト
取り出して、ID
は44 89
であることが分かりました。
4489
をID一覧
から探します!ありました!
このデータはDuration
ですね!
次にDataSize
です。84
を2進数にすると1000 0100
ですね。同様に表から探すと1
バイト分だということが分かります。
DataSize
は1バイト
分だとわかったので84
です。なんですが、左から最初の1
はVINT
用のやつなので無視する必要があります。
というわけで折ります。16進数84
を2進数にすると1000 0100
ですが、左から最初の1
は無視したいので0000 0100
です。
0100
を10進数にしたら4
ですね。よってData
の大きさは4
バイト分だと分かるわけです。
というわけでData
は48 b6 c8 60
。これが動画の長さです。
なんか動画の時間にしては変に見えますが、整数じゃないんですよね。ID
一覧をよく見るとFloat
って書いてあります。なのでそれはまたおいおい
親要素(Segment
/Info
/Tracks
/Cluster
等)はData
にデータではなくEBML
が入っているので、子要素を解析するようにする必要があります。
それもID
の表に書いてあるので見て
それと、DataSize
には長さ不明というのが既に予約されています。(0x01FFFFFFFFFFFFFF
)
もし長さが不明な場合は、子要素のDataSize
を足していけば分かるはず。
DataSize
が不明だと解析がめっちゃ面倒になるので無視したくなりますが、JavaScript
のMediaRecorder
が作ったWebM
には長さ不明が出現します。
なので、長さ不明を受け付けないようなコードを書いてはいけません。
出現すると言っても、出現箇所は決まってて(多分)Segment
とCluster
が長さ不明になるはずです。気をつけましょう(?)
コレを繰り返すことで、解析することが出来ます。
(他にもCodecPrivate
とかは別途説明が必要ですがそれは前書いた記事見て。シークしたいだけなら関係ないので。)
以上!!!データの格納方法!でした!
EBML
に乗っかって入っているデータが以下
公式:
https://www.matroska.org/technical/diagram.html
- EBML Header
- EBML version
- EBML read version
- EBMLMaxIDLength
- EBMLMaxSizeLength
- Document type
- Document type version
- Document type read version
- Segment
- SeekHead
- SeekEntry # Info の位置
- SeekID
- SeekPosition
- SeekEntry # Tracks の位置
- SeekID
- SeekPosition
- SeekEntry # Cluster の位置
- SeekID
- SeekPosition
- SeekEntry # Cue の位置
- SeekID
- SeekPosition
- Info
- TimestampScale
- MuxingApp
- WritingApp
- Duration
- Tracks
- Track
- TrackNumber
- TrackUID
- TrackType
- CodecID
- CodecPrivateData
- AudioTrack
- SamplingFrequency
- Channels
- Track
- TrackNumber
- TrackUID
- TrackType
- CodecID
- VideoTrack
- PixelWidth
- PixelHeight
- Cluster
- ClusterTimestamp
- SimpleBlock
- SimpleBlock
- Cluster # 動画が続く限り Cluster が増えていく
- Cue
- CuePoint
- CueTime
- CueTrackPositions
- CueTrack
- CueClusterPosition
- CuePoint # Cluster の数だけ CuePoint を入れる
↑ のやつから欠けているやつを入れる必要がある
動画の長さがwebm
に記録されていない。
webm
のファイル構造を見るためのGUI
アプリケーションがあるのでそれを開きます。
確かに動画の長さがJavaScript
のMediaRecorder
が吐き出したファイルには存在しないですね。
シークできる動画には動画の長さが記録されていそうです。
というわけでまずは動画の長さをWebM ファイル
に書き込む必要があるわけですね。
詳しくは:
https://www.matroska.org/technical/cues.html
そのシークの目印ってのがCue
要素とかいうやつで、これもJavaScript
のMediaRecorder
が吐き出したファイルには存在しません。
これは映像データ(後述)が、先頭から何バイト目にあるかというのを時間とともに目印として入れておきます。
これにより、プレイヤーがシークする際にCluster
を上から舐める必要がなくなり、シークした時間に一番近い目印を探して、Cluster
を読み出すことが出来るわけです。
・・・が後述しますが、Cue
とこの後説明するSeekHead
が無くても動画時間さえあれば再生できるプレイヤーも存在するらしい。
時間と、シークの目印だけを入れればシークできるのですが、どこにCue
を入れるかによっては話が変わってきます。
というのも
- EBML Header
- Segment
- Info
- Duration
- Tracks
- Track
- Track
- Cue
- Cluster
- Cluster
- Cluster
上記のように、映像、音声データを入れているCluster
より前にCue
を置くことが出来る場合はおそらく問題無いでしょう。
- EBML Header
- Segment
- Info
- Duration
- Tracks
- Track
- Track
- Cluster
- Cluster
- Cluster
- Cue
一方、上記のようにCluster
の後にCue
が来る場合、Cluster
の一番最後にCue
がありますよと教えてあげる必要があります。
Cluster
は映像、音声データの分だけ増えていくため、一度に解析はしないで再生に必要な部分だけ、上から順に取り出すような処理になっているはずです。
で、上から順に再生に必要な分だけ取り出していると、Cue
の存在に誰も気付かないわけです。
Cluster
より先頭にあれば気付く事ができますが、いかんせんCluster
の数が多いので最後では気付かないでしょう。
で、最後にCue
ありますよと教えてあげるのがSeekHead
要素です。
これにCue
が一番最後にあると書いておけば、再生の際にファイルの最後の方にジャンプしてCue
を解析する事ができるわけです。
これもJavaScript
のMediaRecorder
が吐き出したWebM
にはありません。
コレをする方法もあるとは思いますが、圧倒的にCue
を後にするのが楽だと思います(あんまりバイナリ操作慣れてないし)。
というのも、Cue
のサイズを予測する必要があるわけですね。
- EBML Header
- Segment
- Info
- Duration
- Tracks
- Track
- Track
- Cue が入るから適当に大きめのスペースを取る。予測する必要がある
- Cluster
- Cluster
- Cluster
すでに動画ファイルが存在して変換する場合ならまあ予測も出来ると思いますが、
撮影中の場合はどれだけ必要になるか見当もつかないため、Cue
最後が楽だと思います。
また、Cue
はシークの目印なので、Cluster
の位置がもちろん必要なのですが、
そのCluster
はCue
よりも後に来るため、位置計算の際に絶賛書き込み中のCue
自身のサイズまで予測する必要があり、これも大変だと思います。
しかもCue
が増えていくたびに全てのCluster
の位置を更新する必要があり、結構めんどくさい、考えたくない。
- EBML Header
- Segment
- Info
- Duration
- Tracks
- Track
- Track
- Cue
- CuePoint
- ClusterPosition # <-- Cluster の位置を書き込むが、この位置は今書き込んでいる Cue のサイズも考慮する必要がある
- Cluster # <-- もちろん Cue が追加されれば Cluster の位置もズレていく
- Cluster
- Cluster
作ります。
今回はJavaScript
のMediaRecorder
というわけで、やっぱブラウザで動くようにしたいですよね。
ですが、、、バイナリ操作にあんまり慣れてない。。。のでJavaScript
でもTypeScript
でも無く、いつも書いてるKotlin
で行かせてください。
JS / TS
で書ければ良いのですがちょっと自信無いかなあ、慣れてる(?)言語で行かせて欲しい。
Kotlin
で書いて、Kotlin/JS
でビルドすればブラウザで動くのでそれで許して~~~
(JVM
以外でもKotlin
のコード動かせます。もちろん動かすためにはJava
の機能を使わないようにする必要がありますが。)
(java.net.HttpUrlConnection
とかjava.io.File
とか使ったら無理。逆にKotlin
の標準ライブラリだけで書かれていればJS
でも動くかもしれない。)
あ、とりあえずはJava
で作って動かして、問題なければKotlin/JS
にコピーしようと思います。
なまえ | あたい |
---|---|
Intellij IDEA | 2023.2.2 (Community Edition) |
Java | Eclipse Adoptium JDK 17 |
Kotlin | 1.9.0 |
2/10/16 進数変換出来る電卓があると良いです
(Windows
に最初から入ってる電卓をプログラマーモードにすれば良いです)
はい。
それぞれ子要素を持っているか、子要素の場合は親要素のID、を持たせてみました。
https://www.matroska.org/technical/elements.html
/**
* Matroska の ID
*
* @param byteArray ID のバイト配列
* @param isParent 子要素を持つ親かどうか
* @param parentTag 親要素の [MatroskaId]
*/
enum class MatroskaId(
val byteArray: ByteArray,
val isParent: Boolean,
val parentTag: MatroskaId?
) {
EBML(byteArrayOf(0x1A.toByte(), 0x45.toByte(), 0xDF.toByte(), 0xA3.toByte()), true, null),
EBMLVersion(byteArrayOf(0x42.toByte(), 0x86.toByte()), false, EBML),
EBMLReadVersion(byteArrayOf(0x42.toByte(), 0xF7.toByte()), false, EBML),
EBMLMaxIDLength(byteArrayOf(0x42.toByte(), 0xF2.toByte()), false, EBML),
EBMLMaxSizeLength(byteArrayOf(0x42.toByte(), 0xF3.toByte()), false, EBML),
DocType(byteArrayOf(0x42.toByte(), 0x82.toByte()), false, EBML),
DocTypeVersion(byteArrayOf(0x42.toByte(), 0x87.toByte()), false, EBML),
DocTypeReadVersion(byteArrayOf(0x42.toByte(), 0x85.toByte()), false, EBML),
Segment(byteArrayOf(0x18.toByte(), 0x53.toByte(), 0x80.toByte(), 0x67.toByte()), true, null),
SeekHead(byteArrayOf(0x11.toByte(), 0x4D.toByte(), 0x9B.toByte(), 0x74.toByte()), true, Segment),
Seek(byteArrayOf(0x4D.toByte(), 0xBB.toByte()), true, SeekHead),
SeekID(byteArrayOf(0x53.toByte(), 0xAB.toByte()), false, Seek),
SeekPosition(byteArrayOf(0x53.toByte(), 0xAC.toByte()), false, Seek),
Info(byteArrayOf(0x15.toByte(), 0x49.toByte(), 0xA9.toByte(), 0x66.toByte()), true, Segment),
Duration(byteArrayOf(0x44.toByte(), 0x89.toByte()), false, Info),
SegmentUUID(byteArrayOf(0x73.toByte(), 0xA4.toByte()), false, Info),
TimestampScale(byteArrayOf(0x2A.toByte(), 0xD7.toByte(), 0xB1.toByte()), false, Info),
MuxingApp(byteArrayOf(0x4D.toByte(), 0x80.toByte()), false, Info),
WritingApp(byteArrayOf(0x57.toByte(), 0x41.toByte()), false, Info),
Tracks(byteArrayOf(0x16.toByte(), 0x54.toByte(), 0xAE.toByte(), 0x6B.toByte()), true, Segment),
TrackEntry(byteArrayOf(0xAE.toByte()), true, Tracks),
TrackNumber(byteArrayOf(0xD7.toByte()), false, TrackEntry),
TrackUID(byteArrayOf(0x73.toByte(), 0xC5.toByte()), false, TrackEntry),
TrackType(byteArrayOf(0x83.toByte()), false, TrackEntry),
Language(byteArrayOf(0x22.toByte(), 0xB5.toByte(), 0x9C.toByte()), false, TrackEntry),
CodecID(byteArrayOf(0x86.toByte()), false, TrackEntry),
CodecPrivate(byteArrayOf(0x63.toByte(), 0xA2.toByte()), false, TrackEntry),
CodecName(byteArrayOf(0x25.toByte(), 0x86.toByte(), 0x88.toByte()), false, TrackEntry),
FlagLacing(byteArrayOf(0x9C.toByte()), false, TrackEntry),
DefaultDuration(byteArrayOf(0x23.toByte(), 0xE3.toByte(), 0x83.toByte()), false, TrackEntry),
TrackTimecodeScale(byteArrayOf(0x23.toByte(), 0x31.toByte(), 0x4F.toByte()), false, TrackEntry),
VideoTrack(byteArrayOf(0xE0.toByte()), false, TrackEntry),
PixelWidth(byteArrayOf(0xB0.toByte()), false, VideoTrack),
PixelHeight(byteArrayOf(0xBA.toByte()), false, VideoTrack),
FrameRate(byteArrayOf(0x23.toByte(), 0x83.toByte(), 0xE3.toByte()), false, VideoTrack),
MaxBlockAdditionID(byteArrayOf(0x55.toByte(), 0xEE.toByte()), false, VideoTrack),
AudioTrack(byteArrayOf(0xE1.toByte()), true, TrackEntry),
SamplingFrequency(byteArrayOf(0xB5.toByte()), false, AudioTrack),
Channels(byteArrayOf(0x9F.toByte()), false, AudioTrack),
BitDepth(byteArrayOf(0x62.toByte(), 0x64.toByte()), false, AudioTrack),
Cues(byteArrayOf(0x1C.toByte(), 0x53.toByte(), 0xBB.toByte(), 0x6B.toByte()), true, Segment),
CuePoint(byteArrayOf(0xBB.toByte()), true, Cues),
CueTime(byteArrayOf(0xB3.toByte()), false, CuePoint),
CueTrackPositions(byteArrayOf(0xB7.toByte()), true, CuePoint),
CueTrack(byteArrayOf(0xF7.toByte()), false, CueTrackPositions),
CueClusterPosition(byteArrayOf(0xF1.toByte()), false, CueTrackPositions),
CueRelativePosition(byteArrayOf(0xF0.toByte()), false, CueTrackPositions),
Cluster(byteArrayOf(0x1F.toByte(), 0x43.toByte(), 0xB6.toByte(), 0x75.toByte()), true, Segment),
Timestamp(byteArrayOf(0xE7.toByte()), false, Cluster),
SimpleBlock(byteArrayOf(0xA3.toByte()), false, Cluster)
}
EBML
で収納されたそれぞれのデータを表すクラスです。
ID
と、Data
の中身ですね。
/**
* EBMLの要素を表すデータクラス
*
* @param matroskaId [MatroskaId]
* @param data データ
*/
data class MatroskaElement(
val matroskaId: MatroskaId,
val data: ByteArray
)
よく使う関数は拡張関数として生やしておきましょう。
前回のVINT
周りは慣れないビット演算を使ったせいで、なんだかよく分からないけど動いているコードが誕生したので、
→ https://takusan.negitoro.dev/posts/video_webm_spec/#id
今回はVINT
とサイズの対応表とかを用意することでビット演算を少し回避しました。
あ、あとあんまり見かけたことがないのですが、Kotlin
で*
(アスタリスク)を使うと配列の中身を展開してくれます。
JavaScript
だとスプレッド構文とか言うやつですね。JavaScript
のそれと同じだと思います。
// JavaScript
// 展開しない
console.log([1,2,3, [4,5,6] ]) // [1,2,3, [4,5,6] ]
// 配列内に展開する(スプレッド構文)
console.log([1,2,3, ...[4,5,6] ]) // [ 1, 2, 3, 4, 5, 6 ]
// Kotlin
println(listOf(1,2,3, listOf(4,5,6) )) // [1, 2, 3, [4, 5, 6]]
println(listOf(1, 2, 3, *arrayOf(4, 5, 6))) // [1, 2, 3, 4, 5, 6]
別に無くても、byteArrayOf() + byteArrayOf()
みたく+
で繋いでいけばいいんですけど、*
のが見やすそう。
listOf(1, 2, 3, *arrayOf(4, 5, 6))
listOf(1, 2, 3) + arrayOf(4, 5, 6)
詳しくはそれぞれの関数のコメントを見て下さい。
MatroskaExtension.kt
/** DataSize の長さが不定の場合の表現 */
internal val UNKNOWN_DATA_SIZE = byteArrayOf(0x1F.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
/** VINT とバイト数 */
private val BIT_SIZE_LIST = listOf(
0b1000_0000 to 1,
0b0100_0000 to 2,
0b0010_0000 to 3,
0b0001_0000 to 4,
0b0000_1000 to 5,
0b0000_0100 to 6,
0b0000_0010 to 7,
0b0000_0001 to 8
)
/** 最大値と必要なバイト数 */
private val INT_TO_BYTEARRAY_LIST = listOf(
0xFF to 1,
0xFFFF to 2,
0xFFFFFF to 3,
0x7FFFFFFF to 4
)
/** [MatroskaElement]の配列から[MatroskaId]をフィルターする */
internal fun List<MatroskaElement>.filterId(matroskaId: MatroskaId) = this.filter { it.matroskaId == matroskaId }
/**
* VINT の長さを返す
* 注意してほしいのは、DataSize は Data の長さを表すものだが、この DataSize 自体の長さが可変長。
*/
internal fun Byte.getElementLength(): Int {
// UInt にする必要あり
val uIntValue = toInt().andFF()
// 左から最初に立ってるビットを探して、サイズをもとめる
// 予めビットとサイズを用意しておく読みやすさ最優先実装...
return BIT_SIZE_LIST.first { (bit, _) ->
// ビットを AND して、0 以外を探す(ビットが立ってない場合は 0 になる)
uIntValue and bit != 0
}.second
}
/** [MatroskaElement] を EBML 要素のバイト配列にする */
internal fun MatroskaElement.toEbmlByteArray(): ByteArray {
// ID
val idByteArray = this.matroskaId.byteArray
// DataSize
val dataSize = this.data.size
val dataSizeByteArray = dataSize.toDataSize()
// Data
val data = this.data
// ByteArray にする
return byteArrayOf(*idByteArray, *dataSizeByteArray, *data)
}
/** [ByteArray]から[MatroskaId]を探す */
internal fun ByteArray.toMatroskaId(): MatroskaId {
return MatroskaId.entries.first { it.byteArray.contentEquals(this) }
}
/** [ByteArray]が入っている[List]を、全部まとめて[ByteArray]にする */
internal fun List<ByteArray>.concatByteArray(): ByteArray {
val byteArray = ByteArray(this.sumOf { it.size })
var write = 0
this.forEach { cluster ->
repeat(cluster.size) { read ->
byteArray[write++] = cluster[read]
}
}
return byteArray
}
/**
* DataSize 要素から実際の Data の大きさを出す
* @see [getElementLength]
* @return 長さ不定の場合は -1
*/
internal fun ByteArray.getDataSize(): Int {
// 例外で、 01 FF FF FF FF FF FF FF のときは長さが不定なので...
// Segment / Cluster の場合は子要素の長さを全部足せば出せると思うので、、、
if (contentEquals(UNKNOWN_DATA_SIZE)) {
return -1
}
var firstByte = first().toInt().andFF()
// DataSize 要素の場合、最初に立ってるビットを消す
for ((bit, _) in BIT_SIZE_LIST) {
if (firstByte and bit != 0) {
// XOR で両方同じ場合は消す
firstByte = firstByte xor bit
// 消すビットは最初だけなので
break
}
}
// 戻す
return if (size == 1) {
firstByte
} else {
byteArrayOf(firstByte.toByte(), *copyOfRange(1, size)).toInt()
}
}
/** 実際のデータの大きさから DataSize 要素のバイト配列にする */
internal fun Int.toDataSize(): ByteArray {
// Int を ByteArray に
val byteArray = this.toByteArray()
// 最初のバイトは、DataSize 自体のサイズを入れる必要がある( VINT )
val firstByte = byteArray.first().toInt().andFF()
// DataSize 自体のサイズとして、最初のバイトに OR で 1 を立てる
var resultByteArray = byteArrayOf()
BIT_SIZE_LIST.forEachIndexed { index, (bit, size) ->
if (size == byteArray.size) {
resultByteArray = if (firstByte < bit) {
// V_INT を先頭のバイトに書き込む
byteArrayOf(
(firstByte or bit).toByte(),
*byteArray.copyOfRange(1, byteArray.size)
)
} else {
// 最初のバイトに書き込めない場合
// (DataSize 自身のサイズのビットを立てる前の時点で、最初のバイトのほうが大きい場合)
// size が +1 するので、配列の添字は -1 する必要あり
byteArrayOf(BIT_SIZE_LIST[index + 1].first.toByte(), *byteArray)
}
}
}
// 戻す
return resultByteArray
}
/**
* Segment の長さ不定のデータサイズを上から舐めてもとめる。
* ByteArray は ID までは読めている、DataSize は長さ不定なので 8 バイト固定。というわけで ID + DataSize を消した Data だけください。
*/
internal fun ByteArray.analyzeUnknownDataSizeForSegmentData(): Int {
var index = 0
while (true) {
// Segment も Cluster も親要素なので、それぞれの子を見ていけばデータの長さが分かるはず。
val idLength = this[index].getElementLength()
val idElement = this.copyOfRange(index, index + idLength)
// ID 分足していく
index += idLength
// DataSize を取り出す
val dataSizeLength = this[index].getElementLength()
// Cluster の場合は長さ不定の場合があるため、追加処理
val dataSizeElement = this.copyOfRange(index, index + dataSizeLength).getDataSize().let { dataSizeOrUnknown ->
// 長さが求まっていればそれを使う
if (dataSizeOrUnknown != -1) {
return@let dataSizeOrUnknown
}
// Cluster は上から舐めて求められる
val dataByteArray = this.copyOfRange(index + UNKNOWN_DATA_SIZE.size, this.size)
if (idElement.toMatroskaId() == MatroskaId.Cluster) {
// Cluster の長さ不定は求められる、再帰で求める。Data を渡す
dataByteArray.analyzeUnknownDataSizeForClusterData()
} else {
throw RuntimeException("長さ不定")
}
}
// DataSize 分足していく
index += dataSizeLength
// データの大きさを出すため、Data の中身までは見ない
// Data 分足していく
index += dataSizeElement
// もう読み出せない場合はここまでをデータの大きさとする
if (size <= index) {
break
}
// 次の要素が 3 バイト以上ない場合は break(解析できない)
if (size <= index + 3) {
break
}
}
return index
}
/**
* Cluster の長さ不定のデータサイズを上から舐めてもとめる。
*
* ByteArray は ID までは読めている、DataSize は長さ不定なので 8 バイト固定。というわけで ID + DataSize を消した Data だけください。
*/
internal fun ByteArray.analyzeUnknownDataSizeForClusterData(): Int {
var index = 0
while (true) {
// Segment も Cluster も親要素なので、それぞれの子を見ていけばデータの長さが分かるはず。
val idLength = this[index].getElementLength()
val idElement = this.copyOfRange(index, index + idLength)
// Cluster だった場合は break。次の Cluster にぶつかったってことです。
if (idElement.toMatroskaId() == MatroskaId.Cluster) {
break
}
// ID 分足していく
index += idLength
// DataSize を取り出す
val dataSizeLength = this[index].getElementLength()
val dataSizeElement = this.copyOfRange(index, index + dataSizeLength).getDataSize()
// DataSize 分足していく
index += dataSizeLength
// データの大きさを出すため、Data の中身までは見ない
// Data 分足していく
index += dataSizeElement
// もう読み出せない場合はここまでをデータの大きさとする
if (size <= index) {
break
}
// もしかしたら他のブラウザでもなるかもしれないけど、
// Chromeの場合、録画終了を押したとき、要素をきれいに終わらせてくれるわけでは無いらしく SimpleBlock の途中だろうとぶった切ってくるらしい、中途半端にデータが余ることがある
// 例:タグの A3 で終わるなど
// その場合にエラーにならないように、この後3バイト(ID / DataSize / Data それぞれ1バイト)ない場合はループを抜ける
if (size <= index + 3) {
break
}
}
return index
}
/** Int を ByteArray に変換する */
internal fun Int.toByteArray(): ByteArray {
// 多分 max 4 バイト
val size = INT_TO_BYTEARRAY_LIST.first { (maxValue, _) ->
this <= maxValue
}.second
var l = this
val result = ByteArray(size)
for (i in 0..<size) {
result[i] = (l and 0xff).toByte()
l = l shr 8
}
// 逆になってるのでもどす
result.reverse()
return result
}
/** ByteArray から Int へ変換する。ByteArray 内にある Byte は符号なしに変換される。 */
internal fun ByteArray.toInt(): Int {
// 先頭に 0x00 があれば消す
val validValuePos = kotlin.math.max(0, this.indexOfFirst { it != 0x00.toByte() })
var result = 0
// 逆にする
// これしないと左側にバイトが移動するようなシフト演算?になってしまう
// for を 多い順 にすればいいけどこっちの方でいいんじゃない
drop(validValuePos).reversed().also { bytes ->
for (i in 0 until bytes.count()) {
result = result or (bytes.get(i).toInt().andFF() shl (8 * i))
}
}
return result
}
/** ByteをIntに変換した際に、符号付きIntになるので、AND 0xFF するだけの関数 */
internal fun Int.andFF() = this and 0xFF
まずはWebM
の中身からそれぞれの要素を出そうと思います。
Info
とかSimpleBlock
とかを取り出していきます。
長さ不明の場合はサイズを求めるようにしていますが、これはSegment
とCluster
のみで、それ以外で長さ不明を使われたら例外になると思います。
(めんどいのとJavaScript
のMediaRecorder
はコレ以外の要素に関してはちゃんとサイズを入れているので)
ちなみにCluster
の場合は次のCluster
に当たるまで DataSize を取り出し続けます。Segment
は解析できなくなるまでかな。
ちなみにこれ計算リソースの無駄遣いと言われたらそれはそうだと思う(長さ不明の解析のために一回上から舐めて、長さが分かったら今度要素のパースのためにまた舐める)
object FixWebmSeek {
/**
* WebM をパースする
*
* @param webmFileByteArray WebM ファイル
* @return [MatroskaElement]の配列
*/
fun parseWebm(webmFileByteArray: ByteArray): List<MatroskaElement> {
/**
* [byteArray] から次の EBML のパースを行う
*
* @param byteArray EBML の[ByteArray]
* @param startPosition EBML 要素の開始位置
* @return パース結果[MatroskaElement]と、パースしたデータの位置
*/
fun parseEbmlElement(byteArray: ByteArray, startPosition: Int): Pair<MatroskaElement, Int> {
var currentPosition = startPosition
// ID をパース
val idLength = byteArray[currentPosition].getElementLength()
val idByteArray = byteArray.copyOfRange(currentPosition, currentPosition + idLength)
val matroskaId = idByteArray.toMatroskaId()
currentPosition += idLength
// DataSize をパース
val dataSizeLength = byteArray[currentPosition].getElementLength()
// JavaScript の MediaRecorder は Segment / Cluster が DataSize が長さ不明になる
val dataSize = byteArray.copyOfRange(currentPosition, currentPosition + dataSizeLength).getDataSize().let { dataSizeOrUnknown ->
if (dataSizeOrUnknown == -1) {
// 長さ不明の場合は上から舐めて出す
val dataByteArray = byteArray.copyOfRange(currentPosition + UNKNOWN_DATA_SIZE.size, byteArray.size)
when (matroskaId) {
MatroskaId.Segment -> dataByteArray.analyzeUnknownDataSizeForSegmentData()
MatroskaId.Cluster -> dataByteArray.analyzeUnknownDataSizeForClusterData()
else -> throw RuntimeException("Cluster Segment 以外は長さ不明に対応していません;;")
}
} else {
// 長さが求まっていればそれを使う
dataSizeOrUnknown
}
}
currentPosition += dataSizeLength
// Data を取り出す
val dataByteArray = byteArray.copyOfRange(currentPosition, currentPosition + dataSize)
currentPosition += dataSize
// 返す
val matroskaElement = MatroskaElement(matroskaId, dataByteArray)
return matroskaElement to currentPosition
}
/**
* [byteArray] から EBML 要素をパースする。
* 親要素の場合は子要素まで再帰的に見つける。
*
* 一つだけ取りたい場合は [parseEbmlElement]
* @param byteArray WebM のバイト配列
* @return 要素一覧
*/
fun parseAllEbmlElement(byteArray: ByteArray): List<MatroskaElement> {
var readPosition = 0
val elementList = arrayListOf<MatroskaElement>()
while (true) {
// 要素を取得し位置を更新
val (element, currentPosition) = parseEbmlElement(byteArray, readPosition)
elementList += element
readPosition = currentPosition
// 親要素の場合は子要素の解析をする
if (element.matroskaId.isParent) {
val children = parseAllEbmlElement(element.data)
elementList += children
}
// 次のデータが 3 バイト以上ない場合は break(解析できない)
// 3 バイトの理由ですが ID+DataSize+Data それぞれ1バイト以上必要なので
if (byteArray.size <= readPosition + 3) {
break
}
}
return elementList
}
// WebM の要素を全部パースする
return parseAllEbmlElement(webmFileByteArray)
}
}
とりあえず動くか試します。
とりあえずJava
で動くKotlin
で書いてからKotlin/JS
で動くようにしようと思うので、
こんな感じに書いて、適当なところにwebm
ファイルを置いて、ファイルパスを直してください。
private const val WEBM_PATH = """C:\Users\takusan23\Downloads\record-1703001154715.webm"""
fun main(args: Array<String>) {
// ファイルを読み出す
val webmByteArray = File(WEBM_PATH).readBytes()
// 要素のリストに
val elementList = FixWebmSeek.parseWebm(webmByteArray)
println(elementList.map { it.matroskaId })
}
あんまり関係ないですが、Windows
でファイルパスのコピーはファイルを選んでShift + 右クリック
するとコンテキストメニューにパスのコピー
というメニューが出てきます。それを押せば簡単にできます。
何故かShiftキー
を押しながら右クリックしないと出てきません。
適当に動かしてみました、
println
でWebM
の中身が出力されていれば成功です!!
WebM
をそれぞれの要素に分解出来たので、シークのために必要な要素を追加して、WebM
を完成させます。
やるべきことは上の方で説明した通りで、
Info
の中にDuration
を入れるCue
をCluster
の後に入れるInfo
、Tracks
、Cue
の開始位置を宣言するSeekHead
を入れるというわけで、それらをやる処理です。
めんどくさくなったので詳しくはコメント見てください。
object FixWebmSeek {
// 省略 ...
/**
* [parseWebm]で出来た要素一覧を元に、シークできる WebM を作成する
*
* @param elementList [parseWebm]
* @return シークできる WebM のバイナリ
*/
fun fixSeekableWebM(elementList: List<MatroskaElement>): ByteArray {
/** SimpleBlock と Timestamp から動画の時間を出す */
fun getVideoDuration(): Int {
val lastTimeStamp = elementList.filterId(MatroskaId.Timestamp).last().data.toInt()
// Cluster は 2,3 バイト目が相対時間になる
val lastRelativeTime = elementList.filterId(MatroskaId.SimpleBlock).last().data.copyOfRange(1, 3).toInt()
return lastTimeStamp + lastRelativeTime
}
/** 映像トラックの番号を取得 */
fun getVideoTrackNumber(): Int {
var videoTrackIndex = -1
var latestTrackNumber = -1
var latestTrackType = -1
for (element in elementList) {
if (element.matroskaId == MatroskaId.TrackNumber) {
latestTrackNumber = element.data.toInt()
}
if (element.matroskaId == MatroskaId.TrackType) {
latestTrackType = element.data.toInt()
}
if (latestTrackType != -1 && latestTrackNumber != -1) {
if (latestTrackType == 1) {
videoTrackIndex = latestTrackNumber
break
}
}
}
return videoTrackIndex
}
/**
* Cluster を見て [MatroskaId.CuePoint] を作成する
*
* @param clusterStartPosition Cluster 開始位置
* @return CuePoint
*/
fun createCuePoint(clusterStartPosition: Int): List<MatroskaElement> {
// Cluster を上から見ていって CuePoint を作る
val cuePointList = mutableListOf<MatroskaElement>()
// 前回追加した Cluster の位置
var prevPosition = clusterStartPosition
elementList.forEachIndexed { index, element ->
if (element.matroskaId == MatroskaId.Cluster) {
// Cluster の子から時間を取り出して Cue で使う
var childIndex = index
var latestTimestamp = -1
var latestSimpleBlockRelativeTime = -1
while (true) {
val childElement = elementList[childIndex++]
// Cluster のあとにある Timestamp を控える
if (childElement.matroskaId == MatroskaId.Timestamp) {
latestTimestamp = childElement.data.toInt()
}
// Cluster から見た相対時間
if (childElement.matroskaId == MatroskaId.SimpleBlock) {
latestSimpleBlockRelativeTime = childElement.data.copyOfRange(1, 3).toInt()
}
// Cluster の位置と時間がわかったので
if (latestTimestamp != -1 && latestSimpleBlockRelativeTime != -1) {
cuePointList += MatroskaElement(
MatroskaId.CuePoint,
byteArrayOf(
*MatroskaElement(MatroskaId.CueTime, (latestTimestamp + latestSimpleBlockRelativeTime).toByteArray()).toEbmlByteArray(),
*MatroskaElement(
MatroskaId.CueTrackPositions,
byteArrayOf(
*MatroskaElement(MatroskaId.CueTrack, getVideoTrackNumber().toByteArray()).toEbmlByteArray(),
*MatroskaElement(MatroskaId.CueClusterPosition, prevPosition.toByteArray()).toEbmlByteArray()
)
).toEbmlByteArray()
)
)
break
}
}
// 進める
prevPosition += element.toEbmlByteArray().size
}
}
return cuePointList
}
/** SeekHead を組み立てる */
fun reclusiveCreateSeekHead(
infoByteArraySize: Int,
tracksByteArraySize: Int,
clusterByteArraySize: Int
): MatroskaElement {
/**
* SeekHead を作成する
* 注意しないといけないのは、SeekHead に書き込んだ各要素の位置は、SeekHead 自身のサイズを含めた位置にする必要があります。
* なので、SeekHead のサイズが変わった場合、この後の Info Tracks の位置もその分だけズレていくので、注意が必要。
*/
fun createSeekHead(seekHeadSize: Int): MatroskaElement {
val infoPosition = seekHeadSize
val tracksPosition = infoPosition + infoByteArraySize
val clusterPosition = tracksPosition + tracksByteArraySize
// Cue は最後
val cuePosition = clusterPosition + clusterByteArraySize
// トップレベル要素、この子たちの位置を入れる
val topLevelElementList = listOf(
MatroskaId.Info to infoPosition,
MatroskaId.Tracks to tracksPosition,
MatroskaId.Cluster to clusterPosition,
MatroskaId.Cues to cuePosition
).map { (tag, position) ->
MatroskaElement(
MatroskaId.Seek,
byteArrayOf(
*MatroskaElement(MatroskaId.SeekID, tag.byteArray).toEbmlByteArray(),
*MatroskaElement(MatroskaId.SeekPosition, position.toByteArray()).toEbmlByteArray()
)
)
}
val seekHead = MatroskaElement(MatroskaId.SeekHead, topLevelElementList.map { it.toEbmlByteArray() }.concatByteArray())
return seekHead
}
// まず一回 SeekHead 自身のサイズを含めない SeekHead を作る。
// これで SeekHead 自身のサイズが求められるので、SeekHead 自身を考慮した SeekHead を作成できる。
var prevSeekHeadSize = createSeekHead(0).toEbmlByteArray().size
var seekHead: MatroskaElement
while (true) {
seekHead = createSeekHead(prevSeekHeadSize)
val seekHeadSize = seekHead.toEbmlByteArray().size
// サイズが同じになるまで SeekHead を作り直す
if (prevSeekHeadSize == seekHeadSize) {
break
} else {
prevSeekHeadSize = seekHeadSize
}
}
return seekHead
}
// Duration 要素を作る
val durationElement = MatroskaElement(MatroskaId.Duration, getVideoDuration().toFloat().toBits().toByteArray())
// Duration を追加した Info を作る
val infoElement = elementList.filterId(MatroskaId.Info).first().let { before ->
before.copy(data = before.data + durationElement.toEbmlByteArray())
}
// ByteArray にしてサイズが分かるように
val infoByteArray = infoElement.toEbmlByteArray()
val tracksByteArray = elementList.filterId(MatroskaId.Tracks).first().toEbmlByteArray()
val clusterByteArray = elementList.filterId(MatroskaId.Cluster).map { it.toEbmlByteArray() }.concatByteArray()
// SeekHead を作る
val seekHeadByteArray = reclusiveCreateSeekHead(
infoByteArraySize = infoByteArray.size,
tracksByteArraySize = tracksByteArray.size,
clusterByteArraySize = clusterByteArray.size
).toEbmlByteArray()
// Cues を作る
val cuePointList = createCuePoint(seekHeadByteArray.size + infoByteArray.size + tracksByteArray.size)
val cuesByteArray = MatroskaElement(MatroskaId.Cues, cuePointList.map { it.toEbmlByteArray() }.concatByteArray()).toEbmlByteArray()
// Segment 要素に書き込むファイルが完成
// 全部作り直しになる副産物として DataSize が不定長ではなくなります。
val segment = MatroskaElement(
MatroskaId.Segment,
byteArrayOf(
*seekHeadByteArray,
*infoByteArray,
*tracksByteArray,
*clusterByteArray,
*cuesByteArray
)
)
// シークできるように修正した WebM のバイナリ
// WebM 先頭の EBML を忘れずに、これは書き換える必要ないのでそのまま
return byteArrayOf(
*elementList.filterId(MatroskaId.EBML).first().toEbmlByteArray(),
*segment.toEbmlByteArray()
)
}
}
あとはこれをparseWebm
の返り値と組み合わせて使えば動きます。
適当にダウンロードフォルダにでも保存するようにしましょう。
import java.io.File
private const val WEBM_PATH = """C:\Users\takusan23\Downloads\record-1703001154715.webm"""
private val DOWNLOAD_FOLDER = File(System.getProperty("user.home"), "Downloads")
fun main(args: Array<String>) {
// ファイルを読み出す
val webmByteArray = File(WEBM_PATH).readBytes()
// 要素のリストに
val elementList = FixWebmSeek.parseWebm(webmByteArray)
// シークできる WebM にする
val seekableWebmByteArray = FixWebmSeek.fixSeekableWebM(elementList)
// 保存する
File(DOWNLOAD_FOLDER, "fix_webm_seek_kotlin_${System.currentTimeMillis()}.webm").writeBytes(seekableWebmByteArray)
}
どうでしょう?
シークバー、進んでますか?シークも出来ますか?
ブラウザでももちろん見れます。やった~~~
mkvtoolnix
で見てみます。こんな感じ
SeekHead
、Cue
がそれぞれありますね。
また、要素を入れ直している関係で、長さ不明だったDataSize
が確定していますね。パーサーに優しくなった
動画の長さ、Duration
さえ入っていればシークできるプレイヤー実装があるらしいです。
が、まちまちなのでやっぱり Cue
や SeekHead
も入れないとダメなはずです。
どうぞ
多分最新の IDEA で動くはず。
https://github.com/takusan23/FixSeekableWebmKotlin
JavaScript
のMediaRecorder
で出来るWebM
をシーク出来るようにするためにわざわざIDEA
起動するのは面倒!
なんなら画面録画からシーク可能に修正までブラウザで出来ると良いのでKotlin/JS
で動かします!
セットアップ方法がなんか難しくなった?
前はKotlin/JS
すぐ使えた気がするんだけど
公式:
https://kotlinlang.org/docs/js-project-setup.html
うーん、前は新規プロジェクト作成でKotlin/JS
単品が選べた気がするんですけど、、今見るとKotlin Multiplatform
でしか作れない?
わかんない・・
とりあえずはKotlin/JVM
なプロジェクトを作った後に、Kotlin/JS
で動くように直そうと思います。
てな感じでNew Project
で作っていきます。Gradle
はbuild.gradle.kts
の方にします。(ほんとに無いの?)
次にbuild.gradle.kts
を開いて、Kotlin/JS
用に直します。
jvm
用の設定が消えた
plugins {
kotlin("multiplatform") version "1.9.22"
}
group = "io.github.takusan23"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
kotlin {
js {
browser {
// webpack とか
}
binaries.executable()
}
}
多分コピペ直後は赤くなってるので、Gradle Sync
しましょう。
これです
ここに置けば良いって書いてあるのでそうします。
https://kotlinlang.org/docs/running-kotlin-js.html
src
を右クリックして、ディレクトリを作ります。
jsMain/kotlin
です。
出来たらsrc/jsMain/kotlin
へ移動して、App.kt
を作ります。
適当にconsole.log
するコードを置いておきます。
そしたらもうjvm
の方はいらないので、この2つは消して良いはず。
次にindex.html
を置きます。
さっきと同じ用にディレクトリを作る画面を出して、今度はjsMain/resources
を選びます。
そしたらsrc/jsMain/resources
にindex.html
を作ります。
index.html
の中身はそれぞれ調整しないといけないので注意してね。
調整しないといけない項目は、
<title>
<script>
のsrc
属性の値
{こ↑こ↓}.js
のここの名前は、プロジェクト名にする必要があります。build.gradle.kts
のwebpack
の設定で好きに変えられるとは思います<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FixSeekableWebmKotlinJs</title>
</head>
<body>
<script src="FixSeekableWebmKotlinJs.js"></script>
</body>
</html>
ここまで出来たら実行できます。やってみましょう。
https://kotlinlang.org/docs/running-kotlin-js.html#run-the-browser-target
https://kotlinlang.org/docs/dev-server-continuous-compilation.html
ホットリロード付きです。
Web
界隈の開発では当たり前なのかReact Native
とかでもホットリロードされててすごいと思った。
Gradle
のコマンドを実行できるExecute Gradle Task
を開きます。
IDEA
の右側に🐘さんのマークがあるはずなので押してこれ押す。
もし見つけられなかったら、View
からTool Windows
からGradle
を押してもいいです。
そしたら以下を打ち込んで実行です!
gradle jsRun --continuous
どうでしょう?勝手にブラウザが開いてlocalhost:8080
が開くはず?
index.html
に何も無いので真っ白ですが、F12
を押してConsole
を押すと・・・
ありました!Hello
!
多分ちゃんとホットリロードになってるはず。
ちなみにKotlin/JVM
で動かしてたものがKotlin/JS
で何で動かせるんやって話ですが、
Java
の機能を使ってないからなんですよね(さっきも話した気が
確かにMain.kt
ではimport java.xxxx
でJava
の機能を呼び出していますが、
それ以外のファイルではimport java.xxx
等は出てきていません。またKotlin/JVM
でしか動かない拡張関数も使っていないので、そのままコピペできるわけです。
というわけで持ってきました。
適当に画面を作ります。
最低限必要なのは、.webm
ファイルを選ばせる<input type="file">
ですね。
久しぶりにgetElementById
とかを使うな、、、Kotlin/JS
から触る要素にはid
を付けています。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FixSeekableWebmKotlinJs</title>
</head>
<style>
.column {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>
<body>
<div class="column">
<h1>JavaScript の MediaRecorder で作った WebM をシーク可能な WebM ファイルに修正する Kotlin/JS</h1>
<p>処理はすべてブラウザ側の JavaScript で完結します。</p>
<form>
<label for="webm_picker">WebM ファイル:</label>
<input type="file" id="webm_picker" accept="video/webm"/>
</form>
<button id="start_button">処理を開始</button>
<p id="status_text">進捗</p>
</div>
<script src="FixSeekableWebmKotlinJs.js"></script>
</body>
</html>
つくりました、CSS
何も分からん、、
Kotlin/JS
なので、もちろんDOM
操作や、alert
、fetch
なんかも出来ます。
JS
のAPI
が無理やりKotlin
に来た感じなので型がところどころdynamic
になってますね。
import kotlinx.browser.document
import kotlinx.browser.window
fun main() {
console.log("Hello, Kotlin/JS! こんにちは")
document
window.fetch()
window.alert()
}
で、まずは選んだファイルを取り出す処理ですね。
https://developer.mozilla.org/ja/docs/Web/API/File_API/Using_files_from_web_applications
App.kt
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLInputElement
import org.w3c.files.Blob
import org.w3c.files.FileReader
import org.w3c.files.get
fun main() {
val buttonElement = document.getElementById("start_button")!!
val statusElement = document.getElementById("status_text")!!
val inputElement = document.getElementById("webm_picker")!! as HTMLInputElement
// ボタンを押した時
buttonElement.addEventListener("click", {
// 選択したファイル
val selectWebmFile = inputElement.files?.get(0)
if (selectWebmFile == null) {
window.alert("WebM ファイルを選択して下さい")
return@addEventListener
}
// JavaScript の FileReader で WebM の ByteArray を取得する
val fileReader = FileReader()
fileReader.onload = {
// ロード完了
statusElement.textContent = "ロード完了"
// なんか適当に値を返しておく必要がある?
Unit
}
// ロード開始
statusElement.textContent = "ロード中です"
fileReader.readAsArrayBuffer(selectWebmFile)
})
}
これでとりあえずWebM
のロード処理ができました。
適当に選ぶとロード完了
って出るはず
JavaScript
のFileReader
はバイト配列の表現にArrayBuffer
を使います。JS
のですね。
一方Kotlin
はバイト配列の表現にByteArray
を使うので、変換しないとさっき書いたWebM
をシーク可能にする処理が使えないです。
で既に同じような人がいたのでそれに乗っかります。
https://youtrack.jetbrains.com/issue/KT-30098
後はさっきのロード完了のあとに書き足していけば良いはず。
というわけでパースして修正して組み立て直してダウンロードまで書きました。やった~
ダウンロードはこの辺参考にしました
https://javascript.keicode.com/newjs/download-files.php
fun main() {
val buttonElement = document.getElementById("start_button")!!
val statusElement = document.getElementById("status_text")!!
val inputElement = document.getElementById("webm_picker")!! as HTMLInputElement
// ボタンを押した時
buttonElement.addEventListener("click", {
// 選択したファイル
val selectWebmFile = inputElement.files?.get(0)
if (selectWebmFile == null) {
window.alert("WebM ファイルを選択して下さい")
return@addEventListener
}
// JavaScript の FileReader で WebM の ByteArray を取得する
val fileReader = FileReader()
fileReader.onload = {
// ロード完了
statusElement.textContent = "ロード完了"
// WebM のバイナリを取る
val webmByteArray = (fileReader.result as ArrayBuffer).asByteArray()
// 処理を始める
// パース処理
statusElement.textContent = "WebM を分解しています"
val elementList = FixWebmSeek.parseWebm(webmByteArray)
// 修正と再組み立て
statusElement.textContent = "WebM へシーク時に必要な値を書き込んでいます"
val seekableWebmByteArray = FixWebmSeek.fixSeekableWebM(elementList)
// ダウンロード
statusElement.textContent = "終了しました"
seekableWebmByteArray.downloadFromByteArray()
// なんか適当に値を返しておく必要がある?
Unit
}
fileReader.readAsArrayBuffer(selectWebmFile)
})
}
/** JS の ArrayBuffer を Kotlin の ByteArray にする */
private fun ArrayBuffer.asByteArray(): ByteArray = Int8Array(this).unsafeCast<ByteArray>()
/** ByteArray をダウンロードさせる */
private fun ByteArray.downloadFromByteArray() {
// Blob Url
val blob = Blob(arrayOf(this), BlobPropertyBag(type = "video/webm"))
val blobUrl = URL.createObjectURL(blob)
// <a> タグを作って押す
val anchor = document.createElement("a") as HTMLAnchorElement
anchor.href = blobUrl
anchor.download = "fix-seekable-webm-kotlinjs-${Date.now()}.webm"
document.body?.appendChild(anchor)
anchor.click()
anchor.remove()
}
どうでしょうか?
ちゃんとシークできるWebM
がダウンロード出来ましたか?
うおおおおお~
ブラウザで録画して、ブラウザでシークできるWebM
が出来るようになった!!!
Firefox
でも動いた
index.html
とKotlin/JS
のjs
ファイルはGradle
のBuild
で出来るはず?
ドキュメント見たけど無さそうでよくわからない。とりあえず出来ているので良いのかな?
もしくは
保存先はここのはず。
インターネットで公開したい!!
静的サイトなので、適当な静的サイトホスティングサービスにアップロードすれば誰でもアクセスして使えるはずです。
でも静的サイト生成にJava
が必要なことってあんまり無いと思うから、そのホスティングサービスのビルド環境に入ってるか分からなくて調べたけど、Netlify / Cloudflare Pages
には入ってそう。
使う際は
Build command
はchmod +x ./gradlew && ./gradlew build
にします。
Build output directory
は/build/dist/js/productionExecutable
です。
今回は疲れたのでデプロイの部分は省略します。
最近はなんとなくAmazon S3 + CloudFront
がマイブーム(?)なのでそこに上げます。
詳しくは前書いたからそっち見て、ビルドコマンドとかが違うだけで使い回せると思う:
https://takusan.negitoro.dev/posts/aws_sitatic_site_hosting/
せっかくなので画面録画機能も合体させました。
JavaScript
で出来た画面録画のコードをそのままKotlin/JS
で呼べるようにしただけです。
Kotlin/JS
の定義にMediaRecorder
が無くてなんかJavaScript
コードを埋め込む感じになっちゃった。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kotlin/JS で作った WebM ツール</title>
</head>
<style>
.column {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>
<body>
<div class="column">
<h1>JavaScript の MediaRecorder で画面録画するやつ。</h1>
<p>処理はすべてブラウザ側の JavaScript で完結します。WebM
ファイルが生成されます。そのままではシークできないので、下のツールを使ってください。</p>
<button id="record_button">録画開始・終了</button>
<h1>JavaScript の MediaRecorder で作った WebM をシーク可能な WebM ファイルに修正する Kotlin/JS。</h1>
<p>処理はすべてブラウザ側の JavaScript で完結します。</p>
<form>
<label for="webm_picker">WebM ファイル:</label>
<input type="file" id="webm_picker" accept="video/webm"/>
</form>
<button id="start_button">処理を開始</button>
<p id="status_text">進捗</p>
<a href="https://github.com/takusan23/FixSeekableWebmKotlinJs">ソースコード</a>
</div>
<script src="FixSeekableWebmKotlinJs.js"></script>
</body>
</html>
fun main() {
setupScreenRecorde()
setupWebmSeekable()
}
/** 画面録画のやつの初期化 */
private fun setupScreenRecorde() {
val recordButtonElement = document.getElementById("record_button")!!
var mediaStream: MediaStream? = null
var mediaRecorder: dynamic = null
/** 録画データ */
val chunks = arrayListOf<Blob>()
/** 録画中か */
var isRecording = false
fun startRecord() {
// ここから先 Kotlin/JS 側で定義がなかったため全部 dynamic です。こわい!
val mediaDevicesPrototype = js("window.navigator.mediaDevices")
// Promise です
mediaDevicesPrototype.getDisplayMedia(
json(
"audio" to json("channelCount" to 2),
"video" to true
)
).then { displayMedia: MediaStream ->
mediaStream = displayMedia
// どうやら変数とかはそのまま文字列の中に埋め込めば動くらしい。なんか気持ち悪いけど動くよ
mediaRecorder = js(""" new MediaRecorder(displayMedia, { mimeType: 'video/webm; codecs="vp9"' }) """)
// 録画データが細切れになって呼ばれる
mediaRecorder.ondataavailable = { ev: dynamic ->
chunks.add(ev.data as Blob)
Unit
}
// 録画開始
mediaRecorder.start(100)
isRecording = true
// 適当に値を返す
Unit
}.catch { err: dynamic -> console.log(err); window.alert("エラーが発生しました") }
}
fun stopRecord() {
// 録画を止める
isRecording = false
mediaRecorder.stop()
mediaStream?.getTracks()?.forEach { it.stop() }
// Blob にして保存
Blob(chunks.toTypedArray(), BlobPropertyBag(type = "video/webm"))
.downloadFile(fileName = "javascript-mediarecorder-${Date.now()}.webm")
}
recordButtonElement.addEventListener("click", {
if (!isRecording) {
startRecord()
} else {
stopRecord()
}
})
}
/** WebM をシーク可能にするやつの初期化 */
private fun setupWebmSeekable() {
val buttonElement = document.getElementById("start_button")!!
val statusElement = document.getElementById("status_text")!!
val inputElement = document.getElementById("webm_picker")!! as HTMLInputElement
// ボタンを押した時
buttonElement.addEventListener("click", {
// 選択したファイル
val selectWebmFile = inputElement.files?.get(0)
if (selectWebmFile == null) {
window.alert("WebM ファイルを選択して下さい")
return@addEventListener
}
// JavaScript の FileReader で WebM の ByteArray を取得する
val fileReader = FileReader()
fileReader.onload = {
// ロード完了
statusElement.textContent = "ロード完了"
// WebM のバイナリを取る
val webmByteArray = (fileReader.result as ArrayBuffer).asByteArray()
// 処理を始める
// パース処理
statusElement.textContent = "WebM を分解しています"
val elementList = FixWebmSeek.parseWebm(webmByteArray)
// 修正と再組み立て
statusElement.textContent = "WebM へシーク時に必要な値を書き込んでいます"
val seekableWebmByteArray = FixWebmSeek.fixSeekableWebM(elementList)
// ダウンロード
statusElement.textContent = "終了しました"
Blob(arrayOf(seekableWebmByteArray), BlobPropertyBag(type = "video/webm"))
.downloadFile(fileName = "fix-seekable-webm-kotlinjs-${Date.now()}.webm")
// なんか適当に値を返しておく必要がある?
Unit
}
// ロード開始
statusElement.textContent = "ロード中です"
fileReader.readAsArrayBuffer(selectWebmFile)
})
}
/** JS の ArrayBuffer を Kotlin の ByteArray にする */
private fun ArrayBuffer.asByteArray(): ByteArray = Int8Array(this).unsafeCast<ByteArray>()
/** [Blob]をダウンロードする */
private fun Blob.downloadFile(fileName: String) {
// Blob Url
val blobUrl = URL.createObjectURL(this)
// <a> タグを作って押す
val anchor = document.createElement("a") as HTMLAnchorElement
anchor.href = blobUrl
anchor.download = fileName
document.body?.appendChild(anchor)
anchor.click()
anchor.remove()
}
Kotlin/JS
良いですね。プロジェクトの作り方分からんのだけはどうにかして欲しい。
JavaScript
でバイナリ操作するのやったこと無いからKotlin
で書いてみたけど、こういう使い方が良いのかな。あと既にKotlin
のコードがあってブラウザで動かしたいとか?
MediaRecorder
みたいにJavaScript
全部のAPI
があるわけじゃないらしい(?)ので気をつけないといけない。。。
ちなみに、js()
の文字列は定数である必要があるので、変数埋め込みとか使うと怒られる。
が、が、が、展開されるので、名前があっていれば変数等にそのままアクセスできます。
val displayMedia = ...
// 変数等を渡したい場合は、テンプレートではなく変数名をそのまま JavaScript のコードの中に埋め込めばおっけー
val mediaRecorder = js(""" new MediaRecorder(displayMedia, { mimeType: 'video/webm; codecs="vp9"' }) """)
もう疲れたのでやり残したこととか
説明難しいから擬似コードで書きますが、、
こんな感じに、Cluster
も(他の親要素もですが)ByteArray
を持っているんですよね。
このByteArray
、子要素全体のByteArray
になっているので、この後に続く子要素分も足すとメモリが二倍消費になっているはずです。
同じデータ(ByteArray
)をメモリに2つも持ってるのでめっちゃよろしく無い実装かもしれないです。
Element(Cluster, [0x00,0x00,0x00,0x00,0x00]) // Cluster の子要素全体 の ByteArray。この後に続く SimpleBlock の分まで持ってる
Element(Timestamp, [0x00,0x00,0x00,0x00,0x00]) // 各子要素 の ByteArray
Element(SimpleBlock, [0x00,0x00,0x00,0x00,0x00])
Element(SimpleBlock, [0x00,0x00,0x00,0x00,0x00])
Element(SimpleBlock, [0x00,0x00,0x00,0x00,0x00])
DataSize
の大きさだけByteArray
を取り出そうとすると足りない時がある(私のパーサーの実装ミスかも)
とりあえず次の要素のパースの前に後続が 3 バイト以上あるか確認するようにしました。
(ID
+DataSize
+Data
でそれぞれ最低 1 バイトずつ使えば 3 バイトになるはず)
ありがとうございます!!!