feat(enka): support adaptive background & custom art
Build and Deploy / build-and-deploy (push) Successful in 4m27s
Build and Deploy / build-and-deploy (push) Successful in 4m27s
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user