feat :idk
CI / Verify (push) Successful in 1m1s
CI / Build immutable images and deploy (push) Failing after 51s

This commit is contained in:
2026-08-31 22:05:51 +07:00 Unverified
parent e45bd36f5f
commit 040d49fd9b
11 changed files with 3689 additions and 11 deletions
@@ -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,
});
}
+7 -1
View File
@@ -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,
});
}
+2
View File
@@ -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(),
},
+2
View File
@@ -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
+7
View File
@@ -85,6 +85,13 @@
"when": 1788107548585,
"tag": "0011_nervous_quasimodo",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1788177201633,
"tag": "0012_left_bishop",
"breakpoints": true
}
]
}
+14
View File
@@ -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");
});
+12
View File
@@ -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
View File
@@ -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 });
+2
View File
@@ -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
View File
@@ -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",
}}
>