Automatically exports visible layers from your Aseprite file into structured folders, based on your group hierarchy

:bullseye: Export By Group (Recursive) — Aseprite Script

Hi everyone! :waving_hand:
I’m Feel, an indie artist and amateur developer.
I’m sharing a script I made (with a lot of help from ChatGPT)
to make my Aseprite workflow smoother — and hopefully yours too!


:brain: What this script does

This tool automatically exports visible layers from your Aseprite file into structured folders, based on your group hierarchy. It’s especially handy for projects with many nested groups.

:white_check_mark: Recursively walks all groups and sub-groups
:white_check_mark: Supports @group → merges all visible image layers into a single PNG
:white_check_mark: Automatically trims transparent pixels
:white_check_mark: Skips groups/layers that contain # in their name
:white_check_mark: Automatically creates export folders based on group names
:white_check_mark: Writes a full export log to keep track of everything
:white_check_mark: :new_button: Define export path inside your Aseprite file (no script editing needed !)
:white_check_mark: :new_button: @groups are now flattened with full opacity & blend mode support !

:new_button: %choose: You can now use a layer named %choose to trigger a save dialog and select your export folder manually!


:new_button: %choose : You can now use a layer named %choose to trigger a save dialog and select your export folder manually!

:wrench: How it works:

  • As before, placing a layer like %D:/My/Path/ exports sprites to that static path.
  • Now, if you name a layer %choose, the script will open a file dialog asking where to export the sprites.

Aseprite_fdxwKnfBYT


:file_folder: Set your export path inside your Aseprite file

Instead of editing the script to set your export folder,
you now simply add a layer named with % followed by the path.

Example in aseprite, layer name must be :
% E:/MyGame/Exports/

:stop_sign: Rules for the export path layer:

  • You must have exactly one %layer
  • The name must start with %
  • The path must end with a / or \\
  • Use double backslashes if you’re using Windows paths (\\ instead of \)

:white_check_mark: Valid:
% E:/MyGame/Exports/
% E:\\MyGame\\Exports\\

:cross_mark: Invalid:
% E:\MyGame\Exports
% E:\MyGame\Exports\


:package: Example Aseprite structure

Items
├── @sword_iron → exported as a merged sprite: sword_iron.png
├── @shield_wood → exported as a merged sprite: shield_wood.png
├── #hidden_stuff → ignored automatically
└── coin → exported as individual layer PNGs

Exemple of logs :

[2025-05-22 06:10]
Export from: E:\ -redacted- \exemple project.aseprite

→ Skipped (inherited or contains '#'): #Background
→ Entering group: UI
→ Entering group: Trees
→ Entering group: Items

→ Merging group: @shield_wood
→ Exported: E:/  -redacted- /Items/shield_wood.png (trimmed 157x195)

→ Merging group: @sword_iron
→ Exported: E:/ -redacted- /Items/sword_iron.png (trimmed 84x130)
→ Skipped (inherited or contains '#'): #hidden_stuff
Checking layer: coin
→ Exported: E:/ -redacted- /Items/coin.png (trimmed 68x68)

Total exported : 3 layer(s)
Total skipped  : 2 layer(s)
------------

:wrench: How to install

  1. Download the script from GitHub:
    :backhand_index_pointing_right: GitHub – Export By Group (Recursive)

  2. Place the export-by-group-recursive.lua file in your Aseprite scripts folder:
    C:\Users\YourName\AppData\Roaming\Aseprite\scripts\

  3. Run the script in Aseprite via:
    File > Scripts > export-by-group-recursive.lua


Let me know what you think! I’d love feedback, ideas, or improvements.
Happy pixel crafting ! :artist_palette::hammer_and_wrench:

It’s my first script and first GitHub project,
so please don’t hesitate to tell me if I forgot something or did anything wrong :see_no_evil_monkey:

1 Like

Since I’m a new member, I couldn’t include both screenshots in the main post — so here’s a side-by-side view of the Aseprite layer structure and the resulting export folders, to make things easier to understand if it was not clear :blush:

:file_folder:! New ! Set your export path inside your Aseprite file

Instead of editing the script to set your export folder,
you now simply add a layer named with % followed by the path.

Example in aseprite, layer name must be :
% E:/MyGame/Exports/

:stop_sign: Rules for the export path layer:

  • You must have exactly one %layer
  • The name must start with %
  • The path must end with a / or \\
  • Use double backslashes if you’re using Windows paths (\\ instead of \)

:white_check_mark: Valid:
% E:/MyGame/Exports/
% E:\\MyGame\\Exports\\

:cross_mark: Invalid:
% E:\MyGame\Exports
% E:\MyGame\Exports\

2 Likes

