feat :idk
This commit is contained in:
@@ -92,5 +92,8 @@ export default async function Image({
|
||||
return generateOgImage({
|
||||
title: guide.name,
|
||||
sub: `${sectionTitle(page, section.title)} · ${content.highlights.join(" · ") || content.description}`,
|
||||
characterCard: true,
|
||||
backgroundUrl: guide.character.nameCardImageUrl,
|
||||
characterUrl: guide.character.coopImageUrl ?? guide.character.imageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,5 +18,11 @@ export default async function Image({
|
||||
await connection();
|
||||
const guide = await getCachedPublicGuide((await params).character);
|
||||
if (!guide) return new Response("Not found", { status: 404 });
|
||||
return generateOgImage({ title: guide.name, sub: guide.overview });
|
||||
return generateOgImage({
|
||||
title: guide.name,
|
||||
sub: guide.overview,
|
||||
characterCard: true,
|
||||
backgroundUrl: guide.character.nameCardImageUrl,
|
||||
characterUrl: guide.character.coopImageUrl ?? guide.character.imageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -455,6 +455,8 @@ export const catalogCharacters = catalogSchema.table(
|
||||
weaponType: varchar("weapon_type", { length: 64 }),
|
||||
imageKey: text("image_key").notNull(),
|
||||
elementIconKey: text("element_icon_key"),
|
||||
coopImageKey: text("coop_image_key"),
|
||||
nameCardImageKey: text("name_card_image_key"),
|
||||
constellations: jsonb("constellations").$type<CatalogConstellation[]>().default([]).notNull(),
|
||||
talents: jsonb("talents").$type<CatalogTalent[]>().default([]).notNull(),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "catalog"."catalog_character" ADD COLUMN "coop_image_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "catalog"."catalog_character" ADD COLUMN "name_card_image_key" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1788107548585,
|
||||
"tag": "0011_nervous_quasimodo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1788177201633,
|
||||
"tag": "0012_left_bishop",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
characterDetailSchema,
|
||||
cleanLunarisText,
|
||||
constellationSnapshot,
|
||||
nameCardImageName,
|
||||
splashImageName,
|
||||
supportsWeaponType,
|
||||
talentSnapshot,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
@@ -16,6 +18,18 @@ describe("Lunaris character data", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps CardImg names to Lunaris character card assets", () => {
|
||||
expect(splashImageName("UI_Gacha_AvatarIcon_Ayaka")).toBe(
|
||||
"UI_Gacha_AvatarImg_Ayaka.png",
|
||||
);
|
||||
expect(nameCardImageName("UI_Gacha_AvatarIcon_Ayaka")).toBe(
|
||||
"UI_NameCardPic_Ayaka_P.png",
|
||||
);
|
||||
expect(nameCardImageName("UI_Gacha_AvatarIcon_Qin")).toBe(
|
||||
"UI_NameCardPic_Qin_P.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes display markup from constellation text", () => {
|
||||
expect(cleanLunarisText('"A {LINK#N1}<color=#fff>bright</color>{/LINK} morning"')).toBe("A bright morning");
|
||||
});
|
||||
|
||||
@@ -52,6 +52,18 @@ export function avatarIconName(value: string): string {
|
||||
return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_");
|
||||
}
|
||||
|
||||
export function characterAssetName(value: string): string {
|
||||
return avatarIconName(value).replace(/^UI_AvatarIcon_/u, "");
|
||||
}
|
||||
|
||||
export function splashImageName(value: string): string {
|
||||
return `UI_Gacha_AvatarImg_${characterAssetName(value)}.png`;
|
||||
}
|
||||
|
||||
export function nameCardImageName(value: string): string {
|
||||
return `UI_NameCardPic_${characterAssetName(value)}_P.png`;
|
||||
}
|
||||
|
||||
export function supportsWeaponType(
|
||||
characterWeaponType: string | null | undefined,
|
||||
weaponType: string | null | undefined,
|
||||
|
||||
+40
-3
@@ -15,6 +15,8 @@ import {
|
||||
avatarIconName,
|
||||
characterDetailSchema,
|
||||
constellationSnapshot,
|
||||
nameCardImageName,
|
||||
splashImageName,
|
||||
talentSnapshot,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import type { CatalogConstellation, CatalogTalent } from "@/lib/catalog/lunaris";
|
||||
@@ -56,7 +58,11 @@ interface SyncedCharacter {
|
||||
weaponType: string | null;
|
||||
imageKey: string;
|
||||
elementIconKey: string | null;
|
||||
coopImageKey: string | null;
|
||||
nameCardImageKey: string | null;
|
||||
sourceImage: string | null;
|
||||
coopSourceImage: string | null;
|
||||
nameCardSourceImage: string | null;
|
||||
localImage: string | null;
|
||||
constellations: CatalogConstellation[];
|
||||
talents: CatalogTalent[];
|
||||
@@ -154,12 +160,18 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
const detailRaw = await getOptionalJson(`${API_ROOT}/${version}/en/char/${encodeURIComponent(key)}.json`);
|
||||
const detail = detailRaw ? characterDetailSchema.parse(detailRaw) : null;
|
||||
const avatarIcon = webpName(avatarIconName(value.CardImg));
|
||||
const splashImage = splashImageName(value.CardImg);
|
||||
const nameCardImage = nameCardImageName(value.CardImg);
|
||||
const element = detail?.info?.element ?? value.element ?? null;
|
||||
return {
|
||||
version, key, name: value.enName, element, rarity: rarity(value.qualityType), weaponType: detail?.info?.weapon ?? value.weaponType ?? null,
|
||||
imageKey: `catalog/${version}/characters/${avatarIcon}`,
|
||||
elementIconKey: element ? `catalog/${version}/elements/${element.toLowerCase()}.png` : null,
|
||||
coopImageKey: `catalog/${version}/characters/splash/${splashImage}`,
|
||||
nameCardImageKey: `catalog/${version}/characters/namecard/${nameCardImage}`,
|
||||
sourceImage: `${API_ROOT}/assets/avataricon/${encodeURIComponent(avatarIcon)}`,
|
||||
coopSourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(splashImage)}`,
|
||||
nameCardSourceImage: `${API_ROOT}/assets/namecardpic/${encodeURIComponent(nameCardImage)}`,
|
||||
localImage: null,
|
||||
constellations: detail ? constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`) : [],
|
||||
talents: detail ? talentSnapshot(detail, (icon) => `catalog/${version}/talents/${webpName(icon)}`) : [],
|
||||
@@ -177,7 +189,11 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
weaponType: "WEAPON_SWORD_ONE_HAND",
|
||||
imageKey: `catalog/${version}/characters/${traveler.asset}`,
|
||||
elementIconKey: `catalog/${version}/elements/${traveler.element.toLowerCase()}.png`,
|
||||
coopImageKey: null,
|
||||
nameCardImageKey: null,
|
||||
sourceImage: null,
|
||||
coopSourceImage: null,
|
||||
nameCardSourceImage: null,
|
||||
localImage: traveler.asset,
|
||||
constellations: constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`),
|
||||
talents: talentSnapshot(detail, (icon) => `catalog/${version}/talents/${webpName(icon)}`),
|
||||
@@ -200,6 +216,20 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
body: await readFile(join(process.cwd(), "lib/catalog/assets", character.localImage)),
|
||||
target: character.imageKey,
|
||||
} : { source: character.sourceImage!, target: character.imageKey });
|
||||
if (character.coopImageKey && character.coopSourceImage) {
|
||||
uniqueAssets.set(character.coopImageKey, {
|
||||
source: character.coopSourceImage,
|
||||
target: character.coopImageKey,
|
||||
type: "image/png",
|
||||
});
|
||||
}
|
||||
if (character.nameCardImageKey && character.nameCardSourceImage) {
|
||||
uniqueAssets.set(character.nameCardImageKey, {
|
||||
source: character.nameCardSourceImage,
|
||||
target: character.nameCardImageKey,
|
||||
type: "image/png",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const item of [...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
|
||||
for (const character of characters) {
|
||||
@@ -225,13 +255,20 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
}
|
||||
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
|
||||
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
|
||||
characters = characters.map((item) => ({ ...item, elementIconKey: item.elementIconKey && !missingAssets.has(item.elementIconKey) ? item.elementIconKey : null, constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.iconKey : null })), talents: item.talents.map((talent) => ({ ...talent, iconKey: talent.iconKey && !missingAssets.has(talent.iconKey) ? talent.iconKey : null })) }));
|
||||
characters = characters.map((item) => ({
|
||||
...item,
|
||||
elementIconKey: item.elementIconKey && !missingAssets.has(item.elementIconKey) ? item.elementIconKey : null,
|
||||
coopImageKey: item.coopImageKey && !missingAssets.has(item.coopImageKey) ? item.coopImageKey : null,
|
||||
nameCardImageKey: item.nameCardImageKey && !missingAssets.has(item.nameCardImageKey) ? item.nameCardImageKey : null,
|
||||
constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.iconKey : null })),
|
||||
talents: item.talents.map((talent) => ({ ...talent, iconKey: talent.iconKey && !missingAssets.has(talent.iconKey) ? talent.iconKey : null })),
|
||||
}));
|
||||
weapons = weapons.filter((item) => !missingAssets.has(item.imageKey));
|
||||
artifacts = artifacts.filter((item) => !missingAssets.has(item.imageKey));
|
||||
|
||||
const storage = await getMediaStorage();
|
||||
await Promise.all([
|
||||
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, constellations: item.constellations, talents: item.talents }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, coopImageKey: item.coopImageKey, nameCardImageKey: item.nameCardImageKey, constellations: item.constellations, talents: item.talents }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/weapons.json`, JSON.stringify(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/artifacts.json`, JSON.stringify(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
]);
|
||||
@@ -241,7 +278,7 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
await tx.delete(catalogCharacters).where(eq(catalogCharacters.version, version));
|
||||
await tx.delete(catalogWeapons).where(eq(catalogWeapons.version, version));
|
||||
await tx.delete(catalogArtifacts).where(eq(catalogArtifacts.version, version));
|
||||
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, constellations: item.constellations, talents: item.talents })));
|
||||
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, coopImageKey: item.coopImageKey, nameCardImageKey: item.nameCardImageKey, constellations: item.constellations, talents: item.talents })));
|
||||
if (weapons.length) await tx.insert(catalogWeapons).values(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
|
||||
if (artifacts.length) await tx.insert(catalogArtifacts).values(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, imageKey: item.imageKey })));
|
||||
await tx.update(catalogVersions).set({ active: false });
|
||||
|
||||
@@ -27,6 +27,8 @@ function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) {
|
||||
return {
|
||||
...item,
|
||||
imageUrl: publicMediaUrl(item.imageKey),
|
||||
coopImageUrl: item.coopImageKey ? publicMediaUrl(item.coopImageKey) : null,
|
||||
nameCardImageUrl: item.nameCardImageKey ? publicMediaUrl(item.nameCardImageKey) : null,
|
||||
elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null,
|
||||
constellations: item.constellations.map((constellation) => ({
|
||||
...constellation,
|
||||
|
||||
+78
-7
@@ -4,12 +4,14 @@ import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { ImageResponse } from "next/og";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const ogImageSize = { width: 1222, height: 560 } as const;
|
||||
export const ogImageContentType = "image/png";
|
||||
|
||||
let bannerDataPromise: Promise<string> | undefined;
|
||||
let fontDataPromise: Promise<Buffer> | undefined;
|
||||
const imageDataPromises = new Map<string, Promise<string | null>>();
|
||||
|
||||
function shorten(value: string, length: number): string {
|
||||
const normalized = value.replace(/\s+/gu, " ").trim();
|
||||
@@ -31,14 +33,44 @@ function getFontData(): Promise<Buffer> {
|
||||
return fontDataPromise;
|
||||
}
|
||||
|
||||
function getImageData(url: string): Promise<string | null> {
|
||||
const cached = imageDataPromises.get(url);
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = fetch(url)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) return null;
|
||||
const png = await sharp(Buffer.from(await response.arrayBuffer()))
|
||||
.png()
|
||||
.toBuffer();
|
||||
return `data:image/png;base64,${png.toString("base64")}`;
|
||||
})
|
||||
.catch(() => null);
|
||||
imageDataPromises.set(url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function generateOgImage({
|
||||
title,
|
||||
sub,
|
||||
characterCard = false,
|
||||
backgroundUrl,
|
||||
characterUrl,
|
||||
}: {
|
||||
title: string;
|
||||
sub: string;
|
||||
characterCard?: boolean;
|
||||
backgroundUrl?: string | null;
|
||||
characterUrl?: string | null;
|
||||
}) {
|
||||
const [bannerSrc, kanit] = await Promise.all([getBannerData(), getFontData()]);
|
||||
const [bannerSrc, kanit, characterBackgroundSrc, characterArtSrc] =
|
||||
await Promise.all([
|
||||
characterCard ? null : getBannerData(),
|
||||
getFontData(),
|
||||
characterCard && backgroundUrl ? getImageData(backgroundUrl) : null,
|
||||
characterCard && characterUrl ? getImageData(characterUrl) : null,
|
||||
]);
|
||||
const backgroundSrc = characterCard ? characterBackgroundSrc : bannerSrc;
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
@@ -48,26 +80,65 @@ export async function generateOgImage({
|
||||
height: "560px",
|
||||
fontFamily: "Kanit",
|
||||
color: "#fff",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<img src={bannerSrc} alt="Banner" width={1222} height={560} />
|
||||
{backgroundSrc ? (
|
||||
<img src={backgroundSrc} alt="" width={1222} height={560} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
) : (
|
||||
<div style={{ position: "absolute", inset: 0, background: "#111827" }} />
|
||||
)}
|
||||
{characterCard && characterArtSrc ? (
|
||||
<img
|
||||
src={characterArtSrc}
|
||||
alt=""
|
||||
width={480}
|
||||
height={360}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: "58%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{characterCard ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "linear-gradient(90deg, rgba(3, 8, 18, 0.88) 0%, rgba(3, 8, 18, 0.62) 45%, rgba(3, 8, 18, 0.08) 100%)",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 64,
|
||||
position: "absolute",
|
||||
top: "160px",
|
||||
top: characterCard ? "52px" : "160px",
|
||||
left: "60px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
right: "450px",
|
||||
right: characterCard ? "560px" : "450px",
|
||||
bottom: "100px",
|
||||
}}
|
||||
>
|
||||
<div>{shorten(title, 24)}</div>
|
||||
{characterCard ? (
|
||||
<div style={{ display: "flex", color: "#d8f4ee", fontSize: 28 }}>
|
||||
เกนชินไม่ใช่เกมมือถือไกด์
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ display: "flex", fontSize: 64, marginTop: characterCard ? "8px" : 0 }}>
|
||||
{shorten(title, 24)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 24,
|
||||
marginTop: "-20px",
|
||||
marginTop: characterCard ? "8px" : "-20px",
|
||||
color: "#aaa",
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user