コンテンツにスキップ

放送波ストリーミングの仕組み

映像・音声・字幕・データ放送が入っています。入れた順序・採用しなかったもの・残っているリスク・ 決めたときの測定は stream-history.md にあります。

項目 内容
デプロイ形態 セルフホスト(1ユーザー = 1サーバ)
アプリ SvelteKit 単一アプリ(配信・視聴の両方を担当)
入力ソース 放送波 MPEG-TS(ISDB-T / ISDB-S)
コーデック H.264 + AAC (既定) / AV1 + Opus
必須機能 ARIB字幕、データ放送(双方向含む)、複数音声・映像ストリーム

セルフホストなので、NVRAM・地域設定・リモコン設定のユーザーごとの分離と、通信系コンテンツの中継での ユーザー識別は設計で考えない(受信機 = サーバ = ユーザーが 1:1:1)。

┌─────────────────────────────────────────────────────────────────┐
│ チューナー (エージェント) │
└────────────────────────────┬────────────────────────────────────┘
│ MPEG-TS (チャンネル丸ごと)
▼
┌──────────────────────────────┐
│ SvelteKit サーバ │
│ ・1局に絞る (ServiceFilter) │
│ ・プロセス管理/再起動 │
│ ・WebSocket ハブ │
│ ・BML通信系プロキシ │
└───┬──────────────────────┬───┘
│ 1局ぶんの TS │ (押した人がいるときだけ)
▼ ▼
┌───────────────────────────┐ ┌──────────────────┐
│ ffmpeg │ │ BmlDecoder │
│ ├→ pipe:1 fMP4 (映像音声) │ │ カルーセルを │
│ └→ pipe:3 mkv (字幕) │ │ 組み立てる │
└─────────────┬─────────────┘ └────────┬─────────┘
└───────────┬─────────────┘
▼
┌──────────────────────────────┐
│ SvelteKit サーバ (配る) │
└──────────────┬───────────────┘
│ WebSocket (メディア + サイドチャネル)
▼
┌──────────────────────────────┐
│ ブラウザ (SvelteKit client) │
│ ・MSE 直接 → 映像音声 │
│ ・canvas に描く → 字幕 │
│ ・自前のエンジン → データ放送│
└──────────────────────────────┘

映像と字幕は同じ ffmpeg の2つの出口。 別々に起こすと時刻が揃わない(字幕)。 データ放送へは、絞った TS を denpa の中で分ける。

メディアとメタデータは同じコンテナに載らない。 ARIB字幕(B24 PES)もデータ放送(DSM-CC カルーセル + PSI/SI)も、 fMP4 / WebM には規格上入れられず、ffmpeg も GStreamer も扱えない。そこで:

字幕はサーバでデコードして、描くのは受け側

Section titled “字幕はサーバでデコードして、描くのは受け側”

置き場所を決めるところまではサーバで、描くのはブラウザ (とテレビのアプリ) で。 送るのは 「どの字を・どこに・どの大きさで・何色で」まで決めた文字の配置 (JSON。api.md)。

libaribcaption は「デコードする (decoder)」と「描く (renderer)」が分かれていて、置き場所・大きさ・色・外字の 置き換えはデコードする側で決まる。デコードする側を TypeScript に写して (src/lib/ts/b24caption.ts) サーバで回し、 描く側だけを受け側に持つ。全画面でも画面の画素で描け、1枚は数百バイト〜1KB で済む。テレビのアプリは描く側を Kotlin で持つ (denpa-tv 0.16.0 から)。画像 (PNG) を配っていた頃との比較は stream-history.md。

外字 (DRCS) は置き換え表で字にできるものは字に、できないものは画像で送り (1字 3.5KB ほど)、受け側が字の枠いっぱいに引き伸ばす。 放送の外字は 16〜36 画素の白黒の画像で、そのまま伸ばすと1点が 4x4 の階段になる。 サーバで斜めを丸めた濃淡の画像にしてから送る (src/lib/ts/drcs-smooth.ts。水増しされた行・列を畳み、Scale2x で斜めを 細かくし、4x4 ずつ平均して縁に中間の濃さを付ける。16 階調・一辺 64〜256)。 録画には放送の字幕がそのまま入る (S_ARIBSUB) ので同じ描き方で観られる。PGS の画像が入った古いエンコード済みの録画の字幕は読まない。

時刻は字幕に添えられたものをそのまま使う。映像と同じ ffmpeg が付けたので、受け側の 再生位置と直に比べられる(時刻同期)。

役割 採用 備考
エンコード FFmpeg (設定でコーデックのエンコーダに GPU を選んであれば、ライブも録画も GPU でエンコードする — encode.md) CLI パイプラインで差し替え可能
字幕 自前 (ts/b24caption.ts。libaribcaption のデコーダを写したもの) ffmpeg は -c:s copy で写すだけ
データ放送 自前 (src/lib/bml/。デコードする側は ts/bml.ts) データ放送の統合
再生 MSE 直接 H.264+AAC (既定) / AV1+Opus の fMP4。互換モードは WebCodecs で、箱は自前で読む (ts/fmp4.ts・ts/mkv-media.ts)

tsreadex・mpegts.js・aribb24.js・GStreamer を使わない理由は 採用しなかったもの。

コーデックは段階ではなく選択にする。 既定は H.264 + AAC (どの端末でも再生できる) で、 AV1 + Opus はプレイヤーのコーデックのメニューで選ぶ。エンコーダとコンテナ以外は共通なので、字幕もデータ放送もそのまま動く。

画面 /live。左に映像、右に局の一覧。前回見ていた局から開く
エンコード方式 H.264 + AAC / AV1 + Opus を見ながら選べる(エンコード)。src/lib/server/live.ts
運び方 WebSocket 1本(WebSocket プロトコル)
チューナー 録画にだけ譲り、番組表・ロゴ・スキャンからは取り上げる(録画に譲る)
共有 同じ局を2人で見ても、チューナーも ffmpeg も1つ
変換せずに送る コーデックで MPEG-2 を選ぶとエンコードせずに送る(端末ごとに覚える)。放送そのまま(MPEG-2)を送る
畳み方 見ている人が0になったら離す。接続そのものが在席の印
画面の外へ GET /api/services/<id>/live で同じものを HTTP で流す(同じ取り合い・共有。字幕・データ放送は載せない。api.md)

共有の単位は局・音声・エンコード方式・字幕。 1つの物理チャンネルに複数の局が入っていて、 ffmpeg には局を名指しさせている(渡す前に1局へ絞る)ので、映像は局ごとに違う。 二カ国語を主音声で見る人と副音声で見る人も別のものをエンコードし、字幕も何本目を選ぶかで別になる。 チューナーはエージェント側で共有され、増えるのは ffmpeg だけ。

多重音声はサーバで1本に決める

Section titled “多重音声はサーバで1本に決める”

「多重音声」には中身の違う2通りがある。 見る人にはどちらも「音声を選ぶ」操作なので、 1つの一覧に平らに並べる(arib.audioTracks)。

中身 分け方
デュアルモノ 音声は1本。左に主音声・右に副音声(二カ国語・解説放送) -af pan=stereo|c0=c0|c1=c0(副音声は c1)
複数の音声 音声そのものが2本以上 -map 0:p:<局>:a:<何本目>

デュアルモノは1本から3つ(主・副・主+副)出てくる。テレビの「音声切換」と同じ3択。

選ぶのはサーバ側。 複数の音声を入れた fMP4 を MSE で切り替えられないブラウザがある(audioTracks の実装がまちまち)。 エンコードするときに1本に決めれば、どのブラウザでも同じに鳴る。選び直すと再エンコードになるが、チューナーは確保したまま。

選べるものは繋いだあとでないと分からない。 二カ国語の映画が終わればステレオに戻るので、選局のたびに現物を tuned に載せて送る。覚えていた音声が選べなくなったら先頭に落とす(arib.pickTrack)。落とさないと番組が変わった瞬間に音が消える。

止めたりシークバーを戻したりしてわざと遅れて見ている状態を、ライブと分けて持つ。

ライブ 追っかけ
大きく離れたら 飛んで詰める(別のタブから戻ったとき) 飛ばない(見ようとしていた場面が飛ぶ)
速さ 自動(1.05倍まで) 選べる(1 / 1.25 / 1.5 / 2倍)
追いついたら — 自分でライブに戻る(選んだ速さのまま進むと放送を追い越し、貯まりを使い切って止まる)

録画中の録画を頭から観る /chase/<id> も同じ仕組みで流す(server/chase.ts が伸びる元のTSを 追い読みする。チューナーは使わない)。ここでいう「追っかけ」とは別の話。

開いてから映像が出るまでの内訳

Section titled “開いてから映像が出るまでの内訳”

実機(PT3)で割った。チューナーが温まっていれば ≈0.89 秒、冷えていれば頭に選局の 0.66 秒が付く。

画面が動き出してチケットを取り、WebSocket が繋がるまで 160ms
ffmpeg の立ち上がり(固定) 530ms
放送の I フレーム待ち 0〜501ms(GOP はちょうど 501ms)
貯めてから始める(pacing.ts の FLOOR) 200ms
MSE に入れて1枚映るまで 40ms

貯める量はこちらの都合で動かせる唯一のところ。 再生は「貯まった尺 ≧ 貯める量」かつ「映像を出せるようになっている」 (貯まっただけでは始めない)で始まる。0.2 秒 = 塊4つぶん(サーバは 0.05 秒ずつ出す)。 足りずに止まれば増え、荒れる経路では自分で伸びるので、小さいほうから始めてよい。

冷えているときの選局 0.66 秒は、選局の ioctl まで 42ms・復調のロック 320ms・ロックから denpa の1バイト目まで 240ms (PAT → PMT → ECM → カードの往復)。削れたのは 160ms だけで、開く局はページを組む時点で決まっているので、画面が 繋いでくる前にサーバ側でエンコードし始める(server/live.ts の warm)。その 160ms が ffmpeg の 530ms と重なる。

削れなかった残りは放送か機材か ARIB の都合(試して外したものは stream-history.md):

  • ロックの 320ms はチップの物理時間。 ドライバを替えても同じ
  • ロック後の 240ms はスクランブルの復号。 ECM が来るまで鍵が作れない
  • I フレーム待ちは放送の GOP。 MPEG-2 は I フレームからしかデコードを始められない
  • エンコーダは遅れの原因ではない。 -tune zerolatency は B フレームも先読みも作らない

一瞬止まって遅延が増えていく

Section titled “一瞬止まって遅延が増えていく”

宅内でちょうど60秒ごとに一瞬止まり、そのたびに遅れが増えて戻らなかった。貯まりが毎回 「3.1秒 → 1.05倍で詰めて 0.3秒 → 止まる」で、縮める先をいつも FLOOR にしていたのが原因だった。

止まった高さのすぐ上を次の下限にする(pacing.ts の PROBE)。下限を下げ直すのは 10分無事が続いてから(FORGET)。 これと -maxrate を外したことで、途切れは 8分に10回から 10分に1回へ、貯まりは 0.6秒で平らになった (測った経過は stream-history.md)。 宅内なら帯域は余っていて、-maxrate で削られるのは動きの多い場面の画質になる。細い経路には AV1 を選ぶ。

