Armario / app.py
YoBatM's picture
Upload 4 files
c1d9028 verified
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"<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:
# --- 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/<image>.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/<part_type>
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)