Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **draw_pixels: a later call reverts previously-transparent pixels to opaque black**
- On an indexed sprite `CreateCanvas` sets `Sprite.transparentColor` to 255 so
palette index 0 can hold a real color, which means index 0 is opaque. When
`draw_pixels` normalizes the target cel it builds a full-canvas image with
`Image(spr.width, spr.height, spr.colorMode)`, and a freshly created indexed
image is filled with index 0 (opaque), not the transparent index
- Compositing the existing content with the default NORMAL blend
(`full:drawImage(cel.image, cel.position)`) skips the source's transparent
(index 255) pixels, so every pixel a previous call had made transparent but
the current call does not rewrite is left showing the opaque index-0
background, silently turning transparent areas into opaque black (the exact
symptom warned about for locked-palette sprites)
- Fixed by clearing the full-canvas image to the sprite's transparent color
(`full:clear(spr.transparentColor)`) before compositing/drawing on indexed
sprites, in both the existing-cel and new-cel paths; RGB and grayscale
behavior is unchanged
- **create_canvas: a new indexed canvas starts opaque instead of transparent**
- A new indexed sprite's initial cel is filled with palette index 0, but
`CreateCanvas` designates index 255 as the transparent color, so index 0 is
an opaque color (black on DawnBringer 32) and a fresh canvas read back as an
opaque background rather than transparent
- Fixed by clearing the initial cel to the sprite's transparent color right
after creation, so a fresh indexed canvas is genuinely transparent; RGB and
grayscale are unaffected
- **quantize_palette: median cut blends distinct colors under imbalanced pixel frequency**
- `MedianCutQuantization` split each bucket at the pixel-array midpoint
(`mid := len(pixels) / 2`), not at a boundary between distinct colors.
Pixel art typically has wildly imbalanced color frequency (a large
solid fill vastly outnumbering a 1-2px accent detail), so splitting at
the raw pixel-count median lands the cut inside the dominant color's
own run instead of between colors
- The dominant color got needlessly re-subdivided into near-duplicate
buckets while rare accent colors never got isolated into their own
bucket, and were instead averaged into a blended, off-palette hex
value - even when `target_colors` comfortably exceeded the number of
unique colors actually present, which should guarantee lossless
recovery
- Fixed by reducing to a frequency-weighted histogram of unique colors
before bucketing, so a split always falls between distinct colors;
the split point still favors common colors over rare ones by
cumulative pixel count, preserving normal median-cut reduction
behavior when `target_colors` is genuinely smaller than the number of
unique colors
- **Indexed palette: transparentColor collides with a real color after resize**
- `Palette:resize` silently clamps `Sprite.transparentColor` into the new
palette's valid index range, so after `set_palette`, `quantize_palette`,
`add_palette_color`, or `apply_auto_shading` resize an indexed sprite's
palette, the sprite's transparent-color index can land on the same index
as a real, in-use color (typically the palette's last entry)
- Once that collision happens, painting "transparent" (e.g. `fill_area`
with a fully-transparent color) writes that colliding index, so
background fills read back as an opaque real color instead of
transparent, and pixels intentionally drawn in that color can be
auto-trimmed away as if they were transparent
- Fixed by re-asserting `transparentColor = 255` after every palette
resize on an indexed sprite, keeping it safely out of the palette's
real, addressable range

## [0.5.0] - 2025-10-18

### Added
Expand Down
21 changes: 11 additions & 10 deletions pkg/aseprite/lua_auto_shading.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ if palette then
end
end
end

%s
-- Build final palette for JSON output
local paletteHex = {}
for i = 0, #palette - 1 do
Expand All @@ -126,13 +126,14 @@ 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
ReassertIndexedTransparentColor(), // keep transparentColor out of the real palette range
len(generatedColors), // colors_added
regionsShadedCount) // regions_shaded
}
9 changes: 9 additions & 0 deletions pkg/aseprite/lua_canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ func (g *LuaGenerator) CreateCanvas(width, height int, colorMode ColorMode, file
if colorMode == ColorModeIndexed {
return fmt.Sprintf(`local spr = Sprite(%d, %d, %s)
spr.transparentColor = 255
-- A new indexed sprite's initial cel is filled with palette index 0, but index
-- 255 is the transparent index (set above), so index 0 is now an opaque color
-- and the canvas would start as an opaque background instead of transparent.
-- Clear the initial cel to the transparent color so a fresh indexed canvas is
-- genuinely transparent.
local initialCel = spr.layers[1]:cel(spr.frames[1])
if initialCel and initialCel.image then
initialCel.image:clear(spr.transparentColor)
end
spr:saveAs("%s")
print("%s")`, width, height, colorMode.ToLua(), escapedFilename, escapedFilename)
}
Expand Down
111 changes: 100 additions & 11 deletions pkg/aseprite/lua_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,29 +54,36 @@ func FormatColor(c Color) string {

// FormatColorWithPalette formats a Color with optional palette snapping for img:putPixel.
//
// If usePalette is false, returns a direct Color constructor call.
// If usePalette is true, wraps the color in snapToPaletteForPixel() to find the nearest palette color.
// If usePalette is false, wraps the color in resolveExactPaletteColor() to require an exact
// palette match on indexed sprites (errors otherwise) while passing RGB/grayscale colors through
// unchanged. If usePalette is true, wraps the color in snapToPaletteForPixel() to find the
// nearest palette color.
//
// The snapToPaletteForPixel function must be defined in the script (use GeneratePaletteSnapperHelper).
// This is useful for palette-constrained pixel art to ensure all colors match the palette.
// Returns palette index in indexed mode, pixel color in other modes.
// The resolveExactPaletteColor function must be defined in the script when usePalette is false
// (use ResolveExactPaletteColorHelper); snapToPaletteForPixel must be defined when usePalette is
// true (use GeneratePaletteSnapperHelper). Returns palette index in indexed mode, pixel color in
// other modes.
func FormatColorWithPalette(c Color, usePalette bool) string {
if !usePalette {
return FormatColor(c)
return fmt.Sprintf("resolveExactPaletteColor(%d, %d, %d, %d)", c.R, c.G, c.B, c.A)
}
return fmt.Sprintf("snapToPaletteForPixel(%d, %d, %d, %d)", c.R, c.G, c.B, c.A)
}

// FormatColorWithPaletteForTool formats a Color with optional palette snapping for app.useTool.
//
// If usePalette is false, returns a direct Color constructor call.
// If usePalette is true, wraps the color in snapToPaletteForTool() to find the nearest palette color.
// If usePalette is false, wraps the color in resolveExactPaletteColor() to require an exact
// palette match on indexed sprites (errors otherwise) while passing RGB/grayscale colors through
// unchanged. If usePalette is true, wraps the color in snapToPaletteForTool() to find the nearest
// palette color.
//
// The snapToPaletteForTool function must be defined in the script (use GeneratePaletteSnapperHelper).
// Always returns a pixel color value suitable for app.useTool in all color modes.
// The resolveExactPaletteColor function must be defined in the script when usePalette is false
// (use ResolveExactPaletteColorHelper); snapToPaletteForTool must be defined when usePalette is
// true (use GeneratePaletteSnapperHelper). Always returns a value suitable for app.useTool's
// color field in all color modes.
func FormatColorWithPaletteForTool(c Color, usePalette bool) string {
if !usePalette {
return FormatColor(c)
return fmt.Sprintf("resolveExactPaletteColor(%d, %d, %d, %d)", c.R, c.G, c.B, c.A)
}
return fmt.Sprintf("snapToPaletteForTool(%d, %d, %d, %d)", c.R, c.G, c.B, c.A)
}
Expand Down Expand Up @@ -159,6 +166,88 @@ end
`
}

// ReassertIndexedTransparentColor returns a Lua snippet that pins an indexed
// sprite's transparentColor back to 255 after the palette has been resized.
//
// CreateCanvas sets Sprite.transparentColor to 255 for indexed sprites so that
// palette index 0 can hold a real, opaque color instead of being silently
// treated as transparent (see the palette index 0 fix). However, Aseprite
// automatically clamps Sprite.transparentColor into the current palette's
// valid index range whenever Palette:resize shrinks the palette below 256
// colors. For a locked N-color palette (N < 256), transparentColor ends up
// equal to N-1, i.e. the last real color the caller just set.
//
// Once that collision happens, any operation that paints "transparent"
// (for example fill_area's paint bucket, or fully-transparent pixels in an
// imported image) writes the sprite's designated transparent index, which is
// now indistinguishable from that last real color: reading it back reports
// the real color's opaque RGBA instead of transparency, and a pixel
// deliberately drawn in that color can be auto-trimmed away as if it were
// transparent.
//
// Call this immediately after any Palette:resize on an indexed sprite (and
// before spr:saveAs) to keep transparentColor safely out of the palette's
// real, addressable range.
func ReassertIndexedTransparentColor() string {
return `if spr.colorMode == ColorMode.INDEXED then
spr.transparentColor = 255
end
`
}

// ResolveExactPaletteColorHelper returns Lua code defining resolveExactPaletteColor,
// a color-resolution helper for img:putPixel and app.useTool calls made with
// usePalette=false (exact colors, no nearest-match snapping).
//
// On an indexed sprite, both img:putPixel and app.useTool expect a palette
// index, not an arbitrary RGBA value. Passing a Color object directly works
// when the sprite's palette was set earlier in the SAME Aseprite process, but
// pixel-mcp runs each tool call as its own aseprite --batch invocation: the
// palette is set in one process and the drawing happens in the next. In that
// cross-process scenario, Aseprite's own Color-to-index resolution for
// img:putPixel/app.useTool does not reliably match the sprite's actual,
// correctly-loaded palette (confirmed by reading spr.palettes[1] back at the
// point of failure) and silently resolves to an unrelated index instead,
// corrupting the drawn color with no error.
//
// resolveExactPaletteColor works around this by resolving the color itself:
// on an indexed sprite, a fully-transparent color (alpha 0, e.g. from a
// "paint transparent" fill_area call) always resolves to spr.transparentColor
// rather than a palette search, since transparency is a designated index, not
// a color that is expected to exist in the palette. Any other color looks up
// an EXACT match in the active palette and returns that index, erroring if
// none exists (callers should pass use_palette=true to snap to the nearest
// palette color instead). On a non-indexed sprite there is no palette to
// resolve against, so it returns the raw color unchanged, preserving existing
// RGB/grayscale behavior.
func ResolveExactPaletteColorHelper() string {
return `
-- Helper: Resolve an exact color for img:putPixel/app.useTool (usePalette=false)
local function resolveExactPaletteColor(r, g, b, a)
local spr = app.activeSprite
if spr.colorMode ~= ColorMode.INDEXED then
return Color(r, g, b, a)
end

if a == 0 then
return spr.transparentColor
end

local palette = spr.palettes[1]
for i = 0, #palette - 1 do
local palColor = palette:getColor(i)
if palColor.red == r and palColor.green == g and palColor.blue == b and palColor.alpha == a then
return i
end
end

error(string.format(
"Color #%02X%02X%02X%02X has no exact match in the sprite's palette; use use_palette=true to snap to the nearest palette color",
r, g, b, a))
end
`
}

// FormatPoint formats a Point as a Lua Point constructor call.
//
// Returns a string like "Point(10, 20)" suitable for embedding in Lua scripts.
Expand Down
73 changes: 59 additions & 14 deletions pkg/aseprite/lua_drawing.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ func (g *LuaGenerator) DrawPixels(layerName string, frameNumber int, pixels []Pi

escapedName := EscapeString(layerName)

// Add palette snapper helper if needed
// Add the color resolution helper: nearest-match snapping when requested,
// otherwise exact-match-or-error (see ResolveExactPaletteColorHelper).
if usePalette {
sb.WriteString(GeneratePaletteSnapperHelper())
sb.WriteString("\n")
} else {
sb.WriteString(ResolveExactPaletteColorHelper())
}
sb.WriteString("\n")

sb.WriteString(fmt.Sprintf(`local spr = app.activeSprite
if not spr then
Expand All @@ -61,8 +64,35 @@ end

app.transaction(function()
local cel = layer:cel(frame)
if not cel then
cel = spr:newCel(layer, frame)
if cel and cel.image then
-- Composite existing content into a full-canvas image at (0,0) so that
-- sprite coordinates match image coordinates for putPixel below. Without
-- this, Image:putPixel uses cel-local coordinates and pixels are offset
-- by the cel's position whenever content does not start at (0,0).
local full = Image(spr.width, spr.height, spr.colorMode)
-- A freshly created indexed Image is filled with index 0, but on these
-- sprites the transparent index is 255 (set by CreateCanvas). Clear the
-- new image to the sprite's transparent color first; otherwise the
-- default (NORMAL blend) drawImage below skips the source's transparent
-- pixels and leaves the opaque index-0 background showing through,
-- silently turning already-transparent pixels into opaque black.
if spr.colorMode == ColorMode.INDEXED then
full:clear(spr.transparentColor)
end
full:drawImage(cel.image, cel.position)
cel.image = full
cel.position = Point(0, 0)
else
-- No cel yet (or nil image, e.g. a freshly added layer): create a
-- full-canvas cel WITH an image so putPixel has a valid target.
local full = Image(spr.width, spr.height, spr.colorMode)
-- Same transparent-background fix as above: without clearing to the
-- transparent index, every untouched pixel of a new indexed cel would be
-- opaque index 0 instead of transparent.
if spr.colorMode == ColorMode.INDEXED then
full:clear(spr.transparentColor)
end
cel = spr:newCel(layer, frame, full, Point(0, 0))
end

local img = cel.image
Expand Down Expand Up @@ -106,11 +136,14 @@ print("Pixels drawn successfully")`)
func (g *LuaGenerator) DrawLine(layerName string, frameNumber int, x1, y1, x2, y2 int, color Color, thickness int, usePalette bool) string {
var sb strings.Builder

// Add palette snapper helper if needed
// Add the color resolution helper: nearest-match snapping when requested,
// otherwise exact-match-or-error (see ResolveExactPaletteColorHelper).
if usePalette {
sb.WriteString(GeneratePaletteSnapperHelper())
sb.WriteString("\n")
} else {
sb.WriteString(ResolveExactPaletteColorHelper())
}
sb.WriteString("\n")

escapedName := EscapeString(layerName)
sb.WriteString(fmt.Sprintf(`local spr = app.activeSprite
Expand Down Expand Up @@ -190,11 +223,14 @@ print("Line drawn successfully")`,
func (g *LuaGenerator) DrawContour(layerName string, frameNumber int, points []Point, color Color, thickness int, closed bool, usePalette bool) string {
var sb strings.Builder

// Add palette snapper helper if needed
// Add the color resolution helper: nearest-match snapping when requested,
// otherwise exact-match-or-error (see ResolveExactPaletteColorHelper).
if usePalette {
sb.WriteString(GeneratePaletteSnapperHelper())
sb.WriteString("\n")
} else {
sb.WriteString(ResolveExactPaletteColorHelper())
}
sb.WriteString("\n")

escapedName := EscapeString(layerName)
sb.WriteString(fmt.Sprintf(`local spr = app.activeSprite
Expand Down Expand Up @@ -295,11 +331,14 @@ print("Contour drawn successfully")`)
func (g *LuaGenerator) DrawRectangle(layerName string, frameNumber int, x, y, width, height int, color Color, filled bool, usePalette bool) string {
var sb strings.Builder

// Add palette snapper helper if needed
// Add the color resolution helper: nearest-match snapping when requested,
// otherwise exact-match-or-error (see ResolveExactPaletteColorHelper).
if usePalette {
sb.WriteString(GeneratePaletteSnapperHelper())
sb.WriteString("\n")
} else {
sb.WriteString(ResolveExactPaletteColorHelper())
}
sb.WriteString("\n")

escapedName := EscapeString(layerName)
tool := "rectangle"
Expand Down Expand Up @@ -379,11 +418,14 @@ print("Rectangle drawn successfully")`,
func (g *LuaGenerator) DrawCircle(layerName string, frameNumber int, centerX, centerY, radius int, color Color, filled bool, usePalette bool) string {
var sb strings.Builder

// Add palette snapper helper if needed
// Add the color resolution helper: nearest-match snapping when requested,
// otherwise exact-match-or-error (see ResolveExactPaletteColorHelper).
if usePalette {
sb.WriteString(GeneratePaletteSnapperHelper())
sb.WriteString("\n")
} else {
sb.WriteString(ResolveExactPaletteColorHelper())
}
sb.WriteString("\n")

escapedName := EscapeString(layerName)
tool := "ellipse"
Expand Down Expand Up @@ -469,11 +511,14 @@ print("Circle drawn successfully")`,
func (g *LuaGenerator) FillArea(layerName string, frameNumber int, x, y int, color Color, tolerance int, usePalette bool) string {
var sb strings.Builder

// Add palette snapper helper if needed
// Add the color resolution helper: nearest-match snapping when requested,
// otherwise exact-match-or-error (see ResolveExactPaletteColorHelper).
if usePalette {
sb.WriteString(GeneratePaletteSnapperHelper())
sb.WriteString("\n")
} else {
sb.WriteString(ResolveExactPaletteColorHelper())
}
sb.WriteString("\n")

escapedName := EscapeString(layerName)
sb.WriteString(fmt.Sprintf(`local spr = app.activeSprite
Expand Down
Loading