Потоковое видео в Safari: почему это так сложно?
Недавно я реализовал поддержку тегов AI для видео в моем продукте Sortal. Частью этой функции является то, что вы можете затем воспроизводить загруженные вами видео. Я подумал, нет проблем - потоковое видео кажется довольно простым.
На самом деле, это настолько просто (всего несколько строк кода), что я выбрал потоковое видео в качестве темы для примеров в моей книге Bootstrapping Microservices.
Но когда мы подошли к тестированию в Safari, я узнал ужасную правду. Итак, позвольте мне перефразировать предыдущее утверждение: потоковое видео - это просто для Chrome, но не для Safari.
Почему с Safari так сложно? Что нужно, чтобы оно работало в Safari? Ответы на эти вопросы приведены в этой статье.
Попробуйте сами
Прежде чем мы вместе начнем изучать код, попробуйте сами! Код, сопровождающий эту статью в блоге, доступен на GitHub. Вы можете скачать код или использовать Git для клонирования репозитория. Вам понадобится установленный Node.js, чтобы попробовать его.
Запустите сервер в соответствии с инструкциями в файле readme и перейдите в браузере к http://localhost:3000
. Вы увидите либо рисунок 1, либо рисунок 2, в зависимости от того, просматриваете ли вы страницу в Chrome или Safari.
Обратите внимание, что на экране 2, когда веб-страница просматривается в Safari, видео слева не работает. Однако пример справа работает, и в этом посте объясняется, как я создал рабочую версию кода потокового видео для Safari.
Основы потокового видео
Базовую форму потокового видео, работающую в Chrome, легко реализовать на вашем HTTP-сервере. Мы просто транслируем весь видеофайл из серверной части во внешний интерфейс, как показано на рисунке 3.
В интерфейсе
Для рендеринга видео во внешнем интерфейсе мы используем элемент HTML5 video. В этом нет ничего особенного; В листинге 1 показано, как это работает. Это версия, которая работает только в Chrome. Вы можете видеть, что src
видео обрабатывается в серверной части по маршруту /works-in-chrome
.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Video streaming example</title>
</head>
<body>
<video
muted
playsInline
loop
controls
src="/works-in-chrome"
>
</video>
</body>
</html>
На бэкэнде
Бэкэнд для этого примера - очень простой HTTP-сервер, построенный на платформе Express, работающей на Node.js. Вы можете увидеть код в листинге 2. Здесь и реализуется маршрут /works-in-chrome
.
В ответ на HTTP-запрос GET мы транслируем весь файл в браузер. Попутно мы устанавливаем различные заголовки HTTP-ответа.
Заголовок content-type
установлен как video/mp4
, и браузер знает, что это получение видео.
Затем мы получаем stat
файл, чтобы получить его длину и установить его в качестве заголовка content-length
, чтобы браузер знал, сколько данных он получает.
const express = require("express");
const fs = require("fs");
const app = express();
const port = 3000;
app.use(express.static("public"));
const filePath = "./videos/SampleVideo_1280x720_1mb.mp4";
app.get("/works-in-chrome", (req, res) => {
// Set content-type so the browser knows it's receiving a video.
res.setHeader("content-type", "video/mp4");
// Stat the video file to determine its length.
Но в Safari это не работает!
К сожалению, мы не можем просто отправить весь видеофайл в Safari и ожидать, что он заработает. Chrome может с этим справиться, но Safari отказывается играть в игру.
Чего не хватает?
Safari не хочет, чтобы весь файл был доставлен за один раз. Вот почему тактика прямой передачи всего файла не работает.
Safari хочет передавать части файла в потоковом режиме, чтобы его можно было поэтапно буферизовать по частям. Он также требует произвольного специального доступа к любой части файла, которая ему требуется.
Это действительно имеет смысл. Представьте, что пользователь хочет немного перемотать видео - вы же не хотите снова запускать потоковую передачу всего файла, не так ли?
Вместо этого Safari хочет просто вернуться немного назад и снова запросить эту часть файла. Фактически, это работает и в Chrome. Несмотря на то, что базовое потоковое видео работает в Chrome, Chrome действительно может выдавать HTTP запросы диапазона для более эффективной обработки потокового видео.
Рисунок 4 дает вам представление о том, как это работает. Нам нужно изменить наш HTTP-сервер, чтобы вместо потоковой передачи всего видеофайла во внешний интерфейс мы могли обслуживать части файла с произвольным доступом в зависимости от того, что запрашивает браузер.
Поддержка HTTP запросов диапазона
В частности, мы должны поддерживать запросы диапазона. Но как это реализовать?
Для него на удивление мало читаемой документации. Конечно, мы могли бы прочитать спецификации HTTP, но у кого есть для этого время и мотивация? (Я дам вам ссылки на ресурсы в конце этого поста.)
Вместо этого позвольте мне провести вас через обзор моей реализации. Ключ к этому - заголовок HTTP-запроса range
, который начинается с префикса "bytes="
.
Этот заголовок указывает, как интерфейс запрашивает определенный диапазон байтов, который нужно извлечь из видеофайла. В листинге 3 вы можете увидеть, как мы можем проанализировать значение этого заголовка, чтобы получить начальное и конечное значения для диапазона байтов.
const options = {};
let start;
let end;
const range = req.headers.range;
if (range) {
const bytesPrefix = "bytes=";
if (range.startsWith(bytesPrefix)) {
const bytesRange = range.substring(bytesPrefix.length);
const parts = bytesRange.split("-");
if (parts.length === 2) {
const rangeStart = parts[0] && parts[0].trim();
if (rangeStart && rangeStart.length > 0) {
options.start = start = parseInt(rangeStart);
}
const rangeEnd = parts[1] && parts[1].trim();
if (rangeEnd && rangeEnd.length > 0) {
options.end = end = parseInt(rangeEnd);
}
}
}
}
Ответ на запрос HTTP HEAD
HTTP-запрос HEAD - это то, как интерфейс исследует серверную часть для получения информации о конкретном ресурсе. Мы должны позаботиться о том, как с этим справиться.
Платформа Express также отправляет запросы HEAD нашему обработчику HTTP GET, поэтому мы можем проверить req.method
и вернуть early
из обработчика запросов, прежде чем выполнять больше работы, чем необходимо для запроса HEAD.
В листинге 4 показано, как мы отвечаем на запрос HEAD. Нам не нужно возвращать какие-либо данные из файла, но мы должны настроить заголовки ответов, чтобы сообщить веб-интерфейсу, что мы поддерживаем запрос диапазона HTTP, и сообщить ему полный размер видеофайла.
Заголовок ответа accept-ranges
указывает на то, что этот обработчик запроса может ответить на запрос диапазона.
if (req.method === "HEAD") {
res.statusCode = 200;
// Inform the frontend that we accept HTTP
// range requests.
res.setHeader("accept-ranges", "bytes");
// This is our chance to tell the frontend
// the full size of the video file.
res.setHeader("content-length", contentLength);
res.end();
}
else {
// ... handle a normal HTTP GET request ...
}
Полный файл или частичный файл
А теперь самое сложное. Отправляем ли мы файл полностью или часть файла?
С некоторой осторожностью мы можем заставить наш обработчик запросов поддерживать оба метода. Вы можете видеть в листинге 5, как мы вычисляем retrievedLength
из начальных и конечных переменных, когда это запрос диапазона и эти переменные определены; в противном случае мы просто используем contentLength
(полный размер файла), когда это не запрос диапазона.
let retrievedLength;
if (start !== undefined && end !== undefined) {
retrievedLength = (end+1) - start;
}
else if (start !== undefined) {
retrievedLength = contentLength - start;
}
else if (end !== undefined) {
retrievedLength = (end+1);
}
else {
retrievedLength = contentLength;
}
Отправить код состояния и заголовки ответа
Мы рассмотрели запрос HEAD. Все, что осталось обработать - это HTTP запрос GET.
В листинге 6 показано, как мы отправляем соответствующий код состояния успеха и заголовки ответа.
Код состояния меняется в зависимости от того, является ли это запросом полного файла или запросом диапазона для части файла. Если это запрос диапазона, код состояния будет 206 (для частичного содержимого); в противном случае мы используем обычный старый код состояния успеха 200.
// Send status code depending on whether this is
// request for the full file or partial content.
res.statusCode = start !== undefined || end !== undefined ? 206 : 200;
res.setHeader("content-length", retrievedLength);
if (range !== undefined) {
// Conditionally informs the frontend what range of content
// we are sending it.
res.setHeader("content-range",
`bytes ${start || 0}-${end || (contentLength-1)}/${contentLength}`
);
res.setHeader("accept-ranges", "bytes");
}
Потоковая передача части файла
Теперь самое простое: потоковое воспроизведение части файла. Код в листинге 7 почти идентичен коду в базовом примере потокового видео еще в листинге 2.
Разница теперь в том, что мы передаем объект options
. Удобно, что функция createReadStream
из Node.js’ модуля файловой системы принимает начальное и конечное значения в объекте options
, которые позволяют считывать часть файла с жесткого диска.
В случае запроса диапазона HTTP более ранний код в листинге 3 будет анализировать start
и end
значения из заголовка, и мы вставим их в объект options
.
В случае нормального HTTP GET запроса (не запрос диапазона), то start
и end
не были разобраны и не будет находиться в объекте options
, в этом случае, мы просто читаем весь файл.
const fileStream = fs.createReadStream(filePath, options);
fileStream.on("error", error => {
console.log(`Error reading file ${filePath}.`);
console.log(error);
res.sendStatus(500);
});
fileStream.pipe(res);
Собираем все вместе
Теперь давайте объединим весь код в законченный обработчик запросов для потоковой передачи видео, который работает как в Chrome, так и в Safari.
Листинг 8 представляет собой объединенный код от листинга 3 до листинга 7, поэтому вы можете увидеть все это в контексте. Этот обработчик запросов может работать в любом случае. Он может получить часть видеофайла, если этого потребует браузер. В противном случае он извлекает весь файл.
app.get('/works-in-chrome-and-safari', (req, res) => {
// Listing 3.
const options = {};
let start;
let end;
const range = req.headers.range;
if (range) {
const bytesPrefix = "bytes=";
if (range.startsWith(bytesPrefix)) {
const bytesRange = range.substring(bytesPrefix.length);
const parts = bytesRange.split("-");
if (parts.length === 2) {
const rangeStart = parts[0] && parts[0].trim();
if (rangeStart && rangeStart.length > 0) {
options.start = start = parseInt(rangeStart);
}
const rangeEnd = parts[1] && parts[1].trim();
if (rangeEnd && rangeEnd.length > 0) {
options.end = end = parseInt(rangeEnd);
}
}
}
}
res.setHeader("content-type", "video/mp4");
fs.stat(filePath, (err, stat) => {
if (err) {
console.error(`File stat error for ${filePath}.`);
console.error(err);
res.sendStatus(500);
return;
}
let contentLength = stat.size;
// Listing 4.
if (req.method === "HEAD") {
res.statusCode = 200;
res.setHeader("accept-ranges", "bytes");
res.setHeader("content-length", contentLength);
res.end();
}
else {
// Listing 5.
let retrievedLength;
if (start !== undefined && end !== undefined) {
retrievedLength = (end+1) - start;
}
else if (start !== undefined) {
retrievedLength = contentLength - start;
}
else if (end !== undefined) {
retrievedLength = (end+1);
}
else {
retrievedLength = contentLength;
}
// Listing 6.
res.statusCode = start !== undefined || end !== undefined ? 206 : 200;
res.setHeader("content-length", retrievedLength);
if (range !== undefined) {
res.setHeader("content-range", `bytes ${start || 0}-${end || (contentLength-1)}/${contentLength}`);
res.setHeader("accept-ranges", "bytes");
}
// Listing 7.
const fileStream = fs.createReadStream(filePath, options);
fileStream.on("error", error => {
console.log(`Error reading file ${filePath}.`);
console.log(error);
res.sendStatus(500);
});
fileStream.pipe(res);
}
});
});
Обновленный код внешнего интерфейса
Ничего не нужно менять в коде внешнего интерфейса, кроме проверки того, что элемент video
указывает на маршрут HTTP, который может обрабатывать запросы диапазона HTTP.
В листинге 9 показано, что мы просто перенаправили элемент видео на маршрут с именем /works-in-chrome-and-safari
. Этот интерфейс будет работать как в Chrome, так и в Safari.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Video streaming example</title>
</head>
<body>
<video
muted
playsInline
loop
controls
src="/works-in-chrome-and-safari"
>
</video>
</body>
</html>
Вывод
Несмотря на то, что потоковую передачу видео просто запустить для Chrome, для Safari это немного сложнее - по крайней мере, если вы пытаетесь выяснить это самостоятельно из спецификации HTTP.
К счастью для вас, я уже прошел этот путь, и этот пост в блоге заложил основу, которую вы можете использовать для своей собственной реализации потокового видео.
Ресурсы
- Пример кода
- Пост на Stack Overflow, который помог мне понять, чего не хватает
- Спецификация HTTP
- Полезная документация Mozilla:Запросы диапазонаДиапазон206 Статус успешного частичного содержимого