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
6 changes: 4 additions & 2 deletions backend/render/src/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,12 @@ export async function renderPreviewJob(
scene.add(worn);
frameObject(camera, worn);
out[view] = capture(renderer, scene, camera);
} else if (view === "model") {
} else if (view === "model" || view === "pull_2" || view === "charged") {
// 3D bows also show their drawn (and crossbows their loaded) model.
const modelKey = view !== "model" ? view : kind === "gun" ? "carry" : "model";
const { root } = await buildItemRoot(kind, assets, {
center: true,
modelKey: kind === "gun" ? "carry" : "model",
modelKey,
});
scene.add(root);
const box = new THREE.Box3().setFromObject(root);
Expand Down
10 changes: 6 additions & 4 deletions backend/src/api/skins_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,16 +669,17 @@ async def post_submissions(
tiers_list = [tier]
remapped: dict[str, bytes] = {}
remapped_names: dict[str, str | None] = {}
for field in ARMOR_FIELDS:
legacy = ARMOR_FIELDS + tuple(f"{f}_mcmeta" for f in ARMOR_FIELDS)
for field in legacy:
if field in files_bytes:
tier_key = f"{tier}_{field}"
remapped[tier_key] = files_bytes[field]
remapped_names[tier_key] = filenames.get(field)
files_bytes = {
k: v for k, v in files_bytes.items() if k not in ARMOR_FIELDS
k: v for k, v in files_bytes.items() if k not in legacy
}
filenames = {
k: v for k, v in filenames.items() if k not in ARMOR_FIELDS
k: v for k, v in filenames.items() if k not in legacy
}
files_bytes.update(remapped)
filenames.update(remapped_names)
Expand Down Expand Up @@ -820,7 +821,8 @@ def get_thumbnail(


# The owner's uploads only; staff sheets and server files stay staff-side.
_OWNER_FILE_SUFFIXES = (".png", ".json")
# An animated texture has a sibling `<name>.png.mcmeta`; it 404s when the PNG is still.
_OWNER_FILE_SUFFIXES = (".png", ".json", ".png.mcmeta")
_OWNER_HIDDEN_FILES = frozenset({"meta.json", "review_sheet.png", "name_preview.png"})


Expand Down
2 changes: 2 additions & 0 deletions backend/src/characters/web_entitlements.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"bow",
"large_bow",
"crossbow",
"bow_3d",
"crossbow_3d",
"item_3d",
"shield",
"helmet_3d",
Expand Down
196 changes: 196 additions & 0 deletions backend/src/skins/animation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""Minecraft ``.png.mcmeta`` texture animation: validation and first-frame crops.

Frames are laid out the way Minecraft reads them: frame size from the
animation's width/height (or a square of the shorter image side), numbered
left to right, then top to bottom.
"""

from __future__ import annotations

import io
import json
from pathlib import Path
from typing import Any

from PIL import Image

from .base_sets import mcmeta_max_bytes

_ANIMATION_KEYS = frozenset({"frametime", "interpolate", "width", "height", "frames"})


class AnimationError(ValueError):
"""Invalid .mcmeta or a PNG that does not fit its frames."""


def _positive_int(value: Any, *, minimum: int) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value >= minimum


def frame_size(animation: dict[str, Any], image: tuple[int, int]) -> tuple[int, int]:
img_w, img_h = image
w = animation.get("width")
h = animation.get("height")
if w is not None and h is not None:
return w, h
if w is not None:
return w, img_h
if h is not None:
return img_w, h
side = min(img_w, img_h)
return side, side


def parse_mcmeta(
data: bytes,
image: tuple[int, int],
expected_frame: tuple[int, int] | None = None,
*,
label: str = "texture",
) -> tuple[dict[str, Any], int]:
"""Validate an .mcmeta for a PNG of ``image`` size; return (clean json, frame count)."""
cap = mcmeta_max_bytes()
if not data:
raise AnimationError(f"{label}: .mcmeta file is empty")
if len(data) > cap:
raise AnimationError(
f"{label}: .mcmeta is {len(data)} bytes (limit {cap} bytes)"
)
try:
raw = json.loads(data.decode("utf-8-sig"))
except (UnicodeDecodeError, json.JSONDecodeError) as e:
raise AnimationError(f"{label}: .mcmeta is not valid JSON") from e
if not isinstance(raw, dict) or set(raw) != {"animation"}:
raise AnimationError(
f'{label}: .mcmeta must be an object with only an "animation" section'
)
anim = raw["animation"]
if not isinstance(anim, dict):
raise AnimationError(f'{label}: .mcmeta "animation" must be an object')
unknown = sorted(set(anim) - _ANIMATION_KEYS)
if unknown:
raise AnimationError(
f"{label}: .mcmeta animation has unsupported keys: {', '.join(unknown)}"
)

clean: dict[str, Any] = {}
if "frametime" in anim:
if not _positive_int(anim["frametime"], minimum=1):
raise AnimationError(f"{label}: .mcmeta frametime must be a whole number ≥ 1")
clean["frametime"] = anim["frametime"]
if "interpolate" in anim:
if not isinstance(anim["interpolate"], bool):
raise AnimationError(f"{label}: .mcmeta interpolate must be true or false")
clean["interpolate"] = anim["interpolate"]
for key in ("width", "height"):
if key in anim:
if not _positive_int(anim[key], minimum=1):
raise AnimationError(f"{label}: .mcmeta {key} must be a whole number ≥ 1")
clean[key] = anim[key]

img_w, img_h = image
fw, fh = frame_size(clean, image)
if expected_frame is not None and (fw, fh) != expected_frame:
ew, eh = expected_frame
raise AnimationError(
f"{label}: animation frames must be {ew}x{eh}, got {fw}x{fh}"
)
if fw > img_w or fh > img_h or img_w % fw or img_h % fh:
raise AnimationError(
f"{label}: a {img_w}x{img_h} PNG is not a whole grid of {fw}x{fh} frames"
)
count = (img_w // fw) * (img_h // fh)

if "frames" in anim:
frames = anim["frames"]
if not isinstance(frames, list) or not frames:
raise AnimationError(f"{label}: .mcmeta frames must be a non-empty list")
out_frames: list[Any] = []
for entry in frames:
if isinstance(entry, dict):
if set(entry) - {"index", "time"} or "index" not in entry:
raise AnimationError(
f"{label}: .mcmeta frame entries need an index (and optional time)"
)
index = entry["index"]
if "time" in entry and not _positive_int(entry["time"], minimum=1):
raise AnimationError(f"{label}: .mcmeta frame time must be ≥ 1")
cleaned = {"index": index}
if "time" in entry:
cleaned["time"] = entry["time"]
else:
index = entry
cleaned = entry
if not _positive_int(index, minimum=0):
raise AnimationError(f"{label}: .mcmeta frame index must be a whole number ≥ 0")
if index >= count:
raise AnimationError(
f"{label}: .mcmeta frame {index} does not exist (PNG has {count} frames)"
)
out_frames.append(cleaned)
clean["frames"] = out_frames
return {"animation": clean}, count


def mcmeta_bytes(clean: dict[str, Any]) -> bytes:
return json.dumps(clean, separators=(",", ":")).encode("utf-8")


def mcmeta_path(png: Path) -> Path:
return png.with_name(png.name + ".mcmeta")


def _read_animation(png: Path) -> tuple[dict[str, Any], tuple[int, int], int] | None:
"""(animation, frame size, frame count) for a stored PNG with an .mcmeta, else None."""
meta = mcmeta_path(png)
if not meta.is_file():
return None
try:
anim = json.loads(meta.read_text(encoding="utf-8")).get("animation") or {}
with Image.open(png) as im:
size = im.size
except (OSError, ValueError, AttributeError):
return None
fw, fh = frame_size(anim, size)
if fw <= 0 or fh <= 0 or size[0] % fw or size[1] % fh:
return None
return anim, (fw, fh), (size[0] // fw) * (size[1] // fh)


def frame_count(png: Path) -> int:
"""Animation frames of a stored PNG; 1 when it is not animated."""
info = _read_animation(png)
return info[2] if info else 1


def first_frame(png: Path, image: Image.Image) -> Image.Image:
"""Crop ``image`` (loaded from ``png``) to the frame Minecraft shows first."""
info = _read_animation(png)
if info is None:
return image
anim, (fw, fh), count = info
index = 0
frames = anim.get("frames")
if isinstance(frames, list) and frames:
head = frames[0]
index = head.get("index", 0) if isinstance(head, dict) else head
if not isinstance(index, int) or not 0 <= index < count:
index = 0
cols = image.width // fw
x, y = (index % cols) * fw, (index // cols) * fh
return image.crop((x, y, x + fw, y + fh))


def first_frame_png(png: Path) -> bytes | None:
"""First-frame PNG bytes for an animated stored PNG, else None."""
if not mcmeta_path(png).is_file():
return None
try:
with Image.open(png) as im:
im.load()
frame = first_frame(png, im.convert("RGBA"))
except OSError:
return None
buf = io.BytesIO()
frame.save(buf, format="PNG")
return buf.getvalue()
53 changes: 53 additions & 0 deletions backend/src/skins/base_sets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Load what player skins can apply to (ProvinceSystem/shared/skins/base_sets.json).

The frontend reads the same file, so the upload picker and these checks agree.
"""

from __future__ import annotations

import json
from functools import lru_cache
from pathlib import Path
from typing import Any

# backend/src/skins → ProvinceSystem/
_PROVINCE_ROOT = Path(__file__).resolve().parents[3]
_BASE_SETS_PATH = _PROVINCE_ROOT / "shared" / "skins" / "base_sets.json"


@lru_cache(maxsize=1)
def load_base_sets() -> dict[str, Any]:
with _BASE_SETS_PATH.open(encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise RuntimeError(f"Invalid base sets at {_BASE_SETS_PATH}")
return data


def base_sets_by_kind() -> dict[str, frozenset[str]]:
"""Upload kind → ArmourShop base set ids it may target (armour excluded)."""
out: dict[str, set[str]] = {}
for row in load_base_sets()["base_sets"]:
for kind in row["kinds"]:
out.setdefault(kind, set()).add(row["id"])
return {kind: frozenset(ids) for kind, ids in out.items()}


def armor_single_sets() -> dict[str, str]:
"""Single armour set tier id → label (``wild_mage`` → ``Wild Mage``)."""
return {
row["id"]: row["label"] for row in load_base_sets()["armor"]["single_sets"]
}


def limit(name: str) -> int:
return int(load_base_sets()["limits"][name])


def ranged_3d_total_bytes() -> int:
"""Every file of a bow_3d / crossbow_3d upload together, for every rank."""
return limit("ranged_3d_total_bytes")


def mcmeta_max_bytes() -> int:
return limit("mcmeta_max_bytes")
61 changes: 58 additions & 3 deletions backend/src/skins/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,57 @@
"fixed": {"scale": [2.5, 2.5, 2.5]},
}

# Vanilla item/bow.json and item/crossbow.json hand poses (autofill holes only).
_BOW_3D_DEFAULTS: dict[str, Any] = {
**copy.deepcopy(_DEFAULT_TAB),
"thirdperson_righthand": {
"rotation": [-80, 260, -40],
"translation": [-1, -2, 2.5],
"scale": [0.9, 0.9, 0.9],
},
"thirdperson_lefthand": {
"rotation": [-80, -280, 40],
"translation": [-1, -2, 2.5],
"scale": [0.9, 0.9, 0.9],
},
"firstperson_righthand": {
"rotation": [0, -90, 25],
"translation": [1.13, 3.2, 1.13],
"scale": [0.68, 0.68, 0.68],
},
"firstperson_lefthand": {
"rotation": [0, 90, -25],
"translation": [1.13, 3.2, 1.13],
"scale": [0.68, 0.68, 0.68],
},
}
_CROSSBOW_3D_DEFAULTS: dict[str, Any] = {
**copy.deepcopy(_DEFAULT_TAB),
"thirdperson_righthand": {
"rotation": [-90, 0, -60],
"translation": [2, 0.1, -3],
"scale": [0.9, 0.9, 0.9],
},
"thirdperson_lefthand": {
"rotation": [-90, 0, 30],
"translation": [2, 0.1, -3],
"scale": [0.9, 0.9, 0.9],
},
"firstperson_righthand": {
"rotation": [-90, 0, -55],
"translation": [1.13, 3.2, 1.13],
"scale": [0.68, 0.68, 0.68],
},
"firstperson_lefthand": {
"rotation": [-90, 0, 35],
"translation": [1.13, 3.2, 1.13],
"scale": [0.68, 0.68, 0.68],
},
}

# Held 3D kinds: hand/ground/gui/fixed tabs, no head unless the player adds one.
_HELD_KINDS = ("item_3d", "gun", "bow_3d", "crossbow_3d")


class DisplayError(ValueError):
"""Invalid or incomplete model display after merge."""
Expand Down Expand Up @@ -182,14 +233,18 @@ def validate_java_block_model(model: dict[str, Any]) -> None:
def required_tabs(kind: str) -> tuple[str, ...]:
if kind in ("shield", "helmet_3d", "armor_helmet_3d", "mask"):
return COMMON_TABS + (HEAD_TAB,)
if kind in ("item_3d", "gun"):
if kind in _HELD_KINDS:
return COMMON_TABS
raise DisplayError(f"Unknown 3D kind for display: {kind}")


def defaults_for_kind(kind: str) -> dict[str, Any]:
if kind == "shield":
return copy.deepcopy(_SHIELD_DEFAULTS)
if kind == "bow_3d":
return copy.deepcopy(_BOW_3D_DEFAULTS)
if kind == "crossbow_3d":
return copy.deepcopy(_CROSSBOW_3D_DEFAULTS)
return copy.deepcopy(_DEFAULT_TAB)


Expand All @@ -203,8 +258,8 @@ def merge_display(
for key, value in submitted.items():
if isinstance(value, dict):
out[key] = copy.deepcopy(value)
# Drop head from item_3d/gun defaults unless player supplied it.
if kind in ("item_3d", "gun") and HEAD_TAB not in (submitted or {}):
# Drop head from held-item defaults unless player supplied it.
if kind in _HELD_KINDS and HEAD_TAB not in (submitted or {}):
out.pop(HEAD_TAB, None)
return out

Expand Down
Loading
Loading