Skip to main content
Glama
vaheedsk36

Instagram Carousel MCP

by vaheedsk36
README.md
# Instagram Carousel MCP

An MCP server that **designs multi-slide Instagram carousels** as crisp SVG,
shows them in a **live swipeable preview**, and exports them to **PNG** ready to
upload. Pure-Python, no system image libraries required.

## What it does

- Describe a carousel as a list of slide specs → it renders each slide to SVG.
- Six slide templates: `title`, `content`, `list`, `quote`, `stat`, `cta`.
- Six themes: `midnight`, `sunset`, `mono`, `forest`, `slate`, `bubblegum`.
- Three sizes: `portrait` (1080×1350, recommended), `square` (1080×1080),
  `story` (1080×1920).
- A live preview page (swipe / arrow keys / dots) that also rasterises each
  slide to PNG **in the browser** — no extra dependencies for export.

## Architecture

Claude (the MCP client) calls the tools; the server renders SVG, sources media
from a provider chain, and (for reels) rasterises + composites with ffmpeg.

```mermaid
flowchart TB
    Client["Claude Code / Desktop<br/>(MCP client)"]

    subgraph Server["server.py — FastMCP (stdio)"]
      Tools["Tools:<br/>trending_topics · create_carousel · create_reel<br/>save_brand · list_brands · update_slide · add_slide<br/>list_themes · get_preview_url · export_png"]
    end

    subgraph Core["carousel/ package"]
      render["render.py<br/>SVG: templates, wrap, highlight,<br/>drop-shadow, logo/image embed, layers"]
      themes["themes.py<br/>themes + custom brand theme"]
      brand["brand.py<br/>brand profiles + caption (≤5 tags)"]
      news["news.py<br/>Google News RSS"]
      images["images.py<br/>image + logo sourcing"]
      videos["videos.py<br/>Seedance video clips"]
      reel["reel.py<br/>rasterize + ffmpeg<br/>scrim · bug · stitch"]
      preview["preview.py<br/>HTTP preview server + viewer"]
    end

    subgraph Ext["External (keyless unless noted)"]
      replicate["Replicate<br/>Flux img + Seedance video<br/>(token)"]
      pexels["Pexels (key)"]
      openverse["Openverse / Picsum"]
      wiki["Wikipedia / Wikimedia"]
      gnews["Google News RSS"]
    end

    subgraph Sys["System binaries"]
      ffmpeg["ffmpeg"]
      rsvg["rsvg-convert"]
    end

    Out["output/&lt;id&gt;/<br/>slides.svg · PNGs · reel.mp4<br/>caption.txt · music.txt · index.html"]
    Browser["Browser / Preview panel"]

    Client <-->|MCP| Tools
    Tools --> render & brand & news & reel
    render --> themes & images
    reel --> render & videos & rsvg & ffmpeg
    news --> gnews
    images --> replicate & pexels & openverse & wiki
    videos --> replicate
    Tools --> Out
    Tools --> preview --> Browser
    Out --> preview
```

**Image-source policy** (per scene/slide): real entities → `portrait`/logo
(Wikipedia/Commons); news/real-world → `background_photo` (Pexels→Openverse→
Picsum); concepts → `background_query` (Flux); motion → `video_query` (Seedance).

<details><summary>Request flow — <code>create_reel</code></summary>

```mermaid
sequenceDiagram
    participant C as Claude
    participant S as create_reel
    participant R as render.py
    participant M as images/videos
    participant F as ffmpeg + rsvg
    C->>S: scenes + brand + caption + music
    loop each scene
      S->>R: render fg text layer (SVG)
      S->>M: bg → video_query (Seedance) / photo / Flux / still
      S->>F: rasterize fg; composite bg+scrim+text+bug
    end
    S->>F: crossfade-stitch scenes → reel.mp4
    S->>C: preview_url + caption + music
```
</details>

## Tools

| Tool | Purpose |
|------|---------|
| `list_themes` | List available themes with colours. |
| `trending_topics` | Find recent/trending news headlines for a subject (Google News, no key) to pick a timely angle. |
| `create_carousel` | Build a carousel from slide specs; writes SVGs + preview. Accepts `brand`, `caption`, `hashtags`. Returns `preview_url`. |
| `create_reel` | Compile vertical 9:16 scenes (same specs as slides) into an MP4 Reel — Ken-Burns zoom + crossfades, no audio. Needs ffmpeg + rsvg-convert. |
| `update_slide` | Replace one slide (by index) and re-render. |
| `add_slide` | Insert/append a slide. |
| `save_brand` | Create/update a brand profile (handle, logo, custom theme, default hashtags). |
| `list_brands` | List saved brand profiles. |
| `get_preview_url` | Get the live preview URL for an existing carousel. |
| `export_png` | Server-side PNG export (optional; needs Playwright). |

