From 19a010515a2b650631220ba510df58ae90576442 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Tue, 22 Sep 2026 09:47:48 +0900 Subject: [PATCH 1/5] fix(video-set): refuse a --base direction the prompt table does not know An undocumented key such as left= was accepted and then animated with the default facing, so the caller's intent was inverted without a word. The parser now names the allowed directions (back, front, side) and refuses the rest. --- sprite_gen/video/batch.py | 5 ++++- tests/video/test_batch_bases.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/video/test_batch_bases.py diff --git a/sprite_gen/video/batch.py b/sprite_gen/video/batch.py index 0491d0a..97351aa 100644 --- a/sprite_gen/video/batch.py +++ b/sprite_gen/video/batch.py @@ -267,7 +267,10 @@ def _parse_bases(values: list[str]) -> dict[str, Path]: if "=" not in v: raise SystemExit(f"video-set: --base expects direction=path, got {v!r}") d, p = v.split("=", 1) - bases[d.strip()] = Path(p).expanduser().resolve() + d = d.strip() + if d not in VIEW_TEXT: + raise SystemExit(f"video-set: --base direction must be one of {', '.join(sorted(VIEW_TEXT))}, got {d!r}") + bases[d] = Path(p).expanduser().resolve() if not bases: raise SystemExit("video-set: at least one --base direction=path is required") return bases diff --git a/tests/video/test_batch_bases.py b/tests/video/test_batch_bases.py new file mode 100644 index 0000000..e29fb31 --- /dev/null +++ b/tests/video/test_batch_bases.py @@ -0,0 +1,31 @@ +"""`video-set --base` accepts only the directions the prompt table knows. + +An undocumented key such as `left=` used to be accepted silently and then +animated with the default facing, so the caller's intent was inverted without +a word. The parser now names the allowed directions and refuses the rest. +""" +from __future__ import annotations + +import pytest + +from sprite_gen.video import batch + + +def test_known_directions_parse(tmp_path): + still = tmp_path / "s.png" + still.write_bytes(b"") + bases = batch._parse_bases([f"side={still}", f"front={still}", f"back={still}"]) + assert set(bases) == {"side", "front", "back"} + + +@pytest.mark.parametrize("key", ["left", "right", "Side", "profile"]) +def test_unknown_direction_is_refused_by_name(key, tmp_path): + with pytest.raises(SystemExit) as exc: + batch._parse_bases([f"{key}={tmp_path / 's.png'}"]) + message = str(exc.value) + assert repr(key) in message and "back, front, side" in message + + +def test_missing_equals_still_refused(): + with pytest.raises(SystemExit): + batch._parse_bases(["side"]) From 2240052eb3cb5c6ac5d83131ba2d20db708b39b6 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Tue, 22 Sep 2026 09:47:49 +0900 Subject: [PATCH 2/5] test(facing): use a neutral orchestrator variable name in the scrub sample The scrub rule matches the _MEMBER_ID suffix, so the sample name carries no meaning; the old one named a private tool. --- tests/gen/test_facing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/gen/test_facing.py b/tests/gen/test_facing.py index 72650b6..af84866 100644 --- a/tests/gen/test_facing.py +++ b/tests/gen/test_facing.py @@ -218,7 +218,7 @@ def http(*a, **kw): def test_codex_vision_reads_new_answer_and_scrubs_identity(monkeypatch, tmp_path): seen = [] - monkeypatch.setenv("KUMA_MEMBER_ID", "synthetic") + monkeypatch.setenv("ORCHESTRATOR_MEMBER_ID", "synthetic") def run(command, **kwargs): seen.append((command, kwargs)) Path(command[command.index("-o") + 1]).write_text('right') @@ -227,7 +227,7 @@ def run(command, **kwargs): text, metadata = facing_vision.codex_inspect(FIXTURES / "left.png", tmp_path) assert text == "right" and metadata["auth_source"] == "codex-login" assert len(seen) == 1 and "--ephemeral" in seen[0][0] and "read-only" in seen[0][0] - assert "KUMA_MEMBER_ID" not in seen[0][1]["env"] + assert "ORCHESTRATOR_MEMBER_ID" not in seen[0][1]["env"] assert not list(tmp_path.iterdir()) From 1d057c53914c2a496fc4bb6ab88d1c54f207d593 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Tue, 22 Sep 2026 09:49:25 +0900 Subject: [PATCH 3/5] feat(video-canvas): keep room behind the subject on an attack canvas A long weapon drawn back before the strike reached the back edge of the wide layout, which placed every spare pixel in front, and the frames gate refused the clip. The attack profile now keeps 20 % of the width empty behind the subject (--trail, next to --lead); headroom and the other profiles are unchanged and --trail 0 restores the old placement. Measured on the two stills that needed hand-padding in v2.5.3: the samurai passes first try (seam 1.19) and the katana passes after one regeneration (seam 1.63), both from the raw still. --- CHANGELOG.md | 6 +++ docs/video-pipeline.md | 7 ++-- sprite_gen/video/canvas.py | 35 ++++++++++------- tests/video/test_batch_facing.py | 7 +++- tests/video/test_canvas_trail.py | 64 ++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 19 deletions(-) create mode 100644 tests/video/test_canvas_trail.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d64358d..1ae871a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable public changes to `sprite-gen` are recorded here. Versions track the `version:` field in `SKILL.md` and `pyproject.toml`. +## Unreleased (v2.5.4) + +- Attack canvases keep 20 % of their width empty behind the subject (`--trail`, a new wide-canvas margin next to `--lead`). A long weapon drawn back before the strike reached the back edge of the old layout and the frames gate refused the clip; the samurai and katana samples that needed hand-padded stills in v2.5.3 now pass from the raw still. Headroom, lead and every other profile are unchanged, and `--trail 0` restores the previous placement. +- `video-set --base` refuses a direction the prompt table does not know (`back`, `front`, `side`) instead of accepting `left=` and animating it with the default facing. +- The facing test suite names its orchestrator scrub sample neutrally. + ## v2.5.3 - Reference facing controls and observed action returns - Reference edits can opt into `gen --facing right|left` to request orientation in the generation prompt. The default `preserve` leaves existing generation callers unchanged. diff --git a/docs/video-pipeline.md b/docs/video-pipeline.md index f1230b1..bdf3498 100644 --- a/docs/video-pipeline.md +++ b/docs/video-pipeline.md @@ -36,12 +36,13 @@ is a property of the motion state, owned by one table (`STATE_CANVAS`): | State | Shape | Ratio | Room | Why | |---|---|---|---|---| | `jump` | tall | 3:4 | 34 % head-room above the still | airborne frames need height | -| `attack` | wide | 16:9 | 35 % above the still, at least 28 % in front (facing side) | weapon swings rise overhead and extend in front | +| `attack` | wide | 16:9 | 35 % above the still, at least 28 % in front (facing side), 20 % behind | weapon swings rise overhead and extend in front; a long weapon drawn back reaches behind | | `projectile` | wide | 16:9 | 34 % in front | the projectile travels away | | everything else | square | 1:1 | — | in-place motion fits the still | -`--shape tall|wide|square` overrides the row; `--headroom` / `--lead` tune the room; -`--facing left` mirrors the wide layout. Headroom is a fraction of the full canvas +`--shape tall|wide|square` overrides the row; `--headroom` / `--lead` / `--trail` tune the room +(`--trail` is the empty fraction of the width kept behind the subject, for a weapon drawn +back before the strike); `--facing left` mirrors the wide layout. Headroom is a fraction of the full canvas height; wide canvases grow both dimensions to preserve their ratio without shrinking the still. A still whose corners are not one flat colour is refused — a non-flat background cannot be extended without guessing. diff --git a/sprite_gen/video/canvas.py b/sprite_gen/video/canvas.py index 5a86da5..5972abe 100644 --- a/sprite_gen/video/canvas.py +++ b/sprite_gen/video/canvas.py @@ -48,21 +48,22 @@ class CanvasProfile: ratio: float # width / height headroom: float # fraction of the canvas height kept empty ABOVE the still (tall/wide) lead: float # fraction of the canvas width kept empty IN FRONT of the subject (wide) + trail: float # fraction of the canvas width kept empty BEHIND the subject (wide) why: str # The one table. Keys are state names as the sprite-request uses them; unknown # states fall through to `default`. STATE_CANVAS: dict[str, CanvasProfile] = { - "jump": CanvasProfile(SHAPE_TALL, 3 / 4, 0.34, 0.0, "airborne frames need head-room; hair clipped at 1:1"), - "attack": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.35, 0.28, "weapon swings rise overhead and extend in front"), - "projectile": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.0, 0.34, "projectile travels away from the body"), + "jump": CanvasProfile(SHAPE_TALL, 3 / 4, 0.34, 0.0, 0.0, "airborne frames need head-room; hair clipped at 1:1"), + "attack": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.35, 0.28, 0.2, "weapon swings rise overhead and extend in front; a long weapon drawn back reaches behind"), + "projectile": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.0, 0.34, 0.0, "projectile travels away from the body"), # Raised-limb celebrations leave a square frame at the top corners; wide with a # symmetric margin keeps them inside (lead applies in front, the rest pads the back). - "cheer": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.0, 0.30, "arms raised and spread leave a 1:1 frame"), - "wave": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.0, 0.30, "a raised waving arm leaves a 1:1 frame"), - "celebrate": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.0, 0.30, "same envelope as cheer"), - "default": CanvasProfile(SHAPE_SQUARE, 1.0, 0.0, 0.0, "in-place motion fits the still's own frame"), + "cheer": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.0, 0.30, 0.0, "arms raised and spread leave a 1:1 frame"), + "wave": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.0, 0.30, 0.0, "a raised waving arm leaves a 1:1 frame"), + "celebrate": CanvasProfile(SHAPE_WIDE, 16 / 9, 0.0, 0.30, 0.0, "same envelope as cheer"), + "default": CanvasProfile(SHAPE_SQUARE, 1.0, 0.0, 0.0, 0.0, "in-place motion fits the still's own frame"), } SHAPE_DEFAULTS: dict[str, CanvasProfile] = { SHAPE_SQUARE: STATE_CANVAS["default"], @@ -161,6 +162,7 @@ def pad_canvas( facing: str = "right", headroom: float | None = None, lead: float | None = None, + trail: float | None = None, key: str = "auto", ) -> tuple[Image.Image, dict[str, Any]]: """Return (padded RGB image, placement report). Never downsizes the still. @@ -178,8 +180,9 @@ def pad_canvas( w, h = src.size head = profile.headroom if headroom is None else headroom front = profile.lead if lead is None else lead - if not 0 <= head < 0.9 or not 0 <= front < 0.9: - raise SystemExit("video-canvas: --headroom/--lead must be in [0, 0.9)") + back = profile.trail if trail is None else trail + if not 0 <= head < 0.9 or not 0 <= front < 0.9 or not 0 <= back < 0.9 or not front + back < 0.9: + raise SystemExit("video-canvas: --headroom/--lead/--trail must be in [0, 0.9) and lead + trail below 0.9") if profile.shape == SHAPE_SQUARE: side = max(w, h) canvas_w, canvas_h = side, side @@ -190,12 +193,13 @@ def pad_canvas( canvas_h = max(h, round(h / (1 - head)), round(w / profile.ratio)) canvas_w = max(w, round(canvas_h * profile.ratio)) x, y = (canvas_w - w) // 2, canvas_h - h - else: # wide: extra width goes in front of the facing direction; at least the profile ratio + else: # wide: `trail` of the width stays empty behind the subject, the rest of the extra width goes in front; at least the profile ratio required_h = max(h, round(h / (1 - head))) - canvas_w = max(w, round(w / (1 - front)), round(required_h * profile.ratio)) + canvas_w = max(w, round(w / (1 - front - back)), round(required_h * profile.ratio)) canvas_h = max(h, round(canvas_w / profile.ratio)) y = canvas_h - h - x = 0 if facing == "right" else canvas_w - w + behind = min(round(canvas_w * back), canvas_w - w) + x = behind if facing == "right" else canvas_w - w - behind canvas = Image.new("RGB", (canvas_w, canvas_h), fill) canvas.paste(src, (x, y)) report = { @@ -206,6 +210,7 @@ def pad_canvas( "offset": [x, y], "headroom": head, "lead": front, + "trail": back, "facing": facing, "key_rgb": list(fill), "corner_rgb": list(corner), @@ -226,12 +231,13 @@ def run_canvas( lead: float | None, report_path: Path | None, key: str = "auto", + trail: float | None = None, ) -> dict[str, Any]: still = still.expanduser().resolve() if not still.is_file(): raise SystemExit(f"video-canvas: still not found: {still}") profile = profile_for(state, shape) - canvas, report = pad_canvas(Image.open(still), profile, facing=facing, headroom=headroom, lead=lead, key=key) + canvas, report = pad_canvas(Image.open(still), profile, facing=facing, headroom=headroom, lead=lead, trail=trail, key=key) out = out.expanduser().resolve() out.parent.mkdir(parents=True, exist_ok=True) tmp = out.with_name(out.name + ".part") @@ -251,6 +257,7 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--facing", choices=("right", "left"), default="right", help="which way the subject faces (wide canvases add room in front)") parser.add_argument("--headroom", type=float, help="tall/wide: empty fraction of canvas height above the still (default from the profile)") parser.add_argument("--lead", type=float, help="wide: empty fraction in front of the subject (default from the profile)") + parser.add_argument("--trail", type=float, help="wide: empty fraction behind the subject, for a weapon drawn back (default from the profile)") parser.add_argument("--key", choices=KEYS, default="auto", help="chroma key of the still (auto reads the corners; green/magenta are normalized to the exact key; white pads with the corner colour)") parser.add_argument("--report", type=Path, help="write the canvas report JSON here") @@ -259,7 +266,7 @@ def run(**kwargs: object) -> int: payload = run_canvas( Path(str(kwargs["still"])), Path(str(kwargs["out"])), state=kwargs.get("state"), shape=kwargs.get("shape"), facing=str(kwargs.get("facing") or "right"), # type: ignore[arg-type] - headroom=kwargs.get("headroom"), lead=kwargs.get("lead"), report_path=kwargs.get("report"), # type: ignore[arg-type] + headroom=kwargs.get("headroom"), lead=kwargs.get("lead"), trail=kwargs.get("trail"), report_path=kwargs.get("report"), # type: ignore[arg-type] key=str(kwargs.get("key") or "auto"), ) print(json.dumps(payload, ensure_ascii=False, indent=2)) diff --git a/tests/video/test_batch_facing.py b/tests/video/test_batch_facing.py index b6bb3d5..ccfbbc9 100644 --- a/tests/video/test_batch_facing.py +++ b/tests/video/test_batch_facing.py @@ -46,11 +46,14 @@ def video(image, prompt, out, report, **kw): if not options: assert (tmp_path / "side.facing.png").read_bytes() == source_bytes assert result["items"][0]["facing"]["final_direction_source"] == "observation" - assert (canvas["offset"][0] > 0) == (facing == "left") + behind = round(canvas["canvas"][0] * canvas["trail"]) + assert canvas["facing"] == facing + assert canvas["offset"][0] == (behind if facing == "right" else canvas["canvas"][0] - canvas["still"][0] - behind) else: assert "facing" not in result["items"][0] assert prompts[0] == batch.build_prompt(direction, "attack", None, facing="right") - assert canvas["offset"][0] == 0 + assert canvas["facing"] == "right" + assert canvas["offset"][0] == round(canvas["canvas"][0] * canvas["trail"]) def test_changed_facing_cannot_reuse_an_opposite_prompt_clip(tmp_path, offline, monkeypatch): diff --git a/tests/video/test_canvas_trail.py b/tests/video/test_canvas_trail.py new file mode 100644 index 0000000..6970fea --- /dev/null +++ b/tests/video/test_canvas_trail.py @@ -0,0 +1,64 @@ +"""A wide canvas keeps `trail` of its width empty behind the subject. + +A long weapon drawn back before a strike reaches behind the body; with every +spare pixel placed in front, that swing met the back edge and the frames gate +refused the clip. The attack profile now reserves room on both sides. +""" +from __future__ import annotations + +import pytest +from PIL import Image + +from sprite_gen.video import canvas + + +def _still(w: int = 200, h: int = 200) -> Image.Image: + img = Image.new("RGB", (w, h), (0, 255, 0)) + img.paste((120, 80, 40), (60, 40, 140, 200)) + return img + + +def test_attack_profile_reserves_room_behind_and_reports_it(): + padded, report = canvas.pad_canvas(_still(), canvas.STATE_CANVAS["attack"], facing="right") + canvas_w, canvas_h = padded.size + x, y = report["offset"] + assert report["trail"] == canvas.STATE_CANVAS["attack"].trail > 0 + assert x == round(canvas_w * report["trail"]) + assert x + 200 <= canvas_w and y == canvas_h - 200 + # still pixels are preserved at the offset + assert padded.getpixel((x + 100, y + 100)) == (120, 80, 40) + assert padded.getpixel((x // 2, canvas_h - 1)) == (0, 255, 0) + + +def test_trail_mirrors_for_a_left_facing_subject(): + profile = canvas.STATE_CANVAS["attack"] + _, right = canvas.pad_canvas(_still(), profile, facing="right") + padded, left = canvas.pad_canvas(_still(), profile, facing="left") + canvas_w = padded.size[0] + assert left["canvas"] == right["canvas"] + assert left["offset"][0] == canvas_w - 200 - right["offset"][0] + + +def test_trail_zero_keeps_the_old_placement(): + _, report = canvas.pad_canvas(_still(), canvas.STATE_CANVAS["attack"], facing="right", trail=0.0) + assert report["offset"][0] == 0 and report["trail"] == 0.0 + + +def test_other_wide_profiles_are_unchanged(): + for state in ("projectile", "cheer", "wave", "celebrate"): + _, report = canvas.pad_canvas(_still(), canvas.STATE_CANVAS[state], facing="right") + assert report["trail"] == 0.0 and report["offset"][0] == 0 + + +def test_lead_and_trail_are_bounded(): + with pytest.raises(SystemExit): + canvas.pad_canvas(_still(), canvas.STATE_CANVAS["attack"], trail=0.95) + with pytest.raises(SystemExit): + canvas.pad_canvas(_still(), canvas.STATE_CANVAS["attack"], lead=0.5, trail=0.45) + + +def test_cli_trail_reaches_the_report(tmp_path): + still = tmp_path / "still.png" + _still().save(still) + payload = canvas.run_canvas(still, tmp_path / "out.png", state="attack", shape=None, facing="right", headroom=None, lead=None, report_path=tmp_path / "r.json", trail=0.1) + assert payload["trail"] == 0.1 and payload["offset"][0] == round(payload["canvas"][0] * 0.1) From a69785d5849b49467696563e508d2fd2d4924439 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Tue, 22 Sep 2026 10:11:49 +0900 Subject: [PATCH 4/5] test(video-canvas): the wide layout keeps room behind as well as in front --- tests/video/test_video_pipeline.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/video/test_video_pipeline.py b/tests/video/test_video_pipeline.py index b3297f9..cc9458a 100644 --- a/tests/video/test_video_pipeline.py +++ b/tests/video/test_video_pipeline.py @@ -62,7 +62,9 @@ def test_wide_canvas_puts_room_in_front_of_the_facing(tmp_path: Path) -> None: still = Image.open(_still(tmp_path)) right, rep_r = canvas_mod.pad_canvas(still, canvas_mod.profile_for("attack"), facing="right") left, rep_l = canvas_mod.pad_canvas(still, canvas_mod.profile_for("attack"), facing="left") - assert rep_r["offset"][0] == 0 and rep_l["offset"][0] == left.width - 120 + behind = round(right.width * rep_r["trail"]) + assert behind > 0 and rep_r["offset"][0] == behind and rep_l["offset"][0] == left.width - 120 - behind + assert right.width - 120 - behind > behind # more room in front than behind assert abs(right.width / right.height - 16 / 9) < 0.02 @@ -80,9 +82,9 @@ def test_attack_canvas_reserves_overhead_room_and_reports_placement(tmp_path: Pa assert image.crop((x, y, x + 120, y + 160)).tobytes() == Image.open(still).tobytes() assert image.crop((0, 0, image.width, y)).getextrema() == ((0, 0), (255, 255), (0, 0)) assert json.loads(report.read_text()) == rep - assert rep["why"] == "weapon swings rise overhead and extend in front" - # An explicit zero still gives the pre-headroom wide layout. - zero, zero_rep = canvas_mod.pad_canvas(Image.open(still), canvas_mod.profile_for("attack"), headroom=0) + assert rep["why"] == "weapon swings rise overhead and extend in front; a long weapon drawn back reaches behind" + # Explicit zeros still give the pre-headroom, pre-trail wide layout. + zero, zero_rep = canvas_mod.pad_canvas(Image.open(still), canvas_mod.profile_for("attack"), headroom=0, trail=0) assert zero.size == (284, 160) and zero_rep["offset"] == [0, 0] From 33d46ee51cf4f03fb34d10c437b5d316efe78de9 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Tue, 22 Sep 2026 10:17:45 +0900 Subject: [PATCH 5/5] release: v2.5.4 --- CHANGELOG.md | 2 +- SKILL.md | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae871a..60e9c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable public changes to `sprite-gen` are recorded here. Versions track the `version:` field in `SKILL.md` and `pyproject.toml`. -## Unreleased (v2.5.4) +## v2.5.4 - Room behind the attack canvas - Attack canvases keep 20 % of their width empty behind the subject (`--trail`, a new wide-canvas margin next to `--lead`). A long weapon drawn back before the strike reached the back edge of the old layout and the frames gate refused the clip; the samurai and katana samples that needed hand-padded stills in v2.5.3 now pass from the raw still. Headroom, lead and every other profile are unchanged, and `--trail 0` restores the previous placement. - `video-set --base` refuses a direction the prompt table does not know (`back`, `front`, `side`) instead of accepting `left=` and animating it with the default facing. diff --git a/SKILL.md b/SKILL.md index d76faf0..361a5cc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: sprite-gen -version: 2.5.3 +version: 2.5.4 description: "Generates images and game sprites through GPT or Grok with guided provider choices, separate saved defaults, automatic cleanup and optional curation. Handles sprite requests, ordinary image generation/editing, standalone image-to-video clips (i2v, animate this still, 그록 영상, 이매진 비디오, 스틸 움직여줘, first/last frame, reference-to-video, 영상 이어붙이기, 영상 편집, extend/edit a clip), chroma removal, animation atlases, video loops, 큐레이션뷰, image candidates, 팔레트 스왑, palette swap, recolor, rig layers, engine exports, repeating backgrounds, projected shadows, motion/contact inspection and optional scene composition from existing assets." license: Apache-2.0 depends_on: diff --git a/pyproject.toml b/pyproject.toml index 62a76d0..6c3e1f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ build-backend = "setuptools.build_meta" name = "sprite-gen" # Release discipline: keep this package metadata version synchronized with # SKILL.md's `version:` field in the same release commit. -version = "2.5.3" +version = "2.5.4" description = "Component-row pipeline for clean 2D game sprites and animation atlases" readme = "README.md" license = "Apache-2.0"