たくさんの自由帳

JavaScriptでパソコンの画面録画とMPEG-DASHでライブ配信

投稿日 : | 0 日前

文字数(だいたい) : 13235

KotlinDASHJavaScript
Twitterで共有GitHubで開く

どうもこんにちは
Dreamin'Her -僕は、彼女の夢を見る。- 攻略しました。(全年齢)

声優さんが良かったです(詳しくないので分からないですが) Imgur

中盤がけっこう?重いのですがエンディングはよく出来てていいと思います。

タイトル通りだ...

Imgur

Imgur

Imgur

あとOP曲がめっちゃいい。
作品とリンクしてる!!!

せめて 私を 過去にして 今紡ごう 未来を ...

OP曲のおやすみモノクローム、めっちゃいい

(Steamで買えます。)

本題

最近のブラウザって数行かけば画面の録画が出来るらしいんですよね、試してみます。
前回の記事の副産物になります これ

ブラウザで録画するまで

  • getDisplayMediaで画面録画のMediaStreamを取得
  • MediaRecorderの入力にする
  • webmが出来る

なんやこれ...Androidでやるより簡単! ( https://takusan23.github.io/Bibouroku/2020/04/06/MediaProjection/ )、JSすげ~

流石にPC版にしか実装されてませんでしたが、そりゃそうか→ https://caniuse.com/?search=getDisplayMedia

環境

なまえあたい
Windows10 Pro 21H2 (Win11のコンテキストメニュー使いにくいのどうにかならないの;;)
Chrome105

多分localhostみたいな内部サーバーを立てなくても、fileスキーマで動く...?

ブラウザでパソコンの画面を取得する(ミラーリング)

<video>でパソコンの画面をミラーするだけならこれだけで動きます、何やこれ一体...

Imgur

これだけ(Promiseがリジェクトされた場合などは見てないですが)でパソコンの画面がvideo要素内で再生できています!

Imgur

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>getUserMedia</title>
</head>

<body>
    <div class="parent">
        <button id="rec_button">録画開始</button>
        <video id="video" width="640" height="320" muted autoplay />
    </div>
</body>

<style>
    .parent {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
    }
</style>

<script>
    const recordButton = document.getElementById('rec_button')
    const videoElement = document.getElementById('video')

    // 録画を開始して、canvasに描画する
    const startRec = async () => {
        // 画面をキャプチャーしてくれるやつ
        const displayMedia = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true })
        // とりあえず video要素 で再生
        videoElement.srcObject = displayMedia
    }

    // 録画ボタン投下時
    recordButton.onclick = () => {
        startRec()
    }

</script>

</html>

パソコンの画面を録画する

MediaRecordergetDisplayMedia()の返り値を入れることで、録画もできます!

参考になりました!
https://qiita.com/miyataku/items/6ed855a7fb7507ccc244

ちゃんとダウンロードできる

Imgur

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>getUserMedia</title>
</head>

<body>
    <div class="parent">
        <button id="rec_button">録画開始</button>
        <button id="stop_button">録画終了</button>
        <video id="video" width="640" height="320" muted autoplay />
    </div>
</body>

<style>
    .parent {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
    }
</style>

<script>

    // @ts-check

    // 今回利用するコンテナフォーマット、コーデック
    const MIME_TYPE = `video/webm; codecs="vp9,opus"`
    // 録画するやつ
    let mediaRecorder
    // WebMデータが細切れになって来るので一時的に保存する
    let chunks = []

    const recordButton = document.getElementById('rec_button')
    const stopButton = document.getElementById('stop_button')
    const videoElement = document.getElementById('video')

    // 録画を開始して、canvasに描画する
    const startRec = async () => {
        // 画面をキャプチャーしてくれるやつ
        const displayMedia = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true })
        // パソコンの画面を流す
        mediaRecorder = new MediaRecorder(displayMedia, { mimeType: MIME_TYPE })
        // 録画データが着たら呼ばれる
        mediaRecorder.ondataavailable = (ev) => {
            chunks.push(ev.data)
        }
        // 録画開始。100msごとに ondataavailable が呼ばれてデータが貰えるように。
        mediaRecorder.start(100)
        // とりあえず video要素 で再生
        videoElement.srcObject = displayMedia
    }

    // 録画ボタン投下時
    recordButton.onclick = () => {
        startRec()
    }

    // 終了ボタン投下時
    stopButton.onclick = () => {
        // 録画を止める
        mediaRecorder.stop()
        // BlobUrlを作成し、a要素でリンクを作りJSで押すことでダウンロードさせる(DL出来るんだ!
        const blob = new Blob(chunks, { type: MIME_TYPE })
        const blobUrl = URL.createObjectURL(blob)
        const aElement = document.createElement('a')
        aElement.style.display = 'none'
        aElement.href = blobUrl
        aElement.download = `record-${Date.now()}.webm`
        document.body.appendChild(aElement)
        aElement.click()
        // TODO BlobUrlのリソース開放
    }

