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__) # Configuración MAX_PER_PAGE = 50 DEFAULT_CONFIG_URL = "https://static.hartico.tv/renderer-config.json" RESERVED_PARAMS = {"config_url", "page", "per_page", "limit", "offset"} # Caché: {hash → { "types", "_category_index", "_palettes", "config" }} 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() # Si no se especifica categoría, cargar todo if not category: return get_or_load_data(config_url, extra_vars) # Clave específica para la categoría category_cache_key = f"{base_cache_key}_cat_{category}" if category_cache_key in CACHE: return CACHE[category_cache_key] # Cargar datos completos full_data = get_or_load_data(config_url, extra_vars) # Extraer solo la categoría solicitada 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: # ¡cloth_loader.load_game_data ahora devuelve config! figuremap, figuredata_flat, palettes, config = cloth_loader.load_game_data( url, extra_vars ) # En load_game_data, después de resolver el config types, category_index = cloth_loader.get_all_part_types_from_data( figuremap, figuredata_flat ) # 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"

Error al cargar la configuración

{str(e)}
", 500 @app.route("/api/images/.nitro") def serve_nitro_image(image): try: # --- 1. Obtener parámetros --- 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") } # --- 2. Cargar configuración --- 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}") # --- 3. Descargar y parsear .nitro --- 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") # --- 4. Buscar JSON y PNG --- 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") # --- 5. Parsear JSON --- json_str = fly["json"]["content"].decode("utf-8") json_data = json.loads(json_str) # --- 6. Extraer frames --- 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) # --- 7. Modo: obtener total de sprites --- if get_total: return jsonify({"total_sprites": total_sprites}) # --- 8. Seleccionar sprite por índice --- 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] # --- 9. Extraer coordenadas --- 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"]) # --- 10. Recortar y devolver PNG --- 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/.nitro") def serve_nitro_gif(image): try: # --- 1. Obtener parámetros --- 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") } # --- 2. Cargar configuración --- 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}") # --- 3. Descargar y parsear .nitro --- 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") # --- 4. Buscar JSON y PNG --- fly = {} for name, content in files: # If gzip compressed, decompress first 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} # --- 5. Parsear JSON --- json_str = fly["json"]["content"].decode("utf-8") json_data = json.loads(json_str) # --- 6. Extraer frames --- 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) # --- 7. Modo: obtener total de sprites --- if get_total: return jsonify({"total_sprites": total_sprites}) sprites = [] # --- 8. Seleccionar sprite por índice --- for frame_name, frame_info in frames.items(): # --- 9. Extraer coordenadas --- 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"]) # --- 10. Recortar y devolver PNG --- 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, # milisegundos por frame (opcional pero recomendado) loop=0, # 0 = bucle infinito (opcional pero recomendado) disposal=2, # Índice de color transparente (ajusta según tu paleta) optimize=True, # reduce tamaño (opcional) ) 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 # Filtrar ítems por lib_id all_items = data["_category_index"].get(part_type, []) # En /api/findfurni if query: filtered = [] for f in all_items: # Buscar en lib_id if query in f.lib_id.lower(): filtered.append(f) continue # Buscar en id numérico if query in str(f.id): filtered.append(f) continue # Buscar en parts 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] # En /api/furnis/ 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 ), # ← Paleta completa "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" # 1 hora 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)