Я понял это. Это заняло целую вечность, и я ненавидел это. Я помещаю свое объяснение и исходный код на Github, но я также помещу сюда кое-что, на случай, если ссылка умрет по какой-либо причине: https://github.com/kanderson-wellbeats/sideloadWebVttToAVPlayer
Я бросаю это объяснение здесь, чтобы попытаться избавить некоторых будущих людей от боли. Многое из того, что я нашел в Интернете, было неправильным, или упущено из виду запутанные части, или содержало кучу дополнительной нерелевантной информации, или смесь всех трех. Вдобавок я видел, как много людей просят о помощи и пытаются сделать то же самое, но никто не дает четких ответов.
Итак, для начала я опишу, что я пытаюсь сделать. Мой внутренний сервер - это службы мультимедиа Azure, и он действительно отлично подходит для потоковой передачи видео с разным разрешением по мере необходимости, но на самом деле он не поддерживает WebVtt. Да, вы можете разместить там файл, но, похоже, он не может предоставить нам основной список воспроизведения, который включает ссылку на список воспроизведения субтитров (как требует Apple). Кажется, и Apple, и Microsoft решили, что они собираются делать с субтитрами еще в 2012 году, и с тех пор не трогали его. В то время они либо не разговаривали друг с другом, либо намеренно пошли в противоположные стороны, но у них была плохая взаимная совместимость, и теперь такие разработчики, как мы, вынуждены сокращать разрыв между гигантами. Многие онлайн-ресурсы, посвященные этой теме, посвящены таким вещам, как оптимизированное кэширование произвольных потоковых данных, но я обнаружил, что эти ресурсы скорее сбивают с толку, чем полезны. Все, что я хочу сделать, это добавить субтитры к видео по запросу, воспроизводимому в AVPlayer, обслуживаемом Azure Media Services с протоколом HLS, когда у меня есть размещенный файл WebVtt - ни больше, ни меньше. Я начну с описания всего словами, а потом поставлю код в конце.
Вот чрезвычайно сжатая версия того, что вам нужно сделать:
- Перехватывать запросы для основного списка воспроизведения и возвращать его отредактированную версию, которая ссылается на списки воспроизведения субтитров (несколько для нескольких языков или только один для одного языка)
- Выберите субтитры для отображения (хорошо задокументировано на https://developer.apple.com/documentation/avfoundation/media_playback_and_selection/selecting_subtitles_and_alternative_audio_tracks)
- Перехватывать запросы к спискам воспроизведения субтитров, которые будут проходить (после того, как вы выбрали субтитры для отображения) и возвращать списки воспроизведения, которые вы создали на лету, которые ссылаются на файлы WebVtt на сервере
Вот и все. Не слишком много, за исключением того, что мне пришлось столкнуться с множеством сложностей, которые мне пришлось открыть для себя. Я опишу их сначала кратко, а затем более подробно.
Краткие объяснения осложнений:
- Многие запросы будут проходить, но вы должны (и можете только) обработать пару из них самостоятельно, остальные должны пройти без изменений. Я опишу, какие из них нужно обрабатывать, а какие нет, и как с ними обращаться.
- Apple решила, что простого HTTP-запроса недостаточно, и решила скрыть ситуацию, переведя его в странную вещь с двойной идентификацией AVAssetResourceLoadingRequest, которая имеет свойство DataRequest (AVAssetResourceLoadingDataRequest) и свойство ContentInformationRequest (AVAssetResourceLoadingContentInformationRequest). Я до сих пор не понимаю, почему это было необходимо и какую пользу это приносит, но то, что я сделал с ними, работает. Некоторые многообещающие блоги / ресурсы, кажется, предлагают вам возиться с ContentInformationRequest, но я считаю, что вы можете просто игнорировать ContentInformationRequest, и на самом деле возиться с ним чаще, чем просто ломает вещи.
- Apple предлагает вам сегментировать файл VTT на мелкие части, но вы просто не можете сделать это на стороне клиента (Apple запрещает это), но, к счастью, также кажется, что вам на самом деле не нужно этого делать, это просто предложение.
ПЕРЕМЕЩЕНИЕ ЗАПРОСОВ
Чтобы перехватить запросы, вы должны создать подкласс / расширить AVAssetResourceLoaderDelegate, и интересующий метод - это метод ShouldWaitForLoadingOfRequestedResource. Чтобы использовать делегата, создайте экземпляр своего AVPlayer, передав ему AVPlayerItem, но передайте AVPlayerItem AVUrlAsset, у которого есть свойство делегата, которому вы назначаете делегата. Все запросы будут проходить через метод ShouldWaitForLoadingOfRequestedResource, так что именно там будет происходить весь бизнес, за исключением одного скрытого осложнения - метод будет вызываться только в том случае, если запросы начинаются с чего-то другого, кроме http / https, поэтому я советую придерживаться постоянной строки в начале URL-адреса, который вы используете для создания своего AVUrlAsset, который вы можете просто сбрить после того, как запросы поступят к вашему делегату - давайте назовем это CUSTOMSCHEME. Эта часть описана в нескольких местах в Интернете, но это может быть очень неприятно, если вы не знаете, что вам нужно это делать, потому что будет казаться, что вообще ничего не происходит.
ПЕРЕХВАТ - ТИП A) перенаправление
Хорошо, теперь мы перехватываем запросы, но вы не хотите (/ не можете) обрабатывать их все самостоятельно. Некоторые запросы вы просто хотите разрешить. Вы делаете это следующим образом:
- создайте новый NSUrlRequest для ИСПРАВЛЕННОГО URL (удалите эту часть CUSTOMSCHEME из ранее) и установите для него свойство Redirect в LoadingRequest
- создайте новый NSHttpUrlResponse с тем же исправленным URL-адресом и кодом 302 и установите его в свойстве Response в LoadingRequest
- вызвать FinishLoading в запросе LoadingRequest
- вернуть истину
С помощью этих шагов вы можете добавить точки останова и прочее для отладки и проверки всех поступающих запросов, но они будут работать нормально, так что вы ничего не сломаете. Однако этот подход нужен не только для отладки, но и для нескольких запросов даже в готовом проекте.
ПЕРЕХВАТ - ТИП B) ответ редактирования / подделки
Когда приходят какие-то запросы, вы захотите сделать собственный запрос, чтобы ответ на ваш запрос (с некоторыми настройками) можно было использовать для выполнения LoadingRequest. Так что сделайте следующее:
- создайте NSUrlSession и вызовите метод CreateDataTask в сеансе (с исправленным URL-адресом - удалите CUSTOMSCHEME)
- вызвать Resume в DataTask (вне обратного вызова в DataTask)
- вернуть истину
- в обратном вызове DataTask у вас будут данные, поэтому (после внесения изменений) вы вызываете Respond в свойстве DataRequest LoadingRequest с этими (отредактированными) данными, а затем вызываете FinishLoading в LoadingRequest
ПЕРЕСЕЧЕНИЕ - какие запросы получают, какой тип лечения
Будет поступать множество запросов, некоторые из них необходимо перенаправить, на некоторые нужно дать ответы с изготовленными / измененными данными. Вот типы запросов, которые вы увидите в порядке их поступления, и что делать с каждым:
- запрос к главному списку воспроизведения, но RequestedLength DataRequest равен 2 - просто перенаправьте (ТИП A)
- запрос к главному плейлисту, но RequestedLength DataRequest соответствует (неотредактированной) длине главного плейлиста - сделайте свой собственный запрос к главному плейлисту, чтобы вы могли его отредактировать и вернуть отредактированный результат (ТИП B)
- запрос к мастеру воспроизведения, но RequestedLength DataRequest огромен - сделайте то же, что и для предыдущего (ТИП 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!!!"
!!! yourCustomUrlHere !!! вы используете на шаге 2, должны быть обнаружены вами, когда он используется для запроса, чтобы вы могли вернуть созданный список воспроизведения субтитров как часть ответа, поэтому установите для него что-то уникальное. Этот URL-адрес также должен будет использовать параметр CUSTOMSCHEME, чтобы он поступил к делегату. Вы также можете проверить этот пример потоковой передачи, чтобы увидеть, как должен выглядеть манифест: https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html (проанализируйте сетевой трафик с помощью отладчика браузера, чтобы увидеть его).
КАК РЕДАКТИРОВАТЬ ПЛЕЙЛИСТЫ - плейлист с субтитрами
Плейлист с субтитрами немного сложнее. Вы должны сделать все самостоятельно. То, как я это сделал, - это фактически захватить файл WebVtt внутри обратного вызова DataTask, затем проанализировать его, чтобы найти конец самой последней последовательности временных меток, преобразовать это в целое число секунд, а затем вставить это значение в паре мест большой строкой. Опять же, вы можете использовать приведенный выше пример и проанализировать сетевой трафик, чтобы увидеть реальный пример. Вот так это выглядит:
#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
Обратите внимание, что список воспроизведения НЕ сегментирует файл vtt, как рекомендует Apple, потому что это невозможно сделать на стороне клиента (источник: 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; }
}
}
28.04.2021