Skip to content

Commit 36ccfe0

Browse files
committed
feat(export): add advanced export and import tools
- Add export_spritesheet tool with 5 layout options (horizontal/vertical/rows/columns/packed) - Add optional JSON metadata export for spritesheets with frame data and animation tags - Add import_image tool to import external images as layers with optional positioning - Add save_as tool to save sprites to new .aseprite file paths - Add delete_tag tool to remove animation tags by name - Implement 4 Lua generators: ExportSpritesheet, ImportImage, SaveAs, DeleteTag - Add comprehensive unit tests (6 subtests) and integration tests (7 tests) - Update example client with Step 22 demonstrating all Phase 5 tools - Update README.md and CLAUDE.md documentation with new tools - All Phase 5 tests passing with real Aseprite
1 parent cc4d0d8 commit 36ccfe0

14 files changed

Lines changed: 1505 additions & 4 deletions

CLAUDE.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,13 @@ MCP Client → MCP Server (Go) → Lua Script Generation → Aseprite CLI (--bat
8787
- `canvas.go` - Sprite/layer/frame management (create_sprite, add_layer, add_frame, delete_layer with protection, delete_frame with protection)
8888
- `drawing.go` - Drawing primitives (pixels, lines, rectangles, circles, fill, contours for polylines/polygons)
8989
- `selection.go` - Selection and clipboard operations (8 tools)
90-
- `animation.go` - Animation and timeline operations
90+
- `animation.go` - Animation and timeline operations (frame duration, tags, tag deletion, duplication, linked cels)
9191
- `inspection.go` - Pixel data inspection and reading
9292
- `analysis.go` - Reference image analysis (palette extraction, edge detection, composition)
9393
- `dithering.go` - Dithering patterns for gradients and textures (15 patterns)
9494
- `palette_tools.go` - Palette management (set_palette, apply_shading, analyze_palette_harmonies)
9595
- `transform.go` - Transform operations (flip, rotate, scale, crop, resize canvas, outline, downsampling)
96-
- `export.go` - Export and import operations
96+
- `export.go` - Export and import operations (export_sprite, export_spritesheet, import_image, save_as)
9797
- `internal/testutil/` - Testing utilities (no mocks)
9898

9999
### Core Workflow
@@ -126,7 +126,7 @@ Core functionality implemented and tested:
126126
- Select all, deselect, move selection
127127
- Cut, copy, paste operations
128128
- **Important limitation**: Selections are transient and do NOT persist in .aseprite files - they only exist in memory during script execution
129-
- Animation tools (frame duration, tags, duplication, linked cels)
129+
- Animation tools (frame duration, tags, tag deletion, duplication, linked cels)
130130
- Inspection tools (pixel data reading with pagination for verification and analysis)
131131
- **Professional Pixel Art Tools:**
132132
- Reference image analysis (k-means palette extraction, brightness maps, Sobel edge detection)
@@ -150,7 +150,11 @@ Core functionality implemented and tested:
150150
- `crop_sprite`: Crop to rectangular region
151151
- `resize_canvas`: Resize canvas with anchor positioning (5 anchor points)
152152
- `apply_outline`: Apply outline effect with color and thickness
153-
- Sprite export (PNG, GIF, JPG, BMP)
153+
- **Export & Import Tools (4 tools):**
154+
- `export_sprite`: Export to PNG/GIF/JPG/BMP formats
155+
- `export_spritesheet`: Export animation frames as spritesheet with 5 layout options (horizontal/vertical/rows/columns/packed), configurable padding, and optional JSON metadata
156+
- `import_image`: Import external images as layers with optional positioning
157+
- `save_as`: Save sprite to new .aseprite file path
154158
- Metadata retrieval
155159
- Example client implementation (examples/client/main.go)
156160
- Integration test suite with real Aseprite

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ Then use natural language to create sprites:
148148
| `delete_frame` | Delete a frame from the sprite (cannot delete last frame) |
149149
| `set_frame_duration` | Set the duration of an animation frame in milliseconds |
150150
| `create_tag` | Create an animation tag with playback direction |
151+
| `delete_tag` | Delete an animation tag by name |
151152
| `duplicate_frame` | Duplicate an existing frame with all cels |
152153
| `link_cel` | Create a linked cel that shares image data |
153154

@@ -156,6 +157,9 @@ Then use natural language to create sprites:
156157
|------|-------------|
157158
| `get_pixels` | Read pixel data from a rectangular region (paginated, for verification) |
158159
| `export_sprite` | Export sprite to PNG/GIF/JPG/BMP |
160+
| `export_spritesheet` | Export animation frames as spritesheet (horizontal/vertical/rows/columns/packed layout, optional JSON metadata) |
161+
| `import_image` | Import external image file as a layer in the sprite |
162+
| `save_as` | Save sprite to a new .aseprite file path |
159163

160164
## Examples
161165

examples/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ The example creates:
6363
- `../sprites/antialiasing-before.png` - Jagged diagonal line (stair-step pattern)
6464
- `../sprites/antialiasing-after.png` - Smoothed diagonal with intermediate colors applied
6565
- `../sprites/transform-demo.png` - Transform operations: triangle flipped, rotated, and scaled 2x
66+
- `../sprites/animation-spritesheet.png` - 4-frame horizontal spritesheet with growing colored circles
67+
- `../sprites/animation-spritesheet.json` - JSON metadata for spritesheet
68+
- `../sprites/saved-animation.aseprite` - Sprite saved with save_as tool
69+
- `../sprites/imported-spritesheet.png` - Result of importing spritesheet as layer
6670
- `/tmp/selection-demo.png` - Drawing demo showing red squares and blue circle
6771

6872
## Example Output
@@ -105,6 +109,10 @@ Available tools:
105109
- crop_sprite: Crop sprite to rectangular region
106110
- resize_canvas: Resize canvas with anchor positioning
107111
- apply_outline: Apply outline effect to layer with thickness
112+
- export_spritesheet: Export animation frames as spritesheet with layout options
113+
- import_image: Import external image file as a layer
114+
- save_as: Save sprite to a new .aseprite file path
115+
- delete_tag: Delete an animation tag by name
108116
- select_rectangle: Create rectangular selection with mode (replace/add/subtract/intersect)
109117
- select_ellipse: Create elliptical selection with mode
110118
- select_all: Select entire canvas
@@ -232,6 +240,21 @@ Step 21: Demonstrating selection and clipboard operations...
232240
✓ Result saved to: /tmp/selection-demo.png
233241
Note: Selection tools work within single Lua scripts but don't persist across tool calls
234242
243+
Step 22: Demonstrating advanced export tools (spritesheet, import, save_as, delete_tag)...
244+
Creating 16x16 animated sprite with 4 frames...
245+
Creating animation tags...
246+
Exporting as horizontal spritesheet with JSON metadata...
247+
✓ Exported spritesheet: ../sprites/animation-spritesheet.png (4 frames)
248+
✓ JSON metadata: ../sprites/animation-spritesheet.json
249+
Deleting temporary tag 'temp_tag'...
250+
✓ Tag deleted successfully
251+
Saving sprite to new location...
252+
✓ Sprite saved to: ../sprites/saved-animation.aseprite
253+
Creating new sprite to import spritesheet...
254+
Importing spritesheet as a layer...
255+
✓ Spritesheet imported as layer
256+
✓ Exported imported result: ../sprites/imported-spritesheet.png
257+
235258
Example completed successfully!
236259
```
237260

examples/client/main.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,190 @@ func createAnimatedSprite(ctx context.Context, session *mcp.ClientSession, logge
11861186
logger.Information(" ✓ Result saved to: {OutputPath}", selectionOutputPath)
11871187
logger.Information(" Note: Selection tools work within single Lua scripts but don't persist across tool calls")
11881188

1189+
// Step 22: Demonstrate advanced export tools
1190+
logger.Information("")
1191+
logger.Information("Step 22: Demonstrating advanced export tools (spritesheet, import, save_as, delete_tag)...")
1192+
1193+
// Create an animated sprite for spritesheet export
1194+
logger.Information(" Creating 16x16 animated sprite with 4 frames...")
1195+
output, err = callTool(ctx, session, "create_canvas", map[string]any{
1196+
"width": 16,
1197+
"height": 16,
1198+
"color_mode": "rgb",
1199+
})
1200+
if err != nil {
1201+
return fmt.Errorf("create_canvas for spritesheet failed: %w", err)
1202+
}
1203+
1204+
var sheetCreateOutput struct {
1205+
FilePath string `json:"file_path"`
1206+
}
1207+
if err := json.Unmarshal([]byte(output), &sheetCreateOutput); err != nil {
1208+
return fmt.Errorf("failed to parse create_canvas output: %w", err)
1209+
}
1210+
sheetSpritePath := sheetCreateOutput.FilePath
1211+
defer os.Remove(sheetSpritePath)
1212+
1213+
// Add 3 more frames (total 4)
1214+
for i := 0; i < 3; i++ {
1215+
_, err = callTool(ctx, session, "add_frame", map[string]any{
1216+
"sprite_path": sheetSpritePath,
1217+
"duration_ms": 100,
1218+
})
1219+
if err != nil {
1220+
return fmt.Errorf("add_frame failed: %w", err)
1221+
}
1222+
}
1223+
1224+
// Draw different colored circles on each frame
1225+
colors2 := []string{"#FF0000", "#00FF00", "#0000FF", "#FFFF00"}
1226+
for i := 0; i < 4; i++ {
1227+
_, err = callTool(ctx, session, "draw_circle", map[string]any{
1228+
"sprite_path": sheetSpritePath,
1229+
"layer_name": "Layer 1",
1230+
"frame_number": i + 1,
1231+
"center_x": 8,
1232+
"center_y": 8,
1233+
"radius": 4 + i,
1234+
"color": colors2[i],
1235+
"filled": true,
1236+
})
1237+
if err != nil {
1238+
return fmt.Errorf("draw_circle frame %d failed: %w", i+1, err)
1239+
}
1240+
}
1241+
1242+
// Create animation tags
1243+
logger.Information(" Creating animation tags...")
1244+
_, err = callTool(ctx, session, "create_tag", map[string]any{
1245+
"sprite_path": sheetSpritePath,
1246+
"tag_name": "grow",
1247+
"from_frame": 1,
1248+
"to_frame": 4,
1249+
"direction": "forward",
1250+
})
1251+
if err != nil {
1252+
return fmt.Errorf("create_tag failed: %w", err)
1253+
}
1254+
1255+
_, err = callTool(ctx, session, "create_tag", map[string]any{
1256+
"sprite_path": sheetSpritePath,
1257+
"tag_name": "temp_tag",
1258+
"from_frame": 1,
1259+
"to_frame": 2,
1260+
"direction": "pingpong",
1261+
})
1262+
if err != nil {
1263+
return fmt.Errorf("create second tag failed: %w", err)
1264+
}
1265+
1266+
// Export as spritesheet with JSON metadata
1267+
sheetPath := filepath.Join(outputDir, "animation-spritesheet.png")
1268+
logger.Information(" Exporting as horizontal spritesheet with JSON metadata...")
1269+
sheetOutput, err := callTool(ctx, session, "export_spritesheet", map[string]any{
1270+
"sprite_path": sheetSpritePath,
1271+
"output_path": sheetPath,
1272+
"layout": "horizontal",
1273+
"padding": 2,
1274+
"include_json": true,
1275+
})
1276+
if err != nil {
1277+
return fmt.Errorf("export_spritesheet failed: %w", err)
1278+
}
1279+
1280+
var sheetResult struct {
1281+
SpritesheetPath string `json:"spritesheet_path"`
1282+
MetadataPath *string `json:"metadata_path"`
1283+
FrameCount int `json:"frame_count"`
1284+
}
1285+
if err := json.Unmarshal([]byte(sheetOutput), &sheetResult); err != nil {
1286+
return fmt.Errorf("failed to parse export_spritesheet output: %w", err)
1287+
}
1288+
logger.Information(" ✓ Exported spritesheet: {Path} ({Count} frames)", sheetResult.SpritesheetPath, sheetResult.FrameCount)
1289+
if sheetResult.MetadataPath != nil {
1290+
logger.Information(" ✓ JSON metadata: {Path}", *sheetResult.MetadataPath)
1291+
}
1292+
1293+
// Delete the temporary tag
1294+
logger.Information(" Deleting temporary tag 'temp_tag'...")
1295+
_, err = callTool(ctx, session, "delete_tag", map[string]any{
1296+
"sprite_path": sheetSpritePath,
1297+
"tag_name": "temp_tag",
1298+
})
1299+
if err != nil {
1300+
return fmt.Errorf("delete_tag failed: %w", err)
1301+
}
1302+
logger.Information(" ✓ Tag deleted successfully")
1303+
1304+
// Save the sprite to a new location
1305+
savedSpritePath := filepath.Join(outputDir, "saved-animation.aseprite")
1306+
logger.Information(" Saving sprite to new location...")
1307+
saveOutput, err := callTool(ctx, session, "save_as", map[string]any{
1308+
"sprite_path": sheetSpritePath,
1309+
"output_path": savedSpritePath,
1310+
})
1311+
if err != nil {
1312+
return fmt.Errorf("save_as failed: %w", err)
1313+
}
1314+
1315+
var saveResult struct {
1316+
Success bool `json:"success"`
1317+
FilePath string `json:"file_path"`
1318+
}
1319+
if err := json.Unmarshal([]byte(saveOutput), &saveResult); err != nil {
1320+
return fmt.Errorf("failed to parse save_as output: %w", err)
1321+
}
1322+
logger.Information(" ✓ Sprite saved to: {Path}", saveResult.FilePath)
1323+
1324+
// Import the spritesheet back into a new sprite
1325+
logger.Information(" Creating new sprite to import spritesheet...")
1326+
output, err = callTool(ctx, session, "create_canvas", map[string]any{
1327+
"width": 64,
1328+
"height": 32,
1329+
"color_mode": "rgb",
1330+
})
1331+
if err != nil {
1332+
return fmt.Errorf("create_canvas for import failed: %w", err)
1333+
}
1334+
1335+
var importCreateOutput struct {
1336+
FilePath string `json:"file_path"`
1337+
}
1338+
if err := json.Unmarshal([]byte(output), &importCreateOutput); err != nil {
1339+
return fmt.Errorf("failed to parse create_canvas output: %w", err)
1340+
}
1341+
importSpritePath := importCreateOutput.FilePath
1342+
defer os.Remove(importSpritePath)
1343+
1344+
logger.Information(" Importing spritesheet as a layer...")
1345+
_, err = callTool(ctx, session, "import_image", map[string]any{
1346+
"sprite_path": importSpritePath,
1347+
"image_path": sheetPath,
1348+
"layer_name": "Spritesheet",
1349+
"frame_number": 1,
1350+
"position": map[string]any{
1351+
"x": 0,
1352+
"y": 0,
1353+
},
1354+
})
1355+
if err != nil {
1356+
return fmt.Errorf("import_image failed: %w", err)
1357+
}
1358+
logger.Information(" ✓ Spritesheet imported as layer")
1359+
1360+
// Export the result
1361+
importResultPath := filepath.Join(outputDir, "imported-spritesheet.png")
1362+
_, err = callTool(ctx, session, "export_sprite", map[string]any{
1363+
"sprite_path": importSpritePath,
1364+
"output_path": importResultPath,
1365+
"format": "png",
1366+
"frame_number": 0,
1367+
})
1368+
if err != nil {
1369+
return fmt.Errorf("export imported sprite failed: %w", err)
1370+
}
1371+
logger.Information(" ✓ Exported imported result: {Path}", importResultPath)
1372+
11891373
return nil
11901374
}
11911375

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{ "frames": {
2+
"sprite-1759478144095359000 0.aseprite": {
3+
"frame": { "x": 2, "y": 2, "w": 20, "h": 20 },
4+
"rotated": false,
5+
"trimmed": false,
6+
"spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 },
7+
"sourceSize": { "w": 16, "h": 16 },
8+
"duration": 100
9+
},
10+
"sprite-1759478144095359000 1.aseprite": {
11+
"frame": { "x": 24, "y": 2, "w": 20, "h": 20 },
12+
"rotated": false,
13+
"trimmed": false,
14+
"spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 },
15+
"sourceSize": { "w": 16, "h": 16 },
16+
"duration": 100
17+
},
18+
"sprite-1759478144095359000 2.aseprite": {
19+
"frame": { "x": 46, "y": 2, "w": 20, "h": 20 },
20+
"rotated": false,
21+
"trimmed": false,
22+
"spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 },
23+
"sourceSize": { "w": 16, "h": 16 },
24+
"duration": 100
25+
},
26+
"sprite-1759478144095359000 3.aseprite": {
27+
"frame": { "x": 68, "y": 2, "w": 20, "h": 20 },
28+
"rotated": false,
29+
"trimmed": false,
30+
"spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 },
31+
"sourceSize": { "w": 16, "h": 16 },
32+
"duration": 100
33+
}
34+
},
35+
"meta": {
36+
"app": "https://www.aseprite.org/",
37+
"version": "1.x-dev",
38+
"image": "animation-spritesheet.png",
39+
"format": "RGBA8888",
40+
"size": { "w": 90, "h": 24 },
41+
"scale": "1",
42+
"frameTags": [
43+
{ "name": "grow", "from": 0, "to": 3, "direction": "forward", "color": "#000000ff" },
44+
{ "name": "temp_tag", "from": 0, "to": 1, "direction": "pingpong", "color": "#000000ff" }
45+
],
46+
"layers": [
47+
{ "name": "Layer 1", "opacity": 255, "blendMode": "normal" }
48+
],
49+
"slices": [
50+
]
51+
}
52+
}
610 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)