PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

字幕の不具合で WebKit にバグレポートを出した顛末記

こんにちは、クラウド推進技術部開発第1グループの石川です。

フルスタックエンジニアとして、システムプログラミングからWebプログラミングまで幅広く手を出していますが、最近は機械学習が加わりました! 生成AIや、画像認識、超解像などのモデルも組めるフルスタックエンジニアになろうとしているこの頃です。

今回は、Safari で動画を再生する際の字幕の表示がおかしくなる、という不具合の指摘から、WebKit へバグレポートを送り、修正されるまで、という顛末記になります。

起きていた不具合

Safari の Native HLS 再生の際に WebVTT 字幕を In-band (HLS で決められた M3U8 形式) で送った際、本来複数の字幕が同時に表示されるべき場合に、1 つしか表示されず、更にそれ以降の字幕のタイミングが全て後にずれてしまう、というものです。

暫定的な対応はしたのですが、恒久的な対応を行う必要があり、深堀りして調査する事になりました。

不具合の切り分け

今回の問題がどこで発生しているのか、あたりをつけるために、色々な条件で試し、事象の切り分けをしました。

  • video 要素に HLS に複数の字幕が同時に出る WebVTT を入れる => 表示不正が起こる
  • video 要素に track 要素で複数の字幕が同時に出現する WebVTT を入れる => 正常表示される
  • MSE (Media Source Extension) を使うプレイヤーで HLS に当該字幕を入れる => 正常表示される
  • 当該字幕を複数の字幕が同時に出ないように修正して HLS に入れる => 正常表示される
  • iOS の AVPlayer で Native で HLS 再生した際に表示されるか試す => 正常表示される

以上の結果から、Safari の Native HLS 再生の際に、字幕が HLS (M3U8) として送られた上で、同時に字幕が出現する瞬間に何かしらの不整合が起きていると判断しました。

Safari の中で AVPlayer の字幕取得API からブラウザの TextTrack に送るまでの部分が怪しい、という目星が付いたため、この部分を担っている WebKit の実装を追うことにしました。

macOS/iOS の字幕情報取得API の仕様調査

まず、macOS/iOS では、AVPlayer で字幕の中身を取得する場合 AVPlayerItemLegibleOutput を利用します。

WebKit のコードを読むと、AVPlayerItemLegibleOutput の引数の mediaSubtypesForNativeRepresentation に対して FourCC を渡しています。この FourCC に応じて、LegibleOutput の nativeSampleBuffers の中身が変化するため、何を使ってるか調べました。

渡す FourCC としては kCMSubtitleFormatType_3GTextkCMSubtitleFormatType_WebVTT があります。しかし、kCMSubtitleFormatType_3GText では、WebVTT の詳細な情報が取得できず、そもそもブラウザが VTTCue を作るための情報がありませんでした。このため、WebVTT の際はこちらを引数として利用していないということが推察されます。

また、kCMSubtitleFormatType_WebVTT の場合は、ISO/IEC 14496-30 で規定された ISOBMFF 形式の WebVTT の情報が取得できました。こちらには WebVTT の VTTCue を作るための情報が全て表現されています。

さらに、ObjC 側では Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateAVFoundationObjC.mmkCMSubtitleFormatType_WebVTT 固定で利用している記載があります。

MediaPlayerPrivateAVFoundationObjC.mm

RetainPtr<NSArray> subtypes = adoptNS([[NSArray alloc] initWithObjects:@(kCMSubtitleFormatType_WebVTT), nil]);
m_legibleOutput = adoptNS([PAL::allocAVPlayerItemLegibleOutputInstance() initWithMediaSubtypesForNativeRepresentation:subtypes.get()]);
[m_legibleOutput setSuppressesPlayerRendering:YES];

以上から、WebKit は WebVTT の情報の取得に、AVPlayerItemLegibleOutput を利用しており、kCMSubtitleFormatType_WebVTT を指定し、Native HLS 再生時に ISOBMFF の形式で WebVTT の字幕情報を取り扱っている ということが分かりました。

WebKit の調査

というわけで、WebKit の LegibleOutput 周りの処理を調査します。ここが正しくなければ、これ以降の処理を追う必要もなく、おかしくなるためです。

上記の Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateAVFoundationObjC.mm のコールバックは以下になります。

MediaPlayerPrivateAVFoundationObjC.mm

- (void)legibleOutput:(id)output didOutputAttributedStrings:(NSArray *)strings nativeSampleBuffers:(NSArray *)nativeSamples forItemTime:(CMTime)itemTime
{
    UNUSED_PARAM(output);

    ensureOnMainThread([self, strongSelf = retainPtr(self), strings = retainPtr(strings), nativeSamples = retainPtr(nativeSamples), itemTime]() mutable {
        if (!m_player)
            return;

        m_player->queueTaskOnEventLoop([player = m_player, strings = WTFMove(strings), nativeSamples = WTFMove(nativeSamples), itemTime] {
            if (!player)
                return;

            ScriptDisallowedScope::InMainThread scriptDisallowedScope;

            MediaTime time = std::max(PAL::toMediaTime(itemTime), MediaTime::zeroTime());
            player->processCue(strings.get(), nativeSamples.get(), time);
        });
    });
}

