仙石浩明の日記

2010年9月21日

地デジ MPEG-2 TS の PCR/PTS/DTS ラップアラウンド (PCR Wrap-around) 問題を回避して ffmpeg で PS 変換できるようにしてみた hatena_b

(地上)デジタルTV 放送を Linux サーバで予約録画しまくるようになった今日このごろ、 たまに失敗するのが気になっていた。 失敗といっても、 録画した MPEG-2 TS (Transport Stream) ファイルを VLC で再生する分には問題がないが、 ffmpeg を使って MPEG-2 PS (Program Stream) フォーマットへ変換しようとすると途中で映像が止まってしまう (TS 読み込みに失敗しているので他のフォーマットへの変換もおそらく無理)。

おそらく TS のデータに何らかの誤りがあって ffmpeg が映像データを読むのをそこで止めてしまうためだろうと思っていた。 そりゃ放送なんだからノイズが混じることもあるだろうと思い込んでいたのだが、 よほど電波状況が悪くない限りエラー補正が効くだろうから、 ノイズが原因というのはありそうもない話。

なぜ PS へ変換したいかといえば、 TS のままだとファイルサイズが大きすぎる (2時間の映画番組とかだと 12GB を超える) ので、 PS へ変換し (CM カットなどの編集を行なうときは PS の方が便利)、 さらに MPEG-4 などへ変換してサイズを小さくしたいから (もちろん保存する必要がない番組は見たらすぐ消す)。 ところが PS への変換に失敗した番組は TS のまま保存せざるを得ず、 そういうファイルが増えてきて、 2TB のハードディスクもだんだん手狭になってきた。

そこで、 何が原因で TS の読み込みに失敗しているのか、 まずは調べてみようと思ったのだが、 いかんせんファイルがデカすぎる。 12GB もあるとファイルの内容をダンプするのもままならない。 そこで変換が失敗する近辺のデータだけ抜き出して詳しく調べてみようと考えた。 MPEG-2 TS の仕様を調べてみると、 PCR (Program Clock Reference) なるタイムスタンプが 100msec 以下の間隔で MPEG-2 TS には埋め込まれているらしい。 問題の TS ファイルは、 2時間の番組中、 先頭から 1時間35分41秒ほど経過したあたりで読み込みに失敗するので、 PCR の値を手がかりにその近辺のデータを抜き出そうと考えた次第。

まずは PCR の値を表示する perl スクリプト tsdump.pl を作ってみた (後述する tsrenum.pl に -v オプションを与えることによって同じ出力が得られる)。

% ./tsdump.pl -v < 133000_GR23.ts
0008c4a PCR 24:55:00.570 8073051319+138
001a12d PTS 24:55:01.111 8073100071
001a132 DTS 24:55:01.011 8073091062
001d841 PTS 24:55:00.748 8073067367
0022556 PCR 24:55:00.628 8073056524+157
00226d5 PTS 24:55:01.045 8073094065
0026a65 PTS 24:55:00.769 8073069287
0029dcd PTS 24:55:01.078 8073097068
00300f1 PTS 24:55:00.791 8073071207
0033225 PTS 24:55:01.212 8073109080
003322a DTS 24:55:01.111 8073100071
0038f69 PTS 24:55:00.812 8073073127
003bcea PCR 24:55:00.685 8073061729+175
...

「133000_GR23.ts」 が問題の TS ファイル (23チャンネルなので TV東京 ^^;)。 次の 「0008c4a PCR 24:55:00.570 8073051319+138」 という行は、 ファイル先頭から 0008c4a バイト目に PCR があって、 そのタイムスタンプが 「24:55:00.570」 であることを示す。

行末尾の 「8073051319+138」 は生の PCR の値。 つまり、 PCR は 33bit の 「PCR base」 と 9bit の 「PCR extension」 から構成されるが、 それぞれ 8073051319 と 138 であることを示す。 PCR base は 90kHz の解像度、 PCR extension は 27MHz の解像度。 とりあえずここでは後者は無視して、 前者 8073051319 を 90000 (= 90kHz) で割ると 89700.570 秒で、 時分秒に直すと 24:55:00.570 になる。

