feat: full visuals

This commit is contained in:
2026-03-13 14:10:51 +07:00 Unverified
parent fd13b7d627
commit 328ca18e6d
7 changed files with 254 additions and 32 deletions
+91
View File
@@ -0,0 +1,91 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import { file } from "bun";
import overlayImage from "@/assets/overlay.jpg";
const elementColors: Record<string, string> = {
Rock: "#bb9f4b",
Wind: "#52B0B1",
Ice: "#46A8BA",
Water: "#84A1C6",
Electric: "#9876AD",
Fire: "#BA8C83",
Grass: "#2D8E34",
None: "#94a0a7",
};
function avgColor(data: Uint8ClampedArray) {
let r = 0,
g = 0,
b = 0,
n = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 10) continue;
r += data[i];
g += data[i + 1];
b += data[i + 2];
n++;
}
if (!n) return [0, 0, 0];
return [Math.round(r / n), Math.round(g / n), Math.round(b / n)];
}
function rgb(r: number, g: number, b: number) {
return `rgb(${r},${g},${b})`;
}
export async function generateBackground({
width,
height,
element,
}: {
width: number;
height: number;
element: string;
}) {
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);
// overlay texture
const texture = await loadImage(await file(overlayImage).arrayBuffer());
ctx.globalCompositeOperation = "overlay";
ctx.drawImage(
texture,
0,
0,
texture.width,
texture.height,
0,
0,
canvas.width,
canvas.height,
);
ctx.globalCompositeOperation = "source-over";
// sample top middle
const sample = ctx.getImageData(Math.floor(canvas.width / 2), 0, 10, 1);
const [r, g, b] = avgColor(sample.data);
const accent = `linear-gradient(0deg,
${rgb(r, Math.max(0, g - 20), Math.max(0, b - 5))} 0%,
${rgb(r, Math.max(0, g - 10), Math.max(0, b - 15))} 100%)`;
return {
canvas,
accent,
};
}
+81
View File
@@ -0,0 +1,81 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
type Feather = [number, number, number, number];
export async function featherImage(
input: ArrayBuffer,
[top, right, bottom, left]: Feather = [0, 0.3, 0, 0],
) {
const img = await loadImage(Buffer.from(input));
const targetRatio = 1024 / 1124;
let cropW = img.width;
let cropH = img.height;
if (cropW / cropH > targetRatio) {
cropW = cropH * targetRatio;
} else {
cropH = cropW / targetRatio;
}
const sx = (img.width - cropW) / 2;
const sy = (img.height - cropH) / 2;
const canvas = createCanvas(cropW, cropH);
const ctx = canvas.getContext("2d");
ctx.drawImage(img, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
// mask canvas
const mask = createCanvas(cropW, cropH);
const m = mask.getContext("2d");
const w = cropW;
const h = cropH;
// center
m.fillStyle = "white";
m.fillRect(w * left, h * top, w * (1 - left - right), h * (1 - top - bottom));
if (right > 0) {
const fw = w * right;
const g = m.createLinearGradient(w - fw, 0, w, 0);
g.addColorStop(0, "white");
g.addColorStop(1, "transparent");
m.fillStyle = g;
m.fillRect(w - fw, 0, fw, h);
}
if (left > 0) {
const fw = w * left;
const g = m.createLinearGradient(0, 0, fw, 0);
g.addColorStop(0, "transparent");
g.addColorStop(1, "white");
m.fillStyle = g;
m.fillRect(0, 0, fw, h);
}
if (top > 0) {
const fh = h * top;
const g = m.createLinearGradient(0, 0, 0, fh);
g.addColorStop(0, "transparent");
g.addColorStop(1, "white");
m.fillStyle = g;
m.fillRect(0, 0, w, fh);
}
if (bottom > 0) {
const fh = h * bottom;
const g = m.createLinearGradient(0, h - fh, 0, h);
g.addColorStop(0, "white");
g.addColorStop(1, "transparent");
m.fillStyle = g;
m.fillRect(0, h - fh, w, fh);
}
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(mask, 0, 0);
return canvas.toBuffer("image/png");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+51 -32
View File
@@ -11,10 +11,13 @@ import {
} from "enka.ts";
import { twj } from "tw-to-css";
import type { App } from "@";
import { generateBackground } from "@/assets/background";
import { ConstsSvg } from "@/assets/consts";
import { featherImage } from "@/assets/feather";
import shadeSvg from "@/assets/shade.svg" with { type: "file" };
import fontFile from "@/assets/shinshin.woff2" with { type: "file" };
import { TalentSvg } from "@/assets/talent";
import { mockData } from "@/lib/mock";
import { AvatarStore } from "@/type/enka";
const cache = new TTLCache();
@@ -39,8 +42,8 @@ export function card(app: App) {
try {
const avatarStore = await getEnkaAvatarStore();
console.log(`${Date.now() - start}ms - Avatar store loaded`);
// const data = mockData;
const data = await fetchEnka(uid, false);
// const data = await fetchEnka(uid, false);
const data = mockData;
console.log(`${Date.now() - start}ms - Enka data loaded`);
if (typeof data.avatarInfoList === "undefined")
return status(400, "The showcase for this UID is private");
@@ -55,38 +58,54 @@ export function card(app: App) {
500,
"Unsupported character, avatar store doesn't have this character",
);
const resources = await Promise.all(
[
...avatar.Consts,
...Object.values(avatar.Skills),
...Object.values(avatar.Costumes ?? {})
.map((c) => c.Art)
.slice(0, 1),
avatar.SideIconName.replace(
"AvatarIcon_Side",
"Gacha_AvatarImg",
),
].map(async (path) => ({
src: path,
data: await fetch(new URL(path, "https://enka.network")).then(
(res) => res.arrayBuffer(),
const resources = await Promise.all([
...[...avatar.Consts, ...Object.values(avatar.Skills)].map(
async (path) => ({
src: path,
data: await fetch(
new URL(path, "https://enka.network"),
).then((res) => res.arrayBuffer()),
}),
),
fetch(
new URL(
avatar.SideIconName.replace(
"AvatarIcon_Side",
"Gacha_AvatarImg",
),
"https://enka.network",
),
)
.then((res) => res.arrayBuffer())
.then(async (buf) => ({
src: "/avatar",
data: await featherImage(buf, [0, 0.3, 0, 0]),
})),
generateBackground({
element: avatar.Element,
width: 1218,
height: 508,
}).then((r) => ({
src: "/bg",
data: r.canvas.toBuffer("image/webp"),
})),
]);
console.log(
`${Date.now() - start}ms - Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`,
);
console.log(`${Date.now() - start}ms - Image resources loaded`);
return new ImageResponse(
<div tw="relative text-[14.8667px] text-white">
<div tw="aspect-[1218/508] h-full"></div>
<div tw="relative text-[32.25px] text-white rounded-xl overflow-hidden">
<div tw="aspect-[1218/508] h-full">
<img
src="/bg"
alt="background"
tw="w-full h-full object-cover"
/>
</div>
{/* avatar */}
<img
src={
avatar.Costumes?.[0]?.Art ||
avatar.SideIconName.replace(
"AvatarIcon_Side",
"Gacha_AvatarImg",
)
}
src="/avatar"
alt="char"
tw="object-cover h-full aspect-[1024/1124] absolute top-0 -left-[2.5em] scale-x-[1.1] scale-y-[1.15]"
/>
@@ -106,7 +125,7 @@ export function card(app: App) {
{/* char and username */}
<div tw="text-[130%] pb-[.5%] flex items-center gap-[.5em]">
{char.avatar.name}
<div tw="text-[11.596px] opacity-80">
<div tw="text-[0.75em] opacity-80">
&nbsp;&nbsp;▴&nbsp;&nbsp; {data.playerInfo.nickname}
</div>
</div>
@@ -175,7 +194,7 @@ export function card(app: App) {
{Object.entries(avatar.Skills).map(([key, src]) => {
const talentLevel = char.skillLevelMap?.[key] ?? 0;
return (
<div tw="mt-[.3em] relative">
<div tw="mt-[.6em] relative">
<TalentSvg
tw={`Talents ${char.avatar.element} w-[3.6em] opacity-80 text-black`}
/>
@@ -199,13 +218,13 @@ export function card(app: App) {
</div>
</div>
{/* middle section */}
<div tw="absolute top-0 bottom-0 right-[23.1em] w-[21.5em] p-[1.4em_.7em_.7em] bg-green-500/50"></div>
<div tw="absolute top-0 bottom-0 right-[23.1em] w-[21.5em] p-[1.4em_.7em_.7em]"></div>
{/* right section */}
<div tw="absolute top-0 bottom-0 right-0 w-[23.1em] p-[.7em] bg-blue-500/50"></div>
<div tw="absolute top-0 bottom-0 right-0 w-[23.1em] p-[.7em]"></div>
</div>,
{
fonts: [await font.arrayBuffer()],
height: 508,
height: 1024,
persistentImages: [
{ data: await shade.arrayBuffer(), src: "shade" },
],