Hello !

:new_button: Major update to my Aseprite script “Export by Group (Recursive)”.

It now supports automatic flattening of @groups
using Aseprite’s native FlattenLayers()

Which means correct handling of opacity and blend modes

Yeah bcs that wasn’t the case before :face_with_peeking_eye:
I really struggled to get it working properly,
but I’ve tested it on two different projects now and it works great.

Feel free to try the script and let me know what you think. Cheers!

I updated this code, which asks you if you want to trim the layers or not, and also if you add $ to your layer, e.g. $BG → background layer (applied under every image).
PS. just open the .lua file in note pad and replace the whole code.
Thanks FeelPr

"script"

app.transaction(function()
–[[
Export By Group (Preserve Positions, Optional Trim)
Author: Feel & ChatGPT
License: CC BY-NC-SA 4.0
]]

local spr = app.activeSprite
if not spr then return app.alert(“No active sprite”) end
if spr.filename == “” then return app.alert(“Please save your .aseprite file first”) end

– Ask if user wants trimming
local doTrim = app.alert {
title = “Trim Images?”,
text = “Do you want to trim transparent borders from exported images?”,
buttons = { “Yes”, “No” }
} == 1

– Find export path layer
local exportRoot = nil
local percentLayers = {}
for _, layer in ipairs(spr.layers) do
if layer.name:sub(1,1) == “%” then
table.insert(percentLayers, layer)
end
end

if #percentLayers == 0 then
return app.alert(“No export path layer found. Create a layer like: %D:/Path/”)
elseif #percentLayers > 1 then
return app.alert(“Only one export path layer (%) allowed.”)
end
exportRoot = percentLayers[1].name:sub(2)

local lastChar = exportRoot:sub(-1)
if lastChar ~= “/” and lastChar ~= “\” then
return app.alert(“Export path must end with slash or backslash.”)
end
if exportRoot:find(“\”) and not exportRoot:find(“\\”) then
return app.alert(“Windows paths must use double backslashes.”)
end

– Find background layer
local backgroundLayer = nil
for _, layer in ipairs(spr.layers) do
if layer.name:sub(1,1) == “$” and layer.isImage then
if backgroundLayer then
return app.alert(“Only one background layer ($) allowed.”)
end
backgroundLayer = layer
end
end

– Create folders
local function ensureDir(path, cache)
if cache[path] then return end
local sep = package.config:sub(1,1)
if sep == “\” then
os.execute(‘if not exist "’ .. path .. ‘" mkdir "’ .. path .. ‘"’)
else
os.execute(‘mkdir -p "’ .. path .. ‘"’)
end
cache[path] = true
end

– Setup log
local logPath = exportRoot .. “= Export-Logs/”
local createdPaths = {}
ensureDir(logPath, createdPaths)
local logFile = io.open(logPath .. “export-log.txt”, “a”)
if not logFile then return app.alert(“Cannot write export log”) end

local date = os.date(“[%Y-%m-%d %H:%M]”)
logFile:write(date .. "\nExport from: " .. (spr.filename or “(unsaved)”) .. “\n\n”)

local exportCount, skippedCount = 0, 0

local function isGroup(layer)
local ok, children = pcall(function() return layer.layers end)
return ok and children ~= nil
end

local function getTrimBounds(image)
local r = image.bounds
local left, top, right, bottom = r.x + r.width, r.y + r.height, r.x, r.y
local found = false
for y = r.y, r.y + r.height - 1 do
for x = r.x, r.x + r.width - 1 do
if image:getPixel(x, y) >> 24 > 0 then
if x < left then left = x end
if y < top then top = y end
if x > right then right = x end
if y > bottom then bottom = y end
found = true
end
end
end
if not found then return nil end
return {x=left, y=top, w=right-left+1, h=bottom-top+1}
end

– Export one layer or flattened group
local function exportCel(image, cel, name, folderPath)
local bounds = nil
if backgroundLayer then
for _, bgCel in ipairs(backgroundLayer.cels) do
if bgCel.frameNumber == cel.frameNumber then
bounds = getTrimBounds(bgCel.image)
break
end
end
end
if not bounds then bounds = getTrimBounds(image) end
if not bounds then
logFile:write(“→ Skipped: fully transparent\n”)
skippedCount = skippedCount + 1
return
end

local finalW, finalH = spr.width, spr.height
local offset = Point(0, 0)
if doTrim then
finalW, finalH = bounds.w, bounds.h
offset = Point(-bounds.x, -bounds.y)
end

local exportImage = Image(finalW, finalH, image.colorMode)

if backgroundLayer then
for _, bgCel in ipairs(backgroundLayer.cels) do
if bgCel.frameNumber == cel.frameNumber then
exportImage:drawImage(bgCel.image, offset + bgCel.position)
break
end
end
end

exportImage:drawImage(image, offset + cel.position)

ensureDir(folderPath, createdPaths)
local newSprite = Sprite(finalW, finalH)
newSprite:newCel(newSprite.layers[1], cel.frameNumber, exportImage, Point(0, 0))
local filename = folderPath .. name .. “.png”
newSprite:saveCopyAs(filename)
newSprite:close()

logFile:write(string.format(“→ Exported: %s (%dx%d)\n”, filename, finalW, finalH))
exportCount = exportCount + 1
end

local function exportGroupImage(group, name, folderPath)
local function findLayerIndex(container, target)
for i, layer in ipairs(container.layers) do
if layer == target then return i, container end
if layer.isGroup then
local idx, parent = findLayerIndex(layer, target)
if idx then return idx, parent end
end
end
end

local groupIndex, parent = findLayerIndex(spr, group)
if not groupIndex or not parent then
logFile:write(“→ Error: group index not found\n”)
skippedCount = skippedCount + 1
return
end

app.range.layers = { group }
app.command.FlattenLayers()

local flattenedLayer = parent.layers[groupIndex]
if not flattenedLayer or not flattenedLayer.isImage then
logFile:write(“→ Error: flatten failed\n”)
skippedCount = skippedCount + 1
return
end

for _, cel in ipairs(flattenedLayer.cels) do
exportCel(cel.image, cel, name, folderPath)
return
end
end

local function exportLayer(layer, folderPath)
if not layer.isVisible or layer.name:find(“#”) then
logFile:write("→ Skipped: " .. layer.name .. “\n”)
skippedCount = skippedCount + 1
return
end

for _, cel in ipairs(layer.cels) do
exportCel(cel.image, cel, layer.name, folderPath)
return
end
end

– Recursive walker
local function walk(container, parentGroup)
for _, layer in ipairs(container.layers) do
local name = layer.name or “(unnamed)”
local isIgnored = name:find(“#”) or name:sub(1,1) == “%” or name:sub(1,1) == “$”

if isIgnored then
  logFile:write("→ Skipped: " .. name .. "\n")
  skippedCount = skippedCount + 1
elseif not layer.isVisible then
  logFile:write("→ Skipped (not visible): " .. name .. "\n")
  skippedCount = skippedCount + 1
elseif isGroup(layer) then
  if name:sub(1,1) == "@" then
    local cleanName = name:sub(2)
    local folderPath = exportRoot .. (parentGroup and parentGroup .. "/" or "")
    exportGroupImage(layer, cleanName, folderPath)
  else
    local newGroupPath = parentGroup and (parentGroup .. "/" .. name) or name
    walk(layer, newGroupPath)
  end
elseif layer.isImage then
  local folderPath = exportRoot .. (parentGroup and parentGroup .. "/" or "")
  exportLayer(layer, folderPath)
else
  logFile:write("→ Skipped (unknown type): " .. name .. "\n")
  skippedCount = skippedCount + 1
end

end
end

walk(spr, nil)

logFile:write(“\nTotal exported : " .. exportCount .. “\n”)
logFile:write(“Total skipped : " .. skippedCount .. “\n”)
logFile:write(”------------\n\n”)
logFile:close()

app.alert(“Export complete: " .. exportCount .. " exported, " .. skippedCount .. " skipped.”)
end)

app.undo()

1 Like

Thanks a lot for the suggestion and the $ tweak to disable trimming on specific layers — that’s a great idea! :raising_hands:
I’m sure it’ll be useful for a lot of people.


Would you mind editing your post to wrap the full script inside a collapsible section ?
It would help keep the thread more readable, especially on mobile :blush:

You can do it like this :

script

all the script

[details="script"]
all the script
[/details]

I’ll include your addition in the next update on the GitHub repo, thanks again for contributing ! :innocent:

1 Like

I’ve just updated the script Export By Group (Recursive) to support a new handy feature:

:new_button: %choose: You can now use a layer named %choose to trigger a save dialog and select your export folder manually!


:wrench: How it works:

  • As before, placing a layer like %D:/My/Path/ exports sprites to that static path.
  • Now, if you name a layer %choose, the script will open a file dialog asking where to export the sprites.
  • All other export rules stay the same:
    ➤ flattening of groups starting with @
    ➤ trimming empty space
    ➤ folder hierarchy preserved
    ➤ layers or groups with # are ignored
    ➤ and a full export log is generated.

Aseprite_fdxwKnfBYT

Thanks for the suggestion! I’ll update this script in the future if I come across any more useful improvements. Until then… :slight_smile:

1 Like