PCR の他に、 PTS (Presentation Time Stamp) と DTS (Decode Time Stamp) というタイムスタンプも MPEG-2 TS には埋め込まれている。 これらは PCR と違って 90kHz の解像度の 33bit のデータのみ。 MPEG-2 TS では、 PCR の時刻を基準にして、 復号する時刻 (DTS) と再生する時刻 (PTS) を定めている (音声と映像の同期のためにも使われる)。 前述したプログラムの出力において、 2カラム目に PTS, DTS と出力している行は、 それぞれ PTS, DTS の値であることを示す。

で、 PCR の値を表示させてみていきなり気付いてしまったのだが、 この問題の TS ファイルは PCR の値が 33bit の上限値ギリギリ。 33bit の上限は 0x1FFFFFFFF = 8589934591 だから、 PCR は 26:30:43.717 (26時間30分43秒余) までしかカウントできない。 このファイルは冒頭のタイムスタンプが 24:55:00.570 だから、 冒頭から 01:35:43.147 ほど経過したあたりで PCR の値が桁あふれ (オーバーフロー) を起こして 00:00:00.000 へ戻ってしまう (ラップアラウンド)。

これは冒頭から 1時間35分41秒ほど経過したあたりで ffmpeg が止まってしまう症状にピッタリ符合する (2秒ほど差があるのは MPEG-2 復号に要する時間?) ので、 もう原因は PCR のラップアラウンド (PCR Wrap-around) で間違いないと推測。

「VLC で再生する分には問題がない」 と書いたが、 この問題の TS ファイルを VLC で再生して 1時間35分41秒あたり (VLC の場合 TS 再生時は時刻表示を行なわないのでシーンで探さざるを得ず大変) をよく見てみると、 再生が一瞬 (1秒ほど?) 止まっていた。 26時間半に一度、 必ず起こるラップアラウンドなのに、 VLC でも完全な対策は (まだ) 行なわれていないようだ。

原因調査の準備のために作ったスクリプトが、 いきなり原因究明の役に立つとは (^^;) と思いつつ、 tsdump.pl にタイムスタンプの値を付け替える (すなわち 24:55:00.570 から始まるタイムスタンプを 00:00:00.000 始まりにリナンバーする) 処理を付け加えたスクリプト tsrenum.pl を書いてみた (後述する pcr_write, ts_write 関数を追加しただけ)。

% ./tsrenum.pl < 133000_GR23.ts > 133000_GR23_fixed.ts

tsrenum.pl を使ってタイムスタンプを付け替えた 133000_GR23_fixed.ts は、 ffmpeg を使って PS 変換ができるようになった。 原因調査の準備のために作ったスクリプトが、 ほとんどそのまま問題解決の役にも立ってしまった (^^;)^2。

tsrenum.pl は、 標準入力から読み込んだ MPEG-2 TS において最初に現れた PCR/PTS/DTS タイムスタンプを基準 (下記スクリプト中の $pcrOrg) にして、 タイムスタンプを付け替えて標準出力へ書き出す:

