← ドキュメント ・ ← SmartCut ・ English
残す区間 [t_in, t_out) について、次のように切り分ける。
... I ....... I=========================I ....... I ...
^t_in ^k_first ^k_term ^t_out
|<-head->|<--------- body --------->|<-tail->|
再エンコード ストリームコピー 再エンコード
head と tail は GOP の途中で始まる/終わるので、直前のアクセスポイントから
復号して作り直すほかない。その間の body は入力のバイト列をそのまま出力する。
アクセスポイントちょうどで切れば、再エンコードは一切発生しない。
作り直すにはエンコーダが必要になる。ところが VC-1 にはそれが無い。2010 年頃までに
プレスされた Blu-ray の多くがこれで書かれているのに、エンコーダは libavcodec にも、
グラフィックスカードにも、フリーな実装のどれにも存在しない。そこで SmartCut は、
そのピクチャを自分で書く。書くのはイントラピクチャだけだ。断片の外を参照できない
以上、head と tail にはそれで足りる。その代償と測り方は
Rust コアにある。
Python リファレンス実装の構成は次のとおり。
| ファイル | 役割 |
|---|---|
probe.py |
ストリームのパラメータ、アクセスポイント索引、leading picture の検出、参照判定 |
planner.py |
区間指定をセグメント列に変換する |
bitstream.py |
Annex-B / MPEG-2 のアクセスユニット分割 |
renderer.py |
ffmpeg の実行と連結 |
verify.py |
出力を復号し、ソースとフレーム単位で突き合わせる |
「GOP 単位で切って繋ぐだけ」では済まない理由をここに挙げる。いずれも試作の過程で 実際に踏んだ問題であり、いずれも再現を固定するテストがある。
再エンコード部分の SPS を、元ストリームとビット単位で一致させることはできない。
エンコーダが違えば VUI も VBV も違う。そして MP4 の avcC ボックスは、
Matroska の CodecPrivate と同様にパラメータセットを 1 組しか持てない。素朴に
連結すると、コピー部分か再エンコード部分のどちらかが誤った SPS で復号され、映像が
壊れる。
対策は 2 段構えである。
- 各断片を Annex-B の生エレメンタリストリームとして書き出し、単純なバイト 連結で繋ぐ。エレメンタリストリームは IDR ごとに SPS/PPS をストリーム内に持つので、 断片ごとに異なるパラメータセットが合法的に共存できる。
- 最終的な MP4 は
avc3/hev1サンプルエントリで書く。ISO/IEC 14496-15 が 定めるストリーム内保持の形式で、これならavcCに畳まれない。
MPEG-2 にこの問題は無い。シーケンスヘッダが、もともとストリーム内にあるからだ。
ffprobe -skip_frame nokey はオープン GOP のアクセスポイントを取りこぼす。
参照先が無い I ピクチャをデコーダが出力できないからである。試作時のテスト素材では、
実際にある 10 個のうち 3 個しか見つからなかった。
パケットの K フラグを見れば復号そのものが不要になる。速く、かつ正確である。
復号順では I ピクチャより後、表示順では前に来るピクチャを leading picture と 呼ぶ。これは前の GOP を参照しているので、そこからコピーを始めると表示できない。
そして、その leading picture 自身が参照ピクチャかどうかで扱いが正反対になる。
- MPEG-2: B ピクチャが参照されることはない。したがって leading picture は 捨ててよく、オープン GOP でもコピー開始点として使える。
- H.264(x264 の
open-gopなど): B ピラミッドがあるため leading picture が 参照ピクチャになりうる。捨てると、それを参照していた後続フレームがすべて壊れ、 GOP 全体が巻き添えになる。 - HEVC: leading picture は RADL と RASL であり、同じエントリポイントの trailing
picture がそれらを参照することは規格が禁じている。だから符号化器が何をしていようと
常に捨ててよい。RASL に至っては捨てなければならない。参照先が切り口の向こう側に
あるからだ。これが
bitstream::leading_always_droppableである。おかげで、 計測していない次の点まで待たずに、すぐ次のエントリポイントからコピーを始められる。 必要になったのは 4K 放送だった。エントリポイントは全部 CRA で IDR が 1 枚も無く、 ピクチャごとに測ると全部が開始点に使えない判定になる。1 分切り出すと、直す前は 冒頭 13.3 秒を再エンコードしていた。直したあとは 0.44 秒である(区間の 22.8% が 1.4% になった)。 - VC-1: B ピクチャが参照されない点は MPEG-2 と同じだが、ピクチャヘッダを単体で 読めない。ピクチャが自分の種別を 3 ビットで名乗るのか 1 ビットで名乗るのかすら、 シーケンスヘッダ次第だからだ。トランスポートストリームはこれをエントリポイント ごとに置き直しており、libavformat が extradata として渡してくる。シーケンス ヘッダが一度も現れないストリームでは安全側に倒し、すべて参照ピクチャとみなす。 失うのは、オープン GOP からコピーを始められる機会だけだ。
これは実測で確認した。x264 の open-gop 素材で leading picture を捨てたところ、 コピー区間の最初の GOP 60 フレームがすべて不一致になり、残せば一致した。
そこで nal_ref_idc(H.264)、NAL タイプ(HEVC)、picture_coding_type
(MPEG-2)をビットストリームから読み、そのピクチャが参照されるかどうかを判定する。
参照されるなら、その点はコピー開始点に使わない。コピーの終了点なら、どちらでも
オープン GOP で構わない。表示範囲が lead_start で終わるだけだ。
leading picture の除去はコンテナでは表現できない(エディットリストが必要になる)ので、
Annex-B のアクセスユニット境界を自前で解析し、そこでピクチャを切り出す
(bitstream.py)。H.264 の「最初のスライスが
ピクチャの先頭」という規則は、MPEG-2 には通用しない。MPEG-2 はピクチャ開始コードの
あとにヘッダがいくつか続いてからスライスが来るので、切り方が変わる。
-t に秒数を渡すと、2 つの理由で狂う。
-c copyのもとでは-tは DTS に対して評価される。DTS は並べ替え深さの 分だけ表示時刻より先行しているので、次の GOP の I/P ピクチャが余分に混入する (180 フレームが 182 フレームになった)。- 分数フレームレート(30000/1001)では、丸めによって結果が ±1 フレームずれる。
結局、確実なのは表示フレーム数とコピーするパケット数を整数で数えて渡す方法
だけだった(-frames:v N)。パケット数はアクセスポイント索引の復号順インデックスの
差から厳密に求まる。
TS のタイムスタンプは 0 から始まらない(テスト素材では 1.423 秒)。-ss はファイル
先頭からの相対だが、ffmpeg は出力を -ss の分だけ基準化するので、start_time は
出力タイムラインに残留オフセットとして残る。
秒数指定の -t はこれをまともに受ける。そこで、アクセスポイントの時刻は
start_time を引いて正規化し、区間長はフレーム数で渡している。これで両方の問題を
避けられる。
オープン GOP のソースでは、-ss で目的位置へ直接シークすると、ffmpeg は復号でき
なかった GOP を丸ごと捨てる。その結果、出力が最大 1 GOP 分遅れて始まる
(実測 0.2 秒)。数 GOP 手前のアクセスポイントから復号を始め、出力側の -ss で
前を切り落とす。
音声は映像のセグメント単位ではなく、残す区間ごとに切る。
--audio-mode copyはソースのフレームをそのまま使う。区間の境界は最も近い音声 フレームに吸着する(AAC で最大 24 ミリ秒程度)。--audio-mode reencodeはatrimとconcatフィルタを 1 パス通す。サンプル 精度になるかわりに全編を再エンコードする。
Rust コアはこれに加えて smart を持ち、こちらが既定である。境界をまたぐフレーム
だけを再エンコードし、残りはコピーする。5.1ch 録画をステレオに畳むのはチャンネル数の
指定(--audio-channels)で、こちらにはコピー経路が無いので、モードが何であれ
全編の再エンコードになる。
どちらも音声で詳しく扱う。
AAC のエンコーダ遅延とプライミングを、エディットリストで厳密に扱ってはいない。
--verify の参照側を -ss で作ってはいけない。オープン GOP では参照側自体が
ずれるので(理由は 6 と同じ)、正しいカットが誤りとして報告される。先頭から
復号し、フレーム番号で切り出す。
tail は「コピーの終端」から「区間の終わり t_out」までである。ふつうは数フレーム
離れているが、t_out がアクセスポイントのすぐ後に来ると、その差が 1 ピクチャの
間隔より小さくなる。29.97 fps のストリームで、ピクチャが 60.05996 秒にあるところへ
60.060 秒を指定した場合がこれである。
こうして生まれた区間には復号すべきピクチャが 1 枚も入らない。カッターは中身の 無い区間を拒否する(そうでなければ、ピクチャの出てこない再エンコードが素通りして しまう)ので、そのクリップではなく実行そのものが止まる。
したがって tail は、head が 1 フレームを要求するのと同じ理由で、半フレーム
以上あるときにだけ作る。Python リファレンス実装も同じ位置に線を引いている。
念のため、フレーム数 0 と算出された再エンコード区間はそもそも出力しない。
デコーダはピクチャオーダーカウント順にピクチャを返す。そして継ぎ目の両側のカウントは、
別々のエンコーダが書いたものである。再エンコードした head は自前のコード化
ビデオシーケンスを開いて 0 から数え、その後ろに継いだコピー区間 body は録画が
持っていたカウントをそのまま運ぶ。
コピー区間が IDR から始まっていれば話は済む。シーケンスが再開し、デコーダは
抱えていたピクチャを出力するからだ。しかしプレス盤の Blu-ray がエントリーポイントに
置くのは、たいていもう一方のリカバリーポイント付き I ピクチャで、これは何も再開
しない。すると head の最後のカウントがコピー側のピクチャのカウントより上に来た
とき、2 枚はデコーダから逆順に出てくる。出ていく場面のピクチャが、入ってくる場面の
ピクチャより 1 フレーム後に返る。あるディスクでは、12 箇所の切り出しのうち
5 枚がこれに当たった。
復号そのものは間違っていない。 リカバリーポイント以降のピクチャは録画自身と
突き合わせて完全に一致する。乱れるのは 1 枚の表示順だけである。
--clean-joins は再エンコードを少し多めに使って IDR まで進み、上のディスクでは
5 枚を 2 枚まで減らした(残る 2 枚は、届く範囲に IDR が無い継ぎ目である)。
代償は正確さで、2 つのエントリーポイントの間はコピーではなく再エンコードになる。
本来の参照付きで復号した同じピクチャと比べて 51 dB。良い再エンコードではあるが、
録画そのものではない。だから既定では使わず、指定されたときだけ行う。
もう半分は「どこまで進む価値があるか」である。examples/idrdiag.rs は録画を
走査して、各エントリーポイントから次のクリーンなエントリーポイントまでどれだけ
待つかを表示する。手元の H.264 ディスク 4 枚は見事に食い違った。2 枚は中央値で
1〜3 秒、1 枚は 26 分の本編に IDR がひとつしかなく、中央値は 13 分だった。
そこで到達範囲は 2 秒で打ち切り、その範囲にクリーンな点が無ければプランは元のままに
する。並べ替えの無い素材、つまり MPEG-2 と VC-1 は、そもそも一切動かない。
どちらも各ピクチャの表示順を、その GOP の中で明示するからだ。
この読み取りには録画そのものが必要になる。ディスクではインデックスを走査しておらず、
ディスク自身の表から得ているからだ。そこでプラン作成に録画を手にした入口
plan_on を足し、plan は算術のままにした。パスではなく数回のシークで済む。
各エントリーポイントの先頭バイトはすでに分かっている。
出力タイムラインはフィールドで数えている。プルダウンは 3 フィールド表示するピクチャ を含み、フレーム格子では表せないからだ。これは「そのピクチャが何フィールド分表示 されるか」の答えである。「1 フレームが何枚のピクチャで届くか」の答えではない。そして 後者は常に 1 とは限らない。H.264 は 1 フレームをフィールド 2 枚として符号化できる。 PAFF である。 レコーダーが自分で焼く Blu-ray がまさにこれで、1440x1080 29.97 の全フレームが 2 枚に割れている。
放送の 1080i はインターレース素材をフレームとして符号化している。だから放送の コーパスではここに当たらない。レコーダー盤では 2 箇所が同時に壊れる。
- 配置。 1 枚は半フレームで、タイムラインを 1 フィールド占める。2 ではない。 2 を与えると区間の長さは実際の倍になり、各パケットに書く duration も倍になる。
- 先読み。 DTS は表示順が確定するまでピクチャを抱えておく。何枚抱えるかは録画の 並べ替え深度から取るが、これはフレームを数えた値である。1 枚しか抱えないと、 P フレームの復号時刻を、その手前に表示される B フレームが届く前に確定してしまう。 届いた B は行き場を失って落ちる。40 秒の切り出しで 1842 枚中 642 枚。コピー区間は 最初の 1 枚からブロックノイズの塊になった。
そこでキューは枚数ではなく半フレーム単位で測ることにした。1 フィールド分の ピクチャは半分を 1 つ、それ以外は 2 つ。3 フィールド表示のピクチャも 1 フレームなので 2 つ数える。 境界を動かさないためである。これでフレーム符号化の素材は以前とまったく同じだけ 抱えることになり、大半がフレーム符号化で数組だけフィールド対がある録画も、その 数組の周りだけ余裕が増える。
判定はパケットに対して行う。中のピクチャに対してではない。 libavcodec の MPEG-2
パーサはフィールド対を 1 パケットに結合する。手元の放送 1 本は 30 秒の間に 7 回
それをやっていた。2 枚のピクチャ、1 フレーム、2 フィールドである。H.264 パーサは
フィールドを 1 枚ずつ渡す。だから数えるのはパケットが開始するピクチャの数にした。
H.264 なら first_mb_in_slice が 0 のスライス、MPEG-2 なら picture coding
extension の個数である。開始するピクチャが 1 枚で、それがフィールドのときだけ半フレーム
と判定する。
このフラグを読むにはシーケンスヘッダが先に必要になる。field_pic_flag は frame_num
の後ろにあり、その幅は SPS にしか書かれていない。VC-1 のヘッダと同じく録画ごとに
一度だけ読む。frame_mbs_only_flag が立っているシーケンスなら、ピクチャを 1 枚も見ずに
済む。ただしヘッダだけでは決まらない。同じ録画に対して SmartCut が書き戻す
のは MB-AFF で、そのヘッダもフィールドを許しているが、中のピクチャはすべてフレーム
である。
対の 2 枚目は、自分のタイムスタンプではなく1 枚目の隣に置く。2 枚は復号順で 必ず隣り合うので、1 枚目は直前に見たピクチャで確定する。対に 1 つしかタイムスタンプを 与えない録画、あるいは丸めると同じ位置に来る 2 つを与える録画では、そうしないと 2 枚が同じ場所に重なり、片方が行き場を失う。