</script>

</html>

ちなみにこれで生成されるwebmは再生時間がヘッダー部分に入っていないため、シークが遅いらしい。
ここまでのソースコードおいておきます

https://github.com/takusan23/browser-screen-record

index.htmlをコピペしてブラウザで開けば使えると思います。

しくみ

MediaRecorderondataavailableは録画したデータが貰えるコールバックになってます。
MediaRecorder#start()の引数に時間をミリ秒で入れると(上記だと100ミリ秒(0.1秒))、その時間の間隔でondataavailableが呼ばれます。
これを配列に順次保存して、最後に結合してダウンロードしています。

つまり、ondataavailableで貰えるデータを他のブラウザとかに何らかの方法(WebSocketとか)で送信できれば、ローカルライブ配信の完成になります。!!!

ちなみに、ondataavailableはあくまでもデータを分割してるだけなので、全部揃わないと動画ファイルとしては成り立たないです。
最初の動画は単独で再生できますが、2個目以降はメタデータ?が入ってないので再生できません。
(もしかすると最初のヘッダー部分があればなんか再生できるかもしれないです...)

これができれば WebM でライブ配信が出来るのでは?

前回の記事 のAndroidと違い、サーバー側が必要ですが似たようなことができそうなのでやってみます。

環境

なまえあたい
サーバー側言語Kotlin (Ktor使いたい...!)
サーバー側技術Ktor
フロント側技術dash.js

WebM / MPEG-DASH でライブ配信

MPEG-DASHで出来るのですが、ただWebMを公開すればいいというわけではなく初期化セグメントメディアセグメントに分ける必要があります。
が、今回は面倒なので最初に出てきたwebmを初期化セグメントとして使おうかなと
ちなみに初期化セグメントは多分Clusterの開始タグ0x1F 0x43 0xB6 0x75)の前までです。ちゃんとやるならその範囲だけのファイル(init.webm)みたいなのを作るべきだと思います。

詳しくは 前回の記事

ちなみ に最初に出てきたwebmを初期化セグメントとして利用できる理由

単純にClusterの開始タグより前の部分が含まれているから。
デコーダーの起動に必要なメタデータが含まれているのが、最初に呼ばれるondataavailableにはある。

(2個目以降には含まれていないため、2個目以降のバイナリを渡したところで再生できない;;)

Ktorで適当にAPIをつくる

バックエンドは何も詳しくないので...

  • /api/upload
    • WebフロントのMediaRecorderondataavailableが呼ばれたらバイナリを送るAPI。POST
    • よく知らんけど multipart-formdata にする
  • /
    • index.htmlを返す、視聴ページ 兼 録画ページ
  • manifest.mpd
    • MPEG-DASHのマニフェストを返します。dash.jsに渡す
  • segment1.webmsegment2.webm...
    • /api/uploadのファイルを保存しているフォルダを静的配信する
    • Ktorのstatic公開機能で

サクサクっと作る

適当にIDEAでプロジェクトを作ります。
Ktor、簡単にWebサーバーが立てれていい感じ。バックエンドよく分からんけど;;

Imgur

ライブラリを入れる

build.gradle.ktsに書き足します。

dependencies {

    // Ktor
    val ktorVersion = "2.1.1"
    implementation("io.ktor:ktor-server-core:$ktorVersion")
    implementation("io.ktor:ktor-server-netty:$ktorVersion")

    testImplementation(kotlin("test"))
}

Main.kt

なんかMain.ktがドメインのパッケージに居ない(と言うかドメインのパッケージすら無い)ので作って移動させます。
Kotlinだといらないんですかね(そんな事ある?)

ドメイン名.アプリ名みたいな感じのパッケージを作って移動させました。io.github.takusan23.browserdashmirroring

Imgur

package io.github.takusan23.browserdashmirroring

import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.http.content.*
import io.ktor.server.netty.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.io.File

fun main(args: Array<String>) {

    // 映像の保存先
    // プロジェクトのフォルダに作る
    // Node.js の process.cwd() みたいな
    val projectFolder = System.getProperty("user.dir")
    val segmentSaveFolder = File(projectFolder, "static").apply {
        listFiles()?.forEach { it.delete() }
        mkdir()
    }

    // セグメントのインデックス
    var index = 0

    println("http://localhost:8080")

    embeddedServer(Netty, port = 8080) {
        routing {
            // プロジェクトの resources フォルダから取得
            // index.html を返す
            resource("/", "index.html")
            // マニフェストを返す
            resource("/manifest.mpd", "manifest.mpd")

            // フロント側からWebMの細切れが送られてくるので保存していく
            post("/api/upload") {
                // Multipart-FormDataを受け取る
                call.receiveMultipart().forEachPart { partData ->
                    if (partData is PartData.FileItem) {
                        // ファイルを作って保存
                        File(segmentSaveFolder, "segment${index++}.webm").apply {
                            createNewFile()
                            writeBytes(partData.streamProvider().readAllBytes())
                        }
                    }
                }
                call.respond(HttpStatusCode.OK, "保存できました")
            }

            // 静的ファイル公開するように。動画を配信する
            static {
                staticRootFolder = segmentSaveFolder
                files(segmentSaveFolder)
            }
        }
    }.start(true)
}

