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.

소스 정보

저장소
theseamusjames/lopsy.art
최근 소스 활동
2026년 10월 5일 21:06
감지된 SKILL.md 언어
영어
스타
13
포크
1

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
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에서 보기
이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기