feat(art): dynamic avatar transformation
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Build and Deploy / build-and-deploy (push) Successful in 1m7s
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user