Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<game>/`: adapter, profiles, annotations, equipment slots (`equipment/layers.json`), outfit lab.
- `tools/<job>/` with an entry point (`run.py`) per job; one subfolder per third-party program. No loose scripts.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,3 @@

PACKAGE_ROOT = Path(__file__).resolve().parent
REPO_ROOT = PACKAGE_ROOT.parent
SCHEMA_DIR = REPO_ROOT / "schemas"
28 changes: 26 additions & 2 deletions common/schemas.py → common/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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])
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion common/sprites/adapter.py
Original file line number Diff line number Diff line change
@@ -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/<game>/ and are loaded by file path from game.json, so
the shared layer never imports game code by name.
"""
Expand Down
2 changes: 1 addition & 1 deletion docs/adding-a-game.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ under `games/<game>/`.
```text
games/<game>/
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
Expand Down
10 changes: 5 additions & 5 deletions docs/annotation-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<layer>/<sequence>.json` | `spritemotion.pose-annotations` ([schema](../schemas/pose-annotations.schema.json)) | poses of one sequence in one layer |
| `games/<game>/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/<layer>/<sequence>.json` | `spritemotion.pose-annotations` ([schema](../common/schemas/pose-annotations.schema.json)) | poses of one sequence in one layer |
| `games/<game>/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
Expand Down
2 changes: 1 addition & 1 deletion docs/asset-packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/fit-lab-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` | 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=<id>` | Completed jobs, newest first, with action coverage |
| GET `/builds/<job>/review/manifest.json` | Final sprite sequences, frame counts, playback FPS and anchor |

Expand Down
2 changes: 1 addition & 1 deletion docs/guo-integration-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -22,6 +23,7 @@ spritemotion = "spritemotion.pipeline.cli:main"
package-dir = { "spritemotion" = "common" }
packages = [
"spritemotion",
"spritemotion.schemas",
"spritemotion.sprites",
"spritemotion.poses",
"spritemotion.estimation",
Expand All @@ -30,5 +32,8 @@ packages = [
"spritemotion.pipeline",
]

[tool.setuptools.package-data]
"spritemotion.schemas" = ["*.schema.json"]

[tool.pytest.ini_options]
testpaths = ["tests"]
73 changes: 73 additions & 0 deletions tests/integration/test_core_install.py
Original file line number Diff line number Diff line change
@@ -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__
3 changes: 2 additions & 1 deletion tests/unit/test_fit_lab_saves.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)


Expand Down
5 changes: 3 additions & 2 deletions tests/unit/test_starter_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import jsonschema
import pytest
from spritemotion import schemas

ROOT=Path(__file__).resolve().parents[2]
ASSETS=ROOT/'examples/cc0-starter'
Expand All @@ -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}
Expand All @@ -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('<III',raw)
Expand Down
10 changes: 5 additions & 5 deletions tools/fit-lab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ server; it survives reloads unless browser storage is cleared or unavailable.

Disk saves atomically replace `lab-adjustments.json` and retain the previous three saved states in
`lab-adjustments-backups/`. **Restore backup** adds an undoable edit and autosaves it. Backups use the same
`schemas/fit-adjustments.schema.json` format as the current file. Unchanged saves do not rotate backups.
`common/schemas/fit-adjustments.schema.json` format as the current file. Unchanged saves do not rotate backups.
Failed writes leave the current file intact.
If another tab or tool changes the disk file, saving pauses: choose **Use disk version** or **Keep my recovered edits**.
The latter deliberately replaces the latest disk version, which is backed up first. The save status reports disk
Expand Down Expand Up @@ -50,7 +50,7 @@ the current saved fit: the status reads *Matches the saved fit* or *Fit changed
rendered frames. **Auto re-render** (remembered per browser) does this once the saved fit has been still for 3 s and
the shown render is stale or missing, once per fit and pose so a failing build is not retried in a loop.
`GET /api/renders?item=<id>` 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

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tools/fit-lab/adjustments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion tools/sprite-pose-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tools/uo-content/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading