Skip to content

Latest commit

 

History

History
216 lines (163 loc) · 14.5 KB

File metadata and controls

216 lines (163 loc) · 14.5 KB

アルゴリズムと実装上の難所

← ドキュメント← SmartCutEnglish

アルゴリズム

残す区間 [t_in, t_out) について、次のように切り分ける。

残す区間 1 つをコマ単位で見た図。コピーを始められる最初のキーフレームより手前の head と、コピーが届いた最後のキーフレームより後ろの tail だけが再エンコードで、その間はバイト単位のコピー
... I ....... I=========================I ....... I ...
      ^t_in   ^k_first                  ^k_term   ^t_out
    |<-head->|<--------- body --------->|<-tail->|
     再エンコード      ストリームコピー      再エンコード

headtail は GOP の途中で始まる/終わるので、直前のアクセスポイントから 復号して作り直すほかない。その間の body は入力のバイト列をそのまま出力する。 アクセスポイントちょうどで切れば、再エンコードは一切発生しない。

作り直すにはエンコーダが要るが、それが存在しないコーデックが 1 つある。VC-1 である。2010 年頃までにプレスされた Blu-ray の多くがこれで書かれているにも かかわらず、そのエンコーダは libavcodec にも、グラフィックスカードにも、フリーな 実装のどれにも存在しない。そこで SmartCut は、そのピクチャを自分で書く。書くのは イントラピクチャだけである。断片の外を参照してはならない以上、headtail に はそれで足りる。その代償と測り方は Rust コアにある。

Python リファレンス実装の構成は次のとおり。

ファイル 役割
probe.py ストリームのパラメータ、アクセスポイント索引、leading picture の検出、参照判定
planner.py 区間指定をセグメント列に変換する
bitstream.py Annex-B / MPEG-2 のアクセスユニット分割
renderer.py ffmpeg の実行と連結
verify.py 出力を復号し、ソースとフレーム単位で突き合わせる

実装上の難所

「GOP 単位で切って繋ぐだけ」では済まない理由をここに挙げる。いずれも試作の過程で 実際に踏んだ問題であり、いずれも再現を固定するテストがある。

1. パラメータセット(SPS/PPS)が一致しない

再エンコード部分の 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 はそもそもこの問題を持たない。シーケンスヘッダが最初からストリーム内に あるからである。

2. アクセスポイント索引は「パケット」を走査する

ffprobe -skip_frame nokeyオープン GOP のアクセスポイントを取りこぼす。 参照先が無い I ピクチャをデコーダが出力できないからである。試作時のテスト素材では、 実際にある 10 個のうち 3 個しか見つからなかった。

パケットの K フラグを見れば復号そのものが不要になる。速く、かつ正確である。

3. leading picture — オープン GOP 問題の核心

復号順では 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 ではピクチャ開始コードの あとにいくつかのヘッダが続いてからスライスが来るので、アクセスユニットの切り方が 異なる。

4. 区間指定は秒ではなくフレーム数・パケット数で

-t に秒数を渡すと、2 つの理由で狂う。

  • -c copy のもとでは -tDTS に対して評価される。DTS は並べ替え深さの ぶん表示時刻より先行しているので、次の GOP の I/P ピクチャが余分に混入する (180 フレームが 182 フレームになった)。
  • 分数フレームレート(30000/1001)では、丸めによって結果が ±1 フレームずれる。

結局、確実なのは表示フレーム数とコピーするパケット数を整数で数えて渡す方法 だけだった(-frames:v N)。パケット数はアクセスポイント索引の復号順インデックスの 差から厳密に求まる。

5. コンテナの start_time(MPEG-TS)

TS のタイムスタンプは 0 から始まらない(テスト素材では 1.423 秒)。-ss はファイル 先頭からの相対だが、ffmpeg は出力を -ss のぶんだけ基準化するので、start_time は 出力タイムラインに残留オフセットとして残る

秒数指定の -t はこれをまともに受ける。そこで、アクセスポイントの時刻は start_time を引いて正規化し、区間長はフレーム数で渡している。これで両方の問題を 避けられる。

6. 再エンコード区間は「手前から」復号する

オープン GOP のソースでは、-ss で目的位置へ直接シークすると、ffmpeg は復号でき なかった GOP を丸ごと捨てる。その結果、出力が最大 1 GOP ぶん遅れて始まる (実測 0.2 秒)。数 GOP 手前のアクセスポイントから復号を始め、出力側の -ss で 前を切り落とす。

7. 音声は GOP 構造を持たない

音声は映像のセグメント単位ではなく、残す区間ごとに切る。

  • --audio-mode copy はソースのフレームをそのまま使う。区間の境界は最も近い音声 フレームに吸着する(AAC で最大 24 ミリ秒程度)。
  • --audio-mode reencodeatrimconcat フィルタを 1 パス通す。サンプル 精度になるかわりに全編を再エンコードする。

Rust コアはこれに加えて smart を持ち、こちらが既定である。境界がまたがるフレーム だけを再エンコードし、残りはコピーする。またチャンネル数の指定 (--audio-channels)もあり、5.1ch 録画をステレオに畳むのはこれである。 ダウンミックスにはコピー経路が存在しないので、モードが何であれ全編の再エンコードに なる。

どちらも音声で詳しく扱う。

なお AAC のエンコーダ遅延・プライミングをエディットリストで厳密に扱うことは実装して いない。

8. 検証の参照はファイル先頭から全復号する

--verify の参照側を -ss で作ってはいけない。オープン GOP では参照側自体が ずれるので(理由は 6 と同じ)、正しいカットが誤りとして報告される。先頭から 復号し、フレーム番号で切り出す。

9. ピクチャの入らない再エンコード区間を作らない

tail は「コピーの終端」から「区間の終わり t_out」までである。この 2 つは ふつう数フレーム離れているが、t_out がアクセスポイントのすぐ後——たとえば 29.97fps のストリームで、絵が 60.05996 秒にあるところへ 60.060 秒を指定した 場合——に来ると、その差は 1 ピクチャの間隔より小さくなる。

こうして生まれた区間には復号すべき絵が 1 枚も入らない。カッターは中身の無い 窓を拒否する(そうでなければ、絵の出てこない再エンコードが黙って通ってしまう) ので、そのクリップではなく実行そのものが止まる

したがって tail は、head が 1 フレームを要求するのと同じ理由で、半フレーム 以上あるときにだけ作る。Python リファレンス実装も同じ位置に線を引いている。 念のため、フレーム数 0 と算出された再エンコード区間はそもそも出力しない。

10. 継ぎ目の両側のピクチャオーダーカウントは互いのものではない

デコーダはピクチャオーダーカウント順に絵を返す。そして継ぎ目の両側のカウントは、 別々のエンコーダが書いたものである。再エンコードした 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 は算術のままにした。パスではなく数回のシークで済む。 各エントリーポイントの先頭バイトはすでに分かっている。