Добро пожаловать в первую часть серии руководств «Потоковая передача анимации на стороне сервера с помощью MJPEG и Go»!

В большинстве случаев при разработке графических веб-приложений клиент-сервер сложные анимации в реальном времени выполняются непосредственно на стороне клиента с использованием API-интерфейсов рендеринга, таких как WebGL или WebGPU. Данные и код рендеринга отправляются с сервера на клиент, а затем рендерятся движком браузера клиента с использованием графического процессора (GPU). Преимущества очевидны — ЦП и ГП сервера освобождаются от потенциально огромного объема работы в случае одновременного подключения большого количества клиентов.

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

Мы собираемся визуализировать простую анимацию на сервере, а затем использовать популярный видеоформат Motion-JPEG (MJPEG) для потоковой передачи нашей анимации по протоколу HTTP. Несмотря на то, что представленный здесь метод не зависит от языка, пример кода написан на языке программирования Go.

Исходный код этого руководства можно найти на GitHub

Предпосылки

  • Версия компилятора Go 1.13+;
  • Базовые знания языка программирования Go;
  • Базовые знания протокола HTTP и разработки серверов.

Что такое Motion JPEG?

Motion JPEG или MJPEG — это видеоформат, в котором каждый кадр представляет собой одно изображение JPEG. Это широко используемый формат в устройствах видеозахвата, который поддерживается большинством основных веб-браузеров. Возможно, это не самый эффективный формат видео с точки зрения степени сжатия, но его преимущество заключается в простоте использования и понимания. MJPEG выбран для этого руководства по вышеупомянутым причинам.

Отправка MJPEG по HTTP

В основе этого руководства мы собираемся анимировать и передавать изображение за изображением с сервера клиенту по широко используемому протоколу HTTP (протокол передачи гипертекста).

В браузере клиента изображения будут отображаться в <img> HTML-элементе быстро одно за другим, создавая иллюзию анимации. Контент будет получен из запроса URL, указанного в атрибуте src тега <img>. Поскольку мы собираемся генерировать контент на лету, в качестве URL следует указать путь, который обрабатывается нашим сервером. Сервер будет отвечать на эти запросы GET, создавая изображение и отправляя обратно доступные данные изображения, а также соответствующие заголовки содержимого.

При отправке MJPEG по HTTP ответ сервера должен включать:

Content-Type: multipart/x-mixed-replace; boundary="<boundary-name>" 
--<boundary-name> 
Content-Type: image/jpeg 
Content-Length: <number of bytes> 
<JPEG image_1 bytes> 
--<boundary-name> 
Content-Type: image/jpeg 
Content-Length: <number of bytes> 
<JPEG image_2 bytes> 
--<boundary-name> 
Content-Type: image/jpeg 
Content-Length: <number of bytes> 
<JPEG image_n bytes> 
--<boundary-name>

Content-Type: multipart/x-mixed-replace; boundary="<boundary-name>" сообщает нашему клиенту, что ответ будет состоять из нескольких частей (изображений), разделенных <boundary-name>, и содержимое будет заменяться каждый раз.
Content-Type: image/jpeg указывает, что браузер должен интерпретировать полученные байтовые данные как изображение JPEG и Content-Length: <number of bytes> должен быть размером данных.

--<boundary-name> указывает на начало и конец каждого кадра изображения, а <JPEG image_n bytes> — это сами данные изображения.
Обратите внимание, что пустые строки между некоторыми частями ответа важны.

Сервер должен отправить все видеоданные в одном ответе. Это означает, что соединение должно быть открыто на протяжении всего сеанса потоковой передачи анимации, а содержимое ответа должно отправляться постоянно, а не подготовить и отправить все сразу, что более типично для обмена данными по протоколу HTTP.

Создание изображений JPEG в Go

Пришло время заняться программированием!

Из приведенного выше формата HTTP-ответа видно, что фактические данные изображения JPEG являются ключевой частью головоломки, поскольку все остальные части — это просто вопрос установки правильных метаданных контента. Итак, в качестве первого шага давайте создадим в памяти изображение в формате JPEG и заполним его цветом.

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

im := image.NewRGBA(image.Rect(0, 0, 100, 100))

Затем залейте его цветом.

color := color.RGBA{0, 0, 255, 255}
draw.Draw(im, im.Bounds(), &image.Uniform{color}, image.ZP,draw.Src)

