Skip to main content
Glama
README.md
# photopea-api

A headless, read/write client for [Photopea](https://www.photopea.com) — plus an
MCP server, so Claude Code (or any agent) can edit images on its own.

```python
from photopea_api import Photopea

async with Photopea.session() as pp:
    await pp.open_path("photo.jpg")
    await pp.draw.gradient(["#e63946", "#1d3557"], angle=45)
    await pp.text.add("SALE", x=40, y=120, size=64, color="#ffffff")
    await pp.filter.gaussian_blur(2)
    await pp.export_to("out.psd")        # layers preserved
```

No visible browser window. No tab to keep open. No human in the loop.

> **On Photopea.** This drives Photopea through
> [Live Messaging](https://www.photopea.com/api/live), its own documented and
> sanctioned automation API. Nothing here bypasses ads, defeats a protection, or
> touches the paid AI features (Remove BG, Magic Cut) that cost its author money
> per call — Photopea is one developer's ad-funded product and those features
> should not be driven programmatically. It opens one editor per session and does
> local, deterministic editing. If you need volume, self-host with
> [Photopea-Offline](https://github.com/DUOLabs333/Photopea-Offline) rather than
> pointing a fleet at photopea.com.

## Why this exists

Photopea has no backend: it is a client-side WASM app, so there is nothing to
scrape and no API to call. The only sanctioned automation surface is
[Live Messaging](https://www.photopea.com/api/live) — `postMessage` a string of
JavaScript into an iframe and read what comes back. That means automating
Photopea always means *running* Photopea in a browser.

Two projects got halfway there. [`attalla1/photopea-mcp-server`](https://github.com/attalla1/photopea-mcp-server)
has the MCP layer but opens a **visible** browser tab the user must leave alone,
and has been unmaintained since April 2026. [`yikuansun/HeadlessPhotopea`](https://github.com/yikuansun/HeadlessPhotopea)
runs headless but exposes no agent interface. Nobody combined the two.

This does, and fixes the failure modes the reference implementation ships with —
see [RECON.md](RECON.md) for the measurements behind each of the following.

| | reference | here |
|---|---|---|
| browser | visible tab, must stay open | headless, owned by the process |
| call framing | counts `"done"` messages | unique sentinel per call |
| gradients | 16 solid bands | true ramps (206 distinct colours over 300 px) |
| text | fails silently right after boot | blocks until fonts actually rasterise |
| colours | `hexValue` assignment → **black** | `SolidColor` components |
| send layer to front | `move(doc, PLACEATBEGINNING)` — a **no-op** | `move(topLayer, PLACEBEFORE)` |
| after a crashed script | every later call times out | health check + page reload |
| stealth | none | camoufox (patched Firefox) by default |

Each row on the right is covered by a test that asserts on **exported pixels**,
because every failure in the left column looks like success from the API.

## Install

```bash
cd ~/photopea-api
python3.12 -m venv .venv
./.venv/bin/pip install -r requirements.txt
./.venv/bin/python -m camoufox fetch        # one-off, 663 MB
```

Verify both engines really drive Photopea on your machine:

```bash
./.venv/bin/python scripts/gate_check.py
# [PASS] camoufox   boot 4635ms  png 126B
# [PASS] chromium   boot 1813ms  png 126B
```

## Use as an MCP server

```bash
claude mcp add -s user photopea -- ~/photopea-api/.venv/bin/python -m mcp_server.server
```

The editor boots lazily on the first tool call and is reused for the rest of the
session. Environment overrides: `PHOTOPEA_ENGINE` (`camoufox` | `chromium` |
`firefox`, default `camoufox`) and `PHOTOPEA_HEADLESS` (`0` to watch it work,
useful when debugging).

Tools cover documents (create, open, resize, crop, rotate, flip, flatten,
export), layers (add, select, rename, opacity, blend mode, visibility, move,
scale, rotate, flip, reorder, group, delete, tree), drawing (gradients,
rectangles, ellipses, image placement), text (point, paragraph, edit), colour
adjustments, filters, selections, and `run_script` as the escape hatch.

## Engine choice

`camoufox` is the default because it was asked for, and it works. But Photopea
serves **no anti-bot whatsoever** — the recon found zero protection vendors and
zero XHR endpoints — so stealth buys nothing functional here and costs ~2.8 s of
extra boot. Use `chromium` when you control the environment:

```python
from photopea_api.browser import BrowserConfig
async with Photopea.session(BrowserConfig(engine="chromium")) as pp:
    ...
```

## Closing Photopea's gaps

Photopea implements a large slice of Photoshop, but it is missing channels,
every layer effect, eight filters, four colour spaces, guides and AVIF — and
because its Action Manager is a phantom there is no lower level to drop to.

So the architecture inverts: **Photopea is the document engine, Python is the
pixel engine.** One primitive makes it work —
:meth:`PixelOps.export_layer` isolates a layer and exports it *with alpha*, and
`place_image` composites arbitrary RGBA back in. Everything missing is rebuilt
on top of that pair.

| gap | how it is closed |
|---|---|
| layer effects (0/1 native) | `pp.fx.drop_shadow / outer_glow / inner_glow / stroke / color_overlay / gradient_overlay / bevel` — rendered from the layer's alpha |
| 8 missing filters | `pp.pyfilter.radial_blur / smart_blur / spherize / zigzag / glass / lens_flare / deinterlace / clouds` |
| channels + histograms (0/4) | `pp.analyze.histogram / histograms / statistics / channel / palette` — computed from exported pixels, exact |
| HSB / CMYK / Lab / Gray | `script.hsb_to_hex`, `cmyk_to_hex`, `lab_to_hex`, `gray_to_hex` |
| guides | `pp.guides` — tracked Python-side, `render()` draws them onto a layer |
| `selection.store` | `pp.selections.save/load` — replays the geometry we asked for |
| AVIF export | `pp.analyze.export_avif()` via Pillow |

### Live layer styles, not just raster

`pp.fx.*` gives raster effects — an effect arrives as an ordinary layer named
`title (drop_shadow)`. For a **real, editable layer style** use
`pp.fx.apply_live()`, which writes an `lfx2` block into the PSD and hands the
file back:

```python
await pp.fx.apply_live({"drop_shadow": {"color": "#000000", "size": 12}}, layer="title")
```

This works because **Photopea does render PSD layer effects** — verified by
opening a Photoshop-authored file: drop shadow, both glows, both shadows,
bevel/emboss, satin, all three overlays and stroke all render correctly. The
style survives PSD export and stays editable in Photoshop.

**All ten of Photoshop's layer styles are live**: `drop_shadow`,
`inner_shadow`, `outer_glow`, `inner_glow`, `bevel`, `satin`,
`gradient_overlay`, `pattern_overlay`, `stroke`, `color_overlay`. The raster
path in `pp.fx.*` remains as an alternative, not a fallback — it keeps undo
history, which the live path discards when it re-opens the document.

`pattern_overlay` also takes a `tile` (PNG bytes): its pixels live in a global
`Patt` block rather than in the effect descriptor, so the tile is written into
the file and referenced by UUID.

Each raster operation costs one export plus one import — tens of milliseconds at
typical sizes, not free.

One capability falls out of this for free and is worth knowing about:
`pp.analyze.drew_anything(layer)` answers *"did this layer actually rasterise?"*
It is the only reliable guard against Photopea's silent no-ops, and cheap enough
to call after every text layer in a generated pipeline.

## What Photopea can and cannot do

[docs/API_MAP.md](docs/API_MAP.md) is a capability matrix built by *calling* every
candidate member against a live editor. It exists because the API cannot be
introspected — the objects are virtual, `typeof` lies, and an unsupported call
does not raise, it aborts your whole script.

The headline result: Photopea implements a large slice of Photoshop's layer,
filter and selection surface, but its **Action Manager is a phantom**.
`stringIDToTypeID()` echoes its own argument back and `executeAction()` crashes
the interpreter, so the usual "drop to the Action Manager for anything missing"
escape hatch does not exist. Gradients are the visible casualty — hence
`photopea_api/raster.py`, which renders them in Python and composites them in.

## Tests

```bash
./.venv/bin/python -m pytest                # 70 tests, ~13 min
./.venv/bin/python -m pytest -m "not slow"  # skip the per-engine boots
./.venv/bin/python scripts/demo.py          # compose a poster end to end
```

Every visual assertion is made on **exported pixels**, never on layer state —
because the failure mode that matters (text that renders nothing) leaves the
layer looking perfectly correct.

## Layout

```
photopea_api/
  browser.py    engine launcher (camoufox | chromium | firefox)
  bridge.py     Live Messaging transport, sentinel framing
  host.html     the page that hosts the Photopea iframe
  client.py     session, file I/O, export, font readiness
  script.py     JS generation for Photopea's restricted dialect
  raster.py     gradients rendered in Python
  ops/          document, layer, text, draw, adjust, filters, selection
mcp_server/     MCP server over the client
scripts/        recon, gate check, API mapper, targeted probes
docs/API_MAP.md the measured capability matrix
RECON.md        how Photopea works and what it forces on us
```

## Etiquette

Photopea is one developer's ad-funded product and its AI features cost him money
per call. This client drives local deterministic editing only, never touches
`showWindow("magiccut")` or other paid surfaces, and opens one editor per
session. For volume, self-host
([Photopea-Offline](https://github.com/DUOLabs333/Photopea-Offline)) instead.