我们有通过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()
}
}
答案 0 :(得分:1)
我想通了。它花了很长时间,我讨厌它。我把我的解释和源代码放在 Github 上,但我也会把东西放在这里,以防链接因任何原因失效:https://github.com/kanderson-wellbeats/sideloadWebVttToAVPlayer
我在这里放弃这个解释是为了让一些未来的人免于很多痛苦。我在网上发现的很多东西都是错误的,或者遗漏了令人困惑的部分,或者有一堆额外的不相关信息,或者三者的混合。最重要的是,我看到很多人寻求帮助并试图做同样的事情,但没有人提供任何明确的答案。
所以首先我将描述我正在尝试做的事情。我的后端服务器是 Azure 媒体服务,它非常适合根据需要流式传输不同分辨率的视频,但它并不真正支持 WebVtt。是的,你可以在那里托管一个文件,但它似乎不能给我们一个主播放列表,其中包含对字幕播放列表的引用(如 Apple 要求)。苹果和微软似乎早在 2012 年就决定了他们将如何处理字幕,此后一直没有动过。当时他们要么不说话,要么故意反方向,但他们的兼容性很差,现在像我们这样的开发者被迫拉大庞然大物之间的差距。许多涵盖此主题的在线资源都在解决诸如优化任意流数据的缓存之类的问题,但我发现这些资源更令人困惑而不是有用。我想要做的就是为在 AVPlayer 中播放的点播视频添加字幕,当我拥有托管的 WebVtt 文件时,由 Azure 媒体服务使用 HLS 协议提供服务 - 仅此而已。我将首先用文字描述所有内容,然后将实际代码放在最后。
就是这样。不会太多,除了有很多并发症妨碍了我发现自己。我将首先简要描述它们,然后更详细地描述它们。
要拦截请求,您必须继承/扩展 AVAssetResourceLoaderDelegate 并且感兴趣的方法是 ShouldWaitForLoadingOfRequestedResource 方法。要使用委托,请通过将 AVPlayerItem 交给 AVPlayerItem 来实例化您的 AVPlayer,但将 AVPlayerItem 交给 AVUrlAsset,该 AVUrlAsset 具有您将委托分配给的委托属性。所有请求都将通过 ShouldWaitForLoadingOfRequestedResource 方法发出,这样所有业务都会发生,除了一个偷偷摸摸的并发症 - 只有当请求以 http/https 以外的内容开头时才会调用该方法,所以我的建议是坚持一个常量字符串在您用来创建 AVUrlAsset 的 Url 的前面,然后您可以在请求进入您的委托后将其剃掉 - 我们称之为“CUSTOMSCHEME”。这部分在网上的几个地方都有描述,但如果你不知道你必须这样做,那可能会非常令人沮丧,因为它看起来根本没有发生任何事情。
好的,现在我们正在拦截请求,但您不想(/不能)自己处理它们。您只想允许通过的一些请求。为此,您可以执行以下操作:
通过这些步骤,您可以添加断点和内容以调试和检查将通过的所有请求,但它们会正常进行,因此您不会破坏任何内容。但是,这种方法不仅仅用于调试,即使在完成的项目中,对于多个请求也是必要的。
当一些请求进来时,你会想要做一个你自己的请求,这样对你的请求的响应(经过一些调整)可以用来完成 LoadingRequest。因此,请执行以下操作:
会收到大量请求,有些需要重定向,有些需要提供制造/更改的数据响应。以下是您将看到的请求类型,按照它们的出现顺序以及如何处理每个请求:
主播放列表易于编辑。变化是两件事:
#EXT-X-STREAM-INF
开头的每一行,我将在末尾添加 ,SUBTITLES="subs"
)#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/