Skip to content

Latest commit

 

History

History
268 lines (207 loc) · 19.1 KB

File metadata and controls

268 lines (207 loc) · 19.1 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 は入力のバイト列をそのまま出力する。 アクセスポイントちょうどで切れば、再エンコードは一切発生しない。

作り直すにはエンコーダが必要になる。ところが 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(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 はピクチャ開始コードの あとにヘッダがいくつか続いてからスライスが来るので、切り方が変わる。

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 を持ち、こちらが既定である。境界をまたぐフレーム だけを再エンコードし、残りはコピーする。5.1ch 録画をステレオに畳むのはチャンネル数の 指定(--audio-channels)で、こちらにはコピー経路が無いので、モードが何であれ 全編の再エンコードになる。

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

AAC のエンコーダ遅延とプライミングを、エディットリストで厳密に扱ってはいない。

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

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

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

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

こうして生まれた区間には復号すべきピクチャが 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 つのエントリーポイントの間はコピーではなく再エンコードになる。 本来の参照付きで復号した同じピクチャと比べて 51 dB。良い再エンコードではあるが、 録画そのものではない。だから既定では使わず、指定されたときだけ行う。

もう半分は「どこまで進む価値があるか」である。examples/idrdiag.rs は録画を 走査して、各エントリーポイントから次のクリーンなエントリーポイントまでどれだけ 待つかを表示する。手元の H.264 ディスク 4 枚は見事に食い違った。2 枚は中央値で 1〜3 秒、1 枚は 26 分の本編に IDR がひとつしかなく、中央値は 13 分だった。 そこで到達範囲は 2 秒で打ち切り、その範囲にクリーンな点が無ければプランは元のままに する。並べ替えの無い素材、つまり MPEG-2 と VC-1 は、そもそも一切動かない。 どちらも各ピクチャの表示順を、その GOP の中で明示するからだ。

この読み取りには録画そのものが必要になる。ディスクではインデックスを走査しておらず、 ディスク自身の表から得ているからだ。そこでプラン作成に録画を手にした入口 plan_on を足し、plan は算術のままにした。パスではなく数回のシークで済む。 各エントリーポイントの先頭バイトはすでに分かっている。

11. 1 フレームが 2 枚のピクチャで届くことがある

出力タイムラインはフィールドで数えている。プルダウンは 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_flagframe_num の後ろにあり、その幅は SPS にしか書かれていない。VC-1 のヘッダと同じく録画ごとに 一度だけ読む。frame_mbs_only_flag が立っているシーケンスなら、ピクチャを 1 枚も見ずに 済む。ただしヘッダだけでは決まらない。同じ録画に対して SmartCut が書き戻す のは MB-AFF で、そのヘッダもフィールドを許しているが、中のピクチャはすべてフレーム である。

対の 2 枚目は、自分のタイムスタンプではなく1 枚目の隣に置く。2 枚は復号順で 必ず隣り合うので、1 枚目は直前に見たピクチャで確定する。対に 1 つしかタイムスタンプを 与えない録画、あるいは丸めると同じ位置に来る 2 つを与える録画では、そうしないと 2 枚が同じ場所に重なり、片方が行き場を失う。