フロントが投げてきたデータは上記の例だとここに保存されます。

Imgur

index.html

resourcesに置きます。こ↑こ↓です

Imgur

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>getUserMedia</title>
    <!-- MPEG-DASH 視聴用 -->
    <script src="https://cdn.dashjs.org/latest/dash.all.debug.js"></script>
</head>

<body>
    <div class="parent">
        <button id="live_button">配信開始</button>
        <button id="watch_button">視聴開始</button>
        <video id="video" width="640" height="320" muted autoplay />
    </div>
</body>

<style>
    .parent {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
    }
</style>

<script>

    // @ts-check

    // 今回利用するコンテナフォーマット、コーデック
    const MIME_TYPE = `video/webm; codecs="vp9"`
    // 録画するやつ
    let mediaRecorder
    // WebMデータが細切れになって来るので一時的に保存する
    let chunks = []
    // 映像を送る間隔
    const SEND_INTERVAL_MS = 3_000

    const recordButton = document.getElementById('live_button')
    const watchButton = document.getElementById('watch_button')
    const videoElement = document.getElementById('video')

    // サーバーに映像を送る
    const sendSegment = (segment) => {
        const form = new FormData()
        form.append('data', segment)
        fetch('/api/upload', { method: 'POST', body: form })
    }

    // 録画を開始して、canvasに描画する
    const startRec = async () => {
        // 画面をキャプチャーしてくれるやつ
        const displayMedia = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true })
        // パソコンの画面を流す
        mediaRecorder = new MediaRecorder(displayMedia, { mimeType: MIME_TYPE })
        // 録画データが着たら呼ばれる。サーバーに送る
        mediaRecorder.ondataavailable = (ev) => {
            sendSegment(ev.data)
        }
        // 録画開始
        mediaRecorder.start(SEND_INTERVAL_MS)
        // とりあえず video要素 で再生
        videoElement.srcObject = displayMedia
    }

    // 配信ボタン投下時
    recordButton.onclick = () => {
        startRec()
    }

    // 視聴ボタン投下時
    // dash.js による MPEG-DASH の再生を試みる
    watchButton.onclick = () => {
        const url = "/manifest.mpd";
        const player = dashjs.MediaPlayer().create();
        player.initialize(videoElement, url, true);
    }

</script>

</html>

manifest.mpd

これもresourcesに置きます。
とりあえず動いたのを書いてるので、多分なんか無駄なことしてると思います。

<?xml version="1.0" encoding="utf-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" maxSegmentDuration="PT3S" minBufferTime="PT3S" type="dynamic" profiles="urn:mpeg:dash:profile:isoff-live:2011,http://dashif.org/guidelines/dash-if-simple">
  <BaseURL>/</BaseURL>
  <Period start="PT0S">
    <AdaptationSet mimeType="video/webm">
      <Role schemeIdUri="urn:mpeg:dash:role:2011" value="main" />
      <SegmentTemplate duration="3" initialization="/segment0.webm" media="/segment$Number$.webm" startNumber="1"/>
      <Representation id="default" codecs="vp9"/>
    </AdaptationSet>
  </Period>
</MPD>

起動

main関数の再生ボタンみたいなのを押すと起動できます。
←これ

Imgur

http://localhost:8080を開き、配信開始を押します。配信でもプレビューが流れます。
数秒後にもう一つブラウザでhttp://localhost:8080を開き、今度は視聴開始を押します。これで配信側の映像が流れてくると思います。

スマホでも視聴なら出来るはず。

Imgur

すごい!!サーバー側は仲介しかして無いのになんちゃってライブ配信が完成しました!

ちなみに配信を終え、再度配信するためにはセグメントフォルダの中身を消すのと、インデックスを0に戻す必要があります。

Q&A

iOS と iPad OS で再生できますか

  • iPad OS なら再生できると思います
  • iOS は MediaSource Extensions APIに対応すれば動くと思います。

詳しくは 前回の記事

参考になりました

助かります!!!

おわりに

お疲れ様でした、ノシ 888888

ソースコードです

https://github.com/takusan23/BrowserDashMirroring