Режем, сжимаем и конвертируем видео прямо в браузере: WebCodecs, mp4box.js и грабли, на которые мы наступили

Нам понадобилось сделать на сайте простые видеоинструменты: обрезать ролик, сжать его под лимит мессенджера, перегнать MOV в MP4 или WEBM, убрать звук. Обычный путь — загрузить файл на сервер и прогнать через ffmpeg. Но видео тяжёлое: ролик с телефона на 2 ГБ надо сначала долго заливать, а сервер должен его хранить и пережёвывать. Да и не каждому хочется отправлять личное видео на чужой сервер.
Поэтому основной путь у нас — обработка прямо в браузере: файл никуда не уходит, всё делает компьютер пользователя. А сервер с ffmpeg остался запасным вариантом для того, что браузер не тянет.
Ниже — как это устроено и что пошло не так. Код — из рабочего проекта, слегка сокращён.
Конвейер
File ──► mp4box.js ──► VideoDecoder ──► (OffscreenCanvas) ──► VideoEncoder ──► mp4-muxer / webm-muxer ──► Blob
(только moov) WebCodecs масштаб, поворот WebCodecsДемультиплексор — mp4box.js: разбирает контейнер MP4/MOV и отдаёт список сэмплов (кадров) с их смещениями в файле, временем и признаком ключевого кадра.
Кодеки — WebCodecs:
VideoDecoder,VideoEncoder,AudioDecoder,AudioEncoder. Внутри — системные кодеки, часто аппаратные.Мультиплексор — mp4-muxer и webm‑muxer: собирают закодированные кадры обратно в файл.
Перед стартом проверяем, что API вообще есть:
function canBrowser() {
return ['VideoDecoder', 'VideoEncoder', 'AudioDecoder', 'AudioEncoder', 'VideoFrame', 'AudioData']
.every(function (k) { return typeof window[k] === 'function'; });
}Если нет — сразу предлагаем серверный путь.
Файл на 4 ГБ, который не надо читать целиком
Первая проблема — память. file.arrayBuffer() на двухгигабайтном ролике — плохая идея. Но для работы нам нужно только оглавление файла: бокс moov с таблицами сэмплов. Сами кадры (бокс mdat) можно читать по требованию.
У mp4box.js есть флаг discardMdatData: парсер пропускает данные кадров и не держит их в памяти. Скармливаем файл кусками по 4 МБ, пока не получим onReady:
async function readMoov(file) {
var mp4 = MP4Box.createFile(), info = null, failed = null;
mp4.discardMdatData = true;
mp4.onReady = function (i) { info = i; };
mp4.onError = function (e) { failed = e; };
var pos = 0, CH = 4 * MB;
while (!info && !failed && pos < file.size) {
var ab = await file.slice(pos, pos + CH).arrayBuffer();
ab.fileStart = pos;
var next = mp4.appendBuffer(ab, pos + ab.byteLength >= file.size);
// appendBuffer подсказывает, откуда читать дальше: если moov в конце файла, mdat перепрыгнем
pos = typeof next === 'number' && next > pos ? next : pos + ab.byteLength;
}
if (!info) throw new Unsupported('В браузере обрабатываются только MP4 и MOV');
return { mp4: mp4, info: info };
}А байты конкретного кадра достаём через File.slice() — окнами по 8 МБ, потому что кадры в файле лежат почти подряд и окно почти всегда попадает:
function Reader(file) {
var win = null, winStart = 0;
this.read = async function (off, size) {
if (!win || off < winStart || off + size > winStart + win.length) {
winStart = off;
win = new Uint8Array(await file.slice(off, off + Math.max(size, 8 * MB)).arrayBuffer());
}
return win.subarray(off - winStart, off - winStart + size);
};
}В итоге в памяти одновременно держится оглавление, окно 8 МБ, несколько кадров в очередях кодеков и растущий результат. Файлы до 4 ГБ на обычном ноутбуке проходят.
Обрезка с точностью до кадра
Обрезать «по ключевым кадрам» без перекодирования просто, но начало тогда прыгает на секунду‑две. Нам нужна точность до кадра, поэтому мы перекодируем:
Находим последний ключевой кадр до точки начала — декодер не может стартовать с промежуточного кадра.
Декодируем от него, а кадры раньше точки начала просто закрываем.
Каждый оставшийся кадр отдаём кодировщику с новым временем от нуля, раз в 2 секунды просим ключевой кадр — чтобы результат нормально перематывался.
var dec = new VideoDecoder({
output: function (frame) {
var t = frame.timestamp / 1e6;
if (t < start - 1e-4 || t >= end - 1e-4) { frame.close(); return; }
var out = new VideoFrame(frame, { timestamp: Math.round((t - start) * 1e6) });
frame.close();
var key = t - lastKey >= 2;
if (key) lastKey = t;
enc.encode(out, { keyFrame: key });
out.close();
},
error: function (e) { failed = e; }
});Важная деталь — обратное давление. decode() и encode() не блокируются: если просто прогнать цикл по всем сэмплам, в очередь улетят тысячи кадров, а декодированный кадр 4K — это десятки мегабайт видеопамяти. Поэтому после каждого сэмпла ждём, пока очереди разгрузятся:
dec.decode(new EncodedVideoChunk({ type: s.is_sync ? 'key' : 'delta', timestamp: ..., duration: ..., data: bytes }));
while (dec.decodeQueueSize > 12 || enc.encodeQueueSize > 12) {
if (failed) throw failed;
await sleep(2);
}И не забывайте frame.close() на каждом VideoFrame: без этого браузер быстро упирается в лимит кадров, и декодер молча встаёт.
Грабли
1. Время в плеере не совпадает со временем кадров
Пользователь выделяет кусок на <video>, а мы режем по меткам времени сэмплов из mp4box. На части файлов результат съезжал на десятки миллисекунд. Виноват список правок (бокс elst): он говорит плееру «начни показывать дорожку не с нуля» или «вставь пустоту в начале». <video> его учитывает, а сырые cts — нет. Поэтому пересчитываем:
function editOffset(trak, movieScale) {
var e = trak.edts && trak.edts.elst && trak.edts.elst.entries, delay = 0;
if (!e) return 0;
for (var i = 0; i < e.length; i++) {
if (e[i].media_time === -1) { delay += e[i].segment_duration / movieScale; continue; } // пустая правка
return e[i].media_time / trak.mdia.mdhd.timescale - delay;
}
return -delay;
}У видео и звука сдвиги могут быть разными — считаем отдельно для каждой дорожки.
2. Вертикальное видео с телефона лежит на боку
Телефон часто пишет кадр «горизонтально», а в дорожку кладёт матрицу поворота на 90°. Достаём угол из матрицы:
function rotationOf(track) {
var m = track.matrix;
if (!m) return 0;
var deg = ((Math.round(Math.atan2(m[1], m[0]) * 180 / Math.PI) % 360) + 360) % 360;
return [0, 90, 180, 270].indexOf(deg) >= 0 ? deg : 0;
}В MP4 всё просто: передаём rotation в mp4-muxer, и он записывает ту же матрицу. А в WEBM матрицы поворота нет — там приходится физически поворачивать каждый кадр через OffscreenCanvas, меняя местами ширину и высоту.
3. Звук из MOV с iPhone не декодируется
AudioDecoder для AAC нужен description — те самые несколько байт AudioSpecificConfig из бокса esds. В MP4 mp4box.js отдаёт их без проблем. А в MOV с айфонов и камер звуковая запись хранится в формате QuickTime версии 1, который mp4box.js 0.5.2 не разбирает, и esds до нас не доходит.
Решение — найти esds в сырых байтах записи самим и пройти по дескрипторам MPEG-4 (длина каждого записана 7-битными группами):
// DecoderSpecificInfo (тег 5): ES_Descriptor(3) → DecoderConfigDescriptor(4) → DSI(5)
function esdsDsi(b) {
for (var i = 0; i + 12 < b.length; i++) {
if (b[i] !== 0x65 || b[i + 1] !== 0x73 || b[i + 2] !== 0x64 || b[i + 3] !== 0x73) continue; // 'esds'
var p = i + 8; // имя бокса + version/flags
var desc = function () {
var tag = b[p++], len = 0, k = 0;
do { len = (len << 7) | (b[p] & 0x7f); } while ((b[p++] & 0x80) && ++k < 4);
return { tag: tag, len: len };
};
var d = desc();
if (d.tag !== 3) return null;
p += 2; // ES_ID
var flags = b[p++];
if (flags & 0x80) p += 2; // dependsOn_ES_ID
if (flags & 0x40) p += 1 + b[p]; // URL
if (flags & 0x20) p += 2; // OCR_ES_Id
d = desc();
if (d.tag !== 4) return null;
p += 13; // objectType, streamType, буфер, битрейты
d = desc();
if (d.tag !== 5 || p + d.len > b.length) return null;
return b.slice(p, p + d.len);
}
return null;
}Заодно из первого байта DSI узнаём точный профиль AAC: 'mp4a.40.' + (dsi[0] >> 3).
4. «Какой H.264 вы умеете?» — спросите, а не угадывайте
Кодек задаётся строкой вида avc1.640034 — профиль и уровень. Если заказать уровень, который кодировщик не поддерживает, или кадр больше допустимого для уровня, configure() упадёт. Поэтому перебираем варианты от High 5.2 до Baseline 3.1 через isConfigSupported() и берём первый подходящий:
var CODECS = ['avc1.640034', 'avc1.640033', 'avc1.64002a', 'avc1.640028',
'avc1.4d0034', 'avc1.4d0028', 'avc1.42e034', 'avc1.42e01f'];
for (var codec of CODECS) {
var cfg = { codec, width: w, height: h, bitrate, bitrateMode: mode, framerate: fps,
latencyMode: 'quality', avc: { format: 'avc' } };
var s = await VideoEncoder.isConfigSupported(cfg).catch(() => null);
if (s && s.supported) return cfg;
}avc: { format: 'avc' } важен: так кодировщик отдаёт кадры с длинами NAL‑блоков и avcC в метаданных — именно это нужно для MP4. Для WEBM перебираем vp09.00.41.08, vp09.00.40.08, vp09.00.31.08 и на крайний случай vp8.
5. Opus хочет 48 кГц
В MP4 звук обычно 44,1 кГц, а Opus для WEBM принимает только 48 кГц. Ресемплер из Web Audio сюда не прикрутишь — звук идёт потоком кусками из AudioDecoder. Написали простой линейный ресемплер, который помнит последний сэмпл предыдущего куска, чтобы на стыках не было щелчков. Для речи и музыки в роликах с телефона разницы с «правильным» ресемплингом на слух мы не заметили. Для студийного звука так делать не стоит.
6. «Сожми до 25 МБ» — а браузер не может
Режим «уложиться в размер» считает битрейт из длительности и целевого размера. В теории всё сходится, на практике встроенный H.264-кодировщик Chrome в наших тестах не опускался ниже примерно 900 кбит/с на 720 p, сколько ни проси, и в переменном режиме заметно перебирал. Сделали так:
для режима «точный размер» —
bitrateMode: 'constant';кодируем, проверяем фактический размер, если не влезли — поправляем битрейт пропорционально и пробуем ещё раз;
если и это не помогло — уменьшаем кадр: 720 p → 480 p → 360 p;
если промах больше 20%, битрейт не трогаем — кодировщик упёрся в свой предел, сразу уменьшаем кадр;
если не влезли совсем — предлагаем сервер, где у x264 с
-b:vтаких ограничений нет.
for (var i = 0; i < heights.length; i++) {
var factor = 1;
for (var round = 0; round < 2; round++) {
var blob = await processInBrowser({ height: heights[i], factor: factor });
if (blob.size <= limit) return { blob };
var ratio = limit / blob.size;
if (ratio < 0.8) break; // кодировщик упёрся в предел — следующий размер кадра
factor *= ratio * 0.95;
}
}7. Убрать звук без перекодирования — и потерять два кадра
Для «убрать звук» перекодировать видео вообще не нужно: H.264 и HEVC можно перенести в новый MP4 как есть. В mp4-muxer для этого есть addVideoChunkRaw(), куда передаём байты сэмпла, время показа и сдвиг cts − dts:
muxer.addVideoChunkRaw(bytes, s.is_sync ? 'key' : 'delta', us(s.cts), us(s.duration),
i ? undefined : { decoderConfig: decCfg }, us(s.cts) - us(s.dts));Работает мгновенно даже на гигабайтных файлах. Нюанс: mp4-muxer не пишет список правок, поэтому видео с B‑кадрами начинается на пару кадров позже исходника. Без звука это незаметно, и мы это приняли.
8. HEVC с iPhone и прочие «не умею»
VideoDecoder.isConfigSupported() для HEVC отвечает по‑разному в зависимости от браузера, ОС и видеокарты. AVI и MKV mp4box.js не читает вовсе. На все такие случаи у нас одно правило: ошибка класса Unsupported → понятное сообщение и кнопка «Обработать на сервере». Если браузер не может даже показать файл в <video>, страница сразу переходит в серверный режим с ручным вводом времени.
Серверный запасной путь
Он сделан максимально скучно:
загрузка кусками по 4 МБ — меньше стандартного
post_max_sizePHP, поэтому настройки nginx и PHP трогать не пришлось; каждый кусок с повтором при сбое сети;проверка файла через
ffprobe, потом задача в очередь Laravel;один воркер,
nice ffmpegс libx264veryfastили libvpx‑vp9 + libopus для WEBM, прогресс через-progress;лимит 100 МБ, исходник удаляется сразу после обработки, результат — через час.
Браузер справляется с большинством файлов, поэтому серверу хватает одного воркера.
Звук: то же, но проще
Для инструментов со звуком (обрезать песню, вытащить звук из видео, конвертер) всё ещё проще:
декодируем через
OfflineAudioContext.decodeAudioData()— он понимает и звуковую дорожку из MP4 и WEBM;MP3 кодируем lamejs, а M4A и рингтоны M4R — через
AudioEncoder(AAC) и mp4-muxer;при вырезании середины трека стык сглаживаем перекрёстным затуханием на 15 мс с равной мощностью — иначе на стыке слышен щелчок.
Итог
Что получилось:
файл не уходит с компьютера, пока пользователь сам не нажмёт «на сервере»;
гигабайтные ролики обрабатываются без загрузки в память целиком;
точность обрезки — до кадра, вертикальное видео остаётся вертикальным;
сервер нужен только для редких форматов и тяжёлых случаев.
WebCodecs — низкоуровневый API: контейнеры, временные метки, повороты и очереди остаются на вас. Но когда это собрано, браузер вполне заменяет ffmpeg для типовых задач.
Если хотите посмотреть, как это работает вживую, вот ссылка на все наши инструменты. Буду рад вопросам и историям о том, на какие грабли с WebCodecs наступали вы.
Библиотеки: mp4box.js (BSD-3), mp4-muxer и webm‑muxer (MIT), lamejs (LGPL).
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.