← ドキュメント ・ ← 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 は入力のバイト列をそのまま出力する。
アクセスポイントちょうどで切れば、再エンコードは一切発生しない。
作り直すにはエンコーダが要るが、それが存在しないコーデックが 1 つある。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 / HEVC(x264 の
open-gopなど): B ピラミッドがあるため leading picture が参照ピクチャになりうる。捨てると、それを参照していた後続フレームが すべて壊れ、GOP 全体が巻き添えになる。 - 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 を持ち、こちらが既定である。境界がまたがるフレーム
だけを再エンコードし、残りはコピーする。またチャンネル数の指定
(--audio-channels)もあり、5.1ch 録画をステレオに畳むのはこれである。
ダウンミックスにはコピー経路が存在しないので、モードが何であれ全編の再エンコードに
なる。
どちらも音声で詳しく扱う。
なお AAC のエンコーダ遅延・プライミングをエディットリストで厳密に扱うことは実装して いない。
--verify の参照側を -ss で作ってはいけない。オープン GOP では参照側自体が
ずれるので(理由は 6 と同じ)、正しいカットが誤りとして報告される。先頭から
復号し、フレーム番号で切り出す。
tail は「コピーの終端」から「区間の終わり t_out」までである。この 2 つは
ふつう数フレーム離れているが、t_out がアクセスポイントのすぐ後——たとえば
29.97fps のストリームで、絵が 60.05996 秒にあるところへ 60.060 秒を指定した
場合——に来ると、その差は 1 ピクチャの間隔より小さくなる。
こうして生まれた区間には復号すべき絵が 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 つのエントリーポイントの間はコピーではなく再エンコードになる。
同じ絵を本来の参照付きで復号したものと比べて 51dB——良い再エンコードではあるが、
録画そのものではない。だから既定では使わず、頼まれたときだけ行う。
もう半分は「どこまで進む価値があるか」である。examples/idrdiag.rs は録画を
走査して、各エントリーポイントから次の綺麗なエントリーポイントまでどれだけ
待つかを表示する。手元の H.264 ディスク 4 枚は見事に食い違った。2 枚は中央値で
1〜3 秒、1 枚は 26 分の本編に IDR がひとつしかなく、中央値は 13 分だった。
そこで到達範囲は 2 秒で打ち切り、その中に綺麗な点が無ければプランは元のまま
にする。並べ替えの無い素材——MPEG-2 と VC-1、どちらも各ピクチャの表示順を
その GOP の中で明示する——は、そもそも一切動かない。
この読み取りには録画そのものが要る。ディスクではインデックスを走査しておらず、
ディスク自身の表から得ているからだ。そこでプラン作成に録画を手にした入口
plan_on を足し、plan は算術のままにした。パスではなく数回のシークで済む。
各エントリーポイントの先頭バイトはすでに分かっている。