feat: switch avatar imager to epicnabbo.nl with figure conversion
- Update imager to use epicnabbo.nl by default with effect=14 and img_format=apng - Add figure string converter (old Habbo format -> epicnabbo.nl short format) - Update avatarImageUrl to auto-convert figure strings - Update online-users-widget to use new avatarImageUrl - Add tests for imager and figure conversion
This commit is contained in:
1 parent
703c29bc83
commit
fc7e6ca248
10 files changed
+270
-114
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { resolveUpstreamBase } from "@/lib/imager";
|
import { resolveUpstreamBase } from "@/lib/imager";
|
||||||
|
|
||||||
const FIGURE_RE = /^[a-z]{2}-\d+/i;
|
const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i;
|
||||||
const FIGURE_MAX_LEN = 512;
|
const FIGURE_MAX_LEN = 512;
|
||||||
const FIGURE_MAX_PARTS = 24;
|
const FIGURE_MAX_PARTS = 24;
|
||||||
const UPSTREAM_TIMEOUT_MS = 10_000;
|
const UPSTREAM_TIMEOUT_MS = 10_000;
|
||||||
@@ -49,6 +49,7 @@ export async function GET(request: NextRequest) {
|
|||||||
searchParams.get("headOnly") === "1";
|
searchParams.get("headOnly") === "1";
|
||||||
const gesture = searchParams.get("gesture") ?? undefined;
|
const gesture = searchParams.get("gesture") ?? undefined;
|
||||||
const action = searchParams.get("action") ?? undefined;
|
const action = searchParams.get("action") ?? undefined;
|
||||||
|
const effect = searchParams.get("effect") ?? undefined;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
figure,
|
figure,
|
||||||
@@ -60,6 +61,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (headOnly) params.set("headonly", "1");
|
if (headOnly) params.set("headonly", "1");
|
||||||
if (gesture) params.set("gesture", gesture);
|
if (gesture) params.set("gesture", gesture);
|
||||||
if (action) params.set("action", action);
|
if (action) params.set("action", action);
|
||||||
|
if (effect) params.set("effect", effect);
|
||||||
|
|
||||||
const imgFormat = searchParams.get("img_format");
|
const imgFormat = searchParams.get("img_format");
|
||||||
if (imgFormat) params.set("img_format", imgFormat);
|
if (imgFormat) params.set("img_format", imgFormat);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { resolveUpstreamBase } from "@/lib/imager";
|
import { resolveUpstreamBase } from "@/lib/imager";
|
||||||
|
|
||||||
const FIGURE_RE = /^[a-z]{2}-\d+/i;
|
const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i;
|
||||||
const FIGURE_MAX_LEN = 512;
|
const FIGURE_MAX_LEN = 512;
|
||||||
const FIGURE_MAX_PARTS = 24;
|
const FIGURE_MAX_PARTS = 24;
|
||||||
const UPSTREAM_TIMEOUT_MS = 10_000;
|
const UPSTREAM_TIMEOUT_MS = 10_000;
|
||||||
@@ -49,6 +49,7 @@ export async function GET(request: NextRequest) {
|
|||||||
searchParams.get("headOnly") === "1";
|
searchParams.get("headOnly") === "1";
|
||||||
const gesture = searchParams.get("gesture") ?? undefined;
|
const gesture = searchParams.get("gesture") ?? undefined;
|
||||||
const action = searchParams.get("action") ?? undefined;
|
const action = searchParams.get("action") ?? undefined;
|
||||||
|
const effect = searchParams.get("effect") ?? undefined;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
figure,
|
figure,
|
||||||
@@ -60,6 +61,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (headOnly) params.set("headonly", "1");
|
if (headOnly) params.set("headonly", "1");
|
||||||
if (gesture) params.set("gesture", gesture);
|
if (gesture) params.set("gesture", gesture);
|
||||||
if (action) params.set("action", action);
|
if (action) params.set("action", action);
|
||||||
|
if (effect) params.set("effect", effect);
|
||||||
|
|
||||||
const imgFormat = searchParams.get("img_format");
|
const imgFormat = searchParams.get("img_format");
|
||||||
if (imgFormat) params.set("img_format", imgFormat);
|
if (imgFormat) params.set("img_format", imgFormat);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
|
|
||||||
export interface OnlineUser {
|
export interface OnlineUser {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -55,7 +56,7 @@ export function OnlineUsersWidget({
|
|||||||
>
|
>
|
||||||
<div className="relative flex-none w-[50px] h-[62px]">
|
<div className="relative flex-none w-[50px] h-[62px]">
|
||||||
<Image
|
<Image
|
||||||
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
|
src={avatarImageUrl(user.look, { size: "s", headOnly: true })}
|
||||||
alt={user.username}
|
alt={user.username}
|
||||||
fill
|
fill
|
||||||
className="rounded object-cover border border-[var(--admin-border)]"
|
className="rounded object-cover border border-[var(--admin-border)]"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
convertFigurePart,
|
||||||
|
convertFigureString,
|
||||||
|
getNewFormatFigure,
|
||||||
|
} from "./figure-convert";
|
||||||
|
|
||||||
|
describe("convertFigurePart", () => {
|
||||||
|
it("converts head part", () => {
|
||||||
|
expect(convertFigurePart("hr-11782-40-40")).toBe("hd-180-1");
|
||||||
|
expect(convertFigurePart("hr-11782-41-41")).toBe("hd-180-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts hair part", () => {
|
||||||
|
expect(convertFigurePart("hd-180-7-14")).toBe("hd-180-1");
|
||||||
|
expect(convertFigurePart("hd-180-8-14")).toBe("hd-180-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts face part", () => {
|
||||||
|
expect(convertFigurePart("ch-11592-66")).toBe("ch-255-66");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts shirt part", () => {
|
||||||
|
expect(convertFigurePart("lg-10726-79-1408")).toBe("lg-280-110");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts shorts part", () => {
|
||||||
|
expect(convertFigurePart("sh-11764-1408")).toBe("sh-305-62");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts shoes part", () => {
|
||||||
|
expect(convertFigurePart("ha-11958-70-1408")).toBe("ha-310-70");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts waist part", () => {
|
||||||
|
expect(convertFigurePart("wa-2007-0")).toBe("wa-200-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns original for unknown parts", () => {
|
||||||
|
expect(convertFigurePart("unknown-part")).toBe("unknown-part");
|
||||||
|
expect(convertFigurePart("hd-999-99")).toBe("hd-999-99");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("convertFigureString", () => {
|
||||||
|
it("converts full figure string from old to new format", () => {
|
||||||
|
const oldFigure =
|
||||||
|
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0";
|
||||||
|
const newFigure =
|
||||||
|
"hd-180-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62.ha-310-70.wa-200-0";
|
||||||
|
expect(convertFigureString(oldFigure)).toBe(newFigure);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string", () => {
|
||||||
|
expect(convertFigureString("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles already converted figure", () => {
|
||||||
|
const newFigure = "hd-180-1.ch-255-66.lg-280-110";
|
||||||
|
expect(convertFigureString(newFigure)).toBe(newFigure);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getNewFormatFigure", () => {
|
||||||
|
it("converts figure using getNewFormatFigure", () => {
|
||||||
|
const oldFigure = "hr-11782-40-40.hd-180-7-14.ch-11592-66";
|
||||||
|
expect(getNewFormatFigure(oldFigure)).toBe("hd-180-1.hd-180-1.ch-255-66");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
+20
-7
@@ -1,4 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const envMock = vi.hoisted(() => ({
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_IMAGER_URL: "https://img.example.com" as string | undefined,
|
||||||
|
NEXT_PUBLIC_APP_URL: undefined as string | undefined,
|
||||||
|
IMAGING_UPSTREAM_URL: "https://up.example.com" as string | undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/env", () => envMock);
|
||||||
|
|
||||||
import { avatarImageUrl, excerpt, slugify } from "./format";
|
import { avatarImageUrl, excerpt, slugify } from "./format";
|
||||||
|
|
||||||
describe("slugify", () => {
|
describe("slugify", () => {
|
||||||
@@ -14,20 +25,22 @@ describe("slugify", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("avatarImageUrl", () => {
|
describe("avatarImageUrl", () => {
|
||||||
const base = "https://www.habbo.com/habbo-imaging/avatarimage";
|
beforeEach(() => {
|
||||||
|
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
|
||||||
|
});
|
||||||
|
|
||||||
it("appends the figure and options", () => {
|
it("appends the figure and options", () => {
|
||||||
const url = avatarImageUrl(base, "hr-100", { size: "l", headOnly: true });
|
const url = avatarImageUrl("hr-100", { size: "l", headOnly: true });
|
||||||
expect(url).toContain("figure=hr-100");
|
expect(url).toContain("figure=hr-100");
|
||||||
expect(url).toContain("size=l");
|
expect(url).toContain("size=l");
|
||||||
expect(url).toContain("headonly=1");
|
expect(url).toContain("headonly=1");
|
||||||
expect(url.startsWith(`${base}?`)).toBe(true);
|
expect(url.startsWith("https://img.example.com?")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses & when the base already has a query string", () => {
|
it("uses & when the base already has a query string", () => {
|
||||||
expect(
|
// This test is no longer applicable since avatarImageUrl doesn't accept a base parameter
|
||||||
avatarImageUrl(`${base}?x=1`, "hr-100").includes("?x=1&figure=hr-100"),
|
// The base URL is now resolved from env
|
||||||
).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+4
-12
@@ -1,8 +1,8 @@
|
|||||||
import { resolveImagerBase } from "@/lib/imager";
|
import { getNewFormatFigure } from "@/lib/figure-convert";
|
||||||
|
import { getAvatarUrl } from "@/lib/imager";
|
||||||
|
|
||||||
/** Build a Habbo avatar-imager URL for a figure string. */
|
/** Build a Habbo avatar-imager URL for a figure string. */
|
||||||
export function avatarImageUrl(
|
export function avatarImageUrl(
|
||||||
base: string,
|
|
||||||
look: string,
|
look: string,
|
||||||
opts: {
|
opts: {
|
||||||
size?: "s" | "m" | "l";
|
size?: "s" | "m" | "l";
|
||||||
@@ -11,16 +11,8 @@ export function avatarImageUrl(
|
|||||||
headDirection?: number;
|
headDirection?: number;
|
||||||
} = {},
|
} = {},
|
||||||
): string {
|
): string {
|
||||||
const resolved = resolveImagerBase(base);
|
const figure = getNewFormatFigure(look);
|
||||||
const params = new URLSearchParams({ figure: look, img_format: "png" });
|
return getAvatarUrl(figure, opts);
|
||||||
if (opts.size) params.set("size", opts.size);
|
|
||||||
if (opts.headOnly) params.set("headonly", "1");
|
|
||||||
if (opts.direction !== undefined)
|
|
||||||
params.set("direction", String(opts.direction));
|
|
||||||
if (opts.headDirection !== undefined)
|
|
||||||
params.set("head_direction", String(opts.headDirection));
|
|
||||||
const sep = resolved.includes("?") ? "&" : "?";
|
|
||||||
return `${resolved}${sep}${params.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** URL-safe slug from a title (lowercase, ascii, hyphenated). */
|
/** URL-safe slug from a title (lowercase, ascii, hyphenated). */
|
||||||
|
|||||||
+35
-57
File diff suppressed because it is too large.
Load diff
+15
-35
@@ -1,9 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Avatar imager helpers.
|
* Avatar imager helpers.
|
||||||
*
|
*
|
||||||
* The public-facing imager URL resolves to the site's own `/imaging` endpoint
|
* The public-facing imager URL is configured via NEXT_PUBLIC_IMAGER_URL env var.
|
||||||
* by default. That endpoint proxies the image from the configured upstream
|
* Defaults to epicnabbo.nl imager with effect=14 and img_format=apng.
|
||||||
* (defaults to Habbo's public imager).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type { AvatarOptions } from "@/types/admin";
|
export type { AvatarOptions } from "@/types/admin";
|
||||||
@@ -11,45 +10,23 @@ export type { AvatarOptions } from "@/types/admin";
|
|||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import type { AvatarOptions } from "@/types/admin";
|
import type { AvatarOptions } from "@/types/admin";
|
||||||
|
|
||||||
/** Build an absolute default imager URL from public env vars. */
|
const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage";
|
||||||
function defaultImagerUrl(): string {
|
|
||||||
const pub = env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
|
||||||
if (pub && !pub.includes("/api/imaging/avatar"))
|
|
||||||
return pub.replace(/\/+$/, "");
|
|
||||||
const app = env.NEXT_PUBLIC_APP_URL?.trim();
|
|
||||||
if (app) return `${app.replace(/\/+$/, "")}/imaging`;
|
|
||||||
return "/imaging";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the public-facing imager base URL.
|
* Resolve the public-facing imager base URL from env.
|
||||||
* Rejects the broken self-hosted proxy route if stored in settings.
|
* Falls back to epicnabbo.nl if not configured.
|
||||||
*/
|
*/
|
||||||
export function resolveImagerBase(configured?: string | null): string {
|
export function resolveImagerBase(): string {
|
||||||
const fromEnv = env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
const fromEnv = env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
||||||
if (fromEnv && !fromEnv.includes("/api/imaging/avatar")) {
|
if (!fromEnv) {
|
||||||
return fromEnv.replace(/\/+$/, "");
|
return DEFAULT_IMAGER_URL;
|
||||||
}
|
}
|
||||||
|
return fromEnv.replace(/\/+$/, "");
|
||||||
const v = (configured ?? "").trim();
|
|
||||||
if (!v || v.includes("/api/imaging/avatar")) {
|
|
||||||
return defaultImagerUrl();
|
|
||||||
}
|
|
||||||
return v.replace(/\/+$/, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the upstream URL used by the proxy endpoint.
|
* Build an avatar image URL using the configured imager.
|
||||||
* Falls back to Habbo's public imager when nothing else is configured.
|
* Uses effect=14 and img_format=apng by default for epicnabbo.nl compatibility.
|
||||||
*/
|
|
||||||
export function resolveUpstreamBase(): string {
|
|
||||||
const fromEnv = env.IMAGING_UPSTREAM_URL?.trim();
|
|
||||||
if (fromEnv) return fromEnv.replace(/\/+$/, "");
|
|
||||||
return "https://www.habbo.com/habbo-imaging/avatarimage";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build an avatar image URL (site's own `/imaging` endpoint by default).
|
|
||||||
*/
|
*/
|
||||||
export function getAvatarUrl(
|
export function getAvatarUrl(
|
||||||
figure: string,
|
figure: string,
|
||||||
@@ -62,6 +39,8 @@ export function getAvatarUrl(
|
|||||||
headOnly = false,
|
headOnly = false,
|
||||||
gesture,
|
gesture,
|
||||||
action,
|
action,
|
||||||
|
effect = 14,
|
||||||
|
img_format = "apng",
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -69,7 +48,8 @@ export function getAvatarUrl(
|
|||||||
direction: String(direction),
|
direction: String(direction),
|
||||||
head_direction: String(headDirection),
|
head_direction: String(headDirection),
|
||||||
size,
|
size,
|
||||||
img_format: "png",
|
img_format,
|
||||||
|
effect: String(effect),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (headOnly) params.set("headonly", "1");
|
if (headOnly) params.set("headonly", "1");
|
||||||
|
|||||||
@@ -40,4 +40,8 @@ export interface AvatarOptions {
|
|||||||
gesture?: string;
|
gesture?: string;
|
||||||
/** Action: wav, sit, lay, etc. */
|
/** Action: wav, sit, lay, etc. */
|
||||||
action?: string;
|
action?: string;
|
||||||
|
/** Effect ID (e.g., 14 for ghost) */
|
||||||
|
effect?: string | number;
|
||||||
|
/** Image format (e.g., "png", "apng") */
|
||||||
|
img_format?: string;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user