From bbd408c4e65cd35c57d268f4186223d8b2bf31fb Mon Sep 17 00:00:00 2001 From: quinteroac Date: Sun, 26 Apr 2026 13:47:08 -0600 Subject: [PATCH] Add pyproject.toml and requirements.txt for project setup - Created pyproject.toml to define project metadata and dependencies. - Added opencv-python as a required dependency in both pyproject.toml and requirements.txt. --- .gitignore | 3 + README.md | 122 +++ __init__.py | 5 + js/game_assets_maker.js | 105 +++ nodes.py | 1631 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 8 + requirements.txt | 1 + 7 files changed, 1875 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 js/game_assets_maker.js create mode 100644 nodes.py create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98dbeb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dcd530 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# ComfyUI Game Assets Maker + +Custom ComfyUI nodes for game asset preparation workflows. + +## Nodes + +### SeeThrough Parts To SVG Paths + +Converts `SEETHROUGH_PARTS` from `SeeThrough_PostProcess` into SVG path outlines. + +The node traces each part's alpha mask, optionally preserves internal holes with `hierarchy` mode, orders parts by `depth_median`, and can save the generated SVG to ComfyUI's output directory. + +The first output is a ComfyUI `SVG`-compatible object and can be connected directly to the built-in **Save SVG** node. The second output is the same SVG as text for inspection, and the third is the path written by this node when `save_svg` is enabled. + +It intentionally ignores source colors and textures. The output is vector tracing of layer silhouettes only. + +### SeeThrough Parts Rig Probe + +Tests whether `SEETHROUGH_PARTS` tags are useful enough to seed a 2D rig. + +The node reads each part tag, estimates semantic role and side, calculates alpha-based bounds, pivots, and a simple bone hierarchy, then returns `GAME_ASSET_RIG`, editable JSON, and a text report. + +Use the report output to inspect whether tags like `left_arm`, `head`, `torso`, `right_hand`, or similar names are being classified correctly before building a full interactive rig editor. + +### Apply Rig Overrides + +Applies manual JSON edits to a `GAME_ASSET_RIG` while preserving the generated structure. + +Override parts by `id`, `tag`, or `tag:`, and override bones by bone `id`: + +```json +{ + "parts": { + "face": { "pivot": [653, 650] }, + "tag:front hair": { "pivot": [704, 526] } + }, + "bones": { + "face_bone": { "parent": "neck_bone" } + } +} +``` + +The node returns the adjusted `GAME_ASSET_RIG`, updated JSON, and a report of what changed. + +### SeeThrough Parts Pose Rig + +Builds a `GAME_ASSET_RIG` from `SEETHROUGH_PARTS` plus `POSE_KEYPOINT` from **DWPose Estimator** or **OpenPose Pose** in `comfyui_controlnet_aux`. + +Recommended wiring: + +```text +image -> DWPose Estimator.pose_kps +SeeThrough_PostProcess.parts -> SeeThrough Parts Pose Rig.parts +DWPose Estimator.pose_kps -> SeeThrough Parts Pose Rig.pose_kps +SeeThrough Parts Pose Rig.rig -> Rig To SVG Preview.rig +``` + +The node first creates the same automatic tag/bounds rig as **SeeThrough Parts Rig Probe**, then replaces pivots with pose keypoints when available. Missing keypoints fall back to the automatic pivot. + +Current pose mapping: + +```text +face/head -> neck +neck -> neck +nose -> nose +mouth -> mouth landmarks +eye left/right -> left_eye/right_eye +upper_arm -> shoulder +forearm -> elbow +hand -> wrist +thigh -> hip +calf -> knee +foot -> ankle +``` + +The `report` output lists how many pivots came from pose data and which parts used fallback. + +### Rig To SVG Preview + +Renders a `GAME_ASSET_RIG` as an SVG overlay with bounds, bones, pivots, and optional labels. The first output can connect directly to ComfyUI's built-in **Save SVG** node. + +### Rig To Spine Export + +Exports `SEETHROUGH_PARTS` plus a `GAME_ASSET_RIG` as a Spine-style region attachment package. + +Recommended wiring: + +```text +SeeThrough_PostProcess.parts -> Rig To Spine Export.parts +SeeThrough Parts Pose Rig.rig -> Rig To Spine Export.rig +``` + +The node writes a timestamped folder under ComfyUI's output directory: + +```text +game_asset_spine_YYYYmmdd_HHMMSS_xxxxxxxx/ + game_asset_spine_YYYYmmdd_HHMMSS_xxxxxxxx.json + game_asset_spine_YYYYmmdd_HHMMSS_xxxxxxxx.atlas + images/ + face.png + front-hair.png + topwear.png +``` + +Each SeeThrough part becomes a PNG region attachment. Rig bones become Spine bones, slots preserve the rig part order, and image-space Y coordinates are converted to Spine's Y-up coordinates. Exported bones include `length` and setup `rotation` derived from the rig head/tail points, while attachments are counter-rotated in local space so the assembled character stays aligned on import. + +Common semantic parts get a minimal control hierarchy before export. Body/topwear and face become structural controls; hair, iris, and eyelashes become point controls for sway, eye movement, and blinking. Static details like legwear, footwear, ears, brows, nose, mouth, neck, and handwear remain as attachments mapped to the nearest useful control instead of creating extra bones. Attachment paths use the region name, such as `face`, while atlas pages point to files such as `images/face.png`. + +After the node runs, use the **Download Spine ZIP** button on the node to download the generated JSON, atlas, and image folder as a single archive. + +Automatic animation controls: + +```text +animation_preset: all / full_idle / idle_breath / blink / hair_sway / face_alive / no_animations +animation_duration: loop length in seconds +animation_intensity: transform strength multiplier +blink_interval: seconds between blinks +``` + +The default `all` preset writes separate `idle_breath`, `blink`, `hair_sway`, and `face_alive` animations, plus a combined loop named `idle`. These animations use bone transforms only, so they work with the region attachments generated by this MVP exporter. + +This is an MVP region-attachment export, not a mesh/skinning export yet. It is intended as a starting point for importing and refining in Spine. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..c712bcc --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/js/game_assets_maker.js b/js/game_assets_maker.js new file mode 100644 index 0000000..89b2b6a --- /dev/null +++ b/js/game_assets_maker.js @@ -0,0 +1,105 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const SPINE_EXPORT_NODE = "GameAssets_RigToSpineExport"; + +function filenameFromDisposition(disposition, fallback) { + const match = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(disposition || ""); + const value = match?.[1] || match?.[2]; + return value ? decodeURIComponent(value) : fallback; +} + +function rememberExport(node, output) { + const payload = output?.game_assets_spine?.[0] || output?.game_assets_spine; + if (payload?.export_dir) { + node.gameAssetsMakerSpineExport = payload; + } +} + +async function downloadLatestExport(node) { + const exportInfo = node.gameAssetsMakerSpineExport; + if (!exportInfo?.export_dir) { + app.extensionManager?.toast?.add?.({ + severity: "warn", + summary: "Spine export", + detail: "Execute the node before downloading.", + life: 3000, + }); + return; + } + + const response = await api.fetchApi( + `/game_assets_maker/download_spine?dir=${encodeURIComponent(exportInfo.export_dir)}` + ); + if (!response.ok) { + let message = `Download failed (${response.status})`; + try { + const data = await response.json(); + message = data.error || message; + } catch (_error) { + // Keep the status-based message when the response is not JSON. + } + throw new Error(message); + } + + const blob = await response.blob(); + const filename = filenameFromDisposition( + response.headers.get("content-disposition"), + `${exportInfo.asset_name || "spine_export"}.zip` + ); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +function addDownloadButton(node) { + if (node.gameAssetsMakerDownloadButton) { + return; + } + node.gameAssetsMakerDownloadButton = node.addWidget("button", "Download Spine ZIP", null, async () => { + try { + await downloadLatestExport(node); + } catch (error) { + app.extensionManager?.toast?.add?.({ + severity: "error", + summary: "Spine export", + detail: error?.message || "Could not download Spine export.", + life: 5000, + }); + console.error("[GameAssetsMaker] Spine ZIP download failed", error); + } + }); +} + +app.registerExtension({ + name: "ComfyUI.GameAssetsMaker.DownloadSpine", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== SPINE_EXPORT_NODE) { + return; + } + + const originalOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + originalOnNodeCreated?.apply(this, arguments); + addDownloadButton(this); + }; + + const originalOnExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (output) { + originalOnExecuted?.apply(this, arguments); + rememberExport(this, output); + }; + }, + + loadedGraphNode(node) { + if (node.comfyClass === SPINE_EXPORT_NODE) { + addDownloadButton(node); + } + }, +}); \ No newline at end of file diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..e9a0f14 --- /dev/null +++ b/nodes.py @@ -0,0 +1,1631 @@ +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", +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8123828 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "comfyui-game-assets-maker" +description = "ComfyUI nodes for game asset preparation workflows" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "opencv-python>=4.0.0", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cdf15e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +opencv-python>=4.0.0