DevGang
Авторизоваться

Потоковое видео в 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.

Рисунок 1: Пример потокового видео в Chrome.<br>
Рисунок 1: Пример потокового видео в Chrome.
Рисунок 2: Пример потокового видео в Safari.&nbsp;Обратите внимание, что основная потоковая передача видео слева не работает.<br>
Рисунок 2: Пример потокового видео в Safari. Обратите внимание, что основная потоковая передача видео слева не работает.

Основы потокового видео

Базовую форму потокового видео, работающую в Chrome, легко реализовать на вашем HTTP-сервере. Мы просто транслируем весь видеофайл из серверной части во внешний интерфейс, как показано на рисунке 3.

Рисунок 3: Простая потоковая передача видео, работающая в Chrome.<br>
Рисунок 3: Простая потоковая передача видео, работающая в Chrome.

В интерфейсе

Для рендеринга видео во внешнем интерфейсе мы используем элемент HTML5 video. В этом нет ничего особенного; В листинге 1 показано, как это работает. Это версия, которая работает только в Chrome. Вы можете видеть, что src видео обрабатывается в серверной части по маршруту /works-in-chrome.

Листинг 1. Простая веб-страница для рендеринга потокового видео, работающая в 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, чтобы браузер знал, сколько данных он получает.

Листинг 2: Веб-сервер Node.js Express с простой потоковой передачей видео, работающий в Chrome
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-сервер, чтобы вместо потоковой передачи всего видеофайла во внешний интерфейс мы могли обслуживать части файла с произвольным доступом в зависимости от того, что запрашивает браузер.

Рисунок 4: Чтобы потоковое видео работало в Safari, мы должны поддерживать&nbsp;HTTP запросы диапазона, которые могут извлекать часть видеофайла, а не весь файл.<br>
Рисунок 4: Чтобы потоковое видео работало в Safari, мы должны поддерживать HTTP запросы диапазона, которые могут извлекать часть видеофайла, а не весь файл.

Поддержка&nbsp;HTTP запросов диапазона

В частности, мы должны поддерживать запросы диапазона. Но как это реализовать?

Для него на удивление мало читаемой документации. Конечно, мы могли бы прочитать спецификации HTTP, но у кого есть для этого время и мотивация? (Я дам вам ссылки на ресурсы в конце этого поста.)

Вместо этого позвольте мне провести вас через обзор моей реализации. Ключ к этому - заголовок HTTP-запроса range, который начинается с префикса "bytes=".

Этот заголовок указывает, как интерфейс запрашивает определенный диапазон байтов, который нужно извлечь из видеофайла. В листинге 3 вы можете увидеть, как мы можем проанализировать значение этого заголовка, чтобы получить начальное и конечное значения для диапазона байтов.

Листинг 3: Анализ HTTP заголовка диапазона
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 указывает на то, что этот обработчик запроса может ответить на запрос диапазона.

Листинг 4: Ответ на запрос HTTP HEAD
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 (полный размер файла), когда это не запрос диапазона.

Листинг 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;
}

Отправить код состояния и заголовки ответа

Мы рассмотрели запрос HEAD. Все, что осталось обработать - это HTTP запрос GET.

В листинге 6 показано, как мы отправляем соответствующий код состояния успеха и заголовки ответа.

Код состояния меняется в зависимости от того, является ли это запросом полного файла или запросом диапазона для части файла. Если это запрос диапазона, код состояния будет 206 (для частичного содержимого); в противном случае мы используем обычный старый код состояния успеха 200.

Листинг 6: Отправка заголовков ответа
// 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, в этом случае, мы просто читаем весь файл.

Листинг 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);

Собираем все вместе

Теперь давайте объединим весь код в законченный обработчик запросов для потоковой передачи видео, который работает как в Chrome, так и в Safari.

Листинг 8 представляет собой объединенный код от листинга 3 до листинга 7, поэтому вы можете увидеть все это в контексте. Этот обработчик запросов может работать в любом случае. Он может получить часть видеофайла, если этого потребует браузер. В противном случае он извлекает весь файл.

Листинг 8: Полный обработчик HTTP-запросов
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.

Листинг 9: Обновленный код внешнего интерфейса
<!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.

К счастью для вас, я уже прошел этот путь, и этот пост в блоге заложил основу, которую вы можете использовать для своей собственной реализации потокового видео.

Ресурсы

  1. Пример кода
  2. Пост на Stack Overflow, который помог мне понять, чего не хватает
  3. Спецификация HTTP
  4. Полезная документация Mozilla:Запросы диапазонаДиапазон206 Статус успешного частичного содержимого

Источник:

#JavaScript
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

Поделитесь своим опытом, расскажите о новом инструменте, библиотеке или фреймворке. Для этого не обязательно становится постоянным автором.

Попробовать

Оплатив хостинг 25$ в подарок вы получите 100$ на счет

Получить