Назад в блог
13 августа 2026 г.Sergei Solod15 мин чтения

Один тик, который сломал мой CFR: почему 5580 было не 5625, а 3751 — не 3750

Мой валидатор снова и снова отклонял MP4 на 16 кадрах/с из-за пакета длительностью 5580 тиков вместо 5625, а позже поймал 3751 тик там, где 24 кадра/с требовали ровно 3750. Эти две ошибки заставили меня отдельно рассматривать исходные временные данные, квантизацию CFR, временную базу MP4, PTS/DTS, мультиплексирование и проверку на уровне пакетов.

FFmpegH.264CFRВременные метки видеоMP4Медиапайплайн

Однажды мой медиапайплайн перестал публиковать часть видео из-за ошибки, которая на первый взгляд выглядела почти абсурдно:

Некорректная CFR packet duration: 5580 ticks, ожидалось 5625

Это не был сбой декодера. FFmpeg не падал. H.264-файл создавался. Проблему находил мой валидатор уже после кодирования: файл назывался видео с постоянной частотой кадров, но длительность одного из пакетов не лежала на временной сетке, которую я сам задал.

Я попробовал повторный запуск. Получил те же 5580. Потом ещё раз. Снова 5580. Другой исходник позже упал с тем же значением. Это оказалось полезной подсказкой: передо мной был не случайный сетевой сбой и не редкая гонка. Нарушение было детерминированным.

Позже появился второй, внешне похожий случай. Для 24 кадров/с при шкале 90 000 я ожидал длительность пакета ровно 3750 тиков. Валидатор нашёл 3751.

Между этими ошибками есть принципиальная разница. 5580 вместо 5625 — отклонение на 45 тиков, то есть ровно на 0,5 мс. 3751 вместо 3750 — один тик, около 11,1 микросекунды. Я бы сделал ошибку, если бы объяснил обе фразой «FFmpeg что-то округлил».

Именно поэтому эта история оказалась полезнее самого исправления. Она заставила меня наконец разделить четыре вещи, которые раньше слишком легко слипались в одно слово «FPS»: частоту кадров, шкалу времени, PTS/DTS и длительность пакета.

CFR — это не надпись «16 fps» в метаданных

Когда я говорю, что файл имеет постоянную частоту кадров, меня интересует не только то, что ffprobe показывает красивое значение 16/1 или 24/1.

Для моего пайплайна CFR означал более строгий контракт: моменты показа кадров должны лежать на регулярной временной сетке, а нормальная длительность каждого видеосэмпла должна соответствовать одному шагу этой сетки.

Если целевая частота равна 16 кадров/с, длительность кадра составляет:

1 / 16 = 0.0625 s = 62.5 ms

Если временная шкала видеодорожки равна 90 000 тиков в секунду, тот же интервал выражается целым числом:

90000 / 16 = 5625 ticks

Поэтому 5625 не было произвольным числом, придуманным валидатором. Оно следовало непосредственно из двух частей моего контракта: 16 fps и 90 000 ticks/s.

Важный нюанс: такое строгое ожидание справедливо именно потому, что я сознательно выбрал частоты, которые представляются в моей шкале без дроби. Если бы целевая частота не делила шкалу точно, валидатору пришлось бы учитывать допустимую последовательность разных целочисленных длительностей. Нельзя сначала выбрать неудобную рациональную сетку, а потом требовать от каждого пакета одного и того же целого числа.

Частота кадров, временная база и шкала времени MP4 — разные величины

Большая часть путаницы исчезает, если перестать использовать слово «время» как один общий объект.

  • Частота кадров отвечает на вопрос, сколько отображаемых кадров приходится на секунду. Для CFR 16 это один кадр каждые 62,5 мс.
  • Временная база в FFmpeg задаёт длительность одного целочисленного тика. Например, 1/90000 секунды на тик.
  • Шкала времени в MP4 обычно выражает обратную идею: сколько единиц времени приходится на секунду. Для дорожки со шкалой времени 90 000 один тик равен 1/90000 секунды.
  • PTS говорит, когда кадр должен быть показан.
  • DTS говорит, когда соответствующий закодированный пакет должен быть декодирован.
  • Длительность пакета описывает продолжительность соответствующего сэмпла в единицах временной шкалы потока.