映像だけが遅れる(音と字幕は合っている)

Section titled “映像だけが遅れる(音と字幕は合っている)”

字幕は再生位置(音が刻む時計)に合わせて貼っているので、映像だけが遅れるなら遅れているのはデコードで、 運び方でも貯め方でもない。ブラウザは遅れたまま出し続けるので開き直すまで溜まり、放送との差の数字にも出ない。

requestVideoFrameCallback の mediaTime と再生位置の差が「映像だけの遅れ」になる。0.1秒(口の動きの ずれが分かりはじめるところ)を超えたらその場へ飛び直す(live-player.svelte.ts の unslip。3秒に1回まで)。 飛んで起きた詰まりは数えない。数えると映像の遅れを直すたびに貯める量が増える。

  • 測れないこともある。 mediaTime はコンポジタへ渡した時点のコマの時刻で、Windows の Chrome / Edge は映像を 別の面(DirectComposition の overlay)に出すので、面のほうが遅れても数字に出ない。そこで途切れのたびにも飛び直す (元手は1コマぶんで、映像が止まった直後なので目立たない)
  • コマ落ちは合図にしない。 Windows Edge では落ち着いたあとも絶えず 5.6% 落ち、合図にすると飛び続ける

画面の「コマ落ち」「映像の遅れ 最大」「映像の遅れを補正 N回」は、運び側(途切れ)とデコード側を分けて読むためのもの。 「映像の遅れ 最大」が 0 のままなのに映像と音が合わないなら、遅れているのは面のほう。

画面に出す2つは意味が違います。

出す名前 何の差 入っているもの
放送から N秒 隣に置いたテレビが映すはずの時刻と、いま映しているコマの差 端から端まで(ffmpeg のエンコード・回線・手元の貯まり)
貯まり N秒 buffered.end - currentTime 手元にあと何秒ぶん持っているかだけ

放送の PCR と映像の PTS は同じ時計で打たれている。PCR をサーバが受け取った時刻と組にして覚えれば (ts/clock.ts の BroadcastClock)、「このコマは隣のテレビなら何時何分に映るか」が サーバの時計の上で言える。エンコードしたものの物差しへは、ffmpeg が入口で 0 に寄せたぶん (入口の Duration: N/A, start: 72575.147089)を引くだけ(出口の寄せ幅が入口の start: と同じことは実機で確かめた。差 2ms)。

  • 遅れて読んだ組は採らない。 受け取った時刻 − PCR がいちばん小さい組を採る。PCR とサーバは別の水晶 なので 30秒ごとに選び直す(直前の区切りのぶんも残して比べる)
  • 端末の時計は当てにしない。 通知にサーバの時計を添えて差を覚え、以降はサーバの時計の上で数える (LAN の片道は 1ms 未満)。サーバの NTP がずれていても数字は変わらない
  • チューナーの中とエージェント→サーバの時間は入らない(測っていない)
  • 放送の時刻 (TDT/TOT) とは組にしない。 TDT は局ごとに固定でずれている(11局で -0.72〜+0.90秒、2026-09-26 実測。 data.md と同じ結論)

貯まりより小さく出ることはある

Section titled “貯まりより小さく出ることはある”

テレビも PTS まで待って映す。 放送の映像は着いてから映すまで 0.41〜0.50秒(音声は 0.12〜0.15秒) 待たせる作りで、着いた端からエンコードする denpa はそのぶん先回りできる:

放送から = エンコードの完了 + 回線 + 貯まり − (テレビが待つぶん 0.4秒ほど)

待つぶんはどの局もほぼ同じなので、局どうしの比較には使える。

貯まっただけでは始めない(音だけ先に出る)

Section titled “貯まっただけでは始めない(音だけ先に出る)”

MSE を作り直した直後はデコーダが立ち上がっておらず、重ねてあるのは前の局の静止画なので、貯まりの長さだけで play() を 呼ぶと映像が止まったまま新しい局の音が鳴る。readyState が HAVE_CURRENT_DATA に上がるまで待てば一緒に始まる (live-player.svelte.ts の canStart)。映像の無い放送やデコードの失敗で音まで出なくなるほうが困るので、1.5秒で諦めて音だけ先に出す。

新しい局の1枚目が少し止まるのは、直さない

Section titled “新しい局の1枚目が少し止まるのは、直さない”

新しい局の1枚目が 150ms ほど止まって見える。意図した姿で、不具合ではない。 止まっている間は貯まりが target(最短 0.2秒)に届くのを待っていて、止まって見える長さ ≒ 貯める量。その間に見せるのは前の局の映像(音は新しい局) ではなく、新しい局の1枚目(音はまだ出ない)。見えるものと聞こえるものが食い違わないから。

下限は端末に覚えさせている(live-player.svelte.ts の FLOOR_KEY)。毎回 0.2 秒から始めると、開いて30秒以内に必ず1回止まる。 置き場が端末なのは、これが経路の性質だから(同じ人でも宅内の PC と出先のタブレットでは値が違う)。

長く無事なら 10分ごとに 0.3 秒ずつ下げる(ts/pacing.ts の FORGET / PROBE)ので、線を繋ぎ替えた直後は前の経路の値のまま 余計に遅れる。そのために覚えたものを捨てるボタンも出してある(relearn。覚えているものがあるときだけ)。押すと覚えたものを消し、 貯めているぶんも下限まで詰めて放送の今へ戻す。低すぎる側に外れたぶんは放っておいてよい。止まればその場で上がる。

1つの物理チャンネルに複数の局が入っている。 -map 0:v:0 は「最初に見つけた映像」でしかないので、 -map 0:p:<局>:v:0 で局を名指しする。

名指しだけでは足りない。ffmpeg は名指しした局を -probesize のぶん読む間に見つけられなければそのまま終了する (3局が入っている T15 で、丸ごと渡すと 120KB で 1/3 しか通らず、1局に絞ると 20KB でも 3/3)。 録画と同じ ServiceFilter を通して局が1つだけの TS を渡せば、局を探す仕事が消える。字幕も見つけさせるので probesize は 100KB。

  • 量はほとんど減らない(tvk 17.6 → 16.8 Mbit/s)。落ちるのは他局の表と詰め物だけ。狙いは ffmpeg に局を探させないこと
  • 名指しは絞ったあとも残す。 万一違う局が入っていたら、黙って別の局を映すより落ちるほうがいい
  • コマ数は -fpsmax で上限を言う。 probesize が小さいと ffmpeg がでたらめなコマ数を起こすことがあり、 SVT-AV1 は突っぱねる。固定(-r)では本当に 59.94p の局(720p)でコマを落とす

エンコードできなくなったら、その場で言う

Section titled “エンコードできなくなったら、その場で言う”

切り替えの間は前の映像を貼る。MSE を作り直すと <video> は何も映さないので、捨てる前に1枚だけ canvas へ 写して重ね、次の映像が実際に出た時点で剥がす(requestVideoFrameCallback。play() が返っただけではまだ 映っていない)。貼りっぱなしを避ける上限は6秒(live-player.svelte.ts の HOLD_MOST)。壊れているのに 動いて見えるのがいちばん悪いので、ffmpeg が降りたらその場で「映像を出せませんでした」を出す:

  • 終わりを ffmpeg の終了で見る。 流し込みは書き込み先が閉じても終わらないことがある
  • 畳んだセッションを持ったままにしない。 持っていると、同じ局を選び直しても「もう見ている」と見なされる

放送が止まっているだけのときは、そう言う。 エージェントの「電波が来ていないか、その周波数に放送が ありません」は、アンテナや配線を疑わせる。NHK総合は保守の晩に送信機ごと止める(番組表では「放送休止」)。 番組表を持つのは denpa だけなので、同期しなかったときだけ「放送休止です」に言い換える。塞がっていたときや ffmpeg が降りたときに出すと、今度はこちらが嘘をつく(server/live.ts の whyNotTuned)。

ライブは録画にだけ譲る(config.priority)。エージェントは録画から見て弱い相手(ライブを含む)しか いないチューナーも使ってよいものとみなし、要れば取り上げる(agent.md)。予約表で通した組を 録れるようにするためで、代わりにほかのチューナーが空いていてもライブがチューナーを取り上げられることがある。

取り上げられたときの動き(画面もテレビのアプリも同じ):

  1. まず同じ局をリクエストし直す。 使ってよいチューナーが空いていればそこに入り、見ているものは変わらない
  2. 入れなかったときだけ、チューナーを取り上げた録画の追っかけに切り替える(録れているところの少し手前から)。 通知は「録画のため局を切り替えました: <番組名> (録画中を再生しています)」で、隣に「ライブに戻る」
  3. 「ライブに戻る」はリクエストし直しを試す。まだ録画がチューナーを塞いでいれば追っかけに戻り、 「録画が終わるまでライブは見られません (<終了時刻>)」と言う

同じチャンネルの録画は選局を共有するので、取り上げられず何も起きない。入れなかった理由は見分けない (録画が終わったあとに別の理由で入れなかったときは文言が外れる)。

どの録画に取り上げられたかはサーバが読む(server/live-yield.ts)。エージェントは予約を知らないし、取り上げた相手を 教える API も無い。そこでライブはチューナーを確保できた時点で自分の利用者(live <種別>/<チャンネル>)がいるチューナーを GET /denpa/tuners から覚えておき、ストリームが途切れたらそのチューナーの利用者にいる rec <録画ID> を読む。 1回の選局の利用者は終わるまで同じチューナーにいる約束なので、覚えたチューナーは取り上げられるまで変わらない。

取り直すのは見ている側。 サーバはチューナーのストリームが途切れたらセッションを畳み、lost の通知(チューナーを取り上げた録画を添えて)を送る。 再エンコードしたものを途中から渡すと受け側(MSE・テレビの fMP4)が続きとして読めないので、セッションの中では取り直さない。 画面はいつもの繋ぎ直し(待ちが倍々)に乗せる。HTTP で観ているもの(テレビのアプリ)には同じものを通知 (GET /api/events の yielded。api.md)でも流す。

番組が替わって並びが変わったら作り直す

Section titled “番組が替わって並びが変わったら作り直す”

ffmpeg は起きたときの映像・音声・字幕の並び (PMT) のまま -map で選ぶので、番組が替わって音声の本数や 字幕の PID が変わると追従しない。並びは局に絞るところ (ServiceFilter.streams) で読み、変わったかは StreamsWatch で見る (データ放送などの増減では変わったことにしない)。

経路 変わったら
ライブ (エンコード) セッションを畳んで restart を送る。画面はすぐ同じ局をリクエストし直す
追っかけ (エンコード) セッションを畳んで restart を送る。画面は届いたぶんを観終えてから、観ていた位置をリクエストし直す
放送のまま (MPEG-2) のライブ 畳まない。受け側が PMT を読んで PID を選び直し (ts/pes.ts の PesDemuxer)、字幕の ffmpeg だけ起こし直す (放送のままのときの字幕)
元のTSの字幕 (録画・追っかけ) 閉じない。字幕の ffmpeg だけ起こし直す

