← ドキュメント一覧 ・ ← SmartCut ・ English
配布しているのは移植したこちらで、Python 実装はテストオラクルとして残してある。 「先頭フレームが 13ms 早い」制限は解消済み — 下記参照。
| 部分 | 状態 |
|---|---|
| アクセスポイント索引・leading picture 解析 | 完了(Python と出力完全一致) |
| leading picture の参照判定 | 完了・Python より正確 |
| planner | 完了(11ケースで Python と一致) |
| カット(コピー経路) | 完了 |
| カット(再エンコード経路) | 完了 |
| SPS/PPS 混在の解決 | 完了(avc3 + パラメータセット再挿入) |
| 音声(コピー) | 完了(同期を実測検証) |
| 音声(スマートレンダリング) | 完了(境界のフレームだけ焼き直す) |
| 音声(再エンコード) | 完了(サンプル精度、MPEG-2 AAC で出る) |
映像は tests/run_rust_tests.sh の 13 ケースが
tests/run_tests.sh(Python)と同一の無劣化率になる:
h264 single range lossless 180/222 first=0.00000 step=0.033333 jitter=0
h264 cut middle lossless 540/540 first=0.00000 step=0.033333 jitter=0
hevc lossless 300/342 first=0.00000 step=0.033333 jitter=0
ntsc 29.97fps lossless 300/342 first=0.00000 step=0.033367 jitter=0
mpeg2 ts open-GOP lossless 328/342 first=0.00000 step=0.033367 jitter=0
そのうえで全ケースでタイムスタンプが完璧(Python 版は先頭が 13ms 早い)。
Python 版は raw ES を ffmpeg に渡すしかなく、先頭フレームが 13ms 早くなる
(irregular=[(0, 0.046667)])。Rust 版は各ピクチャの表示インデックスから
整数のティックで PTS/DTS を直接振る(出力タイムベースは 1/fps分子
なので 1 フレーム = fps分母 ティック、丸めが一切発生しない):
h264 keyframe-exact lossless 180/180 first=0.00000 step=0.033333 jitter=0
mpeg2 ts open-GOP lossless 283/283 first=0.00000 step=0.033367 jitter=0
tests/run_rust_tests.sh で検証。全ケースで先頭 0.000 秒ちょうど、乱れゼロ。
MP4 の avcC はパラメータセットを 1 組しか持てないが、再エンコード部の SPS は
元ストリームと必ず異なる。さらに MP4 は NAL を length-prefixed で格納するのに
エンコーダは Annex-B を吐く。両方を放置すると
sps_id 32 out of range / Invalid NAL unit size で映像が崩壊する。
- sample entry を
avc3/hev1にしてインバンドのパラメータセットを許可 - エンコーダ出力を Annex-B → length-prefixed に再フレーミング
- コピー部の各キーフレームの手前に、元の SPS/PPS を再挿入する — 再エンコード部の SPS が有効化されたままだとコピー部が誤った SPS で 復号されるため、スプライスのたびに元のパラメータセットを復帰させる
音声は GOP 構造を持たないので、映像セグメントではなく保持区間単位で 扱う。各区間の音声をその区間の映像が始まる出力時刻にアンカーするので、 区間をまたいだドリフトが原理的に起きない……はずだった。
実際には落とし穴があった。MP4 の音声トラックはサンプルの尺(stts)で 時間を表現し、パケットごとのタイムスタンプを保持しない。 保持するのは トラックの開始オフセットだけで、あとはサンプルを連続配置する。つまり 区間境界で音声フレームを 1 つ落とすと、それ以降の全区間が恒久的にずれ、 区間ごとに累積する。クリック音を 0.5 秒ごとに置いた素材で実測したところ、 2 番目の区間が一律 11ms 早くなっていた。
対策は、書き出し済み音声の実際の終端位置を追跡し、その誤差を次の区間の 開始フレーム選択に持ち越すこと。境界に最も近いフレームで開くので、誤差は ±半フレーム(AAC 48kHz で約 10.7ms)に有界で、累積しない。 3 区間・境界を全てフレーム非整列にしたテストで最悪 +7.67ms を確認。
コンテナ側でトリムする道は 2 つ試して、どちらも塞がっていた:
AV_PKT_DATA_SKIP_SAMPLES(ギャップレス再生用のサイドデータ)— MP4 muxer が解釈しない。スキップ指定したサンプルがそのまま残り、音声が 指定量ちょうど遅れた。- 出力ストリームの
initial_padding— muxer がファイルに書かない (出力を probe するとinitial_padding=0)。試したところ単一区間の誤差が 0.00ms から +9.33ms に悪化した。
残るのは再エンコードだけなので、--audio-mode reencode として実装した。
各区間の音声をサンプル単位で切り出して 1 本のエンコーダに連続して流す。
| モード | 境界誤差 | 継ぎ目に残る音 | 焼き直すフレーム |
|---|---|---|---|
copy |
±半フレーム(AAC 48kHz で 10.7ms)、累積なし | 切った側の音が最大 21.3ms | なし |
smart(既定) |
同上 | 残らない(無音になる) | 境界 1 か所につき最大 2、無音の中で切れば 0 |
reencode |
-0.02ms(1 サンプル = 0.021ms なので実質ゼロ) | 残らない | 全部 |
tests/run_audio_tests.sh を SMARTCUT_AUDIO=reencode で走らせると全 5 ケースが
-0.02ms に収まる。映像側はどのモードでも無劣化のまま(実素材で 1798/1798、99.1%)。
既定は smart。 普通のカット——CM を無音の中で落とす——では出力が copy と
バイト単位で一致するので、既定にしても失うものが無い。差が出るのは音の途中で
切ったときだけで、そこでは触るほうが良い。バイト単位の完全一致を保証したい
用途には copy が残してある。10.7ms の境界誤差自体はどちらも同じで、
リップシンクの知覚閾値をはるかに下回る。
実装で踏んだ落とし穴: セグメント境界にまたがる音声フレームが隣り合う 2 つの セグメントから二重に投入され、AAC 1 フレーム(21.3ms)ずつ誤差が累積した。 コピー経路と同じ排他的な取り合い規則に揃えて解決。
tests/run_audio_tests.sh — 2ms のインパルスを 0.5 秒ごとに置いた素材で
A/V 同期をサンプル単位で実測する。定常音では同期のズレは検出できない。
コピーで残るのは境界誤差だけではない。境界にまたがるフレームは丸ごと コピーされるので、その中に入っている「切り落とした側の音」もそのまま出る。 CM の最後の一音が本編の頭に乗るのはこれで、継ぎ目 1 か所につき最大 21.3ms、 継ぎ目の数だけ出る。
smart は、境界がまたがるフレームだけを焼き直す。素材そのもののサンプルから 作り直し、範囲の外側は 1ms のレイズドコサインで無音に落とす。他のフレームは 1 バイトも触らない。実素材(日本テレビ、2 区間・4 境界)で 5606 フレーム中 5602 フレームが素材のバイトそのまま、書き換えたのは 4 個。
境界そのものは動かない。 フレームを 2 つ同じ時刻には置けない——MP4 は サンプルを端から並べるだけだし、MPEG-TS は逆行するタイムスタンプを拒否する ——ので、区間の長さは必ずフレーム単位に丸まる。またがるフレームを開始側に 使えば取りこぼしはゼロになるが、それは 1 つ前の区間の(同じ理由で はみ出している)最後のフレームと同じ時刻を取り合うことになる。 どのモードも「区間はフレーム単位・誤差は中央寄せ」で揃えてある。 smart が変えるのは、またがるフレームの残り半分に何が入っているかだけ。
隣に 1 フレーム、ガードフレームも焼き直す。AAC は窓を半分ずつ重ねて復号 するので、焼き直したフレームがコピーしたフレームと直に隣り合うところだけは 窓の形が食い違いうる。フレームの途中を無音に落とすのはトランジェントで、 トランジェントはエンコーダに短窓を選ばせる——それがまさに食い違いになる。 ガードは素材そのままの音を焼き直して、窓の切り替えをコピー部から 1 フレーム 遠ざける。開始側のフレームが(「一番近いフレーム」規則で)採用されなかった ときは、守るものが無いのでガードも焼かない。
切り落とす側が既に無音なら、何も焼き直さない。 CM は前後の無音の中で切る
ものなので、境界がまたがるフレームの向こう半分はたいてい無音で、そこを
焼き直しても消すものが無い。判定はそのフレームの範囲外サンプルのピークが
-60dBFS を超えるかどうか。実素材の CM ブロック 1 つを落とすカット
(境界 4 つとも無音の中)では焼き直し 0 フレーム=出力が copy と
バイト単位で完全一致する。焼き直しが働くのは、無音でないところで切った
ときだけ。
この設計は TMPGEnc MPEG Smart Renderer 6.1 の出力を素材と突き合わせて 決めた。あちらは音声を 1 フレームも焼き直していない(24 分の出力 67499 フレームのうち 67492 = 99.99% が素材の符号化データそのまま、 残り 7 つも継ぎ目とは無関係な位置)。継ぎ目 3 か所とも -91.0dBFS の デジタル無音の中にあり、そこでフレーム単位に切れば何も失われないからだ。 (ただしあちらはフレームを CBR 256kbps に合わせてパディングを打ち直して いるので、バイト単位で一致するのは 24.7% にとどまる。音のデータは無傷。)
差し替えられるかどうかは、エンコーダの遅延がフレームの整数倍かで決まる。 差し替えるフレームは素材のフレームと同じサンプル範囲を覆っていなければならず、 遅延が半端だと出てくるパケットが素材のフレーム格子から常にずれる。AAC は 1024(=1 フレーム)で合う。AC-3 は 256 で合わない。走る前に一度エンコーダを 開いて確かめ、合わなければそう言ってコピーに落とす。
日本の放送が積んでいるのは MPEG-2 AAC(ADTS の ID ビットが 1、LC、
48kHz、CRC 付き)。ffmpeg の AAC エンコーダが吐くのは MPEG-4 AAC で、raw AAC を
ADTS に組み直す muxer は ID = 0 しか書かない。ADTS muxer には write_mpeg2 が
あるが、MPEG-TS muxer から内部の ADTS muxer へそれを渡す道が無い。
そのままでは「ほとんど MPEG-2、継ぎ目だけ MPEG-4」という、放送録画の後段に
置くツールが素直に読めない stream になる。
なので ADTS ヘッダはこちらで組み立てる(adts.rs)。素材のフレームから
profile・サンプリング周波数・チャンネル構成・ID を写し取り、エンコーダの
出力に被せる。すでに sync word で始まっているパケットはどの muxer も
素通しするので、これで MPEG-TS はそのまま書き、MP4 はコピーしたフレームと
同じ aac_adtstoasc にかけてくれる。
- 既定は素材に合わせる(
--aac auto)。放送素材なら MPEG-2 のまま出る。 - 被せるのはこのツールが作ったフレームだけ。コピーしたフレームのヘッダには
触らない。したがってコピーが混ざる限り素材と違う版は出せない——出せば
「一部だけ別形式」になる——ので、食い違う指定は注意書きを出して無視する。
--audio-mode reencodeはコピーが無いので指定どおりに出る。どちらも テストで確かめている。 - 中身も MPEG-2 AAC LC に収まるように、PNS(MPEG-4 の道具)を切ってある。
- 書き出すフレームは CRC 無し(
protection_absent=1)。CRC はフレームごとの フィールドなので CRC 付きの間に混ざって構わない。間違った CRC を書けば 検査する復号器に捨てられる——書かないほうが安全。 - 同じ枠を
--audio-mode reencodeでも使うので、TS への全再エンコードも MPEG-2 AAC で出る。ffmpeg 単体では作れない組み合わせ。
tests/run_aac_tests.sh — 出力の ADTS を 1 フレームずつ歩いて、全部が
MPEG-2 LC であること、素材のバイトそのままのフレームが何個かを数える。
復号器には聞けない、バイトについての問いなので、専用のテストにしてある。
5.1ch の録画はある。そしてそれが行き着く先の多くは 5.1ch を求めていない——
畳み方の下手なテレビ、センターに乗ったセリフを鳴らさない再生機、スマートフォン。
2ch を指定するとは音声を作り直せということで、これはモードの下に付く設定では
なく、モードを決めてしまう設定である。5.1ch のフレームをステレオのトラックへ
継ぐことはできないので、smart も copy もここでは差し出すものが無い。素材と
違うチャンネル数は全編の焼き直しになり、その旨を告げて実行する。
畳む計算そのものは swresample のもの、つまり係数は libav 自身のもの——センターは -3dB で左右へ、サラウンドはそれぞれの側へ、LFE は捨てる。出てくるのは、その録画を 再生機がダウンミックスしたときの音そのものである。それが狙いで、直したいのであって 作り変えたいのではない。
- 入りと出のレートは同じで、それがこれをフレーム単位の処理に留めている。 swresample はフレームのサンプルを 1 対 1 で返し、持ち越さない。だから各区間を 切り出すサンプル窓は素材の時計の上での意味を保ったままで、境界はサンプル精度の ままである。
- ADTS ヘッダも書き換える。 トランスポートストリームでフレームのチャンネル数を
名乗るのはフレーム自身のヘッダで、ここで引き継ぐヘッダは素材のものだ。放っておくと
ステレオの中身に 5.1ch と書いたヘッダが付き、中身よりヘッダを信じるデコーダは
どちらも得られない。
AdtsFormat::with_channelsがchannel_configを直す。 自分の構成番号を持たないチャンネル数(7ch など)はそのままにする——そういう ストリームはフレーム内の program config element で自分を説明するからである。 - チャンネル数が変わったら、出力ストリームの parameters はエンコーダから取る。 フレーム化の有無によらない。素材の parameters は、もうこのファイルに入っていない トラックを説明しているからだ。フレーム化には何の影響も無い——すでに同期語で始まる パケットは、extradata が何と言おうとそのまま通る。
- 素材から導くビットレートはチャンネル数のぶん下げる。 384kbps は 5.1ch に
かかっていた値であって、畳んだ先のステレオに見合う値ではない。素材から取った値を
畳んだ比率で割り、128kbps を下限とする。
--audio-bitrateを指定したときは そのまま使う。
tests/run_downmix_tests.sh — チャンネルごとに違う純音を入れた 5.1ch の素材を使い、
畳んだ結果をメタデータではなくスペクトルから読む。センターは出力の両チャンネルに、
左サラウンドは左だけに、LFE はどこにも出てはいけない。ADTS の channel_config も
確かめ、最後に 5.1ch のインパルス素材で畳んだ先の A/V 同期を測る——最悪 0.83ms、
全編焼き直しに課している 1ms の基準に対しての値である。
二か国語放送は音声を 2 本、別々の PID で送る。以前は
best(Type::Audio) で 1 本だけ拾っていたので、副音声は読まれもしなかった。
いまは Source.audios が録画の持つ音声を全部並べる。Source.audio は
そのうちの「主音声」で、これは残してある——CM 検出も、プレビュー再生も、
.aac の書き出しも、読むのは 1 本だからである。
出力側は、トラックごとに独立に切る。これが要点で、書き手の側の状態を 1 組から 1 組ずつに割った理由でもある: 2 本のトラックはフレームの落ちる 瞬間が違うので、
- 継ぎ目がフレームの内側に落ちる位置が違う(焼き直す枚数が違う)
- 区間ごとの累積ずれ(drift)が違う
- ADTS の形が違うことすらある(片方が MPEG-2 AAC で片方が MPEG-4)
片方の答えをもう片方に流用すると、もう片方が 1 フレームずつ遅れていく。
なので AudioTrack が 1 本ぶんの状態を全部持ち——出力ストリーム番号、
書き終えた位置、直前のフレームの pts、焼き直したフレームの表、
リエンコーダ——Writer はそれを並べて持つ。
--audio-channels のようなオプションは各トラックに同じように効く。
ただし判断はトラックごとで、5.1ch を畳めと言われたトラックだけが
全編焼き直しになり、もともとステレオのトラックはコピーのままである。
要らないトラックは --drop-stream <ストリーム番号>(GUI ではカット編集
画面のトラックメニュー)で外す。既定は全部書く: 誰も尋ねられていない
トラックは録画に入っていたトラックであって、黙って落とすのは
「この録画は何のためのものか」をこちらが決めることになる。
音声を焼き直す経路には、1 フレームぶんの取り違えがあった。エンコーダの
遅延を initial_padding から自分で数え、先頭のパケットを捨てたうえで
タイムスタンプからも同じ量を引いていた——二重に引いていた。
libav のエンコーダは遅延を負のタイムスタンプで申告する。最初のパケットが
pts = -1024(送る前の窓)で出てきて、次が pts = 0(送った最初のフレーム)。
つまりパケットが言っているとおりに読めばいい。負のものを捨てて、
あとは pts をそのまま位置として使う。run_audio_tests.sh の
A/V 尺ずれが reencode で最大 21.3ms(ちょうど 1 フレーム)だったのが、
これで最大 16.0ms・平均 6.4ms に下がった。
インパルス素材で測れるのは「作った素材でどうか」までで、「実際の放送録画を 切ったとき、出てきた音は本当にその場所の音か」は別の問いだ。そこで出力から 6 秒を復号し、録画のどこと一致するかを相互相関で探す。落としても、消しても、 場所を間違えても通らない。
見るべきはずれの絶対値ではなく、区間ごとにずれが変わるかどうか。デコーダの priming も器の開始時刻もどこでも同じだけ乗るので、一定のずれは測定系のもの。 変わったらそれが継ぎ目の誤り。
2 区間・実素材(日本海テレビ 30 分)での実測:
| 音声 | 器 | 相関 | 継ぎ目でのずれ幅 |
|---|---|---|---|
| コピー | MP4 | 1.000 | 31.0ms |
| コピー | TS | 1.000 | 31.0ms |
| サンプル精度 | MP4 | 1.000 | 0.0ms |
| サンプル精度 | TS | 1.000 | 0.0ms |
音は間違いなく出ている——全区間で相関 1.000、RMS も素材と 1dB 以内。 そのうえで、コピーのままだと継ぎ目 1 か所につき最大 30ms ほど音がずれ、 継ぎ目の数だけ積み上がる。AAC は 1 フレーム 21.3ms の粒でしか切れないので、 境界の丸めがそのまま出る。器を MP4 にしても TS にしても同じ 31.0ms なので、 これは多重化ではなく切り出し側の性質。
サンプル精度で焼き直せば 0.0ms になる(--audio-mode reencode)。ただし
窓からは外してある。理由は 2 つあって、どちらも実測から出ている:
- 実際の CM カットでは、コピーのままでもずれが出なかった。 上の 31.0ms は 無劣化点に乗っていない境界を狙って作った 2 区間での値。CM 検出の境界は アクセスポイントへ吸着させるので、実素材 5 区間の書き出しでは 6 か所すべて +5.7ms で一定——区間をまたいでも動かない。ずれは継ぎ目ごとの丸めであって 積み上がるものではなく、境界の置き方でほぼ消える。
- 焼き直すと ADTS の形が変わり、下流が壊れる。 後述のとおり、
日本の放送 AAC は
ff f8(MPEG-2・CRC あり)だが ffmpeg はff f1(MPEG-4・CRC なし)しか書けない。ARIB 前提の索引ソフトはこれを読み損ねる。
得るもの(ほぼ 0ms の改善)より失うもの(下流が読めない)のほうが大きい、
という判断。エンジンと CLI には残してあるので、必要なら
--audio-mode reencode で出せる。
Python はビットストリームを読むのに ffmpeg をもう一度起動する必要があった
ため、ファイル内の1箇所をサンプルして全体に適用していた。Rust では
パケットを走査するその場で nal_ref_idc を読めるので、アクセスポイント
ごとに厳密に判定している。追加のパスもコストもかからない。