### Slide fields

```
title    eyebrow?, heading,  subheading?, handle?
content  eyebrow?, heading,  body
list     eyebrow?, heading,  items[] (strings), ordered? (bool)
quote    quote,    author?,  role?
stat     value,    label?,   caption?
cta      eyebrow?, heading,  body?, button?, handle?
```
Any slide also accepts `handle` (e.g. `@brand`) and `page` (bool — show `n/total`).

### Images

Slides aren't text-only — you can add photos:

- **`background_image`** (any slide): a full-bleed photo behind the content. Text
  automatically switches to light and a gradient scrim is added so it stays
  readable on top of the image.
- **`image`** (on the `content` template): an inline rounded image card shown
  between the heading and the body text.

You don't have to supply files at all — use **`background_query`** / **`image_query`**
with a text description and the server auto-sources a fitting image, trying in
order: **Replicate/Flux** (AI, needs `REPLICATE_API_TOKEN`) → **Pexels** (stock,
needs `PEXELS_API_KEY`) → **Openverse** (free CC photos, no key) → **Picsum**
(random, guaranteed). Results cache under `assets/cache/`. Override the order
with `IMAGE_PROVIDER_ORDER`. So image-sourcing works with zero setup (real
photos) and upgrades to custom AI art once a Replicate token is present.

```jsonc
{ "template": "title", "heading": "The State of Remote Work",
  "background_query": "minimal home office, soft morning light" }
{ "template": "content", "heading": "Hybrid is winning",
  "image_query": "team collaborating in a bright office",
  "body": "58% of teams now run hybrid." }
```

Or supply images explicitly: `background_image` / `image` values can be a
**local file path**, an **http(s) URL** (downloaded), or a **data URI** — all
base64-embedded so exported PNGs are self-contained. Drop files in `assets/`:

```jsonc
{ "template": "title", "heading": "2026 Report",
  "background_image": "assets/cover.jpg" }
{ "template": "content", "heading": "Hybrid is winning",
  "image": "https://example.com/chart.png",
  "body": "58% of teams now run hybrid — up from 41% last year." }
```

## Reels (vertical video)

`create_reel` turns a list of **scenes** (same spec as carousel slides) into a
1080×1920 MP4: each scene gets a subtle Ken-Burns zoom and scenes crossfade into
each other. No baked-in audio — add trending audio in the Instagram app. Brand,
caption, hashtags, and the image options (`background_query`, `portrait`, etc.)
all work the same as carousels.

```
create_reel(scenes=[...], brand="mypage", per_scene=3.2, transition=0.6)
# -> output/<id>/reel.mp4
```

Requires `ffmpeg` and `rsvg-convert` (`brew install ffmpeg librsvg`). 3–7 scenes
works best.

**Real-video backgrounds.** A scene can have a moving video background instead of
a Ken-Burns still:
- `video_query: "prompt"` — generates a real AI clip (Seedance on Replicate;
  needs `.replicate_token`, ~$0.10–0.20/clip, cached).
- `video: "path-or-url"` — use a clip you already have.
Every scene gets a dark **scrim plate + text drop-shadow** for legibility over
busy footage, and the brand bug sits in Instagram's safe zone. Backgrounds can
be mixed per scene (some video, some generated stills, some real photos).

## Brand your page (theme + logo)

Save a brand profile once, then pass `brand: "<name>"` to `create_carousel` and
every slide gets your colours, logo, @handle, and default hashtags.

```jsonc
// save_brand({ profile: { ... } })  -> brands/mypage.json
{
  "name": "mypage",
  "handle": "@mypage",
  "logo": "mypage-logo.png",          // path; absolute, or relative to brands/
  "base_theme": "midnight",           // theme to extend
  "theme": {                          // override only what you want
    "accent": "#f472b6",
    "bg": ["#1e1b4b", "#312e81"]      // "#hex" solid, or ["#a","#b"] gradient
  },
  "default_hashtags": ["#buildinpublic", "#startup"],
  "caption_signature": "Follow @mypage 🚀",
  "default_size": "portrait"
}
```

