如何在iOS客户端上将外部WebVTT字幕添加到HTTP Live Stream中

时间:2017-04-01 19:31:57

标签: ios avfoundation avplayer hls avkit

我们有通过bitmovin.com编码的视频,并以HTTP Live Streams(Fairplay HLS)的形式提供,但字幕虽然是WebVTT格式,但是作为整个文件的直接URL单独公开,而不是单独的段,并且不属于HLS m3u8播放列表。

我正在寻找单独下载的外部.vtt文件仍然可以包含在HLS流中的方式,并且可以作为AVPlayer中的副标题使用。

我知道Apple的建议是将分段的VTT字幕包含到HLS播放列表中,但我现在无法更改服务器实现,因此我想澄清是否有可能为AVPlayer提供字幕以及HLS流。

关于此主题的唯一有效帖子声称可能是:Subtitles for AVPlayer/MPMoviePlayerController。但是,示例代码从bundle加载本地mp4文件,我正努力通过AVURLAsset使其适用于m3u8播放列表。实际上,我有问题从远程m3u8流中获取videoTrack,因为asset.tracks(withMediaType: AVMediaTypeVideo)返回空数组。任何想法,如果这种方法可以用于真正的HLS流?或者有没有其他方法可以使用HLS流播放单独的WebVTT字幕,而不将它们包含在服务器上的HLS播放列表中?感谢。

func playFpsVideo(with asset: AVURLAsset, at context: UIViewController) {

    let composition = AVMutableComposition()

    // Video
    let videoTrack = composition.addMutableTrack(withMediaType: AVMediaTypeVideo, preferredTrackID: kCMPersistentTrackID_Invalid)

    do {

        let tracks = asset.tracks(withMediaType: AVMediaTypeVideo)

        // ==> The code breaks here, tracks is an empty array
        guard let track = tracks.first else {
            Log.error("Can't get first video track")
            return
        }

        try videoTrack.insertTimeRange(CMTimeRangeMake(kCMTimeZero, asset.duration), of: track, at: kCMTimeZero)

    } catch {

        Log.error(error)
        return
    }


    // Subtitle, some test from the bundle..
    guard let subsUrl = Bundle.main.url(forResource: "subs", withExtension: "vtt") else {
        Log.error("Can't load subs.vtt from bundle")
        return
    }

    let subtitleAsset = AVURLAsset(url: subsUrl)

    let subtitleTrack = composition.addMutableTrack(withMediaType: AVMediaTypeText, preferredTrackID: kCMPersistentTrackID_Invalid)

    do {

        let subTracks = subtitleAsset.tracks(withMediaType: AVMediaTypeText)

        guard let subTrack = subTracks.first else {
            Log.error("Can't get first subs track")
            return
        }

        try subtitleTrack.insertTimeRange(CMTimeRangeMake(kCMTimeZero, asset.duration), of: subTrack, at: kCMTimeZero)

    } catch {

        Log.error(error)
        return
    }


    // Prepare item and play it
    let item = AVPlayerItem(asset: composition)

    let player = AVPlayer(playerItem: item)

    let playerViewController = AVPlayerViewController()
    playerViewController.player = player

    self.playerViewController = playerViewController

    context.present(playerViewController, animated: true) {
        playerViewController.player?.play()
    }
}

2 个答案:

答案 0 :(得分:1)

我想通了。它花了很长时间,我讨厌它。我把我的解释和源代码放在 Github 上,但我也会把东西放在这里,以防链接因任何原因失效:https://github.com/kanderson-wellbeats/sideloadWebVttToAVPlayer

我在这里放弃这个解释是为了让一些未来的人免于很多痛苦。我在网上发现的很多东西都是错误的,或者遗漏了令人困惑的部分,或者有一堆额外的不相关信息,或者三者的混合。最重要的是,我看到很多人寻求帮助并试图做同样的事情,但没有人提供任何明确的答案。

