From 586f3b2a956d33acb52974afe7c888f38d21a3c3 Mon Sep 17 00:00:00 2001 From: MopicMP <239005009+MopicMP@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:57:39 +0200 Subject: [PATCH 1/2] feat(skins): detectar el modelo de la skin al subirla MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Al subir una skin, el modelo (clásico o delgado) se toma de la propia imagen y no del que estaba seleccionado antes. Una skin delgada guardada como clásica muestra franjas negras en los brazos. Los botones Clásico y Delgado siguen permitiendo cambiarlo antes de guardar; una nota bajo los botones avisa que el modelo se detectó. --- .../profiles/SkinCapeManager.svelte | 79 +++++++++---- src/lib/i18n/en-US.json | 1 + src/lib/i18n/es-ES.json | 1 + src/lib/utils/skinModel.ts | 105 ++++++++++++++++++ tests/frontend/ui/skinModel.test.mjs | 88 +++++++++++++++ 5 files changed, 253 insertions(+), 21 deletions(-) create mode 100644 src/lib/utils/skinModel.ts create mode 100644 tests/frontend/ui/skinModel.test.mjs diff --git a/src/lib/components/profiles/SkinCapeManager.svelte b/src/lib/components/profiles/SkinCapeManager.svelte index 0dcb6f60..2c234396 100644 --- a/src/lib/components/profiles/SkinCapeManager.svelte +++ b/src/lib/components/profiles/SkinCapeManager.svelte @@ -13,6 +13,7 @@ } from "$lib/api/cubicApi"; import { showError, showSuccess } from "$lib/state/state.svelte"; import { bumpAvatarVersion } from "$lib/state/avatarCache.svelte"; + import { detectSkinModelFromUrl } from "$lib/utils/skinModel"; import Icon from "$lib/icons/Icon.svelte"; import SkinPreview from "./SkinPreview.svelte"; import CapeList from "./CapeList.svelte"; @@ -39,6 +40,8 @@ let originalCapeId = $state(null); let selectedCapeId = $state(null); let originalVariant = $state<"classic" | "slim">("classic"); + // El modelo actual lo eligió la detección al subir, no el usuario. + let modelDetected = $state(false); interface PendingSkin { filePath: string; @@ -79,6 +82,7 @@ pendingSkin = null; selectedCapeId = originalCapeId; skinModel = originalVariant; + modelDetected = false; } async function handleFileUpload(filePath?: string) { @@ -103,6 +107,11 @@ showError(t("errors.title"), String(e)); return; } + // El modelo sale de la propia skin, no de la anterior: una skin delgada + // guardada como clásica muestra una franja negra en los brazos. + const detected = await detectSkinModelFromUrl(previewUrl); + if (detected) skinModel = detected; + modelDetected = detected !== null; pendingSkin = { filePath: selected, variant: skinModel === "slim" ? "SLIM" : "CLASSIC", @@ -124,6 +133,7 @@ function handleModelChange(model: "classic" | "slim") { skinModel = model; + modelDetected = false; if (pendingSkin) { pendingSkin.variant = model === "slim" ? "SLIM" : "CLASSIC"; } @@ -296,25 +306,32 @@ {:else if profile}
-
- - +
+
+ + +
+ {#if pendingSkin && modelDetected} +

+ {t("userMenu.skinCape.modelDetected")} +

+ {/if}
, + width: number, + height: number, +): SkinModel { + const scale = Math.max(1, Math.round(width / 64)); + // Las skins de 64×32 (anteriores a 1.8) no tienen modelo delgado. + if (Math.round(height / scale) < 64) return "classic"; + + const alphaAt = (x: number, y: number): number => { + // Centro del bloque en skins HD, para que un píxel suelto en el borde no decida. + const px = Math.min(width - 1, x * scale + (scale >> 1)); + const py = Math.min(height - 1, y * scale + (scale >> 1)); + return rgba[(py * width + px) * 4 + 3] ?? 0; + }; + const hasPixels = (zone: Zone): boolean => { + for (let y = zone.y; y < zone.y + zone.h; y++) { + for (let x = zone.x; x < zone.x + zone.w; x++) { + if (alphaAt(x, y) >= CLEAR_ALPHA) return true; + } + } + return false; + }; + + if (!hasPixels(RIGHT_ARM_FRONT)) return "classic"; + return SLIM_UNUSED.some(hasPixels) ? "classic" : "slim"; +} + +/** + * Igual que {@link detectSkinModel}, a partir de una URL que el canvas pueda leer + * (por ejemplo, el data URL de la vista previa). Devuelve `null` si la imagen no + * se puede leer, para que quien llama conserve el modelo elegido. + */ +export async function detectSkinModelFromUrl( + url: string, +): Promise { + try { + const image = await loadImage(url); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + context.drawImage(image, 0, 0); + const { data } = context.getImageData( + 0, + 0, + canvas.width, + canvas.height, + ); + return detectSkinModel(data, canvas.width, canvas.height); + } catch { + return null; + } +} + +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("No se pudo leer la skin")); + image.src = url; + }); +} diff --git a/tests/frontend/ui/skinModel.test.mjs b/tests/frontend/ui/skinModel.test.mjs new file mode 100644 index 00000000..24b34d9f --- /dev/null +++ b/tests/frontend/ui/skinModel.test.mjs @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +import { detectSkinModel } from "../../../src/lib/utils/skinModel.ts"; + +// Skin sintética: se pintan rectángulos opacos en coordenadas de 64×64. +function skin({ width = 64, height = 64, paint = [], alpha = 255 } = {}) { + const scale = width / 64; + const rgba = new Uint8ClampedArray(width * height * 4); + for (const { x, y, w, h } of paint) { + for (let py = y * scale; py < (y + h) * scale; py++) { + for (let px = x * scale; px < (x + w) * scale; px++) { + rgba[(py * width + px) * 4 + 3] = alpha; + } + } + } + return { rgba, width, height }; +} + +const detect = ({ rgba, width, height }) => + detectSkinModel(rgba, width, height); + +// Brazos tal como los dibuja cada modelo (cara de arriba, abajo y las cuatro laterales). +const CLASSIC_ARMS = [ + { x: 44, y: 16, w: 8, h: 4 }, + { x: 40, y: 20, w: 16, h: 12 }, + { x: 36, y: 48, w: 8, h: 4 }, + { x: 32, y: 52, w: 16, h: 12 }, +]; +const SLIM_ARMS = [ + { x: 44, y: 16, w: 6, h: 4 }, + { x: 40, y: 20, w: 14, h: 12 }, + { x: 36, y: 48, w: 6, h: 4 }, + { x: 32, y: 52, w: 14, h: 12 }, +]; + +test("a skin drawn with 3 px arms is detected as slim", () => { + expect(detect(skin({ paint: SLIM_ARMS }))).toBe("slim"); +}); + +test("a skin drawn with 4 px arms is detected as classic", () => { + expect(detect(skin({ paint: CLASSIC_ARMS }))).toBe("classic"); +}); + +test("one drawn column in any slim-only zone means classic", () => { + for (const zone of [ + { x: 55, y: 25, w: 1, h: 1 }, + { x: 51, y: 17, w: 1, h: 1 }, + { x: 46, y: 60, w: 1, h: 1 }, + { x: 43, y: 49, w: 1, h: 1 }, + ]) { + expect(detect(skin({ paint: [...SLIM_ARMS, zone] }))).toBe("classic"); + } +}); + +test("antialiasing leftovers in the slim-only zones do not count", () => { + const image = skin({ paint: SLIM_ARMS }); + const stray = skin({ paint: [{ x: 54, y: 20, w: 2, h: 12 }], alpha: 3 }); + for (let i = 3; i < image.rgba.length; i += 4) { + image.rgba[i] = Math.max(image.rgba[i], stray.rgba[i]); + } + expect(detect(image)).toBe("slim"); +}); + +test("an empty arm falls back to classic instead of guessing slim", () => { + expect(detect(skin())).toBe("classic"); +}); + +test("legacy 64x32 skins are always classic", () => { + const legacy = skin({ + height: 32, + paint: [ + { x: 44, y: 16, w: 6, h: 4 }, + { x: 40, y: 20, w: 14, h: 12 }, + ], + }); + expect(detect(legacy)).toBe("classic"); +}); + +test("HD skins are read with the same rule", () => { + expect(detect(skin({ width: 128, height: 128, paint: SLIM_ARMS }))).toBe( + "slim", + ); + expect(detect(skin({ width: 128, height: 128, paint: CLASSIC_ARMS }))).toBe( + "classic", + ); + expect(detect(skin({ width: 128, height: 64, paint: SLIM_ARMS }))).toBe( + "classic", + ); +}); From 8e4ac5369eac0b2c507011c702c1cd8e5b4d70f5 Mon Sep 17 00:00:00 2001 From: MopicMP <239005009+MopicMP@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:59:49 +0200 Subject: [PATCH 2/2] fix(skins): no decidir el modelo sin datos suficientes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La detección solo responde con pruebas en la imagen: algo dibujado en las columnas que solo existen en el modelo clásico, o una cara dibujada que termina antes de ellas. Si esas caras están vacías (brazo sin dibujar, transparente o imagen incompleta), devuelve null y se conserva el modelo elegido, sin mostrar la nota. --- src/lib/utils/skinModel.ts | 59 ++++++++++++++++++++-------- tests/frontend/ui/skinModel.test.mjs | 27 ++++++++++++- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/src/lib/utils/skinModel.ts b/src/lib/utils/skinModel.ts index ca65d9c8..1bf56390 100644 --- a/src/lib/utils/skinModel.ts +++ b/src/lib/utils/skinModel.ts @@ -7,8 +7,9 @@ * clásica, el juego lee esas columnas vacías y, como dibuja opaca la capa base, * aparece una franja (casi siempre negra) en la parte de atrás del brazo. * - * Ante la duda se devuelve clásico: es el modelo por defecto del juego y el que - * no deja franjas vacías en una skin que no las tenía. + * Solo se decide con pruebas en la imagen. Si las caras que distinguen los dos + * modelos están vacías (brazo sin dibujar, transparente o imagen incompleta), se + * devuelve `null` y quien llama conserva el modelo elegido. */ export type SkinModel = "classic" | "slim"; @@ -20,20 +21,42 @@ interface Zone { h: number; } +/** + * Una cara del brazo cuyo ancho cambia entre modelos: la parte que ambos dibujan + * y las columnas que solo existen en el clásico. + */ +interface ArmFace { + shared: Zone; + classicOnly: Zone; +} + /** Por debajo de este alfa un píxel cuenta como vacío: el antialiasing deja restos. */ const CLEAR_ALPHA = 8; -/** Zonas que el modelo delgado nunca usa, en píxeles de una skin de 64×64. */ -const SLIM_UNUSED: readonly Zone[] = [ - { x: 54, y: 20, w: 2, h: 12 }, // brazo derecho, cara de atrás - { x: 50, y: 16, w: 2, h: 4 }, // brazo derecho, parte de abajo - { x: 46, y: 52, w: 2, h: 12 }, // brazo izquierdo, cara de atrás - { x: 42, y: 48, w: 2, h: 4 }, // brazo izquierdo, parte de abajo +/** Cara de atrás y parte de abajo de cada brazo, en píxeles de una skin de 64×64. */ +const ARM_FACES: readonly ArmFace[] = [ + // brazo derecho, cara de atrás + { + shared: { x: 52, y: 20, w: 2, h: 12 }, + classicOnly: { x: 54, y: 20, w: 2, h: 12 }, + }, + // brazo derecho, parte de abajo + { + shared: { x: 48, y: 16, w: 2, h: 4 }, + classicOnly: { x: 50, y: 16, w: 2, h: 4 }, + }, + // brazo izquierdo, cara de atrás + { + shared: { x: 44, y: 52, w: 2, h: 12 }, + classicOnly: { x: 46, y: 52, w: 2, h: 12 }, + }, + // brazo izquierdo, parte de abajo + { + shared: { x: 40, y: 48, w: 2, h: 4 }, + classicOnly: { x: 42, y: 48, w: 2, h: 4 }, + }, ]; -/** Cara de adelante del brazo derecho: si está vacía, no hay nada que mirar. */ -const RIGHT_ARM_FRONT: Zone = { x: 44, y: 20, w: 4, h: 12 }; - /** * @param rgba píxeles RGBA de la imagen completa, fila por fila * @param width ancho real de la imagen (64, 128, ... para skins HD) @@ -43,9 +66,9 @@ export function detectSkinModel( rgba: ArrayLike, width: number, height: number, -): SkinModel { +): SkinModel | null { const scale = Math.max(1, Math.round(width / 64)); - // Las skins de 64×32 (anteriores a 1.8) no tienen modelo delgado. + // El formato de 64×32 (anterior a 1.8) solo tiene brazos de 4 px. if (Math.round(height / scale) < 64) return "classic"; const alphaAt = (x: number, y: number): number => { @@ -63,14 +86,18 @@ export function detectSkinModel( return false; }; - if (!hasPixels(RIGHT_ARM_FRONT)) return "classic"; - return SLIM_UNUSED.some(hasPixels) ? "classic" : "slim"; + // Algo dibujado donde el delgado no llega solo puede ser de un brazo clásico. + if (ARM_FACES.some((face) => hasPixels(face.classicOnly))) return "classic"; + // Una cara dibujada que termina antes de esas columnas es de un brazo delgado. + if (ARM_FACES.some((face) => hasPixels(face.shared))) return "slim"; + return null; } /** * Igual que {@link detectSkinModel}, a partir de una URL que el canvas pueda leer * (por ejemplo, el data URL de la vista previa). Devuelve `null` si la imagen no - * se puede leer, para que quien llama conserve el modelo elegido. + * se puede leer o no alcanza para decidir, para que quien llama conserve el + * modelo elegido. */ export async function detectSkinModelFromUrl( url: string, diff --git a/tests/frontend/ui/skinModel.test.mjs b/tests/frontend/ui/skinModel.test.mjs index 24b34d9f..725ee980 100644 --- a/tests/frontend/ui/skinModel.test.mjs +++ b/tests/frontend/ui/skinModel.test.mjs @@ -60,8 +60,31 @@ test("antialiasing leftovers in the slim-only zones do not count", () => { expect(detect(image)).toBe("slim"); }); -test("an empty arm falls back to classic instead of guessing slim", () => { - expect(detect(skin())).toBe("classic"); +test("without enough pixels to decide, no model is returned", () => { + // Imagen vacía. + expect(detect(skin())).toBe(null); + // Brazos dibujados, pero con la cara de atrás y la de abajo transparentes: + // justo las caras que distinguen un modelo del otro. + const transparentBacks = [ + { x: 44, y: 16, w: 3, h: 4 }, + { x: 40, y: 20, w: 11, h: 12 }, + { x: 36, y: 48, w: 3, h: 4 }, + { x: 32, y: 52, w: 11, h: 12 }, + ]; + expect(detect(skin({ paint: transparentBacks }))).toBe(null); +}); + +test("one drawn arm is enough to decide", () => { + const leftSlimArm = [ + { x: 36, y: 48, w: 6, h: 4 }, + { x: 32, y: 52, w: 14, h: 12 }, + ]; + expect(detect(skin({ paint: leftSlimArm }))).toBe("slim"); + const rightClassicArm = [ + { x: 44, y: 16, w: 8, h: 4 }, + { x: 40, y: 20, w: 16, h: 12 }, + ]; + expect(detect(skin({ paint: rightClassicArm }))).toBe("classic"); }); test("legacy 64x32 skins are always classic", () => {