- **Logo:** any PNG/JPG/SVG. It's base64-embedded into each slide (top-left), so
  exported PNGs are fully self-contained. Drop your logo in `brands/` (or give an
  absolute path) and set `logo` to it.
- **Theme:** start from a built-in `base_theme` and override any of `accent`,
  `bg`, `bg_angle`, `text`, `muted`, `accent_fg`, `font_sans`, `font_serif`.
- Profiles are JSON in `brands/`, so they commit to the repo and sync across
  devices.

## Captions & hashtags

`create_carousel` (and a follow-up) take `caption` and `hashtags`. The final
post text is assembled as **caption → brand signature → merged hashtags**
(per-call + brand defaults, deduped), written to `caption.txt`, and shown in the
live preview with a **Copy** button — paste straight into Instagram.

```
create_carousel(
  slides=[...],
  brand="mypage",
  caption="Speed compounds. Here are 5 moves that cut our cycle time in half.",
  hashtags=["#shipfast", "#engineering"],
)
```

## Viewing the output interactively

`create_carousel` returns a `preview_url` served by the MCP server itself
(e.g. `http://127.0.0.1:<port>/<carousel-id>/`). Open it in any browser for the
full interactive carousel + **Download all (ZIP)** button.

**In the Claude Code desktop app**, the Preview tool reads
`~/.claude/launch.json`. The included `carousel-preview` config serves a
carousel directory over `http://127.0.0.1:8745/`. To preview a specific
carousel, point its `--directory` arg at `output/<carousel-id>` and start the
preview. (Claude can do this for you on request.)

## Exporting PNGs

- **Easiest:** click **Download all (ZIP)** in the live preview — rasterises all
  slides in the browser and bundles them into one ZIP (a single download, so the
  browser's multi-download block never trips). Zero setup. "Download this slide
  (PNG)" grabs just the current one.
- **Headless / programmatic:** `export_png`. One-time setup:
  ```
  ./.venv/bin/python -m pip install playwright
  ./.venv/bin/python -m playwright install chromium
  ```

## Run it on another device

Prerequisites: **Python 3.10+** and the **`claude`** CLI on PATH.

```bash
git clone https://github.com/vaheedsk36/instagram-carousel-mcp.git
cd instagram-carousel-mcp
./setup.sh           # creates .venv, installs deps, registers the MCP server
```

`setup.sh` registers the server at user scope so it's available in every Claude
Code session on that machine. Verify with `claude mcp list | grep carousel`.
Your brand profiles travel with the repo (they live in `brands/`), so the same
look is available everywhere.

Manual equivalent if you'd rather not run the script:
```bash
python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements.txt
claude mcp add instagram-carousel --scope user -- \
  "$(pwd)/.venv/bin/python" "$(pwd)/server.py"
```

> Note: the `.venv` is machine-specific and git-ignored — always recreate it per
> device. Only the source and `brands/` profiles are committed.

## Project layout

```
server.py              MCP server (FastMCP) — the tools
carousel/themes.py     theme palettes + custom-theme builder
carousel/brand.py      brand profiles (handle, logo, theme, hashtags) + caption assembly
carousel/render.py     slide spec -> SVG (text wrapping, templates, logo embedding)
carousel/preview.py    background HTTP preview server + viewer HTML (caption + copy)
carousel/export.py     optional Playwright SVG->PNG
brands/                saved brand profiles (committed; sync across devices)
output/<id>/           generated slides, manifest, preview, caption.txt, spec
setup.sh               one-shot setup for a fresh machine
requirements.txt       Python dependencies
test_render.py         smoke test covering all templates
test_brand.py          smoke test for brand + logo + caption
```

## Note on this machine

The Homebrew Python 3.14 bottle shipped with a mis-linked `pyexpat` (pointed at
the system `libexpat` which lacks a newer symbol). It was repaired by repointing
the extension at Homebrew's expat:
```
install_name_tool -change /usr/lib/libexpat.1.dylib \
  /opt/homebrew/opt/expat/lib/libexpat.1.dylib <pyexpat.so>
codesign --force -s - <pyexpat.so>
```
A future `brew upgrade python@3.14` may revert this; re-run if `import
xml.parsers.expat` fails again.