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:
| File | Contains | Testable |
|---|---|---|
src/*core.lua | all the real logic. No Aseprite APIs at all. | anywhere — plain Lua |
src/*_main.lua | dialogs, reading app.sprite, writing files | only 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
- Rename
src/palettecore.lua→src/<yourname>core.luaandsrc/paletteout_main.lua→src/<yourname>_main.lua. The builder finds them by glob (*core.lua,*_main.lua) and requires exactly one of each. - Update
src/package.json(name,displayName,version,contributes).namemust match the.luafilename the manifest points at. - Replace the core's contents and its tests. Keep the split.
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.
| Extension | What it does | |
|---|---|---|
| AutoBlob | Generates a full 47-tile blob tileset from a handful of drawn tiles | $5.99 |
| EdgeForge | Outlines and drop shadows that respect transparency and palette | $3.99 |
| PaletteGuard | Audits a sprite against a locked palette and flags every stray colour | $3.99 |
| CelTween | Motion tweening between cels — in-betweens without hand-drawing them | $3.49 |
| DitherForge | Dithered 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.