Жалоба звучала просто: «фото в чатах грузятся очень долго». Замер показал, почему. Видео уходили такими, какими их снял телефон: в среднем 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 секунд.
И четыре правила, без которых такое сжатие приносит больше вреда, чем пользы:
- Не увеличиваем маленькие снимки, только уменьшаем.
- Не трогаем то, что и так лёгкое: пережатие мелкой картинки способно сделать её тяжелее.
- Не трогаем прозрачные PNG: JPEG прозрачность не умеет, фон станет чёрным.
- Любая осечка — возвращаем исходные байты.
На телефоне в нативной сборке всё это делает сам 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 августа, потому что диагноз был поставлен замером, а не догадкой. Без замера первым подозреваемым стал бы сервер, и день ушёл бы не туда.