BitPage

Тест на длительность видео был зелёным при обрезанной картинке: ffprobe мерил контейнер, а не дорожку

Автор:  ·   · 11 мин чтения

Короткий ответ: ffprobe -show_entries format=duration возвращает длительность контейнера, а она по построению равна максимуму по дорожкам. Если в файле есть звуковая дорожка полной длины — например, её держит anullsrc, натянутый на заявленное число секунд, — то укороченное видео на это число не влияет вообще: у меня контейнер отдавал 13,139 с при видеодорожке 11,833 с. Длину картинки спрашивают у дорожки: ffprobe -select_streams v:0 -show_entries stream=duration.

Ролик собирается одним запуском ffmpeg: неподвижные картинки идут подряд по своим долям, озвучка ложится на свои секунды, звуковая дорожка натягивается на всю длину источником тишины, поверх вжигаются субтитры. Есть тест: длительность готового файла должна совпасть с тем, что обещала сборка. Чтобы убедиться, что тест рабочий, я укоротил каждый кусок картинки на 10% — картинка стала кончаться раньше звука, конец фразы ушёл за кадр. Весь набор, несколько сотен тестов, остался зелёным.

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

Коротко (TL;DR)

Почему format=duration не падает вместе с картинкой

Потому что это число — максимум по дорожкам, и так оно устроено с обоих концов: и когда файл записывается, и когда читается. Документация ffprobe про -show_format говорит прямо, и слово «контейнер» там стоит дважды:

Show information about the container format of the input multimedia stream. All the container format information is printed within a section with name “FORMAT”.

Ловушка в том, что format=duration читается как «длительность видео», хотя написано «длительность контейнера». Дальше — арифметика.

На записи. Мой ролик — MP4, и длительность презентации живёт в атоме mvhd. В муксере FFmpeg она считается так (libavformat/movenc.c, функция mov_write_mvhd_tag), и переменная названа без всякой двусмысленности:

