П/ВИН

Фикс Stitch-пайплайна в генераторе слайдов

·9 мин чтения
Фикс Stitch-пайплайна в генераторе слайдов

Когда генератор слайдов перестаёт работать так, как задумано — это не всегда очевидно. Иногда система делает вид, что всё в порядке: запросы проходят, ответы возвращаются, слайды появляются. Но под капотом творится хаос — вместо нового Stitch-пайплайна работает старый legacy-код, дизайн каждого слайда генерируется случайно, а два параллельных запроса создают два разных проекта в Stitch. Именно с этим мы столкнулись в slides-generator, и вот как это починили.

Что такое slides-generator и причём тут Stitch

Slides-generator — это Next.js приложение для автоматической генерации HTML-презентаций. Пользователь вводит тему или загружает документы, AI анализирует контент и генерирует набор слайдов в виде HTML. Затем слайды можно просмотреть прямо в браузере, экспортировать в PDF через Puppeteer или отправить в Google Slides.

Ключевая часть пайплайна — Google Stitch API, который использует Gemini для генерации красивого HTML-кода слайдов. Stitch умеет создавать визуально богатые компоненты с правильной типографикой, цветами и лейаутом. В теории — отличный инструмент. На практике — интеграция была сломана с самого начала.

Проблема первая: Stitch не использовался вообще

Самый неожиданный баг: весь новый Stitch-пайплайн фактически не работал. Вот что происходило на самом деле:

Шаг 1: /api/plan вызывал старую функцию generateFromRawText(), которая отправляла запрос к Claude и получала в ответ полный HTML всех слайдов сразу. Этот HTML сохранялся в поле ai_spec.

Шаг 2: /api/generate видел, что ai_spec уже существует, и полностью пропускал Stitch. Зачем генерировать слайды через Stitch, если HTML уже есть?

В результате Stitch API не вызывался никогда. Слайды генерировались старым способом — одним большим запросом к Claude, который возвращал монолитный HTML. Это было медленно (60+ секунд ожидания), непредсказуемо по качеству и не использовало те возможности, ради которых Stitch вообще подключался.

Фикс потребовал рефакторинга обоих роутов:

// БЫЛО: /api/plan/route.ts
const spec = await generateFromRawText(text); // legacy, возвращает полный HTML
await saveSpec(presentationId, { ai_spec: spec });
 
// СТАЛО: используем planSlidesForStitch
const plan = await planSlidesForStitch(text);
await saveSpec(presentationId, {
  title: plan.title,
  slides: plan.slides.map(s => ({
    title: s.title,
    stitchPrompt: s.stitchPrompt,
    html: undefined // HTML ещё нет, будет генерироваться через Stitch
  })),
  designSystem: plan.designSystem
});

А в /api/generate изменили условие пропуска Stitch:

// БЫЛО: пропускаем если spec существует
if (!spec) {
  // запускаем Stitch
}
 
// СТАЛО: пропускаем только если В spec уже есть HTML
if (!spec || !spec.slides?.some(s => s.html)) {
  // запускаем Stitch
}

Теперь планирование через Claude занимает ~10 секунд вместо 60+, а генерация каждого слайда идёт через Stitch с промтом для конкретного слайда.

Проблема вторая: каждый слайд выглядел по-разному

