feat(enka): support adaptive background & custom art
Build and Deploy / build-and-deploy (push) Successful in 4m27s

This commit is contained in:
2026-07-08 23:02:43 +07:00 Unverified
parent be0c4bd214
commit d4136b7b24
5 changed files with 435 additions and 829 deletions
+144 -3
View File
@@ -1,4 +1,5 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import type { ImageData as CanvasImageData } from "@napi-rs/canvas";
import { file } from "bun";
import overlayImage from "@/games/genshin/assets/overlay.jpg";
@@ -14,6 +15,8 @@ const elementColors: Record<string, string> = {
None: "#94a0a7",
};
type Rgb = [number, number, number];
function avgColor(data: Uint8ClampedArray) {
let r = 0,
g = 0,
@@ -39,23 +42,161 @@ function rgb(r: number, g: number, b: number) {
return `rgb(${r},${g},${b})`;
}
function hexToRgb(hex: string): Rgb {
const expanded = hex.replace(
/^#?([a-f\d])([a-f\d])([a-f\d])$/i,
(_, r: string, g: string, b: string) => `#${r}${r}${g}${g}${b}${b}`,
);
const [r = "00", g = "00", b = "00"] =
expanded.substring(1).match(/.{2}/g) ?? [];
return [parseInt(r, 16), parseInt(g, 16), parseInt(b, 16)];
}
function avgRows(rows: Rgb[]): Rgb {
if (!rows.length) return [0, 0, 0];
const sum = rows.reduce(
(acc, [r, g, b]) => {
acc[0] += r;
acc[1] += g;
acc[2] += b;
return acc;
},
[0, 0, 0],
);
return [
Math.round(sum[0] / rows.length),
Math.round(sum[1] / rows.length),
Math.round(sum[2] / rows.length),
];
}
function normalizeBandColor(color: Rgb, bandIndex: number): Rgb {
const next = [...color] as Rgb;
const brightness = (next[0] + next[1] + next[2]) / (255 * 3);
let delta = 0;
if (brightness > 0.7) delta = 255 * (0.7 - brightness);
else if (brightness < 0.5) delta = 255 * (0.5 - brightness);
if (delta > 0) delta *= 1 - bandIndex / 3;
return [
Math.max(0, Math.round(next[0] + delta)),
Math.max(0, Math.round(next[1] + delta)),
Math.max(0, Math.round(next[2] + delta)),
];
}
function fallbackBandColor(elementColor: string): Rgb {
const color = hexToRgb(elementColor);
const min = Math.min(...color);
return color.map((channel) =>
Math.max(0, Math.round(channel - min / 4)),
) as Rgb;
}
function buildAdaptiveBands(
artSource: CanvasImageData,
elementColor: string,
): Rgb[] {
const bands: Rgb[] = [];
const bandHeight = Math.round(artSource.height / 3);
const fallback = fallbackBandColor(elementColor);
for (let band = 0; band < 3; band++) {
const start = bandHeight * band + 1;
const end = bandHeight * band + bandHeight - 1;
const rows: Rgb[] = [];
for (let y = start; y < end; y++) {
const row: Rgb[] = [];
for (let x = 0; x < artSource.width; x++) {
const index = (y * artSource.width + x) * 4;
if ((artSource.data[index + 3] ?? 0) <= 10) continue;
row.push([
artSource.data[index] ?? 0,
artSource.data[index + 1] ?? 0,
artSource.data[index + 2] ?? 0,
]);
}
if (row.length) rows.push(avgRows(row));
}
let color = avgRows(rows);
color = normalizeBandColor(color, band);
if (Math.max(...color) - Math.min(...color) < 20) {
color = [...fallback] as Rgb;
}
bands.push(color);
}
return bands;
}
export async function generateBackground({
width,
height,
element,
artSource,
}: {
width: number;
height: number;
element: string;
artSource?: ArrayBuffer;
}) {
const canvas = createCanvas(width * 1.2, height * 1.2);
const ctx = canvas.getContext("2d");
const elementColor = elementColors[element] ?? "#444444";
// base fill
ctx.fillStyle = elementColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (artSource) {
try {
const art = await loadImage(artSource);
const artCanvas = createCanvas(art.width, art.height);
const artCtx = artCanvas.getContext("2d");
artCtx.drawImage(art, 0, 0);
const sliceX = Math.min(
Math.round(artCanvas.width * 0.9),
artCanvas.width - 3,
);
const sliceWidth = Math.min(3, artCanvas.width);
const adaptiveSlice = artCtx.getImageData(
Math.max(0, sliceX),
0,
sliceWidth,
artCanvas.height,
);
const bands = buildAdaptiveBands(adaptiveSlice, elementColor);
const gradient = ctx.createLinearGradient(
Math.round(canvas.width / 2),
0 - Math.round(canvas.height / 6),
Math.round(canvas.width / 2) + 50,
canvas.height + Math.round(canvas.height / 6),
);
for (let i = 0; i < bands.length; i++) {
const [r, g, b] = bands[i];
gradient.addColorStop(i / 3 + 1 / 6, rgb(r, g, b));
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
} catch {
ctx.fillStyle = elementColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
} else {
// base fill
ctx.fillStyle = elementColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// overlay texture
const texture = await loadImage(await file(overlayImage).arrayBuffer());
+27 -21
View File
@@ -598,31 +598,34 @@ export function card(app: App) {
"affixes",
).finally(() => log`Stores loaded`),
),
cache.get(`enka-redir::${username}::${buildHash}`)
(cache.get(`enka-redir::${username}::${buildHash}`)
? Promise.resolve(
cache.get(
`enka-redir::${username}::${buildHash}`,
) as Record<
string,
{
id: number;
name: string;
avatar_id: string;
avatar_data: ApiResponse.AvatarInfo;
live: boolean;
public: boolean;
image?: string;
hoyo_type: number;
hoyo: string;
owner: string;
}[]
>,
cache.get(`enka-redir::${username}::${buildHash}`),
)
: fetch(
`https://enka.network/api/profile/${username}/hoyos/${buildHash}/builds/`,
)
.then(throwNotOk)
.then((r) => r.json()),
.then((r) => r.json())) as Promise<
Record<
string,
{
id: number;
name: string;
avatar_id: string;
avatar_data: ApiResponse.AvatarInfo;
live: boolean;
public: boolean;
settings?: {
caption?: string;
};
image?: string;
hoyo_type: number;
hoyo: string;
owner: string;
}[]
>
>,
]);
if (!(char in builds)) return error(404, errorLoc.buildNotFound);
@@ -636,7 +639,7 @@ export function card(app: App) {
const [avatarInfo, playerInfo] = await Promise.all([
resolveAvatarInfo(avatar.avatar_data),
profilePlayerInfo(username, avatar),
profilePlayerInfo(avatar.owner, avatar),
]);
const height =
@@ -648,6 +651,7 @@ export function card(app: App) {
hash: buildHash,
char,
build,
caption: avatar.settings?.caption,
lang,
height,
enableSubstat,
@@ -665,12 +669,14 @@ export function card(app: App) {
const res = await CharacterPage({
avatarInfo,
playerInfo,
uid: username,
uid: avatar.name ?? avatar.owner,
avatarStore,
weaponStore,
relicStore,
caption: avatar.settings?.caption,
locs,
affixes,
customImage: avatar.image,
lang,
height,
debug,
+49 -20
View File
@@ -54,6 +54,8 @@ type CharacterPageOptions = {
relicStore: RelicStore;
locs: EnkaLocs;
affixes: EnkaAffixes;
customImage?: string;
caption?: string;
lang?: keyof EnkaLocs;
height: number;
debug?: "none" | "render" | "dump";
@@ -74,6 +76,8 @@ export async function CharacterPage({
affixes,
lang,
height,
caption,
customImage,
debug = "none",
enableSubstat = false,
enableRollQuality = false,
@@ -157,6 +161,42 @@ export async function CharacterPage({
)
: undefined;
const avatarArtPath =
(char.costumeId && avatar.Costumes?.[char.costumeId].Art) ||
avatar.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg");
const artBytesRes = customImage
? fetch(customImage).then((r) => r.arrayBuffer())
: assets.load(avatarArtPath);
const featherAvatar = (buf: ArrayBuffer) =>
featherImage(buf, {
passes: [[0, 0.3, 0, 0]],
ratio: 1024 / 1124,
});
const avatarRes = customImage
? artBytesRes.then(featherAvatar)
: assets.fnCache(`${avatarArtPath}-avatar`, async () =>
featherAvatar(await artBytesRes),
);
const backgroundRes = customImage
? artBytesRes.then((artSource) =>
generateBackground({
artSource,
element: avatar.Element,
width: 1218,
height: 508,
}).then((r) => r.canvas.toBuffer("image/webp")),
)
: assets.fnCache(
`bg-${avatar.Element}-${avatarArtPath}-1218x508`,
async () =>
generateBackground({
artSource: await artBytesRes,
element: avatar.Element,
width: 1218,
height: 508,
}).then((r) => r.canvas.toBuffer("image/webp")),
);
const resources = await Promise.all([
assets
.load(
@@ -184,26 +224,8 @@ export async function CharacterPage({
}),
),
})),
assets
.hook(
(char.costumeId && avatar.Costumes?.[char.costumeId].Art) ||
avatar.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg"),
(buf) =>
featherImage(buf, {
passes: [[0, 0.3, 0, 0]],
ratio: 1024 / 1124,
}),
)
.then((data) => ({ src: "/avatar", data })),
assets
.fnCache(`bg-${avatar.Element}`, () =>
generateBackground({
element: avatar.Element,
width: 1218,
height: 508,
}).then((r) => r.canvas.toBuffer("image/webp")),
)
.then((data) => ({ src: `/bg-${avatar.Element}`, data })),
avatarRes.then((data) => ({ src: "/avatar", data })),
backgroundRes.then((data) => ({ src: `/bg-${avatar.Element}`, data })),
] satisfies Promise<{ src: string; data: ArrayBuffer | Buffer }>[]);
log`Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
@@ -362,6 +384,13 @@ export async function CharacterPage({
);
})}
</div>
{/* caption */}
<div tw="absolute bottom-[6%] left-[7%] right-[9.5%] z-10 flex justify-center">
<div tw="max-w-lg text-[0.95em] text-center bg-black/40 p-[0.5em_0.7em] border-[0.4em]">
{caption}
</div>
</div>
{/* uid */}
<div tw="flex mb-[-1em] ml-[-.3em] mt-[.3em] text-[.96em] opacity-80">
{uid}
</div>