1
2
3
4
5
6
int64_t max_track_len = 0;
for (i = 0; i < mov->nb_tracks; i++) {
    if (mov->tracks[i].entry > 0 && mov->tracks[i].timescale) {
        int64_t max_track_len_temp = av_rescale_rnd(...);
        if (max_track_len < max_track_len_temp)
            max_track_len = max_track_len_temp;

На чтении. Для контейнеров, которые не несут готовой длительности, libavformat выводит её из дорожек — в libavformat/demux.c, функция update_stream_timings:

1
2
3
4
5
6
7
if (st->duration != AV_NOPTS_VALUE) {
    duration1 = av_rescale_q(st->duration, st->time_base, AV_TIME_BASE_Q);
    if (is_text)
        duration_text = FFMAX(duration_text, duration1);
    else
        duration = FFMAX(duration, duration1);
}

FFMAX в цикле по всем дорожкам. Заголовок libavformat/avformat.h описывает это поле тем же языком:

Duration of the stream, in AV_TIME_BASE fractional seconds. Only set this value if you know none of the individual stream durations and also do not set any of them. This is deduced from the AVStream values if not set.

Вторая половина механизма — звук. Дорожка тишины создаётся фильтром anullsrc («The null audio source, return unprocessed audio frames» — документация фильтров FFmpeg) и натягивается ровно на заявленное число секунд. То есть она всегда полной длины, независимо от того, что случилось с картинкой. Максимум по дорожкам берёт её, и контейнер продолжает рапортовать обещанную длительность.

Отсюда и зелёный тест: я сравнивал число, которое поломка физически не могла изменить, с числом, которое обещала сборка. Такой тест не мог покраснеть — не потому что был настроен неудачно, а потому что измерял не ту величину.

Мелочь из того же кода, которая пригодится позже: дорожки субтитров и данных обрабатываются отдельной ветвью (is_text) и в общий максимум идут только если основные дорожки длительности не дали или расходятся меньше чем на секунду — иначе в лог уходит Ignoring outlier non primary stream duration. Для звука и видео такой защиты нет: они равноправны, максимум берётся по ним напрямую.

Как спросить длительность дорожки вместо контейнера

Ключ — -select_streams, который в документации ffprobe описан как отбор по спецификатору потока: v:0 — первая видеодорожка, a:0 — первая звуковая. Четыре команды, которые я теперь запускаю на любом спорном файле:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# длительность контейнера — та самая, что обманывает
ffprobe -v error -show_entries format=duration -of csv=p=0 movie.mp4
# 13.139000

# длительность видеодорожки — правда про картинку
ffprobe -v error -select_streams v:0 -show_entries stream=duration -of csv=p=0 movie.mp4
# 11.833000        ← вот она, поломка

# звуковая, для сравнения
ffprobe -v error -select_streams a:0 -show_entries stream=duration -of csv=p=0 movie.mp4
# 13.139000

# частота кадров самого файла — понадобится для допуска
ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 movie.mp4
# 30/1
Что спрашиваемКомандаНа сломанном файле
Контейнер-show_entries format=duration13,139 с — не изменилось
Видеодорожка-select_streams v:0 -show_entries stream=duration11,833 с — поймана
Звуковая дорожка-select_streams a:0 -show_entries stream=duration13,139 с — держит длину за всех

Если первая строка расходится со второй — в файле есть дорожка, которая держит длину за остальных. Само расхождение и есть диагноз, никаких дополнительных измерений не нужно.

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

Сверка двух чисел из одного измерения — не сверка

Второй слой той же беды: мой тест сравнивал format=duration с числом, записанным в метаданные артефакта, а метаданные заполнялись тем же самым вызовом ffprobe. Формально это сверка «метаданные соответствуют файлу». Фактически — сравнение числа с самим собой.

Такой тест зелёный по построению и переживает любую поломку: оба числа поедут одинаково, потому что у них один источник. Отличить его от настоящей сверки можно единственным способом — сломать источник и посмотреть на цвет. Если не покраснело, сверки нет.

Правило, которое я из этого вывел: второе число обязано приходить из независимого источника. Не из записи об объекте, а из повторного измерения другого объекта. Длительность озвучки — по звуковому файлу, а не по строке в метаданных о нём.

Допуск выводится из формата, а не назначается на глаз

Допуск должен считаться из устройства формата, иначе он с одинаковым успехом пропускает и поломку, и норму. Видео нарезано на куски, каждый кодируется в целое число кадров, поэтому расхождение неизбежно и пропорционально числу кусков — а не длине ролика.

Формула вышла такая:

допуск = число кусков × длительность кадра

На моём ролике шесть кусков при 30 кадрах в секунду: 6 × 33,3 мс = 0,2 с. Реальное расхождение — 39 мс, чуть больше одного кадра на весь ролик. Намеренная поломка даёт 1,306 с, то есть запас в шесть с половиной раз.

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

А вот что оказалось приятной неожиданностью, когда я посчитал. Запас у выведенного допуска не зависит от длины ролика вообще. Если поломка — это доля p от длины, а допуск — число кусков на длительность кадра, то отношение сокращается до

запас = p × средняя длина куска × частота кадров

Для моих чисел: 0,1 × 2,19 с × 30 = 6,57 — ровно тот запас в шесть с половиной раз, который я намерил. Длина ролика в формуле отсутствует: и поломка, и допуск растут линейно вместе с числом кусков. Константа такого свойства не имеет — она фиксирована, а поломка сжимается вместе с роликом, и на коротком материале они встречаются.

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

Частоту кадров надо спрашивать у файла, и FFmpeg зовёт её догадкой

Частота кадров берётся из готового файла через r_frame_rate, а не константой из кода сборщика — тогда тест заодно проверяет, что сборщик записал именно ту частоту, которую собирался.

Пришёл я к этому не из принципа, а через тупик. Сначала я попробовал импортировать константу частоты прямо из модуля-генератора: не дублировать же значение в тесте. Получил:

ImportError: cannot import name ... from partially initialized module ...
(most likely due to a circular import)

Модуль генератора поднимается реестром, и прямое обращение по имени даёт круговой импорт. Кстати, формулировка тут не случайная: CPython выбирает между «cannot import name X from Y» и вариантом про partially initialized module по результату _PyModuleSpec_IsInitializing — то есть само сообщение об ошибке уже отвечает, круговой это импорт или просто отсутствующее имя. Я это раньше читал как одну общую ошибку.

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

Одна честная оговорка, потому что FFmpeg сам её делает в avformat.h:

Real base framerate of the stream. This is the lowest framerate with which all timestamps can be represented accurately (it is the least common multiple of all framerates in the stream). Note, this value is just a guess!

Для файла с постоянной частотой кадров, который только что собрал ffmpeg, догадка совпадает с истиной и приходит как 30/1. Для материала с переменной частотой на это поле опираться нельзя — там осмысленнее avg_frame_rate, «average framerate» в тех же заголовках. Мой случай первый, поэтому r_frame_rate подходит; чужой материал я бы так не мерил.

Три утверждения вместо одного

Одно утверждение о длительности не покрывает два разных класса поломок, поэтому их три, и все меряют дорожки, а не контейнер:

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

Третье стоит отдельно не для симметрии. Поломка в раскладке — когда доли кадров считаются неверно — первыми двумя не ловится вообще: ffmpeg честно смонтирует искажённый план, и ролик получится ровно той длины, которую ему велели. Контейнер, видеодорожка и звук совпадут между собой идеально, а картинки внутри будут стоять не по своим секундам.

Я проверил оба рода поломок по отдельности, и падают от них разные тесты:

Что сломалЧто покраснелоЧто осталось зелёным
Каждый кусок картинки короче на 10%«ролик той длины, что обещала сборка» (13,139 против 11,833) и «картинка не кончается раньше звука»сумма долей — она не задета
Доли кадров сцены занижены на 10%«кадры сцены складываются в длительность озвучки» (озвучка 3,295 с, кадрам отдано 2,966 с)первые два — ffmpeg смонтировал искажённый план в правильную длину

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

Где этот приём не работает

Длительность дорожки есть не во всех контейнерах, и там stream=duration придёт как N/A. MP4 и MOV несут длительности треков в заголовках, поэтому вопрос к дорожке дёшев. MPEG-TS и сырые потоки таких заголовков не имеют — длину там приходится получать честным проходом по файлу.

Резервный вариант — считать кадры, а не спрашивать про секунды. В документации ffprobe опция описана как «Count the number of frames per stream and report it in the corresponding stream section»:

1
2
ffprobe -v error -select_streams v:0 -count_frames \
        -show_entries stream=nb_read_frames -of csv=p=0 movie.mp4

Это дороже: файл читается целиком. Зато число кадров, поделённое на частоту, — длина картинки, полученная из самой картинки, а не из чьего-то заголовка.

Что сделать, по шагам

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

  1. Прогоните обе команды на одном файле: format=duration и -select_streams v:0 -show_entries stream=duration. Если числа расходятся — дальше читайте не тест, а измерение.
  2. Найдите в тестах все места, где длительность берётся у контейнера, и переведите их на дорожку.
  3. Добавьте утверждение «видеодорожка не короче звуковой» — оно ловит этот класс без всяких внешних ожиданий.
  4. Проверьте каждую «сверку» на общий источник: если оба числа получены одним вызовом, это не сверка. Сломайте источник и посмотрите на цвет.
  5. Замените константные допуски на выведенные из формата: число кусков × длительность кадра.
  6. Частоту кадров спрашивайте у файла (r_frame_rate), а не импортируйте из кода сборщика.
  7. Добавьте отдельное утверждение про раскладку — сумму долей против заново измеренной озвучки. Первые два его не покрывают.
  8. Проверьте набор намеренными поломками обоих родов и убедитесь, что падают разные тесты. Зелёный тест на сломанном входе — это не тест.

Шаг 8 стоит последним, но по отдаче он первый: без него остальные семь остаются предположениями о том, что проверка работает.

Итог

Тест на «результат правильной длины» проверяет не то, что кажется, если мерить контейнер. В любом формате с несколькими дорожками длина контейнера — это максимум по дорожкам, и FFMAX в исходниках FFmpeg стоит там и на записи, и на чтении. Пока в файле есть дорожка полной длины — а источник тишины гарантирует, что она есть, — обрезанная картинка на это число не влияет.

Практический вывод шире ffmpeg. Сверка двух чисел из одного измерения зелёная по построению и переживает любую поломку; отличить её от настоящей проверки можно только одним способом — сломать источник и посмотреть на цвет. И допуск — это часть проверки, а не смягчение для неё: выведенный из устройства формата, он держит постоянный запас независимо от длины материала, а назначенный «на глаз» с равным успехом пропускает и поломку, и норму.

Первоисточники: документация ffprobe, libavformat/avformat.h — поля duration и r_frame_rate, libavformat/demux.cupdate_stream_timings, libavformat/movenc.cmov_write_mvhd_tag, фильтр anullsrc в документации FFmpeg, Python/ceval.c в CPython — текст ошибки про круговой импорт.

#ffmpeg #ffprobe #тестирование #Python #видео

<< Previous Post

|

Next Post >>