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

Эта область довольно неплохо покрыта в десктопной среде — фильтры вроде ffdshow raw video filter и madVR позволяют делать практически всё, что может потребоваться для приятного просмотра. К сожалению, веб не может похвастаться аналогичным тулкитом, и вы либо наслаждаетесь всеми недостатками очередного видео на YouTube, либо открываете его во внешнем приложении вроде MPC-BE, что не очень удобно. А было бы неплохо иметь одну волшебную кнопку, активирующую фильтрацию в месте, где она и должна быть — в вашем браузере.

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

Замечания


Во время чтения статьи следует учесть:
  1. Все приведенные демонстрации основаны на html5 video с установленным атрибутом loop. Это видео может ужасно дергаться и лагать во время переключения на начало видеоряда в некоторых браузерах, по вине этих браузеров. Я не стал переусложнять код ради возможного исправления этой проблемы.
  2. Если повторяющееся видео вас раздражает, можно добавить loop=false к GET-параметрам запроса.
  3. Демки тестировались только в хроме, лисе и IE11, в остальных браузерах может не работать.
  4. Исходный код всех демок приведен прямо внутри соответствующих html-страниц, без зависимостей.
  5. В тексте много исковерканных английских слов и корявых переводов. Я плохо разбираюсь в русскоязычной терминологии, исправления приветствуются.
  6. Мы закрываем глаза на возможные проблемы с CORS, сайтами, использующими Flash-видео и т.п. Только сферические тесты в вакууме.
  7. В JavaScript я проездом, поэтому не стоит слишком доверять приведенному ниже тексту. Для большей уверенности можете поделить приведенное время на 2. Надеюсь увидеть исправления и советы в комментариях.

Принципы реализации


Единственным вариантом, который позволил бы иметь одно ядро для ��сех целевых браузеров (Chrome и Firefox в первую очередь) является браузерное расширение. Альтернатива в виде Google Chrome Native Client, внезапно, работает только в Chrome, и Mozilla на данный момент не собирается поддерживать NaCl в Firefox. Кроме того, я не изучал возможности доступа NaCl к элементам на странице — вполне может оказаться, что для наших целей он не сработает.

Базовый алгоритм работы (теоретического) расширения довольно прост: ищем элемент video на странице, прячем его, и сверху создаем canvas, на котором рендерятся фильтрованные кадры видео-потока. Пока всё просто.

Реальной проблемой у расширения является язык реализации — интерпретируемый JavaScript, а как мы знаем, интерпретируемые языки плохо подходят для серьезных расчетов. Но ведь это не беда! JavaScript последнее время получает море любви и оптимизаций, и существует довольно большое количество программистов, считающих, что JS — язык, подходящий для написания любых приложений и что вообще всё должно двигаться в веб. Более того, доступно множество новых технологий вроде asm.js, SIMD.js, WebGL и WebCL, которые, в теории, позволяют реализовывать всё, что душе угодно, со скоростью лишь немного меньше нативной. Так что мы не должны иметь никаких серьезных проблем с написанием набора фильтров в браузере, правда?

Не совсем.

Чистый JavaScript


Фильтрация в чистом JS работает по следующей схеме:
  1. Получаем оба необходимых элемента — спрятанный video и canvas, расположенный поверх него.
  2. Рисуем кадр из видео на канвасе через context.drawImage(video, 0, 0), где context — 2d контекст, получе��ный с канваса.
  3. Получаем буфер кадра (массив байтов цветов) через context.getImageData(0, 0, width, height).
  4. Обрабатываем буфер требуемыми фильтрами.
  5. Кладем обработанный массив обратно через context.putImageData(imageData, 0, 0).

Этот алгоритм работает и позволяет проводить реальную фильтрацию видео в чистом JavaScript с минимальным количеством очень похожего на C кода. Так будет выглядеть базовая (не оптимизированная) реализация фильтра invert, инвертирующего RGB байты в каждом пикселе кадра:
outputContext.drawImage(video, 0, 0);
var imageData = outputContext.getImageData(0, 0, width, height);
var source = imageData.data;
var length = source.length;
for (var i = 0; i < length; i += 4) {
    source[i  ] = 255 - source[i];
    source[i+1] = 255 - source[i+1];
    source[i+2] = 255 - source[i+2];
    // игнорируем альфу
}
outputContext.putImageData(imageData, 0, 0);