エンコードする経路はその場で起こし直さない。 時刻が 0 から数え直しになり、受け側が続きとして読めないため (lost と同じ理由)。 30 秒以内に続いたらいつもの繋ぎ直し (待ちが倍々) に乗せ、切り替わりの前後で並びが行き来する局で作り直しを繰り返さない。 HTTP で観ているもの (テレビのアプリ) にはストリームを閉じて伝える (api.md)。

追っかけは観ていない先を飛ばさない。 送り込みは倍速なので (server/chase.ts)、作り直すときには観ている位置より 先まで届いている。画面は手元が尽きるまで観てから、観ていた位置をリクエストし直す (resumeChase)。位置は秒で、バイトへは 比例で当たりを付けるので、少し手前に当たると前の番組の PMT を読んでからまた変わる。そこでサーバは変わったところ (読んだ塊の尻のバイト) を 録画ごとに覚え、その 15 秒以内をリクエストされたらそこから読む (chase.ts の resumePlan)。テレビのアプリにも同じように効く。

公式の adapter-bun は Bun.serve を自分で呼ぶが、websocket を渡す方法が無い。そこで入口の server.js が Bun.serve を包んでから build/index.js を読み、adapter の設定に websocket を足して、/api/live/socket の 握手だけ SvelteKit の手前で受ける(理由と、Host をそのまま扱う理由は server.js の頭のコメントに)。

開発サーバ(bun run dev)ではライブが繋がらない(vite も node:http)。確かめるときは bun run build && bun ./server.js。

ブラウザは WebSocket の握手に Authorization を付けてくれない。 画面が先に POST /api/live/ticket で 使い捨てのチケットを取り、それを URL に付けて繋ぐ。チケットの発行は普通の HTTP なので、OIDC も信頼したネットワークも そのまま効く。URL は履歴にもログにも残るので、1回使ったら消す。寿命は30秒。

いまの引数は src/lib/server/live.ts の encodeArgs にあります。 1本の ffmpeg が映像と音声を1つの pipe へ多重化し、Fmp4Splitter が moof+mdat ごとに 割って WebSocket へ流します。

ffmpeg -nostats -fflags nobuffer -probesize <小さく> [<GPU の入口>] -i pipe:0 \
-vf <インタレ解除> \
-map 0:p:<局>:v:0 -fpsmax <上限> <映像のエンコード方式> \
-map 0:p:<局>:a:<何本目> [-af pan=...] <音声のエンコード方式> \
-f mp4 -movflags +empty_moov+default_base_moof \
-frag_duration 50000 -flush_packets 1 pipe:1 \
<字幕の出口 → pipe:3>

値は書かない。引数の並びだけ。 実測した値と理由は encodeArgs / videoArgs の説明にあり、写すと片方が古くなる。

  • -copyts は付けない(1本にしたのは、時刻を揃えるため)
  • -loglevel は下げない。 選べる字幕は入口の見出し(info で出る)から読む(字幕)。進み具合の数字だけ -nostats で止める
  • クライアント側の SourceBuffer は segments(既定)で使う。sequence は届いた順に詰めるので、1つ取りこぼすと以降ずっとずれる。 再生位置の面倒(貯めてから出す・離れたら速める)は src/lib/ts/pacing.ts
  • 1本に多重化している間は +frag_every_frame を使わない。映像だけ・音声だけの塊が交互に並んで MSE が引っかかる (encodeArgs の「コマごとに切らない」)。いまは映像1本・音声1本

エンコード方式は見ながら選べる

Section titled “エンコード方式は見ながら選べる”

画面のコーデックのメニューから H.264 / AV1 を選び直せる(LiveCodec)。音声と同じく、選び直すと 再エンコードになる。局を変えても引き継ぐ。 映像の中身ではなく「その端末で出るか」の話なので。

H.264 AV1
役割 速く。 -preset ultrafast・-tune zerolatency、画質は既定 (crf 23) のまま。量は増える 小さく。 SVT-AV1 preset 12・lookahead=0 (先読みのぶん貯めるのがそのまま遅れになる)
遅れ 電波が届いてから塊が出るまで 0.24〜0.49秒 1.1〜1.5秒。放送の今から1秒ぶん離れる。低遅延指定 (pred-struct=1) は実時間に間に合わない
音声 AAC 256k Opus 256k (元の放送の AAC 256kbps に合わせた値)
向き 宅内。既定 宅外・細い経路

HW エンコーダの無い実機(44スレッド)で、1080i をデコードして 60 コマ/秒の AV1 も実時間に付いていった(立ち上がりは 0.45 秒遅い)。 量はどちらも品質指定なので場面で動く。実測は videoArgs の説明にある。

受け取れない端末では H.264 に戻す。 MediaSource.isTypeSupported が false ならその場で戻し、理由を画面に出す。

設定の「録画」の「エンコード」(/settings/recording#encoding)でそのコーデックのエンコーダに GPU を選んであれば、 録画と同じ GPU でエンコードする(Intel の QSV / VA-API。引数と決め方は encode.md「ライブも同じエンコーダで GPU に載る」)。 MPEG-2 を GPU でデコードできる環境ではデコードとインタレ解除も GPU に寄せる。-fpsmax・字幕の出口(pipe:3)・音声はそのまま通る。

映像を出す前に GPU の ffmpeg が降りたら、ソフトウェアで再エンコードする(その経路は15分覚えてソフトウェアでエンコードする)。 画面には断り書きを一行出す(notice)。HTTP のエンドポイント(sessionStream)は通知を捨てるので、 アプリには再エンコードが少し遅れて見えるだけ。NVIDIA / AMD(av1_nvenc / av1_amf)はまだ試しのエンコードに入れていない。

映像と同じ ffmpeg から、字幕をデコードせずに写して受け取る。出口を2つ持つ(src/lib/server/captions.ts)。 デコードして置き場所を決めるのは denpa(src/lib/ts/b24caption.ts)、描くのはブラウザ(components/player/caption-draw.ts)。

ffmpeg <上の映像の引数と同じ> -i pipe:0 \
... pipe:1 \
-map 0:p:<局>:s:<n> -c:s copy -cluster_time_limit 1 -cluster_size_limit 1 \
-flush_packets 1 -f matroska pipe:3

ここも引数の並びだけ。値は captionOutput の説明にある。

  • 写すだけ (-c:s copy)。Matroska には ARIB の字幕がそのまま (S_ARIBSUB) 入る
  • 局を名指しする。 映像と同じ理由
  • 受けたコマをデコードして文字の配置にし (CaptionText)、0x22 で配る。デコードして出てくるのは字幕が変わったときだけ (字幕管理データや送り直しは何も出さない) なので、間引く必要も無い。同じ録画の 103 秒で、デコードの手間は 218 コマで 6ms

1本にしたのは、時刻を揃えるため

Section titled “1本にしたのは、時刻を揃えるため”

ffmpeg は入口で時刻を 0 に寄せ直すが、寄せ幅はプロセスごとに違う。 別々に起こした2本はエンコードし始めの鍵フレームがずれ、 出てきた時刻どうしを突き合わせても意味が無い。-copyts で放送の絶対時刻を保っても、mp4 のマルチプレクサがその時刻を 0 に詰め直す(-avoid_negative_ts disabled も -muxdelay 0 も -output_ts_offset も効かない)。 1本にすれば入口の寄せが1回で済み、両方の出口に効く(実機で 1ms 以内。実測は captions.ts)。 別々の ffmpeg で測って合わせようとして外した記録は stream-history.md。

引き換えに、音声やエンコード方式を選び直すと字幕の出口も別になる。字幕は次が来るまで出しっぱなしなので、 再エンコードの間はサーバが最後の1枚を配り直して繋ぐ。

字幕を並べただけでは時刻が乗らないので、コンテナに入れて運ばせる。 Matroska は使うところだけなら小さい。 src/lib/ts/mkv.ts が読むのは TimestampScale / Cluster > Timestamp / SimpleBlock と BlockGroup > Block、 それに中身の種類 (CodecID) だけ。NUT はコマの見出しを組み立てるのに表の復元が要り、mp4 は ARIB の字幕を入れられない。

塊の上限を最小(-cluster_time_limit 1 / -cluster_size_limit 1)にして、1枚ごとに書き出させる。溜められるとそのぶん遅れる。

エンコード方式ごとに分かれる

Section titled “エンコード方式ごとに分かれる”

字幕はエンコード方式(音声・コーデック・何本目の字幕か)ごとに分かれる(共有の単位)。 その代わり TS をもう1本もらわずに済む(チューナーの共有をもう1つリクエストしなくてよい)。

言語は全部デコードして、出す言語を人ごとに選ぶ

Section titled “言語は全部デコードして、出す言語を人ごとに選ぶ”

ARIB の字幕は1本の中に最大8言語を持てる (データグループの番号 1〜8。何番がどの言語かは字幕管理データに ISO 639 の 3文字で書いてある)。ffmpeg は字幕をデコードせずに写すので、全部の言語が denpa まで届いている。

  • デコードは言語ごと (CaptionText)。書式・位置・色は言語ごとに別に続くので、デコーダを言語の数だけ持ち、 同じコマを全部に食わせる。第2言語から後ろは字幕管理データに出てきてから作り、最後の字幕管理データを先に食わせる
  • ライブと追っかけは視聴者ごとに選ぶ (Viewer.language。既定は第1言語)。共有しているセッションでも 再エンコードしない。選び直しは {"type":"language","language":2} で、いまのその言語の1枚 (無ければ空の1枚) が届く。 tune / chase にも language を添えられる (繋ぎ直しても続くように)
  • 載っている言語は captions の通知の languages (字幕管理データが届いてから。知らない受け側は読み飛ばせばよく、 そのときは第1言語だけが届く)
  • 録画 (captions.json) は全部の言語を1度に返す。 第1言語は pages、第2言語から後ろは others。 端末に保存したコピーにも全部の言語が入る (前に保存したものは第1言語だけ)
  • 画面は言語の名前 (ISO 639) で端末に覚える (components/player/caption-pref.ts)。第何言語かは番組ごとに違う。 覚えた言語が載っていなければ第1言語。字幕の出し入れも同じく端末に覚える

単一接続で全チャネルを多重化する。

[1 byte: channel][8 bytes: 時刻 (90kHz, BE)][payload...]
channel:
0x00 video init segment (ftyp + moov)
0x01 video media (moof + mdat)。音声も同じ fMP4 に入る
0x02 元の TS 1局に絞った TS そのまま ([放送そのまま(MPEG-2)を送る](#放送そのままmpeg-2を送る))。時刻は 0。init は無い
0x10 audio init segment ※いまは使わない (映像と同じ fMP4)
0x11 audio media ※同上
0x20 (空き) 以前の字幕の画像 (PNG)。もう使わない
0x21 (空き) 以前の「字幕を消す」。もう使わない
0x22 字幕の文字の配置 JSON (api.md「字幕の文字の配置」)。前の1枚を丸ごと置き換える
0x30 データ放送 ResponseMessage (JSON。`ts/bml-protocol.ts`)。時刻は 0
0x40 制御 ここだけ双方向の JSON (選局・追っかけ・データ放送の指示 / tuned・字幕一覧・時計 等の通知)。時刻は 0
  • init セグメントは接続直後に必ず送る(再送用にサーバ側で保持)
  • MSE はバイト列をそのまま appendBuffer() に投げられるので、映像音声のパースは不要
  • 字幕の座標は字幕の面 (plane。ふつう 960x540) の単位。受け側は映像の枠に合わせて伸ばし、画面の画素で描く
  • 頭の時刻は「エンコードした mp4 の物差し」。 映像も音声も字幕も同じ ffmpeg が書き出すので、 受け側の video.currentTime と直に比べられる(時刻同期)
  • 放送のままの経路(0x02)では、字幕の時刻は放送の PTS そのまま(放送のままのときの字幕)
  • 途中から入ってきた視聴者のために、いま出ている字幕はサーバが1枚だけ保持して再送する
対象 方法
映像・音声 fMP4 の timescale で自動的に整合
字幕 添えられた時刻(映像と同じ ffmpeg が付けた mp4 の物差し)に置く。出しておく長さ (duration) があれば過ぎたら消す
データ放送 カルーセルは時刻同期不要(PMT/TOT と denpa の番組表の更新に追従)

測っていません。 字幕のコマに付いてくる時刻を再生位置と比べ、「追い越していない中の最後の1つ」を出すだけです (ts/captions.ts)。映像と字幕を同じ ffmpeg が書き出しているので揃います(字幕)。 「放送から N秒」は遅れを見せるためだけのもので、揃えるのには使っていない。

  • 貼り直すのは映した1枚ごと(live-player の follow)。届いた塊に合わせると、遅れる方向にだけ 0〜50ms ずれる
  • 再エンコードのたびに、待たせている字幕は捨てる(forget)。mp4 の時刻が 0 から数え直しになるため

ブラウザでエンコードせずに見られます。 1局に絞った TS をそのまま WebSocket に流し、ブラウザが WebAssembly の MPEG-2 デコーダでデコードして WebGL2 で描き、音も 同じ WebAssembly の AAC デコーダでデコードして Web Audio で鳴らします(バックグラウンド再生を入れている間は <audio> を通す。下の「時計とリップシンク」)。コーデックのメニューで MPEG-2 を選ぶと 放送のまま送るようになり、エンコード方式と同じく端末ごとに覚えます(LAST_COOKIE。既定は H.264)。

エージェント → 1局に絞る ┬→ WebSocket (0x02 元の TS) → worker ┬→ PES → WASM (mpeg2video) → WebGL2 (bob)
│ └→ ADTS → WASM (aac) ─→ 画面 → Web Audio (時計) [→ <audio>]
├→ ffmpeg (字幕を写すだけ。-copyts) → デコードする → 0x22 文字の配置 (放送の PTS のまま)
└→ BmlDecoder → 0x30 データ放送 (エンコードする経路と同じ)

ブラウザに MPEG-2 のデコーダは無いので持ち込んでいます(Chromium 151 で MediaSource.isTypeSupported・ VideoDecoder.isConfigSupported とも mp2v / mpeg2video は ×。video/mp2t が ○ なのは中身が H.264 の TS の話)。

得るもの: ffmpeg の立ち上がり 0.5〜0.7 秒と、エンコードするのに掛かっているぶんが丸ごと消え、サーバの CPU も 350〜470% からほぼ 0 になります(字幕を写す ffmpeg だけ残る)。残る遅れは受け側の貯め(ts/pacing.ts の FLOOR から始まる貯め)だけです。

2つが揃ったときだけ。決めるのは画面です。

誰が見る 外れたら
コーデックのメニューで MPEG-2 を選んでいる 画面 (LAST_COOKIE の raw) いつもどおりエンコードする
その端末でデコードできる (WebAssembly・worker の WebGL2・AudioContext) 画面 (raw/support.ts) エンコードしたものをリクエストし、理由を断り書きに出す

家の外からでも、http で開いた LAN でも放送のままで見られます。 回線が 1局ぶん(下の「払うもの」)出るかは見る人が決める。 足りない回線では貯め直しを繰り返し、音が5秒来なければエンコードしたものへ戻ります。音を WebCodecs の AudioDecoder ではなく 映像と同じ FFmpeg でデコードするのは、AudioDecoder が安全な繋ぎ(https か localhost)でしか出てこず、ブラウザごとの癖もあるため (stream-history.md)。WebAssembly の無い端末 (iOS の Gecko など)は「WebAssembly が使えません」と出してエンコードしたものに戻ります。

放送のままで見ている途中でも、エンコードしたものへ自分で戻ります(戻したら、その画面を開いている間は リクエストし直さない — 戻しては諦めるを繰り返すと、そのたびに映像が止まる):

  • デコードするのが間に合わない(下の「間に合わないとき」)
  • デコーダが入っていない(/api/live/mpeg2 が 404)・worker が落ちた
  • 映像は出ているのに音が 5 秒来ない・AAC のデコーダが壊れたコマで 20 回続けて転ぶ(1回なら飛ばす)

FFmpeg の mpeg2video のデコーダとフレームの区切りを探す parser、AAC のデコーダだけを emscripten で組んだもの (wasm/mpeg2/。互換モードの音のために Opus のデコーダと、それが当てにする swresample も入っている)。AAC の parser は組まない — ADTS の区切りと時刻は JS(ts/pes.ts の AdtsSplitter)が付け、デコーダは ADTS の頭を自分で読む。音も映像と同じ worker でデコードする (1秒ぶんで数 ms)。Dockerfile の mpeg2wasm 段で毎回組み、git には入れません(生成物を抱えると FFmpeg を上げるたびに差し替えが要り、 組んだ手順と中身が食い違っても気づけない)。FFmpeg はサーバの ffmpeg と同じ版に揃え、emsdk は版で固定しています。置き場はアプリの外 (/opt/denpa/mpeg2、MPEG2_DIR)で、/api/live/mpeg2/<名前> から配ります — 開発と E2E の compose はソースを /app に被せるので、 中に置くと隠れます。

大きさ decoder.wasm 855KB(brotli 292KB / gzip 364KB)+ 読み込み用の decoder.mjs 14KB。配るときは denpa が縮める(Accept-Encoding を見て brotli か gzip。中身が変わったときに1度だけ縮めて覚える)
速さ(手元の Chromium、ヘッドレス) BS の 1920x1080i で 90〜97 コマ/秒、1440x1080i で 110〜130 コマ/秒 — 実時間の3倍
音の速さ(同じ) AAC-LC 48kHz の音1秒で ステレオ 256kbps 1.0〜1.6ms、5.1ch 384kbps 2.3〜2.9ms
組む時間 2分ほど(amd64 / arm64 どちらのランナーでも)
GPU が無いとき 描くほうが間に合わない。ソフトウェアの WebGL(SwiftShader)では 10〜14 コマ/秒しか出ず、5秒でエンコードしたものに戻る。E2E が通るのは映像が小さい(256x144)から

スレッドも SIMD も使いません。 スレッド版は SharedArrayBuffer のために COOP/COEP (crossOriginIsolated)を全ページに要求することになり、データ放送の中継やロゴ、別オリジンへ 開く Hybridcast と噛み合いません。SIMD は1割ほどしか変わりませんでした。1スレッドで実時間の3倍出ているので、払う理由がありません。

時計とリップシンク — 時計は鳴っている音

Section titled “時計とリップシンク — 時計は鳴っている音”

鳴っている音を時計にします(raw/playout.ts)。音は途切れると誰でも気づきますが、映像は 1コマ遅れても気づかれにくい。だから音は届いた順に隙間なく Web Audio の時間の上へ予約し、 映像は「いま耳に届いている PTS」を追いかけて、番が来たコマを出します。放送の PTS は音と映像で 同じ時計(PCR)に乗っているので、それで音と映像が合います。

  • 「いま耳に届いている PTS」は AudioContext.getOutputTimestamp() から出す — 予約している 位置ではなく出口までの遅れを引いた位置。それを performance の時刻と組にして worker へ 流し(20ms ごと)、worker は組から外挿してコマを選ぶ
  • PCR は受け側では使いません。 貯めは「届いた音の端 − 鳴っている位置」で見る(clock の通知は「放送から N 秒」を出すのにだけ使う。 放送のままの経路では ffmpeg の start: を引かず PCR をそのまま渡す)
  • PTS は 26.5 時間で一周するので、受け側は増え続ける数に伸ばして持つ(ts/pes.ts の unwrap)。字幕と時計の通知も、同じ物差しの近くへ伸ばしてから比べる
  • 自動再生で音を断られたら、壁時計を時計にして映像だけ進め、「音を出す」を押された時点で 鳴っている位置のまま音の時計へ移る(RawEngine.follow)。逆に音が止められたら(出口が 外れた・OS に止められた)壁時計へ戻り、映像は止めない

バックグラウンド再生を入れている間と、押して小窓を開いている間は、音を <audio> から鳴らします (RawEngine.route。Safari は除く — そちらは Audio Session を playback にする。理由は watch.md)。GainNode の先を createMediaStreamDestination() に繋ぎ替え、 そのストリームを <audio> の srcObject に渡すだけで、予約の仕方も時計の物差し(context.currentTime)も変わりません。 変わるのは耳に届くまでの遅れです:

  • ストリームへ出した音はページの出口の遅れ(baseLatency + outputLatency)を通らず、代わりに <audio> の 出口の遅れが掛かる(ヘッドレスの Chromium で、直に鳴らしたときより 84ms 早くストリームに入った)
  • <audio> の出口の遅れはページから読めないので 見込み(STREAM_OUT = 30ms)で置く。 時計は getOutputTimestamp() の組のまま、PTS を baseLatency + outputLatency − STREAM_OUT だけ先へずらす。 見込みは少なめに置く。外れると音が映像より遅れる側に倒れる(人は音が先に来るほうに気づきやすい)
  • オン/オフを切り替えたときは、その差のぶん時計が一度だけ飛ぶ(映像が1〜数コマ止まるか飛ぶ)
  • 止める・再開は <audio> にも写す。 ロック画面・車の操作・ブラウザの再生中の通知は <audio> を直に止めるので、それも操作バーに反映する(RawEvents.remote)
  • CSP には掛からない(srcObject は URL を読まない)

エンコードする経路と同じ考えで、決め方も同じものを使います(pacing.nextTarget。止まったら伸ばし、 無事が続いたら縮め、止まった高さを覚える)。覚える置き場も同じ(FLOOR_KEY)。

  • 貯まった音の尺が target に届いてから、放送の今から target 手前で鳴らし始める
  • 使い切ったら止まって貯め直す(止まった回数はエンコードする経路の「途切れ」と同じ数に出る)
  • 溜まりすぎ(target + 1秒が3秒続く。裏に回ったタブが戻ったとき・時計のずれ)は 放送の今へ飛ぶ。1.05 倍で詰めることはしません — 音を速めると音程が変わる

貯めるのはデコードする前です。1080 のフレームは1枚 3MB あるので、デコードしたフレームで貯めると 1秒で 90MB になる。圧縮のまま(PES)持っておき、番の 0.3 秒前にデコードします。デコードできたフレームはデコーダの中で 順番待ちさせ(8枚まで。デコードしたフレームを持つ数)、番を過ぎたものはテクスチャに上げずに捨てます(コマ落ちとして数える)。

インタレ解除 — bob と、櫛になっていないところは織る

Section titled “インタレ解除 — bob と、櫛になっていないところは織る”

1080i の1コマには時刻の違う2枚(フィールド)が1行おきに入っています。フィールドごとに 出して、抜けた行はコマの中だけで埋めます(raw/render.ts のシェーダ。色差も同じ)。

  • 動きが 60 枚/秒のまま出る(エンコードする経路の bwdif と同じ)
  • 抜けた行は、もう片方のフィールドのその行が上下の行の間に収まっていればそのまま使う (織る)。はみ出していれば櫛(2枚の間に動いた)なので、上下の平均で埋める(bob)。 はみ出しが 8/255 までは雑音として織り、16/255 からは埋める(間はなだらかに混ぜる)
  • 前後のコマを持たなくていい。テクスチャ1組で描けて、GPU の手間は誤差

素の bob(いつも上下の平均で埋める)だと、上下のフィールドで縁になる行が1行ずれるので、止まっているテロップの縁が毎秒 30 回上下に揺れる。 織れば止まっているところは揺れない。前のコマと比べる本当の motion adaptive(テクスチャを2組持つ)は、これでも気になれば足す。

インタレの印の無いコマ(720p など)はそのまま出します。色は HD が BT.709、SD が BT.601 (限定レンジ)。1440x1080 は画素の縦横比を掛けて 1920 幅で描きます。

コマを出す間合い — 画面の切り替わりに合わせる

Section titled “コマを出す間合い — 画面の切り替わりに合わせる”

フィールド(59.94 回/秒)と画面の切り替わり(たいてい 60 回/秒)はほぼ同じ間隔で並ぶので、 出す時刻が少し揺れるだけでカクつきます(同じフィールドが2回出て、次のフィールドが飛ぶ)。worker の刻みの時刻で番を見ていた頃は、 切れ目と画面の切り替わりが近い間(16 秒ごとに 2 秒ほど)ずっとこれが起きていました。いまは(raw/cadence.ts):

  • 次の画面の切り替わりの時点で出すフィールドを選ぶ。 画面の requestAnimationFrame の時刻を worker へ流し(vsync)、間隔と位置を覚えて次の切り替わりを見越す。worker の rAF は描いたものが画面に出る速さに引っ張られるので使わない
  • 切り替わりごとの PTS は、切り替わりの間隔ぶんずつ進める(VsyncClock)。時計(鳴っている音)の 組は 20ms ごとに数 ms 揺れて届くので、まず少しずつ寄せ(SmoothClock)、その上で時計へはごく少しずつ寄せる。 8ms 以上食い違えば(飛んだ・貯め直した)合わせ直す
  • 描いた回はデコードしない。 デコードは画面の切り替わりの直後(間隔の前半)にだけ始める (先にデコードしたものが無くなっていればいつでも)

59.94 と 60 の差のぶん、約 17 秒に1回は同じフィールドが2回出ます(周波数を合わせない限り避けられない)。 裏に回って画面の rAF が止まっている間は、刻んだ時刻で選びます。エンコードしたもの(fMP4)も同じ時計で番を見ます。

単体テスト(cadence.test.ts)は刻みとデコードと時計の揺れを模してフィールドが飛ばないことを、E2E(43-raw-cadence)は 偽の放送(上下のフィールドで別の時刻の映像。ts/synth-av.ts)を互換モードの観る画面で流し、worker の WebGL の呼び出しを数えて 画面の切り替わりごとにどのフィールドが出ていたかを確かめる(直す前は 150 回のうち重なり 9・飛び 8、直した後は 0・0)。 画面の rAF が来なかった回(CI は混んで 20〜70 回来ないことがある)はそのぶんフィールドが進んでいて正しいので飛びに数えない。 実機の画面(120Hz・可変リフレッシュ)でどう見えるかは実機で見ないと分からない。

  • 局を変えても worker とデコーダは作り直しません(reset で中身だけ捨てる)。デコーダを 開き直し、I フレームが来るまで渡さない — MPEG-2 は I からしかデコードできず、先に P/B を渡すと 灰色の映像が出る(放送の GOP は 0.5 秒)
  • 貯めている間は新しい局の1枚目を出して待つ(新しい局の1枚目が少し止まると同じ判断)。 エンコードする経路で使う「前の映像を貼る」canvas は空にしておく(古い映像が放送のまま描く canvas の上にかぶさる)
  • 大きく遅れたら(0.5秒)デコードしながら追わずに I フレームまで飛ぶ。止めている間に溜まる圧縮の待ちも8秒で切る
  • サーバでの共有は局と字幕の単位(音声とエンコード方式では分けない — 音声は全部送って画面が選ぶ)。 チューナーの使い方・畳み方・warm はエンコードする経路と同じ

字幕は ffmpeg に写させて、denpa がデコードします(エンコードする経路と同じ)。映像をデコードしない ffmpeg を 1本起こし(captions.rawCaptionArgs)、-copyts で放送の時刻のまま字幕だけを写させます。CPU は 0.2% ほど。

放送のままの経路の受け側は放送の PTS そのものを時計にしているので、字幕も放送の PTS のまま出せば突き合わせられます (mp4 のマルチプレクサが 0 に詰め直す問題は、字幕の出口の Matroska には無い。実機の録画で元の TS の字幕 PES の PTS と 1ms 単位で一致)。 -copyts を外すと合いません — 映像を出さない ffmpeg は寄せ幅を入口の start: と違う値にし、それを外から知る方法が無い。

テレビのアプリも同じ字幕を受け取ります(api.md)。 ライブは放送のままで送るセッションを共有し、字幕 (0x22) と字幕一覧の通知 (0x40) だけを、1つずつ頭に長さを付けて HTTP で流します (チューナーは増えない。字幕だけを受け取る接続は、畳むかどうかの判断に数えない)。元のTSの録画と追っかけは、 リクエストされた位置の少し手前から同じ ffmpeg を1本起こします。アプリは Media3 の TS のパーサが 0 に寄せた幅を 足し戻して放送の PTS にし、そこで突き合わせます。

字幕の ffmpeg が転んでも映像は止めません。流し込みも待たせない — 書き込みが捌けていない間に来た塊は字幕の側に渡さない (字幕が1枚欠けることはありうるが、映像が止まるよりいい)。

番組が替わって並び (PMT) が変わったら、字幕の ffmpeg だけ起こし直します(番組が替わって並びが変わったら作り直す)。 -copyts で放送の時刻のまま写しているので、起こし直しても時刻は続きます。選べる字幕は新しい ffmpeg の見出しから数え直し、 字幕が無いと覚えていた局でも起こし直します(新しい番組には字幕があるかもしれない)。

データ放送は変わりません。放送のまま描く canvas も映像の入れ物の中にあるので、データ放送の小窓へ一緒に動きます。

1コマをデコードする時間の 95 パーセンタイルが1コマの長さの 8 割を超えるか、映像が時計から 0.25 秒以上遅れるのが、1秒ごとに数えて5秒続いたらエンコードしたものに戻します(raw/budget.ts)。 1回の重さ(タブを戻したとき・GC・選局の直後)では戻さない — 戻すと再エンコードで 1 秒近く 映像が止まるので、一瞬の詰まりより高くつく。デコードする時間は操作列に「デコード N ms」と出ます。

デコードしたフレームを持つ数

Section titled “デコードしたフレームを持つ数”

デコードできたフレームを何枚まで先に持つか(DECODED_MOST)は、放送のまま(raw/worker.ts。WASM の中のフレーム)と 互換モード(raw/fmp4.ts。VideoFrame と、渡したまま出てきていないもの)で別に決めています。どちらも 8 ですが、 数えるものも決め手も違います(測った表は stream-history.md)。

  • 互換モードは GPU で上下に崖がある。 GPU のデコーダは受け取ってから出すまでに何枚か抱える(B フレームの並べ替え)ので、 4 以下だと次を渡せずに毎秒 30〜40 コマまで落ちる。12 にすると GPU の置き場が尽きてデコーダが何も出さなくなる。 6 は下の崖に近いので 8。CPU のデコーダはすぐ出すので 2〜12 のどれでも変わらない。崖を測ったのは Radeon 1 台だけなので、 ほかの端末で「再生が追いつきません」が出るなら、まずここを下げて測る
  • 放送のままは 1 回に 1 枚ずつデコードするので、待たせるのは多くて 3〜5 枚。2 だと追いつかず、6 以上でほぼ落とさない。 WASM のメモリはどの値でも最初の 64MB から増えない

測り方は tests/bench(CI では流さない)。互換モードの観る画面に ffmpeg の testsrc2 で作った映像(1440x1080i の MPEG-2・1920x1080p 59.94 の H.264 / AV1)を流し、worker の比べている所を書き換えて振ります。

docker compose --profile docs run --rm docs bunx playwright test -c tests/bench # GPU 無し
docker compose --profile docs run --rm -p 4180:4180 -e BENCH_SERVE=1 docs bunx playwright test -c tests/bench
# ↑ 立てたまま。ふだんの Chrome で http://localhost:4180/ を開き、probe.js の頭の手順で測って report.ts で表にする
# (入口 tests/bench/proxy.ts は認証なしで結果を書き込めるので、測る間だけ立てる)
  • 帯域。 1局に絞った元の TS は実測 14.5〜16.8 Mbit/s で、中身によらずほぼ一定 (エンコードしたものは形式と場面で動き、AV1 なら数分の1まで)。細い回線では放送のままにしないこと
  • 端末の CPU と電池。 デスクトップなら1コマ 10ms だが、携帯や非力なノートでは電池を目に見えて使う。既定を H.264 にしてあるのはこのため
  • MSE に乗っていたものが無くなる:
    • 止めて再開は放送の今から。5分戻れるシークバー・追っかけの速さも無い(1局 15Mbit/s を 5 分持つと 600MB になる)
    • エンコード方式(H.264 / AV1)の選び直し
    • 5.1ch の番組は頭の2つ(前の左右)だけ鳴らす(下げ方を持っていない)
  • インタレ解除はコマの中だけで決める(上)ので、bwdif よりは落ちる

ヘッドレスの Chromium(GPU 無し・SwiftShader)と偽の放送(src/lib/ts/synth-av.ts の小さい MPEG-2 と無音の AAC)でしか通していません(AAC のデコーダだけは ffmpeg で作ったステレオ・5.1ch・ 壊したコマ・途中で形式の変わる ADTS でも確かめた)。本物の GPU・本物の放送で見るべきところ:

  • リップシンク(getOutputTimestamp の遅れの見積もりが Bluetooth などの出口で合っているか)
  • 止まっているテロップがちらつかないか、動くところが櫛にならないか(櫛が見えるなら 織るのをやめる幅 8/255〜16/255 を詰める。ちらつくなら motion adaptive を足す)
  • 1080i を実時間でデコードできるか(ノート・古い PC・Firefox / Safari)。「デコード N ms」を見る
  • デュアルモノ(二カ国語)と複数音声の切り替え、5.1ch の番組(前の左右が出ているか)
  • 長時間(数時間)見て、音の時計と放送の時計のずれで飛び(溜まりすぎ)が何回起きるか
  • 携帯・タブレットの電池(MPEG-2 を選んだときにどれだけ減るか)

互換モード — エンコードしたもの (H.264 / AV1) も canvas に描く

Section titled “互換モード — エンコードしたもの (H.264 / AV1) も canvas に描く”

<video> の再生が止まる端末のための方式です。 テスラの車載ブラウザは、停車中でも <video> の 再生を止めます。canvas に描くものは止めないので、互換モードではライブ・追っかけ・録画の観る画面の H.264 / AV1 も 放送のままと同じ worker でデコードして canvas に描きます(観る画面は下)。

サーバ (変えない) → WebSocket (0x00 init / 0x01 媒体。fMP4) → worker ┬→ moof/trun → WebCodecs (VideoDecoder) → 2D
└→ AAC は ADTS の頭を付けて / Opus は OpusHead で → WASM → 画面 → Web Audio (時計)
  • サーバは変えません。 届くものはエンコードする経路と同じ fMP4 で、画面が MSE ではなく放送のまま描くエンジン (RawEngine の fmp4。raw/fmp4.ts)へ流すだけ。worker は作るときに何を受け取るか(RawInput)を決める (描く先の canvas の使い方が WebGL2 と 2D で違うので途中で替えない)
  • 箱は自前で読みます(ts/fmp4.ts の readInit / readFragment)。moov の avcC / av1C / esds / dOps と、 moof の tfdt / trun。中身の在りかは moof の頭から数える(+default_base_moof)
  • 映像は VideoDecoder(description に avcC / av1C)。デコードできた VideoFrame は描いたらすぐ閉じる (持つとデコーダのフレームの置き場が尽きる。デコードしたフレームを持つ数)。圧縮のまま 持っておき、番の 0.3 秒前に渡すのは MPEG-2 と同じ。コマの時刻が飛んだら(サーバが詰まった客に媒体を捨てた)次の鍵のコマまで捨てる。 デコーダが転んだら開き直して鍵のコマから入り直し、4 回続けて転んだら諦める
  • 音は MPEG-2 と同じ WASM。 AAC は esds の AudioSpecificConfig から ADTS の頭を組んで付ける(adtsHeader)。 Opus は dOps を OpusHead に書き換えて Opus のデコーダ(opus_open)へ渡す。H.264 の WASM のデコーダと dav1d の WASM は作りません
  • 時計は放送のままのときと同じ(鳴っている音。playout.ts)。時刻は MP4 の物差し(0 から数え、一周しない)で、 字幕・時計の通知も同じ物差しで届く
  • 間に合っているか(budget.ts)は、1コマの時間では判じない — GPU の経路は詰まっていなくても 数コマぶん溜めることがある。代わりに VideoDecoder の詰まり(decodeQueueSize が窓の間ずっと 3 枚以上)と、映像の遅れ(0.25 秒)を同じく 5 秒続いたかで数える
  • 速さは等速だけ。 ライブは止めて再開は放送の今から(戻れるシークバーも追っかけの速さも出ない)
  • 追っかけは放送の今を追わない(RawEngine の chase、playout.ts / worker の follow)。溜まっても飛ばず、 止めて再開は止めた所から。シークバーは出すが、動かすといつもリクエストし直す(手元に持っているのは先だけ)。 サーバは倍速で送り込んでくるので、圧縮のまま持つのは 120 秒か 120MB の小さいほうまで(MSE の BUDGET と同じ量)。 超えたら受け取るのをやめ(full)、手元が 5 秒を切ったらいまの位置からリクエストし直す(REFILL)。 追っかけに MPEG-2 は無いので、描けなくなったら理由を出して止まる

オン/オフは端末ごと(再生の設定 (⚙) の「互換モード」。canvas-video.ts)。localStorage の鍵 1 つ (canvas-video = on / off / 無し)で、明示した値が勝ちます。無ければ UA に Tesla/<数字> が ある端末はオン、それ以外はオフ(判定は画面だけ。サーバは UA を見ない)。同じ項目に、この端末で H.264 / AV1 が使えるか(VideoDecoder.isConfigSupported)と、テスラの印があるかを出します。切り替えたらページを読み直します。

運転中の注意を一度だけ出します(canvas-drive-notice)。⚙ で入れたとき(確かめる窓。取消できる)と、 UA の既定で入っている端末で初めて canvas で再生する前(「わかりました」を押すまで再生を始めない)。 位置情報で止める仕組みは作りません。

諦めるとき — <video> には戻りません(テスラでは止まってしまう):

どうするか
この端末でデコードできない形式 コーデックのメニューに「(再生できません)」と出して選ばせない。選んでいた形式がデコードできなければデコードできるほうへ(H.264 → AV1)、どちらも駄目なら MPEG-2、それも駄目なら「再生できる形式がありません」
描いている途中で諦めた(間に合わない・デコーダが転び続ける・音が来ない) MPEG-2 で描けるならそちらへ(断り書きに理由)。無理なら理由を出して止まる
MPEG-2 を諦めた エンコードしたものを canvas に描く(互換モードでなければ <video>)

VideoDecoder は安全な繋ぎ(https か localhost)でしか出てきません。 http で開いた LAN では H.264 / AV1 とも使えず、MPEG-2 で描きます。AV1 はデコードするのが重いので、互換モードでも既定は H.264 です。 E2E(tests/e2e/38-live-compat)は偽 ffmpeg が本物の H.264 + AAC・AV1 + Opus の fMP4 を流し、描くところまで 見ます(H.264 は走らせている Chromium の VideoDecoder がデコードできるときだけ)。

互換モードの観る画面 (/watch/<id>) は <video> を使わず、/api/recordings/<id>/stream を fetch で読んで同じエンジンへ流します (canvas-player.ts。リクエストの仕方は <video> でその場で流すとき (remux-player.ts) と同じ。library.md)。

/stream?codec=h264|av1&from= (fMP4。時刻はページの物差し) ─→ 箱で割る (Fmp4Splitter) ─┐
/stream?codec=mpeg2&from= (元の TS。X-Denpa-Origin) ─────────────────────────────┴→ RawEngine (worker・Web Audio)
  • どれで描くか (ts/playback.ts の pickCanvas)。サーバから使えるのは流すもの (fMP4 と元の TS) だけで、ファイルそのまま (Matroska) は使わない (端末に保存したものは下)。自動はエンコード済みと同じ形式の詰め替え (サーバは軽い) → 変換 (H.264 → AV1) → MPEG-2 の順で、 この端末で描けるもの。コーデックのメニューは「自動・H.264・AV1・MPEG-2」(録画が出せるものだけ。描けない形式は押せない)
  • 時刻はページの物差し。 fMP4 はサーバが物差しのまま書いてくる。元の TS は放送の PTS なので、応答の X-Denpa-Origin (物差しの 0 にあたる PTS) を引いて一周の中で取る (ts/seek.ts の sinceHead)。字幕 (captions.json)・チャプター (CM スキップ)・データ放送・続きの位置はこの位置で引く (40ms ごとの刻み)
  • 決め方は追っかけと同じ follow (playout.ts) — 止めたら位置を保ってそこから再開し、溜まっても今へは飛ばない。 録画のために足したのは2つ:
    • 飛ぶ (シークバー・← →・前/次のチャプター・CM スキップ・続きから)。持っているものを捨てて、飛び先からリクエストし直す。 サーバは飛び先の少し手前 (鍵のコマ・1 秒前) から流すので、手前のコマはデコードするだけで描かず (worker の floor)、 音も飛び先から鳴らす。描けるまでは前の映像のまま (<video> のような蓋 (cover) は要らない)
    • 終わり (finish)。尻まで読んだら、貯めが足りなくても残りを鳴らし、鳴らしきったら止まる (ended)
  • 先へ読みすぎない。 手元の音が fMP4 は 30 秒、元の TS は 10 秒を超えたら読むのを待つ (fetch のストリームが詰まり、サーバも送るのを待つ)。デコーダを読み込み終えるまではバイト数 (8MB) で区切る。それでも 溢れたら (worker が full) いまの位置からリクエストし直す
  • 速さは等速だけ (メニューを出さない)。切り抜き・小窓・バックグラウンド再生 (<audio> を通す) はライブと同じ仕組み
  • 切れたら観ていた位置からリクエストし直す。混んでいて断られたら (503)、変換させずに描けるもの (詰め替え・MPEG-2) を勧める
  • 描けなくなったらライブと同じ順で移る (断り書きに理由)。どれも無理なら理由を出して止まる。<video> には戻らない
  • 端末に保存したもの (オフライン) も canvas に描く (mkv-source.ts → raw/mkv.ts)。保存してあるのはエンコード済みの Matroska で、fMP4 に組み直さず worker がそのまま読み、読んだコマを fMP4 の経路 (Fmp4Video) へ渡す (ts/mkv-media.ts が Tracks の CodecPrivate — avcC / av1C / AudioSpecificConfig / OpusHead — とコマを読む)。
    • 飛ぶ位置は Matroska の Cues から引く (SeekHead が在りかを言う)。頭 (最初の Cluster まで) に飛び先の手前の Cluster からを繋いで、Blob.stream で流す。手前のコマは描かない (floor)。Cues が無ければ頭から
    • Matroska のコマは見せる時刻しか持たないので、デコードする順の時刻は先の 4 コマの見せる時刻のいちばん早いものにする (H.264 の B フレーム)
    • 音は選んだ 1 本 (音声のメニューはトラックの名前 → 言語 → 連番)。選び直すといまの位置から読み直す
    • 描けない形式か読めなければ、サーバから流すものへ。オフラインなら理由を出して止まる

E2E(tests/e2e/40-watch-compat)は、偽 ffmpeg が作り置きの fMP4 をリクエストされた位置から並べて出すもの (fmp4-out.ts) と、 偽の放送の MPEG-2 をそのまま描き、元の TS の位置が物差しに直っているかを見る。飛ぶ・CM スキップ・字幕・止めて再開は、 長い録画のふりをして (fmp4-clip.ts) 見る。

デコードする側も描く側も自前です。映像は denpa が持つ。 2026-10 まで otya128/web-bml を使っていた経緯は stream-history.md。

運び方は衛星のロゴと同じ DSM-CC で、共通のところは ts/dsmcc.ts(ロゴは ts/logo-dsmcc.ts)、データ放送の読み方は ts/bml.ts。 モジュールの中身 (multipart) を切るのも自前 (ts/multipart.ts)。TS を直に読むので psisiarc も要らない。

渡す形式 (ResponseMessage) は web-bml の取り決めを写して ts/bml-protocol.ts に置いた。 録画の隣の .bml.jsonl がこの形式で残るので、名前と意味は変えない (足すのはよい)。

型 中身
pmt データ放送の component (PID・componentId・BXML の情報)
moduleListUpdated どのモジュールが何バイトか (DII)
moduleDownloaded 揃ったモジュール。ファイルごとに base64 と MIME
esEventUpdated / currentTime イベントメッセージ・NPT・放送の時刻
エージェント → denpa ┬→ ffmpeg ┬→ fMP4 → MSE (映像・音声)
│ └→ mkv → デコードする → canvas (字幕)
└→ BmlDecoder → 組み立て済みモジュール → 画面のエンジン (src/lib/bml/)

ffmpeg は1本のまま、チューナーも1つのまま。

デコードする側は server/databroadcast.ts、描く側の入れ物は components/player/DataBroadcast.svelte。 1局に絞った TS の写しを BmlDecoder へ分け (Session.pump)、出てきたものを CHANNEL.data (0x30) に JSON で載せます。覚えておくぶんは ts/carousel.ts。

実機の録画 (1局に絞った約40秒ぶん) で、揃ったモジュールは 16個・約 1.4MB、速さは 128 MB/s(放送の 17 Mbit/s に対して1コアの 1.7%)、 繋いできた人に配り直すぶんは 18個・2MB。

リクエストされてからデコードします。 見ている人のほとんどは押さないので、画面から { type: 'data', on: true } が来たときだけ回し、誰も出していなければ畳みます。引き換えに、押してから出るまで カルーセルが一周するのを待ちます(大きいモジュールほど間隔が空く)。

決めたことが4つ:

  • pcr は出さない。 毎秒50個ほど来るうえ、denpa では映像そのものが時計です
  • programInfo はデコードする側から出さない。 denpa は自前の番組表から作るほうが正確で安い (Session.programInfo)。描く側は局の番号をこれで知ります (arib-dc:// の名前を解決する・NVRAM を局ごとに分ける)。 中継の番号 (transportStreamId) だけは、絞るときに読む PAT の頭から読み取ります (ServiceFilter)。 テレ朝の TVerリンクは browser.getProgramID(4) でこれを取って TVer のサーバへ送るので、null のままだと 「情報を取得することができませんでした。[エラーコード:DT-RE]」で断られます
  • pmt は変わったときだけ。 モジュールも同じ版は配り直しません (カルーセルは回り続け、版が上がらなくても同じものが何度も揃う)
  • デコードする側が転んでも映像は止めない。 投げ返すと TS を送り込んでいるところごと落ち、映像まで出なくなります

denpa が作る ResponseMessage をそのまま食べる。 Svelte から切り離してあり、画面の入れ物は DataBroadcast.svelte が持つ:

const bml = createBml({ root, fonts, ip, store, receiver, clock, onVideoRect, /* ほかは bml/engine.ts の型 */ });
bml.feed(message); // ← ts/bml.ts が吐くものをそのまま
bml.key(code, down); // AribKeyCode (20 が d)
bml.listens('DataButtonPressed');
bml.destroy();

依存は持たない。

映像 動かさない。 object の場所を教えてもらい (onVideoRect)、映像の入れ物を style でそこへ寄せる (placeVideo)。字幕の canvas は入れ物の中なので一緒に縮む。映像より前に描くものの背景には穴を開ける
文書 自前の寛容な XML パーサ → 16 の要素の木 → open な Shadow DOM の中の絶対配置の div。innerHTML も属性の写しも無い (字は textContent、見た目は確かめた値を element.style に書くだけ。CSP の style-src にも頼らない)
CSS 自前で重ね合わせ (単純セレクタ・詳細度・継承・:focus/:active)、確かめた値だけ書く
スクリプト 自前の VM (vm.ts)。eval を使わず、CSP の下でそのまま動く。sleep で止まる・launchDocument で打ち切る。sort に比べる関数を渡せる (日テレのメニューが使う)。比べる関数は戻るまで続けて動かし、命令の数は実行の上限に数える。中で sleep はできない
字 TextDecoder (EUC-JP) と、追加記号は b24-tables。DRCS は 1 字を CSS の mask に
内蔵音 WebAudio で合成
束 押すまで読み込まない (import() で分けてある。どのみちカルーセルの一周を待つので、そこに紛れる)
フォント 丸ゴシックは字幕と同じ Denpa Font (danything/denpa-font)。BML が要る等幅・丸ゴシック・ARIB外字を1つで満たす。イメージにはリリースの woff2 を置き、/api/font/denpa-font.woff2 で配る。角ゴシックは端末のもの
覚えるもの NVRAM は端末の localStorage。denpa_bml_own_ で他と混ざらないようにしてある
郵便番号・都道府県 設定の「受信」の「データ放送」(/settings/reception#broadcast) で入れて、受信機の設定としてエンジンに渡す (nvram://receiverinfo/zipcode・receiverinfo/prefecture。NVRAM には書かない)。放送はこれで天気・地域のニュース・防災情報の場所を決め、郵便番号を入れないと「郵便番号が正しく設定されていません」と出る。都道府県の番号は ARIB STD-B10 付録G 表G-2 (県域指定) のビットの位置 (北海道は東西、東京都・鹿児島県は島部が別で 1〜50。東京都は 14 で、JIS の都道府県コードとは違う)、未設定は 255 (src/lib/prefecture.ts)。郵便番号から都道府県は引かない (表を抱えない)。家の場所は端末で変わらないので、置き場はサーバ。地域符号 (receiverinfo/regioncode) は持たず 0 を返す
リモコン 十字・決定・戻る・数字・色・d をそのまま渡す (aribKey)。指で押すぶんは自前 (components/player/Remote.svelte: 色・十字・決定・戻る・d・数字) で、右の列に置く。放送の画面は枠を下まで塗り潰すので、映像に重ねると埋もれる
d ボタン テレビと同じ振る舞い。 出す操作を文書への d の1押しとしても届け (knock。日テレ・テレ朝・フジの待機ページはこれでメニューを開く)、返事が無ければ 2.5秒おきに押し直す (TOKYO MX の入口は最初の2秒に受けた d を捨てる)。開いたメニューを閉じてしまう局があるので、押すのは3回まで。出ている間の d は、文書が聞いていれば渡し、聞いていなければデータ放送を閉じる (pressD)。右の縦列の d ボタンは放送に渡さず、その場で畳む逃げ道
  • 描く場所は毎回作り直す。 エンジンは渡された要素に Shadow DOM (attachShadow) を張る。Shadow DOM は外せないので、 同じ要素を渡し直すと2度目で転ぶ。局を変えたときも作り直す(カルーセルも覚えるものも局ごと)
  • 出すまでは透明にしておく (opacity)。入口の文書はほぼ白紙のまま自分で invisible を外してくるので、本体が揃う (データ取得中が下りる) まで映像のままにする。visibility では隠せない — エンジンは描く要素ごとに BML の visibility を写すので、 子の visible が親の hidden を打ち消す
  • 切り分けのために data-showing / data-receiving / data-handed を外へ出してある (「押しても何も出ない」が、届いていないのか描かれないのかを外から見分けるため)
  • 文字は EUC-JP の符号として扱う (charCodeAt・fromCharCode・大小)。TBS・テレ東が String.fromCharCode(0xA1A1〜) を、 MX が charCodeAt を EUC の範囲と比べる書き方を使っていた。数は整数 (割り算は切り捨て)、Date は日本の時刻
  • ページを握るのは 1 つの仕事で 5ms まで (budget.ts)。スクリプト・描き直し・約束の続きは 全部この下で動かす。「仕事」はブラウザのタスク 1 つと、それに続くマイクロタスク全部 — マイクロタスクで 入り直しても区切りは始め直さない (始め直すと、マイクロタスクで回り続ける文書でタブごと固まる)。区切りで手を放しても 休みなく回り続ける文書 (10 秒のうち 4 割を超えて動く) は止めて、ログに残す

browser.* は放送が呼ぶ 53 種を全部持ち、動かせないもの (予約・PPP・外のアプリ) は規格どおり失敗 (NaN / null / 0) を返す。 Hybridcast の起動は画面に任せる (Hybridcast)。選局 (epgTune) も画面に任せる (ライブは局の一覧から探して切り替え、録画は失敗を返して「録画の再生中は選局できません」と知らせる)。 録画では再生位置から放送の時刻を出して渡す (clock)。

移れない文書へ移ろうとしても、今の文書のまま失敗 (NaN) を返す。 launchDocument は名前を先に 確かめ、別の局の arib-dc://・ftp・PMT や DII に無いモジュールなら、今の文書を捨てずに NaN を返す (放送のアプリはこれを見て「接続できません」などを自分で出す)。先に今の文書を捨てると、NHK の入口は invisible のまま d を待つ文書なので、データ放送が閉じたように見える。DII には載っているがまだ届いていないモジュールは、待ってから移る。

通信の文書 (http・https) へも移る (TR-B14 の通信コンテンツ)。launchDocument("https://…") は 文書を中継 (/api/bml/proxy、双方向(通信系コンテンツ)プロキシ) で取るまでスクリプトを待たせ、取れたら今の文書を捨てて移る。 双方向がオフ・中継が断った・相手が 2xx 以外・BML でない (text/X-arib-bml でも頭に <bml も無い) なら NaN を返して今の文書のまま。

中の名前 相対の名前 (画像・CSS・script・次の文書) は文書の URL から解決し、同じ中継で取る。arib-dc:// は放送のものとして引く (放送へ戻れる)。~/ は放送の部品を指す書き方なので、通信の文書からは使えない
今どちらか getBrowserStatus("TerrP", "IRDState", …) は、放送の文書なら Broadcast が 1、通信の文書なら Link が 1 (もう片方は 0)。局のスクリプトはこれで BinaryTable などの置き場を選ぶ。両方を 1 にすると、通信の文書で放送のモジュールの名前を作り、それを URL として取得しようとして失敗する
覚えておく 画像・CSS・script は 64 個・8MB まで覚え、長く使っていないものから捨てる。今の文書が使っているものは捨てない。文書そのものは移るたびに取り直す (投票の結果などを古い版で出さない)。取れなかった画像は覚えて取り直さない
NVRAM 通信の文書から使えるのは nvram://local_web/ と、受信機の設定 (receiverinfo/) を読むことだけ。放送の文書が覚えたもの (同意・地域など) を外から来た文書に渡さない
押さえる lockModuleOnMemory はカルーセルのモジュールだけ。通信の文書にいる間は PMT・DII が変わっても入口へ出直さない (データイベントの「入口へ戻る」は従う)
CSP 変えない。取るのは同じオリジンの中継で (connect-src 'self')、画像は今までどおり blob: に作り直す

まだ無いもの: NPT (PCR を送っていないので、時刻指定のイベントメッセージと NPT の TimerFired は起きない)。

双方向(通信系コンテンツ)プロキシ

Section titled “双方向(通信系コンテンツ)プロキシ”

放送局の通信系サーバは受信機の専用ネットワークスタックが前提で、CORS ヘッダを返さない。 そこで SvelteKit サーバが中継する (中身は server/bml-network.ts)。

エンドポイント BML の API 何をする
/api/bml/proxy launchDocument("https://…") と、その文書の中の画像・CSS・script GET して中身をそのまま返す
/api/bml/post transmitTextDataOverIP POST して返りをそのまま返す
/api/bml/confirm confirmIPNetwork 相手の 443 番へ繋がるか (ICMP はコンテナの中から撃てない)

「送る」は応募・投票のためだけの API ではない。 放送のアプリは isIPConnected の申告を信じない。NHK は送る API へ一度投げてみて、 返らなければ「インターネットに接続されていません」と案内する (confirmIPNetwork は呼びに来もしない)。渡さないと、双方向を入れていても 入っていないのと同じ画面になる。

既定はオン。 テレビと同じく、データ放送の通信の項目 (お知らせ・応募・投票・利用状況の調査など) が開く。引き換えに、応募や投票、 視聴の記録 (NHK の「利用調査」など) が放送局へ送られうる。同意を取るのは放送の文書で、denpa は通すだけ。 設定の「受信」の「データ放送」(/settings/reception#broadcast) の説明にこのことを出し、「双方向 (通信系コンテンツ) を使う」を 切ればこの API は 403 を返し、画面側も isIPConnected に 0 を返す。録画の再生では通信しない。 既定がオフだった頃にこの設定を保存した人は、保存した値 (オフ) のまま変わらない。

局ごとのドメイン表は持たない

Section titled “局ごとのドメイン表は持たない”

「いま映している局のドメインだけ許す」には、局とドメインの対応表が要る。その表は必ず古くなり、新しい局や引っ越した先で 黙って繋がらなくなる(放送側の不調と見分けが付かない)。代わりに繋ぎ先ではなく繋ぐ範囲を縛る。危ないのは放送局以外に繋ぐことではなく、 denpa のサーバが内側に繋ぐこと (SSRF) だから。

手 GET と POST。中身は組み立てない。描く側が Denbun=… まで作って渡してくるので、application/x-www-form-urlencoded として素通しし、返りもデコードせずに返す
相手 公開アドレスのみ。私設・ループバック・リンクローカル (雲のメタデータ)・CGNAT・多重放送は断る
仕組み http と https。file: などへ逃がす経路は塞ぐ
見方 名前ではなく引いた先で見る。localhost の別名や、内側を指す公開の名前は綴りでは見分けられない。名前は BML_DNS で引く。家庭の DNS フィルタが局のドメインを 0.0.0.0 に落とすと、上の防御が正しく断ってしまう(config.md)
追いかけ 3回まで。行き先ごとに確かめ直す (1回目が公開でも、飛ばされた先が内側のことがある)
大きさ 4MB / 待ち 10秒
持ち物 何も渡さない (cookie も認証も、User-Agent も)。denpa の資格情報も、端末や利用者を見分けるものも外へ出ない。放送の文書が自分で本文に入れたものは通る (テレビと同じ)。User-Agent で断る局が出たら denpa/<版> と名乗る (実在の受信機は名乗らない)
証明書 確かめない。放送局の通信系サーバは証明書の鎖を最後まで送ってこないことがあり、確かめるとフジの tvid-sha1.tver-tech.co.jp が unable to get local issuer certificate で転ぶ (doRequest)。資格情報は何も乗せず、素の http も通しているので、確かめないことで増える危険は小さい
記録 断ったものは何に繋ごうとしたかを残す
使える人 denpa の画面を使える人だけ (信頼するネットワーク・ログイン・アクセストークン。hooks.server.ts)
種別 可否
dボタン、ページ遷移、地域選択、クイズ選択肢 ○ 受信機内で完結するため動作
NVRAM への保存・読み出し ○ 端末の localStorage (src/lib/bml/nvram.ts)
通信系コンテンツ(http・https の文書へ移る、中の画像など) ○ 中継 API 経由 (データ放送の統合)
応募・投票などのデータ送信(HTTP POST) ○ 中継 API 経由。繋がっているかの判定もここを通る
Cookie × 持たない (端末を見分ける値が放送局へ出ていく。要る局が出たら考える)

データ放送が起動を求めたら、denpa の中で開く。受信機の API は用意できる範囲だけ。

データ放送 (BML) と違い、アプリが電波に乗っていません。 乗っているのは AIT という表だけで、中身は放送局のサーバから HTTPS で取得します。

PMT ─ ES に application_signalling_descriptor (0x6F) ─→ その PID に AIT
AIT ─ transport_protocol_descriptor (URL の頭)
└ simple_application_location_descriptor (その先の経路) → アプリのURL

ts/ait.ts は表を読むだけです。押されなくても読みます(デコードする量が無いうえ、押すまで在ることが 分からないのでは出したいものが出せない)。載っていない局では PMT に印が無いので、AIT を待つこともしません。

見つけたら、操作バーの下の段に別のタブで開くボタンを出します (受信機の API が無いので、多くはそのままでは動きません)。 denpa の中で開くのは、データ放送が startAITControlledApp で起動を求めたときです。

データ放送 ─ getBrowserSupport("ARIB", "AITControlledAppEngineFunction", …) → 1 (開けるときだけ)
─ startAITControlledApp(…) → 画面が AIT のアプリを開く
画面 ─ アプリの HTML を中継で取得 (/api/bml/proxy)
─ 頭に <base href="アプリの URL"> と差し込み (src/lib/hybridcast/shim.ts) を入れる
─ sandbox="allow-scripts" の iframe (/api/bml/hybridcast) へ postMessage で渡し、document.write で開く
アプリ ─ 画像・script・CSS は <base> から元のサーバへ直に取得 (CORS は要らない)
─ fetch・XMLHttpRequest・受信機の記憶・受信機 API は postMessage で画面へ
  • 開けるのは双方向がオンで、AIT にアプリがあるときだけ。オフなら AIT の問い合わせ (AITControlledAppEngineFunction・AITTransferMethod・AITTransportMethod・APIGroup の AITControlledApp.Start) に 0 と答え、startAITControlledApp は失敗を返す (行き先のボタンだけが残る)。 開けないのに 1 と答えてはいけません — BML が起動を求めてきて、そのまま返らなくなります
  • 開くのは AIT にある行き先だけ。 文書が渡した引数に URL があっても使いません。数の引数は どのアプリかの手がかり (organisation_id と application_id が合うもの → application_id が合うもの → autostart のもの → 先頭)
  • 何を訊かれたか (getBrowserSupport の AIT の問い合わせと startAITControlledApp の引数) はブラウザの コンソールに [bml] で出します。局が何を見て案内を出し分けているかを見るためです
  • アプリは映像の上に重ねて、1920x1080 で組ませて枠に合わせて伸ばします (HybridcastFrame.svelte)。開いている間は BML を隠します
  • 戻る (Backspace・x・リモコンの戻る) と d で閉じてデータ放送へ戻ります
  • 色ボタン (r・g・b・y) は受信機のキー (keyCode 403〜406)、矢印・決定・数字はそのまま渡します (KeyEvent.VK_* も用意)
  • アプリが <object type="video/broadcast"> を置けば、放送の映像をその場所へ縮めて出します。 無ければ全面、stop() なら出しません
機能 denpa
放送の映像の表示と縮小 ○ video/broadcast の object (bindToCurrentChannel・stop・setFullScreen・currentChannel・programmes)
いまの局と番組 ○ 放送の programInfo と局の一覧から。navigator.receiverDevice.getCurrentChannel()・getCurrentProgram()
選局 △ データ放送の epgTune と同じ範囲 (局の一覧にある局だけ)。局が変わるとアプリは閉じる
字幕の表示の切り替え ○ getCCStatus・setCCStatus
受信機の記憶 (localStorage) ○ アプリごとに分けて覚える (denpa_hybridcast_<organisation_id>_<application_id>。256KB まで)。denpa のほかの記憶とは混ぜない
アプリの通信 (fetch・XMLHttpRequest) ○ 中継を通す。GET と POST だけ。POST の形式はフォーム・JSON・文字だけ
受信機 ID・証明書・機種 × 持たない。getReceiverId などは null
WebSocket・EventSource・sendBeacon × 差し込みで中継へ渡せないので使えない
同期の XMLHttpRequest △ 非同期で返す
cookie × 持たない。document.cookie は空
DRM 付きの動画 × ライセンスサーバが受信機を見る
スマホ連携 (Hybridcast Connect) × 家の LAN にエンドポイントを開けることになるので作らない
録画の再生中 × データ放送の通信と同じく放送中だけ

受信機 API の名前は IPTVFJ STD-0010 の本文で確かめていません。差し込みは知らない名前の呼び出しにも null (使えない) を返し、何を呼ばれたかをコンソールに [hybridcast] … は使えません で出します。 本物の放送でここを見て、要るものを足していきます。

UA に Hybridcast や実在のメーカーを入れません (中継は UA も送らない)。受信機 ID・証明書も持ちません。 そのため、受信機を確かめるアプリ (UA を見る・証明書を求める・会員や視聴の記録を受信機に結ぶ) は動きません。 denpa 側の努力では越えられないので、作り込みません。

オリジン iframe は sandbox="allow-scripts" (allow-same-origin は付けない)。入れ物 (/api/bml/hybridcast) も CSP の sandbox allow-scripts を返すので、URL を直に開いても使い捨てのオリジンになる。denpa の cookie・localStorage・画面には触れない
繋ぐ 入れ物の CSP で connect-src 'none'。アプリの通信は差し込みが画面へ渡し、画面が中継で取得する。中継の決まりはデータ放送と同じ。denpa 自身 (内側) へは中継が断る
読み込む script・画像・CSS・フォント・動画は元のサーバから直に (*)。フォームの送信・入れ子の iframe・worker・ポップアップ・上の画面を移すことはできない
残る危険 画像・script などの読み込みは中継を通らないので、アプリ (とアプリが読む外の script) は利用者のブラウザから LAN の中や localhost へ GET を送れる (返りは読めない)。読み込む先のサーバには利用者の IP が見える。テレビのブラウザと同じで、ここは受け入れる
受け取る 画面は postMessage の送り元が自分の iframe であることと、形式を確かめる (frameMessage)。選局は denpa が許した範囲だけ
srcdoc・blob: にしない それだと denpa の画面の CSP を受け継ぎ、放送局の script が動かない。緩めると denpa の画面の CSP の意味がなくなる。denpa の画面の CSP は frame-src 'self' を足しただけ
送られる情報 アプリが自分で送るもの (視聴の記録・いまの番組など) は通る。テレビと同じ。双方向をオフにすれば起動しない