10

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

我正在寻找如何将单独下载的外部 .vtt 文件仍包含在 HLS 流中并作为 AVPlayer 中的字幕提供的方式。

我知道 Apple 的建议是将分段 VTT 字幕包含到 HLS 播放列表中,但我现在无法更改服务器实现,所以我想澄清是否可以将字幕提供给 AVPlayer 以与 HLS 流一起播放.

关于这个主题的唯一有效帖子声称它是可能的:AVPlayer/MPMoviePlayerController 的字幕。但是,示例代码从包加载本地 mp4 文件,我正在努力使其适用于 m3u8 播放列表AVURLAsset。实际上,我在从远程 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()
    }
}
4

2 回答 2

5

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

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

因此,首先,我将描述我正在尝试做的事情。我的后端服务器是 Azure 媒体服务,它非常适合根据需要流式传输不同分辨率的视频,但它并不真正支持 WebVtt。是的,您可以在其中托管文件,但它似乎无法为我们提供包含对字幕播放列表的引用的主播放列表(根据 Apple 的要求)。似乎苹果和微软早在 2012 年就决定了他们将如何处理字幕,并且从那以后就没有触及过它。当时他们要么不说话,要么故意走反方向,但他们的兼容性很差,现在像我们这样的开发者被迫拉开庞然大物之间的差距。涵盖该主题的许多在线资源都在解决诸如优化任意流数据的缓存之类的问题,但我发现这些资源更令人困惑而不是有用。当我有一个托管的 WebVtt 文件时,我想要做的就是为在 AVPlayer 中播放的点播视频添加字幕,这些视频由 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。所有请求都将通过 ShouldWaitForLoadingOfRequestedResource 方法进行,因此所有业务都将发生,除了一个偷偷摸摸的复杂情况 - 只有当请求以 http/https 以外的其他内容开头时才会调用该方法,所以我的建议是坚持一个常量字符串在您用于创建 AVUrlAsset 的 Url 的前面,然后您可以在请求进入您的委托后将其剃掉 - 我们称之为“CUSTOMSCHEME”。

拦截 - 类型 A) 重定向

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

  1. 为 CORRECTED Url 创建一个新的 NSUrlRequest(从前面删除“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

INTERCEPTING - 哪些请求得到哪种处理

会有很多请求进来,有些需要重定向,有些需要提供制造/更改的数据响应。以下是您将按进入顺序看到的请求类型以及如何处理每个请求:

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

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

主播放列表易于编辑。改变有两点:

  1. 每个视频资源都有自己的行,他们都需要被告知字幕组(对于以#EXT-X-STREAM-INF,SUBTITLES="subs"在末尾添加的每一行开头)
  2. 需要为每种字幕语言/类型添加新行,都属于具有自己 URL 的字幕组(因此对于每种类型,添加一行#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; }
    }
}
于 2021-04-28T21:24:13.470 回答
2

如果使用流媒体服务,您可以在其中编辑流媒体清单并上传编码媒体所在的其他文件,然后通过一些手动工作(可以编写脚本),您可以将字幕以如下方式放入清单中iOS 期望如此。我能够让它与 Azure 媒体服务一起使用,尽管它有点 hacky。

由于 Azure 媒体服务(我从现在开始将其称为 AMS)流式处理端点动态创建流式处理清单,因此我不能只将必要的更改添加到文件中。相反,我根据 AMS 生成的播放列表创建了一个新的主播放列表。@SomeXamarinDude 在他的回答中解释了主播放列表中所需的更改,但我将包含一个完整的示例。

假设 AMS 从带有 URL 的流式端点生成主播放列表:

https://mediaservicename-use2.streaming.media.azure.net/d36754c2-c8cf-4f0f-b73f-dafd21fff50f/YOUR-ENCODED-ASSET.ism/manifest\(format\=m3u8-aapl\)

看起来像这样:

#EXTM3U
#EXT-X-VERSION:4
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="aac_eng_2_128079_2_1",LANGUAGE="eng",DEFAULT=YES,AUTOSELECT=YES,URI="QualityLevels(128079)/Manifest(aac_eng_2_128079_2_1,format=m3u8-aapl)"
#EXT-X-STREAM-INF:BANDWIDTH=623543,RESOLUTION=320x180,CODECS="avc1.640015,mp4a.40.2",AUDIO="audio"
QualityLevels(466074)/Manifest(video,format=m3u8-aapl)
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=623543,RESOLUTION=320x180,CODECS="avc1.640015",URI="QualityLevels(466074)/Manifest(video,format=m3u8-aapl,type=keyframes)"
#EXT-X-STREAM-INF:BANDWIDTH=976825,RESOLUTION=480x270,CODECS="avc1.64001e,mp4a.40.2",AUDIO="audio"
QualityLevels(811751)/Manifest(video,format=m3u8-aapl)
...

然后,手动创建的播放列表(我将命名)manually-created-playlist.m3u8需要如下所示:

#EXTM3U
#EXT-X-VERSION:4
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",AUTOSELECT=YES,URI="https://mediaservicename-use2.streaming.media.azure.net/d36754c2-c8cf-4f0f-b73f-dafd21fff50f/subtitle-playlist.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="aac_eng_2_128079_2_1",LANGUAGE="eng",DEFAULT=YES,AUTOSELECT=YES,URI="YOUR-ENCODED-ASSET.ism/QualityLevels(128079)/Manifest(aac_eng_2_128079_2_1,format=m3u8-aapl)"
#EXT-X-STREAM-INF:SUBTITLES="subs",BANDWIDTH=623543,RESOLUTION=320x180,CODECS="avc1.640015,mp4a.40.2",AUDIO="audio"
YOUR-ENCODED-ASSET.ism/QualityLevels(466074)/Manifest(video,format=m3u8-aapl)
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=623543,RESOLUTION=320x180,CODECS="avc1.640015",URI="YOUR-ENCODED-ASSET.ism/QualityLevels(466074)/Manifest(video,format=m3u8-aapl,type=keyframes)"
#EXT-X-STREAM-INF:SUBTITLES="subs",BANDWIDTH=976825,RESOLUTION=480x270,CODECS="avc1.64001e,mp4a.40.2",AUDIO="audio"
YOUR-ENCODED-ASSET.ism/QualityLevels(811751)/Manifest(video,format=m3u8-aapl)
...

请注意,我必须对各种比特率播放列表进行路径更改。

然后,需要将此手动播放列表上传到包含其余编码媒体资产的同一 Azure 存储容器。

我还必须创建一个名为subtitle-playlist.m3u8和 a的文件并将其上传transcript.vtt到同一个 Azure 存储容器。我的字幕播放列表如下所示:

#EXTM3U
#EXT-X-TARGETDURATION:61
#EXT-X-ALLOW-CACHE:YES
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:1
#EXTINF:61.061000
https://mediaservicename-use2.streaming.media.azure.net/d36754c2-c8cf-4f0f-b73f-dafd21fff50f/transcript.vtt
#EXT-X-ENDLIST

请注意,某些字幕播放列表值取决于 WebVTT 文件的长度。

此时,您应该能够将 HLS 播放器指向以下 URL 并能够启用隐藏式字幕:

https://mediaservicename-use2.streaming.media.azure.net/d36754c2-c8cf-4f0f-b73f-dafd21fff50f/manually-created-master-playlist.m3u8

我希望这可以帮助别人。显然,在AMS方面正在解决这个问题。

感谢@SomeXamarinDude的回答;如果不是因为你投入的所有基础工作,我会完全迷失在这个问题上。

于 2021-11-10T20:49:28.833 回答