ここで呼び出されている processCue の内容は同一ファイルの以下の部分であり、TextTrack の processCue 関数を呼び出しています。

MediaPlayerPrivateAVFoundationObjC.mm

void MediaPlayerPrivateAVFoundationObjC::processCue(NSArray *attributedStrings, NSArray *nativeSamples, const MediaTime& time)
{
    ASSERT(time >= MediaTime::zeroTime());

    if (!m_currentTextTrack) {
        ALWAYS_LOG(LOGIDENTIFIER, "no current text track");
        return;
    }

    m_currentTextTrack->processCue((__bridge CFArrayRef)attributedStrings, (__bridge CFArrayRef)nativeSamples, time);
}

この TextTrack は、今回追う対象では Source/WebCore/platform/graphics/avfoundation/InbandTextTrackPrivateAVF.cpp で定義されており、当該部分を見ると、以下のように場合分けがあります。

InbandTextTrackPrivateAVF.cpp

void InbandTextTrackPrivateAVF::processCue(CFArrayRef attributedStrings, CFArrayRef nativeSamples, const MediaTime& time)
{
    if (!client())
        return;

    processAttributedStrings(attributedStrings, time);
    processNativeSamples(nativeSamples, time);
}

今回は kCMSubtitleFormatType_WebVTT を対象とするため nativeSamples に、目的の vttc box が入っています。ちなみに、この時は atrributedStrings の方は空配列で情報は何もありません。

では nativeSamples を処理する processNativeSamples を参照すると、以下のコードが目に入ります。

InbandTextTrackPrivateAVF.cpp

void InbandTextTrackPrivateAVF::processNativeSamples(CFArrayRef nativeSamples, const MediaTime& presentationTime)
{
    using namespace PAL;

    if (!nativeSamples)
        return;

    CFIndex count = CFArrayGetCount(nativeSamples);
    if (!count)
        return;

    INFO_LOG(LOGIDENTIFIER, count, " sample buffers at time ", presentationTime);

    for (CFIndex i = 0; i < count; i++) {
        RefPtr<ArrayBuffer> buffer;
        MediaTime duration;
        CMFormatDescriptionRef formatDescription;
        if (!readNativeSampleBuffer(nativeSamples, i, buffer, duration, formatDescription))
            continue;

        auto view = JSC::DataView::create(WTFMove(buffer), 0, buffer->byteLength());
        auto peekResult = ISOBox::peekBox(view, 0);
        if (!peekResult)
            continue;

        auto type = peekResult.value().first;
        auto boxLength = peekResult.value().second;
        if (boxLength > view->byteLength()) {
            ERROR_LOG(LOGIDENTIFIER, "chunk  type = '", type, "', size = ", boxLength, " larger than buffer length!");
            continue;
        }

        INFO_LOG(LOGIDENTIFIER, "chunk  type = '", type, "', size = ", boxLength);

        do {
            if (m_haveReportedVTTHeader || !formatDescription)
                break;

            CFDictionaryRef extensions = CMFormatDescriptionGetExtensions(formatDescription);
            if (!extensions)
                break;

            CFDictionaryRef sampleDescriptionExtensions = static_cast<CFDictionaryRef>(CFDictionaryGetValue(extensions, kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms));
            if (!sampleDescriptionExtensions)
                break;
            
            CFDataRef webvttHeaderData = static_cast<CFDataRef>(CFDictionaryGetValue(sampleDescriptionExtensions, CFSTR("vttC")));
            if (!webvttHeaderData)
                break;

            unsigned length = CFDataGetLength(webvttHeaderData);
            if (!length)
                break;

            // A WebVTT header is terminated by "One or more WebVTT line terminators" so append two line feeds to make sure the parser
            // reccognized this string as a full header.
            auto header = makeString(StringView { CFDataGetBytePtr(webvttHeaderData), length }, "\n\n");

            INFO_LOG(LOGIDENTIFIER, "VTT header ", header);
            client()->parseWebVTTFileHeader(WTFMove(header));
            m_haveReportedVTTHeader = true;
        } while (0);

        if (type == ISOWebVTTCue::boxTypeName()) {
            ISOWebVTTCue cueData = ISOWebVTTCue(presentationTime, duration);
            cueData.read(view);
            INFO_LOG(LOGIDENTIFIER, "VTT cue data ", cueData);
            client()->parseWebVTTCueData(WTFMove(cueData));
        }

        m_sampleInputBuffer.remove(0, (size_t)boxLength);
    }
}

processNativeSamples 関数では nativeSamples の配列を全部見ています。ですが、「nativeSamples の各要素で渡される vttc box は 1 つだけである」という前提で処理が書かれています。

どういうことか、バリデーション用の do while(0) を消して見ると分かりやすいでしょう。

void InbandTextTrackPrivateAVF::processNativeSamples(CFArrayRef nativeSamples, const MediaTime& presentationTime)
{
    using namespace PAL;

    if (!nativeSamples)
        return;

    CFIndex count = CFArrayGetCount(nativeSamples);
    if (!count)
        return;

    INFO_LOG(LOGIDENTIFIER, count, " sample buffers at time ", presentationTime);

    for (CFIndex i = 0; i < count; i++) {
        RefPtr<ArrayBuffer> buffer;
        MediaTime duration;
        CMFormatDescriptionRef formatDescription;
        if (!readNativeSampleBuffer(nativeSamples, i, buffer, duration, formatDescription))
            continue;

        auto view = JSC::DataView::create(WTFMove(buffer), 0, buffer->byteLength());
        auto peekResult = ISOBox::peekBox(view, 0);
        if (!peekResult)
            continue;

        auto type = peekResult.value().first;
        auto boxLength = peekResult.value().second;
        if (boxLength > view->byteLength()) {
            ERROR_LOG(LOGIDENTIFIER, "chunk  type = '", type, "', size = ", boxLength, " larger than buffer length!");
            continue;
        }

        INFO_LOG(LOGIDENTIFIER, "chunk  type = '", type, "', size = ", boxLength);

        /* 省略 */

        if (type == ISOWebVTTCue::boxTypeName()) {
            ISOWebVTTCue cueData = ISOWebVTTCue(presentationTime, duration);
            cueData.read(view);
            INFO_LOG(LOGIDENTIFIER, "VTT cue data ", cueData);
            client()->parseWebVTTCueData(WTFMove(cueData));
        }

        m_sampleInputBuffer.remove(0, (size_t)boxLength);
    }
}

このプログラムでは m_sampleInputBuffer.remove() が呼ばれる回数は nativeSamples の要素毎に 1 回だけです。

この「nativeSamples の各要素で渡される vttc box は 1 つだけである」という前提が正しいか確認するため、実際に AVPlayerItemLegibleOutput で同時に 2 つ表示する場合を試した所、 同時に 2 つ表示する場合はnativeSamples の配列長は 1 であり、中身に 2 つの vttc box が入るという結果となりました。つまり WebKit が processNativeSamples 関数で行っている「nativeSamples の各要素で渡される vttc box は 1 つだけである」という前提が、実際には正しくありませんでした。

調査結果としては WebKit では AVPlayerItemLegibleOutput で渡される ISOBMFF 形式の WebVTT 字幕の配列のうち、最初の vttc box だけを処理するため、同時に表示される字幕を 1 つしか処理できず、結果として同時に1つしか表示されないという挙動につながっている ということが分かりました。

また、後続の字幕のタイミングがずれてしまう問題も、このミスマッチから発生している事が分かりました。上記のコードでは、バッファに字幕を詰めていますが、1 つずつしか取り出さないという処理になっています。このため、バッファから取り出すタイミングがずれていって発生していました。

検証結果の確認

WebKit のレポジトリを clone し、渡される vttc box を 1 つだけでなく全て処理するように修正し、ビルドして確認を行いました。

結果として、複数の vttc box を処理すれば解決し、表示内容、タイミングともに正しい挙動となりました。つまるところ、調査結果でわかった AVFoundation と WebKit の間で認識の齟齬が実際に発生しており、processNativeSamples を修正すれば良いという結論が得られました。

また、履歴を追ったところ、WebVTT の NativeSample を使う対応 当初からこの実装でした。 AVFoundation 側が変化したのか、元々おかしいのかは不明ですが...。

なにはともあれ、修正が確認できたため WebKit への報告をする事にしました。

WebKit へのバグレポートの提出

問題の再現方法、修正方法を特定したため、WebKit の Bugzilla へバグレポートを提出しました。一応、解決した際に利用したコードもパッチとして提出しています。

同時に Feedback Assistant へもバグのフィードバックを行いました。

顛末

WebKit の PR で対応が入りマージされたため、このバグは修正されました。これで将来的には、Safari で Native HLS 再生の際に WebVTT 字幕を In-band で送っても、複数の字幕が同時表示される際の表示不正は起こらなくなります。

終わりに

Safari の動画再生時に起こる字幕の不具合から WebKit のソースを読みビルドして対処する、という得難い経験ができました。 こういう本質的な対応を、字幕以外の多くの部分でもできるようになりたいです、精進します。