Aseprite Extension Starter

August 5, 2026 · View on GitHub

A working, tested template for building Aseprite extensions — with the part almost nobody sets up: an end-to-end test that runs your extension inside real Aseprite and checks the output.

It ships a complete example extension (PaletteOut — export a sprite's palette as GPL, hex list, CSS variables or JSON), so you can run the whole pipeline before you write a line of your own code.

python build.py                 # -> dist/paletteout-v1.0.0.aseprite-extension
python tests/test_palettecore.py  # headless logic tests, no Aseprite needed
python tools/validate_ext.py      # is the packaged zip actually installable?
python e2e/run_e2e.py             # runs inside real Aseprite and diffs the output

The idea: keep the logic out of Aseprite

Aseprite scripting has one structural problem — anything touching the app API can only be exercised by launching the editor, which is slow, awkward to automate, and impossible in CI. So this template splits every extension in two:

FileContainsTestable
src/*core.luaall the real logic. No Aseprite APIs at all.anywhere — plain Lua
src/*_main.luadialogs, reading app.sprite, writing filesonly in Aseprite

The rule that makes it work: the core never sees an Aseprite object. The main script converts Palette/Color/Image into plain tables first. Keep the shell boring and the interesting failures all land somewhere you can test cheaply.

build.py inlines the core into the shell to produce one shipped .lua, because Aseprite extensions have no module search path you can rely on across platforms.

Three layers of testing

1. Headless core tests (tests/) run the actual shipped Lua through lupa — not a Python reimplementation, which would only test your port. Fast enough to run on every save.

2. Package validation (tools/validate_ext.py) opens the built zip and checks the manifest against its contents. The specific bug it exists to catch: if contributes.scripts.path doesn't match the Lua file actually in the archive, the extension installs cleanly and then does nothing at all. That failure reaches your user, not you.

3. End-to-end (e2e/) launches real Aseprite in batch mode (aseprite -b --script), runs the core under Aseprite's own Lua interpreter, and compares the output byte-for-byte against what the headless suite produced.

That third layer earns its keep. Two Lua runtimes agreeing on string.format, integer coercion and table iteration order is not something to assume — and the same harness catches genuine API mistakes. A real one from building these: Image:drawSprite wants a frame number, but app.frame is a Frame object in Aseprite 1.3+. Easy to write, hard to spot by eye, instantly obvious to a pixel-comparing test.

Layout

src/package.json        extension manifest
src/palettecore.lua     pure logic — the part worth testing
src/paletteout_main.lua Aseprite-facing shell
build.py                packages src/ into dist/*.aseprite-extension
tools/build_ext.py      the builder (inlines core into shell)
tools/validate_ext.py   manifest <-> zip consistency
tests/                  headless core tests via lupa
e2e/                    real-Aseprite end-to-end
.github/workflows/ci.yml  runs build + headless tests + validation

CI runs everything except the e2e — GitHub's runners have no Aseprite. Run python e2e/run_e2e.py locally before you tag a release.

Making it yours

  1. Rename src/palettecore.luasrc/<yourname>core.lua and src/paletteout_main.luasrc/<yourname>_main.lua. The builder finds them by glob (*core.lua, *_main.lua) and requires exactly one of each.
  2. Update src/package.json (name, displayName, version, contributes). name must match the .lua filename the manifest points at.
  3. Replace the core's contents and its tests. Keep the split.
  4. python build.py && python tools/validate_ext.py

Requirements: Python 3.11+, lupa and pillow (pip install lupa pillow), and Aseprite ≥ 1.3 for the e2e.

The example extension

PaletteOut exports the active sprite's palette. Install dist/paletteout-v1.0.0.aseprite-extension via Edit → Preferences → Extensions → Add Extension, then use Palette → Export palette as…

Formats: GIMP .gpl (GIMP, Krita, Aseprite, Lospec), plain hex list, CSS custom properties, JSON array. Transparent entries are skipped by default — palette index 0 is conventionally transparent, and exporting it as #000000 silently adds a black swatch that isn't in your art.

If you just want the palette out of a PNG and don't need it inside Aseprite, the same export logic runs client-side in the browser at Palette Extractor (source) — it emits a byte-comparable .gpl.

Licence

MIT — see LICENSE. Use it for commercial extensions without attribution.


Built while making a set of paid Aseprite extensions. The three-layer testing approach above is the one they actually use — every one of them ships through validate_ext.py and a real-Aseprite e2e before it gets tagged.

ExtensionWhat it does
AutoBlobGenerates a full 47-tile blob tileset from a handful of drawn tiles$5.99
EdgeForgeOutlines and drop shadows that respect transparency and palette$3.99
PaletteGuardAudits a sprite against a locked palette and flags every stray colour$3.99
CelTweenMotion tweening between cels — in-betweens without hand-drawing them$3.49
DitherForgeDithered gradients with real Bayer/noise matrices, not a filter$2.99

Current prices and any live discount are shown on the SpriteWright site — it reads the real sale window, so it is never out of date. PaletteOut, in this repo, stays free forever — MIT, no strings.

Issues and PRs welcome — especially if you hit an Aseprite API corner this template should handle.