我弄清楚了这个问题。花费了很长时间,我非常讨厌它。我将我的解释和源代码放在Github上,但如果链接失效,我也会在这里提供: https://github.com/kanderson-wellbeats/sideloadWebVttToAVPlayer
我在这里放置这个解释,试图为一些未来的人节省很多痛苦。我找到的很多东西都是错的,或者漏掉了困惑的部分,或者有很多额外的无关信息,或者混合了这三者。除此之外,我看到很多人寻求帮助,试图做同样的事情,但没有人提供清晰的答案。
所以首先我将描述我要做的事情。我的后端服务器是Azure媒体服务,在需要流式传输不同分辨率视频方面非常出色,但实际上它并不太支持WebVtt。是的,您可以在那里托管文件,但似乎它不能给我们一个包括对字幕播放列表的引用的主播放列表(如Apple要求的)。看起来,微软和Apple在大约2012年就决定了他们要如何处理字幕,并且从那时起就没有再碰过它。当时他们要么没有相互交流,要么故意朝着相反的方向走,但他们恰好具有差劲的互操作性,现在像我们这样的开发人员被迫拉伸巨头之间的差距。许多在线资源涵盖此主题,但它们更加混乱而不是有用。我想做的所有事情只是在由Azure媒体服务提供HLS协议播放的点播视频中添加字幕 - 没有更多,也没有更少。我将从文字上描述一切,然后在最后放置实际代码。
以下是极度简化版的步骤:
- 拦截主播放列表请求并返回一个编辑好的版本,其中引用了字幕播放列表(多种语言需要多个,或者只有一种语言需要一个)
- 选择要显示的字幕(在https://developer.apple.com/documentation/avfoundation/media_playback_and_selection/selecting_subtitles_and_alternative_audio_tracks 上有详细说明)
- 拦截即将到来的字幕播放列表请求(在您选择要显示字幕后),并返回您实时构建的引用服务器上WebVtt文件的播放列表
就是这样。没有太多的东西,但有很多复杂性会妨碍您,我不得不自己发现它们。首先我将简要描述每个复杂性,然后详细描述。
简要的复杂性解释:
- 虽然会有许多请求通过,但只有一小部分请求需要(且能够)由你处理,其他请求需要保持原样。我将描述哪些请求需要处理,哪些不需要以及如何处理它们。
- 苹果认为简单的HTTP请求不够好,因此决定将其转换为奇怪的双重身份AVAssetResourceLoadingRequest对象,并具有一个DataRequest属性(AVAssetResourceLoadingDataRequest)和一个ContentInformationRequest属性(AVAssetResourceLoadingContentInformationRequest)。我仍然不明白为什么这是必要的或者它带来了什么好处,但是我在这里使用它们的方法确实有效。一些有前途的博客/资源似乎表明您必须对ContentInformationRequest进行处理,但我发现您可以简单地忽略ContentInformationRequest,事实上更频繁地干扰它只会破坏事情。
- 苹果建议将VTT文件分成小块,但是您无法在客户端执行此操作(苹果不允许此操作),但幸运的是,似乎您实际上不必这样做,这仅仅是个建议。
拦截请求
要拦截请求,您必须子类化/扩展AVAssetResourceLoaderDelegate,并关注ShouldWaitForLoadingOfRequestedResource方法。要使用该代理,请通过将AVPlayerItem交给AVPlayer来实例化您的AVPlayer,但将AVPlayerItem交给具有委托属性的AVUrlAsset,您将分配该委托。所有请求都将经过ShouldWaitForLoadingOfRequestedResource方法,因此所有业务都将在此处发生,除了一个阴险的复杂性-仅当请求以http/https之外的某些内容开头时,该方法才会调用。因此,我的建议是,在创建AVUrlAsset所使用的URL前面添加一个常量字符串,然后在请求传递到您的代理后将其删除-让我们称之为"CUSTOMSCHEME" 。这部分在线上的一些地方有描述,但如果您不知道必须这样做,它会非常令人沮丧,因为似乎根本没有任何事情发生。
拦截 - 类型A)重定向
好的,现在我们正在拦截请求,但您不想(或者不能)全部自己处理。你可以通过以下方式实现:
- 创建一个新的NSUrlRequest,指向经过修正的URL(删除之前的"CUSTOMSCHEME"部分),并将其设置为LoadingRequest的Redirect属性
- 使用同样的已经修正的URL创建一个新的NSHttpUrlResponse,并将其设置为LoadingRequest的Response属性,状态码为302
- 在LoadingRequest上调用FinishLoading
- 返回true
有了这些步骤,您可以添加断点等以调试和检查所有请求,但它们将正常进行,因此您不会破坏任何内容。然而,这种方法不仅适用于调试,对于几个请求来说,它也是一个必要的事情。
拦截 - B 类型)编辑/伪造响应
当一些请求到达时,您将希望发起一个自己的请求,以便对该请求的响应(稍作调整后)可以用来满足 LoadingRequest。因此,请执行以下操作:
- 创建一个 NSUrlSession,并在会话上调用 CreateDataTask 方法(使用已更正的 URL - 删除 "CUSTOMSCHEME")
- 在 DataTask 的回调之外,调用 DataTask 的 Resume 方法
- 返回 true
- 在 DataTask 回调中,您将获得数据,因此(在进行编辑后)可以使用该(已编辑)数据调用 LoadingRequest 的 DataRequest 属性上的 Respond,然后调用 LoadingRequest 的 FinishLoading
拦截 - 哪些请求需要哪种类型的处理
会有许多请求进来,有些需要重定向,有些需要返回制造/修改后的数据响应。以下是您将看到的请求的类型及其处理方法:
- 主播放列表的请求,但 DataRequest 的 RequestedLength 为 2 - 只需重定向(类型 A)
- 主播放列表的请求,但 DataRequest 的 RequestedLength 与(未编辑)主播放列表的长度匹配 - 进行自己的请求到主播放列表,以便您可以编辑它并返回编辑后的结果(类型 B)
- 主播放列表的请求,但 DataRequest 的 RequestedLength 非常大 - 做与之前相同的事情(类型 B)
- 将会有许多音频和视频片段的请求 - 所有这些请求都需要重定向(类型 A)
- 一旦您正确编辑了主播放列表(并选择了字幕),将会收到一个对于字幕播放列表的请求 - 编辑此请求以返回制造出的字幕播放列表(类型 B)
如何编辑播放列表 - 主播放列表
主播放列表很容易编辑。更改是两个方面:
- 每个视频资源都有自己的行,并且它们都需要告知字幕组(对于每一行以
#EXT-X-STREAM-INF
开头的行,在末尾添加 ,SUBTITLES="subs"
)
- 需要添加新行以表示每种字幕语言/类型,它们都属于具有自己 URL 的字幕组(因此,对于每种类型,添加类似于
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="!!!yourLanguageHere!!!",NAME="!!!yourNameHere!!!",AUTOSELECT=YES,URI="!!!yourCustomUrlHere!!!"
的行
第二步中使用的Url(!!!yourCustomUrlHere!!!)需要由您进行检测,以便在请求时将制作好的字幕播放列表作为响应的一部分返回,因此请将其设置为独特的值。该Url还必须使用"CUSTOMSCHEME",以便到达委托对象。您还可以查看此流媒体示例,以查看清单应如何呈现:https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html(使用浏览器调试器嗅探网络流量进行查看)。
如何编辑播放列表-字幕播放列表
字幕播放列表略微复杂。您必须自己创建整个文件。我的方法是在DataTask回调函数中获取WebVtt文件,然后解析它以找到最后一个时间戳序列的末尾,将其转换为秒数并在大字符串的几个位置插入该值。同样,您可以使用上面提到的示例,并通过嗅探网络流量来查看真实的示例。因此,它看起来像这样:
!!!absoluteUrlToTheWebVttFileOnTheServer!!!
请注意,播放列表不会像苹果建议的那样对vtt文件进行分段,因为这无法在客户端完成(来源:
https://developer.apple.com/forums/thread/113063?answerId=623328022#623328022)。此外,请注意,即使苹果的示例中要求在“EXTINF”行末尾加上逗号,但我也没有这样做,因为这似乎会破坏代码(
https://developer.apple.com/videos/play/wwdc2012/512/)。
现在是实际代码:
public class CustomResourceLoaderDelegate : AVAssetResourceLoaderDelegate
{
public const string LoaderInterceptionWorkaroundUrlPrefix = "CUSTOMSCHEME";
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)") ||
(dataRequest != null &&
dataRequest.RequestedOffset == 0 &&
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 );
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; }
}
}