Цвет описывается структурным типом color.RGBA.

На данный момент изображение еще не в формате JPEG. Также обратите внимание, что сейчас нас интересуют данные изображения, представленные в виде буфера байтов, потому что мы хотим отправить их по HTTP. Давайте сделаем именно это — закодируем наше изображение как байтовый буфер JPEG!

var buff bytes.Buffer
jpeg.Encode(&buff, im, nil)

Мы могли бы захотеть обернуть этот код в удобную функцию, которая принимает параметры изображения, такие как размеры и цвет, и возвращает указатель на буфер байтов изображения.

func getJPEG(w int, h int, color color.RGBA) []byte {
   im := image.NewRGBA(image.Rect(0, 0, w, h))
   draw.Draw(im, im.Bounds(), &image.Uniform{color}, image.ZP, draw.Src)
   var buff bytes.Buffer
   jpeg.Encode(&buff, im, nil)
   return buff.Bytes()
}

Исходный код
Теперь мы используем его таким образом, чтобы создать синее изображение размером 200x200 пикселей.

На данный момент у нас есть данные изображения в памяти и указатель, указывающий на них. Далее давайте посмотрим, как отправить его через HTTP-соединение.

Отправка изображения по HTTP с сервера на клиент

Для создания нашего клиент-серверного приложения нам потребуется разработать как серверную, так и клиентскую части. Клиентская часть будет представлять собой очень простой HTML-документ, который будет содержать заполнитель для изображения. Для серверной части мы создадим HTTP-сервер, который прослушивает входящие соединения и отвечает потоком данных изображения при запросе.

В функции main() вашего проекта Go создайте сервер и обработчик.

func main() {
    http.Handle("/", http.FileServer(http.Dir("./static")))
    http.HandleFunc("/picture", getPicture)
    port := "8080"
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

Сервер будет прослушивать входящие соединения через порт 8080. Статические файлы, такие как индексная страница HTML, будут обслуживаться из каталога /static в нашем проекте. Если клиент отправляет запрос GET по пути /picture, ответ будет отправлен обратно нашим сервером. В этом случае функция getPicture(...) отвечает за отправку данных изображения.

Клиентский код представляет собой HTML-шаблон с элементом <img>, который при загрузке отправляет запрос GET на путь URL /pictures и отображает ответ в виде изображения.

<body>
    <img style="border:2px solid black" src="/picture"  />
</body>

Исходный код
Теперь наша задача — отправить ответ так, чтобы браузер мог его правильно интерпретировать. Давайте теперь реализуем функцию getPicture(...).

func getPicture(w http.ResponseWriter, r *http.Request) {
   imgBytes := getJPEG(200, 200, blue)
   w.Header().Set("Content-Type", "image/jpeg")
   w.Header().Set("Content-Length", strconv.Itoa(len(imgBytes)))
   w.Write(imgBytes)
}

Исходный код
Сначала мы создаем изображение в памяти и возвращаем указатель на срез байтов изображения. Затем мы устанавливаем заголовки ответов таким образом, чтобы браузер мог понять, что он получил в ответ. Мы устанавливаем как тип изображения, так и размер изображения. И, наконец, мы отправляем по соединению сам байтовый буфер.

Запустите сервер с помощью команды go run и откройте браузер, указав localhost:8080. Вы должны увидеть синий квадрат — изображение, которое мы создали и отправили с сервера.

Однако в этом примере нет анимации. Исправим дальше!

Потоковая базовая анимация

Давайте добавим новый путь запроса URL в обработчики наших серверов и обработчик, отвечающий за ответ.

...
http.HandleFunc("/animation", getAnimation)
...

func getAnimation(w http.ResponseWriter, r *http.Request) {
}

Кроме того, не забудьте изменить значение атрибута src тега <img>, чтобы он указывал на наш новый путь запроса.

Чтобы создать базовую анимацию, нам понадобится пара изображений, которые будут отображаться одно за другим. Используя нашу вспомогательную функцию, создайте три изображения — красное, желтое и зеленое, которые при анимации создадут иллюзию смены светофора. Добавьте к функции getAnimation(...) следующее.

size = 200
var (
   red    = color.RGBA{255, 0, 0, 255}
   green  = color.RGBA{0, 255, 0, 255}
   yellow = color.RGBA{255, 255, 0, 255}
)
imgRed := getJPEG(size, size, red)
imgYellow := getJPEG(size, size, yellow)
imgGreen := getJPEG(size, size, green)

Теперь мы готовы отправить наши недавно анимированные кадры обратно клиенту. Сначала мы указываем, что ответ будет состоять из нескольких частей, разделенных boundary.

const boundary = "abcd4321"
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary="+boundary)

Для boundary я выбрал произвольную строку, но это может быть что угодно, если только она не появится в данных, которые мы хотим разделить.

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

w.Write([]byte("\r\n--" + boundary + "\r\n"))
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgRed)) + "\r\n\r\n"))
w.Write(imgRed)
w.Write([]byte("\r\n--" + boundary + "\r\n"))
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgYellow)) + "\r\n\r\n"))
w.Write(imgYellow)
w.Write([]byte("\r\n--" + boundary + "\r\n"))
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgGreen)) + "\r\n\r\n"))
w.Write(imgGreen)
w.Write([]byte("\r\n--" + boundary + "\r\n"))

