Я начал строить этот пайплайн не потому, что мне хотелось поэкспериментировать с кодеками. Причина была проще: анимированные изображения становились слишком дорогим способом раздавать то, что по сути уже являлось коротким немым видео.
Моя нагрузка — в основном короткие animated WebP, GIF и APNG: обычно несколько секунд, десятки отображаемых кадров и очень высокая временная избыточность. Большая часть просмотров приходится на мобильные устройства, одни и те же файлы могут запрашиваться много раз, а время кодирования для меня намного менее важно, чем количество байт, которые потом будут отдаваться пользователям.
На уровне идеи всё звучит просто: «конвертировать анимацию в MP4». В production эта фраза быстро превращается в гораздо более сложную задачу. Анимированное изображение — это не обязательно аккуратная последовательность полноразмерных кадров с одним ровным FPS. Внутри могут быть частичные прямоугольники, правила dispose, alpha blending, нерегулярные задержки, неоднозначные кадры с нулевой длительностью, разные ориентации и timing-метаданные, которые обычные media tools сводят к не очень полезной цифре.
Поэтому мой итоговый пайплайн — это не «запустить FFmpeg на WebP». Это цепочка инвариантов:
animated WebP / GIF / APNG
↓
декодировать и восстановить отображаемые состояния canvas
↓
восстановить исходный timeline
↓
выбрать CFR отдельно для этого source
↓
нормализовать геометрию без upscale
↓
закодировать один консервативный H.264 segment
↓
независимо повторить для каждого source
↓
склеить совместимые segments через stream copy
↓
нормализовать итоговый packet timeline
↓
probe + проверка packets + полный decode
↓
атомарная публикация
Кодек здесь — только одна часть. Сложнее всего сохранить то, что анимация реально показывала, пока под ней полностью меняется способ хранения и доставки.
Первое правило: извлекать отображаемые кадры, а не сохранённые прямоугольники
Самое опасное упрощение — считать, что каждый внутренний кадр анимированного изображения является полной картинкой, которая целиком заменяет предыдущую.
Форматы не обязаны работать так. У кадра animated WebP есть позиция, размер прямоугольника, blend mode и disposal mode. У APNG есть собственные offsets, dimensions, dispose operation и blend operation. В GIF Graphic Control Extension может указывать декодеру оставить предыдущую картинку на месте, очистить затронутую область до background или восстановить предыдущее содержимое canvas.
То есть физически сохранённый frame может быть маленьким patch, который имеет смысл только поверх уже существующего canvas. Если взять такие patches и закодировать их как самостоятельные полноразмерные изображения, получится не «та же анимация меньшего размера», а просто неправильная анимация.
Поэтому граница моего extraction — это состояние canvas, реально отображаемое пользователю. Для каждого шага мне нужны уже полностью скомпозированные пиксели, которые корректный viewer показал бы после применения disposal предыдущего кадра и blending текущего.
Это выглядит как мелочь декодера, но на самом деле это первая гарантия корректности всего video pipeline. Если неправильный partial frame уже превратился в H.264, никакими настройками энкодера дальше его не починить.
Спецификации прямо описывают эту механику: WebP задаёт сборку canvas из ANMF rectangles с blending и disposal; PNG specification описывает для APNG chunk fcTL с offsets, delays, dispose и blend; GIF89a задаёт disposal behavior в Graphic Control Extension.
Время кадра — это данные источника, а не FPS, который можно угадать
Вторая ошибка, которую мне пришлось исключить, — считать FPS из обычного probe безусловной истиной для animated image.
Разные форматы хранят timing по-разному:
- Animated WebP хранит длительность каждого кадра в единицах по 1 ms.
- GIF хранит delay в сотых долях секунды.
- APNG хранит для каждого кадра numerator и denominator; если denominator равен нулю, PNG specification предписывает считать его равным 100.
Именно эти per-frame delays и являются timeline. Удобное поле со средним FPS — всего лишь сводка, причём иногда плохая.
Один реальный WebP особенно хорошо это показал: 1264×720, 49 отображаемых кадров, delays чередовались между 62 и 63 ms, общая длительность составляла 3.063 секунды. Это практически идеальный ритм около 16 fps: по 62.5 ms на кадр.
При этом probe сообщил для source 25 fps.
Если бы я поверил этой цифре и без проверки перевёл файл в 25 или 30 fps, я бы либо изменил исходный timing, либо добавил ненужные повторяющиеся картинки. Авторитетными данными здесь были именно durations исходных кадров, а не headline FPS.
Это особенно важно для старых GIF, вручную собранных APNG и сгенерированных WebP: считать, что timing всегда идеально регулярный, нельзя.
Сначала я нормализую плохой timing, и только потом перевожу его в CFR
Некоторые timing values бессмысленно буквально переносить в playback.
WebP container specification отдельно отмечает, что frame duration 0, а часто и очень маленькие значения вроде 10 ms и меньше, интерпретируются конкретной реализацией. GIF тоже допускает zero delay. APNG говорит, что при нулевом numerator декодер должен показывать следующий кадр максимально быстро, хотя viewer всё равно может наложить разумный нижний предел.
Поэтому production pipeline нужна политика для повреждённых, отсутствующих или подозрительно маленьких delays. В моём случае timing хранится с точностью до миллисекунды, применяется минимальная длительность кадра 10 ms, а 100 ms используется только как fallback, когда нормального timing получить нельзя.
Важно не то, что эти два числа должны подходить всем. Это именно моя normalization policy. Важнее порядок: исправление timing происходит до выбора frame rate, а нормальные source durations сохраняются и не заменяются удобным default.
Почему я не делаю все анимации 30 fps
Когда у меня уже есть отображаемые frames и их durations, нужно превратить timeline формата изображений в video timeline.
Можно было бы всегда использовать 30 fps. Это просто, но для моего материала расточительно. Если анимация реально меняет состояние примерно 16 раз в секунду, хранение её как 30 fps в основном добавляет повторяющиеся временные samples. H.264 умеет хорошо сжимать повторы, но «хорошо сжимаемый мусор» всё равно остаётся мусором, а более высокий cadence дополнительно усложняет точное отображение duration.
Поэтому для каждого source я отдельно выбираю CFR из набора:
10, 12, 15, 16, 18, 20, 24, 25, 30 fps
Selector анализирует исходный timeline и выбирает самый низкий candidate, который достаточно чисто представляет фактические моменты смены картинки. Если меньшие варианты не подходят, fallback — 30 fps.
Я намеренно не публикую здесь один «магический» порог timing error: его имеет смысл проверять на конкретном типе контента. Гораздо важнее правило: начинать с реальных display times и только затем выбирать самый дешёвый CFR, который достаточно точно их представляет.
Для примера с 62/63 ms естественным выбором являются 16 fps, потому что один кадр 16 fps длится ровно 62.5 ms.
У каждого source свой CFR; всему итоговому MP4 не нужен один глобальный FPS
Этот архитектурный момент сначала пришлось отдельно продумать.
Один итоговый MP4 у меня может собираться из нескольких независимо обработанных анимаций. Одна естественно ложится в 12 fps, другая — в 16, следующая — в 24, ещё одна — в 30.
Я не перекодирую их все в один общий FPS только ради того, чтобы concat казался проще.
Вместо этого:
source A → 12 fps CFR H.264 segment
source B → 16 fps CFR H.264 segment
source C → 24 fps CFR H.264 segment
source D → 30 fps CFR H.264 segment
segments → stream-copy concat → один MP4
Каждый отдельный segment остаётся чистым CFR. Итоговый файл при этом можно считать piecewise-CFR и фактически variable-rate на уровне всей последовательности.
Ключ в том, чтобы у всех segments было совместимое описание stream и общий time base, способный точно представить каждый разрешённый cadence.
Почему я использую time base 90 kHz
Для video track я использую timescale 90,000 Hz.
Это не просто большое красивое число. Каждый candidate FPS из моего selector делит 90,000 без остатка:
| FPS | Точная длительность кадра при 90 kHz |
|---|---|
| 10 | 9000 ticks |
| 12 | 7500 ticks |
| 15 | 6000 ticks |
| 16 | 5625 ticks |
| 18 | 5000 ticks |
| 20 | 4500 ticks |
| 24 | 3750 ticks |
| 25 | 3600 ticks |
| 30 | 3000 ticks |
Это очень полезное свойство. Кадр 24 fps в encoded timeline длится не «примерно 41.667 ms», а ровно 3750 ticks. Кадр 16 fps — ровно 5625 ticks. Segment boundaries можно описывать без дробных video ticks.
В сгенерированных MP4 я задаю и video track timescale, и movie timescale равными 90,000. Но ещё важнее, что validator считает ожидаемую сетку ticks инвариантом и не доверяет округлённым десятичным duration.
Геометрию я нормализую до кодирования и никогда не делаю upscale
Collections анимированных изображений грязные не только по timing, но и по геометрии. Source может быть landscape, portrait, square или просто очень маленьким.
Мой delivery ceiling примерно такой:
| Класс source | Максимальная рабочая область |
|---|---|
| Landscape | 1280×720 |
| Portrait | 720×1280 |
| Итоговая последовательность со смешанной ориентацией | примерно canvas 960×960 |
Жёсткое правило — никакого upscale.
Если source уже меньше своего target envelope, я сохраняю его полезные dimensions, а не растягиваю и не заставляю encoder тратить bytes на интерполированные pixels.
Когда segments с разными aspect ratio должны попасть в один итоговый MP4, encoded streams всё равно нужна совместимая geometry. Поэтому я вписываю source в общий canvas и добавляю padding в незанятую область вместо растягивания картинки. В моём pipeline padding/background — чёрный.
Это заодно даёт явный ответ для alpha. Обычный H.264/yuv420p в этом pipeline не переносит alpha channel исходной анимации, поэтому прозрачность нужно flatten на выбранный background. У меня это black; другой продукт может выбрать другое значение, но правило должно быть сознательным, а не случайным побочным эффектом decoder.
Почему H.264 MP4 обычно намного дешевле раздавать, чем animated-image source
Главное структурное преимущество — temporal compression.
GIF умеет хранить sub-images и disposal behavior, APNG может не перерисовывать весь canvas целиком, а animated WebP значительно умнее GIF. Поэтому фраза «анимированные изображения всегда хранят каждый полный кадр отдельно» была бы технически неверной.
Но H.264 изначально спроектирован как video codec. Он умеет описывать картинки через inter prediction от reference pictures, motion information, P-frames и B-frames. Для моего материала это подходит особенно хорошо: в коротких рисованных clips фон часто почти статичен, а между соседними отображаемыми состояниями меняется только лицо, рука, волосы, небольшой crop камеры или другой ограниченный участок.
Для GIF и APNG выигрыш может быть очень большим. Apple сейчас рекомендует H.264 MP4 для static web video и прямо советует использовать MP4 вместо animated GIF; в Safari documentation сказано, что GIF может требовать до 12 раз больше bandwidth и до двух раз больше энергии по сравнению с современным video codec.
Я не использую «12×» как обещание для своих файлов. Это верхний пример из документации Apple, а не мой benchmark.
С animated WebP всё сложнее. Lossy animated WebP уже сжат, иногда очень хорошо. Перевод такого файла в H.264 — это второе lossy-поколение. На контенте с высокой временной избыточностью результат всё равно может быть существенно меньше, но качество нужно проверять глазами. «MP4 меньше» — наблюдение для моей нагрузки, а не закон природы.
Профиль H.264 segment намеренно скучный
Когда source уже реконструирован, правильно timed и нормализован, сами настройки H.264 становятся сравнительно простой частью задачи.
Мой текущий baseline:
| Настройка | Значение |
|---|---|
| Container | MP4 |
| Codec | H.264 / libx264 |
| Sample entry | avc1 |
| Profile / level | Main @ 3.1 |
| Pixel format | 8-bit yuv420p |
| Rate control | CRF 28 |
| Preset | veryslow |
| Tune | animation |
| Reference frames | 4 |
| Maximum B-frames | 5 |
| Open GOP | off |
| B-pyramid | normal |
| Maximum GOP | около 5 секунд |
| VBV | 4M maxrate / 8M bufsize |
| Color | BT.709, limited range |
| Track/movie timescale | 90,000 |
| Audio/subtitles/data | нет |
Main@3.1 я использую как консервативный compatibility envelope для одного универсального файла, а не потому, что новые телефоны не умеют декодировать более сложный H.264. В актуальной Android media documentation Main Profile decoder по-прежнему обязателен начиная с Android 6.0, а 1280×720 при 30 fps приведено как H.264 HD recommendation. Apple в текущей Safari guidance рекомендует H.264-encoded MP4 для static web files.
Смысл профиля — сделать decode максимально скучным, при этом позволить encoder работать очень долго и тщательно.
Я трачу CPU один раз, чтобы не тратить bandwidth всегда
Preset зафиксирован:
-preset veryslow
Для моей архитектуры это логично, потому что encode latency не является bottleneck. Source обрабатывается один раз, а полученный MP4 может раздаваться много раз.
Важно различать дорогой encoder search и сложность decoder. Browser не повторяет работу x264. Я позволяю x264 тратить CPU на поиск более эффективного representation, а требования к декодированию отдельно ограничиваю profile, level, refs и структурой B-frames.
CRF 28 тоже привязан к моей workload. Source обычно рисованный и уже lossy, а приоритет сильно смещён в сторону bandwidth при условии, что картинка выглядит нормально. Я бы не переносил это число как универсальное правило на фильм, grain-heavy camera footage, archival master или quality-critical editing source.
Каждая анимация превращается в независимо закодированный segment
К этому моменту в pipeline уже есть последовательность полных отображаемых frames, выбранный CFR, target geometry и ожидаемая duration.
Упрощённо центральная codec-часть команды для одного segment выглядит так:
ffmpeg -framerate "$SELECTED_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
Это намеренно только центральная encoding-часть. В реальном pipeline frame sequence уже собрана с учётом timeline, а resize/pad dimensions вычисляются из source до запуска этой команды. Если просто скопировать command без предыдущих стадий, будет скопирована как раз самая простая часть задачи.
GOP зависит от выбранного frame rate и составляет примерно пять секунд:
gopFrames = selectedFps * 5
Поэтому для 16 fps получается 80, для 24 fps — 120, для 30 fps — 150.
Concat работает только потому, что контракт каждого segment строгий
FFmpeg concat demuxer умеет читать несколько файлов как одну последовательность packets, но документация прямо говорит: файлы должны иметь одинаковые streams, включая совместимые codec и time base. Кроме того, duration каждого файла используется для позиционирования следующего, поэтому неправильная duration metadata способна испортить timeline.
Именно поэтому concat — не место, где я пытаюсь «сделать файлы совместимыми». Совместимость должна быть установлена до того, как каждый segment вообще будет принят.
Segments используют один codec family, общий profile/level envelope, pixel format, stream layout, canvas geometry для конкретной финальной последовательности, одинаковый color signaling и time base 90 kHz. Внутри нет внезапного audio или subtitle stream. В этом segment workflow я также использую x264 stitchable=1.
Когда эти инварианты выполнены, финальная склейка остаётся stream copy:
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
-an -sn -dn \
-movflags +faststart \
final.mp4
Главное здесь — -c:v copy.
H.264 segments не декодируются и не сжимаются заново только потому, что нужен один файл. Это убирает ещё одно lossy-поколение и не заставляет второй раз платить огромную цену кодирования.
Stream copy сохранил видео, но я всё равно нашёл timing bug в один tick
Это оказался один из самых полезных failures во всём pipeline.
При time base 90 kHz кадр 24 fps должен длиться ровно:
90000 / 24 = 3750 ticks
В какой-то момент validator нашёл реальный concatenated output, где один packet имел duration 3751 tick вместо ожидаемой сетки 3750.
Визуально файл мог выглядеть нормально. Можно было бы ослабить validator и сказать: «один tick ничего не меняет».
Я сделал наоборот. Вся идея 90 kHz и выбранного набора FPS была в том, что durations должны представляться точно. Если packet ушёл с сетки, я хотел найти причину, а не спрятать нарушение инварианта.
Главный вывод: stream copy H.264 payload не гарантирует, что muxed packet timestamps автоматически окажутся точно на той сетке, которую я спроектировал. Concat корректирует timestamps на boundaries между segments, и rescaling может проявить крошечную rounding error, хотя compressed picture data вообще не менялась.
Теперь финальный concat использует FFmpeg bitstream filter setts, чтобы нормализовать PTS, DTS и packet duration на целевую сетку 90 kHz, сохраняя при этом -c:v copy. FFmpeg описывает setts как packet-level bitstream filter, способный менять timestamps и duration без decode stream.
Я специально не публикую здесь context-free expression для setts. В моей реализации correction генерируется из известного segment timeline и выбранного FPS; копирование выражения без тех же metadata было бы скорее опасным, чем полезным.
Полезное правило другое: не ослаблять timestamp invariant только потому, что визуально ошибку сложно заметить.
Validation — не последняя проверка, а часть encoder
Я не публикую MP4 только потому, что FFmpeg завершился с exit code 0.
Pipeline валидирует объект до того, как он заменит production asset.
Минимум я проверяю:
- есть ровно ожидаемый video stream и нет лишнего audio/subtitle/data stream;
- codec — H.264, а profile, level, pixel format и dimensions соответствуют контракту;
- MP4 использует заданный time base;
- количество frames и packets совпадает с построенным timeline;
- packet durations лежат точно на ожидаемой сетке ticks;
- PTS/DTS имеют корректный порядок и timestamps монотонны там, где должны быть;
- segment и final duration согласуются с timeline по заранее определённым правилам;
- файл полностью декодируется, а не просто успешно открывается probe.
ffprobe здесь полезен тем, что показывает streams, frames и отдельные packets. Для такого pipeline просмотр packet-level duration намного информативнее, чем слепое доверие одному полю avg_frame_rate.
Типичная inspection концептуально выглядит так:
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
После этого я всё равно запускаю полный decode, например:
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
И только после прохождения всего контракта файл публикуется атомарно.
Какие ошибки этот pipeline должен предотвращать
Большинство неприятных bugs здесь — не про то, что «x264 неправильно закодировал macroblock». Они возникают раньше или позже:
- Кодировать raw subframes вместо отображаемых состояний canvas. Ошибка disposal/blending навсегда запекается в видео.
- Верить guessed FPS. Source с 62/63 ms может незаметно получить неправильный cadence.
- Насильно делать всё 30 fps. Это создаёт лишние temporal samples и скрывает реальный timeline.
- Увеличивать маленькие source. Pixels становится больше, исходных деталей — нет.
- Растягивать разные aspect ratios. Геометрия портится только ради требований concat.
- Перекодировать после concat. Source уже может быть lossy, segments — lossy H.264, а второй H.264 encode добавляет ещё одно ненужное поколение потерь.
- Считать, что
-c copyдоказывает правильность timing. Баг в один tick показал обратное. - Проверять только container header. Файл может успешно пробиться и всё равно содержать decode error дальше по stream.
Мышление инвариантами помогает разбирать эти failures гораздо лучше, чем бесконечный список новых encoder flags.
Что я сознательно не сохраняю
Конвертация не является lossless в смысле «все свойства source пережили преобразование». Есть несколько явных компромиссов.
Alpha flatten. H.264/yuv420p в этом pipeline — непрозрачный delivery format.
Произвольный VFR аппроксимируется выбранным CFR отдельно для каждого source. Я достаточно близко сохраняю perceived timing для этого класса контента, но не утверждаю, что каждую исходную timestamp можно сохранить как произвольную video duration.
Очень высокое разрешение уменьшается. Цель — bandwidth-first playback на мобильных устройствах, а не archival preservation.
Уже lossy WebP получает ещё один lossy encode. Поэтому quality validation важна, и именно поэтому я не говорю, что любой animated WebP автоматически надо превращать в MP4.
Audio вообще не входит в задачу. Source — немые анимации, поэтому итоговые files намеренно не содержат audio stream.
Это не скрытые ограничения. Это часть design contract.
Когда я бы не использовал такой подход
Я бы не применял этот точный pipeline, если transparency должна оставаться compositable поверх произвольного background страницы, если нестандартный irregular per-frame timing сам является частью контента, если asset — archival master или если система уже имеет adaptive multi-codec video delivery, где разумнее выбрать другую codec strategy.
И я бы benchmark'ил, а не предполагал выигрыш для уже крошечного и хорошо оптимизированного animated WebP. На моей короткой анимации с высокой временной избыточностью video обычно выигрывает, но source format, frame complexity и duration имеют значение.
Это production delivery transformation, а не универсальная замена animated image formats.
Практический pipeline, который я использую сейчас
- Определить, является ли source анимированным, и прочитать реальные frame-control metadata.
- Декодировать animation в полные отображаемые canvas states с учётом blend/disposal semantics.
- Восстановить и санитизировать duration каждого frame.
- Построить source timeline с миллисекундной точностью.
- Для этого source выбрать минимально подходящий CFR из 10/12/15/16/18/20/24/25/30.
- Отобразить displayed states на выбранную CFR timeline.
- Вычислить target dimensions, никогда не делая upscale.
- Вписать/pad в общий canvas, нужный финальной последовательности; сознательно flatten alpha.
- Закодировать один H.264 Main@3.1, yuv420p, avc1 segment с
veryslow, CRF 28 и ограниченной decoder complexity. - Использовать time base 90 kHz, чтобы каждый разрешённый cadence имел целочисленную duration в ticks.
- Независимо повторить процесс для каждого animated source.
- Отклонить segments, нарушающие stream contract.
- Склеить принятые segments через
-c:v copy, а не ещё один lossy encode. - Нормализовать финальный packet timeline на точную tick grid.
- Проверить streams/packets и полностью декодировать final MP4.
- Публиковать только прошедший validation файл.
Большая часть engineering здесь находится до и после x264. Именно этого я не понимал, когда впервые думал о задаче как о простом «превратить анимации в видео».
Главный вывод: настоящим asset является timeline
Animated WebP, GIF или APNG — это не просто «набор картинок». Это временная последовательность состояний canvas.
Когда я начал считать именно эту последовательность источником истины, архитектура стала намного понятнее. Я смог выбирать более дешёвый cadence, не придумывая новое движение; уменьшать resolution, не придумывая pixels; кодировать каждый source независимо, не заставляя всё жить на одном глобальном FPS; и склеивать segments без ещё одного поколения потерь.
H.264 сделал файлы маленькими. MP4 сделал их удобными для доставки. Но оба решения были бы бесполезны, если бы я уже испортил animation на стадии frame extraction или guessed timing.
Кодек оказался простой частью.
Настоящая работа — сохранить смысл анимации, полностью поменяв способ её представления.
Основная документация
- Google WebP Container Specification — canvas assembly, frame duration, blending и disposal для animation.
- W3C PNG Specification, Third Edition — APNG
fcTL, timing, offsets, blend и disposal. - GIF89a specification — frame delay и disposal behavior в GIF.
- FFmpeg Formats Documentation — требования concat demuxer и работа timestamps.
- FFmpeg Bitstream Filters Documentation —
setts. - ffprobe Documentation — проверка streams, frames и packets.
- Apple: Delivering Video Content for Safari — H.264 MP4 для static web video и рекомендация заменять animated GIF.
- Android Supported Media Formats — поддержка H.264 profile/container и playback recommendations.