С B-кадрами PTS и DTS закономерно различаются. Поэтому «починить временные метки», просто присвоив PTS = DTS, было бы особенно опасной идеей. Даже официальная документация фильтра setts отдельно предупреждает, что такой приём не рекомендуется, когда участвуют B-кадры.

Мой валидатор поэтому не требовал «PTS всегда равен DTS». Он требовал законный порядок декодирования и при этом проверял регулярную сетку представления.

Почему я выбрал шкалу 90 000

90 000 не является магическим числом для любого видео. Для моего набора разрешённых частот оно просто оказалось очень удобной целочисленной системой координат:

ЧастотаДлительность кадра при 90 000 тиков/с
10 кадров/с9000 тиков
12 кадров/с7500 тиков
15 кадров/с6000 тиков
16 кадров/с5625 тиков
18 кадров/с5000 тиков
20 кадров/с4500 тиков
24 кадра/с3750 тиков
25 кадров/с3600 тиков
30 кадров/с3000 тиков

Ни одного дробного значения.

Есть и второе полезное свойство:

1 ms = 90 ticks

Это оказалось особенно удобно, потому что исходные анимированные изображения в моём процессе приходили с задержками кадров в миллисекундах.

FFmpeg позволяет явно задавать video_track_timescale для видеодорожки MP4. Но важно понимать, что сама по себе удобная шкала ничего не исправляет. Она лишь делает ошибки заметными. Если ожидаемый шаг равен целому числу 5625, то пакет длительностью 5580 уже невозможно списать на неизбежную дробь.

Число 5580 само рассказало, откуда искать проблему

Самая интересная часть первой ошибки — арифметика:

5580 / 90000 = 0.062 s
0.062 s = 62 ms

То есть проблемная длительность не была каким-то случайным числом. Она идеально совпадала с 62 миллисекундами.

А у меня как раз был реальный исходник с 49 отображаемыми кадрами за 3,063 секунды. Задержки кадров чередовались между 62 и 63 мс:

49 / 3.063 ≈ 15.997 frames/s

62 ms + 63 ms = 125 ms
2 кадра при 16 кадрах/с = 2 × 62.5 ms = 125 ms

На уровне исходной анимации такая последовательность 62/63/62/63... совершенно разумно приближает 16 кадров/с на миллисекундной сетке.

Но после того как я выбрал CFR 16, результат уже должен был жить на другой сетке: каждый обычный кадр — 62,5 мс, или 5625 тиков.

Поэтому 5580 стало почти идеальной уликой. Оно говорило: где-то в результирующую часть процесса попало исходное значение 62 мс вместо уже квантизированного шага CFR 62,5 мс.

Здесь я сознательно сохраняю границу доказательности. По архивному сообщению об ошибке я могу доказать, что 5580 — это ровно 62 мс в шкале 90 000, и что в моих исходных данных действительно встречались 62/63 мс. Я не могу по одной строке лога доказать, какая конкретно функция или операция первой пропустила эти 62 мс дальше. Это сильная диагностическая связь, а не право выдумать точную строку причины задним числом.

Входная шкала 1000 Гц не была ошибкой

Когда видишь одновременно «миллисекундный источник» и «неправильные 62 мс», возникает соблазн убрать входную точность и сразу всё привести к 16 или 30 кадрам/с.

Я проверял это отдельно. Воспроизвёл архитектуру на FFmpeg 7.1.5 с ffconcat и короткими длительностями. При входных данных:

duration 0.010
option framerate 1000

временные отметки сохранялись как:

0 ms
10 ms
20 ms
30 ms

Если же на этом раннем этапе использовать частоту 30, время сразу квантизировалось примерно к шагу 33,3 мс.

То есть 1000 Гц выполняли правильную работу: давали мне миллисекундную сетку для честного представления исходных задержек. Это не означало, что итоговое видео должно иметь 1000 кадров/с.

Архитектурная граница должна быть другой:

исходные задержки с точностью 1 ms
        ↓
авторитетный исходный timeline
        ↓
выбор целевого CFR
        ↓
явная квантизация на сетку CFR
        ↓
дальше эта сетка уже не должна снова превращаться в исходные 62/63 ms

