@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/LICENSE +21 -0
  3. package/README.md +169 -0
  4. package/agents/animation-director.md +65 -0
  5. package/agents/palette-smith.md +57 -0
  6. package/agents/pixel-critic.md +69 -0
  7. package/agents/rig-builder.md +46 -0
  8. package/bin/aseprite-ai-artist +6 -0
  9. package/bin/aseprite-ai-artist.mjs +44 -0
  10. package/dist/bridge/bridge.d.ts +52 -0
  11. package/dist/bridge/bridge.js +230 -0
  12. package/dist/bridge/bridge.js.map +1 -0
  13. package/dist/bridge/client.d.ts +54 -0
  14. package/dist/bridge/client.js +214 -0
  15. package/dist/bridge/client.js.map +1 -0
  16. package/dist/cli.d.ts +10 -0
  17. package/dist/cli.js +272 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/extension.d.ts +21 -0
  20. package/dist/extension.js +65 -0
  21. package/dist/extension.js.map +1 -0
  22. package/dist/install.d.ts +35 -0
  23. package/dist/install.js +185 -0
  24. package/dist/install.js.map +1 -0
  25. package/dist/lib/color.d.ts +66 -0
  26. package/dist/lib/color.js +170 -0
  27. package/dist/lib/color.js.map +1 -0
  28. package/dist/lib/protocol.d.ts +78 -0
  29. package/dist/lib/protocol.js +72 -0
  30. package/dist/lib/protocol.js.map +1 -0
  31. package/dist/lib/render.d.ts +62 -0
  32. package/dist/lib/render.js +134 -0
  33. package/dist/lib/render.js.map +1 -0
  34. package/dist/lib/skills.d.ts +43 -0
  35. package/dist/lib/skills.js +112 -0
  36. package/dist/lib/skills.js.map +1 -0
  37. package/dist/lib/version.d.ts +4 -0
  38. package/dist/lib/version.js +25 -0
  39. package/dist/lib/version.js.map +1 -0
  40. package/dist/server.d.ts +15 -0
  41. package/dist/server.js +144 -0
  42. package/dist/server.js.map +1 -0
  43. package/dist/tools/assets.d.ts +3 -0
  44. package/dist/tools/assets.js +181 -0
  45. package/dist/tools/assets.js.map +1 -0
  46. package/dist/tools/craft.d.ts +12 -0
  47. package/dist/tools/craft.js +143 -0
  48. package/dist/tools/craft.js.map +1 -0
  49. package/dist/tools/draw.d.ts +3 -0
  50. package/dist/tools/draw.js +256 -0
  51. package/dist/tools/draw.js.map +1 -0
  52. package/dist/tools/escape.d.ts +12 -0
  53. package/dist/tools/escape.js +49 -0
  54. package/dist/tools/escape.js.map +1 -0
  55. package/dist/tools/kit.d.ts +29 -0
  56. package/dist/tools/kit.js +85 -0
  57. package/dist/tools/kit.js.map +1 -0
  58. package/dist/tools/look.d.ts +7 -0
  59. package/dist/tools/look.js +215 -0
  60. package/dist/tools/look.js.map +1 -0
  61. package/dist/tools/palette.d.ts +3 -0
  62. package/dist/tools/palette.js +232 -0
  63. package/dist/tools/palette.js.map +1 -0
  64. package/dist/tools/session.d.ts +3 -0
  65. package/dist/tools/session.js +231 -0
  66. package/dist/tools/session.js.map +1 -0
  67. package/dist/tools/structure.d.ts +3 -0
  68. package/dist/tools/structure.js +218 -0
  69. package/dist/tools/structure.js.map +1 -0
  70. package/examples/mcp-configs/claude-code.json +9 -0
  71. package/examples/mcp-configs/codex.toml +13 -0
  72. package/examples/mcp-configs/cursor.json +9 -0
  73. package/examples/mcp-configs/gemini-cli.json +9 -0
  74. package/examples/mcp-configs/vscode.json +10 -0
  75. package/extension/ai-artist.lua +3291 -0
  76. package/extension/package.json +14 -0
  77. package/hooks/hooks.json +25 -0
  78. package/hooks/look-nudge.mjs +46 -0
  79. package/hooks/session-status.mjs +47 -0
  80. package/knowledge/palettes.json +60 -0
  81. package/package.json +69 -0
  82. package/rules/00-core-principles.md +57 -0
  83. package/rules/01-palette-and-color.md +51 -0
  84. package/rules/02-shading-and-light.md +56 -0
  85. package/rules/03-silhouette-and-form.md +60 -0
  86. package/rules/04-outlines-and-edges.md +46 -0
  87. package/rules/05-animation.md +62 -0
  88. package/rules/06-layers-and-rigging.md +52 -0
  89. package/rules/07-review-checklist.md +51 -0
  90. package/rules/README.md +16 -0
  91. package/skills/README.md +29 -0
  92. package/skills/pixel-animate/SKILL.md +91 -0
  93. package/skills/pixel-brief/SKILL.md +61 -0
  94. package/skills/pixel-draw/SKILL.md +93 -0
  95. package/skills/pixel-export/SKILL.md +80 -0
  96. package/skills/pixel-fix/SKILL.md +91 -0
  97. package/skills/pixel-new/SKILL.md +70 -0
  98. package/skills/pixel-palette/SKILL.md +80 -0
  99. package/skills/pixel-review/SKILL.md +81 -0
  100. package/skills/pixel-rig/SKILL.md +76 -0
  101. package/skills/pixel-shade/SKILL.md +77 -0
  102. package/skills/pixel-tileset/SKILL.md +104 -0
  103. 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
+ }