Я собрал генератор музыкальных роликов — тех самых «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 МБ | — |
| 14.8 Мбит/с | 317 МБ | ×8.8 |
| 22.0 Мбит/с | 472 МБ | ×13.0 |
| 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.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. Всё это экономит секунды рендера. А четыреста мегабайт лежали в одной строчке, добавляющей случайные числа к кадру.
Если соберётесь делать что-то похожее — меряйте битрейт по слоям с самого начала. Это пять минут работы, и они сразу показывают, за что вы на самом деле платите.