Есть презентация и текст лекции. Нужен 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

процессы, диск

md5(pptx)+DPI

Описание слайдов

vision-LLM

сеть

sha256(png)+модель

Нарезка + 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 на кластер и почему приоритизация по тарифу ломается тремя разными способами.