Last active
May 30, 2026 10:23
-
-
Save HaydenReeve/bc42a9748dc73beaa14b708774acf5db to your computer and use it in GitHub Desktop.
Aseprite Export For FoundryVTT script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- Export For FoundryVTT | |
| -- Installation: | |
| -- 1. Save this file as "Export For FoundryVTT.lua". | |
| -- 2. Copy it into your Aseprite scripts folder: | |
| -- Windows: %APPDATA%\Aseprite\scripts | |
| -- macOS: ~/Library/Application Support/Aseprite/scripts | |
| -- Linux: ~/.config/aseprite/scripts | |
| -- 3. Create the "scripts" folder if it does not exist yet. | |
| -- 4. In Aseprite, run File > Scripts > Rescan Scripts, or restart Aseprite. | |
| -- 5. Run the script from File > Scripts > Export For FoundryVTT. | |
| -- | |
| -- Notes: | |
| -- - Your sprite must be saved before running the script. | |
| -- - The script exports PNG frames beside the source file, exporting every slice when slices | |
| -- exist (or the whole sprite when they do not), ignoring background layers, with optional | |
| -- white outline, optional nearest-neighbour upscaling, and a configurable output base name. | |
| -- - Empty slices are ignored to match Aseprite's export behaviour. | |
| -- - Outline is applied within exact export bounds, so tightly cropped slices can clip it. | |
| local function fail(message) | |
| if app.isUIAvailable then | |
| app.alert(message) | |
| return | |
| end | |
| error(message) | |
| end | |
| local spr = app.activeSprite | |
| if not spr then | |
| fail("No active sprite found.") | |
| return | |
| end | |
| local sprPath = spr.filename | |
| if sprPath == "" then | |
| fail("Please save your sprite first to establish an export directory.") | |
| return | |
| end | |
| local path = app.fs.filePath(sprPath) | |
| local baseName = app.fs.fileTitle(sprPath) | |
| local params = app.params or {} | |
| local scaleFactor = 10 | |
| local function hasValue(value) | |
| return value ~= nil and value ~= "" | |
| end | |
| local function hasAnyOptionParams() | |
| return hasValue(params.name) | |
| or hasValue(params.upscale) | |
| or hasValue(params.whiteOutline) | |
| or hasValue(params.outline) | |
| or hasValue(params.quiet) | |
| end | |
| local function parseBoolean(value, defaultValue, optionName) | |
| if value == nil or value == "" then | |
| return defaultValue | |
| end | |
| local valueType = type(value) | |
| if valueType == "boolean" then | |
| return value | |
| end | |
| if valueType == "number" then | |
| if value == 1 then | |
| return true | |
| elseif value == 0 then | |
| return false | |
| end | |
| end | |
| if valueType == "string" then | |
| local lowered = string.lower(value) | |
| if lowered == "1" or lowered == "true" then | |
| return true | |
| elseif lowered == "0" or lowered == "false" then | |
| return false | |
| end | |
| end | |
| error("Invalid boolean option for " .. optionName .. ": " .. tostring(value) .. ". Use true, false, 1, or 0.") | |
| end | |
| local function resolveExportName(value) | |
| if value == nil then | |
| return baseName | |
| end | |
| value = tostring(value):gsub("^%s+", ""):gsub("%s+$", "") | |
| if value == "" then | |
| return baseName | |
| end | |
| return value | |
| end | |
| local function resolveOutlineOption(value) | |
| if not value or value == "" then | |
| return "4-way" | |
| end | |
| if type(value) ~= "string" then | |
| value = tostring(value) | |
| end | |
| local requested = string.lower(value) | |
| if requested == "4-way" or requested == "4way" or requested == "circle" then | |
| return "4-way" | |
| elseif requested == "8-way" or requested == "8way" or requested == "square" then | |
| return "8-way" | |
| end | |
| error("Invalid outline option: " .. requested .. ". Use 4-way or 8-way.") | |
| end | |
| local function outlineMatrixForOption(option) | |
| if option == "8-way" then | |
| return "square" | |
| end | |
| return "circle" | |
| end | |
| local function setDialogWidthFromSizeHint(dlg, widthMultiplier) | |
| local bounds = dlg.bounds | |
| local sizeHint = dlg.sizeHint | |
| local targetWidth = math.max(bounds.width, math.floor(sizeHint.width * widthMultiplier + 0.5)) | |
| local targetHeight = math.max(bounds.height, sizeHint.height) | |
| bounds.size = Size(targetWidth, targetHeight) | |
| dlg.bounds = bounds | |
| end | |
| local function resolveExportOptions() | |
| local defaults = { | |
| name = baseName, | |
| upscale = true, | |
| whiteOutline = true, | |
| outline = "4-way", | |
| quiet = not app.isUIAvailable | |
| } | |
| if app.isUIAvailable and not hasAnyOptionParams() then | |
| local dlg = Dialog{ title="Export For FoundryVTT", resizeable=true } | |
| dlg:entry{ id="name", label="Name:", text=defaults.name, hexpand=true, focus=false } | |
| dlg:check{ id="upscale", label="Upscale:", selected=defaults.upscale } | |
| dlg:check{ id="whiteOutline", label="White outline:", selected=defaults.whiteOutline } | |
| dlg:combobox{ id="outline", label="Outline:", option=defaults.outline, options={"4-way", "8-way"} } | |
| dlg:button{ id="ok", text="Export", focus=true } | |
| dlg:button{ id="cancel", text="Cancel" } | |
| setDialogWidthFromSizeHint(dlg, 2) | |
| dlg:show() | |
| if not dlg.data.ok then | |
| return nil | |
| end | |
| return { | |
| name = resolveExportName(dlg.data.name), | |
| upscale = parseBoolean(dlg.data.upscale, defaults.upscale, "upscale"), | |
| whiteOutline = parseBoolean(dlg.data.whiteOutline, defaults.whiteOutline, "whiteOutline"), | |
| outline = resolveOutlineOption(dlg.data.outline), | |
| quiet = defaults.quiet | |
| } | |
| end | |
| return { | |
| name = resolveExportName(params.name), | |
| upscale = parseBoolean(params.upscale, defaults.upscale, "upscale"), | |
| whiteOutline = parseBoolean(params.whiteOutline, defaults.whiteOutline, "whiteOutline"), | |
| outline = resolveOutlineOption(params.outline), | |
| quiet = parseBoolean(params.quiet, defaults.quiet, "quiet") | |
| } | |
| end | |
| local options = resolveExportOptions() | |
| if not options then | |
| return | |
| end | |
| local quiet = options.quiet | |
| -- Helper for filename based on native Aseprite behavior | |
| local function getFrameFilename(base, frameIndex) | |
| local prefix, numStr = base:match("^(.-)(%d+)$") | |
| if prefix and numStr then | |
| local startNum = tonumber(numStr) | |
| local numLength = #numStr | |
| local currentNum = startNum + frameIndex - 1 | |
| return prefix .. string.format("%0" .. numLength .. "d", currentNum) | |
| else | |
| return base .. string.format("%03d", frameIndex) | |
| end | |
| end | |
| local function hasVisibleExportLayer(layers) | |
| for _, layer in ipairs(layers) do | |
| if layer.isVisible then | |
| if layer.isGroup then | |
| if hasVisibleExportLayer(layer.layers) then | |
| return true | |
| end | |
| elseif layer.isImage or layer.isTilemap then | |
| return true | |
| end | |
| end | |
| end | |
| return false | |
| end | |
| local function createWorkingSprite(sprite) | |
| local duplicate = Sprite(sprite) | |
| local backgroundLayer = duplicate.backgroundLayer | |
| if backgroundLayer then | |
| duplicate:deleteLayer(backgroundLayer) | |
| end | |
| if not hasVisibleExportLayer(duplicate.layers) then | |
| error("Sprite has no visible non-background layers to export.") | |
| end | |
| duplicate.selection = Selection() | |
| return duplicate | |
| end | |
| local function validateExportBounds(bounds, label, sprite) | |
| if bounds.width <= 0 or bounds.height <= 0 then | |
| error("Export region \"" .. label .. "\" is empty.") | |
| end | |
| if bounds.x < 0 or bounds.y < 0 | |
| or bounds.x + bounds.width > sprite.width | |
| or bounds.y + bounds.height > sprite.height then | |
| error("Export region \"" .. label .. "\" extends outside sprite bounds.") | |
| end | |
| return Rectangle(bounds.x, bounds.y, bounds.width, bounds.height) | |
| end | |
| local function getExportRegions(sprite) | |
| -- Lua exposes only static slice.bounds values, so animated slice keys cannot be exported here. | |
| if #sprite.slices == 0 then | |
| return { | |
| { | |
| label = "sprite", | |
| bounds = Rectangle(0, 0, sprite.width, sprite.height) | |
| } | |
| } | |
| end | |
| local regions = {} | |
| for index, slice in ipairs(sprite.slices) do | |
| local label = slice.name | |
| if label == "" then | |
| label = "slice #" .. index | |
| end | |
| regions[#regions + 1] = { | |
| label = label, | |
| bounds = validateExportBounds(slice.bounds, label, sprite) | |
| } | |
| end | |
| return regions | |
| end | |
| local createCompositeFrameImage | |
| local function filterEmptySliceRegions(sprite, regions) | |
| if #sprite.slices == 0 then | |
| return regions, 0 | |
| end | |
| local regionHasContent = {} | |
| for frameNumber = 1, #sprite.frames do | |
| local frameImage = createCompositeFrameImage(sprite, frameNumber) | |
| for regionIndex, region in ipairs(regions) do | |
| if not regionHasContent[regionIndex] then | |
| local regionImage = Image(frameImage, region.bounds) | |
| if regionImage and not regionImage:isEmpty() then | |
| regionHasContent[regionIndex] = true | |
| end | |
| end | |
| end | |
| end | |
| local filteredRegions = {} | |
| local ignoredCount = 0 | |
| for regionIndex, region in ipairs(regions) do | |
| if regionHasContent[regionIndex] then | |
| filteredRegions[#filteredRegions + 1] = region | |
| else | |
| ignoredCount = ignoredCount + 1 | |
| end | |
| end | |
| return filteredRegions, ignoredCount | |
| end | |
| local function getPaletteForFrame(sprite, frameNumber) | |
| return sprite.palettes[math.min(frameNumber, #sprite.palettes)] | |
| end | |
| createCompositeFrameImage = function(sprite, frameNumber) | |
| local image = Image(sprite.spec) | |
| image:drawSprite(sprite, frameNumber) | |
| return image | |
| end | |
| local function createExportSprite(sourceSprite, frameNumber, image) | |
| local exportSprite = Sprite(image.width, image.height, sourceSprite.colorMode) | |
| if sourceSprite.colorMode == ColorMode.INDEXED then | |
| exportSprite:setPalette(Palette(getPaletteForFrame(sourceSprite, frameNumber))) | |
| exportSprite.transparentColor = sourceSprite.transparentColor | |
| end | |
| exportSprite:newCel(exportSprite.layers[1], 1, image, Point(0, 0)) | |
| exportSprite.selection = Selection() | |
| return exportSprite | |
| end | |
| local function applyOutline(exportSprite) | |
| if not options.whiteOutline then | |
| return | |
| end | |
| app.activeSprite = exportSprite | |
| app.activeLayer = exportSprite.layers[1] | |
| app.activeFrame = exportSprite.frames[1] | |
| app.command.Outline{ | |
| ui=false, | |
| color=Color{r=255, g=255, b=255, a=255}, | |
| place="outside", | |
| matrix=outlineMatrixForOption(options.outline) | |
| } | |
| end | |
| local function applyScale(exportSprite) | |
| if not options.upscale then | |
| return | |
| end | |
| app.activeSprite = exportSprite | |
| app.command.SpriteSize{ | |
| ui=false, | |
| width=exportSprite.width * scaleFactor, | |
| height=exportSprite.height * scaleFactor, | |
| method="nearest" | |
| } | |
| end | |
| local dup = nil | |
| local exportCount = 0 | |
| local regionCount = 0 | |
| local hasSlices = #spr.slices > 0 | |
| local ignoredEmptySliceCount = 0 | |
| local runOk, runErr = pcall(function() | |
| dup = createWorkingSprite(spr) | |
| local regions = getExportRegions(dup) | |
| local totalFrames = #dup.frames | |
| regions, ignoredEmptySliceCount = filterEmptySliceRegions(dup, regions) | |
| regionCount = #regions | |
| for frameNumber = 1, totalFrames do | |
| local frameImage = createCompositeFrameImage(dup, frameNumber) | |
| for regionIndex, region in ipairs(regions) do | |
| local tmp = nil | |
| local tmpOk, tmpErr = pcall(function() | |
| local exportIndex = (regionIndex - 1) * totalFrames + frameNumber | |
| local regionImage = Image(frameImage, region.bounds) | |
| if not regionImage then | |
| error("Failed to crop export region \"" .. region.label .. "\" for frame " .. frameNumber .. ".") | |
| end | |
| tmp = createExportSprite(dup, frameNumber, regionImage) | |
| applyOutline(tmp) | |
| applyScale(tmp) | |
| local outName = app.fs.joinPath(path, getFrameFilename(options.name, exportIndex) .. ".png") | |
| tmp:saveCopyAs(outName) | |
| exportCount = exportCount + 1 | |
| end) | |
| if tmp then | |
| pcall(function() | |
| tmp:close() | |
| end) | |
| end | |
| if not tmpOk then | |
| error(tmpErr) | |
| end | |
| end | |
| end | |
| end) | |
| -- Clean up duplicate | |
| pcall(function() | |
| if dup then | |
| dup:close() | |
| end | |
| end) | |
| app.activeSprite = spr | |
| if not runOk then | |
| error(runErr) | |
| end | |
| local function buildCompletionMessage() | |
| local outlineText = options.whiteOutline and "with white outline" or "without outline" | |
| local scaleText = options.upscale and "at 1000% with nearest-neighbour scaling" or "at native size" | |
| local scopeText = hasSlices and string.format("across %d non-empty slices", regionCount) or "from the full sprite" | |
| local emptySliceText = ignoredEmptySliceCount > 0 | |
| and string.format(" Ignored %d empty slices.", ignoredEmptySliceCount) | |
| or "" | |
| return string.format( | |
| "Export complete! Saved %d individual .png files with basename \"%s\", %s, %s, %s. Background layers were ignored.%s", | |
| exportCount, | |
| options.name, | |
| scopeText, | |
| outlineText, | |
| scaleText, | |
| emptySliceText | |
| ) | |
| end | |
| local completionMessage = buildCompletionMessage() | |
| if quiet then | |
| print(completionMessage) | |
| else | |
| app.alert(completionMessage) | |
| end |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Warning
This Gist has been archived. The contents are now maintained here: https://github.com/HaydenReeve/Aseprite-General-Scripts