Когда Stitch наконец заработал, обнаружилась следующая проблема: у каждого слайда был свой уникальный дизайн. Один слайд — оранжевый (#f49434), следующий — тёмно-красный (#d44211), потом бордовый (#bd0f0f), потом жёлтый (#f9e406). Презентация выглядела как лоскутное одеяло.

Причина проста: каждый stitchPrompt описывал только контент слайда, но не говорил ничего о цветах и шрифтах. Gemini каждый раз сам выбирал палитру — и выбирал по-разному.

Решение — ввести концепцию designSystem, который Claude выбирает один раз на всю презентацию и который обязательно включается в каждый промт для Stitch:

interface StitchDesignSystem {
  primaryColor: string;   // например "#2563eb"
  backgroundColor: string; // например "#ffffff"
  textColor: string;       // например "#1e293b"
  accentColor: string;     // например "#f59e0b"
  fontFamily: string;      // например "Inter"
  style: string;          // например "modern-minimal"
}
 
interface StitchPlan {
  title: string;
  designSystem: StitchDesignSystem;
  slides: Array<{
    title: string;
    stitchPrompt: string; // начинается с MANDATORY DESIGN SYSTEM: ...
  }>;
}

Системный промт для Claude теперь требует: каждый stitchPrompt должен начинаться со строки "MANDATORY DESIGN SYSTEM: primary color #..., background #..., text #..., font ...". Gemini получает конкретные hex-значения и следует им, не изобретая собственную палитру.

Проблема третья: race condition и два Stitch-проекта

Ещё один неприятный баг — TOCTOU race condition. Если два запроса к /api/generate приходили почти одновременно (например, пользователь быстро кликнул дважды или браузер отправил повторный запрос), оба проходили проверку if (!stitchProjectId) и оба создавали новый Stitch-проект. В итоге у одной презентации появлялось два проекта, слайды перемешивались между ними, генерация ломалась.

Фикс — атомарный guard через базу данных. Вместо read-check-write используем атомарный UPDATE с условием:

// Атомарно проверяем и создаём projectId
const { data, error } = await supabase
  .from('presentations')
  .update({ stitch_project_id: newProjectId })
  .eq('id', presentationId)
  .is('stitch_project_id', null) // условие атомарности
  .select('stitch_project_id')
  .single();
 
// Если update не прошёл (другой запрос уже создал) — читаем существующий
if (!data) {
  const existing = await getExistingProjectId(presentationId);
  // используем существующий
}

Дополнительно установили MAX_CONCURRENT = 1 для генерации слайдов — Stitch API не любит параллельные запросы в рамках одного проекта, и последовательная генерация дала более стабильные результаты.

Проблема четвёртая: слайды иногда не генерировались

В реальных тестах обнаружился паттерн: первый слайд генерируется успешно, второй — нет. В базе данных второй слайд оставался пустым (без HTML, без screenId). При этом Stitch возвращал 200 OK на вызов generate_screen_from_text — никакой ошибки!

Проблема в polling timeout. После вызова generate_screen_from_text мы поллили list_screens в ожидании появления нового экрана. Таймаут стоял 30 секунд — и этого не хватало. Stitch иногда обрабатывает запрос дольше.

Изменения:

  • Polling timeout: 30с → 90 секунд (18 итераций по 5 секунд)
  • Добавили per-slide retry: если слайд не сгенерировался за 90 секунд, пробуем ещё раз через 10 секунд
async function generateScreen(
  projectId: string,
  prompt: string,
  retries = 2
): Promise<string | undefined> {
  for (let attempt = 0; attempt < retries; attempt++) {
    if (attempt > 0) {
      await sleep(10_000); // пауза перед retry
    }
    
    await stitch.generate_screen_from_text({ projectId, prompt });
    
    // Поллинг до 90 секунд
    for (let i = 0; i < 18; i++) {
      await sleep(5_000);
      const screens = await stitch.list_screens({ projectId });
      const newScreen = findNewScreen(screens);
      if (newScreen) return newScreen.html;
    }
  }
  return undefined; // все попытки провалились
}

Прогресс-трекинг: пользователь не должен смотреть в пустой экран

Отдельная история — UX во время генерации. Пользователь нажимал кнопку, попадал на страницу с сообщением «AI планирует слайды...» и ждал. Без таймера, без подэтапов, без понимания, что вообще происходит. Это ощущение черного ящика — худшее, что можно сделать с пользователем в момент ожидания.

Добавили несколько вещей:

  1. Таймер прошедшего времени — «AI планирует слайды... (23с)» — пользователь видит, что процесс идёт
  2. Анимированные подэтапы — «Анализирую контент → Формирую структуру → Генерирую дизайн-систему»
  3. Прогресс по слайдам на странице презентации — каждый готовый слайд появляется сразу, не нужно ждать всех
const [elapsed, setElapsed] = useState(0);
const [subStep, setSubStep] = useState(0);
 
useEffect(() => {
  if (!isPlanning) return;
  const timer = setInterval(() => setElapsed(e => e + 1), 1000);
  return () => clearInterval(timer);
}, [isPlanning]);
 
// В JSX:
<p className="text-muted">
  {PLANNING_SUBSTEPS[subStep]} ({elapsed}с)
</p>

Экспорт: человекочитаемые ошибки

Экспорт в Google Slides требует OAuth-авторизации. Если пользователь не авторизован, старый код просто падал с необработанной ошибкой — никакого сообщения, просто ничего не происходило.

Добавили обработку в handleExport:

async function handleExport() {
  try {
    const result = await exportToGoogleSlides(presentationId);
    window.open(result.url, '_blank');
  } catch (error) {
    if (error.code === 'GOOGLE_AUTH_REQUIRED') {
      setExportError({
        message: 'Необходима авторизация в Google',
        authUrl: error.authUrl
      });
    } else {
      setExportError({ message: 'Ошибка экспорта. Попробуйте ещё раз.' });
    }
  }
}

Теперь пользователь видит сообщение с кнопкой «Авторизоваться в Google» вместо молчаливого провала.

Layout: превью на 2/3 экрана

Ещё одна вещь из плана — пропорции лейаута. На странице презентации превью слайда занимало примерно половину экрана, остальное — панели управления. Это неудобно: главное, что должен видеть пользователь — сам слайд.

Изменили CSS-сетку: превью теперь занимает 2/3 ширины, боковая панель — 1/3. Убрали фиксированную ширину у iframe, заменили на width: 100% чтобы он адаптировался к контейнеру.

/* БЫЛО */
.presentation-layout {
  display: grid;
  grid-template-columns: 1fr 1fr;
}
 
/* СТАЛО */
.presentation-layout {
  display: grid;
  grid-template-columns: 2fr 1fr;
}
 
.slide-preview iframe {
  width: 100%; /* вместо фиксированных 640px */
  aspect-ratio: 16/9;
}

Результат и текущее состояние

После всех изменений пайплайн работает так, как задумывался изначально:

  1. /api/plan вызывает planSlidesForStitch() — Claude за ~10 секунд возвращает структуру с единой дизайн-системой
  2. /api/generate берёт stitchPrompts из сохранённого плана и последовательно генерирует каждый слайд через Stitch
  3. Каждый слайд появляется на странице сразу после генерации
  4. Все слайды используют одну палитру и шрифт
  5. Параллельные запросы не создают дубликаты благодаря атомарному guard'у

Общее время генерации 8-10 слайдов: 3-5 минут. Это медленнее, чем один запрос к Claude, но качество HTML-слайдов несравнимо выше — Stitch генерирует полноценные визуальные компоненты, а не просто текст в div'ах.

Гит-история показывает путь: от первых PDF-экспортов и базовой интеграции Stitch — до исправления race conditions, polling timeout'ов и design consistency. Каждый коммит решал конкретную проблему, которую нашли в реальном использовании.

Выводы

Главный урок этого проекта — интеграция AI API сложнее, чем кажется. Stitch API возвращает 200 OK даже когда экран не создался. Gemini выбирает цвета произвольно, если не дать точные hex-значения. Параллельные запросы создают race conditions там, где ты не ожидаешь. Всё это нужно проверять в реальных условиях, а не полагаться на то, что «API сказал успех».

Второй урок: legacy-код выживает дольше, чем ты думаешь. Функция generateFromRawText() была помечена как deprecated, но продолжала вызываться из /api/plan, потому что никто не проверил весь путь целиком. Хорошая практика — после добавления нового пайплайна сразу удалять старый, чтобы не было соблазна использовать его как fallback навсегда.

Третий урок: UX ожидания — это отдельная фича. Таймер и подэтапы кажутся мелочью, но именно они определяют, будет ли пользователь ждать или закроет вкладку. Генерация презентации занимает минуты — это долго. Без обратной связи пользователь решит, что что-то сломалось.

Четвёртый урок: polling с фиксированным timeout'ом — это технический долг с процентами. 30 секунд казалось достаточно, но Stitch иногда работает медленнее. Timeout нужно устанавливать с запасом и добавлять retry — особенно когда работаешь с внешним API, поведение которого не контролируешь. В нашем случае 90 секунд + 2 попытки решили проблему пропущенных слайдов.

Следующий шаг — переход на кастомные HTML-шаблоны с рендерингом через Playwright вместо Stitch. Это даст полный контроль над дизайном и уберёт зависимость от стороннего API, которое может быть нестабильным. Но это уже тема для отдельной статьи.

Полезные ссылки:

Паша Вин
Паша Вин

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

Связанный проект

Slides Generator