Skip to content
29 changes: 29 additions & 0 deletions .github/workflows/linting_and_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Lint & Test

on: [push]

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.14"]
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --extra dev

- name: Lint with ruff
run: uv run ruff check src/ tests/

- name: Typecheck with mypy
run: uv run mypy src/

- name: Run tests
run: uv run pytest tests/ -v --ignore=tests/test_e2e_knight_quest_live.py --ignore=tests/test_integration_knight_quest_live.py
180 changes: 92 additions & 88 deletions src/aseprite_mcp/tools/adjust.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,90 +84,90 @@ async def adjust_colors(

-- HSV helper functions
local function rgbToHsv(r, g, b)
local rn = r / 255
local gn = g / 255
local bn = b / 255
local max = math.max(rn, gn, bn)
local min = math.min(rn, gn, bn)
local d = max - min
local h = 0
local s = 0
local v = max
if max > 0 then s = d / max end
if d > 0 then
if max == rn then h = (gn - bn) / d
elseif max == gn then h = 2 + (bn - rn) / d
else h = 4 + (rn - gn) / d
end
h = h * 60
if h < 0 then h = h + 360 end
local rn = r / 255
local gn = g / 255
local bn = b / 255
local max = math.max(rn, gn, bn)
local min = math.min(rn, gn, bn)
local d = max - min
local h = 0
local s = 0
local v = max
if max > 0 then s = d / max end
if d > 0 then
if max == rn then h = (gn - bn) / d
elseif max == gn then h = 2 + (bn - rn) / d
else h = 4 + (rn - gn) / d
end
return h, s, v
h = h * 60
if h < 0 then h = h + 360 end
end
return h, s, v
end

local function hsvToRgb(h, s, v)
if s == 0 then
local val = v * 255
return val, val, val
end
h = h / 60
local i = math.floor(h)
local f = h - i
local p = v * (1 - s)
local q = v * (1 - s * f)
local t = v * (1 - s * (1 - f))
local rn, gn, bn
if i == 0 then rn, gn, bn = v, t, p
elseif i == 1 then rn, gn, bn = q, v, p
elseif i == 2 then rn, gn, bn = p, v, t
elseif i == 3 then rn, gn, bn = p, q, v
elseif i == 4 then rn, gn, bn = t, p, v
else rn, gn, bn = v, p, q
end
return rn * 255, gn * 255, bn * 255
if s == 0 then
local val = v * 255
return val, val, val
end
h = h / 60
local i = math.floor(h)
local f = h - i
local p = v * (1 - s)
local q = v * (1 - s * f)
local t = v * (1 - s * (1 - f))
local rn, gn, bn
if i == 0 then rn, gn, bn = v, t, p
elseif i == 1 then rn, gn, bn = q, v, p
elseif i == 2 then rn, gn, bn = p, v, t
elseif i == 3 then rn, gn, bn = p, q, v
elseif i == 4 then rn, gn, bn = t, p, v
else rn, gn, bn = v, p, q
end
return rn * 255, gn * 255, bn * 255
end

app.transaction(function()
for y = 0, img.height - 1 do
for x = 0, img.width - 1 do
local c = img:getPixel(x, y)
local r = app.pixelColor.rgbaR(c)
local g = app.pixelColor.rgbaG(c)
local b = app.pixelColor.rgbaB(c)
local a = app.pixelColor.rgbaA(c)

if a > 0 then
-- Apply brightness
r = math.max(0, math.min(255, r + {brightness}))
g = math.max(0, math.min(255, g + {brightness}))
b = math.max(0, math.min(255, b + {brightness}))

-- Apply contrast
if {contrast} ~= 0 then
local factor = (259 * ({contrast} + 255)) / (255 * (259 - {contrast}))
r = math.max(0, math.min(255, math.floor(factor * (r - 128) + 128 + 0.5)))
g = math.max(0, math.min(255, math.floor(factor * (g - 128) + 128 + 0.5)))
b = math.max(0, math.min(255, math.floor(factor * (b - 128) + 128 + 0.5)))
end

-- Apply hue shift and saturation
if {hue_shift} ~= 0 or {saturation} ~= 0 then
local h, s, v = rgbToHsv(r, g, b)
h = h + {hue_shift}
if h < 0 then h = h + 360 end
if h >= 360 then h = h - 360 end
local sat_adj = {saturation} / 255
s = math.max(0, math.min(1, s + sat_adj))
local nr, ng, nb = hsvToRgb(h, s, v)
r = math.max(0, math.min(255, math.floor(nr + 0.5)))
g = math.max(0, math.min(255, math.floor(ng + 0.5)))
b = math.max(0, math.min(255, math.floor(nb + 0.5)))
end

img:drawPixel(x, y, app.pixelColor.rgba(r, g, b, a))
end
for y = 0, img.height - 1 do
for x = 0, img.width - 1 do
local c = img:getPixel(x, y)
local r = app.pixelColor.rgbaR(c)
local g = app.pixelColor.rgbaG(c)
local b = app.pixelColor.rgbaB(c)
local a = app.pixelColor.rgbaA(c)

if a > 0 then
-- Apply brightness
r = math.max(0, math.min(255, r + {brightness}))
g = math.max(0, math.min(255, g + {brightness}))
b = math.max(0, math.min(255, b + {brightness}))

-- Apply contrast
if {contrast} ~= 0 then
local factor = (259 * ({contrast} + 255)) / (255 * (259 - {contrast}))
r = math.max(0, math.min(255, math.floor(factor * (r - 128) + 128 + 0.5)))
g = math.max(0, math.min(255, math.floor(factor * (g - 128) + 128 + 0.5)))
b = math.max(0, math.min(255, math.floor(factor * (b - 128) + 128 + 0.5)))
end

-- Apply hue shift and saturation
if {hue_shift} ~= 0 or {saturation} ~= 0 then
local h, s, v = rgbToHsv(r, g, b)
h = h + {hue_shift}
if h < 0 then h = h + 360 end
if h >= 360 then h = h - 360 end
local sat_adj = {saturation} / 255
s = math.max(0, math.min(1, s + sat_adj))
local nr, ng, nb = hsvToRgb(h, s, v)
r = math.max(0, math.min(255, math.floor(nr + 0.5)))
g = math.max(0, math.min(255, math.floor(ng + 0.5)))
b = math.max(0, math.min(255, math.floor(nb + 0.5)))
end

img:drawPixel(x, y, app.pixelColor.rgba(r, g, b, a))
end
end
end
end)

spr:saveAs("{esc}")
Expand All @@ -176,7 +176,9 @@ async def adjust_colors(

success, output = get_cli().execute_lua_script(script, filename)
if success:
return f"Adjusted colors on layer '{layer_name}' frame {frame_index} in {filename}"
return (
f"Adjusted colors on layer '{layer_name}' frame {frame_index} in {filename}"
)
return f"Failed to adjust colors: {output}"


Expand Down Expand Up @@ -233,18 +235,18 @@ async def invert_colors(
local img = cel.image

app.transaction(function()
for y = 0, img.height - 1 do
for x = 0, img.width - 1 do
local c = img:getPixel(x, y)
local r = app.pixelColor.rgbaR(c)
local g = app.pixelColor.rgbaG(c)
local b = app.pixelColor.rgbaB(c)
local a = app.pixelColor.rgbaA(c)
if a > 0 then
img:drawPixel(x, y, app.pixelColor.rgba(255 - r, 255 - g, 255 - b, a))
end
end
for y = 0, img.height - 1 do
for x = 0, img.width - 1 do
local c = img:getPixel(x, y)
local r = app.pixelColor.rgbaR(c)
local g = app.pixelColor.rgbaG(c)
local b = app.pixelColor.rgbaB(c)
local a = app.pixelColor.rgbaA(c)
if a > 0 then
img:drawPixel(x, y, app.pixelColor.rgba(255 - r, 255 - g, 255 - b, a))
end
end
end
end)

spr:saveAs("{esc}")
Expand All @@ -253,7 +255,9 @@ async def invert_colors(

success, output = get_cli().execute_lua_script(script, filename)
if success:
return f"Inverted colors on layer '{layer_name}' frame {frame_index} in {filename}"
return (
f"Inverted colors on layer '{layer_name}' frame {frame_index} in {filename}"
)
return f"Failed to invert colors: {output}"


Expand Down
5 changes: 3 additions & 2 deletions src/aseprite_mcp/tools/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ async def start_preview_server(directory: str, port: int = 8000) -> str:
}

if os.name == "nt":
popen_kwargs["creationflags"] = ( # type: ignore[attr-defined]
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
popen_kwargs["creationflags"] = (
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
| getattr(subprocess, "DETACHED_PROCESS", 0)
)
else:
popen_kwargs["start_new_session"] = True # type: ignore[assignment]
Expand Down
24 changes: 18 additions & 6 deletions tests/test_tools_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,19 @@ async def test_delete_frames_end_before_start(self):
from aseprite_mcp.tools.animation import delete_frames

with patch("aseprite_mcp.tools.animation.check_file", return_value=None):
result = await delete_frames(filename="test.ase", start_frame=5, end_frame=2)
result = await delete_frames(
filename="test.ase", start_frame=5, end_frame=2
)
assert "Error" in result
assert "end_frame" in result

@pytest.mark.asyncio
async def test_delete_frames_path_traversal(self):
from aseprite_mcp.tools.animation import delete_frames

result = await delete_frames(filename="../etc/passwd", start_frame=1, end_frame=3)
result = await delete_frames(
filename="../etc/passwd", start_frame=1, end_frame=3
)
assert ".." in result

@pytest.mark.asyncio
Expand All @@ -329,15 +333,19 @@ async def test_delete_frames_file_not_found(self):
"aseprite_mcp.tools.animation.check_file",
return_value="File test.ase not found",
):
result = await delete_frames(filename="test.ase", start_frame=1, end_frame=3)
result = await delete_frames(
filename="test.ase", start_frame=1, end_frame=3
)
assert "not found" in result

@pytest.mark.asyncio
async def test_delete_frames_success(self, mock_cli):
from aseprite_mcp.tools.animation import delete_frames

with patch("aseprite_mcp.tools.animation.check_file", return_value=None):
result = await delete_frames(filename="test.ase", start_frame=2, end_frame=4)
result = await delete_frames(
filename="test.ase", start_frame=2, end_frame=4
)
assert "Deleted frames 2-4" in result
mock_cli.execute_lua_script.assert_called_once()
script = mock_cli.execute_lua_script.call_args[0][0]
Expand All @@ -350,7 +358,9 @@ async def test_delete_frames_single_frame(self, mock_cli):
from aseprite_mcp.tools.animation import delete_frames

with patch("aseprite_mcp.tools.animation.check_file", return_value=None):
result = await delete_frames(filename="test.ase", start_frame=3, end_frame=3)
result = await delete_frames(
filename="test.ase", start_frame=3, end_frame=3
)
assert "Deleted frames 3-3" in result
script = mock_cli.execute_lua_script.call_args[0][0]
assert "for i = 3, 3, -1 do" in script
Expand All @@ -361,7 +371,9 @@ async def test_delete_frames_failure(self, mock_cli):

mock_cli.execute_lua_script.return_value = (False, "Aseprite error")
with patch("aseprite_mcp.tools.animation.check_file", return_value=None):
result = await delete_frames(filename="test.ase", start_frame=1, end_frame=2)
result = await delete_frames(
filename="test.ase", start_frame=1, end_frame=2
)
assert "Failed" in result


Expand Down
Loading
Loading