И хотя этот метод работает для демок и простых картинок, он очень быстро “сдувается” на высоких разрешениях. Хотя вызов drawImage сам по себе довольно быстр даже на 1080p, после добавления getImageData и putImageData время выполнения растет до 20-30 миллисекунд на одну итерацию. Полный код, приведенный выше, выполняется уже за 35-40мс, что является предельной скоростью для PAL-видео (25 кадров в секунду, 40мс на один кадр). Все замеры получены на 4770k, который является одним из наиболее мощных домашних процессоров на данный момент. Это означает, что выполнение любого более-менее сложного фильтра на предыдущих поколениях процессоров невозможно вне зависимости от производительности JavaScript. Любой, даже очень быстрый код, будет упираться в ужасную производительность самого канваса.

Но JavaScript не очень быстр сам по себе. Хотя обычные операции вроде инвертирования или прогона через LUT могут выполняться за разумное время, любой более-менее сложный фильтр вызывает ужасные лаги. Простая реализация фильтра добавления шума (Math.random()*10 к каждому пикселю) требует уже 55 миллисекунд, а 3х3 ядро для блюра, реализованное в приведенном ниже коде, проходит за 400мс, или 2.5 кадров в секунду.
function blur(source, width, height) {
    function blur_core(ptr, offset, stride) {
        return (ptr[offset - stride - 4] +
                ptr[offset - stride] +
                ptr[offset - stride + 4] +
                ptr[offset - 4] +
                ptr[offset] +
                ptr[offset + 4] +
                ptr[offset + stride - 4] +
                ptr[offset + stride] +
                ptr[offset + stride + 4]
                ) / 9;
    }

    var stride = width * 4;
    for (var y = 1; y < (height - 1); ++y) {
        var offset = y * stride;
        for (var x = 1; x < stride - 4; x += 4) {
            source[offset] = blur_core(source, offset, stride);
            source[offset + 1] = blur_core(source, offset + 1, stride);
            source[offset + 2] = blur_core(source, offset + 2, stride);
            offset += 4;
        }
    }
}

Firefox показывает еще более удручающие результаты с 800 мс/проход. Что интересно, IE11 опережает даже Chrome, причем в два раза (но сам canvas у него медленный, так что это не спасает). В любом случае, становится ясно, что чистый JavaScript — неправильное средство для реализации фильтров.

asm.js


Новомодный asm.js — средство от компании Mozilla для оптимизации выполнения JavaScript кода. Генерируемый код по-прежнему будет работать в хроме, однако надеяться не серьезный прирост производительности не стоит, поскольку поддержка asm.js, по всей видимости, еще не добавлена.

К сожалению, я не смог найти простой путь компиляции выбранных функций в asm.js-оптимизированный код. Emscripten генерирует около 4.5 тысяч строк кода при компиляции простой двустрочной функции, и я не понял, как можно вытащить из него только нужный код за разумное время. Писать же asm.js руками — то ещё удовольствие. В любом случае, asm.js упрётся в производительность 2d-контекста канваса, аналогично чистому JavaScript.

SIMD.js


SIMD.js — очень новая технология ручной оптимизации JS-приложений, которая в настоящий момент “поддерживается” только в Firefox Nightly, но очень скоро может получить поддержку всех целевых браузеров. К сожалению, API сейчас работает только с двумя типами данных, float32x4 и uint32x4, что делает всю затею бесполезной для большинства реальных 8-битных фильтров. Более того, тип Int32x4Array пока не реализован даже в Nightly, поэтому любая запись и чтение данных из памяти будут происходить медленно и страшно (когда реализованы подобным образом). Однако, приведу код реализации обычного фильтра инвертирования (на этот раз работающего через XOR):
function invert_frame_simd(source) {
    var fff = SIMD.int32x4.splat(0x00FFFFFF);
    var length = source.length / 4;
    var int32 = new Uint32Array(source.buffer);
    for (var i = 0; i < length; i += 4) {
        var src = SIMD.int32x4(int32[i], int32[i+1], int32[i+2], int32[i+3]);
        var dst = SIMD.int32x4.xor(src, fff);
        int32[i+0] = dst.x;
        int32[i+1] = dst.y;
        int32[i+2] = dst.z;
        int32[i+3] = dst.w;
    }
}

