Slides Generator: рендер-сервис на Bun и Playwright

Есть задачи, которые кажутся простыми, пока не начнёшь их делать. «Генерировать презентации из документов» — звучит как один API-вызов. По факту — это цепочка: распарсить загруженные файлы, собрать промт, отдать Gemini, получить HTML, отрендерить в PNG, собрать PDF. И каждый шаг можно сделать плохо или хорошо. В slides-generator мы прошли через «плохо» и в итоге пришли к архитектуре, которая реально работает.
С чего всё начиналось
Первая версия slides-generator была монолитом. Next.js-приложение само генерировало HTML через Google Stitch API, само рендерило его в PNG через Puppeteer, само собирало PDF через pdf-lib. Ещё был экспорт в Google Slides — с OAuth, Google Drive API и парсингом HTML через Claude Haiku для конвертации в нативные объекты презентации.
Звучит как feature-complete продукт. На практике — это был кошмар:
- Puppeteer внутри Next.js постоянно конфликтовал с деплоем через Dokploy. Chromium требовал особых флагов, памяти, системных зависимостей — всё это плохо совмещалось с контейнером Next.js
- Stitch API — это MCP-обёртка над Gemini, которая была нестабильна и давала мало контроля над промтами
- Google Slides интеграция требовала поддерживать OAuth flow, несколько Google API-клиентов и claude.ts только для парсинга HTML в JSON-структуру
- Масштабирование было невозможным: всё в одном процессе, всё или ничего
При этом рядом уже был живой пример правильной архитектуры — kp-renderer, отдельный Bun-сервис для рендера коммерческих предложений. Он работал через Playwright, принимал HTML по HTTP и отдавал PNG/PDF. Просто и надёжно.
Задача стала очевидной: перенести этот подход в slides-generator.
Новая архитектура: разделение ответственности
Ключевое решение — разбить монолит на два независимых сервиса:
┌──────────────────────────────────────────────┐
│ slides-generator (Next.js, Dokploy) │
│ │
│ - UI: создание, выбор шаблона, загрузка │
│ - /api/generate → Gemini API → HTML │
│ - /api/export-pdf → HTTP → slide-renderer │
│ - /admin → управление промт-шаблонами │
└──────────────────────┬───────────────────────┘
│ HTTP POST /render
┌──────────────────────▼───────────────────────┐
│ slide-renderer (Bun, отдельный VPS) │
│ │
│ - Playwright → PNG для каждого слайда │
│ - pdf-lib → сборка PDF │
│ - /health, /render, /files/:id/* │
└──────────────────────────────────────────────┘
slides-generator занимается бизнес-логикой: принимает документы, собирает промты, вызывает Gemini, управляет шаблонами.
slide-renderer — чистый рендер-сервис: принимает массив HTML-строк, возвращает PNG и PDF. Никакой бизнес-логики, только рендер.
slide-renderer: Bun-сервис с Playwright
Архитектура сервиса намеренно минималистичная — как у kp-renderer. Bun как рантайм (быстрый старт, встроенный HTTP-сервер), Playwright вместо Puppeteer (стабильнее в headless, лучше обрабатывает современный CSS).
Основной эндпоинт:
// slide-renderer/src/routes/render.ts
export async function handleRender(req: Request): Promise<Response> {
const { slides, format = 'both' } = await req.json() as RenderRequest
const id = crypto.randomUUID().slice(0, 8)
const outputDir = path.join(FILES_DIR, id)
await mkdir(outputDir, { recursive: true })
const browser = await chromium.launch({ args: ['--no-sandbox'] })
const pngPaths: string[] = []
for (let i = 0; i < slides.length; i++) {
const page = await browser.newPage()
await page.setViewportSize({ width: 1280, height: 720 })
await page.setContent(slides[i], { waitUntil: 'networkidle' })
const pngPath = path.join(outputDir, `slide-${i + 1}.png`)
await page.screenshot({ path: pngPath, fullPage: false })
pngPaths.push(pngPath)
await page.close()
}
await browser.close()
let pdfUrl: string | undefined
if (format === 'pdf' || format === 'both') {
pdfUrl = await buildPdf(pngPaths, outputDir, id)
}
return Response.json({
id,
pdfUrl: pdfUrl ? `/files/${id}/presentation.pdf` : undefined,
slides: pngPaths.map((_, idx) => ({
index: idx + 1,
pngUrl: `/files/${id}/slide-${idx + 1}.png`
}))
})
}Каждый слайд — отдельная страница браузера. waitUntil: 'networkidle' гарантирует, что шрифты и стили загружены перед скриншотом. Результаты пишутся в /tmp/slide-renderer-files/{id}/ и отдаются через статический файловый сервер.
Аутентификация простая — Bearer token в заголовке, проверяется мидлварой до роутинга.
Хранение шаблонов в Supabase
Для промт-шаблонов и HTML-шаблонов слайдов выбрали Supabase — не файловую систему. Логика такая: шаблоны редактируются через UI админки, значит это контент, а не конфиг. Хранить в БД правильнее: версионирование, CRUD через API, один источник правды.
-- Шаблоны промтов для Gemini
CREATE TABLE prompt_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
type text NOT NULL CHECK (type IN ('base', 'custom')),
parent_id uuid REFERENCES prompt_templates(id),
prompt_text text NOT NULL,
variables jsonb NOT NULL DEFAULT '[]',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- HTML-шаблоны слайдов
CREATE TABLE slide_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
html_content text NOT NULL,
preview_url text,
tags text[] NOT NULL DEFAULT '{}',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Расширение таблицы презентаций
ALTER TABLE presentations
ADD COLUMN IF NOT EXISTS prompt_template_id uuid REFERENCES prompt_templates(id),
ADD COLUMN IF NOT EXISTS slide_template_id uuid REFERENCES slide_templates(id),
ADD COLUMN IF NOT EXISTS render_job_id text;Интеграция с Gemini API
Stitch API убрали, подключили Gemini напрямую через официальный SDK. Это дало полный контроль над промтами — важно, потому что Gemini 2.5 Pro нестандартно реагирует на слишком жёсткие инструкции по стилям и шрифтам: начинает их игнорировать или интерпретировать буквально.
// lib/gemini.ts
import { GoogleGenerativeAI } from '@google/generative-ai'
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!)
export async function generateSlidesHtml(params: {
promptTemplate: string
variables: Record<string, string>
content: string
}): Promise<string[]> {
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-pro',
generationConfig: {
temperature: 0.7,
maxOutputTokens: 32768,
}
})
// Подставляем переменные в шаблон
let prompt = params.promptTemplate
for (const [key, value] of Object.entries(params.variables)) {
prompt = prompt.replaceAll(`{{${key}}}`, value)
}
prompt = prompt.replaceAll('{{content}}', params.content)
const result = await model.generateContent(prompt)
const text = result.response.text()
// Парсим HTML слайды из ответа
return parseSlideHtml(text)
}Отдельная функция parseSlideHtml извлекает массив HTML-документов из ответа модели. Gemini возвращает слайды как отдельные <html>...</html> блоки или разделённые маркерами — парсер обрабатывает оба формата.
Клиент к slide-renderer
Вместо прямых вызовов Playwright из Next.js — HTTP-клиент:
// lib/slide-renderer-client.ts
export async function renderSlides(slides: string[], format: 'png' | 'pdf' | 'both' = 'both') {
const res = await fetch(`${process.env.SLIDE_RENDERER_URL}/render`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.SLIDE_RENDERER_TOKEN}`
},
body: JSON.stringify({ slides, format })
})
if (!res.ok) {
const err = await res.text()
throw new Error(`slide-renderer error ${res.status}: ${err}`)
}
return res.json() as Promise<RenderResult>
}Просто и прозрачно. Next.js ничего не знает про Playwright — только HTTP.
Что удалили
Рефакторинг был атомарным — нельзя было удалять файлы постепенно, потому что всё было связано. Убрали за один коммит:
lib/stitch.ts— Stitch API клиентlib/puppeteer.ts— Puppeteer рендер внутри Next.jslib/html-to-slides.ts— парсинг HTML → Google Slides requestslib/google-slides.ts,lib/google-slides-edit.ts— Google Slides APIlib/google-auth.ts— Google OAuthlib/google-drive.ts— Google Drive API- API routes для Google OAuth callback
В package.json убрали зависимости: puppeteer, @google-cloud/local-auth, googleapis. Это существенно уменьшило размер Docker-образа и убрало несколько источников нестабильности.
Деплой slide-renderer
Сервис деплоится не через Dokploy, а как systemd-процесс или через nohup — аналогично kp-renderer. Это намеренное решение: Playwright требует системные зависимости (Chromium), которые проще поставить один раз на VPS, чем каждый раз собирать в Docker-образе.
Проверка работоспособности:
curl http://localhost:8202/health
# {"status":"ok","version":"1.0.0"}Slides-generator в env получает SLIDE_RENDERER_URL и SLIDE_RENDERER_TOKEN — и не знает, где физически находится сервис.
Результат
Рефакторинг занял порядка 10 задач по плану, но в итоге система стала значительно проще в каждой своей части:
До:
- Один монолит с 7+ внешними зависимостями (Puppeteer, Google APIs, Stitch)
- Нестабильный рендер внутри Next.js процесса
- OAuth flow для Google, который нужно было поддерживать
- Непредсказуемое поведение Gemini через Stitch-обёртку
После:
- slides-generator занимается только бизнес-логикой и UI
- slide-renderer — изолированный сервис, который легко перезапустить независимо
- Gemini вызывается напрямую, промты настраиваются через админку
- Шаблоны хранятся в Supabase, редактируются без деплоя
Каждый сервис можно отлаживать, перезапускать и масштабировать независимо. Если Playwright падает — slides-generator об этом узнает через HTTP 500, а не через краш всего процесса.
Уроки, которые стоит запомнить
Первый урок — не бойся выносить рендер в отдельный сервис. Соблазн держать всё в одном процессе велик: проще деплоить, меньше сетевых вызовов. Но Playwright и Chromium — это тяжёлые системные зависимости, которые плохо живут внутри Node.js/Next.js процесса. Как только у тебя есть рабочий пример правильной архитектуры (в нашем случае kp-renderer), нужно не изобретать велосипед, а переиспользовать подход.
Второй урок — промты для Gemini не должны быть захардкожены в коде. Это контент, который нужно редактировать без деплоя. Таблица prompt_templates в Supabase с переменными-плейсхолдерами — правильное решение. Особенно важно при работе с Gemini 2.5 Pro: модель чувствительна к формулировкам, и часто нужно экспериментировать с промтом итеративно.
Третий урок — атомарный рефакторинг лучше постепенного, когда удаляешь целый слой архитектуры. Если удалять Google Slides API по частям, между коммитами будет неработающее состояние: часть кода импортирует удалённые модули, часть ещё нет. Один большой коммит refactor: replace Stitch+Puppeteer+Google Slides with Claude+Gemini pipeline — честнее и безопаснее.
Четвёртый урок — spec → plan → implementation — это не оверинжиниринг, а экономия времени. Дизайн-спека выявила вопросы про хранение шаблонов, аутентификацию между сервисами, формат ответа рендера — до того, как был написан хоть один байт кода. Это дешевле, чем рефакторить готовый код. Ревью плана нашло unbuildable state между задачами — и это тоже было исправлено до реализации, а не во время.
Пятый урок — разделяй рендер и генерацию. Казалось бы, очевидно, но в первой версии они были перемешаны: Gemini генерировал HTML, Puppeteer рендерил его, pdf-lib собирал PDF — всё в одной API route. Теперь /api/generate отвечает только за HTML, /api/export-pdf только за вызов рендер-сервиса. Каждый маршрут делает одно дело, его легко тестировать и отлаживать изолированно.
Технологии в проекте
- Bun — рантайм для slide-renderer, быстрый старт и встроенный HTTP-сервер
- Playwright — headless Chromium для рендера HTML в PNG
- Google Generative AI SDK — прямая интеграция с Gemini 2.5 Pro
- Supabase — хранение шаблонов и метаданных презентаций
- pdf-lib — сборка PDF из массива PNG

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.
Связанный проект
Slides Generator →