Я снимаю короткие вертикальные ролики для соцсетей и долго собирал их руками в редакторе. Потом заметил, что правки повторяются: поменять слово в субтитре, подвинуть вставку на полсекунды, перекрасить титры. Ради каждой такой мелочи приходилось открывать редактор и пересобирать ролик, так что я написал на Python «станок»: он берёт файл-задание tz.json и собирает по нему ролик с помощью ffmpeg. Задание описывает, какие куски съёмки взять, какие реплики показать субтитрами, где поставить вставки и звуки.
Весь станок вырос до нескольких тысяч строк, поэтому расскажу только про две вещи, которые мне пригодились больше всего. Первая: как устроена сборка слоями с кэшем и почему секунды в задании я отсчитываю по исходной съёмке. Вторая: что станок проверяет после рендера, чтобы мне не пересматривать каждый файл глазами с нуля. Код ниже взят из рабочих файлов, лишнее я убрал.
Что такое задание
Само задание пишется обычным JSON. Вот сокращённый шаблон, который станок печатает по ключу --example:
{ "output": "reel.mp4", "sources": {"dubl": "исходник.mp4", "fon": "фоновая-съёмка.mp4"}, "cut": [ {"source": "dubl", "start": 0.0, "end": 4.2, "note": "хук", "zoom": "in"}, {"source": "fon", "start": 8.0, "end": 11.0, "note": "перебивка на фон"}, {"source": "dubl", "start": 12.4, "end": 18.0, "note": "пейофф", "speed": 1.15} ], "subtitles": { "accent_words": ["нейросеть"], "items": [ {"start": 0.2, "end": 2.0, "text": "первая реплика"}, {"start": 2.0, "end": 4.0, "text": "вторая реплика", "accent": true} ] }, "overlays": [ {"file": "logo.png", "start": 1.0, "duration": 3.0, "preset": "угол"} ], "sfx": [{"preset": "свист", "at": 4.2}], "audio": {"music": {"file": "muzyka.mp3", "volume": -18, "duck": true}} }
В cut лежат куски исходников по порядку, в subtitles реплики, в overlays картинки поверх кадра, в sfx звуковые акценты, в audio музыка. Всё остальное (цвет, раскладки кадра, паспорт со шрифтами и коридорами проверок) тоже живёт в заданиях и файлах настроек, но для этой статьи достаточно перечисленного.
Команда сборки одна:
python3 montazh.py задание.json -o ролик.mp4 python3 montazh.py задание.json --dry-run # что пересоберётся, ничего не считая
Слои и кэш
Ролик собирается четырьмя слоями. Сначала нарезка: отдельный скрипт режет куски по cut и склеивает их, включая ускорение и наезд камеры. Потом картинка: один большой вызов ffmpeg с filter_complex, где поверх нарезки ложатся цвет, вставки и субтитры. Потом звук: музыка с приглушением под голос, звуковые акценты и выравнивание громкости. В конце готовый файл копируется на выход.
Зачем разделять, видно по типичной правке. Если я меняю слово в субтитре, нарезка не меняется, а она самая долгая. Поэтому у каждого слоя есть ключ, и слой пересобирается, только когда ключ изменился. Учёт ведёт небольшой класс:
class Cache: """Учёт собранных слоёв. Порча файла не должна ронять станок навсегда.""" def __init__(self, work: Path): self.path = work / "cache.json" self.data = {} if self.path.exists(): try: self.data = json.loads(self.path.read_text()) except (json.JSONDecodeError, OSError): # Обрыв посреди записи (Ctrl-C на длинном рендере — обычное дело) warn("учёт слоёв повреждён — собираю заново") def hit(self, layer: str, key: str, file: Path) -> bool: rec = self.data.get(layer) return bool(rec and rec.get("key") == key and file.exists()) def put(self, layer: str, key: str, file: Path) -> None: self.data[layer] = {"key": key, "file": str(file), "at": int(time.time())} tmp = self.path.with_suffix(".json.tmp") tmp.write_text(json.dumps(self.data, ensure_ascii=False, indent=2)) os.replace(tmp, self.path) # подмена целиком: полуфайла не останется
Длинный рендер легко прервать по Ctrl-C, и тогда cache.json может остаться обрезанным посреди записи. Поэтому повреждённый учёт я считаю пустым, а пишу через временный файл и os.replace, так что файл либо записан целиком, либо не тронут.
Ключ нарезки я считаю по самому заданию, промежуточные файлы в нём не участвуют. В него входят качество, частота кадров, список диапазонов и отметки времени файлов-исходников. Поле note, то есть моя пометка к куску, в ключ не входит: резать заново из-за переписанного пояснения незачем.
key = digest({"quality": quality, "auto": tz.get("color") == "auto", "fps": int(tz.get("fps") or 30), "ranges": [{**{k: p[k] for k in ("name", "start", "end", "speed")}, "zoom": p.get("zoom")} for p in plan], "stamps": {n: stamp(p) for n, p in tz["_sources"].items()}})
Ключ картинки включает ключ нарезки, ключ звука включает ключ картинки, и так по цепочке. Поэтому правка на нижнем слое сама сбрасывает всё, что выше. Звук считается отдельно от картинки: на финальном шаге видеодорожка копируется без перекодирования (-c:v copy), и меняется только аудио.
Ещё одна мелочь, которая спасла меня от тихого брака: на папку с рабочими файлами берётся блокировка. Два одновременных запуска на одном задании пишут в одни и те же файлы слоёв, и ошибки не будет, выйдет аккуратно покорёженное видео. Теперь второй запуск сразу сообщает, что в папке уже идёт сборка.
Секунды в задании считаются по съёмке
Сначала числа в задании означали секунды готового ролика. Стоило подвинуть рез или ускорить кусок, и все реплики после него уезжали, приходилось пересчитывать руками.
Теперь любое число в subtitles.items, sfx[].at и overlays[].start означает секунду исходной съёмки, как таймкод на файле с телефона до всех резов. Станок сам знает, что вырезано и что ускорено, и переводит секунду в секунду готового ролика, так что рез я двигаю, а субтитры остаются на месте.
С пересчётом была одна неприятность, которую я нашёл только замерами. ffmpeg округляет каждый кусок к кадру, поэтому реальная склейка выходит длиннее расчётной. Я сравнил расчёт с фактом на 21 боевой склейке: готовая длина оказалась на 0,08–0,46% больше теоретической (коэффициент от 1,00079 до 1,00457). На длинном ролике без поправки промах доходил до 0,26 с, а допуск приёмки у меня 0,15 с, и реплику, опоздавшую на четверть секунды, на экране уже видно.
Поправка считается после нарезки как отношение измеренной длины склейки к теоретической сумме длительностей кусков:
def k_popravka(pieces: list[dict], total: float) -> float: theory = sum((float(p["end"]) - float(p["start"])) / float(p.get("speed") or 1.0) for p in pieces) return (total / theory) if theory > 0 and total > 0 else 1.0
total здесь означает реальную длину cut.mp4, которую возвращает ffprobe, а theory считается только из чисел задания, без обращения к ffmpeg. Дальше эту k получают все, кто переводит секунды: субтитры, вставки, звуки. Секунда, попавшая в вырезанный кусок, прижимается к ближайшей границе соседнего куска, а число позже конца всего ролика останавливает сборку понятной ошибкой до рендера.
Старые задания, написанные по прежнему счёту, я перевёл отдельным скриптом-мигратором, который пересчитывает числа обратным ходом. Заодно я снял эталонные файлы субтитров со старого кода до правок и сравнивал с ними новый, потому что других доказательств, что поведение не поменялось, у меня нет.
Картинка: один вызов ffmpeg
Слой картинки собирается одним filter_complex. Вставки накладываются как overlay с окном показа, сквозная графика кладётся на весь ролик, полупрозрачное пятно под текстом идёт отдельным слоем, а субтитры накладываются последним фильтром, чтобы никто не перекрыл буквы. Вот сборка хвоста цепочки:
if ass is not None: chain.append(f"{last}{ass_filter(ass)}[v]") last = "[v]" if chain: cmd += ["-filter_complex", ";".join(chain), "-map", last] else: cmd += ["-map", "0:v"] cmd += ["-map", "0:a?", "-r", str(fps), "-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-c:a", "copy", str(out)] run(cmd, "Собрать картинку")
Субтитры я пишу в формате ASS и подключаю фильтром ass. Путь внутри фильтра нужно экранировать, иначе двоеточие и кавычки ломают разбор:
def ass_path_arg(p: Path) -> str: return str(p.resolve()).replace("\\", "\\\\").replace(":", r"\:").replace("'", r"\'")
На ffmpeg я обжёгся трижды, и про каждый случай в станке теперь стоит комментарий.
Первая: обычный Homebrew-ffmpeg собран без libass и падает на субтитрах кодом 234. Станок ищет полную сборку и только потом откатывается на ffmpeg из PATH, а на Windows, где в готовых сборках libass уже есть, идёт сразу в PATH.
Вторая: прозрачность в .webm читает только декодер libvpx-vp9. Если его не указать перед -i, ffmpeg молча кладёт на кадр чёрный прямоугольник, без единой ошибки. Станок подставляет декодер сам, а в ручных командах приходится помнить.
Третья: наезд камеры на кусок. Сначала я делал его одним фильтром на весь ролик, но scale с eval=frame в ffmpeg 8 кадр не пересчитывает, а zoompan на входе длиннее примерно 29 секунд ломает тайминг, теряется пятая часть кадров. Теперь наезд считается при нарезке, кусок за куском: на коротком куске zoompan работает верно.
Частота кадров у вставок обязана совпадать с частотой ролика, у меня это 30. Иначе ffmpeg дотягивает вставку повтором кадров, и плавное движение начинает подрагивать.
Звук: два прохода loudnorm
Громкость выравнивается к норме соцсетей: целевые −14 LUFS, потолок пика −1 dBTP, диапазон 11 LU. Одного прохода loudnorm для этого мало: в режиме одного прохода он подстраивается на ходу, и громкость плавает. Поэтому первый проход только измеряет и печатает JSON, а второй получает измеренные значения:
def build_sound(src: Path, out: Path, fast: bool) -> None: """Слой ЗВУК: громкость к норме соцсетей. Два прохода — иначе плавает.""" measured = "" if not fast: r = subprocess.run( [FFMPEG, "-hide_banner", "-i", str(src), "-af", f"loudnorm=I={LUFS_TARGET}:TP={LUFS_TP}:LRA={LUFS_LRA}:print_format=json", "-f", "null", "-"], capture_output=True, text=True) m = re.findall(r"\{[^{}]*input_i[^{}]*\}", r.stderr, re.S) if m: s = json.loads(m[-1]) measured = (f":measured_I={s['input_i']}:measured_TP={s['input_tp']}" f":measured_LRA={s['input_lra']}:measured_thresh={s['input_thresh']}" f":offset={s['target_offset']}") run([FFMPEG, "-y", "-i", str(src), "-c:v", "copy", "-af", f"loudnorm=I={LUFS_TARGET}:TP={LUFS_TP}:LRA={LUFS_LRA}{measured}", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(out)], "Выровнять громкость")
JSON loudnorm печатает в stderr вперемешку с остальным выводом, поэтому я достаю последний блок регулярным выражением. В режиме черновика (--draft) первый проход пропускается: в черновике я смотрю склейки, громкость там не важна.
Музыка приглушается под голос через sidechaincompress. Порог пришлось поставить низким, 0,02: речь в дорожке тише музыки, и при заводском значении 0,125 компрессор её просто не замечал. После последнего слова субтитров музыка поднимается на 10 дБ за 400 мс, но поднимать её надо после компрессора, иначе компрессор съест подъём.
Что станок проверяет после рендера
Теперь про вторую часть. Раньше после каждой сборки я открывал файл и смотрел его целиком. Это долго, и в конце сборки на внимательный просмотр обычно не остаётся сил. Поэтому после рендера станок сам делает набор проверок, пишет отчёт рядом с роликом и строку-вердикт в консоль.
Сборку проверка не блокирует, и так я сделал намеренно: ролик уже собран, и если проверка упала из-за непредвиденного (нет модели распознавания, не нашёлся нужный бинарник), терять из-за неё готовый файл глупо. Вызов обёрнут так, что любое исключение печатается, а сборка остаётся целой:
def proverka_gotovogo_posle_sborki(out: Path, work: Path, tz: dict, bez_rechi: bool) -> None: try: import proverka_gotovogo as PGV res = PGV.proverit(out, work / "proverka", bez_rechi, PGV.teksty_iz_tz(tz), tz.get("_pasport")) print(f"\n{res['verdikt']}") for p in res["preduprezhdeniya"]: print(f" ⚠️ {p}") print(f" отчёт: {res['otchet']}") except Exception as e: print(f"\n⚠️ проверка готового не удалась (сборка цела): {e}")
Числа-коридоры лежат в файле настроек. Если нужного ключа нет, функция падает с понятной ошибкой и запасное число втихую не подставляет:
def _koridor(pasport, klyuch: str) -> float: k = (pasport or PS.resolve(None)).proverka_gotovogo if klyuch not in k: raise KeyError(f"в паспорте нет «proverka_gotovogo.{klyuch}» — задай число там") return float(k[klyuch])
Сами коридоры такие: громкость от −14,5 до −13,5 LUFS (вокруг цели −14), потолок пика −1,0 dBTP, окно у краёв ролика 1,5 с, допустимая доля непрозвучавших слов 0,3 и порог похожести слова 0,8.
Громкость и пик
Измеряет фильтр ebur128, а разбирает его итоговый блок. Нужен именно блок Summary: в конце вывода, потому что выше ffmpeg печатает покадровые значения, и обычный поиск по тексту нашёл бы первое попавшееся:
def razobrat_ebur128(txt: str) -> dict: itog = txt[txt.rfind("Summary:"):] if "Summary:" in txt else txt return {"lufs": _iz(itog, r"I:\s+(-?[\d.]+) LUFS"), "lra": _iz(itog, r"LRA:\s+(-?[\d.]+) LU"), "pik": _iz(itog, r"Peak:\s+(-?[\d.]+) dBFS")} def zamer(rolik: Path) -> dict: r = subprocess.run([FFMPEG, "-nostats", "-i", str(rolik), "-af", "ebur128=peak=true", "-f", "null", "-"], capture_output=True, text=True) return razobrat_ebur128(r.stderr)
Вывод идёт в stderr, поэтому читаю r.stderr. Если блока нет, _iz бросает ValueError с текстом регулярки: пустой отчёт хуже явной ошибки. Хотя loudnorm на сборке уже целился в −14, проверка меряет готовый файл независимо от сборки: ей всё равно, как он получился.
Провалы тишины
Пауза в звуке длиннее двух секунд на слух читается как обрыв. Ищу её через silencedetect с порогом −40 дБ:
def provaly_tishiny(rolik: Path) -> list[tuple[float, float]]: r = subprocess.run( [FFMPEG, "-v", "info", "-i", str(rolik), "-af", f"silencedetect=noise={TISHINA_POROG_DB}dB:d={TISHINA_PREDEL}", "-f", "null", "-"], capture_output=True, text=True) nachala = [float(m) for m in re.findall(r"silence_start: (-?[\d.]+)", r.stderr)] dliny = [float(m) for m in re.findall(r"silence_duration: ([\d.]+)", r.stderr)] return list(zip(nachala, dliny))
Регулярка для silence_start допускает знак минус, на случай отрицательного значения. Каждая найденная пара превращается в строку отчёта вида «провал тишины с 3,4 с, 2,1 с».
Раскадровка и контактный лист
Станок берёт из готового ролика кадр раз в 1,5 секунды, уменьшает до 320 пикселей по ширине и склеивает все кадры в одну картинку-сетку:
def raskadrovka(rolik: Path, papka: Path, shag: float) -> list[Path]: papka.mkdir(parents=True, exist_ok=True) for staryy in papka.glob("*.png"): staryy.unlink() subprocess.run( [FFMPEG, "-y", "-v", "error", "-i", str(rolik), "-vf", f"fps=1/{shag},scale=320:-2", "-fps_mode", "vfr", str(papka / "%03d.png")], check=True) return sorted(papka.glob("*.png")) def kontaktnyy_list(kadry: list[Path], vyhod: Path) -> None: kolonok = min(6, max(1, len(kadry))) strok = (len(kadry) + kolonok - 1) // kolonok spisok = vyhod.parent / "spisok.txt" spisok.write_text("".join(f"file '{k.name}'\n" for k in kadry), encoding="utf-8") subprocess.run( [FFMPEG, "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", str(spisok), "-vf", f"tile={kolonok}x{strok}:padding=6:margin=6", "-frames:v", "1", str(vyhod)], check=True) spisok.unlink()
Ролик в 30 секунд превращается в 20 кадров, то есть в сетку 6 на 4. На такой лист я смотрю одним взглядом и вижу всё, что видит человек в ленте с выключенным звуком: не пропал ли титр, не наехала ли вставка на лицо, не осталась ли на полсекунды пустая серая заставка, одинаково ли выглядят соседние куски. Так выглядит просмотр «без звука», который я делаю перед каждой публикацией.
Для сетки я использую демультиплексор concat с файлом списка: tile принимает один поток, а кадров много. Файл spisok.txt функция кладёт рядом с выходом и ждёт в нём относительные имена, поэтому лист собирается внутри папки кадров, а потом копируется рядом с роликом.
Понятен ли ролик и любопытно ли его смотреть, станок судить не берётся: числом это не выразишь, а подделывать такое замером я не хочу. Отдельный режим просто готовит материал для трёх просмотров (без звука, на слух, целиком) и кладёт файл вердикта с тремя вопросами и прочерками вместо ответов. Пока прочерки не заменены словами, скрипт возвращает код 1, и по этому коду видно, что ролик показывать рано.
Сверка речи с субтитрами
Субтитры можно разметить неверно, и по самому файлу субтитров это не поймёшь. Поэтому станок распознаёт речь готового ролика и сверяет её с текстом реплик из задания. Распознавание делает faster-whisper, модель medium, на CPU с квантизацией int8, язык русский, с фильтром голосовой активности (vad_filter=True) и без condition_on_previous_text:
model = WhisperModel("medium", device="cpu", compute_type="int8") segments, info = model.transcribe( str(audio_path), language="ru", word_timestamps=True, vad_filter=True, condition_on_previous_text=False, suppress_tokens=[], )
VAD нужен потому, что на тишине Whisper любит выдумывать слова, особенно в самом хвосте. Для хвоста у меня ещё два фильтра: сегмент у самого конца отбрасывается, если средняя вероятность его слов ниже 0,5, и отдельно отбрасываются слова с вероятностью ниже 0,3.
Сама сверка устроена нарочно простой. Тексты реплик разбираются на слова: нижний регистр, «ё» заменяется на «е», пунктуация убирается, слова с цифрами выбрасываются, потому что «5» и «пять» для станка не одно и то же. Распознанные слова проходят ту же обработку. Дальше три проверки:
def _pohozhi(a: str, b: str, pohozhest: float) -> bool: return a == b or difflib.SequenceMatcher(None, a, b).ratio() >= pohozhest pervoe, poslednee = ozhid[0], ozhid[-1] if not any(_pohozhi(pervoe, t, pohozhest) and s < kraj_s for t, s, _ in uslyshano): res["problemy"].append( f"проглочено начало: «{pervoe}» не слышно в первые {kraj_s:g} с") if not any(_pohozhi(poslednee, t, pohozhest) and e > dlitelnost - kraj_s for t, _, e in uslyshano): res["problemy"].append( f"обрезан конец: «{poslednee}» не слышно в последние {kraj_s:g} с") net = [w for w in ozhid if not any(_pohozhi(w, t, pohozhest) for t, _, _ in uslyshano)] res["rashozhdeniya"] = net res["dolya"] = len(net) / len(ozhid)
Первое слово первой реплики обязано прозвучать в первые 1,5 секунды, последнее слово последней реплики обязано прозвучать в последние 1,5 секунды. Остальное сводится к доле слов, которые Whisper не услышал: если она больше 0,3, станок выводит первые пятнадцать из них. Слово считается услышанным, если среди распознанных есть похожее с коэффициентом SequenceMatcher не ниже 0,8. Без этого допуска «ролик» и «ролики» считались бы разными словами.
Функция sverit_rech ничего не знает про Whisper: ей приходит обычный список слов с отметками начала и конца. Благодаря этому все проверки сверки я пишу тестами без модели, на коротких списках слов. Тестов на неё и на остальную проверку готового сейчас 15, и они выполняются за полторы секунды. Например, тест «пропало первое слово» удаляет слово из списка и ожидает строку про проглоченное начало, а тест «цифры не сравниваются» проверяет, что «5» в тексте и «пять» в речи не дают расхождения.
Распознавание дорогое: на шестисекундном ролике вместе с загрузкой модели оно заняло около 15 секунд. Поэтому в режимах --draft и --preview речь по умолчанию не проверяется, а в обычной сборке её можно отключить флагом --bez-proverki-rechi. Это именно флаг, потому что решение «не сегодня» касается конкретного запуска, сам ролик тут ни при чём. Речь проверяется только если у ролика есть субтитры.
Итог проверки
Все предупреждения собираются в один список и пишутся в файл <ролик>.proverka.md рядом с готовым видео. Туда же ложится контактный лист <ролик>.kontakt.png. А в консоль выходит одна строка:
Проверка готового: <LUFS>, пик <dBTP>, LRA <LU> — всё в коридоре
Вместо скобок станок подставляет измеренные числа. При проблемах вместо «всё в коридоре» стоит «предупреждений: N», и ниже по строке на каждое.
На проверочном ролике я прогнал это так: сгенерировал синтезированной речью три секунды и положил на серый кадр. Громкость вышла −17,3 LUFS, то есть вне коридора, и станок это показал, а речь распознал без замечаний. Такой тест грубый, но он доказывает, что обе ветки (предупреждение и «всё в порядке») срабатывают.
Кадры до рендера и безопасные зоны
Раскадровка смотрит на ролик после сборки, но часть проблем можно поймать раньше и точнее, измерив пиксели. Вертикальное видео в приложении закрыто интерфейсом площадки: сверху шапка и полоса аудио, снизу подпись и кнопки, справа внизу колонка с иконками. Всё, что нарисовано там, пользователь не увидит, и титр, прекрасный в файле, в ленте окажется обрезан.
Поэтому есть отдельный замер: он берёт реальный прямоугольник, который занимают субтитры или вставка, и сравнивает его с безопасной зоной. Числа зоны я замерил на живых скриншотах приложения и храню в отдельном файле, в коде их не повторяю. Для субтитров замер устроен так: файл ASS накладывается на чёрный фон, получается видео, из него берутся кадры пятью в секунду, и на каждом кадре ищется всё, что засветилось ярче порога.
def bbox_ass(path, w, h): """Burn the subtitles onto black and measure what lit up.""" vid = tempfile.mktemp(suffix=".mp4") subprocess.run([ff, "-v", "error", "-y", "-f", "lavfi", "-i", f"color=c=black:s={w}x{h}:d=600:r=5", "-vf", f"ass={path}", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "18", "-t", "600", vid], check=True) frames = tempfile.mkdtemp() subprocess.run([ff, "-v", "error", "-y", "-i", vid, "-vf", "fps=5", f"{frames}/f_%05d.png"], check=True) box = None for f in sorted(glob.glob(f"{frames}/*.png")): m = np.asarray(Image.open(f).convert("L")) > 50 if not m.any(): continue ys, xs = np.nonzero(m) b = (int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())) box = b if box is None else (min(box[0], b[0]), min(box[1], b[1]), max(box[2], b[2]), max(box[3], b[3])) return box
Объединённая рамка по всем кадрам показывает самый широкий и самый низкий текст за весь ролик. Дальше функция judge сравнивает её с границами зоны и называет конкретные пиксели: «на столько-то пикселей ниже нижней границы, подпись приложения закроет». Есть и проверка правого нижнего угла, куда не должен заходить текст. А если самая широкая строка занимает почти весь кадр, замер советует разбить фразу на две вместо того, чтобы уменьшать кегль.
Для тестов самого станка я написал маленький набор функций замера пикселей на готовых роликах: вырезать кадр на нужной секунде, прочитать цвет точки, найти рамку области заданного цвета с допуском. Допуск по каждому каналу RGB стоит 24, потому что h264 слегка искажает цвет при сжатии, и точного совпадения с нарисованным hex ждать нельзя. Без такого инструмента каждый новый тест на готовый ролик писал бы собственный одноразовый замер.
Проверка до рендера: ритм и цвета
До нарезки станок прогоняет задание через «рубеж» и останавливает сборку, если нашёл брак, который потом уже не исправить дешевле, чем заранее. Сюда относится проверка сочетаний цветов: два ярких цвета из разных семей оттенков на заметной площади в течение заметного времени дают блокер. Порог разницы тона у меня 63°, я подобрал его калибровкой на двух точках. Вторая проверка не пускает графику за безопасную зону. А ритм задаётся режимом в самом задании: «плотный» подразумевает смену картинки каждые 2,1–2,9 секунды, «средний» (по умолчанию) 3,4–6,8, «разговорный» 9,8–18,6. Если на экране дольше верхней границы не происходит ничего, станок это называет.
Ограничение у этой проверки такое: цвет она знает только у того, что нарисовано самим станком или лежит с файлом разметки. Готовые картинки-мокапы без разметки она не видит и пишет в отчёте «не проверено», чтобы я не принял их за чистые. Замер по фактическим пикселям мокапов я пока не делал.
Что осталось неидеальным
Порог доли непрозвучавших слов, 0,3, пока стартовый, назначен без подбора. Его ещё надо откалибровать на живых роликах, и до этого сверка речи, скорее всего, то пропустит лишнее, то поднимет ложную тревогу.
Полную сборку настоящего ролика с проверкой в самом конце я на момент написания статьи не прогонял целиком: проверка в отдельности проверена на синтетическом ролике и юнит-тестами, а вся цепочка «сборка, затем проверка» на реальном проекте ещё впереди.
Контактный лист только готовит материал. Решать, понятен ли ролик и не рвётся ли речь, по-прежнему приходится мне. Станок не умеет судить, красиво ли получилось, и я не уверен, что стоит учить его этому.
Раскадровка берёт кадр раз в 1,5 секунды, а значит, мигание на один-два кадра она не увидит. Такое остаётся ловить глазами.
Сверка речи зависит от качества распознавания. Если в ролике много редких слов и терминов, доля расхождений может вырасти без реальной проблемы.
Наконец, весь станок написан под macOS и под полную сборку ffmpeg из Homebrew. Для Windows я добавил откат на PATH и отдельную инструкцию по установке, но шрифта, которым оформлены субтитры, там нет, и надписи отрисуются подстановкой.
Итог в цифрах
Слоёв сборки четыре (нарезка, картинка, звук, финальная копия), и правка слова в субтитре пересобирает только картинку и звук.
Реальная склейка ffmpeg длиннее расчётной на 0,08–0,46% (замер на 21 склейке), без поправки промах доходил до 0,26 с при допуске 0,15 с.
Целевая громкость −14 LUFS, потолок пика −1 dBTP, коридор проверки от −14,5 до −13,5 LUFS.
Контактный лист: кадр раз в 1,5 с, ширина 320 пикселей, не больше 6 колонок.
Сверка речи: Whisper
mediumна CPU, около 15 секунд на шестисекундный ролик, окно у краёв 1,5 с, порог похожести слова 0,8, порог расхождений 0,3.Тестов на проверку готового ролика 15, выполняются за 1,4 секунды.
Правка задания и пересборка теперь стоят секунды, а проверка, которая раньше требовала от меня внимания в самый уставший момент, происходит сама и оставляет рядом с роликом лист с кадрами и файл с предупреждениями.
olku
А можно весь код посмотреть? Однажды озадачился сборкой монтажного приложения, но забросил. Остался лишь инструмент прохода вертикального кропа слева направо или наоборот для нарезки панорамы с горизонтального видео. Могу поделиться, тоже питон и ффмпег