Проблема не в высокой точности на входе. Проблема — если граница между «сохранить исходное время» и «построить CFR» остаётся размытой.

CFR в таком пайплайне — это контролируемая квантизация времени

Для анимированного изображения я сначала хочу понять, что оно на самом деле показывало и когда. Только после этого выбираю более простую сетку доставки.

У реального примера с 62/63 мс естественный целевой ритм — 16 кадров/с. За два исходных кадра проходит ровно 125 мс; за два кадра CFR 16 проходит те же 125 мс. На длинном интервале совпадение почти идеальное.

Но это не значит, что можно оставить внутри выходного файла последовательность 62, 63, 62, 63 мс и просто написать в заголовке «16 fps». Это всё ещё переменные длительности.

Переход к CFR должен быть настоящим переходом:

62 ms, 63 ms, 62 ms, 63 ms
              ↓
62.5 ms, 62.5 ms, 62.5 ms, 62.5 ms

FFmpeg-фильтр fps предназначен именно для построения выбранной частоты кадров: он может отбрасывать или повторять входные кадры в соответствии с их PTS и выбранным правилом округления. В моём случае это удобное место, где неоднородный исходную временную шкалу превращается в регулярный CFR.

После этой точки я предпочитаю не просить ещё один слой FFmpeg повторно «сделать CFR». Если фильтр уже создал нужную сетку, последующая стадия должна её сохранять, а не независимо заново решать, как округлять время.

Почему повторный запуск три раза ничего не исправил

Первая ошибка повторялась буквально с тем же числом:

FAIL: duration 5580, expected 5625
retry 1/3
FAIL: duration 5580, expected 5625
retry 2/3
FAIL: duration 5580, expected 5625

Это хороший пример того, почему повторный запуск должен быть связан с классом ошибки.

Повторный запуск полезен при временном сетевом сбое, недоступном хранилище, временной нехватке ресурса или другой внешней причине, которая может исчезнуть между попытками.

Но если алгоритм каждый раз строит одну и ту же временную сетку и валидатор каждый раз находит один и тот же неправильный целочисленный результат, ожидание две или четыре секунды ничего не меняет.

Теперь для подобных пайплайнов я разделяю хотя бы:

  • временные сбои — повтор может иметь смысл;
  • ошибки входных данных — нужен другой путь обработки или отказ;
  • нарушения детерминированного контракта — повторять бессмысленно, нужно останавливать и диагностировать.

5580 → 5625 относилось именно к третьей категории.

Потом появился 3751 вместо 3750 — и это уже другая история

Для 24 кадров/с на той же шкале математика ещё проще:

90000 / 24 = 3750 ticks

Но в одном реально собранном результате валидатор обнаружил пакет длительностью:

3751 ticks

Здесь разница — не половина миллисекунды:

1 / 90000 s ≈ 0.000011111 s
                  ≈ 11.111 µs

Визуально такой один тик практически невозможно заметить. Именно поэтому было особенно легко сказать: «да ладно, разрешим погрешность ±1».

Я не стал.

Причина не в том, что 11 микросекунд опасны для зрителя. Причина в том, что для 24 кадров/с в шкале 90 000 никакая дробь вообще не нужна. Значение 3750 представляется точно. Поэтому 3751 сообщало не о физической невозможности представить время, а о том, что где-то мой контракт перестал сохраняться.

Копирование потока сохраняет битстрим, но не отменяет работу со временем

В тот момент мой процесс склеивал совместимые H.264-сегменты без второго кодирования с потерями:

ffmpeg -f concat -safe 0 -i segments.ffconcat \
  -c:v copy \
  final.mp4

Очень легко мысленно прочитать -c:v copy как «FFmpeg вообще ничего не меняет». Это неверная модель.

Копирование потока означает, что сжатые H.264-картинки не декодируются и не кодируются заново. Но контейнер, границы файлов и временные метки пакетов всё равно нужно собрать в новую временную последовательность.

Документация демультиплексор concat прямо говорит, что длительность каждого файла используется для корректировки временных отметок следующего файла. FFmpeg также постоянно переводит целочисленные отметки между рациональными временными базами; в библиотеке для этого существуют функции масштабирования с явно заданным округлением.