При повторном запуске сервера и обновлении браузера вы увидите только зеленый квадрат. Почему? Причина в том, что весь наш ответ отправляется сразу, а не по картинкам. Именно так изначально работает HTTP — ответ собирается и отправляется, когда он сделан или достигается предельный размер буфера ответа. Но это не то, что мы хотим! В конце концов, мы разрабатываем сервис прямых трансляций. К счастью для нас, мы можем обойти это! Параметр http.ResponseWriter, который предоставляется нам через обработчик (обычно) реализует интерфейс http.Flusher. Это позволит нам сбросить буфер и отправить наши данные клиенту сразу по нашему запросу. Мы можем получить Flusher вот так.

f, ok := w.(http.Flusher)
if !ok {
   log.Println("HTTP buffer flushing is not implemented")
}

Теперь давайте вызовем этот метод Flush() между кадрами.

w.Write([]byte("\r\n--" + boundary + "\r\n"))
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgRed)) + "\r\n\r\n"))
w.Write(imgRed)
w.Write([]byte("\r\n--" + boundary + "\r\n"))
f.Flush()
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgYellow)) + "\r\n\r\n"))
w.Write(imgYellow)
w.Write([]byte("\r\n--" + boundary + "\r\n"))
f.Flush()
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgGreen)) + "\r\n\r\n"))
w.Write(imgGreen)
w.Write([]byte("\r\n--" + boundary + "\r\n"))

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

Давайте вставим задержку между нашими кадрами, используя стандартную функцию Go Sleep.

delay = 500 * time.Millisecond
w.Write([]byte("\r\n--" + boundary + "\r\n"))
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgRed)) + "\r\n\r\n"))
w.Write(imgRed)
w.Write([]byte("\r\n--" + boundary + "\r\n"))
f.Flush()
time.Sleep(delay)
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgYellow)) + "\r\n\r\n"))
w.Write(imgYellow)
w.Write([]byte("\r\n--" + boundary + "\r\n"))
f.Flush()
time.Sleep(delay)
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgGreen)) + "\r\n\r\n"))
w.Write(imgGreen)
w.Write([]byte("\r\n--" + boundary + "\r\n"))

Исходный код
Наконец-то вы сможете увидеть анимацию, к которой мы стремились.

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

Анимация синусоиды

Пришло время сделать что-то немного более интересное, чем светофоры. Давайте анимируем скользящую волну. Волна будет смоделирована с использованием базовой функции синусоидальной волны,

y = A * sin(B*x + C) + D

где A — амплитуда, B — период, C — фазовый сдвиг, а D — вертикальный сдвиг. x и y — это координаты в нашем пространстве изображения. Поскольку мы хотим анимировать нашу волну, мы будем немного увеличивать фазовый сдвиг C в каждом кадре, чтобы казалось, что волна скользит справа налево.

С точки зрения псевдокода алгоритм анимации можно описать следующим образом.

for num_frames as t: 
   img = new_image 
   for image_width as n: 
        x = n 
        y = A*sin(B*x + t) + D 
        img.set_pixel(x, y, color) 
    send_to_client( img )

Определив общее количество кадров в анимации, мы начинаем каждый кадр с пустого изображения. Для всех горизонтальных пикселей изображения мы находим соответствующий вертикальный пиксель и присваиваем ему какой-либо цвет, затем, когда изображение сформировано, мы отправляем его по HTTP и повторяем.

