Скилл, который сам снимает скриншоты приложения для лендинга


Скриншоты на маркетинговой странице — это та работа, которую ненавидишь дважды. Первый раз — когда руками снимаешь экраны, подбираешь данные, чтобы не торчали пустые таблицы и тестовые «asdf», тащишь всё в фотошоп ради рамки браузера и retina-чёткости. Второй раз — через месяц, когда поменял интерфейс, и все картинки разом протухли. И снова по кругу.
В какой-то момент я решил, что хватит. Пусть приложение снимает само себя. А чтобы не собирать этот конвейер заново в каждом проекте — я завернул его в skill для Claude Code. Теперь «настрой маркетинговые скриншоты» работает в любом новом проекте, а не только там, где я уже всё руками настроил.
Почему скриншоты вообще боль
Если копнуть, проблем тут не одна, а целый букет:
- Данные. На скриншоте дашборда не должно быть «Project 1», «test test» и нулей. Нужны живые, симпатичные, согласованные данные — и одни и те же на всех кадрах.
- Рамка и композиция. Голый скриншот выглядит дёшево. Хочется показать экран в рамке ноутбука или телефона, иногда — совместить несколько кадров внахлёст, что-то обрезать, что-то подвинуть. Руками это каждый раз поход в фотошоп.
- Retina. Снимок в 1x на современном экране — это мыло. Нужен 2x, а чтобы снять его руками, надо всякий раз городить эмуляцию устройства или масштаб браузера — и не забыть это сделать на каждом кадре.
- Две темы. Я делаю сайты со светлой и тёмной темой. Светлый скриншот на тёмном сайте смотрится ужасно, поэтому картинки тоже адаптивные: смотришь светлую тему — кадр из приложения в светлой теме, переключился на тёмную — кадр перерисовывается в тёмной. А это сразу удваивает работу: каждый экран надо снять дважды.
- Устаревание. Поменял кнопку — и пять картинок на лендинге врут. Вручную их никто вовремя не переснимет.
Каждую из этих болей по отдельности решить легко. Проблема в том, что вместе они складываются в рутину, которую делать лень. А раз лень — скриншоты на сайте всегда чуть-чуть устаревшие и чуть-чуть некрасивые. А чаще всё кончается тем, что картинок просто мало: добавлять лень, потом переделывать — тоже, и пользователь приходит на страницу, видит простыню текста без единого скриншота, читать её скучно — и уходит.
Идея: приложение снимает само себя
Решение простое до банальности: не рисовать моки, а снимать реальное запущенное приложение через headless-браузер. Тогда картинка по определению не врёт — это буквально твой продукт. А значит, её можно перегенерировать в любой момент одной командой.
Мне это особенно на руку из-за гибридного подхода в разработке: приложение собирается под все платформы из одной кодовой базы и выглядит везде одинаково. Поэтому один снимок из браузера честно представляет и десктоп, и мобайл — не нужно отдельно поднимать симулятор телефона ради мобильного кадра.
Система разложилась на две части — и это ключевое решение всей затеи:
- Универсальный движок (
scripts/screenshots/) — копируется в любой проект как есть, без правок. Он ничего не знает про конкретное приложение. - Проектный конфиг и сид — маленький файл со списком экранов и скрипт, который засевает красивые демо-данные. Только это и переписываешь под новый проект.
Эталонная реализация живёт в моём тайм-трекере. Дальше — на её примере.
Движок: что делает capture.js
Движок — это набор Node-модулей на Playwright и sharp. Сердце — функция captureShot, которая снимает один кадр в отдельном, свежем браузере. Это первое неочевидное решение: холодный headless-браузер на первой загрузке dev-сервера иногда отдаёт пустую страницу, и изоляция каждого кадра в своём браузере убирает этот флак.
async function captureShot(opts) {
const { baseUrl, route, viewport, deviceScaleFactor = 2,
theme = 'light', prefs = {}, maskEmail, wait,
settleMs = 400, screenshot = {}, outFile } = opts;
const browser = await chromium.launch();
const context = await browser.newContext({
viewport,
deviceScaleFactor, // retina
timezoneId: 'UTC',
colorScheme: theme === 'dark' ? 'dark' : 'light',
});
// ...
}Внутри есть несколько важных мелочей.
Авторизация без логин-формы. Не нужно кликать через экран входа на каждом кадре. Перед навигацией в localStorage подмешиваются токены и закешированный пользователь — и приложение сразу считает себя залогиненным:
await context.addInitScript((prefsArg) => {
for (const [k, v] of Object.entries(prefsArg)) {
window.localStorage.setItem(k, v);
}
}, prefs);Ждём, пока экран реально готов. Мало дождаться загрузки — надо дождаться, пока подтянулись данные и улеглись анимации. Поэтому кадр ждёт networkidle, плюс опциональный data-селектор готовности, плюс короткую паузу, чтобы всё устоялось:
await page.goto(`${baseUrl}${route}`, { waitUntil: 'networkidle' });
if (wait) await page.waitForSelector(wait);
await page.waitForTimeout(settleMs);Чистим кадр перед снимком. Чат-виджеты, дев-бейдж Next.js, настоящий email в углу — всё это не должно попасть на лендинг. Поэтому прямо в странице прячутся все элементы с безумным z-index, удаляется nextjs-portal, а почта заменяется на красивую вымышленную:
await page.evaluate((mask) => {
for (const el of document.querySelectorAll('*')) {
if (parseInt(getComputedStyle(el).zIndex, 10) > 1e6) el.style.display = 'none';
}
document.querySelector('nextjs-portal')?.remove();
if (mask) {
for (const el of document.querySelectorAll('*')) {
if (el.children.length === 0 && el.textContent === mask.real) {
el.textContent = mask.masked; // [email protected] → [email protected]
}
}
}
}, maskEmail);И поверх всего — withRetry, обёртка для того самого флака с холодной загрузкой: первый прогон на коротком таймауте, при падении — повтор на длинном.
async function withRetry(label, fn) {
try { return await fn(25000); }
catch (e) { return await fn(60000); } // cold-load flake
}Главное про движок: его не трогаешь. Скопировал — и забыл. Вся специфика проекта вынесена наружу.
Конфиг: что снимать
Весь «характер» съёмки — в одном декларативном файле screenshots.config.js. Там список кадров contentShots, функция авторизации, карта prefs для инъекции в localStorage и пара maskEmail:
const contentShots = [
{ name: 'tracking-interface', route: '/track', view: 'tiles',
viewport: { width: 1340, height: 760 }, wait: 'text=Deep Work',
screenshot: { mode: 'crop', selector: '[data-testid="tiles-grid"]', padding: 20 } },
{ name: 'feature-hierarchy', route: '/track', view: 'tree',
viewport: { width: 640, height: 940 }, wait: 'text=Deep Work',
screenshot: { mode: 'crop', selector: '[data-testid="activities-tree"]', maxHeight: 660 } },
{ name: 'feature-corrections', route: '/history',
viewport: { width: 460, height: 900 }, wait: 'text=Sleep',
steps: [{ click: '[data-testid="entry-menu-trigger"]', waitFor: 'text=Replace Activity' }],
screenshot: { mode: 'clip', clip: { x: 0, y: 0, width: 460, height: 640 } } },
];
module.exports = {
baseUrl: process.env.BASE_URL || 'http://localhost:3002',
deviceScaleFactor: 2,
maskEmail: { real: '[email protected]', masked: '[email protected]' },
authenticate, prefs, contentShots, hero,
};Добавить новый скриншот на сайт — это дописать сюда один объект и перезапустить. У каждого кадра свой режим съёмки (crop по селектору, element, фиксированный clip или весь экран), steps для кликов (открыть меню, перейти на таб) и prepare — асинхронная функция, в которой можно, например, переключить график на более «показательную» группировку перед снимком.
Сид: откуда берутся красивые данные
Самая недооценённая часть. Скриншот хорош ровно настолько, насколько хороши данные на нём. В тайм-трекере за это отвечает отдельная Django-команда — seed_demo. Она засевает выделенный демо-аккаунт целым маленьким «миром»: сферы жизни (Work, Health, Learning, Family, Rest), дерево активностей с иконками и целями (Deep Work, Meetings, Exercise, Sleep, Reading…), и — самое важное — несколько недель истории с реалистичным дневным ритмом.
class Command(BaseCommand):
@transaction.atomic
def handle(self, *args, **options):
# Идемпотентный сброс: удаляем юзера — каскадом улетают
# сферы, активности и все записи времени.
CustomUser.objects.filter(email=DEMO_EMAIL).delete()
user = CustomUser.objects.create_user(email=DEMO_EMAIL, ...)
# ...создаём сферы, дерево активностей, цели, позиции плиток...
# ~5 недель записей по дневному «ритму» (будни/выходные)
# с джиттером ±20%, чтобы статистика выглядела живой
rng = random.Random(20260530) # фиксированный seed → стабильный результат
for day_offset in range(days - 1, -1, -1):
rhythm = WEEKEND_RHYTHM if is_weekend else WEEKDAY_RHYTHM
for act_key, base_minutes in rhythm:
minutes = int(base_minutes * rng.uniform(0.8, 1.2))
# ...кладём TimeEntry...
# на «сегодня» последний блок оставляем без end_time —
# приложение покажет активный, «идущий прямо сейчас» трекингЗдесь спрятано несколько приятных мелочей. Ритм дня описан шаблоном (сон, зарядка, глубокая работа, встречи, чтение…), к каждому блоку добавляется случайный джиттер — поэтому графики выглядят органично, а не нарисованными под линеечку. random.Random с фиксированным seed делает результат стабильным от прогона к прогону. А последний блок «сегодня» намеренно оставлен открытым, без времени окончания, — чтобы на скриншоте было видно живое состояние «трекинг идёт прямо сейчас».
Команда идемпотентная: каждый запуск удаляет демо-юзера и пересоздаёт всё с нуля, так что аккаунт не обрастает мусором, а скриншоты получаются детерминированными. (Сид — единственное, что плотно завязано на модель данных приложения, поэтому в новом проекте он пишется заново.)
Рамки: MacBook и iPhone без фотошопа
Голый скриншот на лендинге выглядит сиротливо. Поэтому отдельный модуль buildHero берёт десктопный и мобильный кадры и компонует их в один hero-композит — экран в рамке MacBook, рядом телефон в рамке iPhone. Никакого фотошопа: всё складывается через sharp:
async function buildHero({ rawDir, outDir, desktopName, mobileName }) {
for (const theme of ['light', 'dark']) {
const suffix = theme === 'dark' ? '-dark' : '';
// ...накладываем десктоп в рамку макбука, мобайл в рамку айфона
await sharp(/* холст */)
.composite([/* desktop, mobile поверх рамок */])
.png()
.toFile(`${outDir}/hero${suffix}.png`);
}
}На выходе — hero.png и hero-dark.png под светлую и тёмную тему сайта.
Как этим пользоваться
Весь конвейер собран в npm-скрипты, и финальный аккорд — screenshots:all, который прогоняет всё по цепочке:
"screenshots:seed": "cd ../timetracker-api && docker compose exec -T web python manage.py seed_demo",
"screenshots:capture": "node scripts/capture-screenshots.js",
"screenshots:hero": "node scripts/generate-hero.js",
"screenshots:optimize": "node scripts/optimize-screenshots.js",
"screenshots:all": "npm run screenshots:seed && npm run screenshots:capture && npm run screenshots:hero && npm run screenshots:optimize"(В реальном проекте в цепочке есть ещё пара шагов — для блока «как это работает» и сводного графика статистики, — но суть та же.) Засеять данные → снять все кадры → собрать hero → оптимизировать. Готовые PNG падают в public/screenshots/, откуда их подхватывают лендинг и страницы продукта. Оптимизация специально стоит последней: она ужимает картинки до разумного веса, но формат остаётся PNG — чтобы не побить существующие ссылки и image-sitemap. Поменял интерфейс — прогнал команду ещё раз, и все картинки снова актуальны. Скриншоты перестают протухать, потому что это больше не артефакт ручного труда, а функция от текущего кода.
А при чём тут skill
Можно было на этом остановиться — конвейер в одном проекте работает. Но конвейер хорош ровно до момента, пока его не надо повторить в другом проекте. Через пару часов я полез делать то же самое в новом приложении и поймал себя на мысли, что нужно почти то же самое, но слегка по-другому, — а значит, надо выделить общую и частную части.
Что внутри skill
Поэтому я завернул всё в skill для Claude Code. Skill — это просто папка с SKILL.md, где описано, когда навык применять и что делать. В описании — триггеры вроде «настрой маркетинговые скриншоты», «обнови картинки на лендинге», «добавь новый экран в съёмку». Внутри — инструкция и табличка, которая разделяет переносимое и специфичное:
| Универсальное (копировать как есть) | Своё в каждом проекте |
|---|---|
движок scripts/screenshots/ | screenshots.config.js (URL, авторизация, список кадров) |
| тонкие скрипты-раннеры | сид демо-данных под модель приложения |
| список граблей (флак, таймзона, оверлеи) | какие страницы лендинга потребляют картинки |
Skill намеренно тонкий
Фокус в том, что сам skill намеренно тонкий. Он не пытается описать движок словами — он указывает на эталонную реализацию в тайм-трекере и говорит «скопируй дословно». Настоящий артефакт — это код движка, а skill лишь учит Claude, как развернуть его в новом месте и что адаптировать. Отдельно skill хранит то, ради чего всё и затевалось, — накопленные грабли: про флак холодной загрузки, про UTC, про прятанье чат-виджетов, про «оптимизировать в последнюю очередь». Это знание, которое в коде не видно, но без которого конвейер собирается с третьей попытки.
Что получилось
Теперь в любом новом проекте я просто говорю Claude «настрой маркетинговые скриншоты» — он находит эталон, копирует движок, пишет конфиг под мои экраны и сид под мою модель данных. То, что раньше было вечером возни с фотошопом, стало одной фразой.
Что дальше: связать с Figma
Скриншоты для сторов через Figma
Лендинг — это только половина истории. Вторая половина — скриншоты для сторов: Google Play и App Store требуют картинки в строго заданных размерах, да ещё и не «голый» экран, а аккуратно оформленный. Я делаю их в Figma по заранее собранным шаблонам, и это не просто рамка под нужный размер — это целая композиция: фоновая заливка или градиент, рамка устройства, крупные подписи-заголовки. Кадр приложения — лишь один слой внутри всей этой обвязки. Один и тот же скриншот ложится в темплейты под разные размеры и форматы, а Figma уже отдаёт готовые слайсы под каждый стор.
Проблема ровно та же, что и с лендингом: поменялся интерфейс — и скриншоты в сторовых шаблонах протухли. Сейчас этот кусок всё ещё ручной: пересними экран, зайди в Figma, замени картинку в каждом шаблоне, выгрузи слайсы. Логичный следующий шаг — научить тот же skill закрывать и его.
План такой: skill снимает свежие кадры тем же движком, через Figma-интеграцию подставляет их в существующие шаблоны — меняется только слой со скриншотом, а вся композиция (фон, рамка, подписи, выноски) остаётся на месте, — а потом вытаскивает готовые слайсы во всех нужных размерах, отдельно под Google Play и App Store. Тогда весь путь от «поменял кнопку» до «обновил картинки и на лендинге, и в обоих сторах» сворачивается в одну команду. Движок уже умеет давать честный кадр приложения — осталось дотянуть последнюю милю: аккуратно вложить его обратно в оформление и забрать готовое.
Те же скриншоты на всех языках
А дальше эту мысль можно раскручивать ещё. Движок уже умеет переключать тему перед снимком — ровно так же он может переключать язык приложения. Если автоматизировать ещё и переводы (а это отдельная задача, которую тоже хочется отдать в skill), то один прогон снимает весь комплект скриншотов под каждую локаль — и на лендинг, и в сторы, на всех языках сразу. Подписи в Figma-шаблонах при этом тоже подставляются из переводов.
Релиз под ключ: Fastlane
И финальный аккорд — Fastlane. Это инструмент автоматизации релизов под iOS и Android: он умеет заливать в Google Play и App Store не только скриншоты и метаданные, но и саму новую сборку. Если подключить и его, то цепочка замыкается целиком: snap свежих кадров → подстановка в Figma-шаблоны → выгрузка слайсов под все размеры и языки → заливка в оба стора вместе с новой версией. Выкладка новой версии «под ключ» — одной командой, без единого ручного шага между «закоммитил» и «доступно в сторе».
Что я из этого вынес
Два вывода, которые работают далеко за пределами скриншотов.
Рутину — в функцию от исходных данных
Лучший способ не делать рутину — превратить её в функцию от исходных данных. Скриншот, сгенерированный из живого приложения, не устаревает по определению. Как только артефакт перестаёт быть ручным трудом и становится результатом команды — проблема «оно протухло» просто исчезает.
Разделяй переносимое и специфичное
Движок — переносимый, конфиг и сид — специфичные. Это позволило завернуть всё в skill так, чтобы Claude поднимал систему в новом проекте сам. И хороший skill не дублирует логику словами — он указывает на готовый артефакт, а в себе хранит то, что нигде больше не записано: грабли, на которые ты уже наступил, чтобы не наступать снова.
А дальше тот же принцип масштабируется: один раз отделив честный кадр приложения от того, во что он потом оборачивается, ты можешь добавлять новые «обёртки» — рамку для лендинга сегодня, шаблоны сторов завтра — не трогая то, что эти кадры снимает.