Я собрал генератор музыкальных роликов — тех самых «audio edit», которые лежат на ютубе миллионами: размытый фон, обложка по центру, спектр пляшет по кругу, снизу полоса воспроизведения. На вход трек и картинка, на выходе mp4.

Отрендерил первый трёхминутный ролик, открыл папку и не поверил глазам: 472 мегабайта.

В кадре одна статичная картинка. Она даже не двигается толком — медленный зум на 14% за весь ролик. Не игра, не съёмка с рук, не футаж. Откуда полгигабайта?

Разбор занял вечер, и виноватым оказался эффект, который я добавил последним и считал косметикой.

Куда ушёл битрейт

Первым делом я разобрал кадр на слои и стал отключать их по одному, замеряя битрейт видеопотока. Все замеры ниже — одни и те же 6 секунд демо-ролика, 1080p/30, x264 CRF 21, пресет aesthetic. Считается именно видеопоток, без звука: ffprobe по пакетам, а не размер файла, чтобы аудиодорожка не мешалась.

Сначала отключил зерно, чтобы посмотреть на остальные слои без него. Получилось 1.69 Мбит/с на всё про всё, и вклад слоёв такой:

Слой

Вклад в битрейт

Доля

визуализатор

0.41 Мбит/с

24.5%

обложка

0.06 Мбит/с

3.4%

виньетка

0.05 Мбит/с

2.9%

текст и полоса прогресса

0.02 Мбит/с

0.9%

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

А потом я включил зерно обратно:

Настройка

Битрейт

Трёхминутный трек

Против «без зерна»

зерно выключено

1.7 Мбит/с

36 МБ

grain_size: 4

14.8 Мбит/с

317 МБ

×8.8

grain_size: 2

22.0 Мбит/с

472 МБ

×13.0

grain_size: 1 (попиксельно)

56.4 Мбит/с

1210 МБ

×33.4

Зерно добавляет 20.3 Мбит/с — в пятьдесят раз больше, чем пляшущий спектр, и в двенадцать раз больше, чем весь остальной кадр вместе взятый. Умножает файл на тринадцать. Попиксельный шум — на тридцать три: полтора гигабайта на трёхминутный трек из одной картинки.

И это при том, что зерно почти не видно. Я померил его амплитуду после всех масштабирований: среднеквадратичное отклонение 4.6 уровня яркости из 255, меньше двух процентов шкалы. Едва заметная плёночная текстура, которая стоит дороже всего остального в кадре, вместе взятого.

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

Слева без зерна, справа с зерном по умолчанию. Один и тот же фрагмент, без увеличения
Слева без зерна, справа с зерном по умолчанию. Один и тот же фрагмент, без увеличения

Разница — еле заметная текстура на фоне и на обложке. Цена разницы — тринадцать размеров файла.

Почему кодек ломается именно о шум

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

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

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

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

Дальше хуже. У белого шума плоский спектр: энергия размазана по всем пространственным частотам поровну. DCT для того и нужен, чтобы согнать энергию в несколько низкочастотных коэффициентов, а остальные обнулить квантованием. С шумом сгонять нечего — после DCT все 64 коэффициента блока значимы примерно одинаково. Квантователь выбрасывает часть, но их слишком много.

И финальный гвоздь: энтропийный кодер. Случайные данные по определению имеют максимальную энтропию. CABAC сжимает предсказуемое; здесь предсказывать нечего.

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

Лечение первое: зерно блоками

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

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

