Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 58 additions & 21 deletions src/lib/components/profiles/SkinCapeManager.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -39,6 +40,8 @@
let originalCapeId = $state<string | null>(null);
let selectedCapeId = $state<string | null>(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;
Expand Down Expand Up @@ -79,6 +82,7 @@
pendingSkin = null;
selectedCapeId = originalCapeId;
skinModel = originalVariant;
modelDetected = false;
}

async function handleFileUpload(filePath?: string) {
Expand All @@ -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",
Expand All @@ -124,6 +133,7 @@

function handleModelChange(model: "classic" | "slim") {
skinModel = model;
modelDetected = false;
if (pendingSkin) {
pendingSkin.variant = model === "slim" ? "SLIM" : "CLASSIC";
}
Expand Down Expand Up @@ -296,25 +306,32 @@
</div>
{:else if profile}
<div class="preview-card">
<div class="model-selector">
<button
type="button"
class="model-btn"
class:active={skinModel === "classic"}
onclick={() => handleModelChange("classic")}
disabled={processing}
>
{t("userMenu.skinCape.classic")}
</button>
<button
type="button"
class="model-btn"
class:active={skinModel === "slim"}
onclick={() => handleModelChange("slim")}
disabled={processing}
>
{t("userMenu.skinCape.slim")}
</button>
<div class="model-controls">
<div class="model-selector">
<button
type="button"
class="model-btn"
class:active={skinModel === "classic"}
onclick={() => handleModelChange("classic")}
disabled={processing}
>
{t("userMenu.skinCape.classic")}
</button>
<button
type="button"
class="model-btn"
class:active={skinModel === "slim"}
onclick={() => handleModelChange("slim")}
disabled={processing}
>
{t("userMenu.skinCape.slim")}
</button>
</div>
{#if pendingSkin && modelDetected}
<p class="model-hint" in:fly={{ y: -4, duration: 150 }}>
{t("userMenu.skinCape.modelDetected")}
</p>
{/if}
</div>

<SkinPreview
Expand Down Expand Up @@ -493,11 +510,31 @@
height: 100%;
}

.model-selector {
.model-controls {
position: absolute;
top: 12px;
right: 12px;
z-index: 5;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}

.model-hint {
margin: 0;
max-width: 180px;
padding: 3px 8px;
font-size: 0.65rem;
text-align: right;
color: var(--text-secondary);
background: rgba(var(--bg-card-rgb, 0, 0, 0), 0.6);
backdrop-filter: blur(4px);
border-radius: var(--border-radius-sm);
border: var(--border-width) solid var(--border);
}

.model-selector {
display: inline-flex;
gap: 4px;
background: rgba(var(--bg-card-rgb, 0, 0, 0), 0.6);
Expand Down Expand Up @@ -651,7 +688,7 @@
height: 260px;
}

.model-selector {
.model-controls {
top: 8px;
right: 8px;
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@
"rateLimitTitle": "Too many changes",
"rateLimitMessage": "You're making changes too fast. Wait a few seconds before trying again.",
"unequipPending": "Cape unequipped (pending save)",
"modelDetected": "Detected from the skin. You can change it.",
"skinCloset": {
"title": "Skin closet",
"savedSkins": "Saved skins",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@
"rateLimitTitle": "Demasiados cambios",
"rateLimitMessage": "Estás haciendo cambios muy rápido. Esperá unos segundos antes de volver a intentarlo.",
"unequipPending": "Capa desequipada (pendiente de guardar)",
"modelDetected": "Detectado según la skin. Podés cambiarlo.",
"skinCloset": {
"title": "Armario de skins",
"savedSkins": "Skins guardadas",
Expand Down
132 changes: 132 additions & 0 deletions src/lib/utils/skinModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Detecta si una skin fue dibujada para el modelo clásico (Steve, brazos de
* 4 px) o delgado (Alex, brazos de 3 px), mirando la propia imagen.
*
* El modelo delgado deja sin usar dos columnas en la cara de atrás de cada
* brazo y dos en la parte de abajo de la mano. Si una skin delgada se sube como
* 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.
*
* 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";

interface Zone {
x: number;
y: number;
w: number;
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;

/** 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 },
},
];

/**
* @param rgba píxeles RGBA de la imagen completa, fila por fila
* @param width ancho real de la imagen (64, 128, ... para skins HD)
* @param height alto real de la imagen
*/
export function detectSkinModel(
rgba: ArrayLike<number>,
width: number,
height: number,
): SkinModel | null {
const scale = Math.max(1, Math.round(width / 64));
// 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 => {
// 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;
};

// 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 o no alcanza para decidir, para que quien llama conserve el
* modelo elegido.
*/
export async function detectSkinModelFromUrl(
Comment thread
staFF6773 marked this conversation as resolved.
Resolved
url: string,
): Promise<SkinModel | null> {
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<HTMLImageElement> {
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;
});
}
111 changes: 111 additions & 0 deletions tests/frontend/ui/skinModel.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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("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", () => {
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",
);
});
Loading