Это не доказывает, что именно concat был единственной возможной причиной конкретного 3751. Но объясняет, почему фраза «я же использовал -c:v copy, откуда вообще мог измениться временная метка?» неверна.

Копирование сжатых данных и сохранение точной сетку пакетов — разные свойства.

PTS и DTS нельзя чинить одной красивой формулой

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

PTS = N * frame_duration
DTS = N * frame_duration

Для H.264 с B-кадрами это может быть ошибкой.

Кадры могут декодироваться не в том порядке, в котором показываются. PTS определяет порядок представления, DTS — порядок декодирования. Поэтому корректная последовательность с B-кадрами закономерно имеет разницу между ними.

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

Именно поэтому я не считаю хорошей практикой публиковать одну контекстно-независимую магическую строку setts. Выражение зависит от того, какие сегменты, границы и частоты сформировали итоговую временную шкалу.

Зачем мне понадобился setts

FFmpeg предоставляет битстрим-фильтр setts, который способен менять PTS, DTS, длительность пакета и выходную временную базу на уровне пакетов. При этом видеопоток не нужно декодировать и снова сжимать.

Для моего процесса это оказалось правильным уровнем исправления после объединения сегментов без перекодирования: сжатый поток H.264 оставался тем же, а временные поля можно было нормализовать к заранее известной сетке.

Принцип был таким:

известен timeline каждого сегмента
+ известен выбранный CFR
+ известна шкала 90000
        ↓
для каждого участка известна точная допустимая packet grid
        ↓
нормализовать временные поля к этой grid
        ↓
снова проверить каждый пакет

Это существенно отличается от «если встретился 3751, вычесть один». Исправление должно вытекать из модели времени, а не из конкретной ошибки, которую сегодня напечатал валидатор.

Почему я не добавил допуск ±1 тик

Во многих системах небольшой допуск — правильное решение. Если данные пришли из часов, которые нельзя точно выразить в выбранной шкале, требовать абсолютного равенства бессмысленно.

Но у меня был другой контракт. Я специально ограничил частоты набором, для которого 90000 / fps всегда целое число. Это значит:

16 fps → всегда 5625
24 fps → всегда 3750
30 fps → всегда 3000

Когда система спроектирована так, чтобы нормальная длительность выражалась точно, допуск ±1 превращает неизвестное нарушение в молчаливо разрешённое состояние.

Одному зрителю один тик ничего не сделает. Но следующий баг может быть уже не одним тиком. А главное — после введения допуска я больше не знаю, почему файл перестал соответствовать архитектуре.

Поэтому мой критерий такой:

  • если сетка по определению требует чередования целых длительностей — валидировать правильный шаблон;
  • если длительность обязана быть точным целым числом — требовать его точно;
  • не использовать «±1» как универсальный способ заставить красный тест стать зелёным.

Как я теперь проверяю CFR на уровне пакетов

Поля вроде avg_frame_rate полезны, но недостаточны для такого контракта. Мне нужно посмотреть сам поток.

Базовая диагностика выглядит примерно так:

ffprobe -v error \
  -select_streams v:0 \
  -show_streams \
  -show_packets \
  -of json \
  final.mp4

Из уровня потока данных меня интересуют как минимум фактическая временная база и заявленная частота. Из пакетов — pts, dts, duration и их временные представления.

Упрощённая логика для файла, который обязан быть CFR 16 на шкале 90 000:

expected = 90000 / 16   // 5625

for each normal video packet:
    assert packet.duration == 5625

assert DTS ordering is legal
assert PTS/DTS relation is legal for the codec
assert presentation timeline matches the planned frame count and duration

Слово обычный здесь важно. Если формат или конкретная схема намеренно использует список правок, обрезку, необычный завершающий сэмпл или другую особенность, валидатор должен моделировать её явно. Я не предлагаю слепо переносить одно условие на любой MP4 в интернете.

Но для собственного генератора, где структура файла спроектирована мной, строгий валидатор намного ценнее мягкого «похоже на 16 fps».

Проверка метаданных не заменяет полное декодирование

Даже идеальные временные метки не доказывают, что H.264-поток полностью декодируется. И наоборот: файл может декодироваться без заметной ошибки и всё равно нарушать мой временной контракт.

