Медиа в чате: как библиотека видео похудела с 630 до 204 МБ, а фото с 3,8 МБ до 729 КБ

Разбор медиа-пайплайна ЧатЯдра: сжатие фото средствами браузера без библиотек, перекодирование видео до 720p на сервере с ffmpeg, превью первым кадром, кэш подписанных ссылок и проценты загрузки. С параметрами и цифрами до/после.

Жалоба звучала просто: «фото в чатах грузятся очень долго». Замер показал, почему. Видео уходили такими, какими их снял телефон: в среднем 5,7 Мбит/с, 14,4 МБ за 21 секунду, самый тяжёлый ролик 48 МБ. Фото из браузера не сжимались вовсе: средний снимок 695 КБ, максимум 3,7 МБ, а один свежий был 4032×3024 и 3,8 МБ.

Почему это не всплыло раньше

Сжатие в проекте есть с июня, но для аватаров и доски объявлений. Там файл идёт через сервер, и его жмёт sharp. Переписка же грузит вложения напрямую в объектное хранилище, минуя сервер, по подписанной ссылке. Дыра появилась вместе с этим переходом: серверное сжатие стало не по пути. Урок общий: когда меняешь маршрут данных, проверь, что все обработчики остались на новом маршруте.

Фото: сжатие в браузере без библиотек

Пакет выбора файлов в вебе честно пишет, что maxWidth, maxHeight и imageQuality на web не поддерживаются. Поэтому сжатие сделано руками, средствами самого браузера: картинка рисуется на canvas нужного размера, и браузер отдаёт JPEG через canvas.toBlob(). Ни одной сторонней библиотеки, работает быстро.

Параметры:

  • длинная сторона 1600 px: с запасом для любого экрана телефона и просмотра на весь экран;
  • качество 80: граница, за которой разница уже не видна, а вес растёт быстро;
  • файлы легче 300 КБ не трогаем: выигрыш незаметен, а риск испортить есть;
  • таймаут на загрузку картинки 12 секунд.

И четыре правила, без которых такое сжатие приносит больше вреда, чем пользы:

  1. Не увеличиваем маленькие снимки, только уменьшаем.
  2. Не трогаем то, что и так лёгкое: пережатие мелкой картинки способно сделать её тяжелее.
  3. Не трогаем прозрачные PNG: JPEG прозрачность не умеет, фон станет чёрным.
  4. Любая осечка — возвращаем исходные байты.

На телефоне в нативной сборке всё это делает сам image picker, поэтому реализация для веба подключается через conditional import, а для Android остаётся заглушкой.

Видео: перекодирование до 720p на сервере

Сжимать видео на устройстве в вебе нечем, поэтому оно уходит в хранилище как есть, а сервер ставит задачу в очередь. Обоснование порога из комментария в коде: у зрелых приложений тот же ролик весит 2–3 МБ, потому что они пережимают до 720p и примерно 1–1,5 Мбит/с, и на экране телефона разница не видна. Значит, жмём всё, что заметно тяжелее.

Команда ffmpeg целиком:

ffmpeg -i input.mp4
  -vf "scale='min(1280,iw)':'min(1280,ih)':force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2"
  -c:v libx264 -preset veryfast -crf 26
  -maxrate 1800k -bufsize 3600k
  -c:a aac -b:a 96k
  -movflags +faststart
  -y output.mp4

Что здесь важно:

  • вторая scale делает стороны чётными, иначе кодировщик откажется работать;
  • veryfast, а не fast: на сервере два ядра, и очередь не должна растягиваться на минуты ради нескольких процентов размера;
  • maxrate и bufsize — потолок потока: без него сложная сцена всё равно раздувает файл;
  • faststart переносит метаданные в начало, чтобы воспроизведение начиналось до полной загрузки.

Решение «жать или нет» принимается по ffprobe: битрейт выше 2 Мбит/с или сторона больше 1280. И защита от ухудшения: если после обработки стало не легче, оставляем исходник. Так бывает с уже пережатым видео: повторное кодирование добавляет вес и портит картинку, а метрики записали бы это как улучшение.

Очередь живёт в таблице Postgres, воркер берёт задачи через SELECT … FOR UPDATE SKIP LOCKED, одновременно не больше двух ffmpeg: каждый съедает 500–800 МБ памяти, а на сервере её четыре гигабайта. Три повтора с паузами 30, 90 и 300 секунд, таймаут пять минут на файл.

Тот же воркер решает и старую проблему с iPhone: .mov в HEVC не играет в Chrome и на Android, поэтому конвертируется в .mp4 в фоне. Сообщение уходит сразу, а когда файл готов, сервер шлёт в комнату событие videoReady по сокету, и клиент подменяет вложение без перезагрузки. На случай, если сокет оборвался, при переподключении клиент сам переспрашивает статус ожидающих видео.

Превью первым кадром

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

ffmpeg -y -ss 1 -i input.mp4 -vframes 1 -vf "scale='min(640,iw)':-2" -q:v 4 poster.jpg

640 по ширине для плитки достаточно, а вес важен. Для старых роликов превью догнали отдельным скриптом; он по умолчанию работает в режиме dry-run, а уникальный ключ «файл + вид задачи» не даёт завести дубль при повторном запуске.

Кэш ссылок, а не файлов

Вторая причина «долго грузится» была не в весе. Каждое открытие переписки заново спрашивало у сервера ссылку на каждое вложение: на снимок, превью, голосовое. Мало того, что это лавина запросов при прокрутке, так ещё и ссылка каждый раз получалась новая, потому что она подписана. А браузер и телефон запоминают файл именно по адресу. Значит, один и тот же снимок скачивался заново столько раз, сколько его смотрели.

Решение: кэш ссылок на устройстве. Сервер выдаёт ссылку на 24 часа, кэш хранит её 20: отдать ссылку в последнюю минуту — значит подсунуть ту, что протухнет прямо в руках. До 400 записей и карта запросов «в полёте», чтобы при прокрутке один ключ не уходил на сервер несколько раз подряд. Сами файлы кэширует браузер по постоянному адресу.

Проценты загрузки

Отправка переведена на dio: единственный из используемых HTTP-клиентов, который умеет сообщать прогресс через onSendProgress. Мелочь, но человек видит, что файл идёт, а не завис. Одна тонкость: хранилище отвечает на PUT пустым телом, и dio нужно явно попросить не считать это ошибкой.

Итог по цифрам

Что Было Стало
Библиотека роликов (44 файла) 630 МБ 204 МБ
Средний битрейт 5,7 Мбит/с 1,9 Мбит/с
Самый тяжёлый ролик 48,2 МБ 4,6 МБ
Фото 4032×3024 3,8 МБ 729 КБ

Всё это сделано за один день 30 августа, потому что диагноз был поставлен замером, а не догадкой. Без замера первым подозреваемым стал бы сервер, и день ушёл бы не туда.