@pebbly/aseprite-ai-artist 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +170 -0
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/agents/animation-director.md +65 -0
- package/agents/palette-smith.md +57 -0
- package/agents/pixel-critic.md +69 -0
- package/agents/rig-builder.md +46 -0
- package/bin/aseprite-ai-artist +6 -0
- package/bin/aseprite-ai-artist.mjs +44 -0
- package/dist/bridge/bridge.d.ts +52 -0
- package/dist/bridge/bridge.js +230 -0
- package/dist/bridge/bridge.js.map +1 -0
- package/dist/bridge/client.d.ts +54 -0
- package/dist/bridge/client.js +214 -0
- package/dist/bridge/client.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +272 -0
- package/dist/cli.js.map +1 -0
- package/dist/extension.d.ts +21 -0
- package/dist/extension.js +65 -0
- package/dist/extension.js.map +1 -0
- package/dist/install.d.ts +35 -0
- package/dist/install.js +185 -0
- package/dist/install.js.map +1 -0
- package/dist/lib/color.d.ts +66 -0
- package/dist/lib/color.js +170 -0
- package/dist/lib/color.js.map +1 -0
- package/dist/lib/protocol.d.ts +78 -0
- package/dist/lib/protocol.js +72 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/render.d.ts +62 -0
- package/dist/lib/render.js +134 -0
- package/dist/lib/render.js.map +1 -0
- package/dist/lib/skills.d.ts +43 -0
- package/dist/lib/skills.js +112 -0
- package/dist/lib/skills.js.map +1 -0
- package/dist/lib/version.d.ts +4 -0
- package/dist/lib/version.js +25 -0
- package/dist/lib/version.js.map +1 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +144 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/assets.d.ts +3 -0
- package/dist/tools/assets.js +181 -0
- package/dist/tools/assets.js.map +1 -0
- package/dist/tools/craft.d.ts +12 -0
- package/dist/tools/craft.js +143 -0
- package/dist/tools/craft.js.map +1 -0
- package/dist/tools/draw.d.ts +3 -0
- package/dist/tools/draw.js +256 -0
- package/dist/tools/draw.js.map +1 -0
- package/dist/tools/escape.d.ts +12 -0
- package/dist/tools/escape.js +49 -0
- package/dist/tools/escape.js.map +1 -0
- package/dist/tools/kit.d.ts +29 -0
- package/dist/tools/kit.js +85 -0
- package/dist/tools/kit.js.map +1 -0
- package/dist/tools/look.d.ts +7 -0
- package/dist/tools/look.js +215 -0
- package/dist/tools/look.js.map +1 -0
- package/dist/tools/palette.d.ts +3 -0
- package/dist/tools/palette.js +232 -0
- package/dist/tools/palette.js.map +1 -0
- package/dist/tools/session.d.ts +3 -0
- package/dist/tools/session.js +231 -0
- package/dist/tools/session.js.map +1 -0
- package/dist/tools/structure.d.ts +3 -0
- package/dist/tools/structure.js +218 -0
- package/dist/tools/structure.js.map +1 -0
- package/examples/mcp-configs/claude-code.json +9 -0
- package/examples/mcp-configs/codex.toml +13 -0
- package/examples/mcp-configs/cursor.json +9 -0
- package/examples/mcp-configs/gemini-cli.json +9 -0
- package/examples/mcp-configs/vscode.json +10 -0
- package/extension/ai-artist.lua +3291 -0
- package/extension/package.json +14 -0
- package/hooks/hooks.json +25 -0
- package/hooks/look-nudge.mjs +46 -0
- package/hooks/session-status.mjs +47 -0
- package/knowledge/palettes.json +60 -0
- package/package.json +69 -0
- package/rules/00-core-principles.md +57 -0
- package/rules/01-palette-and-color.md +51 -0
- package/rules/02-shading-and-light.md +56 -0
- package/rules/03-silhouette-and-form.md +60 -0
- package/rules/04-outlines-and-edges.md +46 -0
- package/rules/05-animation.md +62 -0
- package/rules/06-layers-and-rigging.md +52 -0
- package/rules/07-review-checklist.md +51 -0
- package/rules/README.md +16 -0
- package/skills/README.md +29 -0
- package/skills/pixel-animate/SKILL.md +91 -0
- package/skills/pixel-brief/SKILL.md +61 -0
- package/skills/pixel-draw/SKILL.md +93 -0
- package/skills/pixel-export/SKILL.md +80 -0
- package/skills/pixel-fix/SKILL.md +91 -0
- package/skills/pixel-new/SKILL.md +70 -0
- package/skills/pixel-palette/SKILL.md +80 -0
- package/skills/pixel-review/SKILL.md +81 -0
- package/skills/pixel-rig/SKILL.md +76 -0
- package/skills/pixel-shade/SKILL.md +77 -0
- package/skills/pixel-tileset/SKILL.md +104 -0
- package/templates/AGENTS.section.md +33 -0
|
@@ -0,0 +1,3291 @@
|
|
|
1
|
+
--------------------------------------------------------------------------------
|
|
2
|
+
-- Aseprite AI Artist — live link between a coding agent and this editor.
|
|
3
|
+
--
|
|
4
|
+
-- Aseprite's Lua WebSocket is a client only, so this dials out to a local bridge
|
|
5
|
+
-- rather than listening. Everything it accepts is a named command from a fixed
|
|
6
|
+
-- table; there is no eval path unless the operator explicitly enables `lua.run`
|
|
7
|
+
-- on the server side.
|
|
8
|
+
--
|
|
9
|
+
-- Design rules for anything added here:
|
|
10
|
+
-- * Never guess. An unknown command returns `unsupported_command` loudly so
|
|
11
|
+
-- the agent reports a missing feature instead of silently doing nothing.
|
|
12
|
+
-- * Every mutation runs inside app.transaction so the user's Ctrl+Z undoes
|
|
13
|
+
-- one agent action, not forty stray pixels.
|
|
14
|
+
-- * Never leave the user's active sprite/frame/layer changed as a side effect.
|
|
15
|
+
--------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
local PROTOCOL_VERSION = 1
|
|
18
|
+
local EXTENSION_VERSION = "0.1.0"
|
|
19
|
+
|
|
20
|
+
-- Optional capabilities. The wire version stays 1 across builds; new command
|
|
21
|
+
-- families are gated on these flags plus the loud unsupported_command reply,
|
|
22
|
+
-- so an older extension degrades visibly rather than mysteriously.
|
|
23
|
+
local FEATURES = { "draw_batch", "recolor", "validate", "tileset", "reference", "filmstrip" }
|
|
24
|
+
|
|
25
|
+
local CONFIG = {
|
|
26
|
+
host = "127.0.0.1",
|
|
27
|
+
port = tonumber(os.getenv("ASEPRITE_AI_PLUGIN_PORT") or "") or 9931,
|
|
28
|
+
reconnect_tick = 2, -- seconds between reconnect bookkeeping ticks
|
|
29
|
+
connect_max_ticks = 3, -- give a handshake ~6s before forcing a reset
|
|
30
|
+
debug = os.getenv("ASEPRITE_AI_DEBUG") == "1",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
local ws = nil
|
|
34
|
+
local connected = false
|
|
35
|
+
local connecting = false
|
|
36
|
+
local connecting_ticks = 0
|
|
37
|
+
local reconnect_timer = nil
|
|
38
|
+
-- Bumped on every connect attempt; events carrying an older value are stale.
|
|
39
|
+
local ws_generation = 0
|
|
40
|
+
-- Assigned near the bottom, once encode_json exists; see "Load marker".
|
|
41
|
+
local status_writer = nil
|
|
42
|
+
|
|
43
|
+
local function log(msg)
|
|
44
|
+
if CONFIG.debug then print("[ai-artist] " .. tostring(msg)) end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
--------------------------------------------------------------------------------
|
|
48
|
+
-- Errors
|
|
49
|
+
--------------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
-- A structured failure the agent can branch on. Raised with error() and caught
|
|
52
|
+
-- by the dispatcher; plain Lua errors become `aseprite_error`.
|
|
53
|
+
local function fault(code, message, details)
|
|
54
|
+
error({ __fault = true, code = code, message = message, details = details or {} }, 0)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
local function need(value, name)
|
|
58
|
+
if value == nil then fault("invalid_args", "Missing required argument '" .. name .. "'.") end
|
|
59
|
+
return value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
--------------------------------------------------------------------------------
|
|
63
|
+
-- Colour helpers
|
|
64
|
+
--------------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
local function hex_to_color(hex)
|
|
67
|
+
if type(hex) ~= "string" then fault("invalid_args", "Colour must be a string.") end
|
|
68
|
+
local s = hex:gsub("^#", "")
|
|
69
|
+
if #s ~= 6 and #s ~= 8 then
|
|
70
|
+
fault("invalid_args", "Colour '" .. hex .. "' must be #rrggbb or #rrggbbaa.")
|
|
71
|
+
end
|
|
72
|
+
return Color{
|
|
73
|
+
r = tonumber(s:sub(1, 2), 16),
|
|
74
|
+
g = tonumber(s:sub(3, 4), 16),
|
|
75
|
+
b = tonumber(s:sub(5, 6), 16),
|
|
76
|
+
a = #s == 8 and tonumber(s:sub(7, 8), 16) or 255,
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
local function color_to_hex(c, with_alpha)
|
|
81
|
+
if with_alpha and c.alpha ~= 255 then
|
|
82
|
+
return string.format("#%02x%02x%02x%02x", c.red, c.green, c.blue, c.alpha)
|
|
83
|
+
end
|
|
84
|
+
return string.format("#%02x%02x%02x", c.red, c.green, c.blue)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local function pixel_to_hex(sprite, value)
|
|
88
|
+
local pc = app.pixelColor
|
|
89
|
+
if sprite.colorMode == ColorMode.INDEXED then
|
|
90
|
+
if value == sprite.transparentColor then return nil end
|
|
91
|
+
local pal = sprite.palettes[1]
|
|
92
|
+
if value >= #pal then return nil end
|
|
93
|
+
return color_to_hex(pal:getColor(value), false)
|
|
94
|
+
elseif sprite.colorMode == ColorMode.GRAY then
|
|
95
|
+
local v, a = pc.grayaV(value), pc.grayaA(value)
|
|
96
|
+
if a == 0 then return nil end
|
|
97
|
+
return string.format("#%02x%02x%02x", v, v, v)
|
|
98
|
+
else
|
|
99
|
+
local a = pc.rgbaA(value)
|
|
100
|
+
if a == 0 then return nil end
|
|
101
|
+
if a == 255 then
|
|
102
|
+
return string.format("#%02x%02x%02x", pc.rgbaR(value), pc.rgbaG(value), pc.rgbaB(value))
|
|
103
|
+
end
|
|
104
|
+
return string.format("#%02x%02x%02x%02x", pc.rgbaR(value), pc.rgbaG(value), pc.rgbaB(value), a)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
-- Convert a Color into the raw pixel value this sprite's colour mode stores.
|
|
109
|
+
local function color_to_pixel(sprite, color)
|
|
110
|
+
local pc = app.pixelColor
|
|
111
|
+
if sprite.colorMode == ColorMode.INDEXED then
|
|
112
|
+
local pal = sprite.palettes[1]
|
|
113
|
+
local best, best_d = 0, math.huge
|
|
114
|
+
for i = 0, #pal - 1 do
|
|
115
|
+
local p = pal:getColor(i)
|
|
116
|
+
local d = (p.red - color.red) ^ 2 + (p.green - color.green) ^ 2 + (p.blue - color.blue) ^ 2
|
|
117
|
+
if d < best_d then best, best_d = i, d end
|
|
118
|
+
end
|
|
119
|
+
return best
|
|
120
|
+
elseif sprite.colorMode == ColorMode.GRAY then
|
|
121
|
+
local v = math.floor(0.299 * color.red + 0.587 * color.green + 0.114 * color.blue + 0.5)
|
|
122
|
+
return pc.graya(v, color.alpha)
|
|
123
|
+
end
|
|
124
|
+
return pc.rgba(color.red, color.green, color.blue, color.alpha)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
--------------------------------------------------------------------------------
|
|
128
|
+
-- CIELAB — palette snapping must be perceptual, not RGB-Euclidean.
|
|
129
|
+
-- Mirrors src/lib/color.ts so both sides agree on "nearest colour".
|
|
130
|
+
--------------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
local function srgb_to_linear(c)
|
|
133
|
+
local v = c / 255
|
|
134
|
+
if v <= 0.04045 then return v / 12.92 end
|
|
135
|
+
return ((v + 0.055) / 1.055) ^ 2.4
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
local function rgb_to_lab(r, g, b)
|
|
139
|
+
local lr, lg, lb = srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)
|
|
140
|
+
local x = (0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb) / 0.95047
|
|
141
|
+
local y = (0.2126729 * lr + 0.7151522 * lg + 0.0721750 * lb) / 1.00000
|
|
142
|
+
local z = (0.0193339 * lr + 0.1191920 * lg + 0.9503041 * lb) / 1.08883
|
|
143
|
+
local function f(t)
|
|
144
|
+
if t > 216 / 24389 then return t ^ (1 / 3) end
|
|
145
|
+
return (24389 / 27) * t / 116 + 16 / 116
|
|
146
|
+
end
|
|
147
|
+
local fx, fy, fz = f(x), f(y), f(z)
|
|
148
|
+
return 116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
local function delta_e(r1, g1, b1, r2, g2, b2)
|
|
152
|
+
local l1, a1, bb1 = rgb_to_lab(r1, g1, b1)
|
|
153
|
+
local l2, a2, bb2 = rgb_to_lab(r2, g2, b2)
|
|
154
|
+
return math.sqrt((l1 - l2) ^ 2 + (a1 - a2) ^ 2 + (bb1 - bb2) ^ 2)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
local function snap_color_to_palette(sprite, color)
|
|
158
|
+
local pal = sprite.palettes[1]
|
|
159
|
+
if not pal or #pal == 0 then return color, 0 end
|
|
160
|
+
local best, best_d = nil, math.huge
|
|
161
|
+
for i = 0, #pal - 1 do
|
|
162
|
+
local p = pal:getColor(i)
|
|
163
|
+
if p.alpha > 0 then
|
|
164
|
+
local d = delta_e(color.red, color.green, color.blue, p.red, p.green, p.blue)
|
|
165
|
+
if d < best_d then best, best_d = p, d end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
if not best then return color, 0 end
|
|
169
|
+
return Color{ r = best.red, g = best.green, b = best.blue, a = color.alpha }, best_d
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
--------------------------------------------------------------------------------
|
|
173
|
+
-- Site resolution — every command works against an explicit target, falling
|
|
174
|
+
-- back to whatever the user has focused, and restores focus when it is done.
|
|
175
|
+
--------------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
local function sprite_display_name(s)
|
|
178
|
+
local base = app.fs.fileName(s.filename or "")
|
|
179
|
+
return base ~= "" and base or "untitled"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
--- Resolve a sprite from an id ("#7" or 7), a full path, or a display name.
|
|
183
|
+
--- Ids are what every result reports back, because two unsaved documents both
|
|
184
|
+
--- answer to "Sprite" and a name lookup silently picks whichever comes first.
|
|
185
|
+
local function find_sprite(name)
|
|
186
|
+
if name == nil or name == "" then
|
|
187
|
+
local s = app.sprite
|
|
188
|
+
if not s then fault("no_active_sprite", "No sprite is open in Aseprite.") end
|
|
189
|
+
return s
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
local wanted_id = tonumber(tostring(name):match("^#?(%d+)$"))
|
|
193
|
+
if wanted_id then
|
|
194
|
+
for _, s in ipairs(app.sprites) do
|
|
195
|
+
if s.id == wanted_id then return s end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
local matches = {}
|
|
200
|
+
for _, s in ipairs(app.sprites) do
|
|
201
|
+
if s.filename == name or sprite_display_name(s) == name
|
|
202
|
+
or sprite_display_name(s) == name then
|
|
203
|
+
matches[#matches + 1] = s
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
if #matches == 1 then return matches[1] end
|
|
207
|
+
if #matches > 1 then
|
|
208
|
+
local ids = {}
|
|
209
|
+
for _, s in ipairs(matches) do ids[#ids + 1] = "#" .. tostring(s.id) end
|
|
210
|
+
fault("invalid_args", "'" .. tostring(name) .. "' matches " .. #matches ..
|
|
211
|
+
" open sprites (" .. table.concat(ids, ", ") .. "). Pass one of those ids instead.")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
local open = {}
|
|
215
|
+
for _, s in ipairs(app.sprites) do
|
|
216
|
+
open[#open + 1] = sprite_display_name(s) .. " (#" .. tostring(s.id) .. ")"
|
|
217
|
+
end
|
|
218
|
+
fault("invalid_args", "No open sprite matches '" .. tostring(name) .. "'. Open: " ..
|
|
219
|
+
(#open > 0 and table.concat(open, ", ") or "(none)") .. ".")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
local function find_layer(sprite, name)
|
|
223
|
+
if name == nil or name == "" then
|
|
224
|
+
local l = app.layer
|
|
225
|
+
if l and l.sprite == sprite then return l end
|
|
226
|
+
if #sprite.layers == 0 then fault("invalid_args", "Sprite has no layers.") end
|
|
227
|
+
return sprite.layers[#sprite.layers]
|
|
228
|
+
end
|
|
229
|
+
local function search(layers)
|
|
230
|
+
for _, l in ipairs(layers) do
|
|
231
|
+
if l.name == name then return l end
|
|
232
|
+
if l.isGroup then
|
|
233
|
+
local found = search(l.layers)
|
|
234
|
+
if found then return found end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
return nil
|
|
238
|
+
end
|
|
239
|
+
local found = search(sprite.layers)
|
|
240
|
+
if not found then fault("invalid_args", "No layer named '" .. tostring(name) .. "'.") end
|
|
241
|
+
return found
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
local function find_frame(sprite, number)
|
|
245
|
+
if number == nil then
|
|
246
|
+
local f = app.frame
|
|
247
|
+
if f and f.sprite == sprite then return f end
|
|
248
|
+
return sprite.frames[1]
|
|
249
|
+
end
|
|
250
|
+
local n = math.floor(number)
|
|
251
|
+
if n < 1 or n > #sprite.frames then
|
|
252
|
+
fault("invalid_args", "Frame " .. n .. " is out of range (1.." .. #sprite.frames .. ").")
|
|
253
|
+
end
|
|
254
|
+
return sprite.frames[n]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
-- Run fn with the user's active sprite/layer/frame restored afterwards.
|
|
258
|
+
local function preserving_site(fn)
|
|
259
|
+
local sprite, layer, frame = app.sprite, app.layer, app.frame
|
|
260
|
+
local ok, result = pcall(fn)
|
|
261
|
+
if sprite then pcall(function() app.sprite = sprite end) end
|
|
262
|
+
if layer then pcall(function() app.layer = layer end) end
|
|
263
|
+
if frame then pcall(function() app.frame = frame end) end
|
|
264
|
+
if not ok then error(result, 0) end
|
|
265
|
+
return result
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
-- app.transaction gained a name argument in 1.3; fall back for older builds so
|
|
269
|
+
-- the extension still works, just with a less descriptive undo entry.
|
|
270
|
+
local function transact(name, fn)
|
|
271
|
+
local ok, err = pcall(function() app.transaction(name, fn) end)
|
|
272
|
+
if ok then return end
|
|
273
|
+
local ok2, err2 = pcall(function() app.transaction(fn) end)
|
|
274
|
+
if not ok2 then error(err2 or err, 0) end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
--------------------------------------------------------------------------------
|
|
278
|
+
-- Cel access
|
|
279
|
+
--------------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
--- True when this cel shares its image with another frame's cel on the same
|
|
282
|
+
--- layer, which is what Aseprite's "linked cel" is.
|
|
283
|
+
local function is_linked_cel(layer, cel)
|
|
284
|
+
local id = cel.image and cel.image.id
|
|
285
|
+
if not id then return false end
|
|
286
|
+
for _, other in ipairs(layer.cels) do
|
|
287
|
+
if other.frameNumber ~= cel.frameNumber and other.image and other.image.id == id then
|
|
288
|
+
return true
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
return false
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
local function get_cel(sprite, layer, frame, create)
|
|
295
|
+
local cel = layer:cel(frame)
|
|
296
|
+
if cel then return cel end
|
|
297
|
+
if not create then return nil end
|
|
298
|
+
if layer.isGroup then fault("invalid_args", "'" .. layer.name .. "' is a group and cannot hold pixels.") end
|
|
299
|
+
local image = Image(sprite.width, sprite.height, sprite.colorMode)
|
|
300
|
+
return sprite:newCel(layer, frame, image, Point(0, 0))
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
-- Cels are only as large as their content, so a draw at canvas coordinates has
|
|
304
|
+
-- to grow the cel first. Growing means a new image the size of the union.
|
|
305
|
+
local function ensure_cel_covers(sprite, cel, bounds)
|
|
306
|
+
local current = cel.bounds
|
|
307
|
+
local minx = math.min(current.x, bounds.x)
|
|
308
|
+
local miny = math.min(current.y, bounds.y)
|
|
309
|
+
local maxx = math.max(current.x + current.width, bounds.x + bounds.width)
|
|
310
|
+
local maxy = math.max(current.y + current.height, bounds.y + bounds.height)
|
|
311
|
+
|
|
312
|
+
-- Clamp to the canvas; pixels outside it are invisible and Aseprite trims them.
|
|
313
|
+
minx = math.max(0, minx); miny = math.max(0, miny)
|
|
314
|
+
maxx = math.min(sprite.width, maxx); maxy = math.min(sprite.height, maxy)
|
|
315
|
+
if maxx <= minx or maxy <= miny then return cel end
|
|
316
|
+
|
|
317
|
+
if minx == current.x and miny == current.y
|
|
318
|
+
and maxx == current.x + current.width and maxy == current.y + current.height then
|
|
319
|
+
return cel
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
local grown = Image(maxx - minx, maxy - miny, sprite.colorMode)
|
|
323
|
+
grown:drawImage(cel.image, Point(current.x - minx, current.y - miny))
|
|
324
|
+
cel.image = grown
|
|
325
|
+
cel.position = Point(minx, miny)
|
|
326
|
+
return cel
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
--------------------------------------------------------------------------------
|
|
330
|
+
-- Region reading
|
|
331
|
+
--------------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
local function clamp_region(sprite, region)
|
|
334
|
+
local r = region or {}
|
|
335
|
+
local x = math.max(0, math.floor(r.x or 0))
|
|
336
|
+
local y = math.max(0, math.floor(r.y or 0))
|
|
337
|
+
local w = math.floor(r.width or (sprite.width - x))
|
|
338
|
+
local h = math.floor(r.height or (sprite.height - y))
|
|
339
|
+
w = math.min(w, sprite.width - x)
|
|
340
|
+
h = math.min(h, sprite.height - y)
|
|
341
|
+
if w <= 0 or h <= 0 then
|
|
342
|
+
fault("invalid_args", "Region is empty after clamping to the " ..
|
|
343
|
+
sprite.width .. "x" .. sprite.height .. " canvas.")
|
|
344
|
+
end
|
|
345
|
+
return { x = x, y = y, width = w, height = h }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
-- Returns { colors = {...}, grid = {...} } where index 1 (wire index 0) is
|
|
349
|
+
-- always transparent. Sending distinct colours plus indices keeps a 64x64 read
|
|
350
|
+
-- at a few KB instead of a wall of hex strings.
|
|
351
|
+
local function read_region(sprite, layer, frame, region, composite)
|
|
352
|
+
local r = clamp_region(sprite, region)
|
|
353
|
+
local source
|
|
354
|
+
|
|
355
|
+
if composite then
|
|
356
|
+
source = Image(sprite.width, sprite.height, sprite.colorMode)
|
|
357
|
+
source:drawSprite(sprite, frame)
|
|
358
|
+
else
|
|
359
|
+
local cel = get_cel(sprite, layer, frame, false)
|
|
360
|
+
source = Image(sprite.width, sprite.height, sprite.colorMode)
|
|
361
|
+
if cel then source:drawImage(cel.image, cel.position) end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
local colors = { false } -- placeholder; index 1 == transparent
|
|
365
|
+
local lookup = {}
|
|
366
|
+
local grid = {}
|
|
367
|
+
local n = 0
|
|
368
|
+
|
|
369
|
+
for row = 0, r.height - 1 do
|
|
370
|
+
for col = 0, r.width - 1 do
|
|
371
|
+
local hex = pixel_to_hex(sprite, source:getPixel(r.x + col, r.y + row))
|
|
372
|
+
local idx = 0
|
|
373
|
+
if hex then
|
|
374
|
+
idx = lookup[hex]
|
|
375
|
+
if not idx then
|
|
376
|
+
n = n + 1
|
|
377
|
+
idx = n
|
|
378
|
+
lookup[hex] = idx
|
|
379
|
+
colors[idx + 1] = hex
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
grid[#grid + 1] = idx
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
colors[1] = "#00000000"
|
|
387
|
+
return {
|
|
388
|
+
sprite = app.fs.fileName(sprite.filename or "") ,
|
|
389
|
+
x = r.x, y = r.y, width = r.width, height = r.height,
|
|
390
|
+
colors = colors, grid = grid,
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
--------------------------------------------------------------------------------
|
|
395
|
+
-- Drawing primitives, operating on a plain image at cel-local coordinates.
|
|
396
|
+
--------------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
local Draw = {}
|
|
399
|
+
|
|
400
|
+
function Draw.pixel(img, ox, oy, x, y, value)
|
|
401
|
+
local lx, ly = x - ox, y - oy
|
|
402
|
+
if lx >= 0 and ly >= 0 and lx < img.width and ly < img.height then
|
|
403
|
+
img:drawPixel(lx, ly, value)
|
|
404
|
+
return 1
|
|
405
|
+
end
|
|
406
|
+
return 0
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
function Draw.line(img, ox, oy, x0, y0, x1, y1, value, thickness)
|
|
410
|
+
-- Bresenham. Aseprite has a line tool, but going through app.useTool changes
|
|
411
|
+
-- the user's brush and active colour, which is rude in the middle of a batch.
|
|
412
|
+
local count = 0
|
|
413
|
+
local dx = math.abs(x1 - x0)
|
|
414
|
+
local dy = -math.abs(y1 - y0)
|
|
415
|
+
local sx = x0 < x1 and 1 or -1
|
|
416
|
+
local sy = y0 < y1 and 1 or -1
|
|
417
|
+
local err = dx + dy
|
|
418
|
+
local half = math.floor((thickness or 1) / 2)
|
|
419
|
+
|
|
420
|
+
while true do
|
|
421
|
+
if (thickness or 1) <= 1 then
|
|
422
|
+
count = count + Draw.pixel(img, ox, oy, x0, y0, value)
|
|
423
|
+
else
|
|
424
|
+
for ty = -half, half do
|
|
425
|
+
for tx = -half, half do
|
|
426
|
+
count = count + Draw.pixel(img, ox, oy, x0 + tx, y0 + ty, value)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
if x0 == x1 and y0 == y1 then break end
|
|
431
|
+
local e2 = 2 * err
|
|
432
|
+
if e2 >= dy then err = err + dy; x0 = x0 + sx end
|
|
433
|
+
if e2 <= dx then err = err + dx; y0 = y0 + sy end
|
|
434
|
+
end
|
|
435
|
+
return count
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
function Draw.rect(img, ox, oy, r, outline, fill)
|
|
439
|
+
local count = 0
|
|
440
|
+
if fill then
|
|
441
|
+
for y = r.y, r.y + r.height - 1 do
|
|
442
|
+
for x = r.x, r.x + r.width - 1 do
|
|
443
|
+
count = count + Draw.pixel(img, ox, oy, x, y, fill)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
if outline then
|
|
448
|
+
local x1, y1 = r.x + r.width - 1, r.y + r.height - 1
|
|
449
|
+
count = count + Draw.line(img, ox, oy, r.x, r.y, x1, r.y, outline, 1)
|
|
450
|
+
count = count + Draw.line(img, ox, oy, r.x, y1, x1, y1, outline, 1)
|
|
451
|
+
count = count + Draw.line(img, ox, oy, r.x, r.y, r.x, y1, outline, 1)
|
|
452
|
+
count = count + Draw.line(img, ox, oy, x1, r.y, x1, y1, outline, 1)
|
|
453
|
+
end
|
|
454
|
+
return count
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
function Draw.ellipse(img, ox, oy, r, outline, fill)
|
|
458
|
+
-- Midpoint ellipse over the bounding box, scan-filled row by row so an
|
|
459
|
+
-- odd-width ellipse stays symmetric — asymmetry is very visible at 16px.
|
|
460
|
+
local count = 0
|
|
461
|
+
local cx = r.x + (r.width - 1) / 2
|
|
462
|
+
local cy = r.y + (r.height - 1) / 2
|
|
463
|
+
local rx = (r.width - 1) / 2
|
|
464
|
+
local ry = (r.height - 1) / 2
|
|
465
|
+
if rx < 0.5 or ry < 0.5 then
|
|
466
|
+
return Draw.rect(img, ox, oy, r, outline, fill)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
local function inside(x, y)
|
|
470
|
+
local nx = (x - cx) / rx
|
|
471
|
+
local ny = (y - cy) / ry
|
|
472
|
+
return nx * nx + ny * ny <= 1.0
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
for y = r.y, r.y + r.height - 1 do
|
|
476
|
+
for x = r.x, r.x + r.width - 1 do
|
|
477
|
+
if inside(x, y) then
|
|
478
|
+
local edge = not (inside(x - 1, y) and inside(x + 1, y) and inside(x, y - 1) and inside(x, y + 1))
|
|
479
|
+
local value = edge and outline or fill
|
|
480
|
+
if value then count = count + Draw.pixel(img, ox, oy, x, y, value) end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
return count
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
function Draw.polygon(img, ox, oy, points, outline, fill, closed)
|
|
488
|
+
local count = 0
|
|
489
|
+
if fill and #points >= 3 then
|
|
490
|
+
local miny, maxy = math.huge, -math.huge
|
|
491
|
+
for _, p in ipairs(points) do
|
|
492
|
+
miny = math.min(miny, p.y); maxy = math.max(maxy, p.y)
|
|
493
|
+
end
|
|
494
|
+
for y = math.floor(miny), math.floor(maxy) do
|
|
495
|
+
local crossings = {}
|
|
496
|
+
for i = 1, #points do
|
|
497
|
+
local a = points[i]
|
|
498
|
+
local b = points[i % #points + 1]
|
|
499
|
+
if (a.y <= y and b.y > y) or (b.y <= y and a.y > y) then
|
|
500
|
+
crossings[#crossings + 1] = a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
table.sort(crossings)
|
|
504
|
+
for i = 1, #crossings - 1, 2 do
|
|
505
|
+
for x = math.floor(crossings[i] + 0.5), math.floor(crossings[i + 1] - 0.5) do
|
|
506
|
+
count = count + Draw.pixel(img, ox, oy, x, y, fill)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
if outline then
|
|
512
|
+
-- Wrap the same way the fill loop above does. Stopping at #points - 1 draws
|
|
513
|
+
-- every edge except the one that closes the shape, so a "closed" triangle
|
|
514
|
+
-- shipped with one side missing and the call still reported success.
|
|
515
|
+
local last = closed and #points or (#points - 1)
|
|
516
|
+
for i = 1, last do
|
|
517
|
+
local a = points[i]
|
|
518
|
+
local b = points[i % #points + 1]
|
|
519
|
+
count = count + Draw.line(img, ox, oy, a.x, a.y, b.x, b.y, outline, 1)
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
return count
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
function Draw.flood(img, ox, oy, sx, sy, value, tolerance, contiguous)
|
|
526
|
+
local lx, ly = sx - ox, sy - oy
|
|
527
|
+
if lx < 0 or ly < 0 or lx >= img.width or ly >= img.height then return 0 end
|
|
528
|
+
local target = img:getPixel(lx, ly)
|
|
529
|
+
if target == value then return 0 end
|
|
530
|
+
|
|
531
|
+
local count = 0
|
|
532
|
+
if not contiguous then
|
|
533
|
+
for y = 0, img.height - 1 do
|
|
534
|
+
for x = 0, img.width - 1 do
|
|
535
|
+
if img:getPixel(x, y) == target then
|
|
536
|
+
img:drawPixel(x, y, value); count = count + 1
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
return count
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
local stack = { { lx, ly } }
|
|
544
|
+
local seen = {}
|
|
545
|
+
while #stack > 0 do
|
|
546
|
+
local p = table.remove(stack)
|
|
547
|
+
local x, y = p[1], p[2]
|
|
548
|
+
local key = y * img.width + x
|
|
549
|
+
if not seen[key] and x >= 0 and y >= 0 and x < img.width and y < img.height then
|
|
550
|
+
seen[key] = true
|
|
551
|
+
if img:getPixel(x, y) == target then
|
|
552
|
+
img:drawPixel(x, y, value)
|
|
553
|
+
count = count + 1
|
|
554
|
+
stack[#stack + 1] = { x + 1, y }
|
|
555
|
+
stack[#stack + 1] = { x - 1, y }
|
|
556
|
+
stack[#stack + 1] = { x, y + 1 }
|
|
557
|
+
stack[#stack + 1] = { x, y - 1 }
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
return count
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
-- Ordered dither matrices. Bayer keeps texture even at low pixel counts, where
|
|
565
|
+
-- random noise just looks like dirt.
|
|
566
|
+
local BAYER = {
|
|
567
|
+
checker = { size = 2, m = { { 0, 2 }, { 3, 1 } } },
|
|
568
|
+
bayer2 = { size = 2, m = { { 0, 2 }, { 3, 1 } } },
|
|
569
|
+
bayer4 = { size = 4, m = {
|
|
570
|
+
{ 0, 8, 2, 10 }, { 12, 4, 14, 6 }, { 3, 11, 1, 9 }, { 15, 7, 13, 5 } } },
|
|
571
|
+
bayer8 = { size = 8, m = nil }, -- built below
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
do
|
|
575
|
+
-- 8x8 Bayer generated from the 4x4 recurrence rather than typed out.
|
|
576
|
+
local m4 = BAYER.bayer4.m
|
|
577
|
+
local m8 = {}
|
|
578
|
+
for y = 1, 8 do
|
|
579
|
+
m8[y] = {}
|
|
580
|
+
for x = 1, 8 do
|
|
581
|
+
local q = m4[((y - 1) % 4) + 1][((x - 1) % 4) + 1]
|
|
582
|
+
local quadrant = (y > 4 and 2 or 0) + (x > 4 and 1 or 0)
|
|
583
|
+
m8[y][x] = q * 4 + ({ [0] = 0, [1] = 2, [2] = 3, [3] = 1 })[quadrant]
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
BAYER.bayer8.m = m8
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
function Draw.dither(img, ox, oy, r, a, b, pattern, ratio)
|
|
590
|
+
local spec = BAYER[pattern]
|
|
591
|
+
local count = 0
|
|
592
|
+
for y = r.y, r.y + r.height - 1 do
|
|
593
|
+
for x = r.x, r.x + r.width - 1 do
|
|
594
|
+
local pick
|
|
595
|
+
if pattern == "noise" then
|
|
596
|
+
pick = math.random() < ratio
|
|
597
|
+
else
|
|
598
|
+
local size = spec.size
|
|
599
|
+
local threshold = (spec.m[(y % size) + 1][(x % size) + 1] + 0.5) / (size * size)
|
|
600
|
+
pick = ratio > threshold
|
|
601
|
+
end
|
|
602
|
+
count = count + Draw.pixel(img, ox, oy, x, y, pick and b or a)
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
return count
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
--------------------------------------------------------------------------------
|
|
609
|
+
-- JSON encoding
|
|
610
|
+
--
|
|
611
|
+
-- Aseprite ships json.decode/json.encode, but its encoder renders an empty Lua
|
|
612
|
+
-- table as `{}`, and this protocol has fields the server validates as arrays
|
|
613
|
+
-- (colorsSnapped, findings, files…). An empty result would fail schema
|
|
614
|
+
-- validation on the far side and turn a successful edit into an error the user
|
|
615
|
+
-- sees. Encoding here fixes the rule explicitly: a table is an array unless it
|
|
616
|
+
-- has a non-integer key.
|
|
617
|
+
--------------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
local function is_array(t)
|
|
620
|
+
for k in pairs(t) do
|
|
621
|
+
if type(k) ~= "number" then return false end
|
|
622
|
+
end
|
|
623
|
+
return true
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
local ESCAPES = {
|
|
627
|
+
['"'] = '\\"', ["\\"] = "\\\\", ["\b"] = "\\b", ["\f"] = "\\f",
|
|
628
|
+
["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t",
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
local function encode_string(s)
|
|
632
|
+
return '"' .. s:gsub('[%c"\\]', function(c)
|
|
633
|
+
return ESCAPES[c] or string.format("\\u%04x", c:byte())
|
|
634
|
+
end) .. '"'
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
local encode_value
|
|
638
|
+
|
|
639
|
+
local function encode_table(t, depth)
|
|
640
|
+
if depth > 24 then return "null" end
|
|
641
|
+
if is_array(t) then
|
|
642
|
+
local parts = {}
|
|
643
|
+
for i = 1, #t do parts[i] = encode_value(t[i], depth + 1) end
|
|
644
|
+
return "[" .. table.concat(parts, ",") .. "]"
|
|
645
|
+
end
|
|
646
|
+
local parts = {}
|
|
647
|
+
for k, v in pairs(t) do
|
|
648
|
+
local encoded = encode_value(v, depth + 1)
|
|
649
|
+
if encoded ~= nil then
|
|
650
|
+
parts[#parts + 1] = encode_string(tostring(k)) .. ":" .. encoded
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
return "{" .. table.concat(parts, ",") .. "}"
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
encode_value = function(v, depth)
|
|
657
|
+
local t = type(v)
|
|
658
|
+
if v == nil then return "null" end
|
|
659
|
+
if t == "boolean" then return tostring(v) end
|
|
660
|
+
if t == "string" then return encode_string(v) end
|
|
661
|
+
if t == "number" then
|
|
662
|
+
if v ~= v or v == math.huge or v == -math.huge then return "null" end
|
|
663
|
+
if v == math.floor(v) and math.abs(v) < 2 ^ 53 then return string.format("%d", v) end
|
|
664
|
+
return string.format("%.6g", v)
|
|
665
|
+
end
|
|
666
|
+
if t == "table" then return encode_table(v, depth or 0) end
|
|
667
|
+
return encode_string(tostring(v))
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
local function encode_json(v) return encode_value(v, 0) end
|
|
671
|
+
|
|
672
|
+
--------------------------------------------------------------------------------
|
|
673
|
+
-- Command handlers
|
|
674
|
+
--------------------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
local H = {}
|
|
677
|
+
|
|
678
|
+
local function sprite_summary(s)
|
|
679
|
+
return {
|
|
680
|
+
id = s.id,
|
|
681
|
+
name = sprite_display_name(s),
|
|
682
|
+
filename = s.filename ~= "" and s.filename or nil,
|
|
683
|
+
width = s.width,
|
|
684
|
+
height = s.height,
|
|
685
|
+
colorMode = ({ [ColorMode.RGB] = "rgb", [ColorMode.GRAY] = "grayscale", [ColorMode.INDEXED] = "indexed" })[s.colorMode],
|
|
686
|
+
frames = #s.frames,
|
|
687
|
+
layers = #s.layers,
|
|
688
|
+
}
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
H["session.site"] = function()
|
|
692
|
+
return {
|
|
693
|
+
sprite = app.sprite and sprite_summary(app.sprite) or nil,
|
|
694
|
+
layer = app.layer and app.layer.name or nil,
|
|
695
|
+
frame = app.frame and app.frame.frameNumber or nil,
|
|
696
|
+
openSprites = #app.sprites,
|
|
697
|
+
}
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
local function flatten_layers(layers, parent, out)
|
|
701
|
+
for _, l in ipairs(layers) do
|
|
702
|
+
local cels = {}
|
|
703
|
+
if not l.isGroup then
|
|
704
|
+
for _, c in ipairs(l.cels) do cels[#cels + 1] = c.frameNumber end
|
|
705
|
+
end
|
|
706
|
+
out[#out + 1] = {
|
|
707
|
+
name = l.name,
|
|
708
|
+
index = l.stackIndex,
|
|
709
|
+
visible = l.isVisible,
|
|
710
|
+
editable = l.isEditable,
|
|
711
|
+
opacity = l.opacity or 255,
|
|
712
|
+
blendMode = tostring(l.blendMode or "normal"),
|
|
713
|
+
isGroup = l.isGroup or false,
|
|
714
|
+
isTilemap = l.isTilemap or false,
|
|
715
|
+
parent = parent,
|
|
716
|
+
cels = cels,
|
|
717
|
+
}
|
|
718
|
+
if l.isGroup then flatten_layers(l.layers, l.name, out) end
|
|
719
|
+
end
|
|
720
|
+
return out
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
H["sprite.info"] = function(args)
|
|
724
|
+
local s = find_sprite(args.sprite)
|
|
725
|
+
local layers = flatten_layers(s.layers, nil, {})
|
|
726
|
+
|
|
727
|
+
local frames = {}
|
|
728
|
+
for i, f in ipairs(s.frames) do
|
|
729
|
+
frames[i] = { number = i, durationMs = math.floor(f.duration * 1000 + 0.5) }
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
local tags = {}
|
|
733
|
+
for i, t in ipairs(s.tags) do
|
|
734
|
+
tags[i] = {
|
|
735
|
+
name = t.name,
|
|
736
|
+
from = t.fromFrame.frameNumber,
|
|
737
|
+
to = t.toFrame.frameNumber,
|
|
738
|
+
direction = tostring(t.aniDir),
|
|
739
|
+
repeats = t.repeats,
|
|
740
|
+
}
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
local result = {
|
|
744
|
+
id = s.id,
|
|
745
|
+
name = sprite_display_name(s),
|
|
746
|
+
filename = s.filename ~= "" and s.filename or nil,
|
|
747
|
+
width = s.width,
|
|
748
|
+
height = s.height,
|
|
749
|
+
colorMode = ({ [ColorMode.RGB] = "rgb", [ColorMode.GRAY] = "grayscale", [ColorMode.INDEXED] = "indexed" })[s.colorMode],
|
|
750
|
+
transparentIndex = s.colorMode == ColorMode.INDEXED and s.transparentColor or nil,
|
|
751
|
+
frameCount = #s.frames,
|
|
752
|
+
layers = layers,
|
|
753
|
+
frames = frames,
|
|
754
|
+
tags = tags,
|
|
755
|
+
modified = s.isModified,
|
|
756
|
+
activeLayer = (app.layer and app.layer.sprite == s) and app.layer.name or nil,
|
|
757
|
+
activeFrame = (app.frame and app.frame.sprite == s) and app.frame.frameNumber or nil,
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if args.includePalette ~= false then
|
|
761
|
+
local pal = s.palettes[1]
|
|
762
|
+
local colors = {}
|
|
763
|
+
for i = 0, #pal - 1 do colors[i + 1] = color_to_hex(pal:getColor(i), true) end
|
|
764
|
+
result.palette = colors
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
if args.includeSlices then
|
|
768
|
+
local slices = {}
|
|
769
|
+
for i, sl in ipairs(s.slices) do
|
|
770
|
+
slices[i] = { name = sl.name, bounds = {
|
|
771
|
+
x = sl.bounds.x, y = sl.bounds.y, width = sl.bounds.width, height = sl.bounds.height } }
|
|
772
|
+
end
|
|
773
|
+
result.slices = slices
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
local sel = s.selection
|
|
777
|
+
if sel and not sel.isEmpty then
|
|
778
|
+
result.selection = { x = sel.bounds.x, y = sel.bounds.y, width = sel.bounds.width, height = sel.bounds.height }
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
return result
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
local ANCHORS = {
|
|
785
|
+
top_left = { 0, 0 }, top = { 0.5, 0 }, top_right = { 1, 0 },
|
|
786
|
+
left = { 0, 0.5 }, center = { 0.5, 0.5 }, right = { 1, 0.5 },
|
|
787
|
+
bottom_left = { 0, 1 }, bottom = { 0.5, 1 }, bottom_right = { 1, 1 },
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
H["sprite.manage"] = function(args)
|
|
791
|
+
local op = need(args.op, "op")
|
|
792
|
+
|
|
793
|
+
if op == "list" then
|
|
794
|
+
local out = {}
|
|
795
|
+
for i, s in ipairs(app.sprites) do
|
|
796
|
+
local sum = sprite_summary(s)
|
|
797
|
+
sum.active = (s == app.sprite)
|
|
798
|
+
sum.modified = s.isModified
|
|
799
|
+
out[i] = sum
|
|
800
|
+
end
|
|
801
|
+
return { sprites = out }
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
if op == "new" then
|
|
805
|
+
local mode = ({ rgb = ColorMode.RGB, grayscale = ColorMode.GRAY, indexed = ColorMode.INDEXED })[args.colorMode or "rgb"]
|
|
806
|
+
local s = Sprite(need(args.width, "width"), need(args.height, "height"), mode)
|
|
807
|
+
app.sprite = s
|
|
808
|
+
return { sprite = sprite_display_name(s), id = s.id, width = s.width, height = s.height }
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
if op == "open" then
|
|
812
|
+
local s = app.open(need(args.path, "path"))
|
|
813
|
+
if not s then fault("aseprite_error", "Aseprite could not open '" .. args.path .. "'.") end
|
|
814
|
+
app.sprite = s
|
|
815
|
+
return { sprite = sprite_display_name(s), id = s.id, width = s.width, height = s.height }
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
local s = find_sprite(args.sprite)
|
|
819
|
+
|
|
820
|
+
if op == "activate" then
|
|
821
|
+
app.sprite = s
|
|
822
|
+
return { sprite = sprite_display_name(s), id = s.id }
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
if op == "save" then
|
|
826
|
+
if (s.filename or "") == "" then
|
|
827
|
+
fault("invalid_args", "This sprite has never been saved. Use op 'save_as' with a path.")
|
|
828
|
+
end
|
|
829
|
+
preserving_site(function() app.sprite = s; app.command.SaveFile() end)
|
|
830
|
+
return { sprite = sprite_display_name(s), id = s.id, path = s.filename }
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
if op == "save_as" then
|
|
834
|
+
s:saveAs(need(args.path, "path"))
|
|
835
|
+
return { sprite = sprite_display_name(s), id = s.id, path = s.filename }
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
if op == "close" then
|
|
839
|
+
local name = sprite_display_name(s)
|
|
840
|
+
local id = s.id
|
|
841
|
+
s:close()
|
|
842
|
+
return { sprite = name, id = id }
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
if op == "resize_canvas" then
|
|
846
|
+
local w = args.width or s.width
|
|
847
|
+
local h = args.height or s.height
|
|
848
|
+
local anchor = ANCHORS[args.anchor or "center"] or ANCHORS.center
|
|
849
|
+
local dx = math.floor((w - s.width) * anchor[1])
|
|
850
|
+
local dy = math.floor((h - s.height) * anchor[2])
|
|
851
|
+
transact("AI: resize canvas", function()
|
|
852
|
+
s:crop(-dx, -dy, w, h)
|
|
853
|
+
end)
|
|
854
|
+
return { sprite = sprite_display_name(s), width = s.width, height = s.height }
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
if op == "set_properties" then
|
|
858
|
+
transact("AI: sprite properties", function()
|
|
859
|
+
if args.pixelAspect then
|
|
860
|
+
local wx, wy = args.pixelAspect:match("^(%d+):(%d+)$")
|
|
861
|
+
if wx then s.pixelRatio = Size(tonumber(wx), tonumber(wy)) end
|
|
862
|
+
end
|
|
863
|
+
if args.colorMode then
|
|
864
|
+
local target = ({ rgb = ColorMode.RGB, grayscale = ColorMode.GRAY, indexed = ColorMode.INDEXED })[args.colorMode]
|
|
865
|
+
if target and target ~= s.colorMode then
|
|
866
|
+
preserving_site(function()
|
|
867
|
+
app.sprite = s
|
|
868
|
+
app.command.ChangePixelFormat{ format = args.colorMode == "grayscale" and "gray" or args.colorMode }
|
|
869
|
+
end)
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
end)
|
|
873
|
+
return { sprite = sprite_display_name(s), width = s.width, height = s.height }
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
fault("unsupported_command", "sprite.manage does not support op '" .. tostring(op) .. "'.")
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
H["pixels.read"] = function(args)
|
|
880
|
+
local s = find_sprite(args.sprite)
|
|
881
|
+
local layer = args.composite and nil or find_layer(s, args.layer)
|
|
882
|
+
local frame = find_frame(s, args.frame)
|
|
883
|
+
return read_region(s, layer, frame, args.region, args.composite ~= false)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
--------------------------------------------------------------------------------
|
|
887
|
+
-- Looking: previews and filmstrips.
|
|
888
|
+
--
|
|
889
|
+
-- Upscaling happens here rather than in the server so the package needs no
|
|
890
|
+
-- native image dependency; Aseprite already has a correct nearest-neighbour
|
|
891
|
+
-- resize. Both operate on a throwaway copy so the user's document is untouched.
|
|
892
|
+
--------------------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
--- Integer upscale for a preview. Bounded by the OUTPUT edge, not by the
|
|
895
|
+
--- factor: capping the factor at 16 rendered a 16px sprite at 256px and an 8px
|
|
896
|
+
--- one at 128px, which are precisely the sizes a vision model cannot read.
|
|
897
|
+
local PREVIEW_TARGET_EDGE = 1024
|
|
898
|
+
local PREVIEW_MAX_EDGE = 2048
|
|
899
|
+
|
|
900
|
+
local function pick_scale(w, h, requested)
|
|
901
|
+
local long = math.max(w, h, 1)
|
|
902
|
+
local by_output = math.max(1, math.floor(PREVIEW_MAX_EDGE / long))
|
|
903
|
+
if requested and requested > 0 then
|
|
904
|
+
return math.max(1, math.min(math.floor(requested), by_output))
|
|
905
|
+
end
|
|
906
|
+
local wanted = math.max(1, math.floor(PREVIEW_TARGET_EDGE / long + 0.5))
|
|
907
|
+
return math.min(wanted, by_output)
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
H["look.preview"] = function(args)
|
|
911
|
+
local s = find_sprite(args.sprite)
|
|
912
|
+
local frame = find_frame(s, args.frame)
|
|
913
|
+
local path = need(args.path, "path")
|
|
914
|
+
|
|
915
|
+
local region = args.region and clamp_region(s, args.region) or
|
|
916
|
+
{ x = 0, y = 0, width = s.width, height = s.height }
|
|
917
|
+
|
|
918
|
+
local flat = Image(s.width, s.height, s.colorMode)
|
|
919
|
+
if args.layer then
|
|
920
|
+
local cel = get_cel(s, find_layer(s, args.layer), frame, false)
|
|
921
|
+
if cel then flat:drawImage(cel.image, cel.position) end
|
|
922
|
+
else
|
|
923
|
+
flat:drawSprite(s, frame)
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
local cropped = Image(region.width, region.height, s.colorMode)
|
|
927
|
+
cropped:drawImage(flat, Point(-region.x, -region.y))
|
|
928
|
+
|
|
929
|
+
local scale = pick_scale(region.width, region.height, args.scale)
|
|
930
|
+
-- The scratch sprite is created inside preserving_site so the user's active
|
|
931
|
+
-- document is what gets restored, not the throwaway we just closed.
|
|
932
|
+
preserving_site(function()
|
|
933
|
+
local out = Sprite(region.width, region.height, s.colorMode)
|
|
934
|
+
if s.colorMode == ColorMode.INDEXED then out:setPalette(s.palettes[1]) end
|
|
935
|
+
out.cels[1].image = cropped
|
|
936
|
+
if scale > 1 then out:resize(region.width * scale, region.height * scale) end
|
|
937
|
+
out:saveCopyAs(path)
|
|
938
|
+
out:close()
|
|
939
|
+
end)
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
sprite = sprite_display_name(s),
|
|
943
|
+
sourceWidth = region.width, sourceHeight = region.height,
|
|
944
|
+
width = region.width * scale, height = region.height * scale,
|
|
945
|
+
scale = scale,
|
|
946
|
+
}
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
H["look.filmstrip"] = function(args)
|
|
950
|
+
local s = find_sprite(args.sprite)
|
|
951
|
+
local path = need(args.path, "path")
|
|
952
|
+
local count = #s.frames
|
|
953
|
+
if count == 0 then fault("invalid_args", "Sprite has no frames.") end
|
|
954
|
+
|
|
955
|
+
local cols = math.ceil(math.sqrt(count))
|
|
956
|
+
local rows = math.ceil(count / cols)
|
|
957
|
+
local gap = 1
|
|
958
|
+
local cellW, cellH = s.width + gap, s.height + gap
|
|
959
|
+
local stripW = cols * cellW - gap
|
|
960
|
+
local stripH = rows * cellH - gap
|
|
961
|
+
|
|
962
|
+
local strip = Image(stripW, stripH, ColorMode.RGB)
|
|
963
|
+
-- Mid grey between cells, so frame boundaries are unambiguous even when the
|
|
964
|
+
-- art itself runs to the edge of the canvas.
|
|
965
|
+
strip:clear(app.pixelColor.rgba(64, 64, 72, 255))
|
|
966
|
+
|
|
967
|
+
for i, frame in ipairs(s.frames) do
|
|
968
|
+
local cell = Image(s.width, s.height, s.colorMode)
|
|
969
|
+
cell:drawSprite(s, frame)
|
|
970
|
+
local rgb = cell.colorMode == ColorMode.RGB and cell or Image(cell, nil)
|
|
971
|
+
local col = (i - 1) % cols
|
|
972
|
+
local row = math.floor((i - 1) / cols)
|
|
973
|
+
strip:drawImage(rgb, Point(col * cellW, row * cellH))
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
local scale = pick_scale(stripW, stripH, args.scale)
|
|
977
|
+
preserving_site(function()
|
|
978
|
+
local out = Sprite(stripW, stripH, ColorMode.RGB)
|
|
979
|
+
out.cels[1].image = strip
|
|
980
|
+
if scale > 1 then out:resize(stripW * scale, stripH * scale) end
|
|
981
|
+
out:saveCopyAs(path)
|
|
982
|
+
out:close()
|
|
983
|
+
end)
|
|
984
|
+
|
|
985
|
+
return {
|
|
986
|
+
sprite = sprite_display_name(s),
|
|
987
|
+
frames = count, scale = scale,
|
|
988
|
+
width = stripW * scale, height = stripH * scale,
|
|
989
|
+
}
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
--------------------------------------------------------------------------------
|
|
993
|
+
-- draw.batch — the workhorse. Every op lands in one transaction.
|
|
994
|
+
--------------------------------------------------------------------------------
|
|
995
|
+
|
|
996
|
+
local function op_bounds(sprite, op)
|
|
997
|
+
local function pts(list)
|
|
998
|
+
local minx, miny, maxx, maxy = math.huge, math.huge, -math.huge, -math.huge
|
|
999
|
+
for _, p in ipairs(list) do
|
|
1000
|
+
minx = math.min(minx, p.x); miny = math.min(miny, p.y)
|
|
1001
|
+
maxx = math.max(maxx, p.x); maxy = math.max(maxy, p.y)
|
|
1002
|
+
end
|
|
1003
|
+
return { x = minx, y = miny, width = maxx - minx + 1, height = maxy - miny + 1 }
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
-- A brush wider than 1px paints beyond its endpoints. Without this padding the
|
|
1007
|
+
-- cel is grown to the endpoints only, and Draw.pixel silently clips the rest
|
|
1008
|
+
-- of the stamp — reporting success and a bounding box that hides the loss.
|
|
1009
|
+
local function pad(box, amount)
|
|
1010
|
+
if amount <= 0 then return box end
|
|
1011
|
+
return {
|
|
1012
|
+
x = box.x - amount, y = box.y - amount,
|
|
1013
|
+
width = box.width + amount * 2, height = box.height + amount * 2,
|
|
1014
|
+
}
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
if op.kind == "pixels" then return pts(op.points) end
|
|
1018
|
+
if op.kind == "line" then
|
|
1019
|
+
return pad(pts({ op.from, op.to }), math.floor((op.thickness or 1) / 2))
|
|
1020
|
+
end
|
|
1021
|
+
if op.kind == "polyline" then return pts(op.points) end
|
|
1022
|
+
if op.kind == "rect" or op.kind == "ellipse" or op.kind == "dither" or op.kind == "gradient" then
|
|
1023
|
+
return op.rect
|
|
1024
|
+
end
|
|
1025
|
+
if op.kind == "fill" then return { x = 0, y = 0, width = sprite.width, height = sprite.height } end
|
|
1026
|
+
if op.kind == "replace" then
|
|
1027
|
+
return op.region or { x = 0, y = 0, width = sprite.width, height = sprite.height }
|
|
1028
|
+
end
|
|
1029
|
+
if op.kind == "clear" then
|
|
1030
|
+
return op.region or { x = 0, y = 0, width = sprite.width, height = sprite.height }
|
|
1031
|
+
end
|
|
1032
|
+
if op.kind == "blit" then
|
|
1033
|
+
return { x = op.to.x, y = op.to.y, width = op.from.width, height = op.from.height }
|
|
1034
|
+
end
|
|
1035
|
+
return { x = 0, y = 0, width = sprite.width, height = sprite.height }
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
H["draw.batch"] = function(args)
|
|
1039
|
+
local s = find_sprite(args.sprite)
|
|
1040
|
+
local layer = find_layer(s, args.layer)
|
|
1041
|
+
local frame = find_frame(s, args.frame)
|
|
1042
|
+
local ops = need(args.ops, "ops")
|
|
1043
|
+
if layer.isGroup then
|
|
1044
|
+
fault("invalid_args", "'" .. layer.name .. "' is a group layer and holds no pixels. Target a child layer.")
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
local snapped = {}
|
|
1048
|
+
local seen_snap = {}
|
|
1049
|
+
|
|
1050
|
+
-- Resolve every colour up front: the palette lock has to be visible to the
|
|
1051
|
+
-- agent as a report, not applied invisibly mid-draw.
|
|
1052
|
+
local function resolve(hex)
|
|
1053
|
+
if hex == nil then return nil end
|
|
1054
|
+
local color = hex_to_color(hex)
|
|
1055
|
+
if args.paletteLock ~= false and s.colorMode ~= ColorMode.INDEXED then
|
|
1056
|
+
local snapped_color, distance = snap_color_to_palette(s, color)
|
|
1057
|
+
local to = color_to_hex(snapped_color, false)
|
|
1058
|
+
local from = color_to_hex(color, false)
|
|
1059
|
+
if to ~= from and not seen_snap[from] then
|
|
1060
|
+
seen_snap[from] = true
|
|
1061
|
+
snapped[#snapped + 1] = { from = from, to = to, deltaE = math.floor(distance * 100 + 0.5) / 100 }
|
|
1062
|
+
end
|
|
1063
|
+
color = snapped_color
|
|
1064
|
+
end
|
|
1065
|
+
return color_to_pixel(s, color)
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
local changed = 0
|
|
1069
|
+
local union = nil
|
|
1070
|
+
local function extend(b)
|
|
1071
|
+
if not b then return end
|
|
1072
|
+
if not union then union = { x = b.x, y = b.y, width = b.width, height = b.height }; return end
|
|
1073
|
+
local x1 = math.min(union.x, b.x)
|
|
1074
|
+
local y1 = math.min(union.y, b.y)
|
|
1075
|
+
local x2 = math.max(union.x + union.width, b.x + b.width)
|
|
1076
|
+
local y2 = math.max(union.y + union.height, b.y + b.height)
|
|
1077
|
+
union = { x = x1, y = y1, width = x2 - x1, height = y2 - y1 }
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
local selection = args.selectionOnly and s.selection or nil
|
|
1081
|
+
if args.selectionOnly and (not selection or selection.isEmpty) then
|
|
1082
|
+
fault("invalid_args",
|
|
1083
|
+
"selectionOnly was set but nothing is selected. Falling back to the whole cel would " ..
|
|
1084
|
+
"repaint far more than you asked for, so this refuses instead. Make a selection with " ..
|
|
1085
|
+
"the select tool first, or drop selectionOnly.")
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
transact(args.label and ("AI: " .. args.label) or "AI: draw", function()
|
|
1089
|
+
local cel = get_cel(s, layer, frame, args.createCel ~= false)
|
|
1090
|
+
if not cel then
|
|
1091
|
+
fault("invalid_args", "No cel on '" .. layer.name .. "' frame " .. frame.frameNumber ..
|
|
1092
|
+
" and createCel is false.")
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
-- Grow once for the whole batch rather than per op.
|
|
1096
|
+
local needed = nil
|
|
1097
|
+
for _, op in ipairs(ops) do
|
|
1098
|
+
local b = op_bounds(s, op)
|
|
1099
|
+
if b then
|
|
1100
|
+
if not needed then needed = { x = b.x, y = b.y, width = b.width, height = b.height }
|
|
1101
|
+
else
|
|
1102
|
+
local x1 = math.min(needed.x, b.x); local y1 = math.min(needed.y, b.y)
|
|
1103
|
+
local x2 = math.max(needed.x + needed.width, b.x + b.width)
|
|
1104
|
+
local y2 = math.max(needed.y + needed.height, b.y + b.height)
|
|
1105
|
+
needed = { x = x1, y = y1, width = x2 - x1, height = y2 - y1 }
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
end
|
|
1109
|
+
if needed then cel = ensure_cel_covers(s, cel, needed) end
|
|
1110
|
+
|
|
1111
|
+
local img = cel.image:clone()
|
|
1112
|
+
local ox, oy = cel.position.x, cel.position.y
|
|
1113
|
+
|
|
1114
|
+
for _, op in ipairs(ops) do
|
|
1115
|
+
local kind = op.kind
|
|
1116
|
+
if kind == "pixels" then
|
|
1117
|
+
local fallback = resolve(op.color)
|
|
1118
|
+
for _, p in ipairs(op.points) do
|
|
1119
|
+
local value = p.color and resolve(p.color) or fallback
|
|
1120
|
+
if value == nil then fault("invalid_args", "A pixel has no colour and the op has no default `color`.") end
|
|
1121
|
+
if not selection or selection:contains(p.x, p.y) then
|
|
1122
|
+
changed = changed + Draw.pixel(img, ox, oy, p.x, p.y, value)
|
|
1123
|
+
end
|
|
1124
|
+
end
|
|
1125
|
+
elseif kind == "line" then
|
|
1126
|
+
changed = changed + Draw.line(img, ox, oy, op.from.x, op.from.y, op.to.x, op.to.y,
|
|
1127
|
+
resolve(op.color), op.thickness or 1)
|
|
1128
|
+
elseif kind == "polyline" then
|
|
1129
|
+
local points = op.points
|
|
1130
|
+
if op.closed then
|
|
1131
|
+
changed = changed + Draw.polygon(img, ox, oy, points, resolve(op.color),
|
|
1132
|
+
op.fill and resolve(op.fill) or nil, true)
|
|
1133
|
+
else
|
|
1134
|
+
for i = 1, #points - 1 do
|
|
1135
|
+
changed = changed + Draw.line(img, ox, oy,
|
|
1136
|
+
points[i].x, points[i].y, points[i + 1].x, points[i + 1].y, resolve(op.color), 1)
|
|
1137
|
+
end
|
|
1138
|
+
end
|
|
1139
|
+
elseif kind == "rect" then
|
|
1140
|
+
changed = changed + Draw.rect(img, ox, oy, op.rect, resolve(op.color),
|
|
1141
|
+
op.fill and resolve(op.fill) or nil)
|
|
1142
|
+
elseif kind == "ellipse" then
|
|
1143
|
+
changed = changed + Draw.ellipse(img, ox, oy, op.rect, resolve(op.color),
|
|
1144
|
+
op.fill and resolve(op.fill) or nil)
|
|
1145
|
+
elseif kind == "fill" then
|
|
1146
|
+
changed = changed + Draw.flood(img, ox, oy, op.at.x, op.at.y, resolve(op.color),
|
|
1147
|
+
op.tolerance or 0, op.contiguous ~= false)
|
|
1148
|
+
elseif kind == "replace" then
|
|
1149
|
+
local from = resolve(op.from)
|
|
1150
|
+
local to = resolve(op.to)
|
|
1151
|
+
local r = op.region and clamp_region(s, op.region) or
|
|
1152
|
+
{ x = ox, y = oy, width = img.width, height = img.height }
|
|
1153
|
+
for y = 0, img.height - 1 do
|
|
1154
|
+
for x = 0, img.width - 1 do
|
|
1155
|
+
local gx, gy = x + ox, y + oy
|
|
1156
|
+
if gx >= r.x and gy >= r.y and gx < r.x + r.width and gy < r.y + r.height
|
|
1157
|
+
and img:getPixel(x, y) == from then
|
|
1158
|
+
img:drawPixel(x, y, to)
|
|
1159
|
+
changed = changed + 1
|
|
1160
|
+
end
|
|
1161
|
+
end
|
|
1162
|
+
end
|
|
1163
|
+
elseif kind == "dither" then
|
|
1164
|
+
changed = changed + Draw.dither(img, ox, oy, op.rect, resolve(op.colorA), resolve(op.colorB),
|
|
1165
|
+
op.pattern or "bayer4", op.ratio or 0.5)
|
|
1166
|
+
elseif kind == "gradient" then
|
|
1167
|
+
local steps = op.steps or 4
|
|
1168
|
+
local from = hex_to_color(op.from)
|
|
1169
|
+
local to = hex_to_color(op.to)
|
|
1170
|
+
local r = op.rect
|
|
1171
|
+
local direction = op.direction or "vertical"
|
|
1172
|
+
|
|
1173
|
+
-- One colour per band, resolved once: resolve() also reports palette
|
|
1174
|
+
-- snapping, and doing it per pixel would flood that report.
|
|
1175
|
+
local band = {}
|
|
1176
|
+
for i = 0, steps - 1 do
|
|
1177
|
+
local t = steps == 1 and 0 or i / (steps - 1)
|
|
1178
|
+
band[i] = resolve(color_to_hex(Color{
|
|
1179
|
+
r = math.floor(from.red + (to.red - from.red) * t + 0.5),
|
|
1180
|
+
g = math.floor(from.green + (to.green - from.green) * t + 0.5),
|
|
1181
|
+
b = math.floor(from.blue + (to.blue - from.blue) * t + 0.5),
|
|
1182
|
+
a = 255,
|
|
1183
|
+
}, false))
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
local cx = r.x + (r.width - 1) / 2
|
|
1187
|
+
local cy = r.y + (r.height - 1) / 2
|
|
1188
|
+
-- Centre to corner, so the outermost band lands on the rect's corners.
|
|
1189
|
+
local radius = math.max(1, math.sqrt((r.width / 2) ^ 2 + (r.height / 2) ^ 2))
|
|
1190
|
+
|
|
1191
|
+
local function position(x, y)
|
|
1192
|
+
if direction == "horizontal" then
|
|
1193
|
+
return r.width <= 1 and 0 or (x - r.x) / (r.width - 1)
|
|
1194
|
+
elseif direction == "diagonal" then
|
|
1195
|
+
local span = (r.width - 1) + (r.height - 1)
|
|
1196
|
+
return span <= 0 and 0 or ((x - r.x) + (y - r.y)) / span
|
|
1197
|
+
elseif direction == "radial" then
|
|
1198
|
+
return math.min(1, math.sqrt((x - cx) ^ 2 + (y - cy) ^ 2) / radius)
|
|
1199
|
+
end
|
|
1200
|
+
return r.height <= 1 and 0 or (y - r.y) / (r.height - 1)
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
local spec = BAYER.bayer4
|
|
1204
|
+
for y = r.y, r.y + r.height - 1 do
|
|
1205
|
+
for x = r.x, r.x + r.width - 1 do
|
|
1206
|
+
local f = position(x, y) * (steps - 1)
|
|
1207
|
+
local lo = math.max(0, math.min(steps - 1, math.floor(f)))
|
|
1208
|
+
local index = lo
|
|
1209
|
+
if op.dither and lo < steps - 1 then
|
|
1210
|
+
-- Ordered dither across the band boundary: the fractional part is
|
|
1211
|
+
-- the probability of taking the next band, thresholded by Bayer so
|
|
1212
|
+
-- the texture is stable rather than noisy.
|
|
1213
|
+
local threshold = (spec.m[(y % spec.size) + 1][(x % spec.size) + 1] + 0.5)
|
|
1214
|
+
/ (spec.size * spec.size)
|
|
1215
|
+
if (f - lo) > threshold then index = lo + 1 end
|
|
1216
|
+
elseif not op.dither then
|
|
1217
|
+
index = math.max(0, math.min(steps - 1, math.floor(f + 0.5)))
|
|
1218
|
+
end
|
|
1219
|
+
changed = changed + Draw.pixel(img, ox, oy, x, y, band[index])
|
|
1220
|
+
end
|
|
1221
|
+
end
|
|
1222
|
+
elseif kind == "clear" then
|
|
1223
|
+
local r = op.region
|
|
1224
|
+
if r then
|
|
1225
|
+
for y = r.y, r.y + r.height - 1 do
|
|
1226
|
+
for x = r.x, r.x + r.width - 1 do
|
|
1227
|
+
changed = changed + Draw.pixel(img, ox, oy, x, y, 0)
|
|
1228
|
+
end
|
|
1229
|
+
end
|
|
1230
|
+
else
|
|
1231
|
+
img:clear()
|
|
1232
|
+
changed = changed + img.width * img.height
|
|
1233
|
+
end
|
|
1234
|
+
elseif kind == "blit" then
|
|
1235
|
+
local srcFrame = op.fromFrame and find_frame(s, op.fromFrame) or frame
|
|
1236
|
+
local srcLayer = op.fromLayer and find_layer(s, op.fromLayer) or layer
|
|
1237
|
+
local srcCel = get_cel(s, srcLayer, srcFrame, false)
|
|
1238
|
+
if srcCel then
|
|
1239
|
+
local r = op.from
|
|
1240
|
+
for y = 0, r.height - 1 do
|
|
1241
|
+
for x = 0, r.width - 1 do
|
|
1242
|
+
local sx = op.flipHorizontal and (r.x + r.width - 1 - x) or (r.x + x)
|
|
1243
|
+
local sy = op.flipVertical and (r.y + r.height - 1 - y) or (r.y + y)
|
|
1244
|
+
local lx, ly = sx - srcCel.position.x, sy - srcCel.position.y
|
|
1245
|
+
if lx >= 0 and ly >= 0 and lx < srcCel.image.width and ly < srcCel.image.height then
|
|
1246
|
+
local value = srcCel.image:getPixel(lx, ly)
|
|
1247
|
+
local transparent = pixel_to_hex(s, value) == nil
|
|
1248
|
+
if not (op.skipTransparent ~= false and transparent) then
|
|
1249
|
+
changed = changed + Draw.pixel(img, ox, oy, op.to.x + x, op.to.y + y, value)
|
|
1250
|
+
end
|
|
1251
|
+
end
|
|
1252
|
+
end
|
|
1253
|
+
end
|
|
1254
|
+
end
|
|
1255
|
+
else
|
|
1256
|
+
fault("unsupported_command", "Unknown draw op '" .. tostring(kind) .. "'.")
|
|
1257
|
+
end
|
|
1258
|
+
extend(op_bounds(s, op))
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
cel.image = img
|
|
1262
|
+
end)
|
|
1263
|
+
|
|
1264
|
+
return {
|
|
1265
|
+
sprite = sprite_display_name(s),
|
|
1266
|
+
layer = layer.name,
|
|
1267
|
+
frame = frame.frameNumber,
|
|
1268
|
+
opsApplied = #ops,
|
|
1269
|
+
pixelsChanged = changed,
|
|
1270
|
+
colorsSnapped = snapped,
|
|
1271
|
+
bounds = union,
|
|
1272
|
+
}
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
--------------------------------------------------------------------------------
|
|
1276
|
+
-- Structure: layers, frames, tags, cels
|
|
1277
|
+
--------------------------------------------------------------------------------
|
|
1278
|
+
|
|
1279
|
+
local BLEND = {
|
|
1280
|
+
normal = BlendMode.NORMAL, multiply = BlendMode.MULTIPLY, screen = BlendMode.SCREEN,
|
|
1281
|
+
overlay = BlendMode.OVERLAY, darken = BlendMode.DARKEN, lighten = BlendMode.LIGHTEN,
|
|
1282
|
+
color_dodge = BlendMode.COLOR_DODGE, color_burn = BlendMode.COLOR_BURN,
|
|
1283
|
+
hard_light = BlendMode.HARD_LIGHT, soft_light = BlendMode.SOFT_LIGHT,
|
|
1284
|
+
difference = BlendMode.DIFFERENCE, exclusion = BlendMode.EXCLUSION,
|
|
1285
|
+
hue = BlendMode.HSL_HUE, saturation = BlendMode.HSL_SATURATION,
|
|
1286
|
+
color = BlendMode.HSL_COLOR, luminosity = BlendMode.HSL_LUMINOSITY,
|
|
1287
|
+
addition = BlendMode.ADDITION, subtract = BlendMode.SUBTRACT, divide = BlendMode.DIVIDE,
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
local function apply_layer_op(s, op)
|
|
1291
|
+
local kind = op.op
|
|
1292
|
+
if kind == "create" then
|
|
1293
|
+
local layer = op.parent and (function()
|
|
1294
|
+
local group = find_layer(s, op.parent)
|
|
1295
|
+
if not group.isGroup then fault("invalid_args", "'" .. op.parent .. "' is not a group layer.") end
|
|
1296
|
+
local l = s:newLayer(); l.parent = group; return l
|
|
1297
|
+
end)() or s:newLayer()
|
|
1298
|
+
layer.name = op.name or layer.name
|
|
1299
|
+
if op.opacity then layer.opacity = op.opacity end
|
|
1300
|
+
if op.blendMode and BLEND[op.blendMode] then layer.blendMode = BLEND[op.blendMode] end
|
|
1301
|
+
if op.visible ~= nil then layer.isVisible = op.visible end
|
|
1302
|
+
if op.index then layer.stackIndex = op.index end
|
|
1303
|
+
return
|
|
1304
|
+
end
|
|
1305
|
+
if kind == "group" then
|
|
1306
|
+
local group = s:newGroupLayer()
|
|
1307
|
+
group.name = op.name or "group"
|
|
1308
|
+
for _, name in ipairs(op.names or {}) do find_layer(s, name).parent = group end
|
|
1309
|
+
return
|
|
1310
|
+
end
|
|
1311
|
+
|
|
1312
|
+
local layer = find_layer(s, op.name)
|
|
1313
|
+
if kind == "rename" then layer.name = need(op.newName, "newName")
|
|
1314
|
+
elseif kind == "delete" then s:deleteLayer(layer)
|
|
1315
|
+
elseif kind == "reorder" then layer.stackIndex = need(op.index, "index")
|
|
1316
|
+
elseif kind == "activate" then app.layer = layer
|
|
1317
|
+
elseif kind == "duplicate" then
|
|
1318
|
+
preserving_site(function() app.layer = layer; app.command.DuplicateLayer() end)
|
|
1319
|
+
elseif kind == "merge" then
|
|
1320
|
+
preserving_site(function() app.layer = layer; app.command.MergeDownLayer() end)
|
|
1321
|
+
elseif kind == "ungroup" then
|
|
1322
|
+
if not layer.isGroup then fault("invalid_args", "'" .. layer.name .. "' is not a group.") end
|
|
1323
|
+
local parent = layer.parent
|
|
1324
|
+
for _, child in ipairs({ table.unpack(layer.layers) }) do child.parent = parent end
|
|
1325
|
+
s:deleteLayer(layer)
|
|
1326
|
+
elseif kind == "set" then
|
|
1327
|
+
if op.visible ~= nil then layer.isVisible = op.visible end
|
|
1328
|
+
if op.editable ~= nil then layer.isEditable = op.editable end
|
|
1329
|
+
if op.opacity ~= nil then layer.opacity = op.opacity end
|
|
1330
|
+
if op.blendMode and BLEND[op.blendMode] then layer.blendMode = BLEND[op.blendMode] end
|
|
1331
|
+
if op.newName then layer.name = op.newName end
|
|
1332
|
+
else
|
|
1333
|
+
fault("unsupported_command", "layer op '" .. tostring(kind) .. "' is not supported.")
|
|
1334
|
+
end
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
H["layer.apply"] = function(args)
|
|
1338
|
+
local s = find_sprite(args.sprite)
|
|
1339
|
+
local batch = args.batch
|
|
1340
|
+
|
|
1341
|
+
if args.op == "list" and not batch then
|
|
1342
|
+
return {
|
|
1343
|
+
sprite = sprite_display_name(s),
|
|
1344
|
+
layers = flatten_layers(s.layers, nil, {}),
|
|
1345
|
+
activeLayer = app.layer and app.layer.name or nil,
|
|
1346
|
+
}
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
local ops = batch or { args }
|
|
1350
|
+
transact("AI: layers", function()
|
|
1351
|
+
for _, op in ipairs(ops) do apply_layer_op(s, op) end
|
|
1352
|
+
end)
|
|
1353
|
+
|
|
1354
|
+
return {
|
|
1355
|
+
sprite = sprite_display_name(s),
|
|
1356
|
+
applied = #ops,
|
|
1357
|
+
layers = flatten_layers(s.layers, nil, {}),
|
|
1358
|
+
activeLayer = app.layer and app.layer.name or nil,
|
|
1359
|
+
}
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
--- The layer LinkCels should act on: the active one when it belongs to this
|
|
1363
|
+
--- sprite, else the topmost non-group layer.
|
|
1364
|
+
local function layer_for_link(s)
|
|
1365
|
+
if app.layer and app.layer.sprite == s and not app.layer.isGroup then return app.layer end
|
|
1366
|
+
for i = #s.layers, 1, -1 do
|
|
1367
|
+
if not s.layers[i].isGroup then return s.layers[i] end
|
|
1368
|
+
end
|
|
1369
|
+
return s.layers[1]
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
local function frame_list(s)
|
|
1373
|
+
local frames, total = {}, 0
|
|
1374
|
+
for i, f in ipairs(s.frames) do
|
|
1375
|
+
local ms = math.floor(f.duration * 1000 + 0.5)
|
|
1376
|
+
frames[i] = { number = i, durationMs = ms }
|
|
1377
|
+
total = total + ms
|
|
1378
|
+
end
|
|
1379
|
+
return frames, total
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
H["frame.apply"] = function(args)
|
|
1383
|
+
local s = find_sprite(args.sprite)
|
|
1384
|
+
local op = need(args.op, "op")
|
|
1385
|
+
|
|
1386
|
+
if op ~= "list" then
|
|
1387
|
+
transact("AI: frames", function()
|
|
1388
|
+
if op == "add" then
|
|
1389
|
+
for _ = 1, (args.count or 1) do s:newEmptyFrame(args.afterFrame and (args.afterFrame + 1) or (#s.frames + 1)) end
|
|
1390
|
+
elseif op == "duplicate" then
|
|
1391
|
+
local src = find_frame(s, args.frame)
|
|
1392
|
+
for _ = 1, (args.count or 1) do
|
|
1393
|
+
if args.linkCels then
|
|
1394
|
+
-- LinkCels only links cels the command machinery created: a frame
|
|
1395
|
+
-- made with Sprite:newFrame() bypasses that and stays unlinked, and
|
|
1396
|
+
-- NewFrame's own "celLinked" content flag does not share the image
|
|
1397
|
+
-- either. Create through the command, then link the pair.
|
|
1398
|
+
preserving_site(function()
|
|
1399
|
+
app.sprite = s
|
|
1400
|
+
app.layer = layer_for_link(s)
|
|
1401
|
+
app.frame = src
|
|
1402
|
+
app.command.NewFrame{ content = "current" }
|
|
1403
|
+
local created = app.frame
|
|
1404
|
+
app.range.frames = { src.frameNumber, created.frameNumber }
|
|
1405
|
+
app.command.LinkCels()
|
|
1406
|
+
end)
|
|
1407
|
+
else
|
|
1408
|
+
s:newFrame(src)
|
|
1409
|
+
end
|
|
1410
|
+
end
|
|
1411
|
+
elseif op == "delete" then
|
|
1412
|
+
s:deleteFrame(find_frame(s, args.frame))
|
|
1413
|
+
elseif op == "set_duration" then
|
|
1414
|
+
if args.durations then
|
|
1415
|
+
for i, ms in ipairs(args.durations) do
|
|
1416
|
+
if s.frames[i] then s.frames[i].duration = ms / 1000 end
|
|
1417
|
+
end
|
|
1418
|
+
else
|
|
1419
|
+
local ms = need(args.durationMs, "durationMs")
|
|
1420
|
+
if args.frame then
|
|
1421
|
+
find_frame(s, args.frame).duration = ms / 1000
|
|
1422
|
+
else
|
|
1423
|
+
for _, f in ipairs(s.frames) do f.duration = ms / 1000 end
|
|
1424
|
+
end
|
|
1425
|
+
end
|
|
1426
|
+
elseif op == "activate" then
|
|
1427
|
+
app.frame = find_frame(s, args.frame)
|
|
1428
|
+
elseif op == "reorder" then
|
|
1429
|
+
preserving_site(function()
|
|
1430
|
+
app.frame = find_frame(s, args.frame)
|
|
1431
|
+
app.command.MoveFrame{ before = need(args.toIndex, "toIndex") }
|
|
1432
|
+
end)
|
|
1433
|
+
else
|
|
1434
|
+
fault("unsupported_command", "frame op '" .. tostring(op) .. "' is not supported.")
|
|
1435
|
+
end
|
|
1436
|
+
end)
|
|
1437
|
+
end
|
|
1438
|
+
|
|
1439
|
+
local frames, total = frame_list(s)
|
|
1440
|
+
return {
|
|
1441
|
+
sprite = sprite_display_name(s),
|
|
1442
|
+
frameCount = #s.frames,
|
|
1443
|
+
frames = frames,
|
|
1444
|
+
totalDurationMs = total,
|
|
1445
|
+
activeFrame = app.frame and app.frame.frameNumber or nil,
|
|
1446
|
+
}
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1449
|
+
local ANIDIR = {
|
|
1450
|
+
forward = AniDir.FORWARD, reverse = AniDir.REVERSE,
|
|
1451
|
+
pingpong = AniDir.PING_PONG, pingpong_reverse = AniDir.PING_PONG_REVERSE,
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
H["tag.apply"] = function(args)
|
|
1455
|
+
local s = find_sprite(args.sprite)
|
|
1456
|
+
local op = need(args.op, "op")
|
|
1457
|
+
|
|
1458
|
+
if op ~= "list" then
|
|
1459
|
+
transact("AI: tags", function()
|
|
1460
|
+
if op == "create" then
|
|
1461
|
+
local t = s:newTag(need(args.from, "from"), need(args.to, "to"))
|
|
1462
|
+
t.name = need(args.name, "name")
|
|
1463
|
+
if args.direction then t.aniDir = ANIDIR[args.direction] end
|
|
1464
|
+
if args.repeats then t.repeats = args.repeats end
|
|
1465
|
+
if args.color then t.color = hex_to_color(args.color) end
|
|
1466
|
+
else
|
|
1467
|
+
local target = nil
|
|
1468
|
+
for _, t in ipairs(s.tags) do if t.name == args.name then target = t end end
|
|
1469
|
+
if not target then fault("invalid_args", "No tag named '" .. tostring(args.name) .. "'.") end
|
|
1470
|
+
if op == "delete" then
|
|
1471
|
+
s:deleteTag(target)
|
|
1472
|
+
elseif op == "update" then
|
|
1473
|
+
if args.newName then target.name = args.newName end
|
|
1474
|
+
if args.from then target.fromFrame = args.from end
|
|
1475
|
+
if args.to then target.toFrame = args.to end
|
|
1476
|
+
if args.direction then target.aniDir = ANIDIR[args.direction] end
|
|
1477
|
+
if args.repeats then target.repeats = args.repeats end
|
|
1478
|
+
if args.color then target.color = hex_to_color(args.color) end
|
|
1479
|
+
else
|
|
1480
|
+
fault("unsupported_command", "tag op '" .. tostring(op) .. "' is not supported.")
|
|
1481
|
+
end
|
|
1482
|
+
end
|
|
1483
|
+
end)
|
|
1484
|
+
end
|
|
1485
|
+
|
|
1486
|
+
local tags = {}
|
|
1487
|
+
for i, t in ipairs(s.tags) do
|
|
1488
|
+
local ms = 0
|
|
1489
|
+
for n = t.fromFrame.frameNumber, t.toFrame.frameNumber do
|
|
1490
|
+
ms = ms + math.floor(s.frames[n].duration * 1000 + 0.5)
|
|
1491
|
+
end
|
|
1492
|
+
tags[i] = {
|
|
1493
|
+
name = t.name, from = t.fromFrame.frameNumber, to = t.toFrame.frameNumber,
|
|
1494
|
+
direction = tostring(t.aniDir),
|
|
1495
|
+
frames = t.toFrame.frameNumber - t.fromFrame.frameNumber + 1,
|
|
1496
|
+
durationMs = ms,
|
|
1497
|
+
}
|
|
1498
|
+
end
|
|
1499
|
+
return { sprite = sprite_display_name(s), tags = tags }
|
|
1500
|
+
end
|
|
1501
|
+
|
|
1502
|
+
H["cel.apply"] = function(args)
|
|
1503
|
+
local s = find_sprite(args.sprite)
|
|
1504
|
+
local op = need(args.op, "op")
|
|
1505
|
+
|
|
1506
|
+
if op == "list" then
|
|
1507
|
+
local cels = {}
|
|
1508
|
+
for _, l in ipairs(flatten_layers(s.layers, nil, {})) do
|
|
1509
|
+
local layer = find_layer(s, l.name)
|
|
1510
|
+
if not layer.isGroup then
|
|
1511
|
+
for _, c in ipairs(layer.cels) do
|
|
1512
|
+
cels[#cels + 1] = {
|
|
1513
|
+
layer = layer.name, frame = c.frameNumber,
|
|
1514
|
+
x = c.bounds.x, y = c.bounds.y,
|
|
1515
|
+
width = c.bounds.width, height = c.bounds.height,
|
|
1516
|
+
-- A linked cel shares one Image object with the cels it is linked to,
|
|
1517
|
+
-- so identity — not existence — is what distinguishes it. The old
|
|
1518
|
+
-- expression collapsed to a constant false and always answered
|
|
1519
|
+
-- "nothing is linked", even right after cel op 'link'.
|
|
1520
|
+
opacity = c.opacity, linked = is_linked_cel(layer, c),
|
|
1521
|
+
}
|
|
1522
|
+
end
|
|
1523
|
+
end
|
|
1524
|
+
end
|
|
1525
|
+
return { sprite = sprite_display_name(s), cels = cels }
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1528
|
+
local layer = find_layer(s, args.layer)
|
|
1529
|
+
local frame = find_frame(s, args.frame)
|
|
1530
|
+
local applied = 0
|
|
1531
|
+
|
|
1532
|
+
transact("AI: cels", function()
|
|
1533
|
+
if op == "create" then
|
|
1534
|
+
get_cel(s, layer, frame, true); applied = 1
|
|
1535
|
+
elseif op == "clear" then
|
|
1536
|
+
local cel = get_cel(s, layer, frame, false)
|
|
1537
|
+
if cel then local img = cel.image:clone(); img:clear(); cel.image = img; applied = 1 end
|
|
1538
|
+
elseif op == "delete" then
|
|
1539
|
+
local cel = get_cel(s, layer, frame, false)
|
|
1540
|
+
if cel then s:deleteCel(cel); applied = 1 end
|
|
1541
|
+
elseif op == "set" or op == "move" then
|
|
1542
|
+
local cel = get_cel(s, layer, frame, false)
|
|
1543
|
+
if not cel then fault("invalid_args", "No cel on '" .. layer.name .. "' frame " .. frame.frameNumber .. ".") end
|
|
1544
|
+
local x = args.x or (cel.position.x + (args.dx or 0))
|
|
1545
|
+
local y = args.y or (cel.position.y + (args.dy or 0))
|
|
1546
|
+
cel.position = Point(x, y)
|
|
1547
|
+
if args.opacity then cel.opacity = args.opacity end
|
|
1548
|
+
applied = 1
|
|
1549
|
+
elseif op == "copy" then
|
|
1550
|
+
local src = get_cel(s, layer, frame, false)
|
|
1551
|
+
if not src then fault("invalid_args", "Nothing to copy from.") end
|
|
1552
|
+
local dstLayer = args.toLayer and find_layer(s, args.toLayer) or layer
|
|
1553
|
+
local dstFrame = args.toFrame and find_frame(s, args.toFrame) or frame
|
|
1554
|
+
s:newCel(dstLayer, dstFrame, src.image:clone(), src.position)
|
|
1555
|
+
applied = 1
|
|
1556
|
+
elseif op == "link" then
|
|
1557
|
+
local frames = args.frames or {}
|
|
1558
|
+
preserving_site(function()
|
|
1559
|
+
app.layer = layer
|
|
1560
|
+
for _, n in ipairs(frames) do
|
|
1561
|
+
app.frame = find_frame(s, n)
|
|
1562
|
+
app.command.LinkCels()
|
|
1563
|
+
applied = applied + 1
|
|
1564
|
+
end
|
|
1565
|
+
end)
|
|
1566
|
+
elseif op == "unlink" then
|
|
1567
|
+
preserving_site(function()
|
|
1568
|
+
app.layer = layer; app.frame = frame; app.command.UnlinkCel(); applied = 1
|
|
1569
|
+
end)
|
|
1570
|
+
else
|
|
1571
|
+
fault("unsupported_command", "cel op '" .. tostring(op) .. "' is not supported.")
|
|
1572
|
+
end
|
|
1573
|
+
end)
|
|
1574
|
+
|
|
1575
|
+
return { sprite = sprite_display_name(s), applied = applied }
|
|
1576
|
+
end
|
|
1577
|
+
|
|
1578
|
+
--------------------------------------------------------------------------------
|
|
1579
|
+
-- Palette
|
|
1580
|
+
--------------------------------------------------------------------------------
|
|
1581
|
+
|
|
1582
|
+
local function palette_hexes(s)
|
|
1583
|
+
local pal = s.palettes[1]
|
|
1584
|
+
local out = {}
|
|
1585
|
+
for i = 0, #pal - 1 do out[i + 1] = color_to_hex(pal:getColor(i), true) end
|
|
1586
|
+
return out
|
|
1587
|
+
end
|
|
1588
|
+
|
|
1589
|
+
H["palette.get"] = function(args)
|
|
1590
|
+
local s = find_sprite(args.sprite)
|
|
1591
|
+
local colors = palette_hexes(s)
|
|
1592
|
+
return { sprite = sprite_display_name(s), colors = colors, size = #colors }
|
|
1593
|
+
end
|
|
1594
|
+
|
|
1595
|
+
H["palette.set"] = function(args)
|
|
1596
|
+
local s = find_sprite(args.sprite)
|
|
1597
|
+
local colors = need(args.colors, "colors")
|
|
1598
|
+
local before = args.remapArt and palette_hexes(s) or nil
|
|
1599
|
+
|
|
1600
|
+
transact("AI: palette", function()
|
|
1601
|
+
local pal = s.palettes[1]
|
|
1602
|
+
if args.replace then
|
|
1603
|
+
pal:resize(#colors)
|
|
1604
|
+
for i, hex in ipairs(colors) do pal:setColor(i - 1, hex_to_color(hex)) end
|
|
1605
|
+
elseif args.append then
|
|
1606
|
+
local start = #pal
|
|
1607
|
+
pal:resize(start + #colors)
|
|
1608
|
+
for i, hex in ipairs(colors) do pal:setColor(start + i - 1, hex_to_color(hex)) end
|
|
1609
|
+
else
|
|
1610
|
+
local start = args.startIndex or 0
|
|
1611
|
+
if start + #colors > #pal then pal:resize(start + #colors) end
|
|
1612
|
+
for i, hex in ipairs(colors) do pal:setColor(start + i - 1, hex_to_color(hex)) end
|
|
1613
|
+
end
|
|
1614
|
+
end)
|
|
1615
|
+
|
|
1616
|
+
-- Replacing a palette on an RGB sprite leaves the art unchanged and therefore
|
|
1617
|
+
-- off-palette. Repainting is opt-in because it is destructive.
|
|
1618
|
+
if args.remapArt and before and s.colorMode ~= ColorMode.INDEXED then
|
|
1619
|
+
transact("AI: remap art to new palette", function()
|
|
1620
|
+
for _, layer in ipairs(s.layers) do
|
|
1621
|
+
if not layer.isGroup then
|
|
1622
|
+
for _, cel in ipairs(layer.cels) do
|
|
1623
|
+
local img = cel.image:clone()
|
|
1624
|
+
local cache = {}
|
|
1625
|
+
for y = 0, img.height - 1 do
|
|
1626
|
+
for x = 0, img.width - 1 do
|
|
1627
|
+
local value = img:getPixel(x, y)
|
|
1628
|
+
local hex = pixel_to_hex(s, value)
|
|
1629
|
+
if hex then
|
|
1630
|
+
local mapped = cache[hex]
|
|
1631
|
+
if mapped == nil then
|
|
1632
|
+
local snapped = snap_color_to_palette(s, hex_to_color(hex))
|
|
1633
|
+
mapped = color_to_pixel(s, snapped)
|
|
1634
|
+
cache[hex] = mapped
|
|
1635
|
+
end
|
|
1636
|
+
if mapped ~= value then img:drawPixel(x, y, mapped) end
|
|
1637
|
+
end
|
|
1638
|
+
end
|
|
1639
|
+
end
|
|
1640
|
+
cel.image = img
|
|
1641
|
+
end
|
|
1642
|
+
end
|
|
1643
|
+
end
|
|
1644
|
+
end)
|
|
1645
|
+
end
|
|
1646
|
+
|
|
1647
|
+
local colors_after = palette_hexes(s)
|
|
1648
|
+
return { sprite = sprite_display_name(s), colors = colors_after, size = #colors_after }
|
|
1649
|
+
end
|
|
1650
|
+
|
|
1651
|
+
H["palette.load"] = function(args)
|
|
1652
|
+
local s = find_sprite(args.sprite)
|
|
1653
|
+
local path = need(args.path, "path")
|
|
1654
|
+
transact("AI: load palette", function()
|
|
1655
|
+
local pal = Palette{ fromFile = path }
|
|
1656
|
+
if not pal then fault("aseprite_error", "Could not read a palette from '" .. path .. "'.") end
|
|
1657
|
+
s:setPalette(pal)
|
|
1658
|
+
end)
|
|
1659
|
+
local colors = palette_hexes(s)
|
|
1660
|
+
return { sprite = sprite_display_name(s), colors = colors, size = #colors, path = path }
|
|
1661
|
+
end
|
|
1662
|
+
|
|
1663
|
+
H["palette.stats"] = function(args)
|
|
1664
|
+
local s = find_sprite(args.sprite)
|
|
1665
|
+
local colors = palette_hexes(s)
|
|
1666
|
+
local in_palette = {}
|
|
1667
|
+
for i, hex in ipairs(colors) do in_palette[hex:sub(1, 7)] = i - 1 end
|
|
1668
|
+
|
|
1669
|
+
local counts = {}
|
|
1670
|
+
for _, layer in ipairs(s.layers) do
|
|
1671
|
+
if not layer.isGroup then
|
|
1672
|
+
for _, cel in ipairs(layer.cels) do
|
|
1673
|
+
local img = cel.image
|
|
1674
|
+
for y = 0, img.height - 1 do
|
|
1675
|
+
for x = 0, img.width - 1 do
|
|
1676
|
+
local hex = pixel_to_hex(s, img:getPixel(x, y))
|
|
1677
|
+
if hex then
|
|
1678
|
+
local key = hex:sub(1, 7)
|
|
1679
|
+
counts[key] = (counts[key] or 0) + 1
|
|
1680
|
+
end
|
|
1681
|
+
end
|
|
1682
|
+
end
|
|
1683
|
+
end
|
|
1684
|
+
end
|
|
1685
|
+
end
|
|
1686
|
+
|
|
1687
|
+
local usage = {}
|
|
1688
|
+
for i, hex in ipairs(colors) do
|
|
1689
|
+
usage[i] = { index = i - 1, hex = hex, pixels = counts[hex:sub(1, 7)] or 0 }
|
|
1690
|
+
end
|
|
1691
|
+
|
|
1692
|
+
local off = {}
|
|
1693
|
+
for hex, n in pairs(counts) do
|
|
1694
|
+
if in_palette[hex] == nil then off[#off + 1] = { hex = hex, pixels = n } end
|
|
1695
|
+
end
|
|
1696
|
+
table.sort(off, function(a, b) return a.pixels > b.pixels end)
|
|
1697
|
+
|
|
1698
|
+
return { sprite = sprite_display_name(s), colors = colors, usage = usage, offPalette = off }
|
|
1699
|
+
end
|
|
1700
|
+
|
|
1701
|
+
--------------------------------------------------------------------------------
|
|
1702
|
+
-- Selection and transforms
|
|
1703
|
+
--------------------------------------------------------------------------------
|
|
1704
|
+
|
|
1705
|
+
H["select.apply"] = function(args)
|
|
1706
|
+
local s = find_sprite(args.sprite)
|
|
1707
|
+
local op = need(args.op, "op")
|
|
1708
|
+
|
|
1709
|
+
if op ~= "get" then
|
|
1710
|
+
transact("AI: selection", function()
|
|
1711
|
+
local current = s.selection
|
|
1712
|
+
local built
|
|
1713
|
+
|
|
1714
|
+
if op == "none" then built = Selection()
|
|
1715
|
+
elseif op == "all" then built = Selection(Rectangle(0, 0, s.width, s.height))
|
|
1716
|
+
elseif op == "rect" then
|
|
1717
|
+
local r = clamp_region(s, need(args.rect, "rect"))
|
|
1718
|
+
built = Selection(Rectangle(r.x, r.y, r.width, r.height))
|
|
1719
|
+
elseif op == "ellipse" then
|
|
1720
|
+
local r = clamp_region(s, need(args.rect, "rect"))
|
|
1721
|
+
built = Selection()
|
|
1722
|
+
local cx, cy = r.x + (r.width - 1) / 2, r.y + (r.height - 1) / 2
|
|
1723
|
+
local rx, ry = (r.width - 1) / 2, (r.height - 1) / 2
|
|
1724
|
+
for y = r.y, r.y + r.height - 1 do
|
|
1725
|
+
local x0 = nil
|
|
1726
|
+
for x = r.x, r.x + r.width - 1 do
|
|
1727
|
+
local nx, ny = (x - cx) / math.max(rx, 0.5), (y - cy) / math.max(ry, 0.5)
|
|
1728
|
+
local inside = nx * nx + ny * ny <= 1.0
|
|
1729
|
+
if inside and x0 == nil then x0 = x end
|
|
1730
|
+
if (not inside or x == r.x + r.width - 1) and x0 ~= nil then
|
|
1731
|
+
local x1 = inside and x or x - 1
|
|
1732
|
+
built:add(Selection(Rectangle(x0, y, x1 - x0 + 1, 1)))
|
|
1733
|
+
x0 = nil
|
|
1734
|
+
end
|
|
1735
|
+
end
|
|
1736
|
+
end
|
|
1737
|
+
elseif op == "color" then
|
|
1738
|
+
local frame = find_frame(s, args.frame)
|
|
1739
|
+
local flat = Image(s.width, s.height, s.colorMode)
|
|
1740
|
+
flat:drawSprite(s, frame)
|
|
1741
|
+
local wanted = color_to_hex(hex_to_color(need(args.color, "color")), false)
|
|
1742
|
+
built = Selection()
|
|
1743
|
+
for y = 0, s.height - 1 do
|
|
1744
|
+
local x0 = nil
|
|
1745
|
+
for x = 0, s.width - 1 do
|
|
1746
|
+
local hex = pixel_to_hex(s, flat:getPixel(x, y))
|
|
1747
|
+
local match = hex ~= nil and hex:sub(1, 7) == wanted
|
|
1748
|
+
if match and x0 == nil then x0 = x end
|
|
1749
|
+
if (not match or x == s.width - 1) and x0 ~= nil then
|
|
1750
|
+
local x1 = match and x or x - 1
|
|
1751
|
+
built:add(Selection(Rectangle(x0, y, x1 - x0 + 1, 1)))
|
|
1752
|
+
x0 = nil
|
|
1753
|
+
end
|
|
1754
|
+
end
|
|
1755
|
+
end
|
|
1756
|
+
elseif op == "invert" then
|
|
1757
|
+
built = Selection(Rectangle(0, 0, s.width, s.height))
|
|
1758
|
+
built:subtract(current)
|
|
1759
|
+
elseif op == "grow" or op == "shrink" then
|
|
1760
|
+
local b = current.bounds
|
|
1761
|
+
local n = args.amount or 1
|
|
1762
|
+
local d = op == "grow" and n or -n
|
|
1763
|
+
built = Selection(Rectangle(b.x - d, b.y - d, math.max(1, b.width + 2 * d), math.max(1, b.height + 2 * d)))
|
|
1764
|
+
else
|
|
1765
|
+
fault("unsupported_command", "select op '" .. tostring(op) .. "' is not supported.")
|
|
1766
|
+
end
|
|
1767
|
+
|
|
1768
|
+
local mode = args.mode or "replace"
|
|
1769
|
+
if mode == "replace" or op == "none" or op == "all" or op == "invert" then
|
|
1770
|
+
s.selection = built
|
|
1771
|
+
elseif mode == "add" then current:add(built); s.selection = current
|
|
1772
|
+
elseif mode == "subtract" then current:subtract(built); s.selection = current
|
|
1773
|
+
elseif mode == "intersect" then current:intersect(built); s.selection = current
|
|
1774
|
+
end
|
|
1775
|
+
end)
|
|
1776
|
+
end
|
|
1777
|
+
|
|
1778
|
+
local sel = s.selection
|
|
1779
|
+
local empty = sel == nil or sel.isEmpty
|
|
1780
|
+
local count = 0
|
|
1781
|
+
if not empty then
|
|
1782
|
+
local b = sel.bounds
|
|
1783
|
+
for y = b.y, b.y + b.height - 1 do
|
|
1784
|
+
for x = b.x, b.x + b.width - 1 do
|
|
1785
|
+
if sel:contains(x, y) then count = count + 1 end
|
|
1786
|
+
end
|
|
1787
|
+
end
|
|
1788
|
+
end
|
|
1789
|
+
|
|
1790
|
+
return {
|
|
1791
|
+
sprite = sprite_display_name(s),
|
|
1792
|
+
empty = empty,
|
|
1793
|
+
bounds = (not empty) and { x = sel.bounds.x, y = sel.bounds.y, width = sel.bounds.width, height = sel.bounds.height } or nil,
|
|
1794
|
+
pixelCount = count,
|
|
1795
|
+
}
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1798
|
+
H["transform.apply"] = function(args)
|
|
1799
|
+
local s = find_sprite(args.sprite)
|
|
1800
|
+
local op = need(args.op, "op")
|
|
1801
|
+
local layer = find_layer(s, args.layer)
|
|
1802
|
+
local frame = find_frame(s, args.frame)
|
|
1803
|
+
local changed = 0
|
|
1804
|
+
|
|
1805
|
+
transact("AI: " .. op, function()
|
|
1806
|
+
if op == "crop_to_content" then
|
|
1807
|
+
preserving_site(function() app.sprite = s; app.command.CanvasSize{ ui = false, trimOutside = true } end)
|
|
1808
|
+
return
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
local cel = get_cel(s, layer, frame, false)
|
|
1812
|
+
if not cel then fault("invalid_args", "No cel on '" .. layer.name .. "' frame " .. frame.frameNumber .. ".") end
|
|
1813
|
+
|
|
1814
|
+
if op == "translate" then
|
|
1815
|
+
cel.position = Point(cel.position.x + (args.dx or 0), cel.position.y + (args.dy or 0))
|
|
1816
|
+
changed = cel.bounds.width * cel.bounds.height
|
|
1817
|
+
return
|
|
1818
|
+
end
|
|
1819
|
+
|
|
1820
|
+
local img = cel.image
|
|
1821
|
+
local img_w, img_h = img.width, img.height
|
|
1822
|
+
local out = Image(img.width, img.height, img.colorMode)
|
|
1823
|
+
|
|
1824
|
+
if op == "flip" then
|
|
1825
|
+
local horizontal = (args.axis or "horizontal") == "horizontal"
|
|
1826
|
+
for y = 0, img.height - 1 do
|
|
1827
|
+
for x = 0, img.width - 1 do
|
|
1828
|
+
local sx = horizontal and (img.width - 1 - x) or x
|
|
1829
|
+
local sy = horizontal and y or (img.height - 1 - y)
|
|
1830
|
+
out:drawPixel(x, y, img:getPixel(sx, sy))
|
|
1831
|
+
end
|
|
1832
|
+
end
|
|
1833
|
+
changed = img_w * img_h
|
|
1834
|
+
cel.image = out
|
|
1835
|
+
elseif op == "rotate" then
|
|
1836
|
+
local angle = ((args.angle or 90) % 360 + 360) % 360
|
|
1837
|
+
if angle == 180 then
|
|
1838
|
+
for y = 0, img.height - 1 do
|
|
1839
|
+
for x = 0, img.width - 1 do
|
|
1840
|
+
out:drawPixel(x, y, img:getPixel(img.width - 1 - x, img.height - 1 - y))
|
|
1841
|
+
end
|
|
1842
|
+
end
|
|
1843
|
+
cel.image = out
|
|
1844
|
+
changed = img_w * img_h
|
|
1845
|
+
elseif angle == 90 or angle == 270 then
|
|
1846
|
+
local rot = Image(img.height, img.width, img.colorMode)
|
|
1847
|
+
for y = 0, img.height - 1 do
|
|
1848
|
+
for x = 0, img.width - 1 do
|
|
1849
|
+
if angle == 90 then rot:drawPixel(img.height - 1 - y, x, img:getPixel(x, y))
|
|
1850
|
+
else rot:drawPixel(y, img.width - 1 - x, img:getPixel(x, y)) end
|
|
1851
|
+
end
|
|
1852
|
+
end
|
|
1853
|
+
cel.image = rot
|
|
1854
|
+
changed = img_w * img_h
|
|
1855
|
+
elseif angle ~= 0 then
|
|
1856
|
+
fault("invalid_args", "Only 90/180/270 rotations are lossless. " ..
|
|
1857
|
+
"The server should have blocked this; pass allowLossy there if the user accepted the quality loss.")
|
|
1858
|
+
end
|
|
1859
|
+
-- angle == 0 falls through with changed still 0: reporting a full-canvas
|
|
1860
|
+
-- change for an operation that touched nothing breaks any agent using
|
|
1861
|
+
-- pixelsChanged to check whether its call did anything.
|
|
1862
|
+
elseif op == "scale" then
|
|
1863
|
+
local f = args.factor or 2
|
|
1864
|
+
local scaled = Image(img.width * f, img.height * f, img.colorMode)
|
|
1865
|
+
for y = 0, scaled.height - 1 do
|
|
1866
|
+
for x = 0, scaled.width - 1 do
|
|
1867
|
+
scaled:drawPixel(x, y, img:getPixel(math.floor(x / f), math.floor(y / f)))
|
|
1868
|
+
end
|
|
1869
|
+
end
|
|
1870
|
+
cel.image = scaled
|
|
1871
|
+
changed = scaled.width * scaled.height
|
|
1872
|
+
elseif op == "outline" then
|
|
1873
|
+
local value = color_to_pixel(s, hex_to_color(need(args.color, "color")))
|
|
1874
|
+
local grown = Image(img.width + 2, img.height + 2, img.colorMode)
|
|
1875
|
+
grown:drawImage(img, Point(1, 1))
|
|
1876
|
+
local result = grown:clone()
|
|
1877
|
+
for y = 0, grown.height - 1 do
|
|
1878
|
+
for x = 0, grown.width - 1 do
|
|
1879
|
+
if pixel_to_hex(s, grown:getPixel(x, y)) == nil then
|
|
1880
|
+
local touching = false
|
|
1881
|
+
for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do
|
|
1882
|
+
local nx, ny = x + d[1], y + d[2]
|
|
1883
|
+
if nx >= 0 and ny >= 0 and nx < grown.width and ny < grown.height
|
|
1884
|
+
and pixel_to_hex(s, grown:getPixel(nx, ny)) ~= nil then touching = true end
|
|
1885
|
+
end
|
|
1886
|
+
if touching then result:drawPixel(x, y, value); changed = changed + 1 end
|
|
1887
|
+
end
|
|
1888
|
+
end
|
|
1889
|
+
end
|
|
1890
|
+
cel.image = result
|
|
1891
|
+
cel.position = Point(cel.position.x - 1, cel.position.y - 1)
|
|
1892
|
+
else
|
|
1893
|
+
fault("unsupported_command", "transform op '" .. tostring(op) .. "' is not supported.")
|
|
1894
|
+
end
|
|
1895
|
+
end)
|
|
1896
|
+
|
|
1897
|
+
local cel = get_cel(s, layer, frame, false)
|
|
1898
|
+
return {
|
|
1899
|
+
sprite = sprite_display_name(s),
|
|
1900
|
+
op = op, pixelsChanged = changed,
|
|
1901
|
+
bounds = cel and { x = cel.bounds.x, y = cel.bounds.y, width = cel.bounds.width, height = cel.bounds.height } or nil,
|
|
1902
|
+
}
|
|
1903
|
+
end
|
|
1904
|
+
|
|
1905
|
+
--------------------------------------------------------------------------------
|
|
1906
|
+
-- Recolor — shading by intent. Mirrors src/lib/color.ts hueShiftShade.
|
|
1907
|
+
--------------------------------------------------------------------------------
|
|
1908
|
+
|
|
1909
|
+
local function rgb_to_hsl(c)
|
|
1910
|
+
local r, g, b = c.red / 255, c.green / 255, c.blue / 255
|
|
1911
|
+
local max, min = math.max(r, g, b), math.min(r, g, b)
|
|
1912
|
+
local l = (max + min) / 2
|
|
1913
|
+
local d = max - min
|
|
1914
|
+
if d == 0 then return 0, 0, l end
|
|
1915
|
+
local sat = l > 0.5 and d / (2 - max - min) or d / (max + min)
|
|
1916
|
+
local h
|
|
1917
|
+
if max == r then h = ((g - b) / d + (g < b and 6 or 0)) * 60
|
|
1918
|
+
elseif max == g then h = ((b - r) / d + 2) * 60
|
|
1919
|
+
else h = ((r - g) / d + 4) * 60 end
|
|
1920
|
+
return h, sat, l
|
|
1921
|
+
end
|
|
1922
|
+
|
|
1923
|
+
local function hsl_to_rgb(h, s, l, a)
|
|
1924
|
+
if s == 0 then
|
|
1925
|
+
local v = math.floor(l * 255 + 0.5)
|
|
1926
|
+
return Color{ r = v, g = v, b = v, a = a }
|
|
1927
|
+
end
|
|
1928
|
+
local q = l < 0.5 and l * (1 + s) or l + s - l * s
|
|
1929
|
+
local p = 2 * l - q
|
|
1930
|
+
local hk = ((h % 360) + 360) % 360 / 360
|
|
1931
|
+
local function channel(t)
|
|
1932
|
+
if t < 0 then t = t + 1 end
|
|
1933
|
+
if t > 1 then t = t - 1 end
|
|
1934
|
+
if t < 1 / 6 then return p + (q - p) * 6 * t end
|
|
1935
|
+
if t < 1 / 2 then return q end
|
|
1936
|
+
if t < 2 / 3 then return p + (q - p) * (2 / 3 - t) * 6 end
|
|
1937
|
+
return p
|
|
1938
|
+
end
|
|
1939
|
+
return Color{
|
|
1940
|
+
r = math.floor(channel(hk + 1 / 3) * 255 + 0.5),
|
|
1941
|
+
g = math.floor(channel(hk) * 255 + 0.5),
|
|
1942
|
+
b = math.floor(channel(hk - 1 / 3) * 255 + 0.5),
|
|
1943
|
+
a = a,
|
|
1944
|
+
}
|
|
1945
|
+
end
|
|
1946
|
+
|
|
1947
|
+
local function clamp01(v) return math.max(0, math.min(1, v)) end
|
|
1948
|
+
|
|
1949
|
+
-- Shadows rotate toward blue and desaturate slightly; highlights rotate toward
|
|
1950
|
+
-- yellow-orange and saturate. A pure luminance change is the tell of art that
|
|
1951
|
+
-- was made by moving a brightness slider.
|
|
1952
|
+
local function shade_color(color, amount)
|
|
1953
|
+
local h, s, l = rgb_to_hsl(color)
|
|
1954
|
+
local sign = amount > 0 and 1 or -1
|
|
1955
|
+
local shift = -sign * 25 * math.abs(amount)
|
|
1956
|
+
local new_h = ((h + shift) % 360 + 360) % 360
|
|
1957
|
+
local new_l = clamp01(l + amount * 0.5)
|
|
1958
|
+
local new_s = clamp01(s + (amount > 0 and 0.06 or -0.04) * math.abs(amount) * 2)
|
|
1959
|
+
return hsl_to_rgb(new_h, new_s, new_l, color.alpha)
|
|
1960
|
+
end
|
|
1961
|
+
|
|
1962
|
+
H["recolor.apply"] = function(args)
|
|
1963
|
+
local s = find_sprite(args.sprite)
|
|
1964
|
+
local layer = find_layer(s, args.layer)
|
|
1965
|
+
local frame = find_frame(s, args.frame)
|
|
1966
|
+
local op = need(args.op, "op")
|
|
1967
|
+
|
|
1968
|
+
local cel = get_cel(s, layer, frame, false)
|
|
1969
|
+
if not cel then fault("invalid_args", "No cel on '" .. layer.name .. "' frame " .. frame.frameNumber .. ".") end
|
|
1970
|
+
|
|
1971
|
+
local selection = args.selectionOnly and s.selection or nil
|
|
1972
|
+
if args.selectionOnly and (not selection or selection.isEmpty) then
|
|
1973
|
+
fault("invalid_args",
|
|
1974
|
+
"selectionOnly was set but nothing is selected. Recolouring the whole cel instead would " ..
|
|
1975
|
+
"change far more than you asked for, so this refuses. Select a region first, or pass a " ..
|
|
1976
|
+
"`region`, or drop selectionOnly.")
|
|
1977
|
+
end
|
|
1978
|
+
local region = args.region and clamp_region(s, args.region) or nil
|
|
1979
|
+
|
|
1980
|
+
-- Map over distinct colours, not pixels: a 64x64 recolour becomes a handful
|
|
1981
|
+
-- of colour decisions plus one pass, instead of four thousand decisions.
|
|
1982
|
+
local mapping, counts, order = {}, {}, {}
|
|
1983
|
+
local img = cel.image
|
|
1984
|
+
local ox, oy = cel.position.x, cel.position.y
|
|
1985
|
+
|
|
1986
|
+
local function in_scope(gx, gy)
|
|
1987
|
+
if region and (gx < region.x or gy < region.y
|
|
1988
|
+
or gx >= region.x + region.width or gy >= region.y + region.height) then return false end
|
|
1989
|
+
if selection and not selection:contains(gx, gy) then return false end
|
|
1990
|
+
return true
|
|
1991
|
+
end
|
|
1992
|
+
|
|
1993
|
+
for y = 0, img.height - 1 do
|
|
1994
|
+
for x = 0, img.width - 1 do
|
|
1995
|
+
if in_scope(x + ox, y + oy) then
|
|
1996
|
+
local hex = pixel_to_hex(s, img:getPixel(x, y))
|
|
1997
|
+
if hex then
|
|
1998
|
+
if counts[hex] == nil then counts[hex] = 0; order[#order + 1] = hex end
|
|
1999
|
+
counts[hex] = counts[hex] + 1
|
|
2000
|
+
end
|
|
2001
|
+
end
|
|
2002
|
+
end
|
|
2003
|
+
end
|
|
2004
|
+
|
|
2005
|
+
for _, hex in ipairs(order) do
|
|
2006
|
+
local color = hex_to_color(hex)
|
|
2007
|
+
local result
|
|
2008
|
+
if op == "shade" then
|
|
2009
|
+
result = shade_color(color, args.amount or -0.2)
|
|
2010
|
+
elseif op == "snap" then
|
|
2011
|
+
result = snap_color_to_palette(s, color)
|
|
2012
|
+
elseif op == "replace" then
|
|
2013
|
+
result = (hex:sub(1, 7) == color_to_hex(hex_to_color(need(args.from, "from")), false))
|
|
2014
|
+
and hex_to_color(need(args.to, "to")) or color
|
|
2015
|
+
elseif op == "hue_shift" then
|
|
2016
|
+
local h, sat, l = rgb_to_hsl(color)
|
|
2017
|
+
result = hsl_to_rgb(h + (args.degrees or 30), sat, l, color.alpha)
|
|
2018
|
+
elseif op == "desaturate" then
|
|
2019
|
+
local h, sat, l = rgb_to_hsl(color)
|
|
2020
|
+
result = hsl_to_rgb(h, sat * (1 - (args.strength or 1)), l, color.alpha)
|
|
2021
|
+
else
|
|
2022
|
+
fault("unsupported_command", "recolor op '" .. tostring(op) .. "' is not supported.")
|
|
2023
|
+
end
|
|
2024
|
+
|
|
2025
|
+
if args.clampToPalette ~= false and op ~= "snap" and s.colorMode ~= ColorMode.INDEXED then
|
|
2026
|
+
result = snap_color_to_palette(s, result)
|
|
2027
|
+
end
|
|
2028
|
+
mapping[hex] = result
|
|
2029
|
+
end
|
|
2030
|
+
|
|
2031
|
+
local changed = 0
|
|
2032
|
+
transact("AI: recolor " .. op, function()
|
|
2033
|
+
local out = img:clone()
|
|
2034
|
+
for y = 0, img.height - 1 do
|
|
2035
|
+
for x = 0, img.width - 1 do
|
|
2036
|
+
if in_scope(x + ox, y + oy) then
|
|
2037
|
+
local hex = pixel_to_hex(s, img:getPixel(x, y))
|
|
2038
|
+
if hex and mapping[hex] then
|
|
2039
|
+
local value = color_to_pixel(s, mapping[hex])
|
|
2040
|
+
if value ~= img:getPixel(x, y) then
|
|
2041
|
+
out:drawPixel(x, y, value)
|
|
2042
|
+
changed = changed + 1
|
|
2043
|
+
end
|
|
2044
|
+
end
|
|
2045
|
+
end
|
|
2046
|
+
end
|
|
2047
|
+
end
|
|
2048
|
+
cel.image = out
|
|
2049
|
+
end)
|
|
2050
|
+
|
|
2051
|
+
local report = {}
|
|
2052
|
+
for _, hex in ipairs(order) do
|
|
2053
|
+
local to = color_to_hex(mapping[hex], false)
|
|
2054
|
+
if to ~= hex:sub(1, 7) then
|
|
2055
|
+
report[#report + 1] = { from = hex:sub(1, 7), to = to, pixels = counts[hex] }
|
|
2056
|
+
end
|
|
2057
|
+
end
|
|
2058
|
+
|
|
2059
|
+
return {
|
|
2060
|
+
sprite = sprite_display_name(s),
|
|
2061
|
+
op = op, pixelsChanged = changed, mapping = report,
|
|
2062
|
+
}
|
|
2063
|
+
end
|
|
2064
|
+
|
|
2065
|
+
--------------------------------------------------------------------------------
|
|
2066
|
+
-- Reference images
|
|
2067
|
+
--------------------------------------------------------------------------------
|
|
2068
|
+
|
|
2069
|
+
H["reference.apply"] = function(args)
|
|
2070
|
+
local s = find_sprite(args.sprite)
|
|
2071
|
+
local op = need(args.op, "op")
|
|
2072
|
+
|
|
2073
|
+
if op == "list" then
|
|
2074
|
+
local refs = {}
|
|
2075
|
+
for _, l in ipairs(s.layers) do
|
|
2076
|
+
if l.name:match("^reference") then refs[#refs + 1] = l.name end
|
|
2077
|
+
end
|
|
2078
|
+
return { sprite = sprite_display_name(s), op = op, references = refs }
|
|
2079
|
+
end
|
|
2080
|
+
|
|
2081
|
+
if op == "remove" then
|
|
2082
|
+
transact("AI: remove reference", function()
|
|
2083
|
+
s:deleteLayer(find_layer(s, args.name or "reference"))
|
|
2084
|
+
end)
|
|
2085
|
+
return { sprite = sprite_display_name(s), op = op }
|
|
2086
|
+
end
|
|
2087
|
+
|
|
2088
|
+
local path = need(args.path, "path")
|
|
2089
|
+
local source = app.open(path)
|
|
2090
|
+
if not source then fault("aseprite_error", "Could not open '" .. path .. "'.") end
|
|
2091
|
+
|
|
2092
|
+
local flat = Image(source.width, source.height, ColorMode.RGB)
|
|
2093
|
+
flat:drawSprite(source, source.frames[1])
|
|
2094
|
+
local sw, sh = source.width, source.height
|
|
2095
|
+
source:close()
|
|
2096
|
+
|
|
2097
|
+
if op == "sample_palette" then
|
|
2098
|
+
local counts, total = {}, 0
|
|
2099
|
+
for y = 0, sh - 1 do
|
|
2100
|
+
for x = 0, sw - 1 do
|
|
2101
|
+
local hex = pixel_to_hex({ colorMode = ColorMode.RGB }, flat:getPixel(x, y))
|
|
2102
|
+
if hex then
|
|
2103
|
+
local key = hex:sub(1, 7)
|
|
2104
|
+
counts[key] = (counts[key] or 0) + 1
|
|
2105
|
+
total = total + 1
|
|
2106
|
+
end
|
|
2107
|
+
end
|
|
2108
|
+
end
|
|
2109
|
+
local list = {}
|
|
2110
|
+
for hex, n in pairs(counts) do list[#list + 1] = { hex = hex, share = n / math.max(total, 1) } end
|
|
2111
|
+
table.sort(list, function(a, b) return a.share > b.share end)
|
|
2112
|
+
local top = {}
|
|
2113
|
+
for i = 1, math.min(#list, args.colors or 16) do
|
|
2114
|
+
top[i] = { hex = list[i].hex, share = math.floor(list[i].share * 1000 + 0.5) / 1000 }
|
|
2115
|
+
end
|
|
2116
|
+
return { sprite = sprite_display_name(s), op = op, palette = top, width = sw, height = sh }
|
|
2117
|
+
end
|
|
2118
|
+
|
|
2119
|
+
-- op == "import"
|
|
2120
|
+
local name = args.name or "reference"
|
|
2121
|
+
local fit = args.fit or "contain"
|
|
2122
|
+
local tw, th = sw, sh
|
|
2123
|
+
if fit ~= "none" then
|
|
2124
|
+
local ratio
|
|
2125
|
+
if fit == "contain" then ratio = math.min(s.width / sw, s.height / sh)
|
|
2126
|
+
elseif fit == "cover" then ratio = math.max(s.width / sw, s.height / sh)
|
|
2127
|
+
else ratio = nil end
|
|
2128
|
+
if ratio then tw, th = math.max(1, math.floor(sw * ratio)), math.max(1, math.floor(sh * ratio))
|
|
2129
|
+
else tw, th = s.width, s.height end
|
|
2130
|
+
end
|
|
2131
|
+
|
|
2132
|
+
local scaled = Image(tw, th, ColorMode.RGB)
|
|
2133
|
+
for y = 0, th - 1 do
|
|
2134
|
+
for x = 0, tw - 1 do
|
|
2135
|
+
scaled:drawPixel(x, y, flat:getPixel(math.floor(x * sw / tw), math.floor(y * sh / th)))
|
|
2136
|
+
end
|
|
2137
|
+
end
|
|
2138
|
+
|
|
2139
|
+
transact("AI: import reference", function()
|
|
2140
|
+
local layer = s:newLayer()
|
|
2141
|
+
layer.name = name
|
|
2142
|
+
layer.opacity = args.opacity or 128
|
|
2143
|
+
layer.isEditable = false -- a reference the agent can accidentally paint on is a trap
|
|
2144
|
+
layer.stackIndex = #s.layers
|
|
2145
|
+
s:newCel(layer, find_frame(s, args.frame), scaled, Point(args.x or 0, args.y or 0))
|
|
2146
|
+
end)
|
|
2147
|
+
|
|
2148
|
+
return { sprite = sprite_display_name(s), op = op, layer = name, width = tw, height = th }
|
|
2149
|
+
end
|
|
2150
|
+
|
|
2151
|
+
--------------------------------------------------------------------------------
|
|
2152
|
+
-- Export
|
|
2153
|
+
--------------------------------------------------------------------------------
|
|
2154
|
+
|
|
2155
|
+
H["export.run"] = function(args)
|
|
2156
|
+
local s = find_sprite(args.sprite)
|
|
2157
|
+
local op = need(args.op, "op")
|
|
2158
|
+
local path = need(args.path, "path")
|
|
2159
|
+
local files = {}
|
|
2160
|
+
local exported_frame = nil
|
|
2161
|
+
|
|
2162
|
+
preserving_site(function()
|
|
2163
|
+
app.sprite = s
|
|
2164
|
+
|
|
2165
|
+
if op == "png" then
|
|
2166
|
+
local frame = find_frame(s, args.frame)
|
|
2167
|
+
exported_frame = frame.frameNumber
|
|
2168
|
+
local flat = Image(s.width, s.height, s.colorMode)
|
|
2169
|
+
flat:drawSprite(s, frame)
|
|
2170
|
+
local out = Sprite(s.width, s.height, s.colorMode)
|
|
2171
|
+
if s.colorMode == ColorMode.INDEXED then out:setPalette(s.palettes[1]) end
|
|
2172
|
+
out.cels[1].image = flat
|
|
2173
|
+
-- (export.run already runs inside preserving_site)
|
|
2174
|
+
if (args.scale or 1) > 1 then out:resize(s.width * args.scale, s.height * args.scale) end
|
|
2175
|
+
out:saveCopyAs(path)
|
|
2176
|
+
out:close()
|
|
2177
|
+
files[#files + 1] = path
|
|
2178
|
+
|
|
2179
|
+
elseif op == "gif" or op == "aseprite" then
|
|
2180
|
+
s:saveCopyAs(path)
|
|
2181
|
+
files[#files + 1] = path
|
|
2182
|
+
|
|
2183
|
+
elseif op == "frames" then
|
|
2184
|
+
-- Aseprite expands {frame} itself when the filename carries the token.
|
|
2185
|
+
s:saveCopyAs(path)
|
|
2186
|
+
files[#files + 1] = path
|
|
2187
|
+
|
|
2188
|
+
elseif op == "spritesheet" then
|
|
2189
|
+
local params = {
|
|
2190
|
+
ui = false,
|
|
2191
|
+
type = SpriteSheetType[string.upper(args.sheetType or "horizontal")] or SpriteSheetType.HORIZONTAL,
|
|
2192
|
+
textureFilename = path,
|
|
2193
|
+
innerPadding = args.padding or 0,
|
|
2194
|
+
trim = args.trim or false,
|
|
2195
|
+
splitTags = args.byTag or false,
|
|
2196
|
+
}
|
|
2197
|
+
if args.includeJson ~= false then
|
|
2198
|
+
local json_path = path:gsub("%.%w+$", "") .. ".json"
|
|
2199
|
+
params.dataFilename = json_path
|
|
2200
|
+
params.dataFormat = SpriteSheetDataFormat.JSON_ARRAY
|
|
2201
|
+
params.listTags = true
|
|
2202
|
+
params.listLayers = true
|
|
2203
|
+
params.listSlices = true
|
|
2204
|
+
files[#files + 1] = json_path
|
|
2205
|
+
end
|
|
2206
|
+
app.command.ExportSpriteSheet(params)
|
|
2207
|
+
files[#files + 1] = path
|
|
2208
|
+
|
|
2209
|
+
else
|
|
2210
|
+
fault("unsupported_command", "export op '" .. tostring(op) .. "' is not supported.")
|
|
2211
|
+
end
|
|
2212
|
+
end)
|
|
2213
|
+
|
|
2214
|
+
return {
|
|
2215
|
+
sprite = sprite_display_name(s),
|
|
2216
|
+
op = op, files = files,
|
|
2217
|
+
width = s.width, height = s.height, frameCount = #s.frames,
|
|
2218
|
+
frame = exported_frame,
|
|
2219
|
+
atlas = (op == "spritesheet" and args.includeJson ~= false) and files[1] or nil,
|
|
2220
|
+
}
|
|
2221
|
+
end
|
|
2222
|
+
|
|
2223
|
+
--------------------------------------------------------------------------------
|
|
2224
|
+
-- Tilesets
|
|
2225
|
+
--------------------------------------------------------------------------------
|
|
2226
|
+
|
|
2227
|
+
--------------------------------------------------------------------------------
|
|
2228
|
+
-- Tileset helpers
|
|
2229
|
+
--------------------------------------------------------------------------------
|
|
2230
|
+
|
|
2231
|
+
--- Copy one grid cell out of a canvas-sized image.
|
|
2232
|
+
local function extract_cell(source, x0, y0, w, h, mode)
|
|
2233
|
+
local cell = Image(w, h, mode)
|
|
2234
|
+
for y = 0, h - 1 do
|
|
2235
|
+
for x = 0, w - 1 do
|
|
2236
|
+
local sx, sy = x0 + x, y0 + y
|
|
2237
|
+
if sx < source.width and sy < source.height then
|
|
2238
|
+
cell:drawPixel(x, y, source:getPixel(sx, sy))
|
|
2239
|
+
end
|
|
2240
|
+
end
|
|
2241
|
+
end
|
|
2242
|
+
return cell
|
|
2243
|
+
end
|
|
2244
|
+
|
|
2245
|
+
--- Exact identity key for a cell. Used for the tolerance-0 fast path, where a
|
|
2246
|
+
--- hash lookup turns an O(cells x tiles) scan into O(cells).
|
|
2247
|
+
local function cell_key(cell)
|
|
2248
|
+
local parts = {}
|
|
2249
|
+
for y = 0, cell.height - 1 do
|
|
2250
|
+
for x = 0, cell.width - 1 do
|
|
2251
|
+
parts[#parts + 1] = cell:getPixel(x, y)
|
|
2252
|
+
end
|
|
2253
|
+
end
|
|
2254
|
+
return table.concat(parts, ",")
|
|
2255
|
+
end
|
|
2256
|
+
|
|
2257
|
+
local function cell_is_empty(cell, sprite)
|
|
2258
|
+
for y = 0, cell.height - 1 do
|
|
2259
|
+
for x = 0, cell.width - 1 do
|
|
2260
|
+
if pixel_to_hex(sprite, cell:getPixel(x, y)) ~= nil then return false end
|
|
2261
|
+
end
|
|
2262
|
+
end
|
|
2263
|
+
return true
|
|
2264
|
+
end
|
|
2265
|
+
|
|
2266
|
+
--- Per-channel max difference between two same-sized cells, 0-255.
|
|
2267
|
+
--- RGB only: in indexed and grayscale modes the raw pixel value is a palette
|
|
2268
|
+
--- index or a packed gray+alpha pair, and running that through rgbaR/G/B/A
|
|
2269
|
+
--- extracts meaningless bit-fields — which merged two maximally different
|
|
2270
|
+
--- tiles into one and called it a successful dedup.
|
|
2271
|
+
local function cell_distance(a, b)
|
|
2272
|
+
local pc = app.pixelColor
|
|
2273
|
+
local worst = 0
|
|
2274
|
+
for y = 0, a.height - 1 do
|
|
2275
|
+
for x = 0, a.width - 1 do
|
|
2276
|
+
local pa, pb = a:getPixel(x, y), b:getPixel(x, y)
|
|
2277
|
+
if pa ~= pb then
|
|
2278
|
+
local d = math.max(
|
|
2279
|
+
math.abs(pc.rgbaR(pa) - pc.rgbaR(pb)),
|
|
2280
|
+
math.abs(pc.rgbaG(pa) - pc.rgbaG(pb)),
|
|
2281
|
+
math.abs(pc.rgbaB(pa) - pc.rgbaB(pb)),
|
|
2282
|
+
math.abs(pc.rgbaA(pa) - pc.rgbaA(pb)))
|
|
2283
|
+
if d > worst then
|
|
2284
|
+
worst = d
|
|
2285
|
+
if worst > 255 then return worst end
|
|
2286
|
+
end
|
|
2287
|
+
end
|
|
2288
|
+
end
|
|
2289
|
+
end
|
|
2290
|
+
return worst
|
|
2291
|
+
end
|
|
2292
|
+
|
|
2293
|
+
--- Pack a tileset's tiles into one image, near-square, row-major.
|
|
2294
|
+
---
|
|
2295
|
+
--- `skip_empty` drops Aseprite's reserved index-0 empty tile. Engines index the
|
|
2296
|
+
--- atlas from 0, so keeping the empty tile in the image shifts every real tile
|
|
2297
|
+
--- by one and produces a map that renders one tile off everywhere. Dropping it
|
|
2298
|
+
--- makes Aseprite index N land at atlas position N-1, which is exactly the
|
|
2299
|
+
--- offset Tiled's `firstgid` of 1 undoes.
|
|
2300
|
+
local function pack_tiles(tileset, mode, skip_empty)
|
|
2301
|
+
local first = skip_empty and 1 or 0
|
|
2302
|
+
local count = math.max(0, #tileset - first)
|
|
2303
|
+
local tw = tileset.grid.tileSize.width
|
|
2304
|
+
local th = tileset.grid.tileSize.height
|
|
2305
|
+
local cols = math.max(1, math.ceil(math.sqrt(math.max(count, 1))))
|
|
2306
|
+
local rows = math.max(1, math.ceil(count / cols))
|
|
2307
|
+
local packed = Image(cols * tw, rows * th, mode)
|
|
2308
|
+
for slot = 0, count - 1 do
|
|
2309
|
+
local tile = tileset:tile(slot + first)
|
|
2310
|
+
if tile and tile.image then
|
|
2311
|
+
packed:drawImage(tile.image, Point((slot % cols) * tw, math.floor(slot / cols) * th))
|
|
2312
|
+
end
|
|
2313
|
+
end
|
|
2314
|
+
return packed, cols, rows, tw, th, count
|
|
2315
|
+
end
|
|
2316
|
+
|
|
2317
|
+
--- Write an image to disk as a PNG via a scratch sprite, optionally upscaled.
|
|
2318
|
+
local function write_image(image, mode, palette, path, scale)
|
|
2319
|
+
preserving_site(function()
|
|
2320
|
+
local out = Sprite(image.width, image.height, mode)
|
|
2321
|
+
if mode == ColorMode.INDEXED and palette then out:setPalette(palette) end
|
|
2322
|
+
out.cels[1].image = image
|
|
2323
|
+
if scale and scale > 1 then out:resize(image.width * scale, image.height * scale) end
|
|
2324
|
+
out:saveCopyAs(path)
|
|
2325
|
+
out:close()
|
|
2326
|
+
end)
|
|
2327
|
+
end
|
|
2328
|
+
|
|
2329
|
+
--------------------------------------------------------------------------------
|
|
2330
|
+
-- blob47
|
|
2331
|
+
--
|
|
2332
|
+
-- The 47-tile "blob" set enumerates every distinct 8-neighbour configuration
|
|
2333
|
+
-- once the meaningless ones are removed: a diagonal neighbour only affects the
|
|
2334
|
+
-- shape when both edges beside it are also filled, so masks that differ only in
|
|
2335
|
+
-- an orphaned corner bit describe the same tile. Reducing all 256 masks by that
|
|
2336
|
+
-- rule leaves exactly 47 canonical values, and sorting them ascending is the
|
|
2337
|
+
-- ordering nearly every blob47 tileset on the internet is authored in.
|
|
2338
|
+
--
|
|
2339
|
+
-- This is computed rather than hardcoded so it is checkable: if the reduction
|
|
2340
|
+
-- does not yield 47 values, the export refuses instead of writing a wangset
|
|
2341
|
+
-- that would autotile wrongly.
|
|
2342
|
+
--------------------------------------------------------------------------------
|
|
2343
|
+
|
|
2344
|
+
-- Bit layout, matching the wangid order Tiled uses.
|
|
2345
|
+
local N, NE, E, SE, S, SW, W, NW = 1, 2, 4, 8, 16, 32, 64, 128
|
|
2346
|
+
|
|
2347
|
+
local function reduce_blob_mask(mask)
|
|
2348
|
+
local out = mask
|
|
2349
|
+
-- A corner survives only when both of its adjacent edges are present.
|
|
2350
|
+
if (out & NE) ~= 0 and not ((out & N) ~= 0 and (out & E) ~= 0) then out = out & ~NE end
|
|
2351
|
+
if (out & SE) ~= 0 and not ((out & S) ~= 0 and (out & E) ~= 0) then out = out & ~SE end
|
|
2352
|
+
if (out & SW) ~= 0 and not ((out & S) ~= 0 and (out & W) ~= 0) then out = out & ~SW end
|
|
2353
|
+
if (out & NW) ~= 0 and not ((out & N) ~= 0 and (out & W) ~= 0) then out = out & ~NW end
|
|
2354
|
+
return out
|
|
2355
|
+
end
|
|
2356
|
+
|
|
2357
|
+
local function blob47_masks()
|
|
2358
|
+
local seen, list = {}, {}
|
|
2359
|
+
for mask = 0, 255 do
|
|
2360
|
+
local reduced = reduce_blob_mask(mask)
|
|
2361
|
+
if not seen[reduced] then
|
|
2362
|
+
seen[reduced] = true
|
|
2363
|
+
list[#list + 1] = reduced
|
|
2364
|
+
end
|
|
2365
|
+
end
|
|
2366
|
+
table.sort(list)
|
|
2367
|
+
return list
|
|
2368
|
+
end
|
|
2369
|
+
|
|
2370
|
+
H["tileset.apply"] = function(args)
|
|
2371
|
+
local s = find_sprite(args.sprite)
|
|
2372
|
+
local op = need(args.op, "op")
|
|
2373
|
+
|
|
2374
|
+
if op == "list" then
|
|
2375
|
+
local out = {}
|
|
2376
|
+
for i, ts in ipairs(s.tilesets or {}) do
|
|
2377
|
+
out[i] = {
|
|
2378
|
+
name = ts.name ~= "" and ts.name or ("tileset " .. i),
|
|
2379
|
+
tileCount = #ts,
|
|
2380
|
+
tileWidth = ts.grid.tileSize.width,
|
|
2381
|
+
tileHeight = ts.grid.tileSize.height,
|
|
2382
|
+
}
|
|
2383
|
+
end
|
|
2384
|
+
return { sprite = sprite_display_name(s), op = op, tilesets = out }
|
|
2385
|
+
end
|
|
2386
|
+
|
|
2387
|
+
if op == "create_layer" then
|
|
2388
|
+
local name = args.name or "tilemap"
|
|
2389
|
+
local tw = args.tileWidth or 16
|
|
2390
|
+
local th = args.tileHeight or 16
|
|
2391
|
+
transact("AI: new tilemap layer", function()
|
|
2392
|
+
preserving_site(function()
|
|
2393
|
+
app.sprite = s
|
|
2394
|
+
app.command.NewLayer{ tilemap = true, gridBounds = Rectangle(0, 0, tw, th) }
|
|
2395
|
+
if app.layer then app.layer.name = name end
|
|
2396
|
+
end)
|
|
2397
|
+
end)
|
|
2398
|
+
return {
|
|
2399
|
+
sprite = sprite_display_name(s), op = op, layer = name,
|
|
2400
|
+
tileWidth = tw, tileHeight = th,
|
|
2401
|
+
}
|
|
2402
|
+
end
|
|
2403
|
+
|
|
2404
|
+
------------------------------------------------------------------------------
|
|
2405
|
+
-- pack: a hand-painted mockup becomes a tileset plus a tilemap that rebuilds it
|
|
2406
|
+
------------------------------------------------------------------------------
|
|
2407
|
+
if op == "pack" then
|
|
2408
|
+
local source = find_layer(s, args.layer)
|
|
2409
|
+
if source.isTilemap then
|
|
2410
|
+
fault("invalid_args", "'" .. source.name .. "' is already a tilemap. Pack a normal painted layer.")
|
|
2411
|
+
end
|
|
2412
|
+
local frame = find_frame(s, args.frame)
|
|
2413
|
+
local tw = args.tileWidth or 16
|
|
2414
|
+
local th = args.tileHeight or 16
|
|
2415
|
+
local tolerance = args.tolerance or 0
|
|
2416
|
+
if tolerance > 0 and s.colorMode ~= ColorMode.RGB then
|
|
2417
|
+
fault("invalid_args",
|
|
2418
|
+
"tolerance > 0 needs an RGB sprite — perceptual distance is not defined over palette " ..
|
|
2419
|
+
"indices or packed gray values, and comparing them merges unrelated tiles. " ..
|
|
2420
|
+
"Convert to RGB, or pack with tolerance 0 (exact matches only).")
|
|
2421
|
+
end
|
|
2422
|
+
|
|
2423
|
+
if s.width % tw ~= 0 or s.height % th ~= 0 then
|
|
2424
|
+
fault("invalid_args", string.format(
|
|
2425
|
+
"Canvas is %dx%d, which is not a whole number of %dx%d tiles. " ..
|
|
2426
|
+
"Resize the canvas to a multiple of the tile size before packing — a partial " ..
|
|
2427
|
+
"edge tile would silently lose pixels.", s.width, s.height, tw, th))
|
|
2428
|
+
end
|
|
2429
|
+
|
|
2430
|
+
-- Render just this layer, so packing a mockup is not polluted by a sketch
|
|
2431
|
+
-- or reference layer sitting above it.
|
|
2432
|
+
local canvas = Image(s.width, s.height, s.colorMode)
|
|
2433
|
+
local src_cel = get_cel(s, source, frame, false)
|
|
2434
|
+
if not src_cel then
|
|
2435
|
+
fault("invalid_args", "'" .. source.name .. "' has no cel on frame " .. frame.frameNumber .. ".")
|
|
2436
|
+
end
|
|
2437
|
+
canvas:drawImage(src_cel.image, src_cel.position)
|
|
2438
|
+
|
|
2439
|
+
local cols = s.width // tw
|
|
2440
|
+
local rows = s.height // th
|
|
2441
|
+
|
|
2442
|
+
-- Index 0 in an Aseprite tileset is the reserved empty tile, so a fully
|
|
2443
|
+
-- transparent cell maps to 0 and costs nothing.
|
|
2444
|
+
local cells = {}
|
|
2445
|
+
local unique = {} -- ordered list of {image=, key=}
|
|
2446
|
+
local by_key = {}
|
|
2447
|
+
local exact_reuse, fuzzy_reuse = 0, 0
|
|
2448
|
+
|
|
2449
|
+
for row = 0, rows - 1 do
|
|
2450
|
+
for col = 0, cols - 1 do
|
|
2451
|
+
local cell = extract_cell(canvas, col * tw, row * th, tw, th, s.colorMode)
|
|
2452
|
+
local index
|
|
2453
|
+
if cell_is_empty(cell, s) then
|
|
2454
|
+
index = 0
|
|
2455
|
+
else
|
|
2456
|
+
local key = cell_key(cell)
|
|
2457
|
+
local hit = by_key[key]
|
|
2458
|
+
if hit then
|
|
2459
|
+
index = hit
|
|
2460
|
+
exact_reuse = exact_reuse + 1
|
|
2461
|
+
elseif tolerance > 0 and s.colorMode == ColorMode.RGB then
|
|
2462
|
+
-- Only the slow path scans: exact matches are already resolved.
|
|
2463
|
+
for i, existing in ipairs(unique) do
|
|
2464
|
+
if cell_distance(cell, existing.image) <= tolerance then
|
|
2465
|
+
index = i
|
|
2466
|
+
fuzzy_reuse = fuzzy_reuse + 1
|
|
2467
|
+
break
|
|
2468
|
+
end
|
|
2469
|
+
end
|
|
2470
|
+
end
|
|
2471
|
+
if not index then
|
|
2472
|
+
unique[#unique + 1] = { image = cell, key = key }
|
|
2473
|
+
index = #unique
|
|
2474
|
+
by_key[key] = index
|
|
2475
|
+
end
|
|
2476
|
+
end
|
|
2477
|
+
cells[row * cols + col] = index
|
|
2478
|
+
end
|
|
2479
|
+
end
|
|
2480
|
+
|
|
2481
|
+
local layer_name = args.name or (source.name .. "-tilemap")
|
|
2482
|
+
|
|
2483
|
+
transact("AI: pack tileset", function()
|
|
2484
|
+
preserving_site(function()
|
|
2485
|
+
app.sprite = s
|
|
2486
|
+
app.command.NewLayer{ tilemap = true, gridBounds = Rectangle(0, 0, tw, th) }
|
|
2487
|
+
local tilemap = app.layer
|
|
2488
|
+
tilemap.name = layer_name
|
|
2489
|
+
|
|
2490
|
+
local ts = tilemap.tileset
|
|
2491
|
+
for _, entry in ipairs(unique) do
|
|
2492
|
+
local tile = s:newTile(ts)
|
|
2493
|
+
tile.image = entry.image
|
|
2494
|
+
end
|
|
2495
|
+
|
|
2496
|
+
local spec = ImageSpec{ width = cols, height = rows, colorMode = ColorMode.TILEMAP }
|
|
2497
|
+
local map = Image(spec)
|
|
2498
|
+
for row = 0, rows - 1 do
|
|
2499
|
+
for col = 0, cols - 1 do
|
|
2500
|
+
map:drawPixel(col, row, app.pixelColor.tile(cells[row * cols + col], 0))
|
|
2501
|
+
end
|
|
2502
|
+
end
|
|
2503
|
+
s:newCel(tilemap, frame, map, Point(0, 0))
|
|
2504
|
+
|
|
2505
|
+
-- Keep the mockup, hidden: a wrong tile size is only obvious once you
|
|
2506
|
+
-- compare, and deleting the original makes that impossible.
|
|
2507
|
+
source.isVisible = false
|
|
2508
|
+
end)
|
|
2509
|
+
end)
|
|
2510
|
+
|
|
2511
|
+
return {
|
|
2512
|
+
sprite = sprite_display_name(s),
|
|
2513
|
+
op = op,
|
|
2514
|
+
layer = layer_name,
|
|
2515
|
+
sourceLayer = source.name,
|
|
2516
|
+
tileWidth = tw, tileHeight = th,
|
|
2517
|
+
columns = cols, rows = rows,
|
|
2518
|
+
cellCount = cols * rows,
|
|
2519
|
+
tileCount = #unique,
|
|
2520
|
+
reusedExact = exact_reuse,
|
|
2521
|
+
reusedFuzzy = fuzzy_reuse,
|
|
2522
|
+
}
|
|
2523
|
+
end
|
|
2524
|
+
|
|
2525
|
+
------------------------------------------------------------------------------
|
|
2526
|
+
-- everything below needs an existing tilemap layer
|
|
2527
|
+
------------------------------------------------------------------------------
|
|
2528
|
+
local layer = find_layer(s, args.layer)
|
|
2529
|
+
if not layer.isTilemap then
|
|
2530
|
+
fault("invalid_args", "'" .. layer.name .. "' is not a tilemap layer. Create one with op 'create_layer', or build one from a mockup with op 'pack'.")
|
|
2531
|
+
end
|
|
2532
|
+
local frame = find_frame(s, args.frame)
|
|
2533
|
+
local tileset = layer.tileset
|
|
2534
|
+
if not tileset then fault("invalid_args", "Layer '" .. layer.name .. "' has no tileset.") end
|
|
2535
|
+
|
|
2536
|
+
if op == "stamp" then
|
|
2537
|
+
local tiles = need(args.tiles, "tiles")
|
|
2538
|
+
local tw = tileset.grid.tileSize.width
|
|
2539
|
+
local th = tileset.grid.tileSize.height
|
|
2540
|
+
local cols = math.max(1, s.width // tw)
|
|
2541
|
+
local rows = math.max(1, s.height // th)
|
|
2542
|
+
local placed, skipped = 0, 0
|
|
2543
|
+
|
|
2544
|
+
transact("AI: stamp tiles", function()
|
|
2545
|
+
local cel = layer:cel(frame)
|
|
2546
|
+
local map
|
|
2547
|
+
if cel then
|
|
2548
|
+
map = cel.image:clone()
|
|
2549
|
+
else
|
|
2550
|
+
-- A tilemap cel must be created with a TILEMAP-mode image; the generic
|
|
2551
|
+
-- cel helper would hand back an RGB one and every stamp would be lost.
|
|
2552
|
+
map = Image(ImageSpec{ width = cols, height = rows, colorMode = ColorMode.TILEMAP })
|
|
2553
|
+
end
|
|
2554
|
+
|
|
2555
|
+
for _, t in ipairs(tiles) do
|
|
2556
|
+
if t.x >= 0 and t.y >= 0 and t.x < map.width and t.y < map.height
|
|
2557
|
+
and t.tile >= 0 and t.tile < #tileset then
|
|
2558
|
+
map:drawPixel(t.x, t.y, app.pixelColor.tile(t.tile, 0))
|
|
2559
|
+
placed = placed + 1
|
|
2560
|
+
else
|
|
2561
|
+
skipped = skipped + 1
|
|
2562
|
+
end
|
|
2563
|
+
end
|
|
2564
|
+
|
|
2565
|
+
if cel then cel.image = map else s:newCel(layer, frame, map, Point(0, 0)) end
|
|
2566
|
+
end)
|
|
2567
|
+
|
|
2568
|
+
if skipped > 0 and placed == 0 then
|
|
2569
|
+
fault("invalid_args", string.format(
|
|
2570
|
+
"None of the %d tiles could be placed. The grid is %dx%d cells and the tileset has %d tiles (valid indices 0..%d).",
|
|
2571
|
+
#tiles, cols, rows, #tileset, #tileset - 1))
|
|
2572
|
+
end
|
|
2573
|
+
|
|
2574
|
+
return {
|
|
2575
|
+
sprite = sprite_display_name(s), op = op,
|
|
2576
|
+
layer = layer.name, tileCount = placed, skipped = skipped,
|
|
2577
|
+
columns = cols, rows = rows,
|
|
2578
|
+
}
|
|
2579
|
+
end
|
|
2580
|
+
|
|
2581
|
+
if op == "get" then
|
|
2582
|
+
local packed, cols, rows, tw, th = pack_tiles(tileset, s.colorMode, false)
|
|
2583
|
+
local files = {}
|
|
2584
|
+
if args.path then
|
|
2585
|
+
write_image(packed, s.colorMode, s.palettes[1], args.path, pick_scale(packed.width, packed.height, nil))
|
|
2586
|
+
files[#files + 1] = args.path
|
|
2587
|
+
end
|
|
2588
|
+
return {
|
|
2589
|
+
sprite = sprite_display_name(s), op = op,
|
|
2590
|
+
layer = layer.name, tileCount = #tileset,
|
|
2591
|
+
tileWidth = tw, tileHeight = th,
|
|
2592
|
+
columns = cols, rows = rows, files = files,
|
|
2593
|
+
}
|
|
2594
|
+
end
|
|
2595
|
+
|
|
2596
|
+
------------------------------------------------------------------------------
|
|
2597
|
+
-- export: packed PNG plus the file the target engine reads
|
|
2598
|
+
------------------------------------------------------------------------------
|
|
2599
|
+
if op == "export" then
|
|
2600
|
+
local path = need(args.path, "path")
|
|
2601
|
+
local format = args.format or "tiled"
|
|
2602
|
+
local layout = args.layout or "grid"
|
|
2603
|
+
|
|
2604
|
+
local packed, cols, rows, tw, th, count = pack_tiles(tileset, s.colorMode, true)
|
|
2605
|
+
if count == 0 then
|
|
2606
|
+
fault("invalid_args", "Tileset '" .. layer.name .. "' has no tiles beyond the empty one; there is nothing to export.")
|
|
2607
|
+
end
|
|
2608
|
+
local base = path:gsub("%.[%w]+$", "")
|
|
2609
|
+
local png_path = base .. ".png"
|
|
2610
|
+
write_image(packed, s.colorMode, s.palettes[1], png_path, 1)
|
|
2611
|
+
|
|
2612
|
+
local name = app.fs.fileTitle(path)
|
|
2613
|
+
local png_name = app.fs.fileName(png_path)
|
|
2614
|
+
local files = { png_path }
|
|
2615
|
+
|
|
2616
|
+
local wangset = nil
|
|
2617
|
+
if layout == "blob47" then
|
|
2618
|
+
local masks = blob47_masks()
|
|
2619
|
+
if #masks ~= 47 then
|
|
2620
|
+
fault("aseprite_error", "blob47 reduction produced " .. #masks .. " masks, not 47; refusing to write a wangset that would autotile wrongly.")
|
|
2621
|
+
end
|
|
2622
|
+
-- Tile 0 is Aseprite's reserved empty tile, so the 47 blob tiles are
|
|
2623
|
+
-- expected at indices 1..47.
|
|
2624
|
+
if #tileset < 48 then
|
|
2625
|
+
fault("invalid_args", string.format(
|
|
2626
|
+
"layout 'blob47' needs 47 tiles after the empty tile (48 total); this tileset has %d. " ..
|
|
2627
|
+
"Either author the full blob47 set in canonical order, or export with layout 'grid'.", #tileset))
|
|
2628
|
+
end
|
|
2629
|
+
local wangtiles = {}
|
|
2630
|
+
for i, mask in ipairs(masks) do
|
|
2631
|
+
-- Two explicit colours rather than 0-as-wildcard: a half-specified
|
|
2632
|
+
-- wangid makes Tiled pick tiles that only sometimes fit.
|
|
2633
|
+
local function bit(b) return (mask & b) ~= 0 and 1 or 2 end
|
|
2634
|
+
wangtiles[i] = {
|
|
2635
|
+
-- Tiled wangtile ids are atlas slots. Aseprite blob tiles live at
|
|
2636
|
+
-- indices 1..47 and the empty tile is dropped from the atlas, so
|
|
2637
|
+
-- slot = index - 1 = i - 1.
|
|
2638
|
+
tileid = i - 1,
|
|
2639
|
+
wangid = { bit(N), bit(NE), bit(E), bit(SE), bit(S), bit(SW), bit(W), bit(NW) },
|
|
2640
|
+
}
|
|
2641
|
+
end
|
|
2642
|
+
wangset = {
|
|
2643
|
+
name = "blob47",
|
|
2644
|
+
type = "mixed",
|
|
2645
|
+
tile = -1,
|
|
2646
|
+
colors = {
|
|
2647
|
+
{ color = "#ff0000", name = "terrain", probability = 1, tile = -1 },
|
|
2648
|
+
{ color = "#00ff00", name = "empty", probability = 1, tile = -1 },
|
|
2649
|
+
},
|
|
2650
|
+
wangtiles = wangtiles,
|
|
2651
|
+
}
|
|
2652
|
+
end
|
|
2653
|
+
|
|
2654
|
+
local function write_text(target, text)
|
|
2655
|
+
local handle = io.open(target, "w")
|
|
2656
|
+
if not handle then fault("aseprite_error", "Could not write '" .. target .. "'.") end
|
|
2657
|
+
handle:write(text)
|
|
2658
|
+
handle:close()
|
|
2659
|
+
files[#files + 1] = target
|
|
2660
|
+
end
|
|
2661
|
+
|
|
2662
|
+
if format == "tiled" then
|
|
2663
|
+
local doc = {
|
|
2664
|
+
columns = cols,
|
|
2665
|
+
image = png_name,
|
|
2666
|
+
imagewidth = packed.width,
|
|
2667
|
+
imageheight = packed.height,
|
|
2668
|
+
margin = 0,
|
|
2669
|
+
spacing = 0,
|
|
2670
|
+
name = name,
|
|
2671
|
+
tilecount = count,
|
|
2672
|
+
tilewidth = tw,
|
|
2673
|
+
tileheight = th,
|
|
2674
|
+
type = "tileset",
|
|
2675
|
+
version = "1.10",
|
|
2676
|
+
tiledversion = "1.11.0",
|
|
2677
|
+
}
|
|
2678
|
+
if wangset then doc.wangsets = { wangset } end
|
|
2679
|
+
write_text(base .. ".tsj", encode_json(doc))
|
|
2680
|
+
|
|
2681
|
+
-- The tileset alone is not loadable as a level; emit the map that uses it
|
|
2682
|
+
-- so the export actually round-trips into Tiled.
|
|
2683
|
+
local cel = layer:cel(frame)
|
|
2684
|
+
if cel then
|
|
2685
|
+
local data = {}
|
|
2686
|
+
local map = cel.image
|
|
2687
|
+
for y = 0, map.height - 1 do
|
|
2688
|
+
for x = 0, map.width - 1 do
|
|
2689
|
+
-- gid = firstgid + atlas slot = 1 + (asepriteIndex - 1) = the
|
|
2690
|
+
-- Aseprite index itself, and index 0 stays 0, which Tiled reads as
|
|
2691
|
+
-- an empty cell.
|
|
2692
|
+
data[#data + 1] = app.pixelColor.tileI(map:getPixel(x, y))
|
|
2693
|
+
end
|
|
2694
|
+
end
|
|
2695
|
+
write_text(base .. ".tmj", encode_json({
|
|
2696
|
+
type = "map",
|
|
2697
|
+
version = "1.10",
|
|
2698
|
+
tiledversion = "1.11.0",
|
|
2699
|
+
orientation = "orthogonal",
|
|
2700
|
+
renderorder = "right-down",
|
|
2701
|
+
infinite = false,
|
|
2702
|
+
width = map.width,
|
|
2703
|
+
height = map.height,
|
|
2704
|
+
tilewidth = tw,
|
|
2705
|
+
tileheight = th,
|
|
2706
|
+
nextlayerid = 2,
|
|
2707
|
+
nextobjectid = 1,
|
|
2708
|
+
tilesets = { { firstgid = 1, source = app.fs.fileName(base .. ".tsj") } },
|
|
2709
|
+
layers = { {
|
|
2710
|
+
id = 1, name = layer.name, type = "tilelayer", visible = true, opacity = 1,
|
|
2711
|
+
x = 0, y = 0, width = map.width, height = map.height, data = data,
|
|
2712
|
+
} },
|
|
2713
|
+
}))
|
|
2714
|
+
end
|
|
2715
|
+
|
|
2716
|
+
elseif format == "godot" then
|
|
2717
|
+
-- Godot 4 TileSet with a single atlas source.
|
|
2718
|
+
local out = {
|
|
2719
|
+
'[gd_resource type="TileSet" load_steps=3 format=3]',
|
|
2720
|
+
'',
|
|
2721
|
+
string.format('[ext_resource type="Texture2D" path="res://%s" id="1_atlas"]', png_name),
|
|
2722
|
+
'',
|
|
2723
|
+
'[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_1"]',
|
|
2724
|
+
'texture = ExtResource("1_atlas")',
|
|
2725
|
+
string.format('texture_region_size = Vector2i(%d, %d)', tw, th),
|
|
2726
|
+
}
|
|
2727
|
+
for slot = 0, count - 1 do
|
|
2728
|
+
out[#out + 1] = string.format('%d:%d/0 = 0', slot % cols, slot // cols)
|
|
2729
|
+
end
|
|
2730
|
+
out[#out + 1] = ''
|
|
2731
|
+
out[#out + 1] = '[resource]'
|
|
2732
|
+
out[#out + 1] = string.format('tile_size = Vector2i(%d, %d)', tw, th)
|
|
2733
|
+
out[#out + 1] = 'sources/0 = SubResource("TileSetAtlasSource_1")'
|
|
2734
|
+
out[#out + 1] = ''
|
|
2735
|
+
write_text(base .. ".tres", table.concat(out, "\n"))
|
|
2736
|
+
|
|
2737
|
+
elseif format == "json" then
|
|
2738
|
+
local doc = {
|
|
2739
|
+
name = name,
|
|
2740
|
+
image = png_name,
|
|
2741
|
+
imageWidth = packed.width,
|
|
2742
|
+
imageHeight = packed.height,
|
|
2743
|
+
tileWidth = tw,
|
|
2744
|
+
tileHeight = th,
|
|
2745
|
+
columns = cols,
|
|
2746
|
+
rows = rows,
|
|
2747
|
+
tileCount = count,
|
|
2748
|
+
indexing = "map.tiles holds Aseprite tile indices: 0 means empty, and index N is atlas slot N-1 in the packed image (row-major, `columns` per row).",
|
|
2749
|
+
}
|
|
2750
|
+
if wangset then
|
|
2751
|
+
local masks = blob47_masks()
|
|
2752
|
+
local mapping = {}
|
|
2753
|
+
for i, mask in ipairs(masks) do mapping[i] = { tile = i, mask = mask } end
|
|
2754
|
+
doc.blob47 = mapping
|
|
2755
|
+
end
|
|
2756
|
+
local cel = layer:cel(frame)
|
|
2757
|
+
if cel then
|
|
2758
|
+
local map = cel.image
|
|
2759
|
+
local grid = {}
|
|
2760
|
+
for y = 0, map.height - 1 do
|
|
2761
|
+
for x = 0, map.width - 1 do
|
|
2762
|
+
grid[#grid + 1] = app.pixelColor.tileI(map:getPixel(x, y))
|
|
2763
|
+
end
|
|
2764
|
+
end
|
|
2765
|
+
doc.map = { width = map.width, height = map.height, tiles = grid }
|
|
2766
|
+
end
|
|
2767
|
+
write_text(base .. ".json", encode_json(doc))
|
|
2768
|
+
|
|
2769
|
+
else
|
|
2770
|
+
fault("invalid_args", "Unknown export format '" .. tostring(format) ..
|
|
2771
|
+
"'. Use 'tiled', 'godot' or 'json'.")
|
|
2772
|
+
end
|
|
2773
|
+
|
|
2774
|
+
return {
|
|
2775
|
+
sprite = sprite_display_name(s), op = op,
|
|
2776
|
+
layer = layer.name, format = format, layout = layout,
|
|
2777
|
+
tileCount = count, tileWidth = tw, tileHeight = th,
|
|
2778
|
+
columns = cols, rows = rows, files = files,
|
|
2779
|
+
}
|
|
2780
|
+
end
|
|
2781
|
+
|
|
2782
|
+
fault("unsupported_command", "tileset op '" .. tostring(op) .. "' is not supported.")
|
|
2783
|
+
end
|
|
2784
|
+
|
|
2785
|
+
--------------------------------------------------------------------------------
|
|
2786
|
+
-- Validation — a lint pass with located findings.
|
|
2787
|
+
--------------------------------------------------------------------------------
|
|
2788
|
+
|
|
2789
|
+
local function add_finding(list, check, severity, message, extra)
|
|
2790
|
+
local f = { check = check, severity = severity, message = message,
|
|
2791
|
+
layer = nil, frame = nil, at = nil, count = nil }
|
|
2792
|
+
for k, v in pairs(extra or {}) do f[k] = v end
|
|
2793
|
+
list[#list + 1] = f
|
|
2794
|
+
end
|
|
2795
|
+
|
|
2796
|
+
H["validate.run"] = function(args)
|
|
2797
|
+
local s = find_sprite(args.sprite)
|
|
2798
|
+
local wanted = {}
|
|
2799
|
+
for _, c in ipairs(args.checks or
|
|
2800
|
+
{ "palette", "strays", "outline", "banding", "layers", "animation", "export_readiness" }) do
|
|
2801
|
+
wanted[c] = true
|
|
2802
|
+
end
|
|
2803
|
+
|
|
2804
|
+
local findings = {}
|
|
2805
|
+
|
|
2806
|
+
if wanted.palette then
|
|
2807
|
+
local stats = H["palette.stats"]({ sprite = args.sprite })
|
|
2808
|
+
if #stats.offPalette > 0 then
|
|
2809
|
+
local worst = stats.offPalette[1]
|
|
2810
|
+
add_finding(findings, "palette", "warning",
|
|
2811
|
+
#stats.offPalette .. " colour(s) in the art are not in the palette; the most used is " ..
|
|
2812
|
+
worst.hex .. " (" .. worst.pixels .. "px). Run recolor op 'snap' to bring them in line.",
|
|
2813
|
+
{ count = #stats.offPalette })
|
|
2814
|
+
end
|
|
2815
|
+
if #stats.colors > 64 then
|
|
2816
|
+
add_finding(findings, "palette", "note",
|
|
2817
|
+
#stats.colors .. " palette entries. Most sprites read better under 32 — a large palette usually means ramps that were never merged.",
|
|
2818
|
+
{ count = #stats.colors })
|
|
2819
|
+
end
|
|
2820
|
+
end
|
|
2821
|
+
|
|
2822
|
+
for _, layer in ipairs(s.layers) do
|
|
2823
|
+
if not layer.isGroup then
|
|
2824
|
+
if wanted.layers and #layer.cels == 0 then
|
|
2825
|
+
add_finding(findings, "layers", "note",
|
|
2826
|
+
"Layer '" .. layer.name .. "' has no cels on any frame.", { layer = layer.name })
|
|
2827
|
+
end
|
|
2828
|
+
|
|
2829
|
+
for _, cel in ipairs(layer.cels) do
|
|
2830
|
+
local img = cel.image
|
|
2831
|
+
|
|
2832
|
+
if wanted.strays then
|
|
2833
|
+
-- A lone opaque pixel with no opaque neighbour is nearly always a
|
|
2834
|
+
-- misplaced click or an off-by-one in generated coordinates.
|
|
2835
|
+
local strays = 0
|
|
2836
|
+
local first = nil
|
|
2837
|
+
for y = 0, img.height - 1 do
|
|
2838
|
+
for x = 0, img.width - 1 do
|
|
2839
|
+
if pixel_to_hex(s, img:getPixel(x, y)) then
|
|
2840
|
+
local neighbours = 0
|
|
2841
|
+
for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do
|
|
2842
|
+
local nx, ny = x + d[1], y + d[2]
|
|
2843
|
+
if nx >= 0 and ny >= 0 and nx < img.width and ny < img.height
|
|
2844
|
+
and pixel_to_hex(s, img:getPixel(nx, ny)) then neighbours = neighbours + 1 end
|
|
2845
|
+
end
|
|
2846
|
+
if neighbours == 0 then
|
|
2847
|
+
strays = strays + 1
|
|
2848
|
+
if not first then first = { x = x + cel.position.x, y = y + cel.position.y } end
|
|
2849
|
+
end
|
|
2850
|
+
end
|
|
2851
|
+
end
|
|
2852
|
+
end
|
|
2853
|
+
if strays > 0 then
|
|
2854
|
+
add_finding(findings, "strays", strays > 3 and "error" or "warning",
|
|
2855
|
+
strays .. " isolated pixel(s) with no neighbour on '" .. layer.name ..
|
|
2856
|
+
"' frame " .. cel.frameNumber .. ". These read as noise at 1x.",
|
|
2857
|
+
{ layer = layer.name, frame = cel.frameNumber, at = first, count = strays })
|
|
2858
|
+
end
|
|
2859
|
+
end
|
|
2860
|
+
|
|
2861
|
+
if wanted.antialiasing and s.colorMode ~= ColorMode.INDEXED then
|
|
2862
|
+
local semi = 0
|
|
2863
|
+
for y = 0, img.height - 1 do
|
|
2864
|
+
for x = 0, img.width - 1 do
|
|
2865
|
+
local a = app.pixelColor.rgbaA(img:getPixel(x, y))
|
|
2866
|
+
if a > 0 and a < 255 then semi = semi + 1 end
|
|
2867
|
+
end
|
|
2868
|
+
end
|
|
2869
|
+
if semi > 0 then
|
|
2870
|
+
add_finding(findings, "antialiasing", "warning",
|
|
2871
|
+
semi .. " semi-transparent pixel(s) on '" .. layer.name ..
|
|
2872
|
+
"'. Partial alpha usually comes from a soft brush or a resize and reads as blur.",
|
|
2873
|
+
{ layer = layer.name, frame = cel.frameNumber, count = semi })
|
|
2874
|
+
end
|
|
2875
|
+
end
|
|
2876
|
+
end
|
|
2877
|
+
end
|
|
2878
|
+
end
|
|
2879
|
+
|
|
2880
|
+
if wanted.outline or wanted.banding then
|
|
2881
|
+
for _, layer in ipairs(s.layers) do
|
|
2882
|
+
if not layer.isGroup and layer.isVisible then
|
|
2883
|
+
for _, cel in ipairs(layer.cels) do
|
|
2884
|
+
local img = cel.image
|
|
2885
|
+
|
|
2886
|
+
if wanted.outline then
|
|
2887
|
+
-- Silhouette edge = an opaque pixel touching transparency. If one
|
|
2888
|
+
-- colour owns most of that edge the sprite is outlined, and the
|
|
2889
|
+
-- stragglers are gaps in it; if none does, the sprite simply is not
|
|
2890
|
+
-- outlined, which is a legitimate style and only worth a note.
|
|
2891
|
+
local edge, counts, total = {}, {}, 0
|
|
2892
|
+
for y = 0, img.height - 1 do
|
|
2893
|
+
for x = 0, img.width - 1 do
|
|
2894
|
+
local hex = pixel_to_hex(s, img:getPixel(x, y))
|
|
2895
|
+
if hex then
|
|
2896
|
+
local exposed = false
|
|
2897
|
+
for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do
|
|
2898
|
+
local nx, ny = x + d[1], y + d[2]
|
|
2899
|
+
if nx < 0 or ny < 0 or nx >= img.width or ny >= img.height
|
|
2900
|
+
or pixel_to_hex(s, img:getPixel(nx, ny)) == nil then exposed = true end
|
|
2901
|
+
end
|
|
2902
|
+
if exposed then
|
|
2903
|
+
total = total + 1
|
|
2904
|
+
counts[hex] = (counts[hex] or 0) + 1
|
|
2905
|
+
edge[#edge + 1] = { x = x + cel.position.x, y = y + cel.position.y, hex = hex }
|
|
2906
|
+
end
|
|
2907
|
+
end
|
|
2908
|
+
end
|
|
2909
|
+
end
|
|
2910
|
+
|
|
2911
|
+
if total > 0 then
|
|
2912
|
+
local best, best_n = nil, 0
|
|
2913
|
+
for hex, n in pairs(counts) do
|
|
2914
|
+
if n > best_n then best, best_n = hex, n end
|
|
2915
|
+
end
|
|
2916
|
+
local share = best_n / total
|
|
2917
|
+
if share >= 0.6 and share < 1.0 then
|
|
2918
|
+
local first = nil
|
|
2919
|
+
for _, e in ipairs(edge) do
|
|
2920
|
+
if e.hex ~= best and not first then first = { x = e.x, y = e.y } end
|
|
2921
|
+
end
|
|
2922
|
+
add_finding(findings, "outline", "warning",
|
|
2923
|
+
string.format(
|
|
2924
|
+
"Outline is %d%% '%s' but %d edge pixel(s) use a different colour on '%s'. " ..
|
|
2925
|
+
"A broken outline reads as a hole in the silhouette.",
|
|
2926
|
+
math.floor(share * 100 + 0.5), best, total - best_n, layer.name),
|
|
2927
|
+
{ layer = layer.name, frame = cel.frameNumber, at = first, count = total - best_n })
|
|
2928
|
+
elseif share < 0.6 then
|
|
2929
|
+
add_finding(findings, "outline", "note",
|
|
2930
|
+
"No single colour owns the silhouette edge on '" .. layer.name ..
|
|
2931
|
+
"', so this sprite is not outlined. That is a valid style — just keep it consistent.",
|
|
2932
|
+
{ layer = layer.name, frame = cel.frameNumber })
|
|
2933
|
+
end
|
|
2934
|
+
end
|
|
2935
|
+
end
|
|
2936
|
+
|
|
2937
|
+
if wanted.banding then
|
|
2938
|
+
-- Banding is a long straight boundary between two colours: the eye
|
|
2939
|
+
-- reads it as a contour line instead of a curved surface.
|
|
2940
|
+
local threshold = math.max(8, math.floor(img.width / 3))
|
|
2941
|
+
local worst, worst_at, worst_pair = 0, nil, nil
|
|
2942
|
+
for y = 0, img.height - 2 do
|
|
2943
|
+
local run, above, below, run_x = 0, nil, nil, 0
|
|
2944
|
+
for x = 0, img.width - 1 do
|
|
2945
|
+
local a = pixel_to_hex(s, img:getPixel(x, y))
|
|
2946
|
+
local b = pixel_to_hex(s, img:getPixel(x, y + 1))
|
|
2947
|
+
if a and b and a ~= b and a == above and b == below then
|
|
2948
|
+
run = run + 1
|
|
2949
|
+
else
|
|
2950
|
+
above, below, run, run_x = a, b, (a and b and a ~= b) and 1 or 0, x
|
|
2951
|
+
end
|
|
2952
|
+
if run > worst then
|
|
2953
|
+
worst = run
|
|
2954
|
+
worst_at = { x = run_x + cel.position.x, y = y + cel.position.y }
|
|
2955
|
+
worst_pair = { above, below }
|
|
2956
|
+
end
|
|
2957
|
+
end
|
|
2958
|
+
end
|
|
2959
|
+
if worst >= threshold and worst_pair then
|
|
2960
|
+
add_finding(findings, "banding", "note",
|
|
2961
|
+
string.format(
|
|
2962
|
+
"A %d-pixel straight boundary between %s and %s on '%s'. " ..
|
|
2963
|
+
"Let the edge wander, or dither part of it, so it reads as a surface rather than a contour line.",
|
|
2964
|
+
worst, tostring(worst_pair[1]), tostring(worst_pair[2]), layer.name),
|
|
2965
|
+
{ layer = layer.name, frame = cel.frameNumber, at = worst_at, count = worst })
|
|
2966
|
+
end
|
|
2967
|
+
end
|
|
2968
|
+
end
|
|
2969
|
+
end
|
|
2970
|
+
end
|
|
2971
|
+
end
|
|
2972
|
+
|
|
2973
|
+
if wanted.animation and #s.frames > 1 then
|
|
2974
|
+
if #s.tags == 0 then
|
|
2975
|
+
add_finding(findings, "animation", "error",
|
|
2976
|
+
#s.frames .. " frames and no animation tags. An engine cannot address untagged frames — add a tag per cycle.",
|
|
2977
|
+
{ count = #s.frames })
|
|
2978
|
+
end
|
|
2979
|
+
local durations = {}
|
|
2980
|
+
for _, f in ipairs(s.frames) do durations[math.floor(f.duration * 1000 + 0.5)] = true end
|
|
2981
|
+
local distinct = 0
|
|
2982
|
+
for _ in pairs(durations) do distinct = distinct + 1 end
|
|
2983
|
+
if distinct == 1 and #s.frames > 3 then
|
|
2984
|
+
add_finding(findings, "animation", "note",
|
|
2985
|
+
"Every frame has the same duration. Uniform timing reads mechanical; hold contact poses longer than pass poses.")
|
|
2986
|
+
end
|
|
2987
|
+
end
|
|
2988
|
+
|
|
2989
|
+
if wanted.export_readiness then
|
|
2990
|
+
if (s.filename or "") == "" then
|
|
2991
|
+
add_finding(findings, "export_readiness", "warning",
|
|
2992
|
+
"Sprite has never been saved, so there is nothing on disk to hand to a game project.")
|
|
2993
|
+
end
|
|
2994
|
+
end
|
|
2995
|
+
|
|
2996
|
+
return { sprite = sprite_display_name(s), findings = findings }
|
|
2997
|
+
end
|
|
2998
|
+
|
|
2999
|
+
--------------------------------------------------------------------------------
|
|
3000
|
+
-- Escape hatch. Gated on the server side; reaching here means the operator
|
|
3001
|
+
-- turned it on deliberately.
|
|
3002
|
+
--------------------------------------------------------------------------------
|
|
3003
|
+
|
|
3004
|
+
H["lua.run"] = function(args)
|
|
3005
|
+
local source = need(args.script, "script")
|
|
3006
|
+
local started = os.clock()
|
|
3007
|
+
local captured = {}
|
|
3008
|
+
|
|
3009
|
+
local chunk, compile_err = load("local sprite = ...\n" .. source, "ai-artist:lua.run", "t")
|
|
3010
|
+
if not chunk then fault("invalid_args", "Lua compile error: " .. tostring(compile_err)) end
|
|
3011
|
+
|
|
3012
|
+
local sprite = nil
|
|
3013
|
+
pcall(function() sprite = find_sprite(args.sprite) end)
|
|
3014
|
+
|
|
3015
|
+
local real_print = print
|
|
3016
|
+
print = function(...)
|
|
3017
|
+
local parts = {}
|
|
3018
|
+
for i = 1, select("#", ...) do parts[#parts + 1] = tostring(select(i, ...)) end
|
|
3019
|
+
captured[#captured + 1] = table.concat(parts, "\t")
|
|
3020
|
+
end
|
|
3021
|
+
|
|
3022
|
+
local ok, result
|
|
3023
|
+
local function body() ok, result = pcall(chunk, sprite) end
|
|
3024
|
+
|
|
3025
|
+
-- transact() can throw, and an un-restored `print` stays hijacked for the
|
|
3026
|
+
-- rest of the Aseprite session — silently swallowing the output of every
|
|
3027
|
+
-- other script the user runs. Restore it whatever happens.
|
|
3028
|
+
local ran, ran_err = pcall(function()
|
|
3029
|
+
if args.label then transact("AI: " .. args.label, body) else body() end
|
|
3030
|
+
end)
|
|
3031
|
+
print = real_print
|
|
3032
|
+
if not ran then fault("aseprite_error", tostring(ran_err)) end
|
|
3033
|
+
|
|
3034
|
+
if not ok then fault("aseprite_error", tostring(result)) end
|
|
3035
|
+
return {
|
|
3036
|
+
ok = true,
|
|
3037
|
+
result = result,
|
|
3038
|
+
stdout = table.concat(captured, "\n"),
|
|
3039
|
+
durationMs = math.floor((os.clock() - started) * 1000),
|
|
3040
|
+
}
|
|
3041
|
+
end
|
|
3042
|
+
|
|
3043
|
+
|
|
3044
|
+
--------------------------------------------------------------------------------
|
|
3045
|
+
-- Dispatch and transport
|
|
3046
|
+
--------------------------------------------------------------------------------
|
|
3047
|
+
|
|
3048
|
+
-- Aseprite's json.decode returns *userdata* with a metatable, not a plain Lua
|
|
3049
|
+
-- table: `type(decoded) == "userdata"`. Handlers index it fine, but any code
|
|
3050
|
+
-- that checks types, takes a length, or stores it alongside real tables breaks
|
|
3051
|
+
-- in ways that look like the command was never received. Normalising once, at
|
|
3052
|
+
-- the boundary, keeps every handler working in plain Lua.
|
|
3053
|
+
local function to_plain(value)
|
|
3054
|
+
local kind = type(value)
|
|
3055
|
+
if kind ~= "table" and kind ~= "userdata" then return value end
|
|
3056
|
+
|
|
3057
|
+
local out = {}
|
|
3058
|
+
|
|
3059
|
+
-- The two shapes behave oppositely, and getting either wrong fails silently:
|
|
3060
|
+
-- array → `#` is the element count, `[1]` is set, pairs yields NOTHING
|
|
3061
|
+
-- object → `#` is the *key* count too, `[1]` is nil, pairs yields the keys
|
|
3062
|
+
-- So length alone cannot tell them apart — an object would be read as an
|
|
3063
|
+
-- array of nils. `[1]` is the discriminator. Read an array with pairs and a
|
|
3064
|
+
-- batch of 40 draw ops arrives as zero ops, and the tool reports success
|
|
3065
|
+
-- having drawn nothing at all.
|
|
3066
|
+
local length = 0
|
|
3067
|
+
pcall(function() length = #value end)
|
|
3068
|
+
if length > 0 and value[1] ~= nil then
|
|
3069
|
+
for i = 1, length do out[i] = to_plain(value[i]) end
|
|
3070
|
+
return out
|
|
3071
|
+
end
|
|
3072
|
+
|
|
3073
|
+
local ok = pcall(function()
|
|
3074
|
+
for k, v in pairs(value) do out[k] = to_plain(v) end
|
|
3075
|
+
end)
|
|
3076
|
+
if not ok then return value end
|
|
3077
|
+
return out
|
|
3078
|
+
end
|
|
3079
|
+
|
|
3080
|
+
local function handle_command(frame)
|
|
3081
|
+
local handler = H[frame.cmd]
|
|
3082
|
+
if not handler then
|
|
3083
|
+
return {
|
|
3084
|
+
id = frame.id, ok = false,
|
|
3085
|
+
error = {
|
|
3086
|
+
code = "unsupported_command",
|
|
3087
|
+
message = "This Aseprite extension does not implement '" .. tostring(frame.cmd) ..
|
|
3088
|
+
"'. Update it with `npx @pebbly/aseprite-ai-artist install-extension` and restart Aseprite.",
|
|
3089
|
+
details = { command = frame.cmd, extensionVersion = EXTENSION_VERSION },
|
|
3090
|
+
},
|
|
3091
|
+
}
|
|
3092
|
+
end
|
|
3093
|
+
|
|
3094
|
+
local ok, result = pcall(handler, frame.args or {})
|
|
3095
|
+
if ok then return { id = frame.id, ok = true, data = result } end
|
|
3096
|
+
|
|
3097
|
+
if type(result) == "table" and result.__fault then
|
|
3098
|
+
return { id = frame.id, ok = false,
|
|
3099
|
+
error = { code = result.code, message = result.message, details = result.details } }
|
|
3100
|
+
end
|
|
3101
|
+
|
|
3102
|
+
return { id = frame.id, ok = false,
|
|
3103
|
+
error = { code = "aseprite_error", message = tostring(result), details = {} } }
|
|
3104
|
+
end
|
|
3105
|
+
|
|
3106
|
+
local function send(payload)
|
|
3107
|
+
if not ws or not connected then return false end
|
|
3108
|
+
local ok, err = pcall(function() ws:sendText(encode_json(payload)) end)
|
|
3109
|
+
if not ok then
|
|
3110
|
+
log("send failed: " .. tostring(err))
|
|
3111
|
+
connected = false
|
|
3112
|
+
end
|
|
3113
|
+
return ok
|
|
3114
|
+
end
|
|
3115
|
+
|
|
3116
|
+
local function send_hello()
|
|
3117
|
+
send({
|
|
3118
|
+
type = "hello",
|
|
3119
|
+
protocol = PROTOCOL_VERSION,
|
|
3120
|
+
extensionVersion = EXTENSION_VERSION,
|
|
3121
|
+
asepriteVersion = tostring(app.version),
|
|
3122
|
+
features = FEATURES,
|
|
3123
|
+
})
|
|
3124
|
+
end
|
|
3125
|
+
|
|
3126
|
+
local handle_socket_event
|
|
3127
|
+
|
|
3128
|
+
--- Events arrive on a closure bound at connect() time, so a socket that has
|
|
3129
|
+
--- already been replaced can still deliver one. `generation` is the identity
|
|
3130
|
+
--- check: an event from an older generation is stale and must not touch state.
|
|
3131
|
+
local function on_message_for(generation)
|
|
3132
|
+
return function(messageType, data)
|
|
3133
|
+
if generation ~= ws_generation then
|
|
3134
|
+
log("ignoring event from superseded socket (gen " .. tostring(generation) .. ")")
|
|
3135
|
+
return
|
|
3136
|
+
end
|
|
3137
|
+
return handle_socket_event(messageType, data)
|
|
3138
|
+
end
|
|
3139
|
+
end
|
|
3140
|
+
|
|
3141
|
+
handle_socket_event = function(messageType, data)
|
|
3142
|
+
if messageType == WebSocketMessageType.OPEN then
|
|
3143
|
+
connected = true
|
|
3144
|
+
connecting = false
|
|
3145
|
+
connecting_ticks = 0
|
|
3146
|
+
log("connected")
|
|
3147
|
+
if status_writer then status_writer("connected") end
|
|
3148
|
+
send_hello()
|
|
3149
|
+
return
|
|
3150
|
+
end
|
|
3151
|
+
|
|
3152
|
+
if messageType == WebSocketMessageType.CLOSE then
|
|
3153
|
+
connected = false
|
|
3154
|
+
connecting = false
|
|
3155
|
+
log("closed")
|
|
3156
|
+
if status_writer then status_writer("disconnected") end
|
|
3157
|
+
return
|
|
3158
|
+
end
|
|
3159
|
+
|
|
3160
|
+
if messageType ~= WebSocketMessageType.TEXT then return end
|
|
3161
|
+
|
|
3162
|
+
local ok, decoded = pcall(function() return json.decode(data) end)
|
|
3163
|
+
if not ok then
|
|
3164
|
+
log("dropped unparseable frame")
|
|
3165
|
+
return
|
|
3166
|
+
end
|
|
3167
|
+
local frame = to_plain(decoded)
|
|
3168
|
+
if type(frame) ~= "table" or frame.id == nil then
|
|
3169
|
+
log("dropped frame with no id")
|
|
3170
|
+
return
|
|
3171
|
+
end
|
|
3172
|
+
|
|
3173
|
+
-- A handler that throws outside pcall would kill the whole extension and take
|
|
3174
|
+
-- the user's link with it, so the reply itself is also guarded.
|
|
3175
|
+
local replied, reply = pcall(handle_command, frame)
|
|
3176
|
+
if not replied then
|
|
3177
|
+
reply = { id = frame.id, ok = false,
|
|
3178
|
+
error = { code = "aseprite_error", message = tostring(reply), details = {} } }
|
|
3179
|
+
end
|
|
3180
|
+
send(reply)
|
|
3181
|
+
end
|
|
3182
|
+
|
|
3183
|
+
local function connect()
|
|
3184
|
+
if connected or connecting then return end
|
|
3185
|
+
connecting = true
|
|
3186
|
+
connecting_ticks = 0
|
|
3187
|
+
ws_generation = ws_generation + 1
|
|
3188
|
+
local generation = ws_generation
|
|
3189
|
+
|
|
3190
|
+
local ok, err = pcall(function()
|
|
3191
|
+
ws = WebSocket{
|
|
3192
|
+
url = "ws://" .. CONFIG.host .. ":" .. CONFIG.port,
|
|
3193
|
+
onreceive = on_message_for(generation),
|
|
3194
|
+
deflate = false,
|
|
3195
|
+
}
|
|
3196
|
+
ws:connect()
|
|
3197
|
+
end)
|
|
3198
|
+
|
|
3199
|
+
if not ok then
|
|
3200
|
+
connecting = false
|
|
3201
|
+
ws = nil
|
|
3202
|
+
log("connect failed: " .. tostring(err))
|
|
3203
|
+
end
|
|
3204
|
+
end
|
|
3205
|
+
|
|
3206
|
+
-- Aseprite drives Lua from the UI thread, so reconnect bookkeeping rides a
|
|
3207
|
+
-- Timer. A running connection keeps working while the window is unfocused;
|
|
3208
|
+
-- re-establishing a dropped one may wait for the next focus.
|
|
3209
|
+
local function start_timer()
|
|
3210
|
+
reconnect_timer = Timer{
|
|
3211
|
+
interval = CONFIG.reconnect_tick,
|
|
3212
|
+
ontick = function()
|
|
3213
|
+
if connected then return end
|
|
3214
|
+
if connecting then
|
|
3215
|
+
connecting_ticks = connecting_ticks + 1
|
|
3216
|
+
if connecting_ticks >= CONFIG.connect_max_ticks then
|
|
3217
|
+
-- A handshake that never resolves leaves `connecting` stuck true and
|
|
3218
|
+
-- blocks every later attempt; force a reset instead.
|
|
3219
|
+
connecting = false
|
|
3220
|
+
if ws then pcall(function() ws:close() end) end
|
|
3221
|
+
ws = nil
|
|
3222
|
+
end
|
|
3223
|
+
return
|
|
3224
|
+
end
|
|
3225
|
+
connect()
|
|
3226
|
+
end,
|
|
3227
|
+
}
|
|
3228
|
+
reconnect_timer:start()
|
|
3229
|
+
end
|
|
3230
|
+
|
|
3231
|
+
--------------------------------------------------------------------------------
|
|
3232
|
+
-- Load marker
|
|
3233
|
+
--
|
|
3234
|
+
-- "Nothing happens" has two very different causes: Aseprite never ran this
|
|
3235
|
+
-- script (extension not installed or not enabled), or it ran and could not
|
|
3236
|
+
-- reach the bridge. From outside Aseprite those look identical, and users end
|
|
3237
|
+
-- up reinstalling things that were already fine. Writing a marker on load lets
|
|
3238
|
+
-- `doctor` tell them apart and name the actual fix.
|
|
3239
|
+
--------------------------------------------------------------------------------
|
|
3240
|
+
|
|
3241
|
+
local function write_status(state)
|
|
3242
|
+
local ok = pcall(function()
|
|
3243
|
+
local path = app.fs.joinPath(app.fs.userConfigPath, "aseprite-ai-artist.status")
|
|
3244
|
+
local handle = io.open(path, "w")
|
|
3245
|
+
if not handle then return end
|
|
3246
|
+
handle:write(encode_json({
|
|
3247
|
+
state = state,
|
|
3248
|
+
version = EXTENSION_VERSION,
|
|
3249
|
+
protocol = PROTOCOL_VERSION,
|
|
3250
|
+
aseprite = tostring(app.version),
|
|
3251
|
+
port = CONFIG.port,
|
|
3252
|
+
at = os.time(),
|
|
3253
|
+
}))
|
|
3254
|
+
handle:close()
|
|
3255
|
+
end)
|
|
3256
|
+
return ok
|
|
3257
|
+
end
|
|
3258
|
+
|
|
3259
|
+
status_writer = write_status
|
|
3260
|
+
|
|
3261
|
+
-- Batch mode (`aseprite -b`) has no UI loop, so a Timer never ticks and a socket
|
|
3262
|
+
-- would never resolve. Skipping the transport there is also what makes the
|
|
3263
|
+
-- command table testable headlessly: see tests/extension.test.lua.
|
|
3264
|
+
if app.isUIAvailable then
|
|
3265
|
+
write_status("loaded")
|
|
3266
|
+
connect()
|
|
3267
|
+
start_timer()
|
|
3268
|
+
log("aseprite-ai-artist " .. EXTENSION_VERSION .. " loaded, dialling :" .. CONFIG.port)
|
|
3269
|
+
end
|
|
3270
|
+
|
|
3271
|
+
-- Exposed for the headless test harness and for `lua.run` introspection.
|
|
3272
|
+
_G.AI_ARTIST = {
|
|
3273
|
+
version = EXTENSION_VERSION,
|
|
3274
|
+
protocol = PROTOCOL_VERSION,
|
|
3275
|
+
features = FEATURES,
|
|
3276
|
+
handlers = H,
|
|
3277
|
+
encodeJson = encode_json,
|
|
3278
|
+
handleCommand = handle_command,
|
|
3279
|
+
toPlain = to_plain,
|
|
3280
|
+
blob47Masks = blob47_masks,
|
|
3281
|
+
-- Exposed so tests/extension.test.lua can pin the SAME numbers as
|
|
3282
|
+
-- tests/color.test.ts. The two CIELAB ports must not drift apart: a palette
|
|
3283
|
+
-- snap that disagrees between the report and the pixels is worse than either
|
|
3284
|
+
-- being wrong alone.
|
|
3285
|
+
rgbToLab = rgb_to_lab,
|
|
3286
|
+
deltaE = delta_e,
|
|
3287
|
+
hexToColor = hex_to_color,
|
|
3288
|
+
colorToHex = color_to_hex,
|
|
3289
|
+
snapColorToPalette = snap_color_to_palette,
|
|
3290
|
+
shadeColor = shade_color,
|
|
3291
|
+
}
|