| | import gzip
|
| | import hashlib
|
| | import json
|
| | import struct
|
| | import zlib
|
| | from io import BytesIO
|
| | from itertools import chain
|
| |
|
| | import requests
|
| | from flask import (
|
| | Flask,
|
| | Response,
|
| | abort,
|
| | jsonify,
|
| | make_response,
|
| | redirect,
|
| | render_template,
|
| | request,
|
| | )
|
| | from PIL import Image
|
| |
|
| | import cloth_loader
|
| |
|
| | app = Flask(__name__)
|
| |
|
| |
|
| | MAX_PER_PAGE = 50
|
| | DEFAULT_CONFIG_URL = "https://static.hartico.tv/renderer-config.json"
|
| | RESERVED_PARAMS = {"config_url", "page", "per_page", "limit", "offset"}
|
| |
|
| |
|
| | CACHE = {}
|
| |
|
| |
|
| | def get_or_load_data_by_category(
|
| | config_url: str, extra_vars: dict, category: str = None
|
| | ):
|
| | """Carga datos cacheados, opcionalmente por categoría."""
|
| | url = config_url.strip() or DEFAULT_CONFIG_URL
|
| | cache_key_input = f"{url}|{sorted(extra_vars.items())}"
|
| | base_cache_key = hashlib.md5(cache_key_input.encode("utf-8")).hexdigest()
|
| |
|
| |
|
| | if not category:
|
| | return get_or_load_data(config_url, extra_vars)
|
| |
|
| |
|
| | category_cache_key = f"{base_cache_key}_cat_{category}"
|
| |
|
| | if category_cache_key in CACHE:
|
| | return CACHE[category_cache_key]
|
| |
|
| |
|
| | full_data = get_or_load_data(config_url, extra_vars)
|
| |
|
| |
|
| | category_data = {
|
| | "types": full_data["types"],
|
| | "_category_index": {category: full_data["_category_index"].get(category, [])},
|
| | "_palettes": full_data["_palettes"],
|
| | "config": full_data["config"],
|
| | }
|
| |
|
| | CACHE[category_cache_key] = category_data
|
| | return category_data
|
| |
|
| |
|
| | def get_or_load_data(config_url: str, extra_vars: dict):
|
| | """Carga o devuelve datos cacheados (incluyendo config)."""
|
| | url = config_url.strip() or DEFAULT_CONFIG_URL
|
| | cache_key_input = f"{url}|{sorted(extra_vars.items())}"
|
| | cache_key = hashlib.md5(cache_key_input.encode("utf-8")).hexdigest()
|
| |
|
| | if cache_key in CACHE:
|
| | return CACHE[cache_key]
|
| |
|
| | print(f"🔄 Cargando datos desde: {url} con vars {extra_vars}")
|
| | try:
|
| |
|
| | figuremap, figuredata_flat, palettes, config = cloth_loader.load_game_data(
|
| | url, extra_vars
|
| | )
|
| |
|
| |
|
| |
|
| | types, category_index = cloth_loader.get_all_part_types_from_data(
|
| | figuremap, figuredata_flat
|
| | )
|
| |
|
| |
|
| | data = {
|
| | "types": types,
|
| | "_category_index": category_index,
|
| | "_palettes": palettes,
|
| | "config": config,
|
| | }
|
| | CACHE[cache_key] = data
|
| | print(f"✅ Datos cacheados para: {url}")
|
| | return data
|
| |
|
| | except Exception as e:
|
| | raise
|
| |
|
| |
|
| | @app.route("/")
|
| | def index():
|
| | config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
|
| | extra_vars = {k: v for k, v in request.args.items() if k not in RESERVED_PARAMS}
|
| | try:
|
| | data = get_or_load_data_by_category(config_url, extra_vars)
|
| | return render_template(
|
| | "armario.html", part_types=data["types"], max_per_page=MAX_PER_PAGE
|
| | )
|
| | except Exception as e:
|
| | import traceback
|
| |
|
| | traceback.print_exc()
|
| | return f"<h2>Error al cargar la configuración</h2><pre>{str(e)}</pre>", 500
|
| |
|
| |
|
| | @app.route("/api/images/<image>.nitro")
|
| | def serve_nitro_image(image):
|
| | try:
|
| |
|
| | config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
|
| | sprite_index_param = request.args.get("sprite")
|
| | get_total = request.args.get("get_total") == "1"
|
| | extra_vars = {
|
| | k: v
|
| | for k, v in request.args.items()
|
| | if k not in RESERVED_PARAMS and k not in ("sprite", "get_total")
|
| | }
|
| |
|
| |
|
| | data = get_or_load_data(config_url, extra_vars)
|
| | config = data["config"]
|
| | asset_url_template = config.get("avatar.asset.url")
|
| | if not asset_url_template:
|
| | abort(500, "❌ avatar.asset.url no encontrado en la configuración")
|
| |
|
| | nitro_url = asset_url_template.format(libname=image)
|
| | print(f"📥 Descargando: {nitro_url}")
|
| |
|
| |
|
| | resp = requests.get(nitro_url)
|
| | resp.raise_for_status()
|
| | nitro_data = resp.content
|
| |
|
| | files = parse_nitro(nitro_data)
|
| | if not files:
|
| | abort(404, "📦 Bundle vacío")
|
| |
|
| |
|
| | fly = {}
|
| | for name, content in files:
|
| | if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
| | fly["png"] = {"content": content, "name": name}
|
| | elif name.lower().endswith(".json"):
|
| | fly["json"] = {"content": content, "name": name}
|
| |
|
| | if "json" not in fly or "png" not in fly:
|
| | abort(404, "❌ No se encontró JSON o PNG en el bundle")
|
| |
|
| |
|
| | json_str = fly["json"]["content"].decode("utf-8")
|
| | json_data = json.loads(json_str)
|
| |
|
| |
|
| | frames = {}
|
| | if isinstance(json_data, dict):
|
| | if "frames" in json_data:
|
| | frames = json_data["frames"]
|
| | elif "spritesheet" in json_data and "frames" in json_data["spritesheet"]:
|
| | frames = json_data["spritesheet"]["frames"]
|
| |
|
| | if not frames:
|
| | abort(
|
| | 400, f"❌ JSON no contiene 'frames'. Claves: {list(json_data.keys())}"
|
| | )
|
| |
|
| | frame_names = list(frames.keys())
|
| | total_sprites = len(frame_names)
|
| |
|
| |
|
| | if get_total:
|
| | return jsonify({"total_sprites": total_sprites})
|
| |
|
| |
|
| | sprite_index = 0
|
| | if sprite_index_param is not None:
|
| | try:
|
| | sprite_index = int(sprite_index_param)
|
| | except ValueError:
|
| | abort(400, "❌ El parámetro 'sprite' debe ser un número entero")
|
| |
|
| | if sprite_index < 0 or sprite_index >= total_sprites:
|
| | abort(
|
| | 400,
|
| | f"❌ Índice de sprite fuera de rango. Debe estar entre 0 y {total_sprites - 1}",
|
| | )
|
| |
|
| | selected_name = frame_names[sprite_index]
|
| | frame_info = frames[selected_name]
|
| |
|
| |
|
| | coords = frame_info.get("frame", frame_info)
|
| | for key in ["x", "y", "w", "h"]:
|
| | if key not in coords:
|
| | abort(
|
| | 400,
|
| | f"❌ Coordenada '{key}' faltante en el sprite '{selected_name}'",
|
| | )
|
| |
|
| | x = int(coords["x"])
|
| | y = int(coords["y"])
|
| | w = int(coords["w"])
|
| | h = int(coords["h"])
|
| |
|
| |
|
| | png_image = Image.open(BytesIO(fly["png"]["content"]))
|
| | sprite = png_image.crop((x, y, x + w, y + h))
|
| |
|
| | output = BytesIO()
|
| | sprite.save(output, format="PNG")
|
| | output.seek(0)
|
| | return Response(output.getvalue(), mimetype="image/png")
|
| |
|
| | except requests.RequestException as e:
|
| | abort(502, f"❌ Error al descargar: {str(e)}")
|
| | except json.JSONDecodeError as e:
|
| | abort(500, f"❌ JSON inválido: {str(e)}")
|
| | except (KeyError, ValueError) as e:
|
| | abort(400, f"❌ Estructura inválida: {str(e)}")
|
| | except zlib.error as e:
|
| | abort(500, f"❌ Error al descomprimir: {str(e)}")
|
| | except Exception as e:
|
| | abort(500, f"❌ Error inesperado: {str(e)}")
|
| |
|
| |
|
| | def parse_nitro(data: bytes):
|
| | """Parsea un archivo .nitro y devuelve [(nombre, datos_descomprimidos), ...]"""
|
| | files = []
|
| | offset = 0
|
| | if len(data) < 2:
|
| | return files
|
| | file_count = struct.unpack(">H", data[offset : offset + 2])[0]
|
| | offset += 2
|
| |
|
| | for _ in range(file_count):
|
| | if offset + 2 > len(data):
|
| | break
|
| | name_len = struct.unpack(">H", data[offset : offset + 2])[0]
|
| | offset += 2
|
| | if offset + name_len > len(data):
|
| | break
|
| | name = data[offset : offset + name_len].decode("utf-8", errors="replace")
|
| | offset += name_len
|
| | if offset + 4 > len(data):
|
| | break
|
| | compressed_size = struct.unpack(">I", data[offset : offset + 4])[0]
|
| | offset += 4
|
| | if offset + compressed_size > len(data):
|
| | break
|
| | compressed_data = data[offset : offset + compressed_size]
|
| | offset += compressed_size
|
| |
|
| | try:
|
| | decompressed = zlib.decompress(compressed_data, -15)
|
| | except zlib.error:
|
| | try:
|
| | decompressed = zlib.decompress(compressed_data)
|
| | except zlib.error:
|
| | decompressed = compressed_data
|
| |
|
| | files.append((name, decompressed))
|
| |
|
| | return files
|
| |
|
| |
|
| | def organizar_paletas(paletas: list[cloth_loader.Palette], paleta_id: int):
|
| | colores: list[dict] = []
|
| | for paleta in paletas:
|
| | if paleta.id != paleta_id:
|
| | continue
|
| | for color in paleta.colors:
|
| | if not color.selectable:
|
| | continue
|
| | colores.append(
|
| | {
|
| | "index": color.index,
|
| | "color": color.id,
|
| | "hexColor": "#" + color.hex_code,
|
| | }
|
| | )
|
| | return colores
|
| |
|
| |
|
| | @app.route("/api/effects")
|
| | def api_effects():
|
| | config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
|
| | extra_vars = {k: v for k, v in request.args.items() if k not in RESERVED_PARAMS}
|
| | data = get_or_load_data_by_category(config_url, extra_vars, "ca")
|
| | config = data["config"]
|
| | effect_map = config["avatar.effectmap.url"]
|
| | map_effect = requests.get(effect_map).json()["effects"]
|
| |
|
| | def guardar_efecto(efecto):
|
| | return {
|
| | "id": efecto["id"],
|
| | "tipo": efecto["type"],
|
| | "file": f"/api/gifs/{efecto['lib']}.nitro?{request.query_string.decode()}",
|
| | }
|
| |
|
| | datos = list(map(guardar_efecto, map_effect))
|
| | return render_template("efectos.html", efectos=datos)
|
| |
|
| |
|
| | @app.route("/api/gifs/<image>.nitro")
|
| | def serve_nitro_gif(image):
|
| | try:
|
| |
|
| | config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
|
| | get_total = request.args.get("get_total") == "1"
|
| | extra_vars = {
|
| | k: v
|
| | for k, v in request.args.items()
|
| | if k not in RESERVED_PARAMS and k not in ("sprite", "get_total")
|
| | }
|
| |
|
| |
|
| | data = get_or_load_data_by_category(config_url, extra_vars, "ca")
|
| | config = data["config"]
|
| | asset_url_template = config.get("avatar.asset.effect.url")
|
| | if not asset_url_template:
|
| | abort(500, "❌ avatar.asset.url no encontrado en la configuración")
|
| |
|
| | nitro_url = asset_url_template.format(libname=image)
|
| | print(f"📥 Descargando: {nitro_url}")
|
| |
|
| |
|
| | resp = requests.get(nitro_url)
|
| | resp.raise_for_status()
|
| | nitro_data = resp.content
|
| |
|
| | files = parse_nitro(nitro_data)
|
| | if not files:
|
| | abort(404, "📦 Bundle vacío")
|
| |
|
| |
|
| | fly = {}
|
| | for name, content in files:
|
| |
|
| | if content[:2] == b"\x1f\x8b":
|
| | content = gzip.decompress(content)
|
| |
|
| | if name.lower().endswith(".png"):
|
| | fly["png"] = {"content": content, "name": name}
|
| |
|
| | elif name.lower().endswith(".json"):
|
| | fly["json"] = {"content": content, "name": name}
|
| |
|
| |
|
| | json_str = fly["json"]["content"].decode("utf-8")
|
| | json_data = json.loads(json_str)
|
| |
|
| |
|
| | frames = {}
|
| | if isinstance(json_data, dict):
|
| | if "frames" in json_data:
|
| | frames = json_data["frames"]
|
| | elif "spritesheet" in json_data and "frames" in json_data["spritesheet"]:
|
| | frames = json_data["spritesheet"]["frames"]
|
| |
|
| | if not frames:
|
| | abort(
|
| | 400, f"❌ JSON no contiene 'frames'. Claves: {list(json_data.keys())}"
|
| | )
|
| |
|
| | frame_names = list(frames.keys())
|
| | total_sprites = len(frame_names)
|
| |
|
| |
|
| | if get_total:
|
| | return jsonify({"total_sprites": total_sprites})
|
| | sprites = []
|
| |
|
| | for frame_name, frame_info in frames.items():
|
| |
|
| | coords = frame_info.get("frame", frame_info)
|
| | for key in ["x", "y", "w", "h"]:
|
| | if key not in coords:
|
| | abort(
|
| | 400,
|
| | f"❌ Coordenada '{key}' faltante en el sprite '{selected_name}'",
|
| | )
|
| |
|
| | x = int(coords["x"])
|
| | y = int(coords["y"])
|
| | w = int(coords["w"])
|
| | h = int(coords["h"])
|
| |
|
| |
|
| | png_image = Image.open(BytesIO(fly["png"]["content"]))
|
| | sprite = png_image.crop((x, y, x + w, y + h))
|
| | sprites.append(sprite)
|
| |
|
| | output = BytesIO()
|
| | sprites[0].save(
|
| | output,
|
| | save_all=True,
|
| | append_images=sprites[1:],
|
| | format="GIF",
|
| | duration=1000,
|
| | loop=0,
|
| | disposal=2,
|
| | optimize=True,
|
| | )
|
| | output.seek(0)
|
| |
|
| | return Response(output.getvalue(), mimetype="image/gif")
|
| |
|
| | except requests.RequestException as e:
|
| | __import__("traceback").print_exc()
|
| | abort(502, f"❌ Error al descargar: {str(e)}")
|
| | except json.JSONDecodeError as e:
|
| | __import__("traceback").print_exc()
|
| | abort(500, f"❌ JSON inválido: {str(e)}")
|
| | except (KeyError, ValueError) as e:
|
| | import traceback
|
| |
|
| | traceback.print_exc()
|
| | abort(400, f"❌ Estructura inválida: {str(e)}")
|
| | except zlib.error as e:
|
| | __import__("traceback").print_exc()
|
| | abort(500, f"❌ Error al descomprimir: {str(e)}")
|
| | except Exception as e:
|
| | __import__("traceback").print_exc()
|
| | abort(500, f"❌ Error inesperado: {str(e)}")
|
| |
|
| |
|
| | @app.route("/api/findfurni")
|
| | def api_findfurni():
|
| | config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
|
| | extra_vars = {k: v for k, v in request.args.items() if k not in RESERVED_PARAMS}
|
| | query = request.args.get("q", "").strip().lower()
|
| | part_type = request.args.get("type", "").strip()
|
| | page = request.args.get("page", default=1, type=int)
|
| | per_page = request.args.get("per_page", default=10, type=int)
|
| | per_page = max(1, min(per_page, MAX_PER_PAGE))
|
| | page = max(1, page)
|
| |
|
| | try:
|
| | data = get_or_load_data_by_category(config_url, extra_vars, part_type)
|
| | if not part_type or part_type not in data["types"]:
|
| | return jsonify({"error": f"Tipo '{part_type}' no válido."}), 400
|
| |
|
| |
|
| |
|
| | all_items = data["_category_index"].get(part_type, [])
|
| |
|
| |
|
| | if query:
|
| | filtered = []
|
| | for f in all_items:
|
| |
|
| | if query in f.lib_id.lower():
|
| | filtered.append(f)
|
| | continue
|
| |
|
| |
|
| | if query in str(f.id):
|
| | filtered.append(f)
|
| | continue
|
| |
|
| |
|
| | found_in_parts = False
|
| | for part in f.parts:
|
| | if query in str(part.id) or query in part.type.lower():
|
| | found_in_parts = True
|
| | break
|
| |
|
| | if found_in_parts:
|
| | filtered.append(f)
|
| | else:
|
| | filtered = all_items
|
| |
|
| | total = len(filtered)
|
| | total_pages = (total + per_page - 1) // per_page
|
| | offset = (page - 1) * per_page
|
| | items_slice = filtered[offset : offset + per_page]
|
| |
|
| | items = [
|
| | {
|
| | "lib_id": f.lib_id,
|
| | "id": f.id,
|
| | "type": f.type,
|
| | "colors": len(
|
| | set([part.colorindex for part in f.parts if f.colorable])
|
| | ),
|
| | "colorable": f.colorable,
|
| | "palette": organizar_paletas(
|
| | data["_palettes"], f.paleta
|
| | ),
|
| | "image_url": f"/api/images/{f.lib_id}.nitro?{request.query_string.decode()}",
|
| | }
|
| | for f in items_slice
|
| | ]
|
| | response = make_response(
|
| | jsonify(
|
| | {
|
| | "items": items,
|
| | "pagination": {
|
| | "page": page,
|
| | "per_page": per_page,
|
| | "total": total,
|
| | "total_pages": total_pages,
|
| | },
|
| | }
|
| | )
|
| | )
|
| | response.headers["Cache-Control"] = "public, max-age=3600"
|
| | return response
|
| | return jsonify()
|
| |
|
| | except Exception as e:
|
| | import traceback
|
| |
|
| | traceback.print_exc()
|
| | return jsonify({"error": str(e)}), 500
|
| |
|
| |
|
| | if __name__ == "__main__":
|
| | print("🚀 Iniciando Armario Virtual...")
|
| | app.run(debug=True, host="0.0.0.0", port=5000)
|
| |
|