- name
- lopsy
- description
- Create images with Lopsy (https://lopsy.art), a free, open-source image editor that runs entirely in the browser: layers, masks, blend modes, layer effects, gradients, brushes, text with Google Fonts, filters and PNG export. Use this when you need to make a poster, illustration, logo, cover, social image or any other raster artwork by driving the editor with Playwright.
# Making images with Lopsy
Lopsy is an image editor that runs entirely in the browser. It has no backend
and no API: there is nothing to call. You make an image the way a person does,
by operating the real UI (menus, tools, panels, mouse drags, the keyboard) in
a browser you control with Playwright, and then exporting a PNG.
This file covers everything you need to do that reliably:
1. [Setup](#setup): install Playwright, save the driver, start it.
2. [The technique](#the-technique): one long-lived page, driven one step at a time.
3. [Coordinates](#coordinates): how document pixels map to screen pixels.
4. [The driver](#the-driver): a complete, tested script with helpers for every common operation.
5. [Recipes](#recipes): how to build shapes, gradients, glows, type and texture.
6. [Behaviours that trip up agents](#behaviours-that-trip-up-agents).
7. [UI reference](#ui-reference): stable selectors, if you need something the helpers don't cover.
Related resources:
- **Feature catalog:** <https://lopsy.art/FEATURES.md>. It's long and exhaustive:
every tool, every slider range, and known defects. Read the sections for
the tools you plan to use before you script them.
- **Index for agents:** <https://lopsy.art/llms.txt>
- **Tutorials** (step-by-step compositions made this way): <https://lopsy.art/tutorials/>
- **Source:** <https://github.com/theseamusjames/lopsy.art>. Open issues list
current bugs, so search them when something looks wrong.
- **Contributing:** we accept pull requests written by AI agents. If you hit a
bug or a missing feature, you're welcome to fix it: read
[AGENTS.md](https://github.com/theseamusjames/lopsy.art/blob/main/AGENTS.md)
for the workflow first.
## Setup
You need Node 18+ and Playwright's Chromium.
```bash
mkdir lopsy-work && cd lopsy-work
npm init -y
npm install --save-exact playwright@1.58.2
npx playwright install chromium
```
Save [the driver](#the-driver) below as `lopsy-driver.mjs`, then start it
in the background and leave it running for the whole session:
```bash
node lopsy-driver.mjs > driver.log 2>&1 &
```
It opens <https://lopsy.art/> headless at 1600×1000 and listens on
`http://localhost:47391`. Useful environment variables:
| Variable | Default | Meaning |
| --- | --- | --- |
| `LOPSY_URL` | `https://lopsy.art/` | Point at a local dev server instead. |
| `DRIVER_PORT` | `47391` | Change if the port is taken. |
| `OUT_DIR` | `./lopsy-out` | Where screenshots, exports and projects are written. |
| `HEADED` | unset | `1` shows the browser window. |
| `REAL_GPU` | unset | `1` drops the SwiftShader flags. Much faster on a machine with a GPU. |
## The technique
A finished piece takes dozens to hundreds of UI operations. Don't write one
giant script and rerun it from the top every time something is off: that's
slow, and a single bad locator throws away all the work before it.
Instead, the driver keeps **one** page open and runs small JavaScript *step
files* that you POST to it. Each step is the body of an async function that
receives `page` (the Playwright page), `h` (the helpers) and `ctx` (an object
that persists between steps, for palettes, measurements and your own helper
functions). Whatever the step returns comes back as JSON.
```bash
cat > steps/01-background.js <<'EOF'
await h.newDocument(1200, 1500);
await h.selectLayer('Layer 1');
await h.renameActive('Sky');
await h.gradient('linear', [{ pos: 0, hex: '#1B2A4A' }, { pos: 1, hex: '#F2A541' }]);
await h.drag([{ x: 600, y: 0 }, { x: 600, y: 1500 }], { steps: 8 });
return await h.shot('background');
EOF
curl -s --data-binary @steps/01-background.js localhost:47391
```
Always send step files with `--data-binary @file`; `echo` and `--data`
mangle newlines. A failing step returns HTTP 500 with the stack trace and the
path of an automatic error screenshot.
The loop:
1. **Plan before you touch the editor.** Decide the concept, the document
size, a palette of 4–8 hex colours, and the layer stack from back to
front. Compute geometry in code (polygons, curves, grids) and keep it
in `ctx`, so shapes line up exactly.
2. **Build in stages**, one step file per stage (background, main shapes,
detail, type, effects, finishing). Keep each step to a few operations.
3. **Look at every screenshot** (`h.shot()` returns the path). Check that
the step did what you intended before you send the next one. Most mistakes
are cheap to undo (`h.key('ControlOrMeta+z')`) straight away and expensive
to find later.
4. **Checkpoint** with `h.saveProject()` after each stage. The page can be
reloaded or the WebGL context lost, and a `.lopsy` file can be reopened
through **File → Open Project...**.
5. **Export** with `h.exportPng('name.png')` and look at the exported file
itself, not only the canvas screenshot.
6. **Stop the driver** when you're done: `curl -s --data '__quit__' localhost:47391`.
## Coordinates
Every helper takes **document** coordinates: pixels of the image, with (0, 0)
at the top-left of the canvas. The driver converts them to screen
coordinates with a closed-form mapping:
- **View → Fit to Screen** sets the pan to 0 and the zoom to
`0.9 × min(containerWidth / docWidth, containerHeight / docHeight)`.
`h.newDocument()` and `h.fit()` run it and record the mapping.
- `screen = containerCentre + (doc − docSize / 2) × zoom`.
- **Never zoom or pan with the mouse wheel or the Hand tool**, because the
mapping would silently go stale. If you must, call `h.fit()` afterwards.
Also call it after **Image → Canvas Size**, **Image Size**, **Crop** or
opening a file.
- `h.probe(x, y)` hovers a point and reads it back from the status bar's
`X: … Y: …` readout. Use it to prove the mapping once per document.
Tool positions are rounded to whole document pixels. At 90% of a
1600×1000 window, one screen pixel is about 1–2 document pixels for a
1000–2000 px image, so precision is good. For very large documents, drags
land in coarser steps.
## The driver
Tested against lopsy.art with Playwright 1.58.2. Save it as
`lopsy-driver.mjs`.
```js
// lopsy-driver.mjs — keeps ONE Lopsy page open and runs JS step files POSTed to it.
import { chromium } from 'playwright';
import http from 'node:http';
import fs from 'node:fs';
const LOPSY_URL = process.env.LOPSY_URL ?? 'https://lopsy.art/';
const PORT = Number(process.env.DRIVER_PORT ?? 47391);
const OUT = process.env.OUT_DIR ?? './lopsy-out';
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
headless: process.env.HEADED !== '1',
// A real GPU is much faster; SwiftShader makes WebGL2 work on headless/CI machines.
args: process.env.REAL_GPU === '1' ? [] : ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
});
const context = await browser.newContext({
viewport: { width: 1600, height: 1000 },
acceptDownloads: true,
permissions: ['clipboard-read', 'clipboard-write'],
});
const page = await context.newPage();
page.setDefaultTimeout(15000);
page.on('dialog', (d) => d.accept());
await page.goto(LOPSY_URL);
await page.getByRole('dialog', { name: 'New Document' }).waitFor();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const blur = () => page.evaluate(() => document.activeElement?.blur());
let view = null; // { left, top, cx, cy, zoom, W, H } — set by h.fit()
let shotN = 0;
const h = {
sleep, blur, OUT,
// ---- document & view ---------------------------------------------------
async newDocument(width, height, background = 'White') {
let dlg = page.getByRole('dialog', { name: 'New Document' });
if (!(await dlg.isVisible())) { await h.menu('File', 'New'); await dlg.waitFor(); }
const unit = dlg.locator('select').first();
await unit.selectOption({ label: 'Pixels' }).catch(() => {});
const nums = dlg.locator('input[type="number"], input[inputmode]');
await nums.nth(0).fill(String(width));
await nums.nth(1).fill(String(height));
await dlg.getByLabel(background).check();
await dlg.getByRole('button', { name: 'Create' }).click();
await dlg.waitFor({ state: 'hidden' });
await sleep(500);
return h.fit();
},
// View → Fit to Screen sets pan to 0 and zoom to 0.9 × the tighter axis, so
// document → screen mapping is closed-form. Call again after anything that
// changes the view or the canvas size.
async fit() {
await h.menu('View', 'Fit to Screen');
const size = await page.locator('footer[aria-label="Status bar"]').innerText();
const m = size.match(/(\d+)\s*x\s*(\d+)\s*px/);
const W = Number(m[1]), H = Number(m[2]);
const r = await page.locator('[data-testid="canvas-container"]').boundingBox();
const zoom = Math.min(r.width / W, r.height / H) * 0.9;
view = { left: r.x, top: r.y, cx: r.x + r.width / 2, cy: r.y + r.height / 2, zoom, W, H };
return view;
},
toScreen(x, y) {
return { x: view.cx + (x - view.W / 2) * view.zoom, y: view.cy + (y - view.H / 2) * view.zoom };
},
// Hover a document point and read the status bar's X/Y back — proves the mapping.
async probe(x, y) {
const s = h.toScreen(x, y);
await page.mouse.move(s.x, s.y);
await sleep(80);
const t = await page.locator('footer[aria-label="Status bar"]').innerText();
const m = t.match(/X:\s*(-?\d+)\s*Y:\s*(-?\d+)/);
return { x: Number(m[1]), y: Number(m[2]) };
},
// ---- pointer -------------------------------------------------------------
async click(x, y, modifiers = []) {
const s = h.toScreen(x, y);
for (const m of modifiers) await page.keyboard.down(m);
await page.mouse.click(s.x, s.y);
for (const m of modifiers) await page.keyboard.up(m);
await sleep(120);
},
async drag(points, { steps = 4, modifiers = [] } = {}) {
const s0 = h.toScreen(points[0].x, points[0].y);
for (const m of modifiers) await page.keyboard.down(m);
await page.mouse.move(s0.x, s0.y);
await page.mouse.down();
for (const p of points.slice(1)) {
const s = h.toScreen(p.x, p.y);
await page.mouse.move(s.x, s.y, { steps });
}
await page.mouse.up();
for (const m of modifiers) await page.keyboard.up(m);
await sleep(150);
},
async key(combo) { await blur(); await page.keyboard.press(combo); await sleep(150); },
// ---- menus & tools ---------------------------------------------------------
async menu(top, item, sub) {
await page.locator('nav[aria-label="Application menu"]').getByRole('button', { name: top, exact: true }).click();
const dd = page.locator(`[role="menu"][aria-label="${top}"]`);
GitHub에서 보기