rng = np.random.default_rng(seed)
step = max(1, int(grain_size))
small = (max(2, h // step), max(2, w // step))

for _ in range(_GRAIN_TILES):
    noise = rng.normal(0.0, 64.0, small)
    patch = Image.fromarray(np.clip(noise + 128.0, 0, 255).astype(np.uint8), "L")
    if step > 1:
        patch = patch.resize((w, h), Image.BILINEAR)
    field = np.asarray(patch, dtype=np.int16) - 128
    self._grain_tiles.append(np.clip(field, -127, 127).astype(np.int8)[..., None])

Блок в 2 пикселя вместо одного даёт в 2.6 раза меньший битрейт — 22.0 против 56.4 Мбит/с. Блок в 4 пикселя — ещё в полтора раза меньше.

Слева зерно блоками по 2 пикселя, справа попиксельное. Разница на глаз — 2.6 раза по битрейту
Слева зерно блоками по 2 пикселя, справа попиксельное. Разница на глаз — 2.6 раза по битрейту

Слева шум сгенерирован в половинном разрешении и растянут, справа — попиксельно. На глаз почти одно и то же: справа чуть «злее» и мельче. По битрейту между ними 2.6 раза.

Приятный побочный эффект: так оно и выглядит правильнее. Настоящее плёночное зерно — это кристаллы галогенида серебра, у них есть физический размер, и на кадре 1080p они занимают не один пиксель. Попиксельный шум выглядит как цифровой брак, а не как плёнка. Я шёл за размером файла, а получил заодно более честную картинку.

Отдельно про хранение. Двенадцать полей шума на 1080p во float32 — это 95 МБ на процесс, а рендер идёт в несколько процессов, и каждый держит свою копию. Поэтому поля лежат в int8: те же двенадцать полей занимают 24 МБ, а точности для шума со среднеквадратичным отклонением в 4.6 уровня яркости хватает с запасом.

Лечение второе: взять кодек, который умеет в шум

Дальше стало интересно. Зерно — это ровно тот тип картинки, на котором современные кодеки отрываются от x264 сильнее всего. Захотелось измерить, насколько.

Сравнивать CRF разных кодеков напрямую бессмысленно: у x264 шкала 0–51, у AV1 0–63, и «CRF 21» у них значит разное. Поэтому я отрендерил lossless-мастер (x264 CRF 0, 155 МБ на шесть секунд), закодировал его разными кодеками и померил VMAF каждого результата относительно мастера. Тогда битрейты можно сравнивать при совпадающем качестве.

Кодек

Битрейт

VMAF

Трёхминутный трек

x264 CRF 18

33.8 Мбит/с

97.6

725 МБ

x264 CRF 21

22.0 Мбит/с

95.0

472 МБ

x264 CRF 23

15.2 Мбит/с

93.0

326 МБ

x264 CRF 26

6.8 Мбит/с

89.4

146 МБ

AV1 CRF 30

4.4 Мбит/с

93.3

94 МБ

AV1 CRF 30 + синтез зерна

3.8 Мбит/с

92.9

82 МБ

Смотрим на строки с VMAF ≈ 93. x264 просит 15.2 Мбит/с, AV1 — 4.4. В три с половиной раза меньше при том же измеренном качестве.

Заодно видно, что CRF 18, который я по привычке считал «почти без потерь и надо бы поставить», стоит в полтора раза больше места, чем CRF 21, ради 2.6 пункта VMAF. На зернистой картинке это просто выброшенные мегабайты.

Синтез зерна в AV1, который обещал больше, чем дал

У AV1 есть ровно та фича, которая напрашивается после всего вышесказанного: film grain synthesis. Идея красивая. Кодировщик убирает зерно с картинки, кодирует чистое видео (дёшево!), оценивает параметры шума и кладёт их в поток отдельной маленькой структурой. Декодер при воспроизведении синтезирует зерно заново и накладывает поверх. Зерно не хранится вообще — хранится его описание.

В libaom это включается через -denoise-noise-level:

ffmpeg -i master.mp4 -c:v libaom-av1 -crf 30 -cpu-used 8 \
       -denoise-noise-level 12 out.mp4

Выигрыш вышел 4.4 → 3.8 Мбит/с. Четырнадцать процентов. Приятно, но я ждал кратного.

И в процессе libaom ругнулся:

Solving latest noise equation system failed 0!

Кодировщик не смог построить модель шума. Скорее всего, потому что моё зерно для него слишком «неправильное»: оценщик в libaom заточен под шум настоящих сенсоров — он коррелирован с яркостью, отличается по цветовым каналам, имеет характерный спектр. А у меня равномерный гауссов шум, одинаковый по всему кадру и по всем каналам, да ещё и растянутый из половинного разрешения. Теоретически идеальный кандидат на синтез, практически — случай, который оценщик не распознал.

Ещё цифра для контекста: шесть секунд 1080p libaom кодировал 1 минуту 46 секунд на cpu-used 8, то есть на самой быстрой настройке. x264 на medium делает те же шесть секунд за считаные секунды.

Поэтому по умолчанию в проекте остался x264. Он играется везде без плясок, кодирует быстро, а пользователь, которому важен размер, может поставить --grain 0 и получить 36 МБ вместо 472. Вывод для себя записал такой: если когда-нибудь будет пайплайн, где размер важнее совместимости, — AV1 на зернистом контенте окупается втрое, но платить придётся временем кодирования и возиться с настройкой синтеза.

Визуализатор, который врал

Со второй частью вышло веселее. Спектр должен реагировать на музыку, и наивная реализация — взять rfft, нарисовать столбики — даёт картинку, которая выглядит правдоподобно и при этом врёт. Разберу три места, где она врёт.

Линейные бины против того, как устроен слух

np.fft.rfft при n_fft=4096 и 44.1 кГц даёт бины шириной 10.77 Гц. Равномерно по частоте — и в этом проблема.

Октава от 30 до 60 Гц — это неполных три бина. Октава от 7 до 14 кГц — около шестисот пятидесяти. Если рисовать бины как есть, то девяносто процентов ширины экрана занимают верха, где в музыке почти ничего не происходит, а весь бас, где сидит основная энергия трека, умещается в три пикселя и не шевелится.

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

edges = np.geomspace(fmin, fmax, n_bands + 2)
bin_freqs = np.fft.rfftfreq(n_fft, 1.0 / sample_rate)

for b in range(n_bands):
    lo, ctr, hi = edges[b], edges[b + 1], edges[b + 2]
    rising  = (bin_freqs > lo)  & (bin_freqs <= ctr)
    falling = (bin_freqs > ctr) & (bin_freqs <  hi)
    bank[b, rising]  = (bin_freqs[rising] - lo) / max(ctr - lo, 1e-9)
    bank[b, falling] = (hi - bin_freqs[falling]) / max(hi - ctr, 1e-9)
    bank[b] /= bank[b].sum()

Дальше весь спектр умножается на матрицу банка одним spectrum @ bank.T — 64 полосы вместо 2049 бинов, и каждая полоса означает примерно одинаковый интервал для уха.

Грабля внутри граблей: когда полоса уже одного бина FFT (а на 30 Гц при таком окне так и есть), в неё не попадает ни один бин, сумма весов ноль, и деление даёт nan. Поэтому в коде есть ветка «полоса пустая — бери ближайший бин».

Тишина, на которой спектр вставал в полный рост

Уровни надо привести к 0…1. Абсолютная шкала не годится: тихий трек не должен рисоваться плоской линией. Значит, нормировка по содержимому — берём верхний перцентиль как опорный уровень и растягиваем диапазон вниз от него.

И вот тут наивная версия ломается красиво. Если анализируемый кусок целиком тихий — эмбиент, длинное интро, или просто пользователь дёрнул --preview 10 и попал на тишину, — то 99.5-й перцентиль оказывается уровнем шума дизера. Нормировка добросовестно растягивает этот шум на всю шкалу.

Я померил. Шесть секунд цифровой тишины с дизером на уровне −90 dBFS:

Опорный уровень

Средний уровень полос

Максимум

без абсолютного порога

−110.5 дБ

0.885

0.999

с порогом −50 дБ

−50.0 дБ

0.001

0.028

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

Лечение в одну строку — опорный уровень не опускается ниже абсолютного порога:

ref = max(float(np.percentile(db, 99.5)), _SILENCE_REF_DB)  # -50 dB
levels = np.clip((db - (ref - cfg.db_range)) / cfg.db_range, 0.0, 1.0)

Теперь на тишине полосы гаснут, как и положено.

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

Симметричное сглаживание выглядит как желе

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

Ухо и глаз ждут другого: резкий подъём, медленный спад. Как у стрелочного VU-метра. Поэтому фильтр асимметричный, с разными коэффициентами вверх и вниз:

rate = np.where(cur > prev, attack, decay)   # attack=0.55, decay=0.16
prev = prev + (cur - prev) * rate

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

Там же рядом — наклон по частоте. Энергия в музыке падает примерно на 6 дБ на октаву, и без компенсации правая половина визуализатора просто не живёт. Добавляю к уровням в дБ 0.55 * 10*log10(f / fmin) — верха поднимаются, кольцо шевелится целиком.

Slowed + reverb на голом ffmpeg

Раз уж это «audio edit», нужен фирменный звук: замедление с понижением тона и мокрая реверберация. Без DSP-библиотек, только фильтры ffmpeg.

Замедление делается не atempo. atempo меняет темп, сохраняя высоту тона, — это ровно то, чего в этом жанре не надо. Нужен эффект «пластинку крутят медленнее», где тон едет вместе с темпом. Это asetrate: мы объявляем, что у файла другая частота дискретизации, и сразу получаем и темп, и питч, после чего пересэмплируем обратно.

f.append(f"asetrate={int(round(sample_rate * cfg.speed))}")
f.append(f"aresample={sample_rate}")

speed=0.85 — классический slowed. speed=1.15 — sped up, который сейчас тоже в моде.

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

early_d = [int(room * k) for k in (0.17, 0.29, 0.41)]
late_d  = [int(room * k) for k in (0.61, 0.83, 1.0)]

Коэффициенты взаимно непериодичны специально. Если взять задержки кратными друг другу — 100, 200, 300 мс — отражения складываются в одни и те же моменты, и вместо помещения получается металлический призвук и флэнжер. Непериодичные задержки дают плотный хвост, который на слух читается как комната.

Три грабли, на которых я потерял время

argparse и пустая строка, которая не None. Я делал CLI двуязычным и переопределил HelpFormatter, чтобы перевести слово usage::

def add_usage(self, usage, actions, groups, prefix=None):
    super().add_usage(usage, actions, groups, prefix or tr("cli.help.usage"))

Выглядит невинно. В справке появилось использование: использование: musicvideo render, а в каждом сообщении об ошибке — использование: musicvideo render: error: ....

Причина в prefix or. Когда argparse собирает prog для подкоманды, он берёт строку вызова родителя и вызывает add_usage с пустой строкой в качестве префикса — именно чтобы получить usage без слова «usage». Пустая строка ложная, or подставляет мой перевод, и слово «использование» уезжает прямо внутрь prog. Дальше prog подставляется всюду, включая %(prog)s: error: %(message)s.

Лечится заменой на явную проверку, и это ровно тот случай, когда or для значения по умолчанию — ловушка:

if prefix is None:
    prefix = tr("cli.help.usage")

Воркеры, которые импортируют ваш скрипт заново. Рендер идёт в пуле процессов, на Windows это spawn, и каждый воркер импортирует модуль, из которого стартовал. Скрипт без if __name__ == "__main__" запускает рендер рекурсивно: каждый воркер начинает собственный рендер со своим пулом. Классика, но напороться на неё легко, потому что в однопроцессном режиме всё работает.

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

Однобуквенное имя, которое заняли дважды. Каталог сообщений я сначала назвал по традиции t(). В коде отрисовки t — это время: frame(t, bass, beat), paste(canvas, t, bass), alpha_at(t). Импорт молча затенялся локальной переменной внутри каждого такого метода. Пока переводы не вызывались изнутри этих функций, всё работало — то есть мина лежала и ждала. Переименовал в tr().

Что получилось

Шесть стилей визуализатора, шесть пресетов (включая вертикальный под Shorts), обработка звука, покадровый рендер в несколько процессов с отдачей кадров в stdin ffmpeg. Из зависимостей — NumPy и Pillow, всё остальное стандартная библиотека и ffmpeg. Двуязычный CLI, 55 тестов, CI на четырёх версиях Python.

Ориентир по скорости, замеренный здесь же: 702 кадра 1080p/30 на восьми ядрах (семь воркеров) — 76 секунд, то есть 9.2 кадра в секунду. Трёхминутный трек по этой цифре выходит примерно в десять минут; выключение зерна ускоряет и рендер, и кодирование.

Код под MIT: https://github.com/ialakey/music-video-generator

Главное, что я вынес из этой истории: самая дорогая вещь в кадре оказалась той, которую я добавил последней и считал ерундой. Я потратил силы на оптимизацию отрисовки — визуализатор рендерится только в тот прямоугольник кадра, которого касается, слои кешируются, шум лежит в int8. Всё это экономит секунды рендера. А четыреста мегабайт лежали в одной строчке, добавляющей случайные числа к кадру.

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