Six Things Photopea's Scripting API Does That Nobody Wrote Down
Undocumented behaviours in Photopea's Live Messaging API — measured, with the workaround for each. If your automation reports success and changes nothing, one of these is probably why.
Photopea is a full image editor in a browser tab, it is free, and it exposes an automation surface: Live Messaging. You postMessage a string of JavaScript into an iframe, Photopea executes it, and you get "done" back. That is essentially the whole official documentation.
It is enough to get started and nowhere near enough to build on. I spent a day driving Photopea from a headless browser, and six behaviours cost me most of that day. None of them are in the docs. Each one fails silently — no exception, no error string, and in three cases a cheerful "done" confirming that nothing happened.
Everything below was measured against photopea.com on 8 August 2026, from Python via Playwright and Chromium. Where a claim is a number, it came from a probe, not from memory.
1. Live Messaging is off unless the URL hash carries a config
This is the first wall, and it looks like a dead detector rather than a configuration problem.
Load https://www.photopea.com in an iframe and it works perfectly. It renders, it is interactive, the console is clean. It simply never sends "done" — so your bridge sits waiting for a handshake that is not coming, and the natural conclusion is that your message listener is broken.
It isn't. Photopea only enters Live Messaging mode when the URL fragment carries a configuration object. Measured: with no hash, zero messages in 32 seconds. With a hash, "done" in under two seconds.
// Silent forever:
iframe.src = "https://www.photopea.com";
// Handshake in <2s:
iframe.src = "https://www.photopea.com#" +
encodeURIComponent(JSON.stringify({ environment: {} }));An empty environment object is sufficient. The official demos do this, but the docs never state it as a requirement.
2. Calling a function stored on an object literal kills the interpreter
This is the worst one, and it is worth reading twice.
var o = { m: function () { return 7; } };
o.m(); // interpreter deadNot "throws". Not "returns undefined". The script interpreter aborts, and no further script runs for the lifetime of that page. Inside an IIFE or at top level, whatever the object is named, whatever the method does. Meanwhile the page keeps rendering normally, so nothing looks wrong.
Plain function declarations and function expressions are both fine:
function m() { return 7; } // fine
var m = function () { return 7; }; // fineWhich means the single most natural way to organise a script prelude — a namespace object holding your helpers — is the one construct that destroys it. I lost the most time here because the symptom impersonates a name collision: my helper object was called ovl, and typeof ovl came back "object" before I declared it, so I spent a long time convinced I was shadowing something in Photopea's own runtime. Renaming it to something unique changed nothing, because the name was never the problem.
Rewrite every helper as a top-level function.
3. Layer bounds are objects that lie when you convert them
layer.bounds returns four values, and typeof reports them as numbers. They are not. They are minified UnitValue objects — JSON.stringify exposes the shape:
[{"Hk":"UnitValue","n":0,"asR":"px"},
{"Hk":"UnitValue","n":0,"asR":"px"},
{"Hk":"UnitValue","n":400,"asR":"px"},
{"Hk":"UnitValue","n":300,"asR":"px"}]Their toString yields "[object Object]", so the two conversions everyone reaches for both return NaN:
var b = layer.bounds;
parseFloat(b[2]) // NaN
Number(b[2]) // NaN
b[2] * 1 // 400 <- works (valueOf)
b[2].value // 400 <- works, but a minified property nameA NaN raises nothing. It flows into your positioning arithmetic, every comparison against it is false, and layers land in places you never asked for. Multiplying by 1 goes through valueOf and is the conversion I would trust, since it does not depend on a property name surviving the next minifier pass.
The same applies to document.width and document.height.
4. A text layer measures 0×0 until the script that created it has returned
Create a type layer, set its contents, then read its bounds in the same script, and you get [0, 0, 0, 0] — always. Read the bounds in the next message and they are correct.
// script 1
var l = app.activeDocument.artLayers.add();
l.kind = LayerKind.TEXT;
l.textItem.contents = "MIRELANDS";
l.textItem.size = 36;
var b = l.bounds; // [0,0,0,0]
// script 2, next postMessage
var b = app.activeDocument.layers[0].bounds; // [33,223,241,251]The layer renders after the script returns. This matters more than it looks, because there is a real and separate hazard nearby: fonts arrive after the ready signal, and text created too early genuinely does come out zero-sized. So an inline bounds check is a validator that reports every font as broken, forever, and points you at the wrong bug.
Split creation and measurement across two messages.
5. app.open(url) is a silent no-op
The documented signature is app.open(url, as, asSmart), where asSmart places the file into the current document as a Smart Object. That would be the ideal way to bring an asset in: one call, non-destructive, re-editable.
It does nothing. It returns "done", the document count is unchanged, the layer count is unchanged.
My first theory was CORS, which is the usual suspect. It is not the answer. I served the asset from a local 127.0.0.1 HTTP server sending Access-Control-Allow-Origin: * and Cross-Origin-Resource-Policy: cross-origin, and tested with asSmart both true and false. Same result: "done", nothing opened.
The working route is to send the file as an ArrayBuffer over postMessage. That opens it as a new document, so getting it into an existing one costs a copy:
var scratch = app.documents[app.documents.length - 1];
app.activeDocument = scratch;
app.activeDocument.selection.selectAll();
app.activeDocument.selection.copy();
app.activeDocument = app.documents[BASE_INDEX];
app.activeDocument.paste();
try { scratch.close(); } catch (e) {}Which leads directly to the next problem.
6. Documents cannot be identified by name, or by reference
Every file loaded through Live Messaging is named "file". Not "cover.png" — "file". Open two and you have two documents sharing a name, so any lookup keyed on document.name silently returns the wrong one. In my case that meant copying the background into itself instead of copying the asset.
The obvious fix is identity comparison. That fails too:
for (var i = 0; i < app.documents.length; i++) {
if (app.documents[i] === app.activeDocument) found = i; // never true
}Each property access returns a fresh wrapper, so the comparison is false for every i. Index is the only handle that holds. Record it before you open anything else, and note that a newly loaded document is appended to the end of app.documents and becomes active.
One more, for completeness
app.documents.add() aborts the interpreter, in the same permanent way as finding #2. There are no scratch documents. If you need a throwaway layer to probe something — font readiness, for instance — create it in the open document and remove it afterwards.
The lesson under all six
Photopea's "done" means the message was processed. It does not mean the operation worked. Four of the six behaviours above return "done" while doing nothing at all, and one of them silently disables everything that follows.
So the acknowledgement is not a result, and any automation built on it needs a second layer: after every mutating call, read back the specific thing it claimed to change — layer count, bounds, index, a pixel — and treat disagreement as a failure rather than a warning. That read-back layer is most of the work in driving this API, and it is the part a quick prototype never has.
There is a wider point here that has nothing to do with Photopea. An API that fails by returning success is far more expensive than one that throws, because the cost lands hours later, in the wrong place, looking like a different bug. When you meet one, the read-back is not defensive programming. It is the only thing standing between you and a confident wrong answer.
Where this came from
These surfaced while building a small MCP server that lets an AI assistant compose layers onto an image without repainting it — assets placed as real layers, type set in real fonts, and the original pixels verifiably untouched. The findings outlived the project, which is why they are written down here.
The code, including the read-back layer and the working import route, is at github.com/Mormolykos/overlayer (MIT).
If you have hit a seventh, I would like to know.
