diff --git a/README.md b/README.md index 7a13c70c..8c95335b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The two sprite pipelines are ordered generation flows. Tool groups contain indep flowchart LR subgraph A["A · atlas rows"] direction LR - a1[prepare] --> a2["gen · gen-set"] --> a3[extract] --> a5[compose-atlas] + a1[prepare] --> a2["gen · gen-set"] --> a3[extract] --> a5[compose-atlas] --> a6[compact-atlas optional] a5 -.-> a4["curation (optional)"] a4 --> a5 end @@ -75,7 +75,7 @@ flowchart LR | Pipeline / tool group / workflow | What goes in → what comes out | Docs | |---|---|---| -| **A · atlas rows** | one still + a list of states → `sprite-sheet-alpha.png` + `manifest.json.frame_layout`, with **Breathe** baked on idle poses | [run-contract](docs/run-contract.md) · [breathing](docs/breathing.md) | +| **A · atlas rows** | `prepare` · `gen` · `gen-set` · `extract` · `compose-atlas` · `compact-atlas` · `curation`; one still + states → runtime atlas pages + `manifest.frame_layout` | [run-contract](docs/run-contract.md) · [compact-atlas](docs/compact-atlas.md) · [breathing](docs/breathing.md) | | **B · video → loop** | one still → per state, a seamless transparent GIF / WebP / strip, animated by Grok Imagine and cut at its true period | [video-pipeline](docs/video-pipeline.md) · [video](docs/video.md) | | **C · utilities** | an imported image or grid sheet → clean transparent cuts; a finished atlas → a curator-ready run | [sheet-slicing](docs/sheet-slicing.md) · [curation](docs/curation.md) | | **D · post-processing** | a finished sheet → deterministic colourways, rig layer composites, Aseprite / Phaser / Flame exports | [recolor](docs/recolor.md) · [layer-tracks](docs/layer-tracks.md) · [engine-export](docs/engine-export.md) | @@ -112,6 +112,7 @@ sprite-gen prepare --out-dir --character-id --base-image base.png # sprite-gen gen-set --run-dir --provider codex # every state row, 4 at a time sprite-gen extract --run-dir # chroma → transparent frames sprite-gen compose-atlas --run-dir # sprite-sheet-alpha.png + manifest.json +sprite-gen compact-atlas --run-dir --page-size 2048 --max-pages 4 # pages + manifest.compact.json sprite-gen curation --run-dir # (optional) pick, nudge, breathe ``` diff --git a/SKILL.md b/SKILL.md index d76faf09..a2177732 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: sprite-gen -version: 2.5.3 +version: 2.6.0 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: @@ -23,6 +23,7 @@ depends_on: - scripts/extract_sprite_row_frames.py - scripts/interpolate_frames.py - scripts/compose_sprite_atlas.py + - scripts/compact_sprite_atlas.py - scripts/preview_animation.py - scripts/compose_selected_cycle.py - scripts/compose_sprite_gif.py @@ -86,7 +87,7 @@ front-facing observations also remain unchanged. Mirroring does not preserve lef | Task | Entry | Contract | |---|---|---| -| GPT image sprites | `prepare`, `gen-set --provider codex`, `extract`, compose and QA | [atlas-workflow](docs/atlas-workflow.md) | +| GPT image sprites | `prepare`, `gen-set --provider codex`, `extract`, compose, optional compact packing and QA | [atlas-workflow](docs/atlas-workflow.md) · [compact-atlas](docs/compact-atlas.md) | | Standalone video / animate a still, pin a last frame, reference images | `video` (`--image`, `--last-frame`, `--reference`) | [video](docs/video.md) | | Continue or edit an existing clip | `video-extend`, `video-edit` | [video](docs/video.md) | | Grok video sprites | `video-set` | [video-pipeline](docs/video-pipeline.md) | diff --git a/docs/README.md b/docs/README.md index 0ffa2127..1735008f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ the code domains describe ownership rather than an execution order. flowchart LR subgraph A["A · atlas rows"] direction LR - a1[prepare] --> a2["gen · gen-set"] --> a3[extract] --> a5[compose-atlas] + a1[prepare] --> a2["gen · gen-set"] --> a3[extract] --> a5[compose-atlas] --> a6[compact-atlas optional] a5 -.-> a4["curation (optional)"] a4 --> a5 end @@ -36,7 +36,7 @@ flowchart LR | Pipeline / tool group / workflow | Entry doc | Verbs | |---|---|---| -| **A · atlas rows** — one still becomes a runtime sprite sheet | [run-contract.md](run-contract.md) | `prepare` → `gen` / `gen-set` → `extract` → `compose-atlas`; optional `curation` and recompose | +| **A · atlas rows** — one still becomes runtime atlas pages | [run-contract.md](run-contract.md) · [compact-atlas.md](compact-atlas.md) | `prepare` → `gen` / `gen-set` → `extract` → `compose-atlas` → optional `compact-atlas`; optional `curation` and recompose | | **B · video → loop** — one still becomes transparent motion loops | [video-pipeline.md](video-pipeline.md) | `video-canvas` → `video` → `video-frames` → `video-loop`, `video-set` | | **C · utilities** — imported images in, clean cuts out | [sheet-slicing.md](sheet-slicing.md) | `cutout`, `slice-sheet`, `unpack-atlas` | | **D · post-processing** — finished sheets, refined | [recolor.md](recolor.md) | `recolor`, `recolor-palette`, `compose-layers`, breathing (compose), `export-pngs`, `export-aseprite` | diff --git a/docs/atlas-workflow.md b/docs/atlas-workflow.md index 4c3a0715..f80b4b33 100644 --- a/docs/atlas-workflow.md +++ b/docs/atlas-workflow.md @@ -20,8 +20,13 @@ $SPRITE_GEN_ROOT/.venv/bin/sprite-gen prepare --out-dir /absolute/run \ $SPRITE_GEN_ROOT/.venv/bin/sprite-gen gen-set --run-dir /absolute/run --provider codex $SPRITE_GEN_ROOT/.venv/bin/sprite-gen extract --run-dir /absolute/run $SPRITE_GEN_ROOT/.venv/bin/sprite-gen compose-atlas --run-dir /absolute/run +$SPRITE_GEN_ROOT/.venv/bin/sprite-gen compact-atlas --run-dir /absolute/run \ + --page-size 2048 --max-pages 4 --max-empty-percent 25 $SPRITE_GEN_ROOT/.venv/bin/sprite-gen compose-gif --run-dir /absolute/run --out-dir /absolute/run/previews $SPRITE_GEN_ROOT/.venv/bin/sprite-gen inspect --run-dir /absolute/run ``` The runtime consumes `manifest.json.frame_layout`; it does not infer a grid from alpha. AI operates only at generation. Frame cleanup and atlas assembly remain deterministic, with one writer per run and atomic publication. [architecture](architecture.md) explains these boundaries. [subject-profiles](subject-profiles.md) covers effects and sparse subjects. [breathing](breathing.md), [recolor](recolor.md) and [layer-tracks](layer-tracks.md) own optional post-processing; none is an extra default question for every sprite request. + +Use [compact-atlas](compact-atlas.md) only after the last compose. It writes a separate +`manifest.compact.json`, so curation/recompose remains non-destructive. diff --git a/docs/compact-atlas.md b/docs/compact-atlas.md new file mode 100644 index 00000000..ae75f0ca --- /dev/null +++ b/docs/compact-atlas.md @@ -0,0 +1,31 @@ +# Compact atlas pages + +`compact-atlas` is a deterministic post-compose tool. It trims transparent pixels +without resampling, packs the resulting rectangles into equal square pages, and writes +the page index plus logical-cell offsets into a new manifest. + +```bash +sprite-gen compact-atlas --run-dir \ + --page-size 2048 --max-pages 4 --gutter 2 --alpha-padding 1 \ + --max-empty-percent 25 +``` + +The default policy keeps every animation clip on one Texture2DArray slice. Empty space +is measured against all allocated fixed-size slices, including gutter. If that ratio is +above `--max-empty-percent`, the report records `thresholdExceeded: true` but still keeps +clip locality. + +Add `--allow-clip-split-over-threshold` to opt into a second global MaxRects pass only +when the threshold is exceeded. That pass may place frames from one clip on multiple +slices. `compact-atlas.report.json.clipsSpanningPages` lists every affected clip, so the +choice is observable rather than implicit. + +Outputs are non-destructive: + +- `-0.png` through `-N.png`, each exactly `page-size` square. +- `manifest.compact.json`, whose frame rects contain `page`, `x/y/w/h`, and + `sourceX/sourceY`. +- `compact-atlas.report.json`, including occupancy, policy, page count and split clips. + +The original `sprite-sheet-alpha.png` and `manifest.json` remain compose/correction SOTs. +Installers copy the compact manifest under the runtime name only after QA. diff --git a/docs/run-contract.md b/docs/run-contract.md index 383b9fdf..88b641d5 100644 --- a/docs/run-contract.md +++ b/docs/run-contract.md @@ -44,6 +44,7 @@ canonical files, not hidden imports. | Extract | `extract_sprite_row_frames.py` | `raw/.png` | on success: `frames//frame-N.png` (+ `.plain.png` twin on pixel-unfake runs), `frames/frames-manifest.json`; on failure: nothing in `frames/`, `extract-failure.json` instead (§6) | | Curate (opt) | `sprite-gen curation` (`serve_curation.py`) + `curation.py` | `frames/` | `curation.json` sidecar | | Compose | `compose_sprite_atlas.py` | `frames/` + `curation.json` | `sprite-sheet-alpha.png`, `manifest.json`, `*.report.json` | +| Compact (opt) | `compact_sprite_atlas.py` | composed atlas + `manifest.json.frame_layout` | fixed-size page PNGs, `manifest.compact.json`, `compact-atlas.report.json` | | Engine export (opt) | `sprite-gen export-aseprite` (`sprite_gen/compose/export_aseprite.py`) | composed `manifest.json` + existing atlas | `exports/aseprite.json`, or `exports/aseprite/.json` for Flame | | Recolor (opt) | `sprite-gen recolor` / `recolor-palette` (`sprite_gen/effects/recolor.py`) | base sheet (default `sprite-sheet-alpha.png`) + recolor spec | `variants/.png`, optional `variants/.manifest.json`, `variants/recolor.report.json` | | Layer bake (opt) | `sprite-gen compose-layers` (`sprite_gen/compose/compose_layers.py`) | `frames/` + `curation.json` + the request's `rig` / `layers` | `layers/.png`, `layers/.manifest.json`, `layers/layers.report.json` (published as one set) | diff --git a/pyproject.toml b/pyproject.toml index 62a76d08..9e8c395f 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.6.0" description = "Component-row pipeline for clean 2D game sprites and animation atlases" readme = "README.md" license = "Apache-2.0" diff --git a/scripts/compact_sprite_atlas.py b/scripts/compact_sprite_atlas.py new file mode 100644 index 00000000..165737e7 --- /dev/null +++ b/scripts/compact_sprite_atlas.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Backward-compatible wrapper for sprite_gen.compose.compact_atlas.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import sprite_gen.compose.compact_atlas as _impl + +globals().update({name: value for name, value in vars(_impl).items() + if name not in {"__name__", "__package__", "__loader__", "__spec__"}}) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sprite_gen/_modules.py b/sprite_gen/_modules.py index 2250f0ff..520c6631 100644 --- a/sprite_gen/_modules.py +++ b/sprite_gen/_modules.py @@ -35,6 +35,7 @@ 'curation': 'curate', 'anchor': 'curate', 'compose_atlas': 'compose', + 'compact_atlas': 'compose', 'compose_cycle': 'compose', 'compose_gif': 'compose', 'compose_layers': 'compose', @@ -83,8 +84,8 @@ # The CLI help, the docs index and the README # pipeline table are checked against THIS list; a verb named here must exist as a verb. PIPELINES: list[dict[str, object]] = [ - {"key": "A", "name": "atlas rows", "verbs": ["prepare", "gen", "gen-set", "extract", "compose-atlas", "curation"], - "chain": "prepare → gen (or gen-set) → extract → compose-atlas; optional curation and recompose", "doc": "docs/run-contract.md"}, + {"key": "A", "name": "atlas rows", "verbs": ["prepare", "gen", "gen-set", "extract", "compose-atlas", "compact-atlas", "curation"], + "chain": "prepare → gen (or gen-set) → extract → compose-atlas → optional compact-atlas; optional curation and recompose", "doc": "docs/run-contract.md"}, {"key": "B", "name": "video → loop", "verbs": ["video-canvas", "video", "video-frames", "video-loop", "video-set"], "chain": "video-canvas → video → video-frames → video-loop, or video-set", "doc": "docs/video-pipeline.md"}, ] diff --git a/sprite_gen/cli.py b/sprite_gen/cli.py index 08a6ea3d..6126bb10 100644 --- a/sprite_gen/cli.py +++ b/sprite_gen/cli.py @@ -10,7 +10,7 @@ from sprite_gen import _modules, gen from sprite_gen.background import tile from sprite_gen.curate import anchor -from sprite_gen.compose import compose_atlas, compose_cycle, compose_gif, compose_layers, export_aseprite, export_pngs +from sprite_gen.compose import compact_atlas, compose_atlas, compose_cycle, compose_gif, compose_layers, export_aseprite, export_pngs from sprite_gen.qa import correction_loop, inspect, preview, score, motion from sprite_gen.frames import cutout, extract, slice_sheet, unpack_atlas from sprite_gen.gen import gen_set, prepare, video @@ -244,6 +244,11 @@ def _add_correction_loop(p: argparse.ArgumentParser) -> None: _add_compose_atlas, compose_atlas.run, ), + "compact-atlas": ( + "Trim and pack a composed atlas into Texture2DArray-compatible pages.", + compact_atlas.add_arguments, + compact_atlas.run, + ), "preview": ("Build motion-QA previews for a sprite-gen run.", _add_preview, preview.run), "compose-cycle": ( "Compose a QA-approved manual frame subset into a selected cycle.", diff --git a/sprite_gen/compose/compact_atlas.py b/sprite_gen/compose/compact_atlas.py new file mode 100644 index 00000000..36ccb385 --- /dev/null +++ b/sprite_gen/compose/compact_atlas.py @@ -0,0 +1,376 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Trim and deterministically pack a composed atlas into Texture2DArray pages.""" + +from __future__ import annotations + +import argparse +import copy +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +from PIL import Image + +from sprite_gen.spec.runio import acquire_run_dir_lock, atomic_save_image, atomic_write_text + + +@dataclass(frozen=True) +class Rect: + x: int + y: int + w: int + h: int + + +@dataclass +class Item: + key: tuple[int, int, int, int] + image: Image.Image + source_x: int + source_y: int + states: set[str] + page: int = -1 + x: int = -1 + y: int = -1 + + @property + def area(self) -> int: + return self.image.width * self.image.height + + +class MaxRectsPage: + """Deterministic best-short-side-fit MaxRects page without rotation.""" + + def __init__(self, size: int): + self.size = size + self.free = [Rect(0, 0, size, size)] + + def clone(self) -> "MaxRectsPage": + other = MaxRectsPage(self.size) + other.free = list(self.free) + return other + + def insert(self, width: int, height: int) -> Rect | None: + candidates: list[tuple[int, int, int, int, Rect]] = [] + for free in self.free: + if width > free.w or height > free.h: + continue + leftover_x = free.w - width + leftover_y = free.h - height + candidates.append((min(leftover_x, leftover_y), max(leftover_x, leftover_y), free.y, free.x, free)) + if not candidates: + return None + + _, _, _, _, free = min(candidates) + placed = Rect(free.x, free.y, width, height) + self._split(placed) + self._prune() + return placed + + def _split(self, used: Rect) -> None: + next_free: list[Rect] = [] + for free in self.free: + if (used.x >= free.x + free.w or used.x + used.w <= free.x + or used.y >= free.y + free.h or used.y + used.h <= free.y): + next_free.append(free) + continue + if used.x > free.x: + next_free.append(Rect(free.x, free.y, used.x - free.x, free.h)) + if used.x + used.w < free.x + free.w: + next_free.append(Rect(used.x + used.w, free.y, + free.x + free.w - used.x - used.w, free.h)) + if used.y > free.y: + next_free.append(Rect(free.x, free.y, free.w, used.y - free.y)) + if used.y + used.h < free.y + free.h: + next_free.append(Rect(free.x, used.y + used.h, free.w, + free.y + free.h - used.y - used.h)) + self.free = [rect for rect in next_free if rect.w > 0 and rect.h > 0] + + def _prune(self) -> None: + kept: list[Rect] = [] + for index, rect in enumerate(self.free): + contained = False + for other_index, other in enumerate(self.free): + if index == other_index: + continue + if (rect.x >= other.x and rect.y >= other.y + and rect.x + rect.w <= other.x + other.w + and rect.y + rect.h <= other.y + other.h): + contained = True + break + if not contained and rect not in kept: + kept.append(rect) + self.free = kept + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--run-dir", required=True, type=Path) + parser.add_argument("--manifest", default="manifest.json") + parser.add_argument("--output-manifest", default="manifest.compact.json") + parser.add_argument("--report", default="compact-atlas.report.json") + parser.add_argument("--page-prefix", default="sprite-sheet-alpha") + parser.add_argument("--page-size", type=int, default=2048) + parser.add_argument("--max-pages", type=int, default=4) + parser.add_argument("--gutter", type=int, default=2) + parser.add_argument("--alpha-padding", type=int, default=1) + parser.add_argument("--max-empty-percent", type=float, default=25.0) + parser.add_argument("--allow-clip-split-over-threshold", action="store_true") + + +def _namespace_from_kwargs(**kwargs: object) -> argparse.Namespace: + parser = argparse.ArgumentParser(add_help=False) + add_arguments(parser) + values: dict[str, object] = {} + remaining = dict(kwargs) + for action in parser._actions: + if action.dest == "help": + continue + value = remaining.pop(action.dest, action.default) + if getattr(action, "required", False) and value is None: + raise TypeError(f"missing required argument: {action.dest}") + values[action.dest] = value + if remaining: + raise TypeError(f"unexpected keyword argument(s): {', '.join(sorted(remaining))}") + return argparse.Namespace(**values) + + +def _validate_options(args: argparse.Namespace) -> None: + if args.page_size <= 0 or args.max_pages <= 0: + raise SystemExit("page-size and max-pages must be positive") + if args.gutter < 0 or args.alpha_padding < 0: + raise SystemExit("gutter and alpha-padding must be non-negative") + if not 0 <= args.max_empty_percent <= 100: + raise SystemExit("max-empty-percent must be between 0 and 100") + + +def _source_atlas(manifest: dict[str, Any], run_dir: Path) -> Path: + name = manifest.get("sprite_sheet_alpha") or manifest.get("game_input") + if not isinstance(name, str) or not name: + raise SystemExit("manifest has no sprite_sheet_alpha/game_input") + path = run_dir / name + if not path.is_file(): + raise SystemExit(f"source atlas does not exist: {path}") + return path + + +def _trim(source: Image.Image, rect: dict[str, Any], padding: int) -> tuple[Image.Image, int, int]: + x, y = int(rect["x"]), int(rect["y"]) + width, height = int(rect["w"]), int(rect["h"]) + cell = source.crop((x, y, x + width, y + height)) + bbox = cell.getchannel("A").getbbox() + if bbox is None: + raise SystemExit(f"frame rect {x},{y},{width},{height} is fully transparent") + left = max(0, bbox[0] - padding) + top = max(0, bbox[1] - padding) + right = min(width, bbox[2] + padding) + bottom = min(height, bbox[3] + padding) + source_x = int(rect.get("sourceX", 0)) + left + source_y = int(rect.get("sourceY", 0)) + top + return cell.crop((left, top, right, bottom)), source_x, source_y + + +def _load_items(manifest: dict[str, Any], source: Image.Image, padding: int) -> tuple[dict[tuple[int, int, int, int], Item], dict[str, list[Item]]]: + rows = manifest.get("frame_layout", {}).get("rows") + if not isinstance(rows, dict) or not rows: + raise SystemExit("manifest.frame_layout.rows is required") + items: dict[tuple[int, int, int, int], Item] = {} + clips: dict[str, list[Item]] = {} + for state, frames in rows.items(): + if not isinstance(frames, list) or not frames: + raise SystemExit(f"frame_layout.rows.{state} must be a non-empty array") + clip: list[Item] = [] + for rect in frames: + key = (int(rect["x"]), int(rect["y"]), int(rect["w"]), int(rect["h"])) + item = items.get(key) + if item is None: + image, source_x, source_y = _trim(source, rect, padding) + item = Item(key, image, source_x, source_y, set()) + items[key] = item + item.states.add(state) + clip.append(item) + clips[state] = clip + return items, clips + + +def _unique_items(items: Iterable[Item]) -> list[Item]: + unique = {item.key: item for item in items} + return sorted(unique.values(), key=lambda item: (-max(item.image.size), -item.area, item.key)) + + +def _place_sequence(page: MaxRectsPage, items: list[Item], gutter: int) -> tuple[MaxRectsPage, list[Rect]] | None: + trial = page.clone() + placements: list[Rect] = [] + for item in _unique_items(items): + placed = trial.insert(item.image.width + gutter * 2, item.image.height + gutter * 2) + if placed is None: + return None + placements.append(placed) + return trial, placements + + +def _commit(items: list[Item], placements: list[Rect], page_index: int, gutter: int) -> None: + for item, rect in zip(_unique_items(items), placements): + item.page = page_index + item.x = rect.x + gutter + item.y = rect.y + gutter + + +def _pack_clips(clips: dict[str, list[Item]], page_size: int, gutter: int, max_pages: int) -> list[MaxRectsPage]: + pages: list[MaxRectsPage] = [] + groups = sorted(clips.items(), key=lambda pair: ( + -sum(item.area for item in _unique_items(pair[1])), pair[0])) + for state, group in groups: + candidates: list[tuple[int, MaxRectsPage, list[Rect]]] = [] + for index, page in enumerate(pages): + result = _place_sequence(page, group, gutter) + if result is not None: + candidates.append((index, result[0], result[1])) + if candidates: + index, trial, placements = min(candidates, key=lambda value: value[0]) + pages[index] = trial + _commit(group, placements, index, gutter) + continue + if len(pages) >= max_pages: + raise SystemExit(f"clip-preserving pack needs more than {max_pages} pages (failed at {state})") + page = MaxRectsPage(page_size) + result = _place_sequence(page, group, gutter) + if result is None: + raise SystemExit(f"clip {state} does not fit one {page_size}x{page_size} page") + pages.append(result[0]) + _commit(group, result[1], len(pages) - 1, gutter) + return pages + + +def _pack_global(items: Iterable[Item], page_size: int, gutter: int, max_pages: int) -> list[MaxRectsPage]: + pages: list[MaxRectsPage] = [] + for item in _unique_items(items): + placed = None + page_index = -1 + for index, page in enumerate(pages): + candidate = page.insert(item.image.width + gutter * 2, item.image.height + gutter * 2) + if candidate is not None: + placed, page_index = candidate, index + break + if placed is None: + if len(pages) >= max_pages: + raise SystemExit(f"frame pack needs more than {max_pages} pages") + page = MaxRectsPage(page_size) + pages.append(page) + placed = page.insert(item.image.width + gutter * 2, item.image.height + gutter * 2) + page_index = len(pages) - 1 + if placed is None: + raise SystemExit(f"frame {item.key} does not fit one {page_size}x{page_size} page") + item.page = page_index + item.x = placed.x + gutter + item.y = placed.y + gutter + return pages + + +def _empty_percent(items: Iterable[Item], page_count: int, page_size: int, gutter: int) -> float: + used = sum((item.image.width + gutter * 2) * (item.image.height + gutter * 2) + for item in _unique_items(items)) + return 100.0 * (1.0 - used / (page_count * page_size * page_size)) + + +def _clip_pages(clips: dict[str, list[Item]]) -> dict[str, list[int]]: + return {state: sorted({item.page for item in frames}) for state, frames in clips.items()} + + +def _run(args: argparse.Namespace) -> int: + _validate_options(args) + run_dir = args.run_dir.expanduser().resolve() + acquire_run_dir_lock(run_dir, "compact_atlas") + manifest_path = run_dir / args.manifest + if not manifest_path.is_file(): + raise SystemExit(f"manifest does not exist: {manifest_path}") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + source_path = _source_atlas(manifest, run_dir) + with Image.open(source_path) as opened: + source = opened.convert("RGBA") + items, clips = _load_items(manifest, source, args.alpha_padding) + + pages = _pack_clips(clips, args.page_size, args.gutter, args.max_pages) + empty_before = _empty_percent(items.values(), len(pages), args.page_size, args.gutter) + split_applied = False + if empty_before > args.max_empty_percent and args.allow_clip_split_over_threshold: + pages = _pack_global(items.values(), args.page_size, args.gutter, args.max_pages) + split_applied = True + empty_after = _empty_percent(items.values(), len(pages), args.page_size, args.gutter) + + page_names = [f"{args.page_prefix}-{index}.png" for index in range(len(pages))] + page_images = [Image.new("RGBA", (args.page_size, args.page_size), (0, 0, 0, 0)) for _ in pages] + for item in items.values(): + page_images[item.page].alpha_composite(item.image, (item.x, item.y)) + + output = copy.deepcopy(manifest) + frame_layout = output["frame_layout"] + frame_layout.update({ + "sheetWidth": args.page_size, + "sheetHeight": args.page_size, + "sheetCount": len(pages), + "sheets": page_names, + "packing": "maxrects-bssf-array-v1", + }) + for state, frames in frame_layout["rows"].items(): + compact_frames = [] + for rect, item in zip(frames, clips[state]): + compact_frames.append({ + "page": item.page, + "x": item.x, + "y": item.y, + "w": item.image.width, + "h": item.image.height, + "sourceX": item.source_x, + "sourceY": item.source_y, + }) + frame_layout["rows"][state] = compact_frames + output["sprite_sheet_alpha"] = page_names[0] + output["sprite_sheet_alpha_pages"] = page_names + output["game_input"] = page_names[0] + output["compact_atlas"] = { + "pageSize": args.page_size, + "pageCount": len(pages), + "gutter": args.gutter, + "alphaPadding": args.alpha_padding, + "maxEmptyPercent": args.max_empty_percent, + "emptyPercent": round(empty_after, 4), + "allowClipSplitOverThreshold": bool(args.allow_clip_split_over_threshold), + "clipSplitApplied": split_applied, + } + + for name, image in zip(page_names, page_images): + atomic_save_image(image, run_dir / name) + atomic_write_text(run_dir / args.output_manifest, + json.dumps(output, ensure_ascii=False, indent=2) + "\n") + + clip_pages = _clip_pages(clips) + report = { + "ok": True, + "sourceManifest": args.manifest, + "outputManifest": args.output_manifest, + "pages": page_names, + "pageCount": len(pages), + "emptyPercentBeforeSplit": round(empty_before, 4), + "emptyPercent": round(empty_after, 4), + "thresholdExceeded": empty_after > args.max_empty_percent, + "clipSplitApplied": split_applied, + "clipsSpanningPages": {state: value for state, value in clip_pages.items() if len(value) > 1}, + } + atomic_write_text(run_dir / args.report, json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + +def run(**kwargs: object) -> int: + return _run(_namespace_from_kwargs(**kwargs)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + add_arguments(parser) + return _run(parser.parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sprite_gen/gen/prepare.py b/sprite_gen/gen/prepare.py index 7fb4983c..75399569 100644 --- a/sprite_gen/gen/prepare.py +++ b/sprite_gen/gen/prepare.py @@ -109,6 +109,11 @@ "Use distinct gait poses that create a readable cycle instead of repeated standing or static bobbing.", "Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.", ], + "move": [ + "Show locomotion through body, arm, leg, hair, and prop movement only.", + "Use distinct gait poses that create a readable cycle instead of repeated standing or static bobbing.", + "Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.", + ], "walk": [ "Show locomotion through body, arm, leg, hair, and prop movement only.", "Use distinct gait poses that create a readable cycle instead of repeated standing or static bobbing.", @@ -759,10 +764,18 @@ def row_prompt(request: dict[str, Any], state: str, entry: dict[str, Any]) -> st cell_height = int(cell["height"]) safe_margin_x = int(cell["safe_margin_x"]) safe_margin_y = int(cell["safe_margin_y"]) + # Direction-contract rows use `_` ids. Exact legacy names keep + # priority, then the pose suffix inherits the same semantic requirements. This + # replaces the retired stick/phase-guide workaround without bringing drawings + # back into geometry-only layout guides. + pose = state.rsplit("_", 1)[-1] + semantic_requirements = STATE_REQUIREMENTS.get(state) + if semantic_requirements is None: + semantic_requirements = STATE_REQUIREMENTS.get(pose, []) state_requirements = [ *direction_prefix_requirements(request, state), *directional_requirements(state), - *STATE_REQUIREMENTS.get(state, []), + *semantic_requirements, ] state_requirement_text = "" if state_requirements: diff --git a/tests/compose/test_compact_atlas.py b/tests/compose/test_compact_atlas.py new file mode 100644 index 00000000..ed097e61 --- /dev/null +++ b/tests/compose/test_compact_atlas.py @@ -0,0 +1,72 @@ +import json +from pathlib import Path + +from PIL import Image, ImageDraw + +from sprite_gen.compose import compact_atlas + + +def _run(tmp_path: Path, *, clips: int = 2, frames: int = 2, page_size: int = 64, + max_empty_percent: float = 100, allow_split: bool = False): + atlas = Image.new("RGBA", (frames * 32, clips * 32), (0, 0, 0, 0)) + draw = ImageDraw.Draw(atlas) + rows = {} + animation_rows = {} + for clip in range(clips): + name = f"clip_{clip}" + row = [] + for frame in range(frames): + x, y = frame * 32, clip * 32 + draw.rectangle((x + 4, y + 4, x + 27, y + 27), fill=(clip * 70, frame * 70, 255, 255)) + row.append({"x": x, "y": y, "w": 32, "h": 32}) + rows[name] = row + animation_rows[name] = {"row": clip, "frames": frames, "fps": 8, "loop": True} + atlas.save(tmp_path / "sprite-sheet-alpha.png") + manifest = { + "characterId": "test", + "sprite_sheet_alpha": "sprite-sheet-alpha.png", + "game_input": "sprite-sheet-alpha.png", + "animation": {"cellWidth": 32, "cellHeight": 32, "columns": frames, "rows": animation_rows}, + "frame_layout": {"sheetWidth": atlas.width, "sheetHeight": atlas.height, + "cellWidth": 32, "cellHeight": 32, "rows": rows}, + } + (tmp_path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + result = compact_atlas.run( + run_dir=tmp_path, page_size=page_size, max_pages=4, gutter=1, alpha_padding=0, + max_empty_percent=max_empty_percent, + allow_clip_split_over_threshold=allow_split, + ) + return result, json.loads((tmp_path / "manifest.compact.json").read_text()), \ + json.loads((tmp_path / "compact-atlas.report.json").read_text()) + + +def test_compact_atlas_trims_without_resampling_and_preserves_each_clip_page(tmp_path: Path) -> None: + result, manifest, report = _run(tmp_path) + + assert result == 0 + assert manifest["frame_layout"]["sheetCount"] == 1 + assert manifest["frame_layout"]["sheets"] == ["sprite-sheet-alpha-0.png"] + assert report["clipsSpanningPages"] == {} + frame = manifest["frame_layout"]["rows"]["clip_0"][0] + assert (frame["w"], frame["h"], frame["sourceX"], frame["sourceY"]) == (24, 24, 4, 4) + with Image.open(tmp_path / "sprite-sheet-alpha-0.png") as page: + restored = page.crop((frame["x"], frame["y"], frame["x"] + frame["w"], frame["y"] + frame["h"])) + assert restored.getpixel((0, 0)) == (0, 0, 255, 255) + + +def test_clip_split_is_applied_only_when_threshold_is_exceeded_and_enabled(tmp_path: Path) -> None: + _result, manifest, report = _run( + tmp_path, clips=2, frames=3, page_size=64, max_empty_percent=10, allow_split=True) + + assert manifest["compact_atlas"]["clipSplitApplied"] is True + assert report["clipSplitApplied"] is True + assert report["clipsSpanningPages"] + + +def test_threshold_excess_does_not_split_without_opt_in(tmp_path: Path) -> None: + _result, manifest, report = _run( + tmp_path, clips=2, frames=2, page_size=64, max_empty_percent=10, allow_split=False) + + assert manifest["compact_atlas"]["clipSplitApplied"] is False + assert report["thresholdExceeded"] is True + assert report["clipsSpanningPages"] == {} diff --git a/tests/packaging/test_package_surface.py b/tests/packaging/test_package_surface.py index d4d0daba..09be2748 100644 --- a/tests/packaging/test_package_surface.py +++ b/tests/packaging/test_package_surface.py @@ -20,6 +20,7 @@ PACKAGE_RUN_MODULES = [ "anchor", + "compact_atlas", "compose_atlas", "compose_cycle", "compose_gif", diff --git a/tests/test_prepare_layout_guides.py b/tests/test_prepare_layout_guides.py index f657e027..d1684cf9 100644 --- a/tests/test_prepare_layout_guides.py +++ b/tests/test_prepare_layout_guides.py @@ -66,3 +66,17 @@ def test_library_does_not_accept_removed_option(tmp_path: Path): with pytest.raises(TypeError, match="unexpected keyword argument.*motion_phase_guides"): prepare.run(out_dir=tmp_path / "run", character_id="runner", motion_phase_guides=True) assert not (tmp_path / "run").exists() + + +@pytest.mark.parametrize("state", ["east_run", "south_move"]) +def test_direction_pose_state_inherits_locomotion_requirements(tmp_path: Path, state: str): + request = {"directions": {"set": [state.split("_", 1)[0]]}, "states": { + state: {"frames": 8, "fps": 8, "loop": True, "action": "eight-phase gait"}, + }} + result = run_script("prepare_sprite_run.py", "--out-dir", str(tmp_path / "run"), + "--character-id", "runner", "--request-json", json.dumps(request)) + assert result.returncode == 0, result.stdout + result.stderr + + prompt = next((tmp_path / "run" / "prompts").rglob("*run.txt" if state.endswith("run") else "*move.txt")).read_text() + assert "Use distinct gait poses that create a readable cycle" in prompt + assert "phase guide" not in prompt