From 95e146c3348a83bc307b607416596ab2dbf60b24 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 18 Oct 2025 16:07:10 -0700 Subject: [PATCH 1/2] feat: add flatten_layers MCP tool Add tool to flatten all sprite layers into a single layer. Includes integration test, example demonstration in client, and documentation updates. --- CLAUDE.md | 4 +- README.md | 1 + cmd/pixel-mcp/main.go | 4 +- examples/README.md | 5 ++ examples/client/main.go | 18 ++++++ examples/quantization/main.go | 42 ++++++------- pkg/aseprite/lua_auto_shading.go | 18 +++--- pkg/aseprite/lua_drawing.go | 28 ++++----- pkg/aseprite/lua_quantization.go | 10 +-- pkg/aseprite/quantization_test.go | 6 +- pkg/server/server.go | 2 +- pkg/server/server_test.go | 2 +- pkg/tools/analysis.go | 2 +- pkg/tools/analysis_test.go | 4 +- pkg/tools/animation.go | 2 +- pkg/tools/animation_integration_test.go | 4 +- pkg/tools/animation_test.go | 4 +- pkg/tools/antialiasing.go | 2 +- pkg/tools/antialiasing_mcp_test.go | 4 +- pkg/tools/canvas.go | 43 ++++++++++++- pkg/tools/canvas_integration_test.go | 62 +++++++++++++++++- pkg/tools/canvas_test.go | 4 +- pkg/tools/dithering.go | 2 +- pkg/tools/dithering_test.go | 4 +- pkg/tools/drawing.go | 2 +- ...awing_rectangle_palette_index0_bug_test.go | 16 ++--- pkg/tools/drawing_test.go | 4 +- pkg/tools/export.go | 2 +- pkg/tools/export_mcp_test.go | 4 +- pkg/tools/inspection.go | 2 +- pkg/tools/inspection_test.go | 4 +- pkg/tools/palette_test.go | 4 +- pkg/tools/palette_tools.go | 2 +- pkg/tools/quantization.go | 63 +++++++++---------- pkg/tools/quantization_integration_test.go | 16 ++--- pkg/tools/register_test.go | 4 +- pkg/tools/selection.go | 2 +- pkg/tools/selection_mcp_test.go | 4 +- pkg/tools/transform.go | 2 +- pkg/tools/transform_mcp_test.go | 4 +- 40 files changed, 267 insertions(+), 145 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d2ef9d..a3d902b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ MCP Client → MCP Server (Go) → Lua Script Generation → Aseprite CLI (--bat - `pkg/config/` - Configuration management (file-based only) - `pkg/server/` - MCP server implementation - `pkg/tools/` - MCP tool implementations organized by category: - - `canvas.go` - Sprite/layer/frame management (create_sprite, add_layer, add_frame, delete_layer with protection, delete_frame with protection) + - `canvas.go` - Sprite/layer/frame management (create_sprite, add_layer, add_frame, delete_layer with protection, delete_frame with protection, flatten_layers) - `drawing.go` - Drawing primitives (pixels, lines, rectangles, circles, fill, contours for polylines/polygons) - `selection.go` - Selection and clipboard operations (8 tools) - `animation.go` - Animation and timeline operations (frame duration, tags, tag deletion, duplication, linked cels) @@ -130,7 +130,7 @@ MCP Client → MCP Server (Go) → Lua Script Generation → Aseprite CLI (--bat Core functionality implemented and tested: - Canvas creation and management (RGB, Grayscale, Indexed) -- Layer and frame operations (add, delete with last-layer/frame protection) +- Layer and frame operations (add, delete with last-layer/frame protection, flatten) - Drawing primitives (pixels, lines, rectangles, circles, fill) with optional palette-aware color snapping - Advanced drawing: Contour tool for drawing polylines and closed polygons with points arrays - **Selection and Clipboard Tools (8 tools):** diff --git a/README.md b/README.md index 3035b03..1db3ba4 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Then use natural language to create sprites: | `create_canvas` | Create new sprite with specified dimensions and color mode | | `add_layer` | Add a new layer to the sprite | | `delete_layer` | Delete a layer from the sprite (cannot delete last layer) | +| `flatten_layers` | Flatten all layers in a sprite into a single layer | | `get_sprite_info` | Get sprite metadata (size, layers, frames) | ### Drawing & Painting diff --git a/cmd/pixel-mcp/main.go b/cmd/pixel-mcp/main.go index c15905b..fd952fb 100644 --- a/cmd/pixel-mcp/main.go +++ b/cmd/pixel-mcp/main.go @@ -9,11 +9,11 @@ import ( "syscall" "time" - "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/pixel-mcp/pkg/server" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" "github.com/willibrandon/mtlog/sinks" + "github.com/willibrandon/pixel-mcp/pkg/config" + "github.com/willibrandon/pixel-mcp/pkg/server" ) var ( diff --git a/examples/README.md b/examples/README.md index ad4efd4..139c5c4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,6 +11,7 @@ The `client/` directory contains a complete example MCP client that demonstrates - Connecting to the Aseprite MCP server via stdio transport - Creating a 64x64 RGB sprite - Adding and deleting layers and frames +- Flattening multiple layers into a single layer - Drawing animated content (growing circles) - Drawing polylines and polygons (zigzag, triangle, star) - Filling areas with colors @@ -137,6 +138,7 @@ Available tools: - create_canvas: Create a new Aseprite sprite - add_layer: Add a new layer to the sprite - delete_layer: Delete a layer from the sprite + - flatten_layers: Flatten all layers into a single layer - add_frame: Add a new frame to the sprite timeline - delete_frame: Delete a frame from the sprite - get_sprite_info: Get metadata about a sprite @@ -251,6 +253,9 @@ Step 17: Demonstrating layer and frame deletion... Deleted Layer 2 (2 layers remaining) Deleted frame 2 (2 frames remaining) Final state: {"width":32,"height":32,"color_mode":"RGB","frame_count":2,"layer_count":2,"layers":["Layer 1","Layer 3"]} + Flattening remaining 2 layers into 1... + Layers flattened successfully + After flattening: {"width":32,"height":32,"color_mode":"RGB","frame_count":2,"layer_count":1,"layers":["Layer 1"]} Step 18: Demonstrating polylines and polygons... Drawing zigzag polyline on frame 1... diff --git a/examples/client/main.go b/examples/client/main.go index 597fbda..1ec982e 100644 --- a/examples/client/main.go +++ b/examples/client/main.go @@ -814,6 +814,24 @@ func createAnimatedSprite(ctx context.Context, session *mcp.ClientSession, logge } logger.Information(" Final state: {Info}", finalInfoResp) + // Demonstrate flatten_layers + logger.Information(" Flattening remaining 2 layers into 1...") + if _, err := callTool(ctx, session, "flatten_layers", map[string]any{ + "sprite_path": deleteSprite, + }); err != nil { + return fmt.Errorf("flatten_layers failed: %w", err) + } + logger.Information(" Layers flattened successfully") + + // Verify flattened state + flattenedInfoResp, err := callTool(ctx, session, "get_sprite_info", map[string]any{ + "sprite_path": deleteSprite, + }) + if err != nil { + return fmt.Errorf("get_sprite_info after flattening failed: %w", err) + } + logger.Information(" After flattening: {Info}", flattenedInfoResp) + // Step 18: Demonstrate drawing polylines and polygons logger.Information("") logger.Information("Step 18: Demonstrating polylines and polygons...") diff --git a/examples/quantization/main.go b/examples/quantization/main.go index 7f7e431..b6e2b05 100644 --- a/examples/quantization/main.go +++ b/examples/quantization/main.go @@ -107,18 +107,18 @@ func run(logger core.Logger) error { // Draw horizontal gradient bars (red to yellow to green to cyan to blue) // We'll batch pixels to make this much faster colors := []struct{ r, g, b int }{ - {255, 0, 0}, // Red - {255, 128, 0}, // Orange - {255, 255, 0}, // Yellow - {128, 255, 0}, // Yellow-Green - {0, 255, 0}, // Green - {0, 255, 128}, // Green-Cyan - {0, 255, 255}, // Cyan - {0, 128, 255}, // Cyan-Blue - {0, 0, 255}, // Blue - {128, 0, 255}, // Blue-Magenta - {255, 0, 255}, // Magenta - {255, 0, 128}, // Magenta-Red + {255, 0, 0}, // Red + {255, 128, 0}, // Orange + {255, 255, 0}, // Yellow + {128, 255, 0}, // Yellow-Green + {0, 255, 0}, // Green + {0, 255, 128}, // Green-Cyan + {0, 255, 255}, // Cyan + {0, 128, 255}, // Cyan-Blue + {0, 0, 255}, // Blue + {128, 0, 255}, // Blue-Magenta + {255, 0, 255}, // Magenta + {255, 0, 128}, // Magenta-Red } barHeight := 128 / len(colors) @@ -189,10 +189,10 @@ func run(logger core.Logger) error { // Step 3: Test each quantization algorithm algorithms := []struct { - name string + name string targetColors int - dither bool - description string + dither bool + description string }{ {"median_cut", 16, false, "Median Cut (balanced, no dither)"}, {"median_cut", 16, true, "Median Cut with Floyd-Steinberg dithering"}, @@ -253,12 +253,12 @@ func run(logger core.Logger) error { // Apply quantization quantizeResp, err := callTool(ctx, session, "quantize_palette", map[string]any{ - "sprite_path": copyPath, - "target_colors": algo.targetColors, - "algorithm": algo.name, - "dither": algo.dither, - "preserve_transparency": false, - "convert_to_indexed": true, + "sprite_path": copyPath, + "target_colors": algo.targetColors, + "algorithm": algo.name, + "dither": algo.dither, + "preserve_transparency": false, + "convert_to_indexed": true, }) if err != nil { return fmt.Errorf("quantize_palette failed: %w", err) diff --git a/pkg/aseprite/lua_auto_shading.go b/pkg/aseprite/lua_auto_shading.go index ee3e7b1..01ac300 100644 --- a/pkg/aseprite/lua_auto_shading.go +++ b/pkg/aseprite/lua_auto_shading.go @@ -126,13 +126,13 @@ spr:saveAs(spr.filename) -- Print JSON result print(json)`, - EscapeString(layerName), // layer name for finding - EscapeString(layerName), // layer name for error - tempImagePath, // shaded image path - frameNumber, // frame number for cel lookup - frameNumber, // frame number for error message - frameNumber, // frame number for newCel - colorList, // generated colors - len(generatedColors), // colors_added - regionsShadedCount) // regions_shaded + EscapeString(layerName), // layer name for finding + EscapeString(layerName), // layer name for error + tempImagePath, // shaded image path + frameNumber, // frame number for cel lookup + frameNumber, // frame number for error message + frameNumber, // frame number for newCel + colorList, // generated colors + len(generatedColors), // colors_added + regionsShadedCount) // regions_shaded } diff --git a/pkg/aseprite/lua_drawing.go b/pkg/aseprite/lua_drawing.go index 12a608b..8345010 100644 --- a/pkg/aseprite/lua_drawing.go +++ b/pkg/aseprite/lua_drawing.go @@ -1039,18 +1039,18 @@ print("Dithering applied successfully")`, frameNumber, frameNumber, c1.R, c1.G, c1.B, c1.A, c2.R, c2.G, c2.B, c2.A, - width, // line 915: error buffer width - height, // line 920: py loop - width, // line 925: clear buffer width - width, // line 930: px loop - width, // line 933: width check for division by zero - width, // line 936: gradient calculation - width, // line 967: right neighbor check - height, // line 972: bottom neighbor check - width, // line 981: bottom-right check - width, // line 1007: right neighbor check (RGB) - height, // line 1012: bottom neighbor check (RGB) - width, // line 1021: bottom-right check (RGB) - x, // line 1031: x coordinate - y) // line 1031: y coordinate + width, // line 915: error buffer width + height, // line 920: py loop + width, // line 925: clear buffer width + width, // line 930: px loop + width, // line 933: width check for division by zero + width, // line 936: gradient calculation + width, // line 967: right neighbor check + height, // line 972: bottom neighbor check + width, // line 981: bottom-right check + width, // line 1007: right neighbor check (RGB) + height, // line 1012: bottom neighbor check (RGB) + width, // line 1021: bottom-right check (RGB) + x, // line 1031: x coordinate + y) // line 1031: y coordinate } diff --git a/pkg/aseprite/lua_quantization.go b/pkg/aseprite/lua_quantization.go index a8addf8..b0898e4 100644 --- a/pkg/aseprite/lua_quantization.go +++ b/pkg/aseprite/lua_quantization.go @@ -121,11 +121,11 @@ spr:saveAs(spr.filename) -- Print JSON result print(json)`, - len(palette), // palette resize - colorList, // color list - conversionCode, // conversion code - originalColors, // original_colors - len(palette), // quantized_colors + len(palette), // palette resize + colorList, // color list + conversionCode, // conversion code + originalColors, // original_colors + len(palette), // quantized_colors EscapeString(algorithm)) // algorithm_used } diff --git a/pkg/aseprite/quantization_test.go b/pkg/aseprite/quantization_test.go index a9c496a..c790b75 100644 --- a/pkg/aseprite/quantization_test.go +++ b/pkg/aseprite/quantization_test.go @@ -278,10 +278,10 @@ func TestQuantizePalette(t *testing.T) { } tests := []struct { - name string + name string targetColors int - algorithm string - wantErr bool + algorithm string + wantErr bool }{ { name: "median_cut to 4 colors", diff --git a/pkg/server/server.go b/pkg/server/server.go index 658166d..5bae4b1 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -20,10 +20,10 @@ import ( "fmt" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" "github.com/willibrandon/pixel-mcp/pkg/tools" - "github.com/willibrandon/mtlog/core" ) // Server wraps the MCP server and provides Aseprite tool implementations. diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 8550578..0d48157 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -3,9 +3,9 @@ package server import ( "testing" - "github.com/willibrandon/pixel-mcp/internal/testutil" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/sinks" + "github.com/willibrandon/pixel-mcp/internal/testutil" ) func TestNew(t *testing.T) { diff --git a/pkg/tools/analysis.go b/pkg/tools/analysis.go index a1a834d..2cd91ff 100644 --- a/pkg/tools/analysis.go +++ b/pkg/tools/analysis.go @@ -10,9 +10,9 @@ import ( "os" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // AnalyzeReferenceInput defines the input parameters for the analyze_reference tool. diff --git a/pkg/tools/analysis_test.go b/pkg/tools/analysis_test.go index 9d46008..1e01a23 100644 --- a/pkg/tools/analysis_test.go +++ b/pkg/tools/analysis_test.go @@ -13,10 +13,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) // createAnalysisTestSession creates an MCP session with analysis tools registered diff --git a/pkg/tools/animation.go b/pkg/tools/animation.go index 119e3a1..d6d871f 100644 --- a/pkg/tools/animation.go +++ b/pkg/tools/animation.go @@ -6,9 +6,9 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // SetFrameDurationInput defines the input parameters for the set_frame_duration tool. diff --git a/pkg/tools/animation_integration_test.go b/pkg/tools/animation_integration_test.go index a80cf2e..0e187b1 100644 --- a/pkg/tools/animation_integration_test.go +++ b/pkg/tools/animation_integration_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/sinks" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) // Integration tests for animation tools with real Aseprite. diff --git a/pkg/tools/animation_test.go b/pkg/tools/animation_test.go index f90e693..ee0566f 100644 --- a/pkg/tools/animation_test.go +++ b/pkg/tools/animation_test.go @@ -9,10 +9,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) func TestSetFrameDurationInput_Validation(t *testing.T) { diff --git a/pkg/tools/antialiasing.go b/pkg/tools/antialiasing.go index be7523a..42a6615 100644 --- a/pkg/tools/antialiasing.go +++ b/pkg/tools/antialiasing.go @@ -7,9 +7,9 @@ import ( "math" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // SuggestAntialiasingInput defines the input parameters for antialiasing suggestions. diff --git a/pkg/tools/antialiasing_mcp_test.go b/pkg/tools/antialiasing_mcp_test.go index 2789c78..d368be3 100644 --- a/pkg/tools/antialiasing_mcp_test.go +++ b/pkg/tools/antialiasing_mcp_test.go @@ -9,10 +9,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) // createAntialiasingTestSession creates an MCP session with antialiasing tools registered diff --git a/pkg/tools/canvas.go b/pkg/tools/canvas.go index 7f7c500..d0ec5b8 100644 --- a/pkg/tools/canvas.go +++ b/pkg/tools/canvas.go @@ -34,9 +34,9 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // CreateCanvasInput defines the input parameters for the create_canvas tool. @@ -322,6 +322,33 @@ func RegisterCanvasTools(server *mcp.Server, client *aseprite.Client, gen *asepr return nil, &DeleteFrameOutput{Success: true}, nil }), ) + + // Register flatten_layers tool + mcp.AddTool( + server, + &mcp.Tool{ + Name: "flatten_layers", + Description: "Flatten all layers in a sprite into a single layer.", + }, + maybeWrapWithTiming("flatten_layers", logger, cfg.EnableTiming, func(ctx context.Context, req *mcp.CallToolRequest, input FlattenLayersInput) (*mcp.CallToolResult, *FlattenLayersOutput, error) { + opLogger := logger.WithContext(ctx) + opLogger.Debug("flatten_layers tool called", "sprite_path", input.SpritePath) + + // Generate Lua script + script := gen.FlattenLayers() + + // Execute Lua script with the sprite + _, err := client.ExecuteLua(ctx, script, input.SpritePath) + if err != nil { + opLogger.Error("Failed to flatten layers", "error", err) + return nil, nil, fmt.Errorf("failed to flatten layers: %w", err) + } + + opLogger.Information("Layers flattened successfully", "sprite", input.SpritePath) + + return nil, &FlattenLayersOutput{Success: true}, nil + }), + ) } // DeleteLayerInput defines the input parameters for the delete_layer tool. @@ -346,6 +373,20 @@ type DeleteFrameOutput struct { Success bool `json:"success" jsonschema:"Whether the frame was deleted successfully"` } +// FlattenLayersInput defines the input parameters for the flatten_layers tool. +// +// Flattens all layers in a sprite into a single layer. +type FlattenLayersInput struct { + SpritePath string `json:"sprite_path" jsonschema:"Path to the Aseprite sprite file"` // Path to the sprite file to modify +} + +// FlattenLayersOutput defines the output for the flatten_layers tool. +// +// Indicates whether the layers were successfully flattened. +type FlattenLayersOutput struct { + Success bool `json:"success" jsonschema:"Whether the layers were flattened successfully"` // True if the layers were flattened successfully +} + // generateTimestamp returns a Unix timestamp in nanoseconds suitable for unique filenames. func generateTimestamp() int64 { return time.Now().UnixNano() diff --git a/pkg/tools/canvas_integration_test.go b/pkg/tools/canvas_integration_test.go index 69a4ded..e849d49 100644 --- a/pkg/tools/canvas_integration_test.go +++ b/pkg/tools/canvas_integration_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/sinks" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) // Integration tests for create_canvas tool with real Aseprite. @@ -383,3 +383,61 @@ func TestIntegration_GetSpriteInfoWithLayersAndFrames(t *testing.T) { t.Logf("✓ Retrieved complex sprite info (4 layers, 4 frames)") } + +func TestIntegration_FlattenLayers(t *testing.T) { + cfg := testutil.LoadTestConfig(t) + client := aseprite.NewClient(cfg.AsepritePath, cfg.TempDir, 30*time.Second) + gen := aseprite.NewLuaGenerator() + ctx := context.Background() + + // Create a sprite + spritePath := testutil.TempSpritePath(t, "test-flatten.aseprite") + createScript := gen.CreateCanvas(64, 64, aseprite.ColorModeRGB, spritePath) + _, err := client.ExecuteLua(ctx, createScript, "") + if err != nil { + t.Fatalf("Failed to create canvas: %v", err) + } + defer os.Remove(spritePath) + + // Add multiple layers + layers := []string{"Layer 2", "Layer 3", "Layer 4"} + for _, layerName := range layers { + addLayerScript := gen.AddLayer(layerName) + _, err := client.ExecuteLua(ctx, addLayerScript, spritePath) + if err != nil { + t.Fatalf("Failed to add layer %s: %v", layerName, err) + } + } + + // Verify we have 4 layers (1 default + 3 added) + infoScript := gen.GetSpriteInfo() + output, err := client.ExecuteLua(ctx, infoScript, spritePath) + if err != nil { + t.Fatalf("Failed to get sprite info: %v", err) + } + if !strings.Contains(output, "\"layer_count\": 4") { + t.Errorf("Expected 4 layers before flattening, got: %s", output) + } + + // Flatten layers + flattenScript := gen.FlattenLayers() + output, err = client.ExecuteLua(ctx, flattenScript, spritePath) + if err != nil { + t.Fatalf("Failed to flatten layers: %v", err) + } + + if !strings.Contains(output, "Layers flattened successfully") { + t.Errorf("Expected success message, got: %s", output) + } + + // Verify we now have 1 layer + output, err = client.ExecuteLua(ctx, infoScript, spritePath) + if err != nil { + t.Fatalf("Failed to get sprite info after flattening: %v", err) + } + if !strings.Contains(output, "\"layer_count\": 1") { + t.Errorf("Expected 1 layer after flattening, got: %s", output) + } + + t.Logf("✓ Flattened 4 layers into 1 layer") +} diff --git a/pkg/tools/canvas_test.go b/pkg/tools/canvas_test.go index a05816c..0e7389f 100644 --- a/pkg/tools/canvas_test.go +++ b/pkg/tools/canvas_test.go @@ -10,11 +10,11 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/willibrandon/mtlog" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/internal/testutil" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog" - "github.com/willibrandon/mtlog/core" ) func TestCreateCanvasInput_Validation(t *testing.T) { diff --git a/pkg/tools/dithering.go b/pkg/tools/dithering.go index 5da6a4b..e4a6bc0 100644 --- a/pkg/tools/dithering.go +++ b/pkg/tools/dithering.go @@ -5,9 +5,9 @@ import ( "fmt" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // DrawWithDitherInput defines the input parameters for the draw_with_dither tool. diff --git a/pkg/tools/dithering_test.go b/pkg/tools/dithering_test.go index f440769..1e097c9 100644 --- a/pkg/tools/dithering_test.go +++ b/pkg/tools/dithering_test.go @@ -9,10 +9,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) // createDitheringTestSession creates an MCP session with dithering tools registered diff --git a/pkg/tools/drawing.go b/pkg/tools/drawing.go index a6c2ad8..b2dc74b 100644 --- a/pkg/tools/drawing.go +++ b/pkg/tools/drawing.go @@ -6,9 +6,9 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // PixelInput represents a single pixel to be drawn. diff --git a/pkg/tools/drawing_rectangle_palette_index0_bug_test.go b/pkg/tools/drawing_rectangle_palette_index0_bug_test.go index 2617c50..1ed5a03 100644 --- a/pkg/tools/drawing_rectangle_palette_index0_bug_test.go +++ b/pkg/tools/drawing_rectangle_palette_index0_bug_test.go @@ -50,11 +50,11 @@ func TestIntegration_DrawRectangle_GetPixels_PaletteIndex0Bug(t *testing.T) { drawRedScript := gen.DrawRectangle( "Layer 1", 1, - 0, 0, // x, y - 4, 4, // width, height + 0, 0, // x, y + 4, 4, // width, height aseprite.Color{R: 255, G: 0, B: 0, A: 255}, // RED - true, // filled - true, // use_palette (snap to nearest palette color) + true, // filled + true, // use_palette (snap to nearest palette color) ) _, err = client.ExecuteLua(ctx, drawRedScript, spritePath) if err != nil { @@ -65,11 +65,11 @@ func TestIntegration_DrawRectangle_GetPixels_PaletteIndex0Bug(t *testing.T) { drawGreenScript := gen.DrawRectangle( "Layer 1", 1, - 4, 0, // x, y - 4, 4, // width, height + 4, 0, // x, y + 4, 4, // width, height aseprite.Color{R: 0, G: 255, B: 0, A: 255}, // GREEN - true, // filled - true, // use_palette + true, // filled + true, // use_palette ) _, err = client.ExecuteLua(ctx, drawGreenScript, spritePath) if err != nil { diff --git a/pkg/tools/drawing_test.go b/pkg/tools/drawing_test.go index 74e3312..f31dc7d 100644 --- a/pkg/tools/drawing_test.go +++ b/pkg/tools/drawing_test.go @@ -9,10 +9,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) func TestDrawPixelsInput_Validation(t *testing.T) { diff --git a/pkg/tools/export.go b/pkg/tools/export.go index 7af1638..26bbf69 100644 --- a/pkg/tools/export.go +++ b/pkg/tools/export.go @@ -8,9 +8,9 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // ExportSpriteInput defines the input parameters for the export_sprite tool. diff --git a/pkg/tools/export_mcp_test.go b/pkg/tools/export_mcp_test.go index 6ae92ff..a4deefa 100644 --- a/pkg/tools/export_mcp_test.go +++ b/pkg/tools/export_mcp_test.go @@ -10,10 +10,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) // createExportTestSession creates an MCP session with export tools registered diff --git a/pkg/tools/inspection.go b/pkg/tools/inspection.go index 8a46c55..e1795c1 100644 --- a/pkg/tools/inspection.go +++ b/pkg/tools/inspection.go @@ -7,9 +7,9 @@ import ( "strconv" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // GetPixelsInput defines the input parameters for the get_pixels tool. diff --git a/pkg/tools/inspection_test.go b/pkg/tools/inspection_test.go index 0fddfc8..1c2ed61 100644 --- a/pkg/tools/inspection_test.go +++ b/pkg/tools/inspection_test.go @@ -8,10 +8,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) func TestGetPixelsInput_Validation(t *testing.T) { diff --git a/pkg/tools/palette_test.go b/pkg/tools/palette_test.go index 731fd80..a042353 100644 --- a/pkg/tools/palette_test.go +++ b/pkg/tools/palette_test.go @@ -9,10 +9,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) func TestSetPaletteInput_Validation(t *testing.T) { diff --git a/pkg/tools/palette_tools.go b/pkg/tools/palette_tools.go index b1741cb..d52fd76 100644 --- a/pkg/tools/palette_tools.go +++ b/pkg/tools/palette_tools.go @@ -7,9 +7,9 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // SetPaletteInput defines the input parameters for the set_palette tool. diff --git a/pkg/tools/quantization.go b/pkg/tools/quantization.go index f6ba77b..fc5f477 100644 --- a/pkg/tools/quantization.go +++ b/pkg/tools/quantization.go @@ -133,45 +133,44 @@ func RegisterQuantizationTools(server *mcp.Server, client *aseprite.Client, gen "quantized_colors", len(palette), "algorithm", input.Algorithm) + // Step 3: If dithering is requested, remap pixels to quantized palette with dithering + if input.Dither { + // Convert hex palette to color.Color slice + paletteColors := make([]color.Color, len(palette)) + for i, hexColor := range palette { + c, err := colorful.Hex(hexColor) + if err != nil { + return nil, nil, fmt.Errorf("invalid palette color %s: %w", hexColor, err) + } + r, g, b := c.RGB255() + paletteColors[i] = color.RGBA{R: r, G: g, B: b, A: 255} + } + + // Remap image with dithering + ditheredImg := aseprite.RemapPixelsWithDithering(img, paletteColors, true) - // Step 3: If dithering is requested, remap pixels to quantized palette with dithering - if input.Dither { - // Convert hex palette to color.Color slice - paletteColors := make([]color.Color, len(palette)) - for i, hexColor := range palette { - c, err := colorful.Hex(hexColor) + // Save dithered image to temp file + ditheredPNG := filepath.Join(tempDir, "dithered.png") + ditheredFile, err := os.Create(ditheredPNG) if err != nil { - return nil, nil, fmt.Errorf("invalid palette color %s: %w", hexColor, err) + return nil, nil, fmt.Errorf("failed to create dithered PNG: %w", err) } - r, g, b := c.RGB255() - paletteColors[i] = color.RGBA{R: r, G: g, B: b, A: 255} - } + defer ditheredFile.Close() - // Remap image with dithering - ditheredImg := aseprite.RemapPixelsWithDithering(img, paletteColors, true) - - // Save dithered image to temp file - ditheredPNG := filepath.Join(tempDir, "dithered.png") - ditheredFile, err := os.Create(ditheredPNG) - if err != nil { - return nil, nil, fmt.Errorf("failed to create dithered PNG: %w", err) - } - defer ditheredFile.Close() + if err := png.Encode(ditheredFile, ditheredImg); err != nil { + return nil, nil, fmt.Errorf("failed to encode dithered PNG: %w", err) + } - if err := png.Encode(ditheredFile, ditheredImg); err != nil { - return nil, nil, fmt.Errorf("failed to encode dithered PNG: %w", err) - } + // Replace sprite content with dithered image + replaceScript := gen.ReplaceWithImage(ditheredPNG) + _, err = client.ExecuteLua(ctx, replaceScript, input.SpritePath) + if err != nil { + return nil, nil, fmt.Errorf("failed to replace sprite with dithered image: %w", err) + } - // Replace sprite content with dithered image - replaceScript := gen.ReplaceWithImage(ditheredPNG) - _, err = client.ExecuteLua(ctx, replaceScript, input.SpritePath) - if err != nil { - return nil, nil, fmt.Errorf("failed to replace sprite with dithered image: %w", err) + opLogger.Information("Dithering applied successfully", + "sprite", input.SpritePath) } - - opLogger.Information("Dithering applied successfully", - "sprite", input.SpritePath) - } // Step 4: Generate and execute Lua script to apply quantized palette applyScript := gen.ApplyQuantizedPalette( palette, diff --git a/pkg/tools/quantization_integration_test.go b/pkg/tools/quantization_integration_test.go index 56eb220..9a667fe 100644 --- a/pkg/tools/quantization_integration_test.go +++ b/pkg/tools/quantization_integration_test.go @@ -216,16 +216,16 @@ func TestIntegration_QuantizePalette_Octree(t *testing.T) { // Draw circles with various colors colors := []aseprite.Color{ - {R: 255, G: 0, B: 0, A: 255}, // Red - {R: 0, G: 255, B: 0, A: 255}, // Green - {R: 0, G: 0, B: 255, A: 255}, // Blue - {R: 255, G: 255, B: 0, A: 255}, // Yellow - {R: 255, G: 0, B: 255, A: 255}, // Magenta - {R: 0, G: 255, B: 255, A: 255}, // Cyan + {R: 255, G: 0, B: 0, A: 255}, // Red + {R: 0, G: 255, B: 0, A: 255}, // Green + {R: 0, G: 0, B: 255, A: 255}, // Blue + {R: 255, G: 255, B: 0, A: 255}, // Yellow + {R: 255, G: 0, B: 255, A: 255}, // Magenta + {R: 0, G: 255, B: 255, A: 255}, // Cyan } for i, color := range colors { - x := (i % 3) * 21 + 10 - y := (i / 3) * 32 + 16 + x := (i%3)*21 + 10 + y := (i/3)*32 + 16 drawScript := gen.DrawCircle("Layer 1", 1, x, y, 8, color, true, false) _, err := client.ExecuteLua(ctx, drawScript, spritePath) if err != nil { diff --git a/pkg/tools/register_test.go b/pkg/tools/register_test.go index 97dd5c8..2cae9ee 100644 --- a/pkg/tools/register_test.go +++ b/pkg/tools/register_test.go @@ -5,10 +5,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) // createTestServer creates a minimal test MCP server for registration tests diff --git a/pkg/tools/selection.go b/pkg/tools/selection.go index 0b66b2c..a132a31 100644 --- a/pkg/tools/selection.go +++ b/pkg/tools/selection.go @@ -6,9 +6,9 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // SelectRectangleInput defines the input parameters for the select_rectangle tool. diff --git a/pkg/tools/selection_mcp_test.go b/pkg/tools/selection_mcp_test.go index 06c2a2a..2c7cbc5 100644 --- a/pkg/tools/selection_mcp_test.go +++ b/pkg/tools/selection_mcp_test.go @@ -9,10 +9,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) // createSelectionTestSession creates an MCP session with selection tools registered diff --git a/pkg/tools/transform.go b/pkg/tools/transform.go index 400c2da..bab46c5 100644 --- a/pkg/tools/transform.go +++ b/pkg/tools/transform.go @@ -8,9 +8,9 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/willibrandon/mtlog/core" "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/pixel-mcp/pkg/config" - "github.com/willibrandon/mtlog/core" ) // DownsampleImageInput defines the input parameters for the downsample_image tool. diff --git a/pkg/tools/transform_mcp_test.go b/pkg/tools/transform_mcp_test.go index bf8ea55..5408a53 100644 --- a/pkg/tools/transform_mcp_test.go +++ b/pkg/tools/transform_mcp_test.go @@ -9,10 +9,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/willibrandon/pixel-mcp/internal/testutil" - "github.com/willibrandon/pixel-mcp/pkg/aseprite" "github.com/willibrandon/mtlog" "github.com/willibrandon/mtlog/core" + "github.com/willibrandon/pixel-mcp/internal/testutil" + "github.com/willibrandon/pixel-mcp/pkg/aseprite" ) // createTransformTestSession creates an MCP session with transform tools registered From e1c72d0e3411c85bae96708a06076050476ca36a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 18 Oct 2025 16:16:59 -0700 Subject: [PATCH 2/2] chore: add v0.5.0 to CHANGELOG Document flatten_layers tool addition. --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2387e2..0f353ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2025-10-18 + +### Added +- **Layer Flattening Tool** (`flatten_layers`) + - Flattens all layers in a sprite into a single layer + - Uses Aseprite's built-in flatten operation + - Integration test and example demonstration included + ## [0.4.0] - 2025-10-18 ### Added