Slides Generator: Bun-рендер вместо Puppeteer

Когда я впервые запустил slides-generator, там всё было в одной куче: Puppeteer крутился прямо внутри Next.js, Stitch API общался с Gemini через MCP-обёртку, а Google Slides интеграция тащила за собой OAuth, Drive API и отдельный клиент под Claude Haiku, который парсил HTML в объекты для Google. Работало — но ненадёжно. И после того, как я сделал kp-renderer — отдельный Bun-сервис для рендеринга PDF коммерческих предложений — стало очевидно: нужно переделывать по той же схеме.
В этой статье расскажу, как мы разобрали монолит на части, зачем вынесли рендер в отдельный процесс и что получилось в итоге.
Что было не так с первоначальной архитектурой
Исходный slides-generator был типичным Next.js приложением, которое делало слишком много всего за раз. lib/puppeteer.ts запускал браузер прямо в процессе Next.js — а это значит, что каждый запрос на рендер слайдов создавал тяжёлый Chromium-процесс внутри того же контейнера, где работал UI и API. При нескольких одновременных запросах это приводило к нехватке памяти.
lib/stitch.ts обращался к Gemini через Stitch API — MCP-обёртку, которая нестабильна по своей природе: она завязана на конкретную версию протокола, и любое обновление могло сломать интеграцию. Плюс Stitch плохо работал с кастомными промтами — сложно было объяснить ему, чтобы он не навязывал жёсткие стили шрифтов и цветов, а оставлял это на усмотрение шаблона.
Google Slides интеграция (lib/google-slides.ts, lib/google-auth.ts, lib/google-drive.ts, lib/html-to-slides.ts) добавляла ещё один слой сложности: Claude Haiku парсил сгенерированный HTML обратно в объекты Google Slides API. Это дорого по токенам и хрупко — любое изменение HTML структуры ломало парсинг.
В итоге список проблем выглядел так:
- Puppeteer в Next.js = нестабильный рендер, memory leaks
- Stitch API = нестабильная MCP-обёртка над Gemini
- Google Slides = лишняя сложность, дорогой парсинг через Claude Haiku
- Нет библиотеки HTML-шаблонов — каждая презентация генерировалась с нуля
- Нет adminки для управления промтами
Референс: как работает kp-renderer
Прежде чем проектировать новую архитектуру, я посмотрел на то, что уже работает в production. kp-renderer — отдельный Bun-сервис для рендеринга коммерческих предложений. Он запускается через bun run index.ts, управляется через kill + nohup, слушает на выделенном порту.
Архитектура простая: принимает POST-запрос с данными, рендерит HTML-шаблон через Playwright, отдаёт PNG или PDF. Имеет /health эндпоинт — {"status": "ok"} — чтобы можно было проверить, что процесс жив.
Эта схема оказалась надёжной в продакшне: браузер изолирован в отдельном процессе, Next.js не знает про Chromium, масштабировать можно независимо. Именно это и нужно было повторить для слайдов.
Новая архитектура: три независимых слоя
После брейнсторминга и написания спеки архитектура выглядит так:
┌─────────────────────────────────────────────┐
│ slides-generator (Next.js, Dokploy) │
│ │
│ UI: создание, выбор шаблона, загрузка docs │
│ /api/generate → Claude анализирует docs │
│ → Gemini 2.5 Pro → HTML │
│ /api/export-pdf → slide-renderer │
│ /admin → промт-шаблоны, HTML-шаблоны│
└─────────────────────┬───────────────────────┘
│ HTTP POST /render
┌─────────────────────▼───────────────────────┐
│ slide-renderer (Bun, отдельный VPS-порт) │
│ │
│ GET /health │
│ POST /render → Playwright → PNG + PDF │
└─────────────────────────────────────────────┘
│
┌─────────────────────▼───────────────────────┐
│ Supabase │
│ presentations, prompt_templates, │
│ slide_templates │
└─────────────────────────────────────────────┘
Gemini 2.5 Pro напрямую через официальный SDK — без Stitch, без MCP. Google Slides — убираем полностью.
Что и как переделали
1. slide-renderer — новый Bun-сервис
По аналогии с kp-renderer создали отдельный сервис на Bun. Минимальный HTTP-сервер, два эндпоинта:
// slide-renderer/index.ts
const server = Bun.serve({
port: Number(process.env.PORT ?? 8202),
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === '/health') {
return Response.json({ status: 'ok' });
}
if (url.pathname === '/render' && req.method === 'POST') {
// Auth check
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (token !== process.env.SLIDE_RENDERER_TOKEN) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const { slides, format = 'both' } = await req.json();
const result = await renderSlides(slides, format);
return Response.json(result);
}
return Response.json({ error: 'Not found' }, { status: 404 });
},
});Playwright запускается один раз при старте сервиса, браузер переиспользуется между запросами:
// slide-renderer/renderer.ts
import { chromium, type Browser } from 'playwright';
let browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (!browser || !browser.isConnected()) {
browser = await chromium.launch({ headless: true });
}
return browser;
}
export async function renderSlides(
slides: string[],
format: 'png' | 'pdf' | 'both'
) {
const b = await getBrowser();
const pngs: Buffer[] = [];
for (const html of slides) {
const page = await b.newPage();
await page.setViewportSize({ width: 1280, height: 720 });
await page.setContent(html, { waitUntil: 'networkidle' });
const png = await page.screenshot({ type: 'png', fullPage: false });
pngs.push(png);
await page.close();
}
// Собираем PDF из PNG через pdf-lib
const pdf = format !== 'png' ? await buildPdf(pngs) : null;
return { pngs: pngs.map(b => b.toString('base64')), pdf: pdf?.toString('base64') };
}2. Supabase: новые таблицы
Добавили две таблицы — prompt_templates и slide_templates. Первая хранит промты для Gemini с переменными ({{slide_count}}, {{content}}, {{style_notes}}), вторая — HTML-шаблоны слайдов.
CREATE TABLE prompt_templates (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
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 DEFAULT '[]',
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE TABLE slide_templates (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL,
description text,
html_content text NOT NULL,
thumbnail_url text,
tags text[] DEFAULT '{}',
created_at timestamptz DEFAULT now()
);В presentations добавили prompt_template_id и slide_template_id — ссылки на использованные шаблоны.
3. Gemini напрямую вместо Stitch
Заменили lib/stitch.ts на lib/gemini.ts — прямой клиент к Gemini API:
// lib/gemini.ts
import { GoogleGenerativeAI } from '@google/generative-ai';
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
export async function generateSlides({
promptTemplate,
variables,
attachments,
}: GenerateSlidesParams): Promise<string[]> {
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-pro-preview-03-25',
});
// Подставляем переменные в шаблон промта
const prompt = interpolateTemplate(promptTemplate, variables);
const result = await model.generateContent([
prompt,
...attachments, // инлайн файлы (PDF, текст)
]);
const text = result.response.text();
// Парсим HTML слайды из ответа Gemini
return parseHtmlSlides(text);
}Важный момент: Gemini плохо работает с жёсткими промтами про стили и шрифты. Поэтому промт-шаблоны в prompt_templates намеренно не содержат директив по дизайну — только структуру и контент. Визуальное оформление приходит из HTML-шаблонов, которые Gemini заполняет данными.
4. Клиент к slide-renderer
В slides-generator добавили lib/slide-renderer-client.ts:
// lib/slide-renderer-client.ts
const RENDERER_URL = process.env.SLIDE_RENDERER_URL!;
const RENDERER_TOKEN = process.env.SLIDE_RENDERER_TOKEN!;
export async function renderPresentation(
slides: string[],
format: 'png' | 'pdf' | 'both' = 'both'
) {
const res = await fetch(`${RENDERER_URL}/render`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${RENDERER_TOKEN}`,
},
body: JSON.stringify({ slides, format }),
});
if (!res.ok) {
throw new Error(`Renderer error: ${res.status}`);
}
return res.json();
}5. Что удалили
После рефакторинга из проекта ушли:
lib/stitch.ts— Stitch API клиентlib/puppeteer.ts— Puppeteer внутри Next.jslib/html-to-slides.ts— парсинг HTML → Google Slides через Claude Haikulib/google-slides.ts,lib/google-slides-edit.ts— Google Slides APIlib/google-auth.ts,lib/google-drive.ts— Google OAuth и Drive- API routes для Google авторизации
Это не только упростило кодовую базу, но и убрало ~8 npm-зависимостей, включая тяжёлые googleapis и puppeteer.
6. GitHub Actions + GHCR деплой
Для slide-renderer настроили CI/CD через GitHub Actions: при пуше в master собирается Docker-образ и пушится в GitHub Container Registry. На сервере — docker pull + перезапуск контейнера. Никакого Dokploy — сервис управляется напрямую.
# .github/workflows/deploy.yml (упрощённо)
name: Build and Deploy slide-renderer
on:
push:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t ghcr.io/${{ github.repository }}/slide-renderer:latest .
- name: Push to GHCR
run: docker push ghcr.io/${{ github.repository }}/slide-renderer:latestСхема промтов для Gemini: почему переменные важнее жёстких директив
Один из главных инсайтов этого проекта — как правильно промтить Gemini для генерации HTML. Gemini 2.5 Pro хорошо генерирует структурированный HTML, но плохо реагирует на жёсткие директивы типа «используй шрифт Arial 24px, цвет #333333 для заголовков». Такие промты либо игнорируются, либо создают хрупкую зависимость от конкретных значений.
Правильный подход — разделить обязанности:
- Промт описывает что должно быть на слайдах: структуру, количество, ключевые тезисы, тон
- HTML-шаблон задаёт как это выглядит: CSS-стили, шрифты, цвета, layout
Gemini заполняет шаблон контентом, не трогая стили. Результат предсказуемый и стабильный.
Админка позволяет настраивать промт-шаблоны с переменными без деплоя. Например, {{slide_count}} — количество слайдов, {{language}} — язык презентации, {{tone}} — формальный или неформальный стиль. Переменные подставляются перед отправкой в Gemini.
Результат
После рефакторинга:
Стабильность рендера — Playwright в изолированном Bun-процессе не влияет на Next.js. Память используется предсказуемо, браузер переиспользуется между запросами.
Простота промтинга — прямой Gemini API без Stitch-обёртки. Можно использовать любую модель (gemini-2.5-pro), передавать файлы напрямую, контролировать параметры генерации.
Кастомные HTML-шаблоны — то, чего не было в первой версии. Теперь можно создать несколько визуальных стилей для презентаций и выбирать нужный без изменения кода.
Меньше зависимостей — убрали Puppeteer, Stitch, весь googleapis стек. Пакет Next.js-приложения стал легче, время сборки сократилось.
CI/CD для рендерера — GitHub Actions + GHCR дают автоматический деплой при пуше в master, что было невозможно с ручным управлением через nohup.
Уроки
Главный урок этого проекта — не держите браузер внутри Next.js. Puppeteer и Playwright — тяжёлые зависимости, которые конфликтуют с serverless-подходом и container limits. Выносите рендер в отдельный долгоживущий процесс. Это не преждевременная оптимизация — это правильная архитектура с первого дня.
Второй урок: MCP-обёртки удобны для прототипирования, но не для продакшна. Stitch API упростил первоначальную интеграцию с Gemini, но создал хрупкую зависимость от стороннего инструмента. Прямой API сложнее настроить, но надёжнее в работе.
Третий урок: разделяйте контент и представление на уровне промтинга. Когда Gemini отвечает за структуру и текст, а HTML-шаблон — за визуальное оформление, результаты стабильнее и предсказуемее. Не пытайтесь управлять CSS через промт — это заведомо проигрышная стратегия.
Четвёртый урок: паттерн kp-renderer оказался переиспользуемым. То, что начиналось как одноразовое решение для КП, стало шаблоном для slide-renderer. Bun + Playwright + /health + Bearer token auth — это работает. Когда у тебя есть референсная реализация, новый сервис пишется в разы быстрее.
Последнее: AdminUI для промтов — это не «потом», это сразу. Без возможности менять промт-шаблоны без деплоя каждая итерация по качеству генерации требует деплойного цикла. Это замедляет в 5-10 раз. Сделайте adminку в первом же спринте.
Полезные ссылки:

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