-- Aseprite layer normalizer for Spine tag workflow. -- Usage: open a sprite, then File > Scripts > Rescan Scripts, run this script. -- It renames Eyes/Mouth/Body/Accessory/Hand/Emote groups and their child layers to [slot:] / [skin:] tags. local spr = app.activeSprite if not spr then app.alert("No active sprite.") return end local warnings = {} local function warn(message) table.insert(warnings, message) end local function stripTags(name) local cleaned = name:gsub("%s*%[[^%]]+%]", "") cleaned = cleaned:gsub("^%s+", ""):gsub("%s+$", "") return cleaned end local function taggedName(base, tag) return base .. " [" .. tag .. "]" end local function lower(name) return string.lower(stripTags(name)) end local function findTopGroup(baseName) local target = string.lower(baseName) for _, layer in ipairs(spr.layers) do if layer.isGroup and lower(layer.name) == target then return layer end end return nil end local function findChildLayerByBase(parent, baseName) local target = string.lower(baseName) for _, layer in ipairs(parent.layers) do if lower(layer.name) == target then return layer end end return nil end local function renameIdleChildrenToNormal(parent, context) for _, layer in ipairs(parent.layers) do if layer.isGroup and lower(layer.name) == "idle" then warn(context .. ": renamed skin folder '" .. layer.name .. "' to 'Normal'.") layer.name = "Normal" end end end local function createLayerInGroup(parent, name) local layer = spr:newLayer() local moved = pcall(function() layer.parent = parent end) if not moved or layer.parent ~= parent then warn("Could not move copied layer '" .. name .. "' under '" .. stripTags(parent.name) .. "'; your Aseprite build may not support setting Layer.parent from scripts.") end layer.name = name return layer end local function copyLayerToGroup(source, parent, name) local copy = createLayerInGroup(parent, name) copy.opacity = source.opacity copy.blendMode = source.blendMode copy.isVisible = source.isVisible for _, cel in ipairs(source.cels) do local image = cel.image:clone() local newCel = spr:newCel(copy, cel.frame, image, cel.position) newCel.opacity = cel.opacity end return copy end local function levenshtein(a, b) local lenA, lenB = #a, #b local matrix = {} for i = 0, lenA do matrix[i] = {[0] = i} end for j = 0, lenB do matrix[0][j] = j end for i = 1, lenA do for j = 1, lenB do local cost = (a:sub(i, i) == b:sub(j, j)) and 0 or 1 local deletion = matrix[i - 1][j] + 1 local insertion = matrix[i][j - 1] + 1 local substitution = matrix[i - 1][j - 1] + cost matrix[i][j] = math.min(deletion, insertion, substitution) end end return matrix[lenA][lenB] end local function scoreName(name, target) local n = lower(name) local t = string.lower(target) if n == t then return 0 end if n:find(t, 1, true) then return 1 end if t:find(n, 1, true) then return 2 end return levenshtein(n, t) end local function normalizeExpectedChildren(parent, expected, required, context, excluded) excluded = excluded or {} local used = {} for _, expectedName in ipairs(expected) do local exact = findChildLayerByBase(parent, expectedName) if exact and not used[exact] then exact.name = expectedName used[exact] = true end end for _, expectedName in ipairs(expected) do if not findChildLayerByBase(parent, expectedName) then local bestLayer = nil local bestScore = 999 for _, layer in ipairs(parent.layers) do if not used[layer] and not excluded[lower(layer.name)] then local s = scoreName(layer.name, expectedName) if s < bestScore then bestScore = s bestLayer = layer end end end if bestLayer and bestScore <= 3 then warn(context .. ": renamed likely typo '" .. bestLayer.name .. "' to '" .. expectedName .. "'.") bestLayer.name = expectedName used[bestLayer] = true elseif required[expectedName] then warn(context .. ": missing required layer '" .. expectedName .. "'.") end end end end local function normalizeEyes() local group = findTopGroup("Eyes") if not group then warn("Missing top group: Eyes") return end group.name = taggedName("Eyes", "slot:Eyes") renameIdleChildrenToNormal(group, "Eyes") local normal = findChildLayerByBase(group, "Normal") for _, state in ipairs(group.layers) do if state.isGroup then local stateName = stripTags(state.name) state.name = taggedName(stateName, "skin:Eyes/" .. stateName) normalizeExpectedChildren(state, {"Brow", "Open", "Close", "Pupil"}, {Open = true, Close = true}, "Eyes/" .. stateName) for _, expectedName in ipairs({"Open", "Close"}) do if not findChildLayerByBase(state, expectedName) then local source = normal and findChildLayerByBase(normal, expectedName) if source then copyLayerToGroup(source, state, expectedName) warn("Eyes/" .. stateName .. ": copied missing '" .. expectedName .. "' from Eyes/Normal.") else warn("Eyes/" .. stateName .. ": cannot copy missing '" .. expectedName .. "' because Eyes/Normal lacks it.") end end end for _, layer in ipairs(state.layers) do local base = stripTags(layer.name) if base == "Pupil" then layer.name = taggedName("Pupil", "slot:Pupil") elseif base == "Brow" then layer.name = taggedName("Brow", "slot:Brow") else layer.name = taggedName(base, "slot:Eyes") end end end end end local function tagMouthLayers(state) for _, layer in ipairs(state.layers) do local base = stripTags(layer.name) if base == "Mustache" then layer.name = taggedName("Mustache", "slot:Mustache") else layer.name = taggedName(base, "slot:Mouth") end end end local function normalizeMouthState(state, context) local byIndex = {} for _, layer in ipairs(state.layers) do local base = stripTags(layer.name) local index = base:match("[_%- ]([012])%D*$") or base:match("[_%- ]0([012])%D*$") if index then local target = "Lips_0" .. index if not byIndex[target] then byIndex[target] = layer else warn(context .. ": duplicate candidate for " .. target .. " ('" .. layer.name .. "').") end end end for i = 0, 2 do local target = "Lips_0" .. i if byIndex[target] then if stripTags(byIndex[target].name) ~= target then warn(context .. ": renamed '" .. byIndex[target].name .. "' to '" .. target .. "'.") end byIndex[target].name = target end end normalizeExpectedChildren(state, {"Lips_00", "Lips_01", "Lips_02"}, {Lips_00 = true, Lips_01 = true, Lips_02 = true}, context, {mustache = true}) tagMouthLayers(state) end local function nearestMouthLayer(state, targetIndex) local bestLayer = nil local bestDistance = 999 for i = 0, 2 do local layer = findChildLayerByBase(state, "Lips_0" .. i) if layer then local distance = math.abs(i - targetIndex) if distance < bestDistance then bestDistance = distance bestLayer = layer end end end return bestLayer end local function fillMissingMouthLayers(state, context) for i = 0, 2 do local target = "Lips_0" .. i if not findChildLayerByBase(state, target) then local source = nearestMouthLayer(state, i) if source then copyLayerToGroup(source, state, target) warn(context .. ": copied nearest layer '" .. stripTags(source.name) .. "' to fill missing '" .. target .. "'.") else warn(context .. ": cannot fill missing '" .. target .. "' because no Lips layer exists.") end end end end local function normalizeMouth() local group = findTopGroup("Mouth") if not group then warn("Missing top group: Mouth") return end group.name = taggedName("Mouth", "slot:Mouth") renameIdleChildrenToNormal(group, "Mouth") for _, state in ipairs(group.layers) do if state.isGroup then local stateName = stripTags(state.name) state.name = taggedName(stateName, "skin:Mouth/" .. stateName) normalizeMouthState(state, "Mouth/" .. stateName) fillMissingMouthLayers(state, "Mouth/" .. stateName) tagMouthLayers(state) end end end local function normalizeBody() local group = findTopGroup("Body") if not group then warn("Missing top group: Body") return end group.name = taggedName("Body", "slot:Body") renameIdleChildrenToNormal(group, "Body") for _, skin in ipairs(group.layers) do if skin.isGroup then local skinName = stripTags(skin.name) skin.name = taggedName(skinName, "skin:Body/" .. skinName) for _, layer in ipairs(skin.layers) do local base = stripTags(layer.name) layer.name = taggedName(base, "slot:Body") end end end end local function normalizeOptionalSlotGroup(groupName, slotName) local group = findTopGroup(groupName) if not group then return end group.name = taggedName(groupName, "slot:" .. slotName) for _, layer in ipairs(group.layers) do local base = stripTags(layer.name) layer.name = taggedName(base, "slot:" .. slotName) end end local function normalizeAccessory() local group = findTopGroup("Accessory") if not group then warn("Missing top group: Accessory") return end group.name = taggedName("Accessory", "slot:Accessory") renameIdleChildrenToNormal(group, "Accessory") for _, skin in ipairs(group.layers) do if skin.isGroup then local skinName = stripTags(skin.name) skin.name = taggedName(skinName, "skin:Body/" .. skinName) for _, layer in ipairs(skin.layers) do local base = stripTags(layer.name) layer.name = taggedName(base, "slot:Accessory") end else local base = stripTags(skin.name) skin.name = taggedName(base, "slot:Accessory") warn("Accessory: layer '" .. base .. "' is directly under Accessory; expected it under a Body skin folder such as Normal or Wild.") end end end local function normalizeHand() local group = findTopGroup("Hand") if not group then return end group.name = taggedName("Hand", "slot:Hand") renameIdleChildrenToNormal(group, "Hand") for _, skin in ipairs(group.layers) do if skin.isGroup then local skinName = stripTags(skin.name) skin.name = taggedName(skinName, "skin:Body/" .. skinName) for _, layer in ipairs(skin.layers) do local base = stripTags(layer.name) layer.name = taggedName(base, "slot:Hand") end else local base = stripTags(skin.name) skin.name = taggedName(base, "slot:Hand") warn("Hand: layer '" .. base .. "' is directly under Hand; expected it under a Body skin folder such as Normal or Wild.") end end end app.transaction("Normalize Spine Tags", function() normalizeEyes() normalizeMouth() normalizeBody() normalizeAccessory() normalizeHand() normalizeOptionalSlotGroup("Emote_F", "Emote_F") normalizeOptionalSlotGroup("Emote_B", "Emote_B") end) if #warnings > 0 then app.alert({ title = "Spine tag normalization finished with warnings", text = warnings }) else app.alert("Spine tag normalization finished with no warnings.") end