На данный момент приведенный код выполняется значительно медленней чистого JS — 1600мс/проход (пользователи Nighly могут попробовать очередное демо). Похоже, придется подождать еще достаточное количество времени, прежде чем можно будет делать хоть что-то полезное с этой технологией. К сожалению, не ясно, как будет реализована поддержка 256-битных YMM регистров (int32x4 — обычный 128-битный xmm из SSE2), и будут ли доступны инструкции из более новых технологий вроде SSSE3. Ну и SIMD.js не спасает от медленного канваса. Зато фанаты SIMD могут уже сейчас получить некотор��е привычные баги, прямо в браузере!

WebGL


Совершенно другой способ реализации фильтров — WebGL. В самом базовом понимании WebGL — JS-интерфейс для нативной технологии OpenGL, которая позволяет выполнять разнообразный код на GPU. Обычно она используется для программирования графики в играх и т.п., однако никто не мешает обрабатывать картинки или даже видео с её помощью. WebGL также не требует вызовов getImageData, что в теории позволяет избежать типичного 20мс-лага.

Но ничто не бывает бесплатно — WebGL не является средством общего назначения и использовать это API для абстрактного неграфического кода — ужасная боль. Потребуется определять бесполезные вертексы (которые всегда будут покрывать весь кадр), правильно позиционировать текстуру (которая будет закрывать весь кадр), а затем использовать видео в качестве текстуры. К счастью, WebGL достаточно умён, чтобы запрашивать нужные кадры из видео автоматом. По крайней мере, в хроме и лисе. IE11 же обрадует ошибкой WEBGL11072: INVALID_VALUE: texImage2D: This texture source is not supported.

Наконец, для написания фильтров придётся использовать шейдеры, реализуемые на немного ущербном языке GLSL, который (по крайней мере в WebGL-варианте) даже не поддерживает установку константных массивов, поэтому любые массивы надо будет либо передавать с помощью uniforms (такие типа-глобальные переменные), либо использовать индийский способ:
float core1[9];
core1[0] = 1.0;
core1[1] = 1.0;
core1[2] = 0.0;
core1[3] = 1.0;
core1[4] = 0.0;
core1[5] = -1.0;
core1[6] = 0.0;
core1[7] = -1.0;
core1[8] = -1.0;

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

В общем, технологии вроде CUDA и OpenCL были придуманы не от хорошей жизни.

В оправдание WebGL, он имеет действительно потрясающую для веба производительность (которую вы не мож��те измерить). По крайней мере, он может обработать фильтр prewitt из masktools (выбор максимального значения из четырех 3х3 ядер) в реальном времени на 1080p и выше. Если вы ненавидите себя и не боитесь получить немного неподдерживаемый код, WebGL позволяет делать с видео довольно интересные вещи. Более разумным может быть использование библиотеки seriously.js, которая прячет часть шаблонного WebGL-кода, однако может оказаться недостаточно продвинутой для обработки изменения разрешения видео или реализации временных фильтров.

Если же вы себя любите, то, скорее всего, вам захочется использовать что-то вроде WebCL.

WebCL


Но не получится. Википедия говорит, что WebCL 1.0 был финализирован 19-ого марта этого года, что делает технологию самой молодой из всего списка, моложе даже SIMD.js. И, в отличие от SIMD.js, она не будет поддерживаться в Firefox в ближайшем будущем. Где-то читал об аналогичном решении для Chrome, но потерял ссылку. Так что WebCL на данный момент является мёртвой технологией без ясного будущего.

Заключение


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