Идите вперед и создайте URL-путь /wave и добавьте новый обработчик. Измените атрибут src в документе HTML на "/wave".

...
http.HandleFunc("/wave", getSinewaves)
...

func getSinewaves(w http.ResponseWriter, r *http.Request) {
}

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

const (
    width  = 400
    height = 300
    nframes = 60
    delay = 50 * time.Millisecond
)

Мы знакомы с размерами и задержкой. nframes — это общее количество кадров в нашей анимации.

w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary="+boundary)
for t := 0; t < nframes; t++ {
 ...
}

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

img := image.NewPaletted(image.Rect(0, 0, width, height), palette)

Функция NewPaletted из пакета Image создает новое изображение с палитрой, состоящее из цветов, на которые указывает аргумент palette. Давайте определим нашу палитру где-нибудь вне цикла кадра.

var palette = []color.Color{color.White, blue}
const (
    whiteIndex = 0
    blueIndex  = 1
)

Не забудьте добавить определение цвета blue рядом с цветами, которые мы определили ранее.

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

for n := 0; n < width; n++ {
    x := float64(n)
    a := height / 3.0
    b := 0.01
    c := float64(t) / 6.0
    d := height / 2.0
    y := a*math.Sin(x*b+c) + d
    img.SetColorIndex(int(x), int(y), blueIndex)
}

Сначала мы вычисляем координаты x и y, которые должны быть окрашены в соответствии с функцией синуса, рассмотренной ранее. Затем назначьте цвет с индексом палитры blueIndex пикселю.

Остальной код аналогичен тому, который мы обсуждали в предыдущем разделе. Сначала мы кодируем изображение в формат JPEG и извлекаем часть его байтового массива.

var buff bytes.Buffer
jpeg.Encode(&buff, img, nil)
imgBytes := buff.Bytes()

Затем формируем содержимое ответа. Помните, что анимация должна начинаться с граничного маркера. В противном случае первый кадр будет пропущен.

if t == 0 {
    w.Write([]byte("\r\n--" + boundary + "\r\n"))
}
w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgBytes)) + "\r\n\r\n"))
w.Write(imgBytes)
w.Write([]byte("\r\n--" + boundary + "\r\n"))

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

f.Flush()
time.Sleep(delay)

В целом функция обработчика должна выглядеть так.

func getSinewaves(w http.ResponseWriter, r *http.Request) {
    var palette = []color.Color{color.White, blue}
    const (
        whiteIndex = 0
        blueIndex  = 1
    )
    const (
        width  = 400
        height = 300
        nframes = 60
        delay = 50 * time.Millisecond
    )
    f, ok := w.(http.Flusher)
    if !ok {
        log.Println("HTTP buffer flushing is not implemented")
    }
    w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary="+boundary)
    for t := 0; t < nframes; t++ {
        img := image.NewPaletted(image.Rect(0, 0, width, height), palette)
        for n := 0; n < width; n++ {
            x := float64(n)
            a := height / 3.0
            b := 0.01
            c := float64(t) / 6.0
            d := height / 2.0
            y := a*math.Sin(x*b+c) + d
            img.SetColorIndex(int(x), int(y), blueIndex)
        }
        var buff bytes.Buffer
        jpeg.Encode(&buff, img, nil)
        imgBytes := buff.Bytes()
        if t == 0 {
            w.Write([]byte("\r\n--" + boundary + "\r\n"))
        }
        w.Write([]byte("Content-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(imgBytes)) + "\r\n\r\n"))
        w.Write(imgBytes)
        w.Write([]byte("\r\n--" + boundary + "\r\n"))
        f.Flush()
        time.Sleep(delay)
    }
}

Исходный код
Теперь, когда вы перекомпилируете и запустите сервер, вы должны увидеть анимированную скользящую волну в своем браузере.

Вывод

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

Приведенный выше код предназначен только для представления идеи прямой трансляции и никоим образом не оптимизирован! Пакет стандартной библиотеки Go Image, вероятно, также не очень подходит для анимации в реальном времени. В следующем уроке мы будем рендерить и транслировать что-то более красочное, используя мощную библиотеку 3D-рендеринга — OpenGL.

Автор: Иварс Русбергс

Первоначально опубликовано на https://ivarsblog.com.