#!/usr/bin/perl
use strict;
use warnings;
use POSIX;
use Getopt::Std;
our ($opt_v);
getopts('v') || &help;
my $PacketSize = 188;
my $offset = 0;
my $pcrOrg;
for (;;) {
    my $buf;
    my $len = read(STDIN, $buf, $PacketSize);
    last unless $len > 0;
    my @buf = unpack("C*", $buf);
    die if $buf[0] != 0x47;        # sync byte
    my $ind = (($buf[1] & 0xE0) >> 5);
    my $adp = (($buf[3] & 0x30) >> 4);
    my $pos = 4;
    if ($adp & 0x2) {        # Adaptation field exist
        $pos++;
        my $af = $buf[$pos++];
        if ($af & 0x10) {
            my ($pcr33, $pcr9) = &pcr(\@buf, $pos);
            printf(STDERR "%07x PCR %s %d+%d\n",
                   $offset+$pos, &stime($pcr33), $pcr33, $pcr9) if $opt_v;
            &pcr_write(\@buf, $pos, $pcr33);
            $pos += 6;
        }
        if ($af & 0x08) {
            $pos += 6;
        }
        if ($af & 0x04) {
            $pos++;
        }
    }
    if ($ind & 0x02) {
        if ($buf[$pos] == 0x00 && $buf[$pos+1] == 0x00
            && $buf[$pos+2] == 0x01 && ($buf[$pos+6] & 0xC0) == 0x80) {
            my $flag = $buf[$pos+7];
            $pos += 9;
            if ($flag & 0x80) { # PTS
                my $ts = &ts(\@buf, $pos);
                printf(STDERR "%07x PTS %s %d\n",
                       $offset+$pos, &stime($ts), $ts) if $opt_v;
                &ts_write(\@buf, $pos, $ts);
                $pos += 5;
                if ($flag & 0x40) { # DTS
                    my $ts = &ts(\@buf, $pos);
                    printf(STDERR "%07x DTS %s %d\n",
                           $offset+$pos, &stime($ts), $ts) if $opt_v;
                    &ts_write(\@buf, $pos, $ts);
                    $pos += 5;
                }
            }
        }
    }
    syswrite(STDOUT, pack("C*", @buf), $len);
    $offset += $len;
}

sub stime {
    my ($ts) = @_;
    my $sec = floor($ts / 90000);        # 90kHz
    $ts -= $sec * 90000;
    my $min = floor($sec / 60);
    $sec -= $min * 60;
    my $hour = floor($min / 60);
    $min -= $hour * 60;
    return sprintf("%02d:%02d:%02d.%03d", $hour, $min, $sec, $ts/90);
}

sub help {
    print STDERR <<EOF;
Usage: tsrenum.pl <opt>
opt:   -v            ; verbose
EOF
    exit 1;
}

MPEG-2 TS を標準入力から 1パケットずつ @buf に読み込んで、 PCR を見つけたら
pcr(\@buf, $pos) 関数を呼び出して PCR base の値 $pcr33 を取得し、
pcr_write(\@buf, $pos, $pcr33) 関数で PCR base の値を変更して標準出力へ書き出す。 PTS, DTS についても同様に、 ts(\@buf, $pos) 関数を呼び出して値を取得し、 ts_write(\@buf, $pos, $ts) 関数で変更する。

ISO/IEC 13818-1 の 2.4.3.4節 Adaptation field (20ページ) の Table 2-6 - Transport Stream adaptation field を見ると、 adaptation field の 2バイト目から 33bit が PCR base の値、 続いて 6bit の予約ビット、 その次の 9bit が PCR extension の値であることが分かる。 したがって pcr(\@buf, $pos) 関数は以下のように書ける:

sub pcr {
    my ($br, $pos) = @_;
    my $pcr33 = (($br->[$pos] << 25) | ($br->[$pos+1] << 17)
                 | ($br->[$pos+2] << 9) | ($br->[$pos+3] << 1)
                 | (($br->[$pos+4] & 0x80) != 0));
    my $pcr9 = ((($br->[$pos+4] & 0x01) << 8) | $br->[$pos+5]);
    return ($pcr33, $pcr9);
}

pcr_write(\@buf, $pos, $pcr33) 関数は PCR base の値 $pcr33 から $pcrOrg を減算してから pcr(\@buf, $pos) 関数の逆を行なうだけ:

sub pcr_write {
    my ($br, $pos, $pcr33) = @_;
    $pcrOrg = $pcr33 unless defined $pcrOrg;
    $pcr33 -= $pcrOrg;
    $br->[$pos] =   (($pcr33 >> 25) & 0xFF);
    $br->[$pos+1] = (($pcr33 >> 17) & 0xFF);
    $br->[$pos+2] = (($pcr33 >>  9) & 0xFF);
    $br->[$pos+3] = (($pcr33 >>  1) & 0xFF);
    if ($pcr33 & 1) {
        $br->[$pos+4] |= 0x80;
    } else {
        $br->[$pos+4] &= 0x7F;
    }
}

ISO/IEC 13818-1 の 2.4.3.7節 Semantic definition of fields in PES packet (31ページ) の Table 2-17 - PES packet を見ると、 PES (Packetized Elementary Stream) の 9バイト目から marker bit を挟みつつ 33bit の PTS と DTS が格納されていることが分かる。

したがって ts(\@buf, $pos) 関数と ts_write(\@buf, $pos, $ts) 関数は以下のように書ける:

sub ts {
    my ($br, $pos) = @_;
    return ((($br->[$pos] & 0x0E) << 29)
            | ($br->[$pos+1] << 22) | (($br->[$pos+2] & 0xFE) << 14)
            | ($br->[$pos+3] << 7)  | (($br->[$pos+4] & 0xFE) >> 1));
}

sub ts_write {
    my ($br, $pos, $ts) = @_;
    $pcrOrg = $ts unless defined $pcrOrg;
    $ts -= $pcrOrg;
    $br->[$pos] =   ((($ts >> 29) & 0x0E) | ($br->[$pos] & 0xF1));
    $br->[$pos+1] =  (($ts >> 22) & 0xFF);
    $br->[$pos+2] = ((($ts >> 14) & 0xFE) | ($br->[$pos+2] & 0x01));
    $br->[$pos+3] =  (($ts >>  7) & 0xFF);
    $br->[$pos+4] = ((($ts <<  1) & 0xFE) | ($br->[$pos+4] & 0x01));
}

すべて解決した後で気付いたのだが (^^;)、 ラップアラウンドする TS ファイルを読み込んだときは ffmpeg が的確なエラーメッセージを出力していた:

...
frame= 1328 fps= 21 q=2.0 size=   31898kB time=44.00 bitrate=5938.8kbits/s dup=24 drop=0    
frame= 1341 fps= 21 q=2.0 size=   32002kB time=44.45 bitrate=5898.1kbits/s dup=24 drop=0    
[mpegts @ 0x6253c0]Invalid timestamps stream=0, pts=4078, dts=8589929661, size=53459
*** drop!
*** 1 dup!
*** drop!
*** drop!
*** drop!
*** drop!
*** drop!
adding -2147483648 audio samples of silence
adding -2147483648 audio samples of silence
*** drop!
...

「Invalid timestamps ... pts=4078, dts=8589929661」 すなわち PTS が 00:00:00.045 なのに、 DTS が 26:30:43.663 なので無効なタイムスタンプである、 というエラー出力。 タイムスタンプがラップアラウンドしたときは、 「Invalid timestamps」 とは言えないと思うのだが...

念のため ffmpeg の最新版を 「git clone git://git.ffmpeg.org/ffmpeg/」 で取得してコンパイルしてみたのだが、 「FFmpeg version git-735bbae」 でも同様に 「Invalid timestamps」 エラーが出力された。

Filed under: プログラミングと開発環境 — hiroaki_sengoku @ 08:13

1 Comment

  1. Due to me not knowing a word in japanese language and spam control on
    your site I’m unable to post comments. Sorry for this and my email!

    I just wanted to let you know your article helped me as I was solving
    very similar problem and also to say thank you for your code and ideas.

    Just one note – I process 24 hours of recording (~ 40GB MPEG TS) and its
    terribly slow. Do you have any idea how to speed up your script? …my
    perl knowledge sucks so I’m considering rewrite it to C.

    Thank you and take care!

    Comment by Kamil Šrot — 2013年1月4日 @ 04:52

RSS feed for comments on this post.

Sorry, the comment form is closed at this time.