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 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"} 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 _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), {'"': """}) 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'', " Game asset rig preview", ' ', ] 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" ") lines.append(" ") lines.append(' ') 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" ") lines.append(" ") lines.append(' ') 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" ") 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" {escape(str(part.get('tag', part.get('id', ''))))}") lines.append(" ") lines.append("") 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" ") svg_lines = [ f'', " SeeThrough parts trace", f' ', *path_elements, " ", "", ] 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) 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, } 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", }