コンテンツにスキップ

録画の置き場と配り方

エンコードが終わったものをどこに置き、どう配るか。 保存先の構成、認証、削除、 録画の節目の通知まで。

エンコード方法そのものは encode.md、外から一覧を取る API は api.md。

コピー禁止の番組は録らないので、置き場にも来ません (市販のレコーダーと同じ。 data.md、Discussion #760)。 置き場にあるものは、端末への保存 (オフライン視聴) を含めてこれまでどおり配ります。

/media/encoded/ ← ENCODED_DIR
└── テストアニメ/
├── テストアニメ - 2026-08-01 - 2130 決戦.mkv
├── テストアニメ - 2026-08-01 - 2130 決戦-poster.jpg ← サムネイル (一覧の画像)
└── テストアニメ - 2026-08-01 - 2130 決戦.bml.jsonl ← データ放送 (持っている録画だけ)

番組情報はDBが持ち、denpa の画面が出します。 隣に置くのはサムネ (-poster.jpg、一覧と詳細の画像) と、データ放送の変化ログ (.bml.jsonl) だけ。変化ログは エンコードするときに元のTSから取り出し、観る画面のデータ放送 (⚙ から出す) が再生位置に合わせて流し直します (server/recorded-bml.ts)。シリーズ名のフォルダにまとめるのはディスク上で見やすくするためで、 同じシリーズ・同じ分に別の録画がぶつかったときだけ … [39].mkv のように録画IDを足します。

コーデックを両方選ぶと、1本の録画が2ファイルになります (… .mkv = AV1、 … [H264].mkv = H.264。mp4 は ARIB 字幕を持てないので、どちらも Matroska)。主 (再生の 既定) は AV1 で DB の library_path、もう一方は alt_path。ポスターは 両方の隣に置きます。外から片方を消されたら、実体との照合 (files.reconcile) が 消えたほうだけを外し、主なら残ったほうを繰り上げます (一覧の画像は途切れない)。 自分がいま置いてあるファイルは衝突と数えません (再エンコードのたびに名前が変わらないように。library.libraryPath)。

名前を決めるのは、エンコードが終わってからです。作業ファイルにはジョブIDを入れ (<名前>.<jobId>.<codec>.encoding)、置き場所は置く直前に決め直します。同じ番組を2本同時に エンコードしても、2つの ffmpeg が1つのファイルに書き込みません。

再エンコードするときは、始める前に前のものを消します (DB の library_path/alt_path も空ける)。 出来上がるまで別名 (.encoding) に書くので、消さないとエンコードしている間は mkv が 2本ぶん場所を取り、画面に出る大きさより使用量が多いことが外から分かりません。 元のTSは別にあるので、失敗・中止してももう一度押せば戻ります (その間の再生は 元のTSを返す)。DB を空けずに残すと、実体との照合が「保存先から消えた」と読んで 録画ごと削除済みに倒し、元のTSまで消します。

録画一覧の行を押すと /watch/<id> が開き、その場で再生が始まります (番組表からは詳細の「再生」)。ほかに観られるのはテレビのアプリだけです (下)。

エンコードしたものはブラウザがそのまま読めます。AV1 + Opus の Matroska は Chromium で canPlayType が probably を返し、実機の録画で再生もシークも通ったので、 端末ごとに渡す先を用意する理由が無くなりました。

画面 768px から映像の右に番組の中身のサイドバー (長ければそこだけ巻き取る)。狭い画面では映像が上
開いたとき 指で触っている端末 (pointer: coarse) はそのまま全画面へ。抜けると2段組
押したとき マウスはどこを押しても再生/一時停止。指は操作列の出し入れ (YouTube と同じ)
左右の端を素早く2回 10秒 戻す/送る。続けて押せばそのぶん重なる
チャプター送り CM飛ばし。入っているときだけ出す
シークバー 映像の上に重ねる細い半透明の溝。CM の区間は柔らかい琥珀で分け、観たぶんは濃い琥珀 (続いている CM は1本に。色は denpa-tv と同じ #FFB300)。本編の観たぶんは主の色。CM の端とそれ以外の切れ目 (引き継いだ録画の名前の付いたチャプター) で溝を 2px 区切る。マウスを載せた・触っている間だけ太くなり、つまみが出る。読み上げでは位置にチャプターの名前を添える (SeekBar.svelte・seek-track.ts)
CMを自動で飛ばす 既定でオン (切らずにエンコードした録画で毎回ボタンを押さずに済むように)。選んだ値は端末ごとに覚える。ロゴでの判定に失敗した録画だけは、覚えていてもオフで始める (無音だけで当てていて外れやすく、外れると本編を飛ばす)。⚙ の「CMスキップ」で切り替える。CM のコマは1枚も出さない (下の「CM は1コマも見せない」)
削除 観たその場で消せる。末尾はたいてい CM なので、流したまま押せる
字幕 持っている録画でだけボタンが出る。既定で出す (ライブと同じ)。放送の字幕を放送どおりの置き場所で描くのでルビも出る
続き 途中で止めたところから始まる。端末をまたいでも続く (覚えるのはサーバ側)
読み込み待ち 0.4秒 以上詰まったら輪を出す。 詰まりか壊れたかが見えないと待つか決められない。短い詰まりで出すと点滅するだけ
速さ 1〜2倍。選んだ値は端末ごとに覚える。残り時間も倍速のぶんを割って出す
切り抜き いまの1コマを字幕ごと PNG に (番組名_YYYYMMDD-HHMMSS.png)。スマホは共有シート (写真へ保存)、PC はファイルに保存してクリップボードにも置く (貼れない繋ぎ (http) では保存だけ)

操作バーとキー操作は watch.md、なぜそうしたかは player.md。

自動で飛ばすとき、CM のコマは1枚も出しません。 ブラウザは先のコマを何枚かコンポジタへ 渡し終えているので、飛ぶ位置を先読みしても渡し済みのぶんは出ます。予測で消せないので隠します。

やること なぜ
跨ぐ手前 0.1秒 (3コマ) 前に飛ぶ 入ってから飛ぶと必ず1コマ映る
飛ぶ直前 そのコマを写して蓋にする 止めたコマのほうが飛んだように見える (黒は切れて見える)。写すのは本編にいるときだけ。CM の中で写せば蓋そのものが CM になる
観はじめ チャプターが届くまで黒い蓋 チャプターは開いてから取得するので、揃うまで頭の CM がそのまま流れていた
着いたあと 4コマ数えてから外す requestVideoFrameCallback は「コンポジタへ渡した」ところで来て、画面に出るのはその先。さらに Windows の Chrome / Edge は映像を DOM とは別の面に出すので、蓋を外す更新が映像より先に画面へ出る (位置も章も本編なのに画面だけ CM のまま、という画面が実機で撮れた)

飛ぶ先を正しくするのはエンコードするほうの仕事です。チャプターの時刻がずれていれば、 ここで何をしても CM の途中に着地します (cm.md)。

録り終えた録画はどれも /watch で観ます (エンコードを待っている間も、失敗したものも)。 録っている最中のものは予約の側にいて、その行の「追っかけ」で追っかけ再生 (/chase/<id>) が開きます。録り終えるとその画面に 観る画面への案内が出ます。再生の印が消えるのは、消した録画と録画そのものが失敗したものだけです。

配信は /api/recordings/<id>/file (Range 対応。既定で返すものは app.md)。 ?source= で名指しもできます。ts = 元のTS、encoded = エンコードしたもの (両方エンコードしたときは AV1)、 alt = もう一方 (H.264)。テレビのアプリと端末に保存も名指しします。再生のためのエンドポイントなので、 添付 (Content-Disposition: attachment) にはしません。

録画をファイルとして外へ持ち出す機能 (ダウンロード) は持ちません。 エンコードした録画 (mkv) にはコピー制御の符号が 引き継がれないためです (元のTSは符号が 入ったまま保存していて、取り除いたり書き換えたりはしていません。Discussion #760)。 端末に保存 (offline.md) は denpa の中だけで観るもので、こちらは残しています。

観る画面はファイルを名指しします (?source=encoded か alt。下)。エンコードしている最中に 名指しなしで開くと元のTSが返り、何も映りません。

何で観るかは、サーバが出せるものから画面が選ぶ

Section titled “何で観るかは、サーバが出せるものから画面が選ぶ”

分担: サーバは「何を出せるか・どの形式で出すか・時刻の物差し」を決め、画面は「どれを使うか」を決めます (端末で再生できるか、人が選んだコーデック)。サーバは端末が何かを知りません。開いたときに api/recordings/<id>/playback を聞きます (api.md)。

録画にあるもの 出せるもの
エンコード済み ファイルそのまま。読めない端末にはサーバが fMP4 に詰め替えて流す (映像は写すだけ。軽い)
エンコード済みと違うコーデックを選んだ エンコード済みを元にサーバがその場で変換して流す
元の TS だけ (エンコード前・失敗・「エンコードしない」) サーバが元の TS をその場で変換して流す
元の TS (CM を切らずにエンコードした録画か、エンコード前) MPEG-2 のまま流す (端末で再生する方式。⚙ のコーデックで選ぶ)

画面の選び方 (ts/playback.ts の pickOffer): そのまま読めるファイル (canPlayType を Matroska と WebM で。 端末の名前では決めない) → 詰め替え (MSE が受け取れるもの) → 変換 (H.264 → AV1)。 コーデックのメニュー (この端末で観られる方式が2つ以上あるときだけ) で「自動」か H.264・AV1 を選べ、 選んだものは端末ごとに覚えます。AV1 だけエンコードした録画でも H.264 を選べば観られます (サーバが変換する)。

ファイルをそのまま読んでいるときの音声の切り替えは、ブラウザが入れ物の音声を出せるか次第です (video.audioTracks)。Chrome・Firefox は既定で出さないので、そのままでは2本目 (副音声・解説) を選べません。 そこで2本以上あれば playback の名前で並べ、2本目を選んだら、いまの位置からその場で流すほうへ移ります (ts/playback.ts の streamForAudio。同じコーデックの詰め替えを先に)。

その場の変換には同時に走らせる本数の上限があります (設定の「録画」の「同時に変換する本数」。既定は encode.md)。埋まっていると 「サーバが混んでいます」と出し、サーバに変換させずに観られるコーデックがあればそれを勧めます。

時刻の物差しは 1 ページで 1 つです。 エンコード済みがあればその時刻、無ければ元の TS の頭を 0。 サーバはどの方式でもストリームの時刻をこれに乗せるので、続きの位置・CM 飛ばし・字幕・データ放送・速さは currentTime のまま効きます。まだエンコードしていない録画は、チャプター (CM 飛ばし) を検出した区間 (cm_ranges。元の TS の頭からの秒) から組み、字幕を元の TS から抜きます。データ放送は エンコード済みの時刻で書いてあるので、エンコードするまで出しません。

その場で流しているとき
バー 最初から全長。飛んだ先が溜まっていなければ、そこからリクエストし直す (少し先へ送るぶんは届くのを待つ)
続き・字幕・チャプター・CM 飛ばし・データ放送・速さ そのまま効く (頭から観たときだけ数十ms ずれる)
音声の切り替え 選ぶと、いまの位置からリクエストし直す (流しているのは選んだ1本だけ)
端末に保存したもの 使わない。オフラインではそのまま渡し、読めなければ観られる先 (Chrome・Edge かテレビのアプリ) を案内する
切れたとき 観ていた位置からリクエストし直す (待ちは倍々で10秒が頭打ち、続けて8回で諦める)

互換モード (再生画面の ⚙ の「互換モード」) では <video> を使いません。 同じ /stream を fetch で読んで canvas に 描きます (H.264・AV1 の fMP4 か、元の TS の MPEG-2。速さは等速だけ)。端末に保存したものも、保存した Matroska を読んで canvas に描きます。 作りは stream.md に。

iPhone の Safari は H.264 の録画をそのまま読めません (#503)。Matroska を WebM としてしか読まず、 WebM に入れてよい映像は VP8 / VP9 / AV1 だけです (H.264 は開発者向けの設定の奥。WebKit の SourceBufferParserWebM)。そこでサーバが詰め替えて MSE (ManagedMediaSource) に流します。 対応は iOS 17.1 以降です (iOS 16 までの iPhone は MSE の実装を持たないので、今までどおり 「このブラウザでは再生できませんでした」と出し、Chrome・Edge かテレビのアプリを案内する)。実機 (iPhone) ではまだ確かめていません。

ほかに読めなかったときも、そうと出して観られる先を案内します。

ブラウザのほかには、テレビのアプリで観られます (danything/denpa-tv)。 Android TV / Fire TV でライブと録画を観られます (続きから・CM 飛ばし・低遅延のライブ)。家の外からは QR で繋いで アクセストークンで入ります (auth.md)。

外部のプレイヤー (VLC など) には対応しません。XSPF などの出力も、ログインせずに開ける URL を渡す仕組みもありません (前にあった「再生リンクをコピー」はやめました。config.md)。

入れ物の中の、放送の ARIB 字幕 (S_ARIBSUB) です (隣に字幕のファイルは置かない)。denpa の観る画面はサーバでデコードして 文字の配置で渡し、ブラウザが描きます (ライブと同じ。api/recordings/<id>/captions.json)。前の字幕の画像 (PGS) の扱いと入れ方は encode.md に。

ファイルのエンドポイントは、画面と同じ資格で開けます (信頼したネットワーク・ログインの控え・アクセストークン)。 観る画面の <video> も端末に保存も同じ URL にそのままリクエストして、URL に資格情報は埋めません。 決まりごとと理由は auth.md に。

denpa の画面から消します (押し間違い防止に2回押し)。自動削除はありません。 denpa を通さずに消されたものは RECONCILE_INTERVAL (既定5分) ごとの突き合わせで見つけます (すぐなら「ファイルと照合」)。

見るのは両方向です。 実体の無い行だけでなく、動画が無くなった付属ファイル (サムネイル・.bml.jsonl などの作業ファイル、昔の .sup .nfo) と空フォルダも 掃きます。付属ファイルは動画と同じ名前で隣に置くので、名前でどの動画のものか分かります (元のTSを残さない設定だと、付属ファイルだけが録るたびに積もるため)。 前の CM検出 (dtvindex) が作った .dtvi は、元のTSを残している録画の隣なら残ります (要らなければ手で消せます)。

動画そのものには触りません。行の無い動画は手で置いたものかもしれないので、数えるだけです。

録画の節目を Webhook で外に飛ばせます。設定の「連携」の「通知」に Discord や Slack の Incoming Webhook の URL をそのまま入れられます。

  • 録画開始 / 録画完了 / 録画失敗 / エンコード完了 / エンコード失敗、および録画の外側の 変事 (ディスク残量わずか / チューナーエージェント切断 / チューナーエージェント復帰)。選ばなければ全部 (種類は src/lib/webhook-events.ts)
  • 送信は投げっぱなし。通知先が遅くても録画を止めないよう、10秒で打ち切って結果だけ 記録します。直近の結果は同じ節に出ます
  • 録画の失敗は画面を開くまで気づけないので、せめて失敗だけでも入れておくのを勧めます