diff --git a/CLAUDE.md b/CLAUDE.md index 6b560f9..04fd1a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Open work and where to start: `docs/handoff.md`. The audited CC0 starter models in `examples/cc0-starter` may be committed with their original license and provenance hashes. This exception does not cover game-derived or commercial assets. -- `common/` is the shared Python package (imports as `spritemotion`); `schemas/` holds every JSON data contract. +- `common/` is the shared Python package (imports as `spritemotion`); `common/schemas/` holds every JSON data contract. Extend a schema (and `docs/`) before emitting a new field. - `games//`: adapter, profiles, annotations, equipment slots (`equipment/layers.json`), outfit lab. - `tools//` with an entry point (`run.py`) per job; one subfolder per third-party program. No loose scripts. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4abc39..d1f8d0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ does not establish correct in-game fit or replace local Blender render review. - Blender scripts take their arguments after `--`, must work under `blender -b --factory-startup`, and must guard `main()` with `if __name__ == "__main__":`. -- New data files get a schema in `schemas/`, or an entry in +- New data files get a schema in `common/schemas/`, or an entry in [docs/annotation-format.md](docs/annotation-format.md) if they are an internal format. - Add a test with each behaviour change. The sample character exists so that pipeline behaviour can be checked against known 3D ground truth. diff --git a/common/__init__.py b/common/__init__.py index 906bb88..a9c0138 100644 --- a/common/__init__.py +++ b/common/__init__.py @@ -11,4 +11,3 @@ PACKAGE_ROOT = Path(__file__).resolve().parent REPO_ROOT = PACKAGE_ROOT.parent -SCHEMA_DIR = REPO_ROOT / "schemas" diff --git a/common/schemas.py b/common/schemas/__init__.py similarity index 69% rename from common/schemas.py rename to common/schemas/__init__.py index 3360ce5..9f1b6ef 100644 --- a/common/schemas.py +++ b/common/schemas/__init__.py @@ -6,10 +6,14 @@ from __future__ import annotations from functools import lru_cache +from pathlib import Path from typing import Any -from . import SCHEMA_DIR -from .jsonio import read_json +from .. import __version__ +from ..jsonio import read_json + +# The schema files are package data that sit beside this module. +SCHEMA_DIR = Path(__file__).resolve().parent SCHEMA_FILES = { "spritemotion.game": "game.schema.json", @@ -25,6 +29,26 @@ class SchemaError(ValueError): pass +def version() -> str: + """The SpriteMotion package version these schemas ship with.""" + return __version__ + + +def path(filename: str) -> Path: + """Path of a schema file by name, e.g. ``fit-adjustments.schema.json``.""" + if Path(filename).name != filename or not filename.endswith(".schema.json"): + raise ValueError(f"Not a schema file name: {filename!r}.") + file = SCHEMA_DIR / filename + if not file.is_file(): + raise FileNotFoundError(f"No schema file {filename!r} in {SCHEMA_DIR}.") + return file + + +def load_file(filename: str) -> dict: + """Load a schema by file name, for schemas that have no document ``kind``.""" + return read_json(path(filename)) + + @lru_cache(maxsize=None) def load_schema(kind: str) -> dict: return read_json(SCHEMA_DIR / SCHEMA_FILES[kind]) diff --git a/schemas/asset-pack.schema.json b/common/schemas/asset-pack.schema.json similarity index 100% rename from schemas/asset-pack.schema.json rename to common/schemas/asset-pack.schema.json diff --git a/schemas/equipment-slots.schema.json b/common/schemas/equipment-slots.schema.json similarity index 100% rename from schemas/equipment-slots.schema.json rename to common/schemas/equipment-slots.schema.json diff --git a/schemas/fit-adjustments.schema.json b/common/schemas/fit-adjustments.schema.json similarity index 100% rename from schemas/fit-adjustments.schema.json rename to common/schemas/fit-adjustments.schema.json diff --git a/schemas/fit-build.schema.json b/common/schemas/fit-build.schema.json similarity index 100% rename from schemas/fit-build.schema.json rename to common/schemas/fit-build.schema.json diff --git a/schemas/fit-head-ab.schema.json b/common/schemas/fit-head-ab.schema.json similarity index 100% rename from schemas/fit-head-ab.schema.json rename to common/schemas/fit-head-ab.schema.json diff --git a/schemas/fit-lab-build.schema.json b/common/schemas/fit-lab-build.schema.json similarity index 100% rename from schemas/fit-lab-build.schema.json rename to common/schemas/fit-lab-build.schema.json diff --git a/schemas/fit-lab-service.schema.json b/common/schemas/fit-lab-service.schema.json similarity index 100% rename from schemas/fit-lab-service.schema.json rename to common/schemas/fit-lab-service.schema.json diff --git a/schemas/fit-reference.schema.json b/common/schemas/fit-reference.schema.json similarity index 100% rename from schemas/fit-reference.schema.json rename to common/schemas/fit-reference.schema.json diff --git a/schemas/game.schema.json b/common/schemas/game.schema.json similarity index 100% rename from schemas/game.schema.json rename to common/schemas/game.schema.json diff --git a/schemas/pose-annotations.schema.json b/common/schemas/pose-annotations.schema.json similarity index 100% rename from schemas/pose-annotations.schema.json rename to common/schemas/pose-annotations.schema.json diff --git a/schemas/skeleton.schema.json b/common/schemas/skeleton.schema.json similarity index 100% rename from schemas/skeleton.schema.json rename to common/schemas/skeleton.schema.json diff --git a/schemas/sprite-sequence.schema.json b/common/schemas/sprite-sequence.schema.json similarity index 100% rename from schemas/sprite-sequence.schema.json rename to common/schemas/sprite-sequence.schema.json diff --git a/schemas/starter-catalog.schema.json b/common/schemas/starter-catalog.schema.json similarity index 100% rename from schemas/starter-catalog.schema.json rename to common/schemas/starter-catalog.schema.json diff --git a/common/sprites/adapter.py b/common/sprites/adapter.py index 30cf375..31edb13 100644 --- a/common/sprites/adapter.py +++ b/common/sprites/adapter.py @@ -1,7 +1,7 @@ """The interface a game adapter implements. An adapter turns a user's local game installation into a normalized dataset: -canvas-sized RGBA frames plus a manifest (see schemas/sprite-sequence.schema.json). +canvas-sized RGBA frames plus a manifest (see common/schemas/sprite-sequence.schema.json). Adapters live in games// and are loaded by file path from game.json, so the shared layer never imports game code by name. """ diff --git a/docs/adding-a-game.md b/docs/adding-a-game.md index c5cd783..653128b 100644 --- a/docs/adding-a-game.md +++ b/docs/adding-a-game.md @@ -8,7 +8,7 @@ under `games//`. ```text games// README.md what is supported, where the user points the extractor, known limits - game.json descriptor (schemas/game.schema.json) + game.json descriptor (common/schemas/game.schema.json) extraction/ the adapter: local game files -> normalized dataset profiles/ per character: canvas, directions, camera; optional sequence catalog skeletons/ skeleton(s) and rig mappings diff --git a/docs/annotation-format.md b/docs/annotation-format.md index 8e38ec6..8dc8a25 100644 --- a/docs/annotation-format.md +++ b/docs/annotation-format.md @@ -4,14 +4,14 @@ Annotations are 2D joint positions on sprite frames. They are the part of SpriteMotion that people build together, so the format records where each pose came from and which pixels it was drawn on. -Schemas are in `schemas/`: +Schemas are in `common/schemas/`: | File | Schema id | Describes | |---|---|---| -| `dataset.json` | `spritemotion.dataset` ([schema](../schemas/sprite-sequence.schema.json)) | one extracted character: canvas, directions, camera, frames with fingerprints | -| `skeleton.json` | `spritemotion.skeleton` ([schema](../schemas/skeleton.schema.json)) | joint names, drawing chains, symmetric pairs | -| `annotations//.json` | `spritemotion.pose-annotations` ([schema](../schemas/pose-annotations.schema.json)) | poses of one sequence in one layer | -| `games//game.json` | `spritemotion.game` ([schema](../schemas/game.schema.json)) | a game adapter and its characters | +| `dataset.json` | `spritemotion.dataset` ([schema](../common/schemas/sprite-sequence.schema.json)) | one extracted character: canvas, directions, camera, frames with fingerprints | +| `skeleton.json` | `spritemotion.skeleton` ([schema](../common/schemas/skeleton.schema.json)) | joint names, drawing chains, symmetric pairs | +| `annotations//.json` | `spritemotion.pose-annotations` ([schema](../common/schemas/pose-annotations.schema.json)) | poses of one sequence in one layer | +| `games//game.json` | `spritemotion.game` ([schema](../common/schemas/game.schema.json)) | a game adapter and its characters | All coordinates are **canvas pixels**: origin at the top left, x to the right, y down. The canvas has a fixed size per dataset and the character's ground diff --git a/docs/asset-packs.md b/docs/asset-packs.md index bacf3d6..336a057 100644 --- a/docs/asset-packs.md +++ b/docs/asset-packs.md @@ -2,7 +2,7 @@ The content studio (`tools/uo-content`) can fit parts of third-party 3D asset packs (modular outfits, helmets, weapons) onto the UO body and export them as UO equipment. Each pack is described by one **asset-pack mapping**, a -JSON document (`schema: spritemotion.asset-pack`, `schemas/asset-pack.schema.json`). A mapping holds two tables: +JSON document (`schema: spritemotion.asset-pack`, `common/schemas/asset-pack.schema.json`). A mapping holds two tables: 1. **Bones → target rig.** How the pack's skeleton is fitted onto the canonical UO rig (`uo-model3d-v13`). 2. **Parts → equipment slots.** Which of the game's equipment layers each part type becomes. diff --git a/docs/fit-lab-service.md b/docs/fit-lab-service.md index a200e98..4eb3a01 100644 --- a/docs/fit-lab-service.md +++ b/docs/fit-lab-service.md @@ -6,13 +6,13 @@ error, not permission to start another server on that port. Service discovery co | Route | Contract | |---|---| -| GET `/api/service` | `schemas/fit-lab-service.schema.json`; capability and pack negotiation | +| GET `/api/service` | `common/schemas/fit-lab-service.schema.json`; capability and pack negotiation | | GET `/data/manifest.json` | Available item IDs, slots, actions, GLB paths and camera | | GET `/api/mapping` | Local pack defaults; never publish this response | | GET `/api/state` | Adjustment document, opaque revision and saved backups | | POST `/api/adjustments` | `{adjustments, base_revision}`; 409 preserves a concurrent editor's changes | | GET `/api/backups/` | A prior document; restoring it is another revision-checked save | -| GET, POST `/api/build` | Build state / `schemas/fit-lab-build.schema.json` request | +| GET, POST `/api/build` | Build state / `common/schemas/fit-lab-build.schema.json` request | | GET `/api/renders?item=` | Completed jobs, newest first, with action coverage | | GET `/builds//review/manifest.json` | Final sprite sequences, frame counts, playback FPS and anchor | diff --git a/docs/guo-integration-plan.md b/docs/guo-integration-plan.md index 0c46f46..47bff66 100644 --- a/docs/guo-integration-plan.md +++ b/docs/guo-integration-plan.md @@ -113,7 +113,7 @@ An equipment distribution system is a later GUO Asset Store contract extension. | Repository | Proposed change | |---|---| -| SpriteMotion | New bridge/export schemas under `schemas/`; service adapter and export helper under a dedicated `tools/` job directory; reuse `tools/uo-content` builds and `tools/fit-lab` adjustments. | +| SpriteMotion | New bridge/export schemas under `common/schemas/`; service adapter and export helper under a dedicated `tools/` job directory; reuse `tools/uo-content` builds and `tools/fit-lab` adjustments. | | GUO | New SpriteMotion client/panel code under `godot/GUO/addons/guo_editor/`; generic importer alongside `tools/uopack/outfit.py`; orchestration under `tools/guo` or a dedicated tool following GUO conventions. | | Both | Synthetic contract fixtures, compatibility tests and a recorded end-to-end acceptance recipe. | diff --git a/pyproject.toml b/pyproject.toml index 807ab7c..f7de0a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,11 @@ description = "Reconstruct editable 3D characters and animations from existing 2 readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } -dependencies = ["numpy>=1.24", "Pillow>=10"] +dependencies = [] [project.optional-dependencies] -test = ["pytest>=7", "jsonschema>=4"] +imaging = ["numpy>=1.24", "Pillow>=10"] +test = ["pytest>=7", "jsonschema>=4", "numpy>=1.24", "Pillow>=10"] [project.scripts] spritemotion = "spritemotion.pipeline.cli:main" @@ -22,6 +23,7 @@ spritemotion = "spritemotion.pipeline.cli:main" package-dir = { "spritemotion" = "common" } packages = [ "spritemotion", + "spritemotion.schemas", "spritemotion.sprites", "spritemotion.poses", "spritemotion.estimation", @@ -30,5 +32,8 @@ packages = [ "spritemotion.pipeline", ] +[tool.setuptools.package-data] +"spritemotion.schemas" = ["*.schema.json"] + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/integration/test_core_install.py b/tests/integration/test_core_install.py new file mode 100644 index 0000000..d49b3e9 --- /dev/null +++ b/tests/integration/test_core_install.py @@ -0,0 +1,73 @@ +"""The installable core: schemas ship as package data and the package imports with the standard library only.""" +import json +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +from spritemotion import __version__, schemas + +REPO = Path(__file__).resolve().parents[2] + +SCHEMA_NAMES = sorted(p.name for p in schemas.SCHEMA_DIR.glob("*.schema.json")) + +STDLIB_ONLY = """ +import sys +sys.path.insert(0, {target!r}) +import spritemotion, spritemotion.jsonio, spritemotion.schemas as s +for kind in s.SCHEMA_FILES: + assert s.load_schema(kind) +for name in {names!r}: + assert s.load_file(name) +leaked = sorted(m for m in ("numpy", "PIL") if m in sys.modules) +assert not leaked, leaked +print(s.version(), len(s.SCHEMA_FILES)) +""" + + +def test_version_matches_pyproject(): + text = (REPO / "pyproject.toml").read_text(encoding="utf-8") + declared = next(line for line in text.splitlines() if line.startswith("version = ")) + assert declared.split('"')[1] == __version__ == schemas.version() + + +def test_schema_files_live_only_in_the_package(): + assert SCHEMA_NAMES + assert not (REPO / "schemas").exists() + assert set(schemas.SCHEMA_FILES.values()) <= set(SCHEMA_NAMES) + + +def test_load_file_and_path_reject_other_names(): + assert schemas.load_file("fit-adjustments.schema.json")["type"] == "object" + assert schemas.path("starter-catalog.schema.json").is_file() + for bad in ("../pyproject.toml", "missing.schema.json", "game.json"): + with pytest.raises((ValueError, FileNotFoundError)): + schemas.path(bad) + + +def test_wheel_carries_every_schema_and_core_imports_without_imaging(tmp_path): + out = tmp_path / "wheel" + build = subprocess.run([sys.executable, "-m", "pip", "wheel", "--no-deps", "--no-build-isolation", "-w", str(out), str(REPO)], + capture_output=True, text=True, cwd=tmp_path) + if build.returncode: + pytest.skip("cannot build a wheel here (setuptools missing?): " + build.stderr[-300:]) + wheel = next(out.glob("spritemotion-*.whl")) + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + metadata = archive.read(next(n for n in names if n.endswith(".dist-info/METADATA"))).decode() + for name in SCHEMA_NAMES: + assert f"spritemotion/schemas/{name}" in names, name + assert not any(n.startswith("schemas/") for n in names) + assert "Requires-Dist: numpy" not in metadata.split("Provides-Extra: imaging")[0] + + # Import from the unpacked wheel, not the checkout, with the standard library only. + target = tmp_path / "site" + with zipfile.ZipFile(wheel) as archive: + archive.extractall(target) + # -S leaves out site-packages, so numpy and Pillow could not be imported even by accident. + run = subprocess.run([sys.executable, "-I", "-S", "-c", STDLIB_ONLY.format(names=SCHEMA_NAMES, target=str(target))], + capture_output=True, text=True, cwd=tmp_path) + assert run.returncode == 0, run.stderr + assert run.stdout.split()[0] == __version__ diff --git a/tests/unit/test_fit_lab_saves.py b/tests/unit/test_fit_lab_saves.py index 64ed22d..26dc38d 100644 --- a/tests/unit/test_fit_lab_saves.py +++ b/tests/unit/test_fit_lab_saves.py @@ -6,6 +6,7 @@ import pytest import jsonschema +from spritemotion import schemas ROOT = Path(__file__).resolve().parents[2] spec = importlib.util.spec_from_file_location('fit_lab_adjustments', ROOT / 'tools/fit-lab/adjustments.py') @@ -31,7 +32,7 @@ def test_three_backups_restore_and_noop(tmp_path): assert store.backup(state['backups'][0]['id']) == adjustment(4) with pytest.raises(FileNotFoundError): store.backup('../lab-adjustments.json') - schema = json.loads((ROOT / 'schemas/fit-adjustments.schema.json').read_text()) + schema = schemas.load_file('fit-adjustments.schema.json') jsonschema.validate(state['adjustments'], schema) diff --git a/tests/unit/test_starter_assets.py b/tests/unit/test_starter_assets.py index 321becf..47cfa3f 100644 --- a/tests/unit/test_starter_assets.py +++ b/tests/unit/test_starter_assets.py @@ -5,6 +5,7 @@ import jsonschema import pytest +from spritemotion import schemas ROOT=Path(__file__).resolve().parents[2] ASSETS=ROOT/'examples/cc0-starter' @@ -14,7 +15,7 @@ def test_every_layer_has_an_appropriate_starter_route(): document=json.loads((ASSETS/'catalog.json').read_text()) - jsonschema.validate(document,json.loads((ROOT/'schemas/starter-catalog.schema.json').read_text())) + jsonschema.validate(document,schemas.load_file('starter-catalog.schema.json')) items=document['items'] layers=json.loads((ROOT/'games/ultima-online/equipment/layers.json').read_text())['layers'] assert {i['layer'] for i in items}=={i['id'] for i in layers} @@ -32,7 +33,7 @@ def test_every_layer_has_an_appropriate_starter_route(): def test_bundled_sources_are_self_contained_and_mappings_validate(): mapping=json.loads((ASSETS/'outfit-mapping.json').read_text()) - jsonschema.validate(mapping,json.loads((ROOT/'schemas/asset-pack.schema.json').read_text())) + jsonschema.validate(mapping,schemas.load_file('asset-pack.schema.json')) for path in ASSETS.glob('*.glb'): raw=path.read_bytes() magic,version,size=struct.unpack_from('` lists finished renders for an item, newest first (`$defs.renders` in -`schemas/fit-lab-build.schema.json`); while building, `GET /api/build` adds `mode`, `started` and `progress`. +`common/schemas/fit-lab-build.schema.json`); while building, `GET /api/build` adds `mode`, `started` and `progress`. ## In the lab @@ -79,11 +79,11 @@ one selected item, not an entire group. Directory-only imports are not build sou `POST /api/build` takes `{item, mode: build|rebuild, coverage: preview|action|full, action}`. `GET /api/build` returns idle/building/complete/failed status, with item, job/review on success, or error on failure. `unchanged` marks a rebuild with no changed existing blocks. `lab-builds.json` maps item IDs to last-successful job IDs. -Both contracts are described in `schemas/fit-lab-build.schema.json`. Successful reviews are served at `/builds/`. +Both contracts are described in `common/schemas/fit-lab-build.schema.json`. Successful reviews are served at `/builds/`. Preview **Base** selects the original UO sprite, the animated 3D body, or transparent content only. The original is extracted from the canonical model's embedded original frames; it is a visual reference, -not a final holdout render. `reference.json` follows `schemas/fit-reference.schema.json`, indexes +not a final holdout render. `reference.json` follows `common/schemas/fit-reference.schema.json`, indexes `reference.png` by `action,frame,stored-direction`, and stays in ignored workspace data. Mirroring applies to the complete composite. Poke highlighting is separately switchable. @@ -94,7 +94,7 @@ over every frame of checked actions and all five stored directions. The table re lower counts alone do not establish a better match to original artwork. Download the report for provenance. The downloadable contact sheet shows the pose with the largest content-pixel change for each item (before/after pairs); original reference pixels are composited underneath when available. JSON frame indices are zero-based, -and printed contact-sheet frame numbers are one-based. Reports follow `schemas/fit-head-ab.schema.json`. +and printed contact-sheet frame numbers are one-based. Reports follow `common/schemas/fit-head-ab.schema.json`. **Load fitted GLBs from a folder** accepts a local directory of already fitted, self-contained GLBs. Select the destination slot first. Files must have skin weights using the canonical body's bone names; raw FBX and diff --git a/tools/fit-lab/adjustments.py b/tools/fit-lab/adjustments.py index 310ad69..9343156 100644 --- a/tools/fit-lab/adjustments.py +++ b/tools/fit-lab/adjustments.py @@ -15,7 +15,7 @@ class ConflictError(ValueError): def validate(data): - """Dependency-free validation matching schemas/fit-adjustments.schema.json.""" + """Dependency-free validation matching common/schemas/fit-adjustments.schema.json.""" def number(value): return type(value) in (int, float) and math.isfinite(value) diff --git a/tools/sprite-pose-editor/README.md b/tools/sprite-pose-editor/README.md index 6dedb8f..a9aa185 100644 --- a/tools/sprite-pose-editor/README.md +++ b/tools/sprite-pose-editor/README.md @@ -3,7 +3,7 @@ A Godot 4.7 editor for reviewing and correcting 2D joint annotations on sprite frames. It works with any SpriteMotion dataset. The canvas size, directions and mirror pairs, sequences, frame counts, skeleton and colours all come from the -dataset manifest (`dataset.json`, see `schemas/sprite-sequence.schema.json`). +dataset manifest (`dataset.json`, see `common/schemas/sprite-sequence.schema.json`). Nothing is hard-coded for a particular game. ![Editor on the test fixture](../../docs/images/sprite-pose-editor.png) diff --git a/tools/uo-content/README.md b/tools/uo-content/README.md index ad687e8..b39bd3e 100644 --- a/tools/uo-content/README.md +++ b/tools/uo-content/README.md @@ -28,7 +28,7 @@ Preview builds contain actions 0, 4, 9, 22 and 25 across five stored directions. ### Fit corrections and selected blocks Jobs can specify `actions` and optionally exact `blocks` (`[[action, stored_direction], ...]`). Directions are 0–4; -mirrored views share their stored block. Fit-aware settings follow `schemas/fit-build.schema.json`. +mirrored views share their stored block. Fit-aware settings follow `common/schemas/fit-build.schema.json`. `fit_item: {id, slot, part}` identifies an item; `fit_adjustments` is a frozen adjustment document. When omitted, pack jobs look for `lab-adjustments.json` beside their mapping and identify the item from the local lab source list. Explicit snapshots take precedence. Unmatched item identity is an error when item/group/slot corrections need it.