Гайд · кодовое слово КАРУСЕЛЬ
Карусель из карточек-твитов - формат, который в ленте читают до конца: он выглядит как чужая переписка, а не как реклама. Руками такая пачка собирается полдня в редакторе. У меня она собирается одной фразой: я говорю тему, Клод пишет тред, генератор рисует слайды. Ниже - весь путь, скил и код целиком, забирайте.
Задание одной фразой: «собери карусель-тред на тему 6 вещей, которые ИИ делает за меня каждый день». Результат: 8 слайдов, время от фразы до готовых картинок - 23 секунды (сама отрисовка меньше секунды), затраты 0 рублей. Ни одной платной генерации: текст пишет Клод, картинки рисует библиотека Pillow на моём компьютере.
Файл make_thread.py. Кладите куда удобно, он ни от чего не зависит, кроме двух библиотек выше. Шрифт берётся системный Segoe UI, если его нет - подставляется запасной.
"""Генератор ТРЕД-КАРУСЕЛИ (карточки-твиты) для @sergei.pro.ai.
Второй формат карусели рядом с make_carousel.py (фото + текст). Здесь слайд =
белая карточка-твит на тёмном фоне: аватар, имя, @ник, текст. Тред пишет Клод в
YAML, техника (шрифты, карточка, перенос, тень) — на стороне скрипта.
Запуск:
.venv\\Scripts\\python.exe scripts\\make_thread.py carousels\\<name>.yaml
Результат: output/<slug>/slide_01.png ... + сетка output/<slug>/_grid.png
"""
import argparse
import sys
import time
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
import yaml
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
PROJECT_ROOT = Path(__file__).resolve().parents[1]
OUTPUT_DIR = PROJECT_ROOT / "output"
W, H = 1080, 1350
BG = (14, 16, 20)
CARD = (255, 255, 255)
INK = (15, 20, 25)
GREY = (110, 120, 130)
BLUE = (29, 155, 240)
WIN_FONTS = Path("C:/Windows/Fonts")
FALLBACK = PROJECT_ROOT / "assets" / "fonts"
def font(bold: bool, size: int):
for path in ((WIN_FONTS / ("segoeuib.ttf" if bold else "segoeui.ttf")),
(FALLBACK / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"))):
if path.exists():
return ImageFont.truetype(str(path), size)
raise FileNotFoundError("Нет ни Segoe UI, ни DejaVu")
def wrap(draw, text: str, f, max_w: int):
"""Перенос по словам + сохранение переводов строк из YAML."""
lines = []
for para in text.split("\n"):
if not para.strip():
lines.append("")
continue
cur = ""
for word in para.split():
probe = f"{cur} {word}".strip()
if draw.textlength(probe, font=f) <= max_w:
cur = probe
else:
if cur:
lines.append(cur)
cur = word
lines.append(cur)
return lines
def round_avatar(path: Path, size: int):
img = Image.open(path).convert("RGB")
iw, ih = img.size
side = min(iw, ih)
# лицо на рефах — в верхней трети кадра
top = int(ih * 0.06)
if top + side > ih:
top = max(0, ih - side)
left = (iw - side) // 2
img = img.crop((left, top, left + side, top + side)).resize((size, size), Image.LANCZOS)
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
out = Image.new("RGBA", (size, size))
out.paste(img, (0, 0), mask)
return out
def draw_card(slide_text: str, meta: dict, avatar: Image.Image, idx: int, total: int):
img = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(img)
pad = 56
card_x0, card_y0 = pad, 150
card_x1, card_y1 = W - pad, H - 210
# тень + карточка
shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle(
(card_x0 + 8, card_y0 + 14, card_x1 + 8, card_y1 + 14), radius=34, fill=(0, 0, 0, 90))
img.paste(Image.alpha_composite(img.convert("RGBA"), shadow).convert("RGB"), (0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle((card_x0, card_y0, card_x1, card_y1), radius=34, fill=CARD)
# шапка карточки
ax, ay, asize = card_x0 + 48, card_y0 + 48, 104
img.paste(avatar, (ax, ay), avatar)
f_name = font(True, 42)
f_handle = font(False, 36)
name = meta.get("name", "Сергей")
d.text((ax + asize + 26, ay + 6), name, font=f_name, fill=INK)
nw = d.textlength(name, font=f_name)
# синяя галочка
cx, cy, r = ax + asize + 26 + nw + 26, ay + 6 + 21, 16
d.ellipse((cx - r, cy - r, cx + r, cy + r), fill=BLUE)
d.line([(cx - 7, cy), (cx - 2, cy + 6), (cx + 8, cy - 6)], fill=(255, 255, 255), width=4)
d.text((ax + asize + 26, ay + 58), meta.get("handle", "@sergei.pro.ai"),
font=f_handle, fill=GREY)
# текст твита — кегль подбирается под объём
max_w = card_x1 - card_x0 - 96
avail_h = card_y1 - (ay + asize + 54) - 60
for size in (56, 52, 48, 44, 40, 36, 32):
f_body = font(False, size)
lines = wrap(d, slide_text, f_body, max_w)
lh = int(size * 1.42)
if len(lines) * lh <= avail_h:
break
y = ay + asize + 54
for line in lines:
d.text((card_x0 + 48, y), line, font=f_body, fill=INK)
y += lh
# подвал карточки: счётчик слайда
f_small = font(False, 32)
d.text((card_x0 + 48, card_y1 - 66), f"{idx}/{total}", font=f_small, fill=GREY)
# подпись под карточкой
f_foot = font(True, 38)
foot = meta.get("footer", "листай →") if idx == 1 else meta.get("footer_rest", "")
if foot:
tw = d.textlength(foot, font=f_foot)
d.text(((W - tw) / 2, card_y1 + 58), foot, font=f_foot, fill=(235, 238, 242))
return img
def main():
ap = argparse.ArgumentParser()
ap.add_argument("yaml_path")
args = ap.parse_args()
t0 = time.time()
data = yaml.safe_load(Path(args.yaml_path).read_text(encoding="utf-8"))
slug = data.get("slug") or Path(args.yaml_path).stem
tweets = data["tweets"]
meta = {k: data[k] for k in ("name", "handle", "footer", "footer_rest") if k in data}
avatar_path = Path(data["avatar"]) if data.get("avatar") else None
if avatar_path and not avatar_path.is_absolute():
avatar_path = (PROJECT_ROOT / avatar_path).resolve()
avatar = round_avatar(avatar_path, 104)
out = OUTPUT_DIR / slug
out.mkdir(parents=True, exist_ok=True)
imgs = []
for i, tweet in enumerate(tweets, 1):
img = draw_card(tweet.strip(), meta, avatar, i, len(tweets))
img.save(out / f"slide_{i:02d}.png")
imgs.append(img)
# контактная сетка для показа в ролике
cols = min(4, len(imgs))
rows = (len(imgs) + cols - 1) // cols
tw, th = W // 3, H // 3
grid = Image.new("RGB", (cols * tw, rows * th), BG)
for i, im in enumerate(imgs):
grid.paste(im.resize((tw, th), Image.LANCZOS), ((i % cols) * tw, (i // cols) * th))
grid.save(out / "_grid.png")
print(f"Готово: {len(imgs)} слайдов -> {out}")
print(f"Рендер: {time.time() - t0:.1f} с")
if __name__ == "__main__":
main()
Чтобы не объяснять заново каждый раз, правила лежат в файле-скиле. Путь: .claude/skills/karusel/SKILL.md в вашем проекте. После этого достаточно сказать «собери карусель на тему такую-то» - Клод сам вспомнит формат, напишет тред и запустит отрисовку.
---
name: karusel
description: Собрать карусель-тред (карточки в виде твитов) из ОДНОЙ фразы с темой — Клод пишет тред по виральным каркасам, генератор рендерит слайды 1080×1350. Используй, когда Сергей говорит "собери карусель", "карусель-тред на тему X", "/karusel X", "сделай слайды-твиты", "тред в карусель". Не про рилсы (это reels-pipeline) и не про фото-карусели отношенческого (это make_carousel.py).
---
# Карусель-тред за одну строку
Формат: слайд = белая карточка-твит на тёмном фоне (аватар + имя + ник + текст).
Работает для любого аккаунта — меняются только `name` / `handle` / `avatar` в YAML.
**Движок:** `carousel-pipeline/scripts/make_thread.py` (Pillow, 0 ₽, локально).
Рядом живёт старый формат «фото + текст» — `make_carousel.py`, его не трогаем.
## Как собрать (3 шага, ~20-30 секунд)
1. **Тред.** Пишешь YAML в `carousel-pipeline/carousels/thread_<slug>.yaml`:
```yaml
title: "Тема карусели"
slug: thread_<slug>
name: "Сергей"
handle: "@sergei.pro.ai"
avatar: ../ai-creator-account/prompts/avatar-refs/ref-closeup.png
footer: "листай →"
tweets:
- |
Первый твит - обложка. Крючок + обещание, что будет дальше.
- |
1. Пункт.
Раскрытие пункта: конкретика, цифра, инструмент по имени.
```
2. **Рендер.**
```powershell
cd carousel-pipeline
.venv\Scripts\python.exe scripts\make_thread.py carousels\thread_<slug>.yaml
```
3. **Результат.** `output/<slug>/slide_01.png …` + `output/<slug>/_grid.png`
(сетка всех слайдов — она же кадр «результат» для ролика).
Кегль текста подбирается сам под объём (56 → 32). Пустая строка в YAML = пустая
строка на карточке. Счётчик `N/8` и «листай →» рисуются автоматически.
## Каркас треда (по нему пишем текст)
- **Слайд 1 - обложка.** Кто говорит + почему его слушать + что будет внутри.
Заход от факта или цифры, не от вопроса-боли.
- **Слайды 2…N-1 - пункты.** Один пункт = одна мысль. Формула пункта:
что делаю → как именно → чем подтверждаю (цифра, имя инструмента, срок).
- **Последний слайд - вывод.** Не призыв «подпишись», а честный итог, который
можно унести. Призыв живёт в описании поста, не на слайде.
Правила текста те же, что у всего публикуемого: «КВН» вместо запретной аббревиатуры,
короткие тире « - », прямые кавычки. Название соцсети зависит от площадки: в самой
соцсети - прямо, за её пределами (сайты, телеграм, Дзен) - «Запретграм».
Финальную чистку текста гнать через скил `humaniser`.
## Проверка перед отдачей
- Открыть `_grid.png` глазами: нет обрезанных строк, кегль читается в ленте.
- Пункты не повторяют друг друга, в каждом есть конкретика.
- Аватар — реальное фото, ник совпадает с аккаунтом публикации.
## Факт (первый прогон, 04.08.2026)
Тема «6 вещей, которые ИИ делает за меня каждый день» → 8 слайдов.
От фразы с темой до готовых PNG — **23 секунды** (рендер 0.9 с), затраты 0 ₽.
Результат: `carousel-pipeline/output/thread_ii_za_menya/`.
Тред живёт в YAML - это простой текстовый файл. Его пишет Клод, но полезно понимать устройство: сверху настройки карточки, ниже список твитов. Пустая строка внутри твита останется пустой строкой на картинке.
title: "6 вещей, которые ИИ делает за меня каждый день"
slug: thread_ii_za_menya
name: "Сергей"
handle: "@sergei.pro.ai"
avatar: ../ai-creator-account/prompts/avatar-refs/ref-closeup.png
footer: "листай →"
tweets:
- |
Мне 45. Год назад я не умел ничего, кроме как открыть браузер.
Сейчас шесть дел в день за меня делает нейросеть - и я расскажу какие.
- |
1. Читает документы.
Кидаю договор или справку - получаю пересказ на человеческом и список мест,
где меня могут подловить.
Этот скил - один узел моего контент-завода. Рядом такие же: клон голоса, аватар в кадре, парсер чужих выстрелов, касса. Как это всё собиралось за 14 дней - в сериале. Что происходит сейчас, включая ставку «первая продажа за 30 дней», пишу в дневнике - t.me/sergeiai90.