feat(art): dynamic avatar transformation
Build and Deploy / build-and-deploy (push) Successful in 1m7s

This commit is contained in:
2026-07-10 10:01:07 +07:00 Unverified
parent b5d5124e7c
commit 65e28a203d
6 changed files with 252 additions and 73 deletions
+85
View File
@@ -0,0 +1,85 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import type { Canvas } from "@napi-rs/canvas";
import {
applyFeatherToCanvas,
type Feather,
} from "@/games/genshin/assets/feather";
export type CharacterTransform = {
x: number;
y: number;
w: number;
};
function drawWithTransform(
canvas: Canvas,
image: Awaited<ReturnType<typeof loadImage>>,
transform: CharacterTransform,
) {
const ctx = canvas.getContext("2d");
const drawWidth = transform.w * canvas.width;
const drawHeight = (image.height / image.width) * drawWidth;
const drawX = transform.x * canvas.width;
const drawY = transform.y * canvas.height;
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
function drawBuiltInFallback(
canvas: Canvas,
image: Awaited<ReturnType<typeof loadImage>>,
) {
const ctx = canvas.getContext("2d");
const drawHeight = canvas.height * 1.3;
const drawWidth = drawHeight * (image.width / image.height);
const drawX = canvas.width * 0.5 - drawWidth / 2;
const drawY = canvas.height * 0.55 - drawHeight / 2;
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
function drawCustomFallback(
canvas: Canvas,
image: Awaited<ReturnType<typeof loadImage>>,
) {
const ctx = canvas.getContext("2d");
const drawWidth = canvas.width;
const drawHeight = (image.height / image.width) * drawWidth;
const drawY = (canvas.height - drawHeight) / 2;
ctx.drawImage(image, 0, drawY, drawWidth, drawHeight);
}
export async function renderCharacterArtToCanvas(
input: ArrayBuffer,
{
stageWidth,
stageHeight,
transform,
passes = [[0, 0.3, 0, 0]],
isCustom = false,
}: {
stageWidth: number;
stageHeight: number;
transform?: CharacterTransform;
passes?: Feather[];
isCustom?: boolean;
},
) {
const image = await loadImage(Buffer.from(input));
const preFeatherCanvas = createCanvas(stageWidth, stageHeight);
if (transform) drawWithTransform(preFeatherCanvas, image, transform);
else if (isCustom) drawCustomFallback(preFeatherCanvas, image);
else drawBuiltInFallback(preFeatherCanvas, image);
const finalCanvas = createCanvas(stageWidth, stageHeight);
finalCanvas.getContext("2d").drawImage(preFeatherCanvas, 0, 0);
applyFeatherToCanvas(finalCanvas, { passes });
return {
preFeatherCanvas,
finalCanvas,
};
}
+17 -9
View File
@@ -1,4 +1,5 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import type { Canvas } from "@napi-rs/canvas";
import type { ImageData as CanvasImageData } from "@napi-rs/canvas";
import { file } from "bun";
@@ -142,11 +143,13 @@ export async function generateBackground({
width,
height,
element,
artCanvas,
artSource,
}: {
width: number;
height: number;
element: string;
artCanvas?: Canvas;
artSource?: ArrayBuffer;
}) {
const canvas = createCanvas(width * 1.2, height * 1.2);
@@ -154,24 +157,29 @@ export async function generateBackground({
const elementColor = elementColors[element] ?? "#444444";
if (artSource) {
if (artCanvas || artSource) {
try {
const art = await loadImage(artSource);
const artCanvas = createCanvas(art.width, art.height);
const artCtx = artCanvas.getContext("2d");
const sourceCanvas = artCanvas ?? createCanvas(1, 1);
artCtx.drawImage(art, 0, 0);
if (!artCanvas && artSource) {
const art = await loadImage(artSource);
sourceCanvas.width = art.width;
sourceCanvas.height = art.height;
sourceCanvas.getContext("2d").drawImage(art, 0, 0);
}
const artCtx = sourceCanvas.getContext("2d");
const sliceX = Math.min(
Math.round(artCanvas.width * 0.9),
artCanvas.width - 3,
Math.round(sourceCanvas.width * 0.9),
sourceCanvas.width - 3,
);
const sliceWidth = Math.min(3, artCanvas.width);
const sliceWidth = Math.min(3, sourceCanvas.width);
const adaptiveSlice = artCtx.getImageData(
Math.max(0, sliceX),
0,
sliceWidth,
artCanvas.height,
sourceCanvas.height,
);
const bands = buildAdaptiveBands(adaptiveSlice, elementColor);
const gradient = ctx.createLinearGradient(
+40 -27
View File
@@ -1,6 +1,44 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import type { Canvas } from "@napi-rs/canvas";
type Feather = [number, number, number, number];
export type Feather = [number, number, number, number];
export function applyFeatherToCanvas(
canvas: Canvas,
{
passes,
}: {
passes: Feather[];
},
) {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const sides = [
[0, 0, 0, h],
[w, 0, 0, 0],
[0, h, 0, 0],
[0, 0, w, 0],
];
for (const [top, right, bottom, left] of passes) {
[top, right, bottom, left].forEach((s, i) => {
if (!s) return;
const g = ctx.createLinearGradient(
...(sides[i] as [number, number, number, number]),
);
g.addColorStop(0, "rgba(255,255,255,0)");
g.addColorStop(s, "rgba(255,255,255,1)");
ctx.globalCompositeOperation = "destination-in";
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "source-over";
});
}
}
export async function featherImage(
input: ArrayBuffer,
@@ -30,32 +68,7 @@ export async function featherImage(
ctx.drawImage(img, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
const w = cropW;
const h = cropH;
const sides = [
[0, 0, 0, h], // top
[w, 0, 0, 0], // right
[0, h, 0, 0], // bottom
[0, 0, w, 0], // left
];
for (const [top, right, bottom, left] of passes) {
[top, right, bottom, left].forEach((s, i) => {
if (!s) return;
const g = ctx.createLinearGradient(
...(sides[i] as [number, number, number, number]),
);
g.addColorStop(0, "rgba(255,255,255,0)");
g.addColorStop(s, "rgba(255,255,255,1)");
ctx.globalCompositeOperation = "destination-in";
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "source-over";
});
}
applyFeatherToCanvas(canvas, { passes });
return canvas.toBuffer("image/png");
}
+41
View File
@@ -0,0 +1,41 @@
export const TRANSFORM_CONSTANT = {
"10000005": "-0.016674353503593475,0.027102803738317905,1.2263671875000002,0",
"10000007": "-0.016674353503593475,0.027102803738317905,1.2263671875000002,0",
"10000117": "-0.3267884428741016,0.06140186915887837,1.605322265625,0",
"10000118": " -0.31077201576937885,0.05504672897196261,1.605322265625,0",
} as const;
// thanks stackoverflow
type WS = " " | "\n" | "\t";
type TrimLeft<S extends string> = S extends `${WS}${infer R}` ? TrimLeft<R> : S;
type TrimRight<S extends string> = S extends `${infer R}${WS}`
? TrimRight<R>
: S;
type Trim<S extends string> = TrimLeft<TrimRight<S>>;
type ParseTransform<S extends string> =
S extends `${infer X},${infer Y},${infer W},${string}`
? {
readonly x: Trim<X> extends `${infer N extends number}` ? N : never;
readonly y: Trim<Y> extends `${infer N extends number}` ? N : never;
readonly w: Trim<W> extends `${infer N extends number}` ? N : never;
}
: never;
type TransformMap<T extends Record<string, string>> = {
readonly [K in keyof T]: ParseTransform<T[K]>;
};
function makeTransformMap<
const T extends Record<string, `${string},${string},${string},${string}`>,
>(obj: T): TransformMap<T> {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
const [x, y, w] = value.split(",").map(Number);
return [key, { x, y, w }];
}),
) as TransformMap<T>;
}
export const TRANSFORM_MAP = makeTransformMap(TRANSFORM_CONSTANT);
+2 -1
View File
@@ -606,7 +606,8 @@ export function card(app: App) {
`https://enka.network/api/profile/${username}/hoyos/${buildHash}/builds/`,
)
.then(throwNotOk)
.then((r) => r.json())) as Promise<
.then((r) => r.json())
).finally(() => log`Enka data loaded`) as Promise<
Record<
string,
{
+67 -36
View File
@@ -4,6 +4,7 @@ import { EquipType } from "enka.ts";
import { ImageResponse } from "takumi-js/response";
import { font, iconFont, sansFont } from "@/assets/fonts";
import { renderCharacterArtToCanvas } from "@/games/genshin/assets/avatar";
import { generateBackground } from "@/games/genshin/assets/background";
import { ConstsSvg } from "@/games/genshin/assets/consts";
import { featherImage } from "@/games/genshin/assets/feather";
@@ -17,6 +18,7 @@ import { assets, getAvatar } from "@/games/genshin/data/assets";
import { buildDisplayRows } from "@/games/genshin/data/rows";
import type { RollSummary } from "@/games/genshin/data/substat";
import { buildRollSummary } from "@/games/genshin/data/substat";
import { TRANSFORM_MAP } from "@/games/genshin/data/transform";
import type {
AvatarStore,
EnkaAffixes,
@@ -171,28 +173,69 @@ export async function CharacterPage({
const avatarArtPath =
(char.costumeId && avatar.Costumes?.[char.costumeId].Art) ||
avatar.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg");
const fontSize = 30.466 * (height / 1024);
const leftStageWidth = Math.round(30.4 * fontSize);
const leftStageHeight = Math.round(height);
const defaultTransform = customImage
? undefined
: TRANSFORM_MAP[`${char.avatar.avatarId}` as keyof typeof TRANSFORM_MAP];
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,
});
let avatarCanvasRes: Promise<
Awaited<ReturnType<typeof renderCharacterArtToCanvas>>
> | null = null;
const getAvatarCanvas = () =>
(avatarCanvasRes ??= artBytesRes.then((buf) =>
renderCharacterArtToCanvas(buf, {
stageWidth: leftStageWidth,
stageHeight: leftStageHeight,
transform: defaultTransform,
isCustom: !!customImage,
}),
));
const avatarRes = customImage
? artBytesRes.then(featherAvatar)
: assets.fnCache(`${avatarArtPath}-avatar`, async () =>
featherAvatar(await artBytesRes),
? getAvatarCanvas().then(({ finalCanvas }) =>
finalCanvas.toBuffer("image/png"),
)
: assets.fnCache(
`${avatarArtPath}-avatar-${leftStageWidth}x${leftStageHeight}`,
async () => (await getAvatarCanvas()).finalCanvas.toBuffer("image/png"),
);
const backgroundRes = adaptiveColor
? artBytesRes.then((artSource) =>
const backgroundAccentRes = adaptiveColor
? getAvatarCanvas().then(({ preFeatherCanvas }) =>
generateBackground({
artSource,
artCanvas: preFeatherCanvas,
element: avatar.Element,
width: 1218,
height: 508,
}),
}).then((r) => r.accent),
)
: generateBackground({
element: avatar.Element,
width: 1218,
height: 508,
}).then((r) => r.accent);
const backgroundRes = adaptiveColor
? customImage
? getAvatarCanvas().then(({ preFeatherCanvas }) =>
generateBackground({
artCanvas: preFeatherCanvas,
element: avatar.Element,
width: 1218,
height: 508,
}).then((r) => r.canvas.toBuffer("image/webp")),
)
: assets.fnCache(
`bg-${avatar.Element}-${avatarArtPath}-${leftStageWidth}x${leftStageHeight}-1218x508`,
async () =>
generateBackground({
artCanvas: (await getAvatarCanvas()).preFeatherCanvas,
element: avatar.Element,
width: 1218,
height: 508,
}).then((r) => r.canvas.toBuffer("image/webp")),
)
: assets.fnCache(`bg-${avatar.Element}`, async () =>
generateBackground({
element: avatar.Element,
@@ -229,15 +272,7 @@ export async function CharacterPage({
),
})),
avatarRes.then((data) => ({ src: "/avatar", data })),
backgroundRes.then((data) => ({
src: `/bg-${avatar.Element}`,
data:
data instanceof Buffer || data instanceof ArrayBuffer
? data
: (
data as Awaited<ReturnType<typeof generateBackground>>
).canvas.toBuffer("image/webp"),
})),
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")}`;
@@ -272,7 +307,7 @@ export async function CharacterPage({
<SubstatDisplay
substat={rollSummary}
element={avatar.Element}
accent={((await backgroundRes) as { accent: string }).accent}
accent={await backgroundAccentRes}
>
<div
tw={`flex relative text-white rounded-[.5em] overflow-hidden aspect-[1218/508] h-[${height}px]`}
@@ -284,21 +319,14 @@ export async function CharacterPage({
tw="w-full h-full object-cover"
/>
</div>
<div tw="absolute top-0 bottom-0 left-0 w-[30.4em]"></div>
<div
tw="absolute top-0 bottom-0 left-0 w-[30.4em] p-[1.6em] flex flex-col"
style={{
textShadow: "0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)",
}}
>
<img
src="/avatar"
alt="char"
tw="absolute h-full aspect-[1024/1124] left-[50%] top-[55%] max-w-none"
style={{
transform: "translate(-50%, -50%) scale(1.3)",
transformOrigin: "50% 50%",
}}
/>
<img src="/avatar" alt="char" tw="absolute inset-0 w-full h-full" />
<div tw="flex absolute top-0 left-0 bottom-0 right-0">
<img src="shade" alt="shade" tw="opacity-40" />
</div>
@@ -399,10 +427,13 @@ export async function CharacterPage({
</div>
);
})}
<div tw="text-[0.9em] -mb-[1.3em] mt-[1.3em] right-[.8em] opacity-70">
{" "}
{artSource}
</div>
{/* artSource */}
{artSource && (
<div tw="text-[0.9em] -mb-[1.3em] mt-[1.3em] right-[.8em] opacity-70">
{" "}
{artSource}
</div>
)}
</div>
{/* caption */}
{caption && (
@@ -698,7 +729,7 @@ function SubstatDisplay({
{substat.map((s) => (
<div
key={s.id}
tw="mr-[1.2em] flex gap-[.2em] items-center whitespace-nowrap"
tw="mr-[1.65em] flex gap-[.2em] items-center whitespace-nowrap"
>
<div tw="ml-[.2em] mb-[.1em] text-[.75em] flex flex-col items-center font-[Open_Sans]">
{s.efficiency}