Есть презентация и текст лекции. Нужен MP4, где каждый слайд висит на экране ровно столько, сколько звучит его озвучка. Звучит как «склеить картинки со звуком через FFmpeg», и примерно за час можно собрать работающий прототип.
Проблема начинается дальше: прототип на сорокаслайдовой колоде работает минуты, занимает одно ядро из четырёх, при падении воркера начинает всё заново, а на кириллице periodically выдаёт кракозябры вместо текста. Ниже — разбор того, во что этот час превращается на дистанции: где узкие места, какие решения пришлось принять и чем за каждое заплачено.
Контекст, чтобы дальше было понятно про ограничения: это ядро Edllm — сервиса, который собирает видеоуроки из презентаций. Всё описанное крутится на 4 vCPU / 8 GB без GPU.
Ограничения, из которых растут все решения
4 vCPU, 8 GB, без GPU. Не «взяли A100 и всё летает», а обычный VPS. Это сразу убивает варианты с локальным рендерингом видео тяжёлыми средствами и заставляет считать каждое ядро.
Русский язык. Кириллица в шрифтах презентации, кириллица в TTS, числа и сокращения, которые надо проговаривать словами, а не по цифрам.
Задача идёт минуты, не секунды. Значит: нужен честный прогресс, устойчивость к рестарту воркера и отмена, после которой не списываются деньги за несделанную работу.
Воспроизводимость. Одна и та же презентация не должна рендериться дважды.
Стек: Python 3.13, FastAPI (async), Celery (prefork, синхронный), PostgreSQL 17, Redis, всё в Docker Compose.
Общая схема
PPTX + текст лекции │ ├─ 1. LibreOffice → PDF → pdftoppm → slide-1.png … slide-N.png [кеш: md5+DPI] │ ├─ 2. vision-LLM: описание каждого слайда [кеш: sha256+модель] │ ├─ 3. text-LLM: режет текст ровно на N кусков, оборачивает в SSML │ ├─ 4. TTS(k) ──► FFmpeg encode(k) ← два пула, работают внахлёст │ └─ 5. ffmpeg -f concat -c copy → lesson.mp4
Этап | Чем | Природа нагрузки | Кеш |
|---|---|---|---|
Рендер слайдов | LibreOffice + pdftoppm | процессы, диск |
|
Описание слайдов | vision-LLM | сеть |
|
Нарезка + SSML | text-LLM | сеть | — |
Озвучка | TTS по HTTP | I/O-bound | по хешу текста |
Кодирование | FFmpeg (libx264) | CPU-bound | — |
Склейка | FFmpeg concat | диск | — |
Ключевое наблюдение, к которому мы вернёмся в четвёртом разделе: соседние стадии упираются в разные ресурсы. Озвучка ждёт сеть, кодирование жжёт процессор. Если выстроить их «стенка к стенке», половину времени простаивает то одно, то другое.
Этап 1. PPTX → PNG, и почему это каскад из двух процессов
Первое желание — отрендерить слайды на Python. Не выйдет: python-pptx парсит XML, но не рисует. Своя отрисовка через Pillow — это переписывание рендерера PowerPoint вместе со шрифтами, автофигурами и SmartArt.
Второе желание — попросить LibreOffice отдать PNG напрямую:
libreoffice --headless --convert-to png deck.pptx # так не надо
На практике это отдаёт один PNG на всю презентацию, а не по кадру на слайд. Поэтому каскад:
# backend/app/services/video_service.py _run([ "libreoffice", "--headless", f"-env:UserInstallation=file://{lo_user_dir}", "--convert-to", "pdf", "--outdir", pdf_dir, pptx_path, ]) _run([ "pdftoppm", "-png", "-r", str(SLIDE_DPI), "-aa", "yes", "-aaVector", "yes", pdf_path, os.path.join(output_dir, "slide"), ])
pdftoppm из poppler даёт предсказуемый масштаб через -r и приличный антиалиасинг. Альтернативы (pdf2image, PyMuPDF) тянут в образ ещё один нативный бинарь, а poppler там уже есть.
DPI = 150, а не 300. На 1080p разница с 300 не видна, а PNG получается вчетверо легче — это ускоряет и pdftoppm, и последующее кодирование. Константа лежит в одном месте с остальными тюнингами:
# backend/app/constants.py SLIDE_DPI = 150 # на 1080p неотличимо от 300 DPI, но PNG в ~4 раза меньше
Если на вход пришёл PDF — LibreOffice надо пропустить. Прогон PDF через LO «ради единообразия пайплайна» корёжит встроенные шрифты, особенно кириллические. PDF идёт прямо в pdftoppm, и кеш слайдов для него тоже пропускается.
Сверху — дисковый кеш по хешу содержимого:
def _pptx_cache_key(pptx_path: str) -> str: """Хеш содержимого + DPI → стабильный ключ для кеша PNG-слайдов.""" h = hashlib.md5() with open(pptx_path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return f"{h.hexdigest()}_dpi{SLIDE_DPI}"
Ключ именно по содержимому, а не по id урока (потеряли бы переиспользование между уроками) и не по mtime (меняется от простого копирования). Смена SLIDE_DPI инвалидирует кеш автоматически — это ровно то поведение, которое хочется. Попадание в кеш пропускает обе стадии целиком, а LibreOffice — самый дорогой шаг пайплайна.
Цена решения: кеш растёт без TTL. Разгребается отдельной периодической задачей. Если бы делал заново — закладывал бы срок жизни сразу.
Этап 2. Vision вместо OCR — и два разных флоу
Когда текста лекции нет, его надо откуда-то взять. Первая мысль — Tesseract: распознали текст со слайда, отдали в TTS. Не работает.
Слайд «Преимущества микросервисов: масштабируемость, изоляция, независимый деплой» после OCR превращается ровно в эту строку. Озвучка буллетов — это не лекция, это чтение оглавления вслух. Плюс OCR ничего не скажет про схему, график и стрелочки, а на них половина смысла.
Поэтому на слайд смотрит vision-модель. Важная деталь: два vision-флоу в проекте устроены по-разному, и это не случайность.
Ручной режим (есть текст лекции, нужно разложить по слайдам) — параллельно,
asyncio.Semaphore(4). Нужна короткая характеристика слайда, чтобы следующим шагом LLM поняла, какой кусок текста к какому слайду прилепить. Связность между слайдами тут не нужна, независимость даёт скорость.Авто-режим (текста нет, модель пишет сама) — последовательно. В промпт каждому слайду подкладывается контекст трёх предыдущих. Без этого на выходе N несвязанных абзацев вместо лекции, где можно сказать «как мы видели на прошлом слайде».
Кеш здесь по sha256(png) + провайдер + модель. Смена модели инвалидирует кеш сама — тоже желаемое поведение.
Этап 3. Контракт с LLM, который надо проверять по счёту
Вход: сплошной текст лекции и N описаний слайдов. Выход: ровно N кусков, каждый — валидный SSML.
Помимо нарезки промпт делает ещё три вещи: выкидывает мета-токены, которые преподаватели пишут в конспектах («Слайд 3:», «(показать схему)»), превращает числа и сокращения в слова (иначе TTS читает «2025» как «два ноль два пять») и расставляет паузы.
Ответ ожидается строго JSON:
{"chunks": ["<p>...</p>", "<p>...</p>", "..."]}
И вот главная мысль этого раздела: контракт с моделью надо валидировать по числу элементов, а не по «выглядит нормально». Модель регулярно возвращает N−1 или N+1 чанк, особенно на длинных колодах. Если это проглотить, поедет вся синхронизация: с седьмого слайда озвучка будет рассказывать про восьмой, и заметит это только зритель.
Проверка тривиальная: распарсился JSON и len(chunks) == total_slides — идём дальше. Иначе — детерминированный фолбэк: нарезка исходного текста по предложениям пропорционально числу слайдов. Хуже по качеству, но урок не разъезжается, и пользователь получает результат, а не 500.
Этап 4. Главное: озвучка и кодирование внахлёст
Наивная схема:
Последовательно (TTS-всё → encode-всё): TTS: [s0][s1][s2][s3] ENC: [s0][s1][s2][s3] └── FFmpeg простаивал всю фазу TTS ──┘
Между тем зависимость здесь только локальная: чтобы закодировать слайд k, нужен WAV слайда k — и больше ничего. Значит, кодирование можно начинать сразу, как приехала озвучка конкретного слайда:
Стримингом (as_completed): TTS: [s0][s1][s2][s3] ENC: [s0][s1][s2][s3] └── encode s0 стартует, как только готов его WAV ──┘
Реализация — два ThreadPoolExecutor, открытых в одном with, соединённых через as_completed:
# backend/app/tasks/video_pipeline.py with ( ThreadPoolExecutor(max_workers=_TTS_WORKERS, thread_name_prefix="tts") as tts_pool, ThreadPoolExecutor(max_workers=_ENCODE_WORKERS, thread_name_prefix="enc") as enc_pool, ): slides_needing_tts = [i for i in range(total_slides) if i not in cp_segments_done] tts_futures = {tts_pool.submit(_do_tts, i): i for i in slides_needing_tts} enc_futures: dict = {} # Чейнинг: каждый завершённый TTS немедленно порождает задачу кодирования. for tts_future in as_completed(tts_futures): idx, audio_path = tts_future.result() enc_future = enc_pool.submit( video_service.encode_segment, idx, image_paths[idx], audio_path, seg_work_dir, ) enc_futures[enc_future] = idx _checkpoint("tts", ...) # Сбор результатов кодирования (enc_pool ещё жив внутри того же `with`). for enc_future in as_completed(enc_futures): idx = enc_futures[enc_future] segment_paths[idx] = enc_future.result() _checkpoint("encoding", ...)
Ключевое здесь — что оба цикла as_completed стоят внутри одного with. Пока первый сабмитит задачи кодирования по мере готовности WAV-ов, enc_pool уже их выполняет. Слайд 0 кодируется, пока озвучиваются 1–4.
Почему два разных пула, а не один общий: стадии упираются в разные ресурсы. TTS-поток ждёт сеть — их можно держать побольше. Кодирование жрёт CPU — их должно быть меньше, чтобы не задушить машину:
# backend/app/constants.py TTS_WORKERS = 4 # совпадает с числом потоков контейнера TTS ENCODE_WORKERS = 3 # параллельные процессы FFmpeg; оставляет запас под LibreOffice
TTS_WORKERS=4 не с потолка: это ровно то, что умеет наш TTS-контейнер. Для облачного провайдера значение другое — размер пула выбирается по провайдеру, а сам синтез спрятан за единым интерфейсом, так что пайплайну всё равно, кто озвучивает.
Почему потоки, а не asyncio: Celery-таска здесь строго синхронная (почему — в разделе про грабли), а обе операции блокирующие — HTTP-запрос к TTS и subprocess с FFmpeg. Потоки ровно к месту, GIL не мешает.
Честно про цену: as_completed внутри цикла, наполняющего второй as_completed, читается тяжелее последовательной версии. Это сознательный размен читаемости на латентность — по нашим замерам сборка ускоряется [[ЗАМЕР_ПОСЛЕДОВАТЕЛЬНО]] → [[ЗАМЕР_СТРИМИНГ]], примерно вдвое. Чтобы при рефакторинге никто не «упростил» это обратно, рядом с кодом лежит комментарий с объяснением, зачем так.
Внутри TTS-джобы
text = strip_ssml_tags(chunk) parts = split_for_tts(text, max_chars=SILERO_MAX_CHARS) wavs = [http_synthesize(p, voice) for p in parts] wav = concat_wav(wavs)
Нарезка на 800 символов — не эстетика, а необходимость: на очень длинном входе движок начинает отдавать обрезанный результат. Режем по предложениям, если не влезает — по запятым. Куски склеиваются стандартным модулем wave: параметры у всех одинаковые, склейка честно побайтовая.
Дальше — обрезка хвостовой тишины, иначе на стыке слайдов слышен провал:
ffmpeg -y -i in.wav \ -af silenceremove=stop_periods=-1:stop_duration=0.15:stop_threshold=-40dB \ out.wav
С предохранителем: если после обрезки осталось меньше 0.1 с — берём оригинал. Иначе один слайд с тихой озвучкой превращается в пустой кадр.
Внутри кодирования
Длительность берётся у ffprobe по готовому WAV, дальше — статичный кадр плюс аудио:
ffmpeg -y -loop 1 -t "$DURATION" -i slide-7.png -i slide-7.wav \ -c:v libx264 -tune stillimage -preset fast -pix_fmt yuv420p -r 25 \ -c:a aac -b:a 192k -ar 48000 -shortest \ _seg_0007.mkv
-tune stillimage здесь принципиален: картинка неподвижная, и тюнинг под неё даёт заметно меньший битрейт при том же качестве. Параметры заданы жёстко — 25 fps, yuv420p, AAC 48 кГц — и это не «взяли что-то разумное», а условие следующего этапа.
Этап 5. Склейка без перекодирования
ffmpeg -y -f concat -safe 0 -i segments.txt -c copy lesson.mp4
-c copy — байтовое склеивание без повторного кодирования. Секунда вместо минут, которые ушли бы на filter_complex с concat-фильтром.
Ровно за это мы и платим жёсткими параметрами сегментов из предыдущего раздела: у всех кусков должны совпадать кодек, контейнер, частота кадров и параметры звука. Стоит одному сегменту уехать — например, если захочется вставить между слайдами видеофрагмент — и concat -c copy перестанет работать. Пока такой фичи нет, размен выгодный.
Промежуточные сегменты — .mkv, а не .mp4: контейнер терпимее к дописыванию и не требует финализации moov-атома.
Устойчивость: чекпоинты, отмена, деньги
Минутная задача падает — воркер перезапустили, провайдер ответил 500. Переозвучивать 39 готовых слайдов из-за сорокового не хочется, поэтому по границам слайдов пишутся чекпоинты в Redis. Слайд, у которого уже есть готовый сегмент, при повторном прогоне пропускает TTS целиком — это видно прямо в формировании списка задач:
slides_needing_tts = [i for i in range(total_slides) if i not in cp_segments_done]
Отмена — кооперативная. Никакого SIGKILL: пользователь жмёт «Отменить», в состояние ставится флаг, пайплайн проверяет его на границах, а пулы гасятся через shutdown(wait=False, cancel_futures=True) — то, что в очереди, выбрасывается, то, что уже в работе, доигрывает. Убийство процесса дало бы недописанные файлы и зависший статус.
Дальше — деньги. Кредиты не списываются по факту запуска: сначала резерв, потом по результату — списание или возврат. Задача, упавшая на середине, не должна ни увести баланс в минус, ни списаться дважды. Это тот случай, когда «сделать проще» означает «однажды объясняться с пользователем, почему у него пропали деньги».
И finally: rmtree(work_dir) — рабочая директория с PNG и WAV живёт ровно на время задачи. На восьми гигабайтах это не паранойя.
Грабли, на которых постояли
1. AsyncSession внутри Celery-таски — тихий дедлок. API полностью асинхронный на asyncpg, и очень хочется переиспользовать те же сессии в тасках. Нельзя: prefork-воркер не даёт greenlet-контекст, которого ждёт async-драйвер, и всё встаёт намертво — без ошибки, просто висит. В app/tasks/* живут только синхронные сессии на psycopg2, а URL для них выводится из той же единственной настройки:
_sync_url = settings.DATABASE_URL.replace("+asyncpg", "+psycopg2")
Диагностика этого стоила двух дней именно потому, что не падает.
2. MissingGreenlet прямо в сериализации ответа. Поле updated_at с onupdate=func.now() после коммита помечено как expired: реальное значение знает только БД. Когда Pydantic читает его при сериализации, SQLAlchemy идёт за ним в базу синхронно — и на async-сессии это падает. Лечится одной строкой в маппере, но её надо знать:
__mapper_args__ = {"eager_defaults": True}
3. Потоки пула не трогают сессию главной задачи. Каждый TTS-поток открывает собственную SyncSession, а контекст для биллинга переустанавливается в потоке заново: ContextVar не переходят через границу потока. Это ровно тот баг, который не воспроизводится в тестах на одном слайде.
4. Приоритеты на Redis инвертированы. Меньшее число — больший приоритет, 0 разгребается первым. Обратно RabbitMQ. И три настройки брокера, без которых priority= молча игнорируется: priority_steps, queue_order_strategy="priority" и worker_prefetch_multiplier=1. Последняя не косметическая — с префетчем больше единицы воркер выхватывает низкоприоритетную задачу раньше, чем приходит высокоприоритетная, и приоритеты перестают что-либо значить. Всё «работает», просто не так, как задумано.
5. Кеши растут без сборки мусора. slides_cache/ и tts_cache/ ключуются по хешу и безопасны к удалению, но сами не чистятся. Разгребается периодической задачей.
6. TTS капризен к смешанным глифам. На смеси CJK и кириллицы движок отвечает «Invalid XML format» и HTTP 500. Облачная альтернатива отдаёт mp3, который приходится транскодировать в 48 кГц mono WAV — чтобы совпасть с частотой основного движка и не заставлять FFmpeg ресемплить на каждом сегменте.
Что из этого стоит забрать
Если убрать специфику видео, остаются три довольно общих вещи.
Конвейер из стадий разной природы не надо выстраивать стенка к стенке. as_completed — копеечный по коду способ заставить дорогую стадию стартовать тогда, когда готов первый вход, а не когда закончилась вся предыдущая фаза.
Контракт с LLM надо проверять машинно. Не «ответ выглядит разумно», а len(chunks) == total_slides и детерминированный фолбэк на случай расхождения. Модель ошибается в счёте регулярно, и на длинных входах чаще.
Самые дорогие баги — молчаливые. Дедлок без ошибки, приоритеты, которые «как будто работают», кеш, который растёт до конца диска. Ни один из них не падает в CI. Поэтому границы, которые нельзя нарушать, у нас закреплены не договорённостью на ревью, а докстрингом рядом с кодом и тестом, который валит сборку.
Отдельно готов разобрать соседний узел: четыре очереди Celery, один beat на кластер и почему приоритизация по тарифу ломается тремя разными способами.