Поэтому эти проверки решают разные задачи:

ffprobe / packet validation
→ структура, временные отметки, длительности, параметры потока

полный decode
→ способен ли весь сжатый поток действительно декодироваться

После уровне пакетов проверки я всё равно использую полное декодирование с жёсткой обработкой ошибок:

ffmpeg -v error -xerror -err_detect explode \
  -i final.mp4 -f null -

Файл публикуется только после прохождения обеих стадий. Нулевой код завершения энкодера для меня больше не означает «готово».

Что на самом деле доказали два сбоя

После этой истории мне важно не преувеличивать причинность.

Про 5580 я могу подтвердить:

  • валидатор несколько раз детерминированно получил 5580 вместо 5625;
  • при шкале 90 000 значение 5580 ровно соответствует 62 мс;
  • в реальных исходных данных были кадры по 62 и 63 мс;
  • целевой CFR 16 требует 62,5 мс, то есть 5625 тиков;
  • входная миллисекундная сетка 1000 Гц экспериментально сохраняла короткие исходные длительности.

Это делает утечку исходной миллисекундной длительности в часть, где уже ожидался CFR, очень сильным объяснением. Но архивный лог сам по себе не устанавливает точную строку кода, в которой это произошло.

Про 3751 я могу подтвердить:

  • в реально собранном результате был найден пакет 3751 при ожидаемых 3750 для 24 кадров/с;
  • эта ошибка появилась в процессе, где сегменты затем объединялись путём копирования потока;
  • для нормализации итогового временной шкалы пакетов в пайплайне понадобился setts;
  • FFmpeg документирует и корректировку временных меток в concat, и изменение на уровне пакетов PTS/DTS/длительности через setts.

Это хорошо согласуется с округлением/масштабированием временных значений на границах и при перемультиплексировании, но я не превращаю согласующуюся механику в заявление «concat всегда создаёт +1 тик».

Процесс, который я использовал бы теперь

  1. Сначала восстановить авторитетный временную шкалу источника, не угадывая его по одному полю FPS.
  2. Сохранять исходные миллисекундные задержки на достаточно точной входной шкале.
  3. Отдельно выбрать целевой CFR.
  4. Явно квантизировать исходную временную шкалу на регулярную сетку выбранного CFR.
  5. Выбрать шкалу времени, на которой разрешённые частоты представляются целыми длительностями.
  6. Закодировать сегмент и не просить последующие стадии независимо повторять преобразование частоты.
  7. Проверить временной базы, число кадров, PTS/DTS и длительность пакета каждого сегмента до склейки.
  8. До объединения сегментов без перекодирования проверить совместимость кодека, конфигурации AVC, геометрии, цвета и временной модели.
  9. После concat снова проверить сетку пакетов; не считать -c:v copy доказательством неизменности временных меток.
  10. Если требуется нормализация, строить её из известной временной шкалы и применять на уровне пакетов, а не исправлять отдельные числа эвристикой.
  11. Повторно проверить все пакеты.
  12. Полностью декодировать итоговый файл.
  13. Публиковать его атомарно только после прохождения контракта.

Этот порядок важнее конкретной команды FFmpeg. Он разделяет сохранение исходного времени, выбор новой сетки и последующую проверку того, что новая сетка действительно сохранилась до финального MP4.

Главный вывод: CFR — это целочисленный контракт времени

Раньше значение вроде 16 fps казалось мне почти самодостаточным свойством файла. После этих ошибок я перестал ему доверять без контекста.

В моём случае 16 кадров/с означают не просто строку в ffprobe. Они означают:

time base = 1/90000
normal frame duration = 5625 ticks
presentation cadence = 62.5 ms
packet timeline соответствует этой сетке
DTS/PTS остаются законными для H.264 с reorder

5580 было ценным числом, потому что выдало старую миллисекундную сетку. 3751 было ценным числом именно потому, что визуально почти ничего не значило: один тик заставил меня проверить, действительно ли система сохраняет свой собственный инвариант.

С тех пор мой принцип простой: не валидировать ярлык «CFR». Валидировать время, из которого этот ярлык должен следовать.

Основная документация