Я строил этот процесс не ради экспериментов с кодеками. Причина была проще: анимированные изображения стали слишком дорогим способом раздавать то, что по сути уже является коротким немым видео.
Основная нагрузка у меня — короткие анимированные WebP, GIF и APNG: обычно несколько секунд, несколько десятков отображаемых кадров и много временной избыточности. Большинство просмотров идёт с мобильных устройств, одни и те же файлы запрашиваются многократно, поэтому одноразовая стоимость кодирования для меня гораздо менее важна, чем байты, которые потом передаются при каждом просмотре.
Сложность не в вызове FFmpeg. Анимированное изображение не обязано быть аккуратным набором полноразмерных картинок с одной регулярной частотой кадров. В нём могут быть частичные прямоугольники, правила смешивания и очистки, альфа-канал, неравномерные задержки, кадры с нулевой длительностью, разные ориентации и метаданные времени, которые универсальная утилита может свести к вводящей в заблуждение цифре.
Поэтому преобразование у меня описывается не одной командой, а набором инвариантов:
анимированный WebP / GIF / APNG
↓
восстановить полные отображаемые состояния холста
↓
восстановить и нормализовать исходную временную шкалу
↓
проанализировать все источники итоговой последовательности
↓
выбрать один CFR для итогового MP4
↓
построить минимальный общий холст без увеличения
↓
закодировать совместимые сегменты H.264
↓
проверить контракт потока
↓
объединить сегменты копированием потока
↓
нормализовать и проверить временную шкалу пакетов
↓
проверить HTTP-раздачу
↓
атомарно опубликовать
Кодек важен, но ещё важнее сохранить именно то, что исходная анимация реально показывала.
Измеренный рабочий результат: 217 анимированных WebP превратились в один MP4 размером 78,49 МБ
На входе было не одно видео размером 1,49 ГБ. Это были 217 отдельных анимированных WebP с 10 633 отображаемыми кадрами. Вместе исходные анимации занимали около 1,49 ГБ.
ВХОД
217 анимированных WebP
1,49 ГБ суммарно
10 633 отображаемых кадра
ВЫХОД
1 H.264 MP4
78,49 МБ
0,98 Мбит/с
1264×720
30 fps CFR
Main@3.1 / CRF 28 / veryslow
Итоговый H.264-файл весил 78,49 МБ при среднем потоке около 0,98 Мбит/с. По отношению к суммарному объёму исходных анимаций это примерно в 19 раз меньше, то есть около 94,7% экономии данных.
Это реальный сквозной результат всего процесса, а не чистый A/B-тест «старый H.264 против нового H.264». Представление изменилось с сотен анимированных изображений на одно видео с межкадровым сжатием, поэтому я не приписываю все 19× одному CRF 28, veryslow или отдельному параметру кодировщика.
Сначала нужно восстановить именно те картинки, которые видит зритель
Самое опасное упрощение — считать, что каждый сохранённый кадр анимации является полной картинкой, полностью заменяющей предыдущую.
Кадр анимированного WebP может описывать прямоугольник в определённой позиции плюс правила смешивания и очистки. У APNG есть смещения, размеры, задержка, операции очистки и смешивания. GIF тоже умеет оставлять предыдущий холст, очищать область или восстанавливать более раннее состояние.
Поэтому сохранённый кадр может быть лишь небольшим фрагментом, смысл которого зависит от уже построенного холста. Если кодировать такие фрагменты как самостоятельные полные изображения, получится не уменьшенная версия исходной анимации, а другая, неправильная анимация.
Граница извлечения у меня — полное отображаемое состояние холста: полностью скомпонованные пиксели, которые корректный просмотрщик показал бы после применения правила очистки предыдущего кадра и правила смешивания текущего.
Это первая гарантия корректности всего процесса. Если неправильный частичный кадр уже сплющен в H.264, никакой CRF, пресет или параметр контейнера потом его не исправит.
Время кадра — это данные источника, а не FPS, который можно угадать
Форматы анимации хранят время по-разному. Анимированный WebP задаёт длительность каждого кадра с точностью 1 мс. GIF хранит задержку в сотых долях секунды. APNG хранит числитель и знаменатель длительности каждого кадра; если знаменатель равен нулю, спецификация PNG предписывает считать его равным 100.
Именно эти задержки образуют временную шкалу. Усреднённый FPS из универсальной утилиты — лишь краткое описание, причём иногда ошибочное для практической обработки.
Один реальный WebP в моём процессе имел размер 1264×720 и 49 отображаемых кадров. Задержки чередовались между 62 и 63 мс, а общая длительность составляла 3,063 секунды. По сути это ритм 16 fps, потому что один кадр при 16 fps длится 62,5 мс.
При этом универсальная утилита показала для этого источника 25 fps. Если бы я поверил этой цифре, я бы либо изменил исходный тайминг, либо создал ненужные повторяющиеся кадры.
Нужна и явная политика для некорректных или неоднозначных задержек. WebP прямо оставляет интерпретацию нулевой и часто очень маленькой длительности реализации. В GIF возможна нулевая задержка. В APNG нулевой числитель означает, что следующий кадр следует показать как можно быстрее, хотя просмотрщик вправе ввести практический минимум.
Моя политика нормализации сохраняет миллисекундную точность, применяет небольшой минимум 10 мс к нулевым или явно бессмысленно коротким длительностям и использует 100 мс только как запасной вариант, когда полезной информации о времени действительно нет. Это моя инженерная политика, а не универсальный стандарт.
Я выбираю один CFR для всего итогового MP4, а не ставлю 30 fps по умолчанию
После восстановления отображаемых состояний и их длительностей я переношу исходную временную шкалу на видеошкалу. Я не кодирую всё вслепую в 30 fps.
Для всех источников, которые войдут в один итоговый MP4, я рассматриваю небольшой набор кандидатов:
10, 12, 15, 16, 18, 20, 24, 25, 30 fps
Алгоритм выбирает минимальный CFR, который достаточно хорошо представляет всю итоговую последовательность. Разные MP4 могут получить разную частоту, но все независимо закодированные сегменты внутри одного MP4 используют один и тот же выбранный CFR.
На примере длительностью 3,063 секунды экономия видна особенно хорошо. При 16 fps нужно примерно 49 выходных кадров, а при 30 fps — около 92. Если другой источник в том же итоговом MP4 действительно требует 30 fps, вся коллекция использует 30. Смешивать разные частоты кадров внутри одного итогового потока я не пытаюсь.
Для видеодорожки я использую временную шкалу 90 000 Гц, потому что каждая разрешённая частота даёт целую длительность кадра:
10 fps → 9000 тактов
12 fps → 7500 тактов
15 fps → 6000 тактов
16 fps → 5625 тактов
18 fps → 5000 тактов
20 fps → 4500 тактов
24 fps → 3750 тактов
25 fps → 3600 тактов
30 fps → 3000 тактов
Именно временная шкала видеодорожки задаёт эту точную сетку кадров. Временную шкалу самого MP4-контейнера я тоже ставлю в 90 000 для единообразия, но это отдельные часы контейнера. Валидатор проверяет видеопакеты по точной целочисленной сетке, а не доверяет округлённым десятичным длительностям.
Ограничения разрешения — это потолки, а не обязательные размеры холста
Мой диапазон доставки — примерно до 1280×720 для горизонтального материала, до 720×1280 для вертикального и не более 960 по ширине и высоте для смешанной ориентации.
Неприкосновенное правило: никогда не увеличивать исходник. Изображение 900×600 не становится лучше после увеличения до 1280×720; появляются лишь интерполированные пиксели, которые кодировщику придётся описывать.
Второе правило менее очевидно: 960×960 — это ограничивающий прямоугольник, а не обязательный квадратный холст.
Сначала я рассчитываю размеры каждого источника только с уменьшением. Затем для итоговой последовательности строится самый маленький общий холст с чётными размерами, который способен вместить все уже уменьшенные активные области.
Например, если последовательности нужны горизонтальная картинка 960×540 и вертикальная 500×900, общий холст может быть 960×900, а не 960×960. Размеры всех сегментов всё равно одинаковы, поэтому объединение копированием потока остаётся возможным, но я не кодирую лишнюю чёрную область.
Соотношение сторон сохраняется, свободное место заполняется, а изображение не растягивается. В моём процессе фон чёрный. Обычный H.264/yuv420p не сохраняет исходный альфа-канал, поэтому прозрачность намеренно компонуется с этим фоном, а не теряется случайно.
Почему H.264 MP4 хорошо подходит именно для такой раздачи
GIF, APNG и анимированный WebP вовсе не примитивны. Они тоже умеют не перерисовывать неизменившиеся области, поэтому утверждение «видео всегда меньше» было бы неправильным.
Но H.264 изначально рассчитан на временное предсказание между изображениями. Короткие рисованные циклы со статичным фоном и небольшими меняющимися областями хорошо подходят для опорных кадров, межкадрового предсказания, P- и B-кадров.
В актуальной документации Safari Apple рекомендует H.264 в MP4 для статического видео и пишет, что анимированный GIF может расходовать до 12 раз больше трафика и примерно вдвое больше энергии по сравнению с современным видеокодеком. Эти 12× — пример Apple, а не мой результат.
Поэтому измеренное уменьшение примерно в 19 раз полезно как реальное наблюдение в рабочей системе, но я всё равно сравниваю варианты, а не предполагаю, что любой уже маленький анимированный WebP обязательно проиграет MP4.
Каждая анимация кодируется отдельным сегментом по единому контракту потока
К моменту запуска кодировщика процесс уже знает отображаемые кадры, общий выбранный CFR, итоговый холст и ожидаемую длительность.
Центральная часть команды для сегмента выглядит примерно так:
ffmpeg -framerate "$COLLECTION_FPS" -i frame-%06d.png \
-c:v libx264 \
-preset veryslow \
-tune animation \
-crf 28 \
-profile:v main \
-level:v 3.1 \
-pix_fmt yuv420p \
-tag:v avc1 \
-refs 4 \
-bf 5 \
-g "$GOP_FRAMES" \
-maxrate:v 4M \
-bufsize:v 8M \
-x264-params "stitchable=1:open-gop=0:b-pyramid=normal:nal-hrd=none" \
-color_range tv \
-color_primaries bt709 \
-color_trc bt709 \
-colorspace bt709 \
-fps_mode passthrough \
-map_metadata -1 \
-map_chapters -1 \
-an -sn -dn \
-video_track_timescale 90000 \
-movie_timescale 90000 \
-t "$EXPECTED_DURATION" \
segment.mp4
Максимальный GOP получается примерно пятисекундным и зависит от выбранного CFR: 80 кадров при 16 fps, 120 при 24 fps и 150 при 30 fps.
Ограничение -t "$EXPECTED_DURATION" здесь не декоративное. В моём списке кадров последняя картинка повторяется как служебный завершающий элемент, чтобы FFmpeg применил длительность предыдущего настоящего кадра. Без явного ограничения длительности этот повтор может превратиться в лишний конечный кадр.
Я воспроизвёл это на случае с 49 кадрами и длительностью 3,063 секунды. Без -t получилось 50 кадров при 16 fps и 94 при 30 fps. С -t 3.063 получилось ровно ожидаемые 49 кадров при 16 fps и 92 при 30 fps.
Объединение копированием потока безопасно только после строгой проверки совместимости
Демультиплексор concat в FFmpeg ожидает одинаковые потоки, включая кодек и временную базу, и использует длительность каждого файла для размещения следующего. Неверная длительность поэтому способна породить артефакты временной шкалы.
Я не использую объединение для превращения несовместимых файлов в совместимые. До приёма сегмент уже обязан удовлетворять контракту:
CFR коллекции = одинаковый
временная база потока = одинаковая
временная шкала видеодорожки MP4 = одинаковая
размеры холста / SAR = одинаковые
профиль / уровень / формат пикселей = одинаковые
цветовая сигнализация = одинаковая
avcC / дополнительные данные AVC = побайтово одинаковые
Я использую stitchable=1 в x264, потому что сегменты кодируются независимо, но не считаю этот переключатель доказательством одинаковой конфигурации AVC. Перед объединением я всё равно сравниваю фактические байты конфигурации.
Когда контракт выполнен, итоговое объединение не требует второго сжатия видео:
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
-an -sn -dn \
-movflags +faststart \
final.mp4
-c:v copy не даёт заново декодировать и повторно сжимать уже готовые H.264-сегменты.
Валидация проверяет и MP4-файл, и способ его раздачи
Я не публикую файл только потому, что FFmpeg завершился с кодом 0.
Валидатор ловил реальные детерминированные ошибки времени: при 16 fps я получал 5580 тактов там, где контракт требовал 5625, а позже в 24-fps результате встретилось 3751 вместо точных 3750. Подробное расследование расхождения в один такт — отдельная тема; здесь важен вывод: повторный запуск той же операции не исправляет детерминированную ошибку временной шкалы.
Для самого медиаобъекта я проверяю ожидаемое число потоков, профиль и уровень H.264, формат пикселей, точные запланированные размеры, SAR, цветовую сигнализацию, временную шкалу видеодорожки 90 кГц, длительности пакетов, количество кадров и пакетов, общую длительность, отношения PTS/DTS, одинаковую конфигурацию AVC у сегментов, расположение moov перед mdat и полное декодирование в режиме жёстких ошибок.
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
Но корректный локальный MP4 всё ещё можно неправильно раздавать по сети. Поэтому я отдельно проверяю путь HTTP: ожидаемый Content-Type, правильный Content-Length, поддержку запросов диапазонов байтов, корректный ответ 206 Partial Content и правильный Content-Range.
После изменения контракта кодирования я запускаю небольшой тест на реальных устройствах и браузерах, а не считаю, что ffprobe гарантирует аппаратную совместимость. Проверяю старт, перемотку, зацикливание, уход приложения в фон и возврат, а также воспроизведение диапазонов на актуальном iPhone с Safari, недорогом Android-устройстве и основных настольных браузерах.
Только после того как и сам файл, и его сетевая раздача проходят контракт, рабочий ресурс заменяется атомарно.
Где этот процесс намеренно теряет информацию
Это преобразование для раздачи, а не архивный мастер. Альфа-канал намеренно компонуется с фоном. Неравномерная исходная временная шкала квантуется в один CFR итогового MP4. Большие исходники могут уменьшаться. Уже сжатый с потерями анимированный WebP проходит ещё одно поколение сжатия с потерями. Звука намеренно нет.
Я бы не использовал именно этот процесс, если прозрачность должна работать на произвольном фоне, если точный неравномерный тайминг каждого кадра сам по себе несёт смысл, если создаётся архивный источник или если приложение уже имеет адаптивную многокодековую видеосистему, которая решает задачу раздачи иначе.
Очень маленькие и уже хорошо оптимизированные анимированные WebP я тоже сначала сравниваю, а не предполагаю, что MP4 обязательно будет меньше.
Последовательность обработки, которую я использую сейчас
- Определить формат анимации и прочитать реальные метаданные управления кадрами.
- Восстановить полные отображаемые состояния холста с учётом правил смешивания и очистки формата.
- Восстановить и нормализовать длительность каждого кадра.
- Построить авторитетную исходную временную шкалу с точностью до миллисекунды.
- Проанализировать все источники, которые войдут в один итоговый MP4.
- Выбрать один общий CFR из 10/12/15/16/18/20/24/25/30.
- Перенести отображаемые состояния на эту CFR-шкалу.
- Рассчитать активные размеры только с уменьшением; никогда не увеличивать.
- Построить минимальный общий холст с чётными размерами.
- Заполнить свободное место без растягивания и намеренно свести альфа-канал с фоном.
- Закодировать каждый источник как H.264 Main@3.1 / yuv420p / avc1 по одному контракту потока.
- Ограничить каждый сегмент его ожидаемой длительностью.
- Отбраковать сегмент, если фактическая конфигурация AVC или тайминг нарушает контракт.
- Объединить принятые сегменты через
-c:v copy. - Нормализовать и проверить итоговую временную шкалу пакетов.
- Полностью декодировать результат.
- Проверить HTTP-заголовки, диапазоны байтов и частичную выдачу.
- После изменения профиля кодирования выполнить тест на реальных устройствах и браузерах.
- Публиковать атомарно только после успешного прохождения всех проверок.
Источник истины — временная шкала, а не расширение файла
Анимированный WebP, GIF или APNG — это временная последовательность отображаемых состояний холста, а не просто набор картинок с определённым расширением.
H.264 способен очень эффективно использовать временную избыточность, но он не исправит неправильную композицию, выдуманный тайминг или несовместимые метаданные сегментов. Большая часть инженерной работы, сделавшей этот процесс надёжным, находится до и после x264.
Главный принцип, который я из этого вынес: менять представление данных стоит только после того, как точно определено, что обязано остаться неизменным.
Основная документация
- Спецификация контейнера WebP от Google — прямоугольники кадров, длительность, смешивание и очистка.
- Спецификация PNG W3C, третье издание — время кадров APNG, смещения, смешивание и очистка.
- Спецификация GIF89a — задержки и правила очистки GIF.
- Документация FFmpeg по форматам — требования concat и поведение контейнера MP4.
- Документация FFmpeg по фильтрам битового потока —
setts. - Документация ffprobe — проверка потоков и пакетов.
- Apple: доставка видео в Safari — H.264 MP4 для статического видео и замена анимированных GIF.
- Поддерживаемые форматы мультимедиа Android — поддержка H.264 и требования HTTP-потоковой передачи.