Files

2720 lines
103 KiB
Python

import json
import math
import os
import re
import uuid
import zipfile
from copy import deepcopy
from datetime import datetime
from io import BytesIO
from xml.sax.saxutils import escape
import cv2
import folder_paths
import numpy as np
import torch
try:
from aiohttp import web
from server import PromptServer
except Exception:
web = None
PromptServer = None
class SVGData:
def __init__(self, data):
self.data = data
def _validated_export_dir(export_dir):
output_root = os.path.abspath(folder_paths.get_output_directory())
requested = os.path.abspath(os.path.expanduser(str(export_dir or "")))
if not requested or not os.path.isdir(requested):
raise ValueError("Spine export directory does not exist")
if os.path.commonpath([output_root, requested]) != output_root:
raise ValueError("Spine export directory must be inside the ComfyUI output directory")
return requested
async def _download_spine_export(request):
try:
export_dir = _validated_export_dir(request.query.get("dir", ""))
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
asset_name = os.path.basename(export_dir.rstrip(os.sep)) or "spine_export"
archive = BytesIO()
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
for root, _dirs, files in os.walk(export_dir):
for filename in files:
file_path = os.path.join(root, filename)
archive_name = os.path.relpath(file_path, export_dir)
zip_file.write(file_path, archive_name)
return web.Response(
body=archive.getvalue(),
content_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{asset_name}.zip"'},
)
def _register_game_assets_routes():
if PromptServer is None or web is None:
return
if getattr(PromptServer.instance, "_game_assets_maker_routes_registered", False):
return
PromptServer.instance.routes.get("/game_assets_maker/download_spine")(_download_spine_export)
PromptServer.instance._game_assets_maker_routes_registered = True
_register_game_assets_routes()
SVG_DEPTH_ORDERS = ["back_to_front", "front_to_back", "input_order"]
SVG_CONTOUR_MODES = ["external", "hierarchy"]
SPINE_EXPORT_VERSION = "spine-export-v4-minimal-control-bones"
SPINE_ANIMATION_PRESETS = ["all", "full_idle", "idle_breath", "blink", "hair_sway", "face_alive", "no_animations"]
SPINE_CONTROL_BONE_ROLES = {"clothing", "torso", "head", "hair", "iris", "eyelash"}
SPINE_POINT_CONTROL_ROLES = {"hair", "iris", "eyelash"}
ASEPRITE_SPRITESHEET_TYPES = ["Half Body", "Full Body", "Face Expressions"]
ASEPRITE_LAYOUT_DIRECTIONS = ["Horizontal", "Vertical", "Grid"]
ASEPRITE_ANIMATION_DIRECTIONS = ["forward", "reverse", "pingpong"]
ASEPRITE_EXPORT_VERSION = "aseprite-atlas-v1"
SIDE_WORDS = {
"left": ["left", "l", "lt", "lhs", "izq", "izquierda"],
"right": ["right", "r", "rt", "rhs", "der", "derecha"],
}
ROLE_PATTERNS = [
("clothing", ["topwear", "clothes", "clothing", "shirt", "jacket", "dress", "ropa", "camisa"]),
("handwear", ["handwear", "glove", "gloves", "mitten", "mittens", "guante"]),
("hand", ["hand", "mano"]),
("forearm", ["forearm", "lowerarm", "lower_arm", "antebrazo"]),
("upper_arm", ["upperarm", "upper_arm", "armupper", "brazoalto"]),
("arm", ["arm", "brazo"]),
("foot", ["foot", "feet", "pie", "shoe", "boot"]),
("calf", ["calf", "shin", "lowerleg", "lower_leg", "pantorrilla"]),
("thigh", ["thigh", "upperleg", "upper_leg", "muslo"]),
("leg", ["leg", "pierna"]),
("head", ["head", "cabeza", "face", "cara"]),
("neck", ["neck", "cuello"]),
("ear", ["ear", "ears", "oreja"]),
("hair", ["hair", "pelo", "cabello"]),
("eyewhite", ["eyewhite", "eye_white", "whiteeye", "white_eye"]),
("iris", ["iris", "irides"]),
("eyelash", ["eyelash", "lashes", "pestaña"]),
("eyebrow", ["eyebrow", "brow", "ceja"]),
("eye", ["eye", "ojo"]),
("nose", ["nose", "nariz"]),
("mouth", ["mouth", "boca"]),
("torso", ["torso", "body", "chest", "spine", "cuerpo", "pecho"]),
("pelvis", ["pelvis", "hips", "hip", "cadera"]),
("weapon", ["weapon", "sword", "gun", "arma", "espada"]),
("accessory", ["accessory", "prop", "item", "accesorio"]),
]
def _safe_svg_id(value):
text = re.sub(r"[^a-zA-Z0-9_.-]+", "-", str(value)).strip("-")
if not text:
text = "part"
if not re.match(r"^[a-zA-Z_]", text):
text = f"part-{text}"
return text
def _format_number(value):
value = float(value)
if abs(value - round(value)) < 1e-6:
return str(int(round(value)))
return f"{value:.3f}".rstrip("0").rstrip(".")
def _safe_filename_stem(value, fallback="sprite"):
text = re.sub(r"[^a-zA-Z0-9_.-]+", "_", str(value or "")).strip("_.-")
return text or fallback
def _parse_sprite_names(sprite_names, sprite_count):
if isinstance(sprite_names, (list, tuple)):
names = [str(name).strip() for name in sprite_names]
else:
text = str(sprite_names or "").strip()
names = []
if text:
try:
loaded = json.loads(text)
if isinstance(loaded, list):
names = [str(name).strip() for name in loaded]
elif isinstance(loaded, dict):
names = [str(loaded.get(str(index + 1), "")).strip() for index in range(sprite_count)]
except json.JSONDecodeError:
names = [line.strip() for line in text.splitlines()]
normalized = []
used = set()
for index in range(sprite_count):
name = names[index] if index < len(names) and names[index] else f"sprite_{index + 1:02d}"
base = _safe_filename_stem(name, f"sprite_{index + 1:02d}")
unique = base
suffix = 2
while unique in used:
unique = f"{base}_{suffix}"
suffix += 1
used.add(unique)
normalized.append(unique)
return normalized
def _build_aseprite_atlas(
sheet_width,
sheet_height,
spritesheet_type,
sprite_count,
layout_direction,
sprite_names,
columns,
image_filename,
frame_duration,
):
sheet_width = int(sheet_width)
sheet_height = int(sheet_height)
sprite_count = int(sprite_count)
columns = int(columns)
if sheet_width <= 0 or sheet_height <= 0:
raise ValueError("Spritesheet width and height must be greater than zero")
if sprite_count <= 0:
raise ValueError("Sprite count must be greater than zero")
layout_direction = str(layout_direction or "Horizontal")
if layout_direction == "Horizontal":
columns = sprite_count
elif layout_direction == "Vertical":
columns = 1
elif layout_direction == "Grid" and columns <= 0:
columns = _balanced_grid_columns(sprite_count)
elif columns <= 0:
columns = sprite_count
if columns > sprite_count:
columns = sprite_count
rows = int(math.ceil(sprite_count / columns))
if sheet_width % columns != 0:
raise ValueError(f"Spritesheet width {sheet_width} is not divisible by columns {columns}")
if sheet_height % rows != 0:
raise ValueError(f"Spritesheet height {sheet_height} is not divisible by calculated rows {rows}")
frame_width = sheet_width // columns
frame_height = sheet_height // rows
names = _parse_sprite_names(sprite_names, sprite_count)
frames = {}
for index, name in enumerate(names):
col = index % columns
row = index // columns
frame_key = f"{name}.png"
frames[frame_key] = {
"frame": {"x": col * frame_width, "y": row * frame_height, "w": frame_width, "h": frame_height},
"rotated": False,
"trimmed": False,
"spriteSourceSize": {"x": 0, "y": 0, "w": frame_width, "h": frame_height},
"sourceSize": {"w": frame_width, "h": frame_height},
"duration": int(frame_duration),
}
atlas = {
"frames": frames,
"meta": {
"app": "ComfyUI Game Assets Maker",
"version": ASEPRITE_EXPORT_VERSION,
"image": str(image_filename or "spritesheet.png"),
"format": "RGBA8888",
"size": {"w": sheet_width, "h": sheet_height},
"scale": "1",
"spritesheetType": spritesheet_type,
"layout": {
"direction": layout_direction,
"columns": columns,
"rows": rows,
"spriteCount": sprite_count,
"frameWidth": frame_width,
"frameHeight": frame_height,
},
},
}
return atlas
def _balanced_grid_columns(item_count):
item_count = max(1, int(item_count))
best_columns = item_count
best_score = None
for columns in range(1, item_count + 1):
rows = int(math.ceil(item_count / columns))
empty_cells = columns * rows - item_count
score = (empty_cells, abs(columns - rows), -columns)
if best_score is None or score < best_score:
best_score = score
best_columns = columns
return best_columns
def _load_aseprite_atlas(aseprite_json):
if isinstance(aseprite_json, dict):
atlas = deepcopy(aseprite_json)
else:
try:
atlas = json.loads(str(aseprite_json or ""))
except json.JSONDecodeError as error:
raise ValueError(f"Invalid Aseprite JSON: {error}") from error
if not isinstance(atlas, dict):
raise ValueError("Invalid Aseprite JSON: expected an object")
return atlas
def _aseprite_frame_items(atlas):
frames = atlas.get("frames")
if isinstance(frames, dict):
items = list(frames.items())
elif isinstance(frames, list):
items = []
for index, frame_data in enumerate(frames):
if not isinstance(frame_data, dict):
continue
filename = frame_data.get("filename") or f"sprite_{index + 1:02d}.png"
items.append((str(filename), frame_data))
else:
raise ValueError("Invalid Aseprite JSON: missing frames object/list")
parsed = []
for filename, frame_data in items:
if not isinstance(frame_data, dict) or not isinstance(frame_data.get("frame"), dict):
continue
frame = frame_data["frame"]
try:
x = int(frame["x"])
y = int(frame["y"])
w = int(frame["w"])
h = int(frame["h"])
except (KeyError, TypeError, ValueError) as error:
raise ValueError(f"Invalid frame coordinates for {filename}") from error
if w <= 0 or h <= 0:
raise ValueError(f"Invalid frame size for {filename}: {w}x{h}")
parsed.append({"name": str(filename), "x": x, "y": y, "w": w, "h": h})
if not parsed:
raise ValueError("Aseprite JSON does not contain any valid frames")
return parsed
def _extract_aseprite_sprites(image, frame_items, image_index=0, background_value=0.0):
if not isinstance(image, torch.Tensor) or image.ndim != 4:
raise ValueError("Expected IMAGE tensor with shape [batch, height, width, channels]")
batch_index = max(0, min(int(image_index), image.shape[0] - 1))
sheet = image[batch_index]
sheet_h, sheet_w = int(sheet.shape[0]), int(sheet.shape[1])
channels = int(sheet.shape[2])
max_w = max(item["w"] for item in frame_items)
max_h = max(item["h"] for item in frame_items)
sprites = []
clipped = []
for item in frame_items:
x1 = max(0, item["x"])
y1 = max(0, item["y"])
x2 = min(sheet_w, item["x"] + item["w"])
y2 = min(sheet_h, item["y"] + item["h"])
if x1 >= x2 or y1 >= y2:
raise ValueError(f"Frame {item['name']} is outside the spritesheet bounds")
crop = sheet[y1:y2, x1:x2, :]
canvas = torch.full(
(max_h, max_w, channels),
float(background_value),
dtype=sheet.dtype,
device=sheet.device,
)
canvas[: crop.shape[0], : crop.shape[1], :] = crop
sprites.append(canvas)
if x1 != item["x"] or y1 != item["y"] or x2 != item["x"] + item["w"] or y2 != item["y"] + item["h"]:
clipped.append(item["name"])
return torch.stack(sprites, dim=0), clipped
def _parse_aseprite_animation_tags(animation_tags, animation_count, frame_count):
if isinstance(animation_tags, (list, tuple)):
raw_tags = list(animation_tags)
else:
text = str(animation_tags or "").strip()
raw_tags = []
if text:
try:
loaded = json.loads(text)
if isinstance(loaded, list):
raw_tags = loaded
except json.JSONDecodeError:
raw_tags = []
frame_count = max(1, int(frame_count))
animation_count = max(1, int(animation_count))
tags = []
for index in range(animation_count):
raw = raw_tags[index] if index < len(raw_tags) and isinstance(raw_tags[index], dict) else {}
name = _safe_filename_stem(raw.get("name") or f"animation_{index + 1:02d}", f"animation_{index + 1:02d}")
from_frame = int(raw.get("from", 0))
to_frame = int(raw.get("to", frame_count - 1))
direction = str(raw.get("direction") or "forward").lower()
color = str(raw.get("color") or "#000000ff")
if direction not in ASEPRITE_ANIMATION_DIRECTIONS:
direction = "forward"
from_frame = max(0, min(frame_count - 1, from_frame))
to_frame = max(0, min(frame_count - 1, to_frame))
if from_frame > to_frame:
from_frame, to_frame = to_frame, from_frame
if not re.match(r"^#[0-9a-fA-F]{8}$", color):
color = "#000000ff"
tags.append(
{
"name": name,
"from": from_frame,
"to": to_frame,
"direction": direction,
"color": color.lower(),
}
)
return tags
def _build_aseprite_animation_json(aseprite_json, frame_count, animation_count, animation_tags):
if str(aseprite_json or "").strip():
output = _load_aseprite_atlas(aseprite_json)
try:
inferred_frame_count = len(_aseprite_frame_items(output))
except ValueError:
inferred_frame_count = int(frame_count)
frame_count = inferred_frame_count or int(frame_count)
else:
output = {"meta": {}}
tags = _parse_aseprite_animation_tags(animation_tags, animation_count, frame_count)
meta = output.setdefault("meta", {})
meta["frameTags"] = tags
return output, tags
def _aseprite_animation_frame_names(frame_count, tags, fallback_prefix):
frame_count = max(1, int(frame_count))
counters = {}
names = []
for frame_index in range(frame_count):
owner = None
for tag in tags:
if int(tag["from"]) <= frame_index <= int(tag["to"]):
owner = tag["name"]
break
base = _safe_filename_stem(owner or fallback_prefix or "frame", "frame")
counters[base] = counters.get(base, 0) + 1
names.append(f"{base}_{counters[base]:02d}")
return names
def _build_aseprite_animation_atlas(
sheet_width,
sheet_height,
frame_count,
layout_direction,
columns,
animation_count,
animation_tags,
image_filename,
frame_duration,
frame_name_prefix,
):
tags = _parse_aseprite_animation_tags(animation_tags, animation_count, frame_count)
frame_names = _aseprite_animation_frame_names(frame_count, tags, frame_name_prefix)
atlas = _build_aseprite_atlas(
sheet_width=sheet_width,
sheet_height=sheet_height,
spritesheet_type="Animation",
sprite_count=frame_count,
layout_direction=layout_direction,
sprite_names=frame_names,
columns=columns,
image_filename=image_filename,
frame_duration=frame_duration,
)
atlas["meta"]["frameTags"] = tags
atlas["meta"]["atlasType"] = "Animation"
return atlas, tags, frame_names
def _aseprite_frame_tags(atlas):
tags = atlas.get("meta", {}).get("frameTags", [])
if not isinstance(tags, list):
return []
normalized = []
for index, tag in enumerate(tags):
if not isinstance(tag, dict):
continue
try:
from_frame = int(tag.get("from", 0))
to_frame = int(tag.get("to", from_frame))
except (TypeError, ValueError):
continue
direction = str(tag.get("direction") or "forward").lower()
if direction not in ASEPRITE_ANIMATION_DIRECTIONS:
direction = "forward"
normalized.append(
{
"name": str(tag.get("name") or f"animation_{index + 1:02d}"),
"from": from_frame,
"to": to_frame,
"direction": direction,
"color": str(tag.get("color") or "#000000ff"),
}
)
return normalized
def _animation_frame_indexes(tag, frame_count, loop_count=1, include_pingpong_endpoint=False):
if frame_count <= 0:
return []
start = max(0, min(frame_count - 1, int(tag.get("from", 0))))
end = max(0, min(frame_count - 1, int(tag.get("to", start))))
if start > end:
start, end = end, start
indexes = list(range(start, end + 1))
direction = str(tag.get("direction") or "forward").lower()
if direction == "reverse":
indexes = list(reversed(indexes))
elif direction == "pingpong" and len(indexes) > 1:
reverse_tail = list(reversed(indexes if include_pingpong_endpoint else indexes[1:-1]))
indexes = indexes + reverse_tail
loop_count = max(1, int(loop_count))
return indexes * loop_count
def _image_tensor_size(image, image_index=0):
if not isinstance(image, torch.Tensor) or image.ndim != 4:
raise ValueError("Expected IMAGE tensor with shape [batch, height, width, channels]")
batch_index = max(0, min(int(image_index), image.shape[0] - 1))
return int(image[batch_index].shape[1]), int(image[batch_index].shape[0]), batch_index
def _json_excerpt(value, limit=6000):
text = str(value or "")
if len(text) <= limit:
return text
return text[:limit] + "\n...TRUNCATED..."
def _build_aseprite_vlm_audit_prompt(atlas, image_width, image_height, atlas_kind):
frame_items = _aseprite_frame_items(atlas)
tags = _aseprite_frame_tags(atlas)
current_frames = [
{"name": item["name"], "x": item["x"], "y": item["y"], "w": item["w"], "h": item["h"]}
for item in frame_items
]
prompt_payload = {
"task": "Inspect the provided spritesheet image and correct the Aseprite JSON frame rectangles.",
"atlas_kind": atlas_kind,
"image_size": {"w": image_width, "h": image_height},
"current_frames": current_frames,
"current_frame_tags": tags,
"rules": [
"Return only JSON. No markdown, no explanation.",
"Do not invent new animation names or expression names unless a frame is truly missing a usable name.",
"Keep the same frame order as current_frames whenever possible.",
"Coordinates are pixel rectangles in image space: x, y, w, h.",
"All rectangles must stay inside image_size.",
"If the current JSON is already correct, return the same rectangles.",
"For visual_novel atlases, frames may be expressions or character states.",
"For animation atlases, frames are sequential animation frames and frameTags define ranges.",
],
"response_schema": {
"frames": [
{"name": "existing_frame_name.png", "x": 0, "y": 0, "w": 256, "h": 256}
],
"layout": {
"direction": "Horizontal | Vertical | Grid | Unknown",
"columns": 1,
"rows": 1,
},
"frameTags": tags,
"notes": ["short correction note"],
},
}
return json.dumps(prompt_payload, indent=2, ensure_ascii=False)
def _extract_json_object(text):
text = str(text or "").strip()
if not text:
raise ValueError("VLM response is empty")
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
if fenced:
text = fenced.group(1).strip()
if not text.startswith("{"):
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
text = text[start : end + 1]
try:
parsed = json.loads(text)
except json.JSONDecodeError as error:
raise ValueError(f"Could not parse VLM response as JSON: {error}") from error
if not isinstance(parsed, dict):
raise ValueError("VLM response JSON must be an object")
return parsed
def _vlm_frames_from_response(vlm_data):
frames = vlm_data.get("frames")
if isinstance(frames, list):
parsed = []
for index, frame in enumerate(frames):
if not isinstance(frame, dict):
continue
name = str(frame.get("name") or frame.get("filename") or f"frame_{index + 1:02d}.png")
try:
parsed.append(
{
"name": name,
"x": int(frame["x"]),
"y": int(frame["y"]),
"w": int(frame["w"]),
"h": int(frame["h"]),
}
)
except (KeyError, TypeError, ValueError) as error:
raise ValueError(f"Invalid VLM frame rectangle at index {index}") from error
return parsed
if isinstance(frames, dict):
parsed = []
for name, frame_data in frames.items():
if not isinstance(frame_data, dict):
continue
rect = frame_data.get("frame", frame_data)
if not isinstance(rect, dict):
continue
try:
parsed.append(
{
"name": str(name),
"x": int(rect["x"]),
"y": int(rect["y"]),
"w": int(rect["w"]),
"h": int(rect["h"]),
}
)
except (KeyError, TypeError, ValueError) as error:
raise ValueError(f"Invalid VLM frame rectangle for {name}") from error
return parsed
raise ValueError("VLM response must include frames as a list or object")
def _clamp_frame_rect(frame, image_width, image_height):
x = max(0, min(int(frame["x"]), image_width - 1))
y = max(0, min(int(frame["y"]), image_height - 1))
w = max(1, int(frame["w"]))
h = max(1, int(frame["h"]))
if x + w > image_width:
w = image_width - x
if y + h > image_height:
h = image_height - y
return {"name": frame["name"], "x": x, "y": y, "w": max(1, w), "h": max(1, h)}
def _apply_vlm_aseprite_correction(atlas, vlm_response, image_width, image_height):
corrected = deepcopy(atlas)
current_items = _aseprite_frame_items(corrected)
vlm_data = _extract_json_object(vlm_response)
vlm_frames = [_clamp_frame_rect(frame, image_width, image_height) for frame in _vlm_frames_from_response(vlm_data)]
if not vlm_frames:
raise ValueError("VLM response did not include any usable frames")
current_names = [item["name"] for item in current_items]
name_set = set(current_names)
use_index_mapping = len(vlm_frames) == len(current_items)
updated_frames = {}
changed = []
for index, vlm_frame in enumerate(vlm_frames):
frame_name = vlm_frame["name"]
if frame_name not in name_set and use_index_mapping:
frame_name = current_names[index]
old_frame_data = {}
if isinstance(corrected.get("frames"), dict):
old_frame_data = deepcopy(corrected["frames"].get(frame_name, {}))
old_rect = old_frame_data.get("frame", {})
new_rect = {"x": vlm_frame["x"], "y": vlm_frame["y"], "w": vlm_frame["w"], "h": vlm_frame["h"]}
old_frame_data["frame"] = new_rect
old_frame_data["rotated"] = bool(old_frame_data.get("rotated", False))
old_frame_data["trimmed"] = bool(old_frame_data.get("trimmed", False))
old_frame_data["spriteSourceSize"] = {"x": 0, "y": 0, "w": new_rect["w"], "h": new_rect["h"]}
old_frame_data["sourceSize"] = {"w": new_rect["w"], "h": new_rect["h"]}
old_frame_data["duration"] = int(old_frame_data.get("duration", 100))
updated_frames[frame_name] = old_frame_data
if old_rect != new_rect:
changed.append(frame_name)
corrected["frames"] = updated_frames
meta = corrected.setdefault("meta", {})
meta["size"] = {"w": image_width, "h": image_height}
layout = vlm_data.get("layout")
if isinstance(layout, dict):
frame_widths = [frame_data["frame"]["w"] for frame_data in updated_frames.values()]
frame_heights = [frame_data["frame"]["h"] for frame_data in updated_frames.values()]
frame_width = frame_widths[0] if len(set(frame_widths)) == 1 else max(frame_widths)
frame_height = frame_heights[0] if len(set(frame_heights)) == 1 else max(frame_heights)
meta["layout"] = {
**meta.get("layout", {}),
"direction": str(layout.get("direction", meta.get("layout", {}).get("direction", "Unknown"))),
"columns": int(layout.get("columns", meta.get("layout", {}).get("columns", 1)) or 1),
"rows": int(layout.get("rows", meta.get("layout", {}).get("rows", 1)) or 1),
"spriteCount": len(updated_frames),
"frameWidth": int(frame_width),
"frameHeight": int(frame_height),
}
frame_tags = vlm_data.get("frameTags")
if isinstance(frame_tags, list):
meta["frameTags"] = frame_tags
return corrected, changed, vlm_data
def _contour_to_path(contour, offset_x, offset_y, close_path=True):
points = contour.reshape(-1, 2)
if len(points) < 2:
return ""
commands = [f"M {_format_number(points[0][0] + offset_x)} {_format_number(points[0][1] + offset_y)}"]
for point in points[1:]:
commands.append(f"L {_format_number(point[0] + offset_x)} {_format_number(point[1] + offset_y)}")
if close_path:
commands.append("Z")
return " ".join(commands)
def _sort_parts(tag2pinfo, depth_order):
items = list(tag2pinfo.items())
if depth_order == "input_order":
return items
reverse = depth_order == "back_to_front"
return sorted(items, key=lambda item: item[1].get("depth_median", 1.0), reverse=reverse)
def _svg_attributes(**values):
attrs = []
for key, value in values.items():
if value is None:
continue
attr_name = key.replace("_", "-")
attr_value = escape(str(value), {'"': "&quot;"})
attrs.append(f'{attr_name}="{attr_value}"')
return " ".join(attrs)
def _tag_tokens(tag):
text = str(tag).lower()
spaced = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", str(tag)).lower()
return [token for token in re.split(r"[^a-z0-9]+", spaced) if token], text.replace("-", "_")
def _detect_side(tag):
tokens, compact = _tag_tokens(tag)
token_set = set(tokens)
for side, words in SIDE_WORDS.items():
if token_set.intersection(words):
return side
if compact.startswith(("l_", "left_")) or compact.endswith(("_l", "_left")):
return "left"
if compact.startswith(("r_", "right_")) or compact.endswith(("_r", "_right")):
return "right"
return "center"
def _detect_role(tag):
tokens, compact = _tag_tokens(tag)
token_set = set(tokens)
for role, patterns in ROLE_PATTERNS:
for pattern in patterns:
if pattern in token_set or pattern in compact:
return role
return "unknown"
def _part_bounds(pinfo, alpha_threshold):
img = pinfo.get("img")
xyxy = pinfo.get("xyxy", [0, 0, 0, 0])
offset_x, offset_y = int(xyxy[0]), int(xyxy[1])
if isinstance(img, np.ndarray) and img.ndim == 3 and img.shape[-1] >= 4:
alpha = img[..., 3]
ys, xs = np.where(alpha > int(alpha_threshold))
if xs.size and ys.size:
x1 = int(xs.min()) + offset_x
y1 = int(ys.min()) + offset_y
x2 = int(xs.max()) + offset_x + 1
y2 = int(ys.max()) + offset_y + 1
centroid = [float(xs.mean() + offset_x), float(ys.mean() + offset_y)]
return [x1, y1, x2, y2], centroid
if len(xyxy) >= 4:
x1, y1, x2, y2 = [int(value) for value in xyxy[:4]]
else:
x1 = y1 = x2 = y2 = 0
return [x1, y1, x2, y2], [(x1 + x2) / 2.0, (y1 + y2) / 2.0]
def _pivot_for_role(role, bounds, centroid):
x1, y1, x2, y2 = bounds
cx = (x1 + x2) / 2.0
cy = (y1 + y2) / 2.0
if role == "head":
return [cx, y2]
if role == "hair":
return centroid
if role == "neck":
return [cx, y1]
if role in {"hand", "forearm", "upper_arm", "arm", "thigh", "calf", "leg", "foot"}:
return [cx, y1]
if role in {"ear", "eyewhite", "iris", "eyelash", "eyebrow", "eye", "nose", "mouth", "handwear", "weapon", "accessory"}:
return centroid
return [cx, cy]
def _bone_tail_for_role(role, bounds, pivot):
x1, y1, x2, y2 = bounds
cx = (x1 + x2) / 2.0
cy = (y1 + y2) / 2.0
if role in {"head", "hair", "ear"}:
return [cx, y1]
if role in {"neck", "torso", "pelvis", "clothing"}:
return [cx, y2]
if role in {"hand", "forearm", "upper_arm", "arm", "thigh", "calf", "leg", "foot"}:
return [cx, y2]
return [cx, cy]
def _find_part_id(parts, role, side=None):
for part in parts:
if part["role"] != role:
continue
if side is None or part["side"] == side or part["side"] == "center":
return part["id"]
return None
def _suggest_parent(part, parts):
role = part["role"]
side = part["side"]
if role == "head":
return _find_part_id(parts, "neck") or _find_part_id(parts, "torso")
if role in {"ear", "hair", "eyewhite", "eyebrow", "eye", "nose", "mouth"}:
return _find_part_id(parts, "head")
if role in {"iris", "eyelash"}:
return _find_part_id(parts, "eyewhite", side) or _find_part_id(parts, "eye", side) or _find_part_id(parts, "head")
if role == "neck":
return _find_part_id(parts, "torso") or _find_part_id(parts, "clothing")
if role == "clothing":
return _find_part_id(parts, "torso")
if role == "handwear":
return _find_part_id(parts, "hand", side) or _find_part_id(parts, "clothing") or _find_part_id(parts, "torso")
if role == "pelvis":
return _find_part_id(parts, "torso")
if role == "upper_arm":
return _find_part_id(parts, "torso")
if role == "forearm":
return _find_part_id(parts, "upper_arm", side) or _find_part_id(parts, "arm", side)
if role == "hand":
return _find_part_id(parts, "forearm", side) or _find_part_id(parts, "arm", side)
if role == "arm":
return _find_part_id(parts, "torso")
if role == "thigh":
return _find_part_id(parts, "pelvis") or _find_part_id(parts, "torso")
if role == "calf":
return _find_part_id(parts, "thigh", side) or _find_part_id(parts, "leg", side)
if role == "foot":
return _find_part_id(parts, "calf", side) or _find_part_id(parts, "leg", side)
if role == "leg":
return _find_part_id(parts, "pelvis") or _find_part_id(parts, "torso")
if role in {"weapon", "accessory"}:
return _find_part_id(parts, "hand", side) or _find_part_id(parts, "torso")
return None
def _round_point(point):
return [round(float(point[0]), 3), round(float(point[1]), 3)]
def _load_rig(value):
if isinstance(value, str):
value = json.loads(value)
if not isinstance(value, dict):
raise ValueError("Expected GAME_ASSET_RIG dict or rig JSON string")
return deepcopy(value)
def _load_overrides(value):
text = (value or "").strip()
if not text:
return {}
overrides = json.loads(text)
if not isinstance(overrides, dict):
raise ValueError("Rig overrides must be a JSON object")
return overrides
def _override_items(value, key_name):
if value is None:
return []
if isinstance(value, dict):
return list(value.items())
if isinstance(value, list):
items = []
for item in value:
if not isinstance(item, dict):
continue
item_id = item.get(key_name) or item.get("id") or item.get("tag")
if item_id:
data = dict(item)
data.pop(key_name, None)
items.append((str(item_id), data))
return items
raise ValueError("Override sections must be objects or lists")
def _find_index_by_id_or_tag(items, selector):
if selector.startswith("tag:"):
tag = selector[4:]
for index, item in enumerate(items):
if item.get("tag") == tag:
return index
return None
for index, item in enumerate(items):
if item.get("id") == selector or item.get("tag") == selector:
return index
return None
def _merge_override(target, override):
for key, value in override.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
_merge_override(target[key], value)
else:
target[key] = value
def _apply_rig_overrides(rig, overrides):
changed_parts = []
changed_bones = []
parts = rig.setdefault("parts", [])
for selector, override in _override_items(overrides.get("parts"), "part"):
index = _find_index_by_id_or_tag(parts, str(selector))
if index is None:
raise ValueError(f"Unknown rig part override target: {selector}")
_merge_override(parts[index], override)
changed_parts.append(parts[index].get("id", str(selector)))
bones = rig.setdefault("bones", [])
for selector, override in _override_items(overrides.get("bones"), "bone"):
index = _find_index_by_id_or_tag(bones, str(selector))
if index is None:
raise ValueError(f"Unknown rig bone override target: {selector}")
_merge_override(bones[index], override)
changed_bones.append(bones[index].get("id", str(selector)))
rig["overrides"] = {
"parts": changed_parts,
"bones": changed_bones,
}
return changed_parts, changed_bones
def _rig_report(rig, title="Rig"):
stats = rig.get("stats", {})
canvas = rig.get("canvas", {})
parts = rig.get("parts", [])
bones = rig.get("bones", [])
lines = [
title,
f'canvas: {canvas.get("width", "?")}x{canvas.get("height", "?")}',
f'parts: {stats.get("parts", len(parts))} | bones: {len(bones)}',
"",
]
for part in parts:
bone = next((item for item in bones if item.get("part") == part.get("id")), {})
parent = bone.get("parent") or "root"
lines.append(
f'- {part.get("tag", part.get("id"))}: role={part.get("role")}, side={part.get("side")}, '
f'pivot={part.get("pivot")}, parent={parent}'
)
return "\n".join(lines)
def _rig_to_svg(rig, show_bounds, show_labels, bone_color, pivot_color, label_color, stroke_width):
canvas = rig.get("canvas", {})
width = int(canvas.get("width", 1024))
height = int(canvas.get("height", 1024))
parts = rig.get("parts", [])
bones = rig.get("bones", [])
lines = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
" <title>Game asset rig preview</title>",
' <g id="rig-bounds" fill="none">',
]
if show_bounds:
for part in parts:
bounds = part.get("bounds") or [0, 0, 0, 0]
x1, y1, x2, y2 = [float(value) for value in bounds[:4]]
attrs = _svg_attributes(
x=_format_number(x1),
y=_format_number(y1),
width=_format_number(max(0.0, x2 - x1)),
height=_format_number(max(0.0, y2 - y1)),
stroke="#999999",
stroke_width="1",
stroke_dasharray="6 4",
data_part=part.get("id", ""),
)
lines.append(f" <rect {attrs}/>")
lines.append(" </g>")
lines.append(' <g id="rig-bones" fill="none">')
for bone in bones:
head = bone.get("head") or [0, 0]
tail = bone.get("tail") or head
attrs = _svg_attributes(
x1=_format_number(head[0]),
y1=_format_number(head[1]),
x2=_format_number(tail[0]),
y2=_format_number(tail[1]),
stroke=bone_color,
stroke_width=_format_number(stroke_width),
stroke_linecap="round",
data_bone=bone.get("id", ""),
data_parent=bone.get("parent", "") or "",
)
lines.append(f" <line {attrs}/>")
lines.append(" </g>")
lines.append(' <g id="rig-pivots">')
for part in parts:
pivot = part.get("pivot") or [0, 0]
attrs = _svg_attributes(
cx=_format_number(pivot[0]),
cy=_format_number(pivot[1]),
r="5",
fill=pivot_color,
stroke="#ffffff",
stroke_width="2",
data_part=part.get("id", ""),
)
lines.append(f" <circle {attrs}/>")
if show_labels:
label_attrs = _svg_attributes(
x=_format_number(pivot[0] + 8),
y=_format_number(pivot[1] - 8),
fill=label_color,
font_size="18",
font_family="monospace",
data_part=part.get("id", ""),
)
lines.append(f" <text {label_attrs}>{escape(str(part.get('tag', part.get('id', ''))))}</text>")
lines.append(" </g>")
lines.append("</svg>")
return "\n".join(lines)
def _keypoints_to_array(keypoints, canvas_width, canvas_height):
if keypoints is None:
return np.zeros((0, 3), dtype=np.float32)
array = np.asarray(keypoints, dtype=np.float32)
if array.size == 0:
return np.zeros((0, 3), dtype=np.float32)
if array.ndim == 1:
usable = array.size - (array.size % 3)
array = array[:usable].reshape((-1, 3))
elif array.ndim == 2 and array.shape[1] >= 3:
array = array[:, :3]
else:
return np.zeros((0, 3), dtype=np.float32)
if array.size and np.nanmax(np.abs(array[:, :2])) <= 1.0:
array[:, 0] *= float(canvas_width)
array[:, 1] *= float(canvas_height)
return array
def _pose_person(pose_kps, person_index):
if not isinstance(pose_kps, list) or not pose_kps:
return None, 0, 0
frame = pose_kps[0]
if not isinstance(frame, dict):
return None, 0, 0
people = frame.get("people") or []
if person_index < 0 or person_index >= len(people):
return None, int(frame.get("canvas_width", 0)), int(frame.get("canvas_height", 0))
return people[person_index], int(frame.get("canvas_width", 0)), int(frame.get("canvas_height", 0))
def _valid_keypoint(array, index, min_confidence):
if index < 0 or index >= len(array):
return None
point = array[index]
if len(point) < 3 or float(point[2]) < float(min_confidence):
return None
if not np.isfinite(point[:2]).all():
return None
return [float(point[0]), float(point[1])]
def _mean_keypoint(array, indexes, min_confidence):
points = [_valid_keypoint(array, index, min_confidence) for index in indexes]
points = [point for point in points if point is not None]
if not points:
return None
return [float(np.mean([point[0] for point in points])), float(np.mean([point[1] for point in points]))]
def _pose_points_from_person(person, canvas_width, canvas_height, min_confidence):
if not isinstance(person, dict):
return {}
body = _keypoints_to_array(person.get("pose_keypoints_2d"), canvas_width, canvas_height)
face = _keypoints_to_array(person.get("face_keypoints_2d"), canvas_width, canvas_height)
left_hand = _keypoints_to_array(person.get("hand_left_keypoints_2d"), canvas_width, canvas_height)
right_hand = _keypoints_to_array(person.get("hand_right_keypoints_2d"), canvas_width, canvas_height)
points = {
"nose": _valid_keypoint(body, 0, min_confidence) or _mean_keypoint(face, range(27, 36), min_confidence),
"neck": _valid_keypoint(body, 1, min_confidence),
"right_shoulder": _valid_keypoint(body, 2, min_confidence),
"right_elbow": _valid_keypoint(body, 3, min_confidence),
"right_wrist": _valid_keypoint(body, 4, min_confidence),
"left_shoulder": _valid_keypoint(body, 5, min_confidence),
"left_elbow": _valid_keypoint(body, 6, min_confidence),
"left_wrist": _valid_keypoint(body, 7, min_confidence),
"right_hip": _valid_keypoint(body, 8, min_confidence),
"right_knee": _valid_keypoint(body, 9, min_confidence),
"right_ankle": _valid_keypoint(body, 10, min_confidence),
"left_hip": _valid_keypoint(body, 11, min_confidence),
"left_knee": _valid_keypoint(body, 12, min_confidence),
"left_ankle": _valid_keypoint(body, 13, min_confidence),
"right_eye": _valid_keypoint(body, 14, min_confidence) or _mean_keypoint(face, range(42, 48), min_confidence),
"left_eye": _valid_keypoint(body, 15, min_confidence) or _mean_keypoint(face, range(36, 42), min_confidence),
"mouth": _mean_keypoint(face, range(48, 68), min_confidence),
"face": _mean_keypoint(face, range(0, 68), min_confidence),
"left_hand": _mean_keypoint(left_hand, range(0, 21), min_confidence),
"right_hand": _mean_keypoint(right_hand, range(0, 21), min_confidence),
}
return {key: _round_point(value) for key, value in points.items() if value is not None}
def _pose_source_for_part(part):
role = part.get("role")
side = part.get("side")
if role == "head":
return "neck"
if role == "neck":
return "neck"
if role == "nose":
return "nose"
if role == "mouth":
return "mouth"
if role in {"eyewhite", "iris", "eyelash", "eyebrow", "eye"}:
return f"{side}_eye" if side in {"left", "right"} else None
if role == "upper_arm":
return f"{side}_shoulder" if side in {"left", "right"} else None
if role == "forearm":
return f"{side}_elbow" if side in {"left", "right"} else None
if role == "hand":
return f"{side}_wrist" if side in {"left", "right"} else None
if role == "thigh":
return f"{side}_hip" if side in {"left", "right"} else None
if role == "calf":
return f"{side}_knee" if side in {"left", "right"} else None
if role == "foot":
return f"{side}_ankle" if side in {"left", "right"} else None
return None
def _pose_tail_source_for_bone(bone):
role = bone.get("role")
side = bone.get("side")
if role == "head":
return "nose"
if role == "neck":
return "face"
if role == "upper_arm":
return f"{side}_elbow" if side in {"left", "right"} else None
if role == "forearm":
return f"{side}_wrist" if side in {"left", "right"} else None
if role == "thigh":
return f"{side}_knee" if side in {"left", "right"} else None
if role == "calf":
return f"{side}_ankle" if side in {"left", "right"} else None
return None
def _apply_pose_points_to_rig(rig, pose_points):
part_sources = {}
bone_sources = {}
for part in rig.get("parts", []):
source = _pose_source_for_part(part)
if source and source in pose_points:
part["pivot"] = pose_points[source]
part["pivot_source"] = f"pose:{source}"
part_sources[part["id"]] = source
else:
part["pivot_source"] = "fallback:auto"
parts_by_id = {part.get("id"): part for part in rig.get("parts", [])}
for bone in rig.get("bones", []):
part = parts_by_id.get(bone.get("part"))
if part is not None:
bone["head"] = part.get("pivot", bone.get("head"))
source = _pose_tail_source_for_bone(bone)
if source and source in pose_points:
bone["tail"] = pose_points[source]
bone["tail_source"] = f"pose:{source}"
bone_sources[bone["id"]] = source
else:
bone["tail_source"] = "fallback:auto"
rig["pose"] = {
"points": pose_points,
"parts_from_pose": part_sources,
"bones_from_pose": bone_sources,
}
return part_sources, bone_sources
def _spine_y(value, canvas_height, scale):
return (float(canvas_height) - float(value)) * float(scale)
def _spine_point(point, canvas_height, scale):
return [float(point[0]) * float(scale), _spine_y(point[1], canvas_height, scale)]
def _part_image_info(part, tag2pinfo):
pinfo = tag2pinfo.get(part.get("tag")) or tag2pinfo.get(part.get("id"))
if not isinstance(pinfo, dict):
return None, [0, 0, 0, 0]
img = pinfo.get("img")
xyxy = pinfo.get("xyxy") or part.get("bounds") or [0, 0, 0, 0]
return img, [float(value) for value in xyxy[:4]]
def _write_part_png(img, output_path):
if not isinstance(img, np.ndarray) or img.ndim != 3:
return False
image = img
if image.dtype != np.uint8:
image = np.clip(image, 0.0, 1.0) * 255.0 if np.issubdtype(image.dtype, np.floating) else np.clip(image, 0, 255)
image = image.astype(np.uint8)
if image.shape[-1] == 4:
image = cv2.cvtColor(image, cv2.COLOR_RGBA2BGRA)
elif image.shape[-1] == 3:
image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
else:
return False
return bool(cv2.imwrite(output_path, image))
def _spine_bone_name(part_id):
return f"{part_id}_bone"
def _rotate_point(point, degrees):
radians = math.radians(float(degrees))
cos_value = math.cos(radians)
sin_value = math.sin(radians)
x, y = float(point[0]), float(point[1])
return [x * cos_value - y * sin_value, x * sin_value + y * cos_value]
def _to_local_point(point, origin, rotation_degrees):
return _rotate_point([float(point[0]) - float(origin[0]), float(point[1]) - float(origin[1])], -float(rotation_degrees))
def _spine_bone_shape(bone, absolute, parent_rotation, canvas_height, scale):
if bone.get("role") in SPINE_POINT_CONTROL_ROLES:
return None, float(parent_rotation)
tail = bone.get("tail") or bone.get("head")
tail_absolute = _spine_point(tail, canvas_height, scale)
dx = tail_absolute[0] - absolute[0]
dy = tail_absolute[1] - absolute[1]
length = math.hypot(dx, dy)
if length <= 0.001:
return None, parent_rotation
absolute_rotation = math.degrees(math.atan2(dy, dx))
local_rotation = absolute_rotation - float(parent_rotation)
return {
"rotation": round(local_rotation, 3),
"length": round(length, 3),
}, absolute_rotation
def _spine_setup_bones(spine_bones):
return {
bone.get("name"): {
"x": float(bone.get("x", 0.0)),
"y": float(bone.get("y", 0.0)),
"rotation": float(bone.get("rotation", 0.0)),
}
for bone in spine_bones
}
def _rig_bones_by_role(rig, roles=None):
requested = set(roles) if roles is not None else None
return [
bone
for bone in rig.get("bones", [])
if requested is None or bone.get("role") in requested
]
def _add_timeline(animation, bone_name, timeline_name, frames):
if not frames:
return
timelines = animation.setdefault("bones", {}).setdefault(bone_name, {})
timelines.setdefault(timeline_name, []).extend(frames)
def _translate_frames(setup_bones, bone_name, points):
setup = setup_bones.get(bone_name, {"x": 0.0, "y": 0.0})
return [
{"time": round(float(time), 3), "x": round(setup["x"] + float(dx), 3), "y": round(setup["y"] + float(dy), 3)}
for time, dx, dy in points
]
def _rotate_frames(points):
return [{"time": round(float(time), 3), "value": round(float(value), 3)} for time, value in points]
def _scale_frames(points):
return [{"time": round(float(time), 3), "x": round(float(x), 3), "y": round(float(y), 3)} for time, x, y in points]
def _merge_spine_animation(base, extra):
for bone_name, timelines in extra.get("bones", {}).items():
target = base.setdefault("bones", {}).setdefault(bone_name, {})
for timeline_name, frames in timelines.items():
target.setdefault(timeline_name, []).extend(frames)
target[timeline_name].sort(key=lambda frame: float(frame.get("time", 0.0)))
return base
def _build_idle_breath_animation(rig, spine_bones, duration, intensity):
duration = max(0.25, float(duration))
strength = max(0.0, float(intensity))
setup_bones = _spine_setup_bones(spine_bones)
animation = {"bones": {}}
amplitudes = {
"torso": 8.0,
"clothing": 8.0,
"neck": 4.0,
"head": 3.0,
}
for bone in _rig_bones_by_role(rig, amplitudes.keys()):
bone_name = bone.get("id")
if bone_name not in setup_bones:
continue
amplitude = amplitudes.get(bone.get("role"), 3.0) * strength
_add_timeline(
animation,
bone_name,
"translate",
_translate_frames(setup_bones, bone_name, [(0.0, 0.0, 0.0), (duration / 2.0, 0.0, amplitude), (duration, 0.0, 0.0)]),
)
return animation
def _build_blink_animation(rig, spine_bones, duration, intensity, blink_interval):
duration = max(0.25, float(duration))
strength = max(0.0, min(1.0, float(intensity)))
interval = max(0.25, float(blink_interval))
closed_y = max(0.05, 1.0 - (0.9 * strength))
animation = {"bones": {}}
eye_roles = {"eyewhite", "iris", "eyelash", "eye"}
eye_bones = [bone.get("id") for bone in _rig_bones_by_role(rig, eye_roles)]
eye_bones = [bone_name for bone_name in eye_bones if any(item.get("name") == bone_name for item in spine_bones)]
if not eye_bones:
return animation
blink_times = []
current = min(0.45, duration / 2.0)
while current < duration:
blink_times.append(current)
current += interval
if not blink_times:
blink_times.append(duration / 2.0)
for bone_name in eye_bones:
points = [(0.0, 1.0, 1.0)]
for time in blink_times:
close_time = min(duration, time + 0.045)
open_time = min(duration, time + 0.13)
points.extend([(time, 1.0, 1.0), (close_time, 1.0, closed_y), (open_time, 1.0, 1.0)])
points.append((duration, 1.0, 1.0))
_add_timeline(animation, bone_name, "scale", _scale_frames(points))
return animation
def _build_hair_sway_animation(rig, spine_bones, duration, intensity):
duration = max(0.25, float(duration))
strength = max(0.0, float(intensity))
setup_bones = _spine_setup_bones(spine_bones)
animation = {"bones": {}}
for index, bone in enumerate(_rig_bones_by_role(rig, {"hair"})):
bone_name = bone.get("id")
if bone_name not in setup_bones:
continue
base_rotation = setup_bones[bone_name]["rotation"]
amplitude = (4.0 + (index % 3)) * strength
if "back" in str(bone.get("part", "")).lower():
amplitude *= -0.8
_add_timeline(
animation,
bone_name,
"rotate",
_rotate_frames(
[
(0.0, base_rotation),
(duration * 0.25, base_rotation + amplitude),
(duration * 0.75, base_rotation - amplitude),
(duration, base_rotation),
]
),
)
return animation
def _build_face_alive_animation(rig, spine_bones, duration, intensity):
duration = max(0.25, float(duration))
strength = max(0.0, float(intensity))
setup_bones = _spine_setup_bones(spine_bones)
animation = {"bones": {}}
for bone in _rig_bones_by_role(rig, {"iris"}):
bone_name = bone.get("id")
if bone_name not in setup_bones:
continue
direction = -1.0 if bone.get("side") == "right" else 1.0
amplitude = 3.0 * strength * direction
_add_timeline(
animation,
bone_name,
"translate",
_translate_frames(
setup_bones,
bone_name,
[(0.0, 0.0, 0.0), (duration * 0.35, amplitude, 0.8 * strength), (duration * 0.7, -amplitude, 0.0), (duration, 0.0, 0.0)],
),
)
for bone in _rig_bones_by_role(rig, {"mouth"}):
bone_name = bone.get("id")
if bone_name not in setup_bones:
continue
amount = 0.04 * strength
_add_timeline(
animation,
bone_name,
"scale",
_scale_frames([(0.0, 1.0, 1.0), (duration / 2.0, 1.0 + amount, 1.0 - amount), (duration, 1.0, 1.0)]),
)
return animation
def _build_spine_animations(rig, spine_bones, preset, duration, intensity, blink_interval):
if preset in {None, "", "none"}:
preset = "all"
preset = preset if preset in SPINE_ANIMATION_PRESETS else "all"
if preset == "no_animations":
return {}
builders = {
"idle_breath": lambda: _build_idle_breath_animation(rig, spine_bones, duration, intensity),
"blink": lambda: _build_blink_animation(rig, spine_bones, duration, intensity, blink_interval),
"hair_sway": lambda: _build_hair_sway_animation(rig, spine_bones, duration, intensity),
"face_alive": lambda: _build_face_alive_animation(rig, spine_bones, duration, intensity),
}
animations = {}
if preset in builders:
animations[preset] = builders[preset]()
return animations
idle = {"bones": {}}
for name, builder in builders.items():
built = builder()
if preset == "all":
animations[name] = built
_merge_spine_animation(idle, built)
animations["idle"] = idle
return animations
def _spine_control_bone_id(part_id):
return _spine_bone_name(part_id) if part_id else None
def _spine_export_bone_ids(rig):
export_ids = {"root"}
for bone in rig.get("bones", []):
if bone.get("role") in SPINE_CONTROL_BONE_ROLES:
export_ids.add(bone.get("id") or _spine_bone_name(bone.get("part", "part")))
return export_ids
def _rig_bones_by_id(rig):
return {
bone.get("id") or _spine_bone_name(bone.get("part", "part")): bone
for bone in rig.get("bones", [])
}
def _nearest_exported_parent_bone(bone, bones_by_id, export_ids):
parent = bone.get("parent") or "root"
visited = set()
while parent and parent != "root" and parent not in visited:
if parent in export_ids:
return parent
visited.add(parent)
parent_bone = bones_by_id.get(parent)
parent = parent_bone.get("parent") if parent_bone else "root"
return "root"
def _part_role_by_id(rig):
return {part.get("id"): part.get("role") for part in rig.get("parts", [])}
def _attachment_bone_for_part(part, rig, bones_by_part, bones_by_id, export_ids):
part_id = part.get("id")
candidate = _spine_control_bone_id(part_id)
if candidate in export_ids:
return candidate
role = part.get("role")
side = part.get("side")
parts = rig.get("parts", [])
fallback_part_id = None
if role in {"eyewhite", "eyebrow", "eye", "nose", "mouth", "ear"}:
fallback_part_id = _find_part_id(parts, "head")
elif role in {"neck", "handwear", "hand", "forearm", "upper_arm", "arm", "accessory"}:
fallback_part_id = _find_part_id(parts, "clothing") or _find_part_id(parts, "torso")
elif role in {"leg", "foot", "thigh", "calf"}:
return "root"
elif role in {"weapon"}:
fallback_part_id = _find_part_id(parts, "hand", side) or _find_part_id(parts, "clothing")
fallback_bone = _spine_control_bone_id(fallback_part_id)
if fallback_bone in export_ids:
return fallback_bone
source_bone = bones_by_part.get(part_id)
if source_bone:
parent = _nearest_exported_parent_bone(source_bone, bones_by_id, export_ids)
if parent in export_ids:
return parent
return "root"
def _build_spine_bones(rig, canvas_height, scale):
bones = [{"name": "root"}]
bone_abs = {"root": [0.0, 0.0]}
bone_rotation_abs = {"root": 0.0}
export_ids = _spine_export_bone_ids(rig)
bones_by_id = _rig_bones_by_id(rig)
rig_bones = [bone for bone in rig.get("bones", []) if (bone.get("id") or _spine_bone_name(bone.get("part", "part"))) in export_ids]
pending = list(rig_bones)
while pending:
progressed = False
next_pending = []
for bone in pending:
name = bone.get("id") or _spine_bone_name(bone.get("part", "part"))
parent = _nearest_exported_parent_bone(bone, bones_by_id, export_ids)
if parent not in bone_abs:
next_pending.append(bone)
continue
head = bone.get("head") or [0, canvas_height]
absolute = _spine_point(head, canvas_height, scale)
parent_absolute = bone_abs[parent]
parent_rotation = bone_rotation_abs.get(parent, 0.0)
shape, absolute_rotation = _spine_bone_shape(
bone,
absolute,
parent_rotation,
canvas_height,
scale,
)
local_position = _to_local_point(absolute, parent_absolute, parent_rotation)
entry = {
"name": name,
"parent": parent,
"x": round(local_position[0], 3),
"y": round(local_position[1], 3),
}
if shape:
entry.update(shape)
bones.append(entry)
bone_abs[name] = absolute
bone_rotation_abs[name] = absolute_rotation
progressed = True
if not progressed:
for bone in next_pending:
name = bone.get("id") or _spine_bone_name(bone.get("part", "part"))
head = bone.get("head") or [0, canvas_height]
absolute = _spine_point(head, canvas_height, scale)
shape, absolute_rotation = _spine_bone_shape(bone, absolute, 0.0, canvas_height, scale)
entry = {"name": name, "parent": "root", "x": round(absolute[0], 3), "y": round(absolute[1], 3)}
if shape:
entry.update(shape)
bones.append(entry)
bone_abs[name] = absolute
bone_rotation_abs[name] = absolute_rotation
break
pending = next_pending
return bones, bone_abs, bone_rotation_abs, export_ids
def _build_spine_atlas(regions):
lines = []
for region in regions:
lines.extend(
[
f'{region["page"]}.png',
f'size: {region["width"]},{region["height"]}',
"format: RGBA8888",
"filter: Linear,Linear",
"repeat: none",
region["name"],
" rotate: false",
" xy: 0, 0",
f' size: {region["width"]}, {region["height"]}',
f' orig: {region["width"]}, {region["height"]}',
" offset: 0, 0",
" index: -1",
"",
]
)
return "\n".join(lines)
def _build_spine_export(parts, rig, export_dir, asset_name, scale, animation_preset, animation_duration, animation_intensity, blink_interval):
tag2pinfo = parts.get("tag2pinfo")
frame_size = parts.get("frame_size")
if not isinstance(tag2pinfo, dict) or frame_size is None:
raise ValueError("Invalid SEETHROUGH_PARTS data: missing tag2pinfo or frame_size")
canvas_h, canvas_w = [int(value) for value in frame_size[:2]]
loaded_rig = _load_rig(rig)
images_dir = os.path.join(export_dir, "images")
os.makedirs(images_dir, exist_ok=True)
spine_bones, bone_abs, bone_rotation_abs, export_bone_ids = _build_spine_bones(loaded_rig, canvas_h, scale)
animations = _build_spine_animations(
loaded_rig,
spine_bones,
animation_preset,
animation_duration,
animation_intensity,
blink_interval,
)
slots = []
attachments = {}
atlas_regions = []
exported_parts = []
skipped_parts = []
bones_by_id = _rig_bones_by_id(loaded_rig)
bones_by_part = {bone.get("part"): bone for bone in loaded_rig.get("bones", [])}
for part in loaded_rig.get("parts", []):
part_id = part.get("id") or _safe_svg_id(part.get("tag", "part"))
img, xyxy = _part_image_info(part, tag2pinfo)
if not isinstance(img, np.ndarray) or img.ndim != 3:
skipped_parts.append(part_id)
continue
height, width = int(img.shape[0]), int(img.shape[1])
if width <= 0 or height <= 0:
skipped_parts.append(part_id)
continue
image_path = os.path.join(images_dir, f"{part_id}.png")
if not _write_part_png(img, image_path):
skipped_parts.append(part_id)
continue
bone_name = _attachment_bone_for_part(part, loaded_rig, bones_by_part, bones_by_id, export_bone_ids)
x1, y1, x2, y2 = xyxy
center = [(x1 + x2) / 2.0, (y1 + y2) / 2.0]
center_spine = _spine_point(center, canvas_h, scale)
bone_position = bone_abs.get(bone_name, [0.0, 0.0])
bone_rotation = bone_rotation_abs.get(bone_name, 0.0)
local_center = _to_local_point(center_spine, bone_position, bone_rotation)
attachment_name = part_id
attachment_path = part_id
slots.append({"name": part_id, "bone": bone_name, "attachment": attachment_name})
attachments[part_id] = {
attachment_name: {
"type": "region",
"path": attachment_path,
"x": round(local_center[0], 3),
"y": round(local_center[1], 3),
"rotation": round(-bone_rotation, 3),
"width": round(width * float(scale), 3),
"height": round(height * float(scale), 3),
}
}
atlas_regions.append({"page": f"images/{part_id}", "name": attachment_path, "width": width, "height": height})
exported_parts.append(part_id)
spine_json = {
"skeleton": {
"hash": "",
"spine": "4.1",
"x": 0,
"y": 0,
"width": round(canvas_w * float(scale), 3),
"height": round(canvas_h * float(scale), 3),
"images": "./images/",
},
"bones": spine_bones,
"slots": slots,
"skins": [{"name": "default", "attachments": attachments}],
"animations": animations,
}
spine_json_text = json.dumps(spine_json, indent=2, ensure_ascii=False)
atlas_text = _build_spine_atlas(atlas_regions)
json_path = os.path.join(export_dir, f"{asset_name}.json")
atlas_path = os.path.join(export_dir, f"{asset_name}.atlas")
with open(json_path, "w", encoding="utf-8") as json_file:
json_file.write(spine_json_text)
with open(atlas_path, "w", encoding="utf-8") as atlas_file:
atlas_file.write(atlas_text)
return {
"json": spine_json_text,
"atlas": atlas_text,
"export_dir": export_dir,
"json_path": json_path,
"atlas_path": atlas_path,
"images_dir": images_dir,
"exported_parts": exported_parts,
"skipped_parts": skipped_parts,
}
class GameAssets_SeeThroughPartsToSVGPaths:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"parts": ("SEETHROUGH_PARTS",),
"contour_mode": (SVG_CONTOUR_MODES, {"default": "hierarchy"}),
"depth_order": (SVG_DEPTH_ORDERS, {"default": "back_to_front"}),
"alpha_threshold": ("INT", {"default": 10, "min": 0, "max": 255, "step": 1}),
"simplify_epsilon": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 64.0, "step": 0.1}),
"min_area": ("INT", {"default": 16, "min": 0, "max": 1048576, "step": 1}),
"stroke_width": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 64.0, "step": 0.1}),
"stroke_color": ("STRING", {"default": "#111111"}),
"fill_paths": ("BOOLEAN", {"default": False}),
"fill_color": ("STRING", {"default": "none"}),
"save_svg": ("BOOLEAN", {"default": True}),
"filename_prefix": ("STRING", {"default": "seethrough_trace"}),
}
}
RETURN_TYPES = ("SVG", "STRING", "STRING")
RETURN_NAMES = ("svg", "svg_text", "svg_path")
FUNCTION = "trace"
CATEGORY = "Game Assets/Canvas"
def trace(
self,
parts,
contour_mode="hierarchy",
depth_order="back_to_front",
alpha_threshold=10,
simplify_epsilon=1.5,
min_area=16,
stroke_width=1.0,
stroke_color="#111111",
fill_paths=False,
fill_color="none",
save_svg=True,
filename_prefix="seethrough_trace",
):
if not isinstance(parts, dict):
raise ValueError("Expected SEETHROUGH_PARTS dict from SeeThrough_PostProcess")
tag2pinfo = parts.get("tag2pinfo")
frame_size = parts.get("frame_size")
if not isinstance(tag2pinfo, dict) or frame_size is None:
raise ValueError("Invalid SEETHROUGH_PARTS data: missing tag2pinfo or frame_size")
canvas_h, canvas_w = [int(value) for value in frame_size[:2]]
retrieval_mode = cv2.RETR_EXTERNAL if contour_mode == "external" else cv2.RETR_CCOMP
path_elements = []
skipped = 0
for tag, pinfo in _sort_parts(tag2pinfo, depth_order):
img = pinfo.get("img")
if img is None or not isinstance(img, np.ndarray) or img.ndim != 3 or img.shape[-1] < 4:
skipped += 1
continue
alpha = img[..., 3]
mask = (alpha > int(alpha_threshold)).astype(np.uint8) * 255
contours, _hierarchy = cv2.findContours(mask, retrieval_mode, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
skipped += 1
continue
xyxy = pinfo.get("xyxy", [0, 0, img.shape[1], img.shape[0]])
offset_x, offset_y = int(xyxy[0]), int(xyxy[1])
subpaths = []
for contour in contours:
area = abs(cv2.contourArea(contour))
if area < int(min_area):
continue
if simplify_epsilon > 0:
contour = cv2.approxPolyDP(contour, float(simplify_epsilon), True)
path_data = _contour_to_path(contour, offset_x, offset_y, close_path=True)
if path_data:
subpaths.append(path_data)
if not subpaths:
skipped += 1
continue
depth = pinfo.get("depth_median", "")
attrs = _svg_attributes(
id=_safe_svg_id(tag),
d=" ".join(subpaths),
fill=fill_color if fill_paths else "none",
stroke=stroke_color if stroke_width > 0 else "none",
stroke_width=_format_number(stroke_width),
fill_rule="evenodd",
vector_effect="non-scaling-stroke",
data_tag=tag,
data_depth=depth,
)
path_elements.append(f" <path {attrs}/>")
svg_lines = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{canvas_w}" height="{canvas_h}" viewBox="0 0 {canvas_w} {canvas_h}">',
" <title>SeeThrough parts trace</title>",
f' <g id="seethrough-parts" data-contour-mode="{escape(contour_mode)}" data-depth-order="{escape(depth_order)}">',
*path_elements,
" </g>",
"</svg>",
]
svg = "\n".join(svg_lines)
svg_output = SVGData([BytesIO(svg.encode("utf-8"))])
svg_path = ""
if save_svg:
output_dir = folder_paths.get_output_directory()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix = str(uuid.uuid4())[:8]
safe_prefix = re.sub(r"[^a-zA-Z0-9_.-]+", "_", filename_prefix).strip("_") or "seethrough_trace"
svg_filename = f"{safe_prefix}_{timestamp}_{suffix}.svg"
svg_path = os.path.join(output_dir, svg_filename)
with open(svg_path, "w", encoding="utf-8") as svg_file:
svg_file.write(svg)
print(
f"[GameAssetsMaker] Traced {len(path_elements)} SVG part paths "
f"({skipped} skipped) from SeeThrough parts",
flush=True,
)
return (svg_output, svg, svg_path)
class GameAssets_SeeThroughPartsRigProbe:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"parts": ("SEETHROUGH_PARTS",),
"alpha_threshold": ("INT", {"default": 10, "min": 0, "max": 255, "step": 1}),
"depth_order": (SVG_DEPTH_ORDERS, {"default": "back_to_front"}),
}
}
RETURN_TYPES = ("GAME_ASSET_RIG", "STRING", "STRING")
RETURN_NAMES = ("rig", "rig_json", "report")
FUNCTION = "probe"
CATEGORY = "Game Assets/Rigging"
def probe(self, parts, alpha_threshold=10, depth_order="back_to_front"):
if not isinstance(parts, dict):
raise ValueError("Expected SEETHROUGH_PARTS dict from SeeThrough_PostProcess")
tag2pinfo = parts.get("tag2pinfo")
frame_size = parts.get("frame_size")
if not isinstance(tag2pinfo, dict) or frame_size is None:
raise ValueError("Invalid SEETHROUGH_PARTS data: missing tag2pinfo or frame_size")
canvas_h, canvas_w = [int(value) for value in frame_size[:2]]
rig_parts = []
for tag, pinfo in _sort_parts(tag2pinfo, depth_order):
role = _detect_role(tag)
side = _detect_side(tag)
bounds, centroid = _part_bounds(pinfo, alpha_threshold)
pivot = _pivot_for_role(role, bounds, centroid)
part_id = _safe_svg_id(tag)
rig_parts.append(
{
"id": part_id,
"tag": str(tag),
"role": role,
"side": side,
"bounds": bounds,
"pivot": _round_point(pivot),
"centroid": _round_point(centroid),
"depth": pinfo.get("depth_median", None),
}
)
bones = []
for part in rig_parts:
parent_part_id = _suggest_parent(part, rig_parts)
bones.append(
{
"id": f'{part["id"]}_bone',
"part": part["id"],
"parent": f"{parent_part_id}_bone" if parent_part_id else None,
"role": part["role"],
"side": part["side"],
"head": part["pivot"],
"tail": _round_point(_bone_tail_for_role(part["role"], part["bounds"], part["pivot"])),
}
)
known_parts = [part for part in rig_parts if part["role"] != "unknown"]
rig = {
"version": 1,
"source": "SEETHROUGH_PARTS",
"canvas": {"width": canvas_w, "height": canvas_h},
"stats": {
"parts": len(rig_parts),
"recognized": len(known_parts),
"unknown": len(rig_parts) - len(known_parts),
},
"parts": rig_parts,
"bones": bones,
}
rig_json = json.dumps(rig, indent=2, ensure_ascii=False)
lines = [
"SeeThrough rig probe",
f"canvas: {canvas_w}x{canvas_h}",
f"parts: {len(rig_parts)} | recognized: {len(known_parts)} | unknown: {len(rig_parts) - len(known_parts)}",
"",
]
for part, bone in zip(rig_parts, bones):
parent = bone["parent"] or "root"
lines.append(
f'- {part["tag"]}: role={part["role"]}, side={part["side"]}, '
f'pivot={part["pivot"]}, parent={parent}'
)
report = "\n".join(lines)
print(f"[GameAssetsMaker] Rig probe recognized {len(known_parts)}/{len(rig_parts)} tagged parts", flush=True)
return (rig, rig_json, report)
class GameAssets_ApplyRigOverrides:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"rig": ("GAME_ASSET_RIG",),
"overrides_json": (
"STRING",
{
"default": '{\n "parts": {},\n "bones": {}\n}',
"multiline": True,
},
),
}
}
RETURN_TYPES = ("GAME_ASSET_RIG", "STRING", "STRING")
RETURN_NAMES = ("rig", "rig_json", "report")
FUNCTION = "apply"
CATEGORY = "Game Assets/Rigging"
def apply(self, rig, overrides_json):
adjusted_rig = _load_rig(rig)
overrides = _load_overrides(overrides_json)
changed_parts, changed_bones = _apply_rig_overrides(adjusted_rig, overrides)
rig_json = json.dumps(adjusted_rig, indent=2, ensure_ascii=False)
report = _rig_report(adjusted_rig, title="Adjusted rig")
report += "\n\nOverrides applied"
report += f"\nparts: {', '.join(changed_parts) if changed_parts else 'none'}"
report += f"\nbones: {', '.join(changed_bones) if changed_bones else 'none'}"
print(
f"[GameAssetsMaker] Applied rig overrides to {len(changed_parts)} parts and {len(changed_bones)} bones",
flush=True,
)
return (adjusted_rig, rig_json, report)
class GameAssets_SeeThroughPartsPoseRig:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"parts": ("SEETHROUGH_PARTS",),
"pose_kps": ("POSE_KEYPOINT",),
"person_index": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1}),
"pose_confidence_threshold": ("FLOAT", {"default": 0.05, "min": 0.0, "max": 1.0, "step": 0.01}),
"alpha_threshold": ("INT", {"default": 10, "min": 0, "max": 255, "step": 1}),
"depth_order": (SVG_DEPTH_ORDERS, {"default": "back_to_front"}),
}
}
RETURN_TYPES = ("GAME_ASSET_RIG", "STRING", "STRING")
RETURN_NAMES = ("rig", "rig_json", "report")
FUNCTION = "rig_from_pose"
CATEGORY = "Game Assets/Rigging"
def rig_from_pose(
self,
parts,
pose_kps,
person_index=0,
pose_confidence_threshold=0.05,
alpha_threshold=10,
depth_order="back_to_front",
):
rig, _rig_json, _report = GameAssets_SeeThroughPartsRigProbe().probe(parts, alpha_threshold, depth_order)
person, pose_width, pose_height = _pose_person(pose_kps, int(person_index))
if person is None:
rig["pose"] = {"error": f"No person found at index {person_index}"}
rig_json = json.dumps(rig, indent=2, ensure_ascii=False)
report = _rig_report(rig, title="Pose rig fallback")
report += f"\n\nNo DWPose/OpenPose person found at index {person_index}; using automatic tag/bounds pivots."
return (rig, rig_json, report)
canvas = rig.get("canvas", {})
canvas_width = int(canvas.get("width", pose_width or 0))
canvas_height = int(canvas.get("height", pose_height or 0))
pose_points = _pose_points_from_person(person, canvas_width, canvas_height, pose_confidence_threshold)
part_sources, bone_sources = _apply_pose_points_to_rig(rig, pose_points)
rig["pose"]["person_index"] = int(person_index)
rig["pose"]["confidence_threshold"] = float(pose_confidence_threshold)
rig_json = json.dumps(rig, indent=2, ensure_ascii=False)
report = _rig_report(rig, title="DWPose assisted rig")
report += "\n\nPose keypoints"
report += f"\npoints detected: {len(pose_points)}"
report += f"\npart pivots from pose: {len(part_sources)}"
report += f"\nbone tails from pose: {len(bone_sources)}"
if part_sources:
report += "\n"
for part_id, source in part_sources.items():
report += f"\n- {part_id}: pose:{source}"
print(
f"[GameAssetsMaker] Built pose rig with {len(part_sources)} pose pivots and {len(bone_sources)} pose bone tails",
flush=True,
)
return (rig, rig_json, report)
class GameAssets_RigToSpineExport:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"parts": ("SEETHROUGH_PARTS",),
"rig": ("GAME_ASSET_RIG",),
"filename_prefix": ("STRING", {"default": "game_asset_spine"}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.001, "max": 100.0, "step": 0.001}),
"animation_preset": (SPINE_ANIMATION_PRESETS, {"default": "all"}),
"animation_duration": ("FLOAT", {"default": 2.0, "min": 0.25, "max": 30.0, "step": 0.05}),
"animation_intensity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}),
"blink_interval": ("FLOAT", {"default": 3.0, "min": 0.25, "max": 30.0, "step": 0.05}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("spine_json", "json_path", "atlas_path", "images_dir", "report")
FUNCTION = "export"
CATEGORY = "Game Assets/Rigging"
OUTPUT_NODE = True
@classmethod
def IS_CHANGED(cls, *args, **kwargs):
return SPINE_EXPORT_VERSION
def export(
self,
parts,
rig,
filename_prefix="game_asset_spine",
scale=1.0,
animation_preset="all",
animation_duration=2.0,
animation_intensity=1.0,
blink_interval=3.0,
):
if not isinstance(parts, dict):
raise ValueError("Expected SEETHROUGH_PARTS dict from SeeThrough_PostProcess")
safe_prefix = re.sub(r"[^a-zA-Z0-9_.-]+", "_", filename_prefix).strip("_") or "game_asset_spine"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix = str(uuid.uuid4())[:8]
asset_name = f"{safe_prefix}_{timestamp}_{suffix}"
export_dir = os.path.join(folder_paths.get_output_directory(), asset_name)
os.makedirs(export_dir, exist_ok=True)
result = _build_spine_export(
parts,
rig,
export_dir,
asset_name,
float(scale),
animation_preset,
float(animation_duration),
float(animation_intensity),
float(blink_interval),
)
animation_names = sorted(json.loads(result["json"]).get("animations", {}).keys())
report = "\n".join(
[
"Spine export",
f"json: {result['json_path']}",
f"atlas: {result['atlas_path']}",
f"images: {result['images_dir']}",
f"exported parts: {len(result['exported_parts'])}",
f"skipped parts: {len(result['skipped_parts'])}",
f"skipped ids: {', '.join(result['skipped_parts']) if result['skipped_parts'] else 'none'}",
f"animations: {', '.join(animation_names) if animation_names else 'none'}",
]
)
print(
f"[GameAssetsMaker] Exported Spine rig with {len(result['exported_parts'])} region attachments",
flush=True,
)
return {
"ui": {
"game_assets_spine": [
{
"asset_name": asset_name,
"export_dir": result["export_dir"],
"json_path": result["json_path"],
"atlas_path": result["atlas_path"],
"images_dir": result["images_dir"],
}
]
},
"result": (result["json"], result["json_path"], result["atlas_path"], result["images_dir"], report),
}
class GameAssets_RigToSVGPreview:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"rig": ("GAME_ASSET_RIG",),
"show_bounds": ("BOOLEAN", {"default": True}),
"show_labels": ("BOOLEAN", {"default": True}),
"stroke_width": ("FLOAT", {"default": 4.0, "min": 0.25, "max": 64.0, "step": 0.25}),
"bone_color": ("STRING", {"default": "#2f80ed"}),
"pivot_color": ("STRING", {"default": "#ff3366"}),
"label_color": ("STRING", {"default": "#111111"}),
}
}
RETURN_TYPES = ("SVG", "STRING")
RETURN_NAMES = ("svg", "svg_text")
FUNCTION = "render"
CATEGORY = "Game Assets/Rigging"
def render(
self,
rig,
show_bounds=True,
show_labels=True,
stroke_width=4.0,
bone_color="#2f80ed",
pivot_color="#ff3366",
label_color="#111111",
):
loaded_rig = _load_rig(rig)
svg = _rig_to_svg(loaded_rig, show_bounds, show_labels, bone_color, pivot_color, label_color, stroke_width)
svg_output = SVGData([BytesIO(svg.encode("utf-8"))])
print(f"[GameAssetsMaker] Rendered rig SVG preview with {len(loaded_rig.get('bones', []))} bones", flush=True)
return (svg_output, svg)
class GameAssets_AsepriteVisualNovelAtlas:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sheet_width": ("INT", {"default": 2048, "min": 1, "max": 65536, "step": 1}),
"sheet_height": ("INT", {"default": 2048, "min": 1, "max": 65536, "step": 1}),
"spritesheet_type": (ASEPRITE_SPRITESHEET_TYPES, {"default": "Half Body"}),
"sprite_count": ("INT", {"default": 4, "min": 1, "max": 128, "step": 1}),
"layout_direction": (ASEPRITE_LAYOUT_DIRECTIONS, {"default": "Horizontal"}),
"columns": ("INT", {"default": 0, "min": 0, "max": 128, "step": 1}),
"sprite_names": (
"STRING",
{
"default": '["neutral", "happy", "sad", "angry"]',
},
),
"image_filename": ("STRING", {"default": "visual_novel_character.png"}),
"frame_duration": ("INT", {"default": 100, "min": 1, "max": 60000, "step": 1}),
"save_json": ("BOOLEAN", {"default": True}),
"filename_prefix": ("STRING", {"default": "vn_character_atlas"}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("aseprite_json", "json_path", "report")
FUNCTION = "generate"
CATEGORY = "Game Assets/Aseprite"
OUTPUT_NODE = True
@classmethod
def IS_CHANGED(cls, *args, **kwargs):
return ASEPRITE_EXPORT_VERSION
def generate(
self,
sheet_width=2048,
sheet_height=2048,
spritesheet_type="Half Body",
sprite_count=4,
layout_direction="Horizontal",
columns=0,
sprite_names='["neutral", "happy", "sad", "angry"]',
image_filename="visual_novel_character.png",
frame_duration=100,
save_json=True,
filename_prefix="vn_character_atlas",
):
atlas = _build_aseprite_atlas(
sheet_width,
sheet_height,
spritesheet_type,
sprite_count,
layout_direction,
sprite_names,
columns,
image_filename,
frame_duration,
)
aseprite_json = json.dumps(atlas, indent=2, ensure_ascii=False)
json_path = ""
if save_json:
output_dir = folder_paths.get_output_directory()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix = str(uuid.uuid4())[:8]
safe_prefix = _safe_filename_stem(filename_prefix, "vn_character_atlas")
json_filename = f"{safe_prefix}_{timestamp}_{suffix}.json"
json_path = os.path.join(output_dir, json_filename)
with open(json_path, "w", encoding="utf-8") as json_file:
json_file.write(aseprite_json)
layout = atlas["meta"]["layout"]
report = "\n".join(
[
"Aseprite visual novel atlas",
f"type: {spritesheet_type}",
f"spritesheet: {int(sheet_width)}x{int(sheet_height)}",
f"sprites: {layout['spriteCount']}",
f"direction: {layout['direction']}",
f"layout: {layout['columns']} columns x {layout['rows']} rows",
f"frame: {layout['frameWidth']}x{layout['frameHeight']}",
f"image: {image_filename}",
f"json: {json_path or 'not saved'}",
]
)
print(
f"[GameAssetsMaker] Generated Aseprite atlas with {layout['spriteCount']} frames "
f"({layout['columns']}x{layout['rows']})",
flush=True,
)
return (aseprite_json, json_path, report)
class GameAssets_AsepriteAtlasSpritePreview:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"spritesheet": ("IMAGE",),
"aseprite_json": ("STRING", {"default": "", "multiline": True}),
"image_index": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
"background_value": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
RETURN_NAMES = ("sprites", "sprite_names", "report")
FUNCTION = "preview"
CATEGORY = "Game Assets/Aseprite"
def preview(self, spritesheet, aseprite_json, image_index=0, background_value=0.0):
atlas = _load_aseprite_atlas(aseprite_json)
frame_items = _aseprite_frame_items(atlas)
sprites, clipped = _extract_aseprite_sprites(spritesheet, frame_items, image_index, background_value)
names = [item["name"] for item in frame_items]
max_w = max(item["w"] for item in frame_items)
max_h = max(item["h"] for item in frame_items)
lines = [
"Aseprite sprite preview",
f"sprites: {len(frame_items)}",
f"output batch: {len(frame_items)} images",
f"canvas per sprite: {max_w}x{max_h}",
f"source image index: {max(0, min(int(image_index), int(spritesheet.shape[0]) - 1))}",
f"clipped frames: {', '.join(clipped) if clipped else 'none'}",
"",
"frames:",
]
for item in frame_items:
lines.append(f"- {item['name']}: x={item['x']} y={item['y']} w={item['w']} h={item['h']}")
report = "\n".join(lines)
print(f"[GameAssetsMaker] Extracted {len(frame_items)} Aseprite sprites for preview", flush=True)
return (sprites, "\n".join(names), report)
class GameAssets_AsepriteAnimationTags:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"frame_count": ("INT", {"default": 4, "min": 1, "max": 10000, "step": 1}),
"animation_count": ("INT", {"default": 1, "min": 1, "max": 128, "step": 1}),
"animation_tags": (
"STRING",
{
"default": '[{"name":"idle","from":0,"to":3,"direction":"forward","color":"#000000ff"}]',
},
),
"save_json": ("BOOLEAN", {"default": True}),
"filename_prefix": ("STRING", {"default": "aseprite_animation_tags"}),
},
"optional": {
"aseprite_json": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("animation_json", "merged_aseprite_json", "json_path", "report")
FUNCTION = "generate"
CATEGORY = "Game Assets/Aseprite"
OUTPUT_NODE = True
def generate(
self,
frame_count=4,
animation_count=1,
animation_tags='[{"name":"idle","from":0,"to":3,"direction":"forward","color":"#000000ff"}]',
save_json=True,
filename_prefix="aseprite_animation_tags",
aseprite_json="",
):
merged, tags = _build_aseprite_animation_json(aseprite_json, frame_count, animation_count, animation_tags)
animation_payload = {"meta": {"frameTags": tags}}
animation_json = json.dumps(animation_payload, indent=2, ensure_ascii=False)
merged_json = json.dumps(merged, indent=2, ensure_ascii=False)
json_path = ""
if save_json:
output_dir = folder_paths.get_output_directory()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix = str(uuid.uuid4())[:8]
safe_prefix = _safe_filename_stem(filename_prefix, "aseprite_animation_tags")
json_filename = f"{safe_prefix}_{timestamp}_{suffix}.json"
json_path = os.path.join(output_dir, json_filename)
with open(json_path, "w", encoding="utf-8") as json_file:
json_file.write(merged_json)
lines = [
"Aseprite animation tags",
f"frame count: {max(1, int(frame_count))}",
f"animations: {len(tags)}",
f"merged atlas: {'yes' if str(aseprite_json or '').strip() else 'no'}",
f"json: {json_path or 'not saved'}",
"",
"tags:",
]
for tag in tags:
lines.append(
f"- {tag['name']}: frames {tag['from']}..{tag['to']} "
f"direction={tag['direction']} color={tag['color']}"
)
report = "\n".join(lines)
print(f"[GameAssetsMaker] Generated {len(tags)} Aseprite animation frame tags", flush=True)
return (animation_json, merged_json, json_path, report)
class GameAssets_AsepriteAnimationAtlas:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sheet_width": ("INT", {"default": 1024, "min": 1, "max": 65536, "step": 1}),
"sheet_height": ("INT", {"default": 1024, "min": 1, "max": 65536, "step": 1}),
"frame_count": ("INT", {"default": 4, "min": 1, "max": 10000, "step": 1}),
"layout_direction": (ASEPRITE_LAYOUT_DIRECTIONS, {"default": "Horizontal"}),
"columns": ("INT", {"default": 0, "min": 0, "max": 128, "step": 1}),
"animation_count": ("INT", {"default": 1, "min": 1, "max": 128, "step": 1}),
"animation_tags": (
"STRING",
{
"default": '[{"name":"idle","from":0,"to":3,"direction":"forward","color":"#000000ff"}]',
},
),
"image_filename": ("STRING", {"default": "animation_spritesheet.png"}),
"frame_duration": ("INT", {"default": 100, "min": 1, "max": 60000, "step": 1}),
"frame_name_prefix": ("STRING", {"default": "frame"}),
"save_json": ("BOOLEAN", {"default": True}),
"filename_prefix": ("STRING", {"default": "aseprite_animation_atlas"}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("aseprite_json", "json_path", "report")
FUNCTION = "generate"
CATEGORY = "Game Assets/Aseprite"
OUTPUT_NODE = True
def generate(
self,
sheet_width=1024,
sheet_height=1024,
frame_count=4,
layout_direction="Horizontal",
columns=0,
animation_count=1,
animation_tags='[{"name":"idle","from":0,"to":3,"direction":"forward","color":"#000000ff"}]',
image_filename="animation_spritesheet.png",
frame_duration=100,
frame_name_prefix="frame",
save_json=True,
filename_prefix="aseprite_animation_atlas",
):
atlas, tags, frame_names = _build_aseprite_animation_atlas(
sheet_width,
sheet_height,
frame_count,
layout_direction,
columns,
animation_count,
animation_tags,
image_filename,
frame_duration,
frame_name_prefix,
)
aseprite_json = json.dumps(atlas, indent=2, ensure_ascii=False)
json_path = ""
if save_json:
output_dir = folder_paths.get_output_directory()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix = str(uuid.uuid4())[:8]
safe_prefix = _safe_filename_stem(filename_prefix, "aseprite_animation_atlas")
json_filename = f"{safe_prefix}_{timestamp}_{suffix}.json"
json_path = os.path.join(output_dir, json_filename)
with open(json_path, "w", encoding="utf-8") as json_file:
json_file.write(aseprite_json)
layout = atlas["meta"]["layout"]
lines = [
"Aseprite animation atlas",
f"spritesheet: {int(sheet_width)}x{int(sheet_height)}",
f"frames: {layout['spriteCount']}",
f"direction: {layout['direction']}",
f"layout: {layout['columns']} columns x {layout['rows']} rows",
f"frame: {layout['frameWidth']}x{layout['frameHeight']}",
f"image: {image_filename}",
f"json: {json_path or 'not saved'}",
"",
"animations:",
]
for tag in tags:
lines.append(
f"- {tag['name']}: frames {tag['from']}..{tag['to']} "
f"direction={tag['direction']} color={tag['color']}"
)
lines.extend(["", "frame names:", *[f"- {name}.png" for name in frame_names]])
report = "\n".join(lines)
print(
f"[GameAssetsMaker] Generated animation atlas with {len(frame_names)} frames and {len(tags)} tags",
flush=True,
)
return (aseprite_json, json_path, report)
class GameAssets_AsepriteAnimationPreview:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"spritesheet": ("IMAGE",),
"aseprite_json": ("STRING", {"default": "", "multiline": True}),
"animation_name": ("STRING", {"default": "idle"}),
"image_index": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
"loop_count": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1}),
"include_pingpong_endpoint": ("BOOLEAN", {"default": False}),
"background_value": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "INT", "STRING")
RETURN_NAMES = ("animation_frames", "frame_names", "frame_delay_ms", "report")
FUNCTION = "preview"
CATEGORY = "Game Assets/Aseprite"
def preview(
self,
spritesheet,
aseprite_json,
animation_name="idle",
image_index=0,
loop_count=1,
include_pingpong_endpoint=False,
background_value=0.0,
):
atlas = _load_aseprite_atlas(aseprite_json)
frame_items = _aseprite_frame_items(atlas)
tags = _aseprite_frame_tags(atlas)
if not tags:
raise ValueError("Aseprite JSON does not contain meta.frameTags")
selected = None
requested = str(animation_name or "").strip()
for tag in tags:
if tag["name"] == requested:
selected = tag
break
if selected is None:
selected = tags[0]
indexes = _animation_frame_indexes(
selected,
len(frame_items),
loop_count=loop_count,
include_pingpong_endpoint=include_pingpong_endpoint,
)
if not indexes:
raise ValueError(f"Animation {selected['name']} does not resolve to any frames")
selected_items = [frame_items[index] for index in indexes]
animation_frames, clipped = _extract_aseprite_sprites(
spritesheet,
selected_items,
image_index=image_index,
background_value=background_value,
)
names = [item["name"] for item in selected_items]
durations = []
raw_frames = atlas.get("frames", {})
for item in selected_items:
frame_data = raw_frames.get(item["name"], {}) if isinstance(raw_frames, dict) else {}
try:
durations.append(int(frame_data.get("duration", 100)))
except (TypeError, ValueError):
durations.append(100)
frame_delay_ms = int(round(sum(durations) / len(durations))) if durations else 100
report = "\n".join(
[
"Aseprite animation preview",
f"requested animation: {requested or '(first tag)'}",
f"selected animation: {selected['name']}",
f"direction: {selected['direction']}",
f"source range: {selected['from']}..{selected['to']}",
f"output frames: {len(indexes)}",
f"frame indexes: {', '.join(str(index) for index in indexes)}",
f"frame delay: {frame_delay_ms} ms",
f"clipped frames: {', '.join(clipped) if clipped else 'none'}",
f"available animations: {', '.join(tag['name'] for tag in tags)}",
]
)
print(
f"[GameAssetsMaker] Built animation preview '{selected['name']}' with {len(indexes)} frames",
flush=True,
)
return (animation_frames, "\n".join(names), frame_delay_ms, report)
class GameAssets_AsepriteVLMAuditPrompt:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"spritesheet": ("IMAGE",),
"aseprite_json": ("STRING", {"default": "", "multiline": True}),
"atlas_kind": (["Auto", "Visual Novel", "Animation"], {"default": "Auto"}),
"image_index": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
RETURN_NAMES = ("spritesheet", "vlm_prompt", "report")
FUNCTION = "build"
CATEGORY = "Game Assets/Aseprite"
def build(self, spritesheet, aseprite_json, atlas_kind="Auto", image_index=0):
atlas = _load_aseprite_atlas(aseprite_json)
image_width, image_height, batch_index = _image_tensor_size(spritesheet, image_index)
if atlas_kind == "Auto":
meta = atlas.get("meta", {})
atlas_kind = "Animation" if meta.get("frameTags") or meta.get("atlasType") == "Animation" else "Visual Novel"
prompt = _build_aseprite_vlm_audit_prompt(atlas, image_width, image_height, atlas_kind)
report = "\n".join(
[
"Aseprite VLM audit prompt",
f"atlas kind: {atlas_kind}",
f"image index: {batch_index}",
f"image size: {image_width}x{image_height}",
f"frames: {len(_aseprite_frame_items(atlas))}",
"Send the image output and vlm_prompt to a VLM node, then connect the VLM text response to Apply VLM Correction.",
]
)
return (spritesheet, prompt, report)
class GameAssets_AsepriteApplyVLMCorrection:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"spritesheet": ("IMAGE",),
"aseprite_json": ("STRING", {"default": "", "multiline": True}),
"vlm_response": ("STRING", {"default": "", "multiline": True}),
"image_index": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
"save_json": ("BOOLEAN", {"default": True}),
"filename_prefix": ("STRING", {"default": "aseprite_vlm_corrected"}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("corrected_aseprite_json", "json_path", "report")
FUNCTION = "apply"
CATEGORY = "Game Assets/Aseprite"
OUTPUT_NODE = True
def apply(
self,
spritesheet,
aseprite_json,
vlm_response,
image_index=0,
save_json=True,
filename_prefix="aseprite_vlm_corrected",
):
atlas = _load_aseprite_atlas(aseprite_json)
image_width, image_height, batch_index = _image_tensor_size(spritesheet, image_index)
corrected, changed, vlm_data = _apply_vlm_aseprite_correction(
atlas,
vlm_response,
image_width,
image_height,
)
corrected_json = json.dumps(corrected, indent=2, ensure_ascii=False)
json_path = ""
if save_json:
output_dir = folder_paths.get_output_directory()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix = str(uuid.uuid4())[:8]
safe_prefix = _safe_filename_stem(filename_prefix, "aseprite_vlm_corrected")
json_filename = f"{safe_prefix}_{timestamp}_{suffix}.json"
json_path = os.path.join(output_dir, json_filename)
with open(json_path, "w", encoding="utf-8") as json_file:
json_file.write(corrected_json)
notes = vlm_data.get("notes", [])
if isinstance(notes, list):
notes_text = "; ".join(str(note) for note in notes) if notes else "none"
else:
notes_text = str(notes)
report = "\n".join(
[
"Aseprite VLM correction",
f"image index: {batch_index}",
f"image size: {image_width}x{image_height}",
f"frames changed: {len(changed)}",
f"changed ids: {', '.join(changed) if changed else 'none'}",
f"notes: {notes_text}",
f"json: {json_path or 'not saved'}",
]
)
print(f"[GameAssetsMaker] Applied VLM correction to {len(changed)} Aseprite frames", flush=True)
return (corrected_json, json_path, report)
NODE_CLASS_MAPPINGS = {
"GameAssets_SeeThroughPartsToSVGPaths": GameAssets_SeeThroughPartsToSVGPaths,
"GameAssets_SeeThroughPartsRigProbe": GameAssets_SeeThroughPartsRigProbe,
"GameAssets_SeeThroughPartsPoseRig": GameAssets_SeeThroughPartsPoseRig,
"GameAssets_ApplyRigOverrides": GameAssets_ApplyRigOverrides,
"GameAssets_RigToSpineExport": GameAssets_RigToSpineExport,
"GameAssets_RigToSVGPreview": GameAssets_RigToSVGPreview,
"GameAssets_AsepriteVisualNovelAtlas": GameAssets_AsepriteVisualNovelAtlas,
"GameAssets_AsepriteAtlasSpritePreview": GameAssets_AsepriteAtlasSpritePreview,
"GameAssets_AsepriteAnimationTags": GameAssets_AsepriteAnimationTags,
"GameAssets_AsepriteAnimationAtlas": GameAssets_AsepriteAnimationAtlas,
"GameAssets_AsepriteAnimationPreview": GameAssets_AsepriteAnimationPreview,
"GameAssets_AsepriteVLMAuditPrompt": GameAssets_AsepriteVLMAuditPrompt,
"GameAssets_AsepriteApplyVLMCorrection": GameAssets_AsepriteApplyVLMCorrection,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"GameAssets_SeeThroughPartsToSVGPaths": "SeeThrough Parts To SVG Paths",
"GameAssets_SeeThroughPartsRigProbe": "SeeThrough Parts Rig Probe",
"GameAssets_SeeThroughPartsPoseRig": "SeeThrough Parts Pose Rig",
"GameAssets_ApplyRigOverrides": "Apply Rig Overrides",
"GameAssets_RigToSpineExport": "Rig To Spine Export",
"GameAssets_RigToSVGPreview": "Rig To SVG Preview",
"GameAssets_AsepriteVisualNovelAtlas": "Aseprite Visual Novel Atlas",
"GameAssets_AsepriteAtlasSpritePreview": "Aseprite Atlas Sprite Preview",
"GameAssets_AsepriteAnimationTags": "Aseprite Animation Tags",
"GameAssets_AsepriteAnimationAtlas": "Aseprite Animation Atlas",
"GameAssets_AsepriteAnimationPreview": "Aseprite Animation Preview",
"GameAssets_AsepriteVLMAuditPrompt": "Aseprite VLM Audit Prompt",
"GameAssets_AsepriteApplyVLMCorrection": "Aseprite Apply VLM Correction",
}