Skip to main content

lopsy

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.

Quellinformationen

Repository
theseamusjames/lopsy.art
Letzte Quellaktivität
5. Oktober 2026 um 21:06
Erkannte Sprache von SKILL.md
Englisch
Sterne
13
Forks
1

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
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}"]`);
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen