П/ВИН

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

·9 мин чтения
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.js
  • lib/html-to-slides.ts — парсинг HTML → Google Slides через Claude Haiku
  • lib/google-slides.ts, lib/google-slides-edit.ts — Google Slides API
  • lib/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