所以首先我将描述我正在尝试做的事情。我的后端服务器是 Azure 媒体服务,它非常适合根据需要流式传输不同分辨率的视频,但它并不真正支持 WebVtt。是的,你可以在那里托管一个文件,但它似乎不能给我们一个主播放列表,其中包含对字幕播放列表的引用(如 Apple 要求)。苹果和微软似乎早在 2012 年就决定了他们将如何处理字幕,此后一直没有动过。当时他们要么不说话,要么故意反方向,但他们的兼容性很差,现在像我们这样的开发者被迫拉大庞然大物之间的差距。许多涵盖此主题的在线资源都在解决诸如优化任意流数据的缓存之类的问题,但我发现这些资源更令人困惑而不是有用。我想要做的就是为在 AVPlayer 中播放的点播视频添加字幕,当我拥有托管的 WebVtt 文件时,由 Azure 媒体服务使用 HLS 协议提供服务 - 仅此而已。我将首先用文字描述所有内容,然后将实际代码放在最后。

以下是您需要做的极其精简的版本:

  1. 拦截对主播放列表的请求并返回引用字幕播放列表的编辑版本(多个用于多种语言,或仅用于一种语言)
  2. 选择要显示的副标题(在 https://developer.apple.com/documentation/avfoundation/media_playback_and_selection/selecting_subtitles_and_alternative_audio_tracks 中有详细记录)
  3. 拦截对将通过的字幕播放列表的请求(在您选择要显示的字幕后)并返回您即时构建的引用服务器上的 WebVtt 文件的播放列表

就是这样。不会太多,除了有很多并发症妨碍了我发现自己。我将首先简要描述它们,然后更详细地描述它们。

简单的并发症解释:

  1. 许多请求都会通过,但您应该(并且只能)自己处理其中的几个请求,其他请求需要被允许原封不动地通过。我将描述哪些需要处理,哪些不需要,以及如何处理。
  2. Apple 认为简单的 HTTP 请求不够好,并决定通过将其转换为具有 DataRequest 属性 (AVAssetResourceLoadingDataRequest) 和 ContentInformationRequest 属性 (AVAssetResourceLoadingContentInformationRequest) 的奇怪双重身份 AVAssetResourceLoadingRequest 事物来掩盖事物。我仍然不明白为什么这是必要的或它带来了什么好处,但我在这里与他们所做的工作是有效的。一些有前途的博客/资源似乎建议您必须弄乱 ContentInformationRequest,但我发现您可以简单地忽略 ContentInformationRequest,事实上,弄乱它的频率不仅仅是破坏事情。
  3. Apple 建议您将 VTT 文件分割成小块,但您根本无法在客户端执行此操作(Apple 不允许这样做),但幸运的是,您似乎实际上也不必这样做,这只是一个建议.

拦截请求

要拦截请求,您必须继承/扩展 AVAssetResourceLoaderDelegate 并且感兴趣的方法是 ShouldWaitForLoadingOfRequestedResource 方法。要使用委托,请通过将 AVPlayerItem 交给 AVPlayerItem 来实例化您的 AVPlayer,但将 AVPlayerItem 交给 AVUrlAsset,该 AVUrlAsset 具有您将委托分配给的委托属性。所有请求都将通过 ShouldWaitForLoadingOfRequestedResource 方法发出,这样所有业务都会发生,除了一个偷偷摸摸的并发症 - 只有当请求以 http/https 以外的内容开头时才会调用该方法,所以我的建议是坚持一个常量字符串在您用来创建 AVUrlAsset 的 Url 的前面,然后您可以在请求进入您的委托后将其剃掉 - 我们称之为“CUSTOMSCHEME”。这部分在网上的几个地方都有描述,但如果你不知道你必须这样做,那可能会非常令人沮丧,因为它看起来根本没有发生任何事情。

拦截 - 类型 A) 重定向

好的,现在我们正在拦截请求,但您不想(/不能)自己处理它们。您只想允许通过的一些请求。为此,您可以执行以下操作:

  1. 创建一个新的 NSUrlRequest 到更正的 Url(去掉之前的“CUSTOMSCHEME”部分)并将其设置为 LoadingRequest 上的 Redirect 属性
  2. 使用相同的更正 URL 和 302 代码创建一个新的 NSHttpUrlResponse,并将其设置为 LoadingRequest 上的 Response 属性
  3. 在 LoadingRequest 上调用 FinishLoading
  4. 返回真

通过这些步骤,您可以添加断点和内容以调试和检查将通过的所有请求,但它们会正常进行,因此您不会破坏任何内容。但是,这种方法不仅仅用于调试,即使在完成的项目中,对于多个请求也是必要的。

拦截 - B 类)编辑/伪造回复

当一些请求进来时,你会想要做一个你自己的请求,这样对你的请求的响应(经过一些调整)可以用来完成 LoadingRequest。因此,请执行以下操作:

  1. 创建一个 NSUrlSession 并在会话上调用 CreateDataTask 方法(使用更正的 URL - 删除“CUSTOMSCHEME”)
  2. 在 DataTask 上调用 Resume(在 DataTask 的回调之外)
  3. 返回真
  4. 在 DataTask 的回调中,您将拥有数据,因此(在进行编辑后)您使用该(已编辑的)数据在 LoadingRequest 的 DataRequest 属性上调用 Respond,然后在 LoadingRequest 上调用 FinishLoading

拦截 - 哪些请求得到哪种类型的处理

会收到大量请求,有些需要重定向,有些需要提供制造/更改的数据响应。以下是您将看到的请求类型,按照它们的出现顺序以及如何处理每个请求:

  1. 对主播放列表的请求,但 DataRequest 的 RequestedLength 为 2 - 只需重定向(类型 A)
  2. 对主播放列表的请求,但 DataRequest 的 RequestedLength 与主播放列表的(未编辑的)长度匹配 - 对主播放列表执行您自己的请求,以便您可以对其进行编辑并返回编辑后的结果(类型 B)
  3. 向主播放器发出请求,但 DataRequest 的 RequestedLength 非常庞大 - 执行与前一个 (TYPE B) 相同的操作
  4. 很多音频和视频片段的请求都会通过 - 所有这些请求都需要重定向(类型 A)
  5. 一旦您正确编辑了主播放列表(并选择了字幕),就会收到字幕播放列表的请求 - 编辑此列表以返回制作的字幕播放列表(类型 B)

如何编辑播放列表 - 主播放列表

主播放列表易于编辑。变化是两件事:

  1. 每个视频资源都有自己的行,并且都需要告诉他们字幕组(对于以 #EXT-X-STREAM-INF 开头的每一行,我将在末尾添加 ,SUBTITLES="subs"
  2. 需要为每种字幕语言/类型添加新行,都属于具有自己网址的字幕组(因此,对于每种类型,添加类似 #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="!!!yourLanguageHere!!!",NAME="!!!yourNameHere!!!",AUTOSELECT=YES,URI="!!!yourCustomUrlHere!!!" 的行

!!!yourCustomUrlHere!!!您在第 2 步中使用的内容在用于请求时必须被您检测到,以便您可以将制作的字幕播放列表作为响应的一部分返回,因此请将其设置为唯一的内容。该 Url 还必须使用“CUSTOMSCHEME”,以便委托。您还可以查看此流式传输示例以了解清单的外观:https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html(使用浏览器调试器嗅探网络流量以查看)。

如何编辑播放列表 - 字幕播放列表

字幕播放列表有点复杂。你必须自己做整个事情。我这样做的方法是在 DataTask 回调中自己实际获取 WebVtt 文件,然后解析该文件以找到最后一个时间戳序列的结尾,将其转换为整数秒,然后插入该值在一个大字符串的几个地方。同样,您可以使用上面列出的示例并嗅探网络流量,以查看自己的真实示例。所以它看起来像这样:

#EXTM3U
#EXT-X-TARGETDURATION:!!!thatLengthIMentioned!!!
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:!!!thatLengthIMentioned!!!
!!!absoluteUrlToTheWebVttFileOnTheServer!!!
#EXT-X-ENDLIST

请注意,播放列表不会按照 Apple 的建议对 vtt 文件进行分段,因为这无法在客户端完成(来源:https://developer.apple.com/forums/thread/113063?answerId=623328022#623328022)。另请注意,我不会在“EXTINF”行的末尾放置逗号,即使 Apple 的示例在此处表示要这样做,因为它似乎破坏了它:https://developer.apple.com/videos/play/wwdc2012/512/

现在是实际代码:

public class CustomResourceLoaderDelegate : AVAssetResourceLoaderDelegate
{
    public const string LoaderInterceptionWorkaroundUrlPrefix = "CUSTOMSCHEME"; // a scheme other than http(s) needs to be used for AVUrlAsset's URL or ShouldWaitForLoadingOfRequestedResource will never be called
    private const string SubtitlePlaylistBoomerangUrlPrefix = LoaderInterceptionWorkaroundUrlPrefix + "SubtitlePlaylist";
    private const string SubtitleBoomerangUrlSuffix = "m3u8";
    private readonly NSUrlSession _session;
    private readonly List<SubtitleBundle> _subtitleBundles;

    public CustomResourceLoaderDelegate(IEnumerable<WorkoutSubtitleDto> subtitles)
    {
        _subtitleBundles = subtitles.Select(subtitle => new SubtitleBundle {SubtitleDto = subtitle}).ToList();
        _session = NSUrlSession.FromConfiguration(NSUrlSessionConfiguration.DefaultSessionConfiguration);
    }

    public override bool ShouldWaitForLoadingOfRequestedResource(AVAssetResourceLoader resourceLoader,
        AVAssetResourceLoadingRequest loadingRequest)
    {
        var requestString = loadingRequest.Request.Url.AbsoluteString;
        var dataRequest = loadingRequest.DataRequest;

        if (requestString.StartsWith(SubtitlePlaylistBoomerangUrlPrefix))
        {
            var uri = new Uri(requestString);
            var targetLanguage = uri.Host.Split(".").First();
            var targetSubtitle = _subtitleBundles.FirstOrDefault(s => s.SubtitleDto.Language == targetLanguage);

            Debug.WriteLine("### SUBTITLE PLAYLIST " + requestString);
            if (targetSubtitle == null)
            {
                loadingRequest.FinishLoadingWithError(new NSError());
                return true;
            }
            var subtitlePlaylistTask = _session.CreateDataTask(NSUrlRequest.FromUrl(NSUrl.FromString(targetSubtitle.SubtitleDto.CloudFileURL)),
                (data, response, error) =>
                {
                    if (error != null)
                    {
                        loadingRequest.FinishLoadingWithError(error);
                        return;
                    }
                    if (data == null || !data.Any())
                    {
                        loadingRequest.FinishLoadingWithError(new NSError());
                        return;
                    }
                    MakePlaylistAndFragments(targetSubtitle, Encoding.UTF8.GetString(data.ToArray()));

                    loadingRequest.DataRequest.Respond(NSData.FromString(targetSubtitle.Playlist));
                    loadingRequest.FinishLoading();
                });
            subtitlePlaylistTask.Resume();
            return true;
        }

        if (!requestString.ToLower().EndsWith(".ism/manifest(format=m3u8-aapl)") || // lots of fragment requests will come through, we're just going to fix their URL so they can proceed normally (getting bits of video and audio)
            (dataRequest != null && 
             dataRequest.RequestedOffset == 0 && // this catches the first (of 3) master playlist requests. the thing sending out these requests and handling the responses seems unable to be satisfied by our handling of this (just for the first request), so that first request is just let through. if you mess with request 1 the whole thing stops after sending request 2. although this means the first request doesn't get the same edited master playlist as the second or third, apparently that's fine.
             dataRequest.RequestedLength == 2 &&
             dataRequest.CurrentOffset == 0))
        {
            Debug.WriteLine("### REDIRECTING REQUEST " + requestString);
            var redirect = new NSUrlRequest(new NSUrl(requestString.Replace(LoaderInterceptionWorkaroundUrlPrefix, "")));
            loadingRequest.Redirect = redirect;
            var fakeResponse = new NSHttpUrlResponse(redirect.Url, 302, null, null);
            loadingRequest.Response = fakeResponse;
            loadingRequest.FinishLoading();
            return true;
        }

        var correctedRequest = new NSMutableUrlRequest(new NSUrl(requestString.Replace(LoaderInterceptionWorkaroundUrlPrefix, "")));
        if (dataRequest != null)
        {
            var headers = new NSMutableDictionary();
            foreach (var requestHeader in loadingRequest.Request.Headers)
            {
                headers.Add(requestHeader.Key, requestHeader.Value);
            }
            correctedRequest.Headers = headers;
        }

        var masterPlaylistTask = _session.CreateDataTask(correctedRequest, (data, response, error) =>
        {
            Debug.WriteLine("### REQUEST CARRIED OUT AND RESPONSE EDITED " + requestString);
            if (error == null)
            {
                var dataString = Encoding.UTF8.GetString(data.ToArray());
                var stringWithSubsAdded = AddSubs(dataString);

                dataRequest?.Respond(NSData.FromString(stringWithSubsAdded));

                loadingRequest.FinishLoading();
            }
            else
            {
                loadingRequest.FinishLoadingWithError(error);
            }
        });
        masterPlaylistTask.Resume();
        return true;
    }

    private string AddSubs(string dataString)
    {
        var tracks = dataString.Split("\r\n").ToList();
        for (var ii = 0; ii < tracks.Count; ii++)
        {
            if (tracks[ii].StartsWith("#EXT-X-STREAM-INF"))
            {
                tracks[ii] += ",SUBTITLES=\"subs\"";
            }
        }

        tracks.AddRange(_subtitleBundles.Select(subtitle => "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"" + subtitle.SubtitleDto.Language + "\",NAME=\"" + subtitle.SubtitleDto.Title + "\",AUTOSELECT=YES,URI=\"" + SubtitlePlaylistBoomerangUrlPrefix + "://" + subtitle.SubtitleDto.Language + "." + SubtitleBoomerangUrlSuffix + "\""));

        var finalPlaylist = string.Join("\r\n", tracks);
        return finalPlaylist;
    }

    private void MakePlaylistAndFragments(SubtitleBundle subtitle, string vtt)
    {
        var noWhitespaceVtt = vtt.Replace(" ", "").Replace("\n", "").Replace("\r", "");
        var arrowIndex = noWhitespaceVtt.LastIndexOf("-->");
        var afterArrow = noWhitespaceVtt.Substring(arrowIndex);
        var firstColon = afterArrow.IndexOf(":");
        var period = afterArrow.IndexOf(".");
        var timeString = afterArrow.Substring(firstColon - 2, period /*(+ 2 - 2)*/);
        var lastTime = (int)TimeSpan.Parse(timeString).TotalSeconds;

        var resultLines = new List<string>
        {
            "#EXTM3U",
            "#EXT-X-TARGETDURATION:" + lastTime,
            "#EXT-X-VERSION:3",
            "#EXT-X-MEDIA-SEQUENCE:0",
            "#EXT-X-PLAYLIST-TYPE:VOD",
            "#EXTINF:" + lastTime,
            subtitle.SubtitleDto.CloudFileURL,
            "#EXT-X-ENDLIST"
        };

        subtitle.Playlist = string.Join("\r\n", resultLines);
    }

    private class SubtitleBundle
    {
        public WorkoutSubtitleDto SubtitleDto { get; set; }
        public string Playlist { get; set; }
    }

    public class WorkoutSubtitleDto
    {
        public int WorkoutID { get; set; }
        public string Language { get; set; }
        public string Title { get; set; }
        public string CloudFileURL { get; set; }
    }
}

答案 1 :(得分:0)

由于我不得不处理类似的问题,因此我写了一篇关于此的小文章,该文章链接到GitHub上的一些概念证明。希望这会有所帮助。

https://jorisweimar.com/programming/supporting-external-webvtt-subtitles-in-avplayer/