diff --git a/.gitignore b/.gitignore
index 7b8da95..159dd40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+/public/takumi.wasm
+/public/takumi.css
diff --git a/Dockerfile b/Dockerfile
index 918e1f8..f98d012 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -30,7 +30,8 @@ COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
# Bun 1.3.14 can crash after a successful Next build on arm64. The application
# still runs on Bun; Node is used only for the deterministic compiler process.
-RUN node node_modules/next/dist/bin/next build
+RUN node scripts/prepare-image-export.mjs && \
+ node node_modules/next/dist/bin/next build
FROM dependencies AS migration
WORKDIR /app
diff --git a/bun.lock b/bun.lock
index 4271f31..a912397 100644
--- a/bun.lock
+++ b/bun.lock
@@ -42,10 +42,12 @@
"sharp": "^0.35.4",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
+ "takumi-js": "2.13.7",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3",
},
"devDependencies": {
+ "@tailwindcss/node": "4.3.3",
"@tailwindcss/postcss": "^4",
"@types/bun": "^1.4.0",
"@types/node": "^20",
@@ -500,6 +502,28 @@
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="],
+ "@takumi-rs/core": ["@takumi-rs/core@2.13.7", "", { "dependencies": { "@takumi-rs/helpers": "2.13.7" }, "optionalDependencies": { "@takumi-rs/core-darwin-arm64": "2.13.7", "@takumi-rs/core-darwin-x64": "2.13.7", "@takumi-rs/core-linux-arm64-gnu": "2.13.7", "@takumi-rs/core-linux-arm64-musl": "2.13.7", "@takumi-rs/core-linux-x64-gnu": "2.13.7", "@takumi-rs/core-linux-x64-musl": "2.13.7", "@takumi-rs/core-win32-arm64-msvc": "2.13.7", "@takumi-rs/core-win32-x64-msvc": "2.13.7" }, "peerDependencies": { "csstype": "*" }, "optionalPeers": ["csstype"] }, "sha512-FBkglt8kGBZwT2CHr+kppN+FH72Oy8gLIZz1BIbD9vNcocLrvtLsh1Y0nB18mOPmaROKGUVUXLPnIuWo49CRng=="],
+
+ "@takumi-rs/core-darwin-arm64": ["@takumi-rs/core-darwin-arm64@2.13.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jbe1ik4bcVOOHDpa5RCjm5fe+LfJjjyebvvPBMekhEKXg6hlTbIEFmhr/GCxMOKUfaNsvb4amZ/bVCo0NDSCjA=="],
+
+ "@takumi-rs/core-darwin-x64": ["@takumi-rs/core-darwin-x64@2.13.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-PvmSmhjbSosM3XaHZ9VhFm1x7Xc9oZ5GqXlskiwZblKIi5S4EI43buhUd28xzcBlKkB/vC3c15Cj417XjnUk4g=="],
+
+ "@takumi-rs/core-linux-arm64-gnu": ["@takumi-rs/core-linux-arm64-gnu@2.13.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-TGvDidIF3dtb7TMoJRF+0BET/v6eAuQxV8AXo/57GqO3TM9wjLt3AbkrFkzxSsaIRXlBQxCfb+GsS0YdGnpo4w=="],
+
+ "@takumi-rs/core-linux-arm64-musl": ["@takumi-rs/core-linux-arm64-musl@2.13.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-CL92+JeFbqlPfWI2SSQqLtcjWpxEsPYQ+wNhaqUWLnlTMfZfj+Fl3ERFLbmK+lYnGs7bzpPXLisd3GJQ37rTYw=="],
+
+ "@takumi-rs/core-linux-x64-gnu": ["@takumi-rs/core-linux-x64-gnu@2.13.7", "", { "os": "linux", "cpu": "x64" }, "sha512-ZxoapyUDlInik93WIA9+Sk/ByOb3QGO7GaL9PA7OOdIlK5ovK9v9q88tZb1cvfxtIVzdE9HPBUhb5quNMcuXWg=="],
+
+ "@takumi-rs/core-linux-x64-musl": ["@takumi-rs/core-linux-x64-musl@2.13.7", "", { "os": "linux", "cpu": "x64" }, "sha512-OqoVTd7mrlIwjKYYFizUwz+3zpnVsbrpLFPXFC+BmUxdFYNZwKa0R+Pk56EslyBZS3CuBy+RnBusHCvNC7+09g=="],
+
+ "@takumi-rs/core-win32-arm64-msvc": ["@takumi-rs/core-win32-arm64-msvc@2.13.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-evSyi/y3uV03U+PJfLeG2V3aRsmmWJ6elrFpkLtHebWwi49kXazTL5TYsCkPj5hA4i+rNs6z/t/BygLj1Krp8Q=="],
+
+ "@takumi-rs/core-win32-x64-msvc": ["@takumi-rs/core-win32-x64-msvc@2.13.7", "", { "os": "win32", "cpu": "x64" }, "sha512-GawtljFxuJemQynlF5wryxtSLv18XNuKSjMt52xf66WMaBKt4eN6/b7yIZp9QqKPof8ip8uqFOt7fWVSUN+gHQ=="],
+
+ "@takumi-rs/helpers": ["@takumi-rs/helpers@2.13.7", "", { "peerDependencies": { "preact": "^10.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["preact", "react"] }, "sha512-6AKk/ixuvoCEZD9/LPAz3kxTcwWHeZh15quhHIHYiVxFRl4x2LyKN8j6eZngiepYYsORXAuJ5YlTGA7uIRfeMQ=="],
+
+ "@takumi-rs/wasm": ["@takumi-rs/wasm@2.13.7", "", { "dependencies": { "@takumi-rs/helpers": "2.13.7" }, "peerDependencies": { "csstype": "*" }, "optionalPeers": ["csstype"] }, "sha512-tc2IFETJ9HtZpS94cL3qSTiecJhhR4RUTXbb3E8cyz6WKdyoZSFkL2r67xnidVtcv22RKTyfVJ2gxV7eWvF+mA=="],
+
"@tiptap/core": ["@tiptap/core@3.31.3", "", { "peerDependencies": { "@tiptap/pm": "3.31.3" } }, "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg=="],
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ=="],
@@ -1674,6 +1698,8 @@
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
+ "takumi-js": ["takumi-js@2.13.7", "", { "dependencies": { "@takumi-rs/core": "2.13.7", "@takumi-rs/helpers": "2.13.7", "@takumi-rs/wasm": "2.13.7" } }, "sha512-6CuATUv1O6X/aD3T0OQIkv0nyesefJibamskfQxcQqKVl2CCk6kIX9E0e+glMTILTJC2I1IcrcFEPlmDlcexgQ=="],
+
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
diff --git a/components/admin/guide-overview-form.tsx b/components/admin/guide-overview-form.tsx
index ebddd8c..782308d 100644
--- a/components/admin/guide-overview-form.tsx
+++ b/components/admin/guide-overview-form.tsx
@@ -11,7 +11,7 @@ import { saveGuideOverview } from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { MediaUploader } from "@/components/admin/media-uploader";
import { MaterialPoster, type PosterBlendMode } from "@/components/public/material-poster";
-import { GuideImageExport } from "@/components/public/guide-image-export";
+import { TakumiImageExport } from "@/components/public/takumi-image-export";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -458,26 +458,16 @@ export function GuideOverviewForm({
ลบภาพตัวละคร
) : null}
-
-
-
+ preview
+ template={{ kind: "materials", data: { character: guide.character, characterArtUrl: posterArt.url, artworkOffsetX: posterArtOffset.x, artworkOffsetY: posterArtOffset.y, artworkScale: posterArtScale, topColors: posterTopColors, bottomColors: posterBottomColors, bleeding } }}
+ />
diff --git a/components/admin/overview-image-generator.tsx b/components/admin/overview-image-generator.tsx
index a8dff70..fa40d33 100644
--- a/components/admin/overview-image-generator.tsx
+++ b/components/admin/overview-image-generator.tsx
@@ -21,7 +21,6 @@ import {
import { CSS } from "@dnd-kit/utilities";
import {
CropIcon,
- DownloadIcon,
GripVerticalIcon,
ImageIcon,
PlusIcon,
@@ -41,6 +40,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { TakumiImageExport } from "@/components/public/takumi-image-export";
import {
Card,
CardContent,
@@ -80,7 +80,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
-import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { elementIconFilter } from "@/lib/catalog/element";
import { rarityGradientClass } from "@/lib/catalog/rarity";
@@ -699,7 +698,6 @@ export function OverviewImageGenerator({
characters: Character[];
elementIconUrls: Record;
}) {
- const captureRef = useRef(null);
const [character, setCharacter] = useState(null);
const [artworkUrl, setArtworkUrl] = useState(null);
const [artworkError, setArtworkError] = useState(null);
@@ -735,7 +733,6 @@ export function OverviewImageGenerator({
coordinateGetter: sortableKeyboardCoordinates,
}),
);
- const [exporting, setExporting] = useState(false);
const [backgroundUrl, setBackgroundUrl] = useState(null);
const [fadeColor, setFadeColor] = useState("#0E0200");
const [fadeTint, setFadeTint] = useState(100);
@@ -998,49 +995,6 @@ export function OverviewImageGenerator({
}
}
- async function download() {
- const node = captureRef.current;
- if (
- !node ||
- !character ||
- !artworkUrl ||
- !normalizeHex(topColorStart) ||
- !normalizeHex(topColorEnd) ||
- !normalizeHex(bottomColorStart) ||
- !normalizeHex(bottomColorEnd) ||
- !normalizeHex(elementColorStart) ||
- !normalizeHex(elementColorEnd) ||
- !normalizeHex(fadeColor)
- )
- return;
-
- setExporting(true);
- try {
- const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
- await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
- const blob = await domToBlob(node, {
- backgroundColor: "#000000",
- type: "image/png",
- width: 1000,
- height: 1000,
- scale: 3,
- });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = overviewFileName(character.key, displayRole);
- document.body.append(link);
- link.click();
- link.remove();
- window.setTimeout(() => URL.revokeObjectURL(url), 0);
- toast.success("ดาวน์โหลดภาพ Overview แล้ว");
- } catch {
- toast.error("ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
- } finally {
- setExporting(false);
- }
- }
-
const ready = Boolean(
character &&
artworkUrl &&
@@ -1335,18 +1289,12 @@ export function OverviewImageGenerator({
- void download()}
- >
- {exporting ? (
-
- ) : (
-
- )}
- {exporting ? "กำลังส่งออก…" : "ดาวน์โหลด PNG 3000×3000"}
-
+
@@ -1469,28 +1417,6 @@ export function OverviewImageGenerator({
-
-
-
-
{
diff --git a/components/public/artifact-image-export.tsx b/components/public/artifact-image-export.tsx
index fa200b0..fde0475 100644
--- a/components/public/artifact-image-export.tsx
+++ b/components/public/artifact-image-export.tsx
@@ -1,9 +1,10 @@
"use client";
-import { useState, type ReactNode } from "react";
+import { useState } from "react";
import { DownloadIcon } from "lucide-react";
-import { GuideImageExport } from "@/components/public/guide-image-export";
+import { TakumiImageExport } from "@/components/public/takumi-image-export";
+import type { PublicGuide } from "@/lib/guides/queries";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -27,13 +28,13 @@ const sectionOptions = [
type ArtifactExportSection = (typeof sectionOptions)[number][0];
export function ArtifactImageExport({
- children,
+ guide,
fileName,
hasNote,
targetConditionLabels,
recommendedConditionLabels,
}: {
- children: ReactNode;
+ guide: PublicGuide;
fileName: string;
hasNote: boolean;
targetConditionLabels: readonly string[];
@@ -47,15 +48,6 @@ export function ArtifactImageExport({
const [targetCondition, setTargetCondition] = useState("0");
const showNote = hasNote && includeNote;
const entireSection = sections.size === sectionOptions.length;
- const hiddenSections = sectionOptions
- .filter(([value]) => !sections.has(value))
- .map(([value]) => `[data-artifact-export-section="${value}"]{display:none!important}`)
- .join("");
- const targetRule = `[data-target-condition]:not([data-target-condition="${targetCondition}"]){display:none!important}`;
- const recommendedRule = `[data-recommended-condition]:not([data-recommended-condition="${recommendedCondition}"]){display:none!important}`;
- const noteRule = showNote
- ? ""
- : '[data-artifact-export-section="note"]{display:none!important}';
const onlySection = sections.size === 1 ? [...sections][0] : null;
const exportWidth = onlySection === "best-stats"
? 700
@@ -63,16 +55,6 @@ export function ArtifactImageExport({
? 800
: 1200;
const exportHeight = onlySection === "best-stats" ? 700 : undefined;
- const hideEmptyRightColumn = !showNote && !sections.has("target-stats") && !sections.has("talents")
- ? ".artifact-export-right{display:none!important}"
- : "";
- const layoutRules = onlySection === "tierlist"
- ? ".artifact-export-grid{grid-template-columns:1fr!important}.artifact-export-tierlist{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));gap:1.25rem}"
- : onlySection === "best-stats"
- ? ".artifact-export-root{height:100%!important}.artifact-export-grid{grid-template-columns:1fr!important}.artifact-export-left{display:flex!important;flex-direction:column!important}.artifact-export-main-stats,.artifact-export-substats{width:480px!important;max-width:100%!important;margin-inline:auto!important}.artifact-export-main-stats{order:1!important}.artifact-export-substats{order:2!important}.artifact-export-right{width:100%!important}.guide-export-watermark{top:auto!important;bottom:16px!important}"
- : onlySection === "target-stats" || onlySection === "talents"
- ? ".artifact-export-grid{display:block!important}.artifact-export-right{width:560px!important;max-width:100%;margin-left:auto!important;margin-right:auto!important;justify-self:center!important}"
- : "";
return (
@@ -170,16 +152,13 @@ export function ArtifactImageExport({
) : null}
-
-
- {children}
-
+ template={{ kind: "artifact", data: { guide, sections: [...sections], recommendedCondition: Number(recommendedCondition), targetCondition: Number(targetCondition), includeNote: showNote } }}
+ />
);
diff --git a/components/public/guide-image-export.tsx b/components/public/guide-image-export.tsx
index 78db04d..67dfe0d 100644
--- a/components/public/guide-image-export.tsx
+++ b/components/public/guide-image-export.tsx
@@ -62,17 +62,7 @@ export function GuideImageExport({
node.querySelectorAll("[data-export-note]").forEach((note) => {
note.style.display = "none";
});
- // A note can be the only remaining content in the artifact right column.
- const right = node.querySelector(".artifact-export-right");
- if (right) {
- const sections = right.querySelectorAll(
- "[data-artifact-export-section]",
- );
- const hasVisibleSection = Array.from(sections).some(
- (section) => getComputedStyle(section).display !== "none",
- );
- if (!hasVisibleSection) right.style.display = "none";
- }
+
}
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx
index 82ded9f..83e550f 100644
--- a/components/public/structured-guide.tsx
+++ b/components/public/structured-guide.tsx
@@ -31,7 +31,7 @@ import { WeaponChart } from "@/components/public/weapon-chart";
import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/public/weapon-display-mode";
import { ArtifactImageExport } from "@/components/public/artifact-image-export";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
-import { ConstellationImage } from "@/components/public/constellation-image";
+import { TakumiImageExport } from "@/components/public/takumi-image-export";
import { ConditionSwitcher } from "@/components/public/condition-switcher";
import { MaterialPoster } from "@/components/public/material-poster";
import { RichTextContent } from "@/components/rich-text-content";
@@ -242,25 +242,15 @@ function GuideOverview({
bleeding={guide.posterBleeding}
linkMaterials
/>
-
-
-
+ preview
+ template={{ kind: "materials", data: { character: guide.character, characterArtUrl: guide.posterArtUrl, artworkOffsetX: guide.posterArtOffsetX, artworkOffsetY: guide.posterArtOffsetY, artworkScale: guide.posterArtScale, topColors: guide.posterTopColors, bottomColors: guide.posterBottomColors, bleeding: guide.posterBleeding } }}
+ />
) : null}
{youtubeVideos.length ? (
@@ -615,13 +605,12 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
guide={guide}
action={
group.name || `เงื่อนไข ${index + 1}`)}
targetConditionLabels={targetConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
- >
-
-
+ />
}
/>
);
@@ -1156,17 +1145,12 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
{guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
- 1 ? `-${group.name || `Condition-${groupIndex + 1}`}` : ""}.png`}
width={1500}
height={1500}
- captureClassName="p-0"
- >
-
-
+ template={{ kind: "constellation", data: { guide, group } }}
+ />
{groups.length > 1 ?
{group.name || `เงื่อนไข ${groupIndex + 1}`} : null}
{group.note ?
{group.note}
: null}
diff --git a/components/public/takumi-image-export.tsx b/components/public/takumi-image-export.tsx
new file mode 100644
index 0000000..1dd02dc
--- /dev/null
+++ b/components/public/takumi-image-export.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { DownloadIcon, EyeIcon } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import type { ExportRequest, ExportTemplate } from "@/lib/image-export/types";
+import { downloadImage, renderImage } from "@/lib/image-export/client";
+
+export function TakumiImageExport({
+ template,
+ fileName,
+ width = 1200,
+ height,
+ format = "png",
+ disabled = false,
+ allowWithoutNotes = true,
+ preview = false,
+ buttonLabel = "ส่งออกภาพ",
+}: {
+ template: ExportTemplate;
+ fileName: string;
+ width?: number;
+ height?: number;
+ format?: "png" | "webp";
+ disabled?: boolean;
+ allowWithoutNotes?: boolean;
+ preview?: boolean;
+ buttonLabel?: string;
+}) {
+ const [mode, setMode] = useState<
+ "with-notes" | "without-notes" | "preview" | null
+ >(null);
+ const [image, setImage] = useState<{ url: string; key: string } | null>(null);
+ const mounted = useRef(false);
+ const previewUrl = useRef
(null);
+ const active = useRef(false);
+ const request = { ...template, width, height, format } as ExportRequest;
+ const key = preview ? JSON.stringify(request) : "";
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ if (previewUrl.current) URL.revokeObjectURL(previewUrl.current);
+ };
+ }, []);
+ async function generate(nextMode: NonNullable) {
+ if (active.current) return;
+ active.current = true;
+ setMode(nextMode);
+ try {
+ const blob = await renderImage({
+ ...request,
+ withoutNotes: nextMode === "without-notes",
+ });
+ if (!mounted.current) return;
+ if (nextMode === "preview") {
+ if (previewUrl.current) URL.revokeObjectURL(previewUrl.current);
+ previewUrl.current = URL.createObjectURL(blob);
+ setImage({ url: previewUrl.current, key });
+ } else {
+ const name =
+ nextMode === "without-notes"
+ ? /\.[^.]+$/u.test(fileName)
+ ? fileName.replace(/(\.[^.]+)$/u, "-without-notes$1")
+ : `${fileName}-without-notes`
+ : fileName;
+ downloadImage(blob, name);
+ }
+ } catch {
+ if (mounted.current) toast.error("ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
+ } finally {
+ active.current = false;
+ if (mounted.current) setMode(null);
+ }
+ }
+ return (
+
+
+ {(["with-notes", "without-notes"] as const)
+ .filter((value) => allowWithoutNotes || value === "with-notes")
+ .map((value) => (
+ void generate(value)}
+ >
+ {mode === value ? (
+
+ ) : (
+
+ )}
+ {mode === value
+ ? "กำลังส่งออก…"
+ : value === "without-notes"
+ ? "ส่งออกภาพโดยไม่รวมโน้ต"
+ : buttonLabel}
+
+ ))}
+ {preview ? (
+ void generate("preview")}
+ >
+ {mode === "preview" ? (
+
+ ) : (
+
+ )}
+ ดูตัวอย่างภาพ
+
+ ) : null}
+
+ {image ? (
+
+ {image.key !== key ? (
+
+ ตัวอย่างยังไม่รวมการเปลี่ยนแปลงล่าสุด กดดูตัวอย่างภาพอีกครั้ง
+
+ ) : null}
+ {/* Encoded local preview must bypass Next.js image optimization. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx
index 49bc660..f32b7f9 100644
--- a/components/public/team-search.tsx
+++ b/components/public/team-search.tsx
@@ -6,7 +6,7 @@ import Link from "next/link";
import { ArrowUpRightIcon, SearchIcon, XIcon } from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
-import { GuideImageExport } from "@/components/public/guide-image-export";
+import { TakumiImageExport } from "@/components/public/takumi-image-export";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -161,55 +161,6 @@ export function TeamArtifactIcon({
);
}
-function TeamExportImage({ team }: { team: Team }) {
- const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
- const seconds = Number(team.rotationSeconds);
- return (
-
-
-
- {team.members.map((member) => {
- const damage = Number(member.damage);
- const percent = total > 0 ? damage / total * 100 : 0;
- return (
-
-
-
-
-
- {member.weapon ?
: null}
-
-
-
-
-
- {member.character.name}
- C{member.constellation}R{member.refinement}
-
- {damage.toLocaleString("en-US")}
-
-
-
-
- );
- })}
-
-
-
DPS: {seconds > 0 ? (total / seconds).toLocaleString("en-US", { maximumFractionDigits: 0 }) : "0"}
-
By YT Genshin Isn't Mobile Game
-
- );
-}
-
function TeamCard({
team,
action,
@@ -463,9 +414,7 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
key={team.id}
team={team}
action={
-
-
-
+
}
/>
))}
diff --git a/components/public/weapon-image-export.tsx b/components/public/weapon-image-export.tsx
index 4af5fb4..d730986 100644
--- a/components/public/weapon-image-export.tsx
+++ b/components/public/weapon-image-export.tsx
@@ -1,10 +1,10 @@
"use client";
-import Image from "next/image";
+
import { DownloadIcon } from "lucide-react";
-import { GuideExportBranding } from "@/components/public/guide-export-branding";
-import { GuideImageExport } from "@/components/public/guide-image-export";
+
+import { TakumiImageExport } from "@/components/public/takumi-image-export";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -17,90 +17,6 @@ import {
import type { PublicGuide } from "@/lib/guides/queries";
type WeaponGroup = PublicGuide["weaponGroups"][number];
-type DamageMetric = "overallPercent" | "personalPercent";
-
-function formattedPercent(value: string | null): string {
- if (value === null) return "-%";
- const numeric = Number(value);
- if (numeric === 0) return "-%";
- return `${numeric.toLocaleString("en-US", { maximumFractionDigits: 2 })}%`;
-}
-
-function WeaponComparisonImage({
- guide,
- group,
- metric,
- conditionLabel,
-}: {
- guide: PublicGuide;
- group: WeaponGroup;
- metric: DamageMetric;
- conditionLabel: string;
-}) {
- const values = group.rows.map((row) => Number(row[metric]) || 0);
- const maximum = Math.max(100, ...values);
-
- return (
-
-
-
-
-
- {group.rows.map((row) => {
- const numeric = Number(row[metric]) || 0;
- const width = row[metric] === null
- ? 28
- : numeric <= 100
- ? Math.max(28, Math.max(0, numeric) * 0.5)
- : Math.min(90, 50 + ((numeric - 100) / Math.max(1, maximum - 100)) * 40);
- return (
-
-
-
-
-
-
-
- {row.weapon.name} R{row.refinement}
-
-
-
- {formattedPercent(row[metric])}
- {row.note ? {row.note} : null}
-
-
-
- );
- })}
-
-
- {group.note ? (
-
- {group.note}
-
- ) : null}
-
- );
-}
-
export function WeaponImageExport({
guide,
group,
@@ -134,20 +50,13 @@ export function WeaponImageExport({
{metricOptions.map(([metric, label]) => (
-
-
-
+ template={{ kind: "weapon", data: { guide, group, metric, conditionLabel } }}
+ />
))}
diff --git a/lib/guides/image-export.test.tsx b/lib/guides/image-export.test.tsx
index 1ec2e17..41d42f3 100644
--- a/lib/guides/image-export.test.tsx
+++ b/lib/guides/image-export.test.tsx
@@ -2,28 +2,29 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
-import { GuideImageExport } from "@/components/public/guide-image-export";
+import { TakumiImageExport } from "@/components/public/takumi-image-export";
+import { exportFixtures } from "@/lib/image-export/fixtures";
import { MaterialPoster } from "@/components/public/material-poster";
-import { ConstellationImage } from "@/components/public/constellation-image";
+import { ConstellationImage } from "@/lib/image-export/templates/constellation";
import type { PublicGuide } from "@/lib/guides/queries";
describe("image export controls", () => {
it("always offers both export modes even without notes", () => {
- const html = renderToStaticMarkup(No notes );
+ const html = renderToStaticMarkup( );
expect(html).toContain("ส่งออกภาพ");
expect(html).toContain("ส่งออกภาพโดยไม่รวมโน้ต");
expect(html.match(/ {
- const html = renderToStaticMarkup(Content );
+ const html = renderToStaticMarkup();
expect(html).toContain("ส่งออกภาพ");
expect(html).not.toContain("ส่งออกภาพโดยไม่รวมโน้ต");
expect(html.match(/ {
- const html = renderToStaticMarkup(No sections );
+ const html = renderToStaticMarkup( );
expect(html.match(/\sdisabled=""/gu)).toHaveLength(2);
});
diff --git a/lib/image-export/README.md b/lib/image-export/README.md
new file mode 100644
index 0000000..028356b
--- /dev/null
+++ b/lib/image-export/README.md
@@ -0,0 +1,7 @@
+Custom image exports render from serializable data in a lazily created Web Worker using Takumi WASM. Templates use export primitives and shared calculation helpers; they do not mount webpage components. `GuideImageExport` remains the screenshot path for extra webpage sections.
+
+`prepare-image-export.mjs` copies the pinned WASM binary and compiles export CSS using the application's design tokens. Dev, build, and test scripts run it automatically; Docker runs it with Node before Next builds. The generated `public/takumi.wasm` and `public/takumi.css` are ignored by Git. Restart dev after changing export utility classes or theme tokens to regenerate the CSS.
+
+Load fonts from local assets. Original artwork and mask URLs are fetched in the worker, including blob URLs for unsaved uploads. Remote assets need CORS permission. Fetch failures reject the export; they do not fall back to a screenshot.
+
+Keep intrinsically sized artifact columns in Flexbox: nested auto-height grids can measure as zero in the current WASM renderer. Regression tests render actual WASM output, check sizes and constellation bar pixels, and cover artifact selections, notes, WebP, failed assets, and worker retries.
diff --git a/lib/image-export/assets.ts b/lib/image-export/assets.ts
new file mode 100644
index 0000000..6e1ee54
--- /dev/null
+++ b/lib/image-export/assets.ts
@@ -0,0 +1,33 @@
+import type { Node } from "takumi-js";
+
+export function imageSources(node: Node): string[] {
+ const sources = new Set();
+ function visit(current: Node) {
+ if (current.type === "image" && typeof current.src === "string")
+ sources.add(current.src);
+ for (const value of Object.values(current.style ?? {})) {
+ if (typeof value !== "string") continue;
+ for (const match of value.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g))
+ sources.add(match[1]);
+ }
+ if (current.type === "container") current.children?.forEach(visit);
+ }
+ visit(node);
+ return [...sources];
+}
+export async function loadImageSources(node: Node, origin: string) {
+ return Promise.all(
+ imageSources(node).map(async (src) => {
+ const response = await fetch(new URL(src, origin), {
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!response.ok)
+ throw new Error(`Image load failed: ${response.status}`);
+ return {
+ src,
+ data: new Uint8Array(await response.arrayBuffer()),
+ cache: src.startsWith("blob:") ? ("none" as const) : ("auto" as const),
+ };
+ }),
+ );
+}
diff --git a/lib/image-export/client.test.ts b/lib/image-export/client.test.ts
new file mode 100644
index 0000000..d35ba58
--- /dev/null
+++ b/lib/image-export/client.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { exportFixtures } from "./fixtures";
+import type { WorkerRequest, WorkerResult } from "./types";
+
+class TestWorker {
+ static instances: TestWorker[] = [];
+ requests: WorkerRequest[] = [];
+ onmessage?: (event: { data: WorkerResult }) => void;
+ onerror?: () => void;
+ terminate = vi.fn();
+ constructor() {
+ TestWorker.instances.push(this);
+ }
+ postMessage(request: WorkerRequest) {
+ this.requests.push(request);
+ }
+}
+beforeEach(() => {
+ vi.resetModules();
+ TestWorker.instances = [];
+ vi.stubGlobal("Worker", TestWorker);
+ vi.stubGlobal("window", { location: { origin: "http://export.test" } });
+});
+afterEach(() => vi.unstubAllGlobals());
+
+describe("export worker client", () => {
+ it("reuses one lazy worker and matches replies to concurrent requests", async () => {
+ const { renderImage } = await import("./client");
+ expect(TestWorker.instances).toHaveLength(0);
+ const first = renderImage(exportFixtures[0]);
+ const second = renderImage({ ...exportFixtures[0], format: "webp" });
+ expect(TestWorker.instances).toHaveLength(1);
+ const worker = TestWorker.instances[0];
+ const [a, b] = worker.requests;
+ worker.onmessage!({
+ data: { id: b.id, bytes: new Uint8Array([2]).buffer },
+ });
+ worker.onmessage!({
+ data: { id: a.id, bytes: new Uint8Array([1]).buffer },
+ });
+ expect((await first).type).toBe("image/png");
+ expect((await second).type).toBe("image/webp");
+ });
+ it("rejects a failed job and permits retry", async () => {
+ const { renderImage } = await import("./client");
+ const failed = renderImage(exportFixtures[0]);
+ const worker = TestWorker.instances[0];
+ const rejection = expect(failed).rejects.toThrow("Missing artwork");
+ worker.onmessage!({
+ data: { id: worker.requests[0].id, error: "Missing artwork" },
+ });
+ await rejection;
+ const retried = renderImage(exportFixtures[0]);
+ worker.onmessage!({
+ data: { id: worker.requests[1].id, bytes: new ArrayBuffer(1) },
+ });
+ expect(await retried).toBeInstanceOf(Blob);
+ });
+ it("clears pending jobs and recreates a crashed worker", async () => {
+ const { renderImage } = await import("./client");
+ const failed = renderImage(exportFixtures[0]);
+ const rejection = expect(failed).rejects.toThrow("Image renderer failed");
+ TestWorker.instances[0].onerror!();
+ await rejection;
+ expect(TestWorker.instances[0].terminate).toHaveBeenCalledOnce();
+ const retried = renderImage(exportFixtures[0]);
+ const worker = TestWorker.instances[1];
+ worker.onmessage!({
+ data: { id: worker.requests[0].id, bytes: new ArrayBuffer(1) },
+ });
+ expect(await retried).toBeInstanceOf(Blob);
+ });
+});
diff --git a/lib/image-export/client.ts b/lib/image-export/client.ts
new file mode 100644
index 0000000..48b8a9c
--- /dev/null
+++ b/lib/image-export/client.ts
@@ -0,0 +1,52 @@
+import type { ExportRequest, WorkerResult } from "./types";
+let worker: Worker | undefined;
+let nextId = 0;
+const pending = new Map<
+ number,
+ {
+ resolve: (blob: Blob) => void;
+ reject: (error: Error) => void;
+ format: string;
+ }
+>();
+export function renderImage(request: ExportRequest): Promise {
+ if (!worker) {
+ worker = new Worker(new URL("./export.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ worker.onmessage = ({ data }: MessageEvent) => {
+ const job = pending.get(data.id);
+ if (!job) return;
+ pending.delete(data.id);
+ if (data.error !== undefined) job.reject(new Error(data.error));
+ else job.resolve(new Blob([data.bytes], { type: `image/${job.format}` }));
+ };
+ worker.onerror = () => {
+ worker?.terminate();
+ worker = undefined;
+ for (const job of pending.values())
+ job.reject(new Error("Image renderer failed"));
+ pending.clear();
+ };
+ }
+ return new Promise((resolve, reject) => {
+ const id = ++nextId;
+ pending.set(id, { resolve, reject, format: request.format });
+ try {
+ worker!.postMessage({ id, request, origin: window.location.origin });
+ } catch (error) {
+ pending.delete(id);
+ reject(error);
+ }
+ });
+}
+export function downloadImage(blob: Blob, fileName: string) {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.download = fileName.replace(/[\\/:*?"<>|]/gu, "-");
+ link.href = url;
+ document.body.append(link);
+ link.click();
+ link.remove();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
diff --git a/lib/image-export/export.worker.ts b/lib/image-export/export.worker.ts
new file mode 100644
index 0000000..2190405
--- /dev/null
+++ b/lib/image-export/export.worker.ts
@@ -0,0 +1,71 @@
+import { init, Renderer } from "takumi-js/wasm/no-init";
+import type { WorkerRequest, WorkerResult } from "./types";
+import { renderExport } from "./render";
+
+let rendererPromise: Promise | undefined;
+async function getRenderer(origin: string) {
+ rendererPromise ??= (async () => {
+ const response = await fetch(new URL("/takumi.wasm", origin), {
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!response.ok) throw new Error("Unable to load image renderer");
+ await init({ module_or_path: await response.arrayBuffer() });
+ const renderer = new Renderer();
+ try {
+ const fonts = await Promise.all(
+ [
+ { name: "Anuphan", path: "/Anuphan.ttf" },
+ { name: "Genshin Overview", path: "/zh-cn.ttf" },
+ {
+ name: "Kanit Overview",
+ path: "/Kanit-SemiBold.ttf",
+ weight: 600,
+ style: "normal" as const,
+ },
+ {
+ name: "Kanit Poster",
+ path: "/Kanit-ExtraBoldItalic.ttf",
+ weight: 800,
+ style: "italic" as const,
+ },
+ ].map(async ({ path, ...descriptor }) => {
+ const font = await fetch(new URL(path, origin), {
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!font.ok) throw new Error("Unable to load export fonts");
+ return {
+ ...descriptor,
+ data: new Uint8Array(await font.arrayBuffer()),
+ };
+ }),
+ );
+ for (const font of fonts) await renderer.registerFont(font);
+ return renderer;
+ } catch (error) {
+ renderer.free();
+ throw error;
+ }
+ })().catch((error) => {
+ rendererPromise = undefined;
+ throw error;
+ });
+ return rendererPromise;
+}
+let queue = Promise.resolve();
+self.onmessage = ({ data }: MessageEvent) => {
+ queue = queue.then(async () => {
+ try {
+ const renderer = await getRenderer(data.origin);
+ const image = await renderExport(renderer, data.request, data.origin);
+ const bytes = image.buffer as ArrayBuffer;
+ self.postMessage({ id: data.id, bytes } satisfies WorkerResult, {
+ transfer: [bytes],
+ });
+ } catch (error) {
+ self.postMessage({
+ id: data.id,
+ error: error instanceof Error ? error.message : "Image export failed",
+ } satisfies WorkerResult);
+ }
+ });
+};
diff --git a/lib/image-export/fixtures.ts b/lib/image-export/fixtures.ts
new file mode 100644
index 0000000..7edcdce
--- /dev/null
+++ b/lib/image-export/fixtures.ts
@@ -0,0 +1,247 @@
+// Representative data for export renderer regression tests.
+import type { PublicGuide } from "@/lib/guides/queries";
+import type { ExportRequest } from "./types";
+const character = {
+ key: "raiden",
+ name: "Raiden Shogun",
+ element: "Electro",
+ rarity: 5,
+ weaponType: "Polearm",
+ imageUrl: "/test-art.png",
+ splashImageUrl: "/test-art.png",
+ nameCardImageUrl: null,
+ baseStats: [
+ { key: "hp", value: 12907 },
+ { key: "atk", value: 337 },
+ ],
+ ascension: [
+ {
+ items: [
+ {
+ key: "material",
+ name: "Ascension Material",
+ amount: 168,
+ imageUrl: "/test-art.png",
+ },
+ ],
+ },
+ ],
+ leveling: [],
+ talents: [
+ { type: "E", name: "Skill", iconUrl: null },
+ { type: "Q", name: "Burst", iconUrl: null },
+ ],
+};
+export const exportGuide = {
+ name: "Raiden",
+ character,
+ coverUrl: "/test-art.png",
+ elementIconUrls: {},
+ artifactGroups: [
+ {
+ id: "a",
+ name: "Late Game",
+ rows: [
+ {
+ id: "r",
+ first: {
+ key: "emblem",
+ name: "Emblem",
+ imageUrl: "/test-art.png",
+ rarity: 5,
+ },
+ second: null,
+ percent: "100",
+ note: "อันดับภาษาไทย",
+ },
+ ],
+ },
+ ],
+ artifactProfile: {
+ note: "โน้ตโปรไฟล์",
+ recommendedConditions: [
+ {
+ name: "First",
+ note: "โน้ตออฟ",
+ sands: ["ATK%"],
+ goblet: ["Electro DMG%"],
+ circlet: ["CRIT RATE"],
+ substats: ["CRIT RATE", "ER"],
+ },
+ {
+ name: "Second",
+ note: null,
+ sands: ["ER"],
+ goblet: ["ATK%"],
+ circlet: ["CRIT DAMAGE"],
+ substats: ["ER"],
+ },
+ ],
+ targetConditions: [
+ {
+ name: "First",
+ note: "เป้าหมาย",
+ rows: [{ stat: "ER", value: "250%" }],
+ },
+ { name: "Second", note: null, rows: [{ stat: "ATK", value: "2000" }] },
+ ],
+ talents: [
+ { type: "Q", level: 9, relationToNext: "gt" },
+ { type: "E", level: 9, relationToNext: "gt" },
+ ],
+ },
+} as unknown as PublicGuide;
+export const exportFixtures: ExportRequest[] = [
+ {
+ kind: "constellation",
+ width: 1500,
+ height: 1500,
+ format: "png",
+ data: {
+ guide: exportGuide,
+ group: {
+ id: "c",
+ name: "Team",
+ note: "โน้ตภาษาไทย",
+ rows: [
+ { level: 1, cumulativePercent: "115.5", isGreat: true },
+ { level: 6, cumulativePercent: "200", isGreat: false },
+ ],
+ } as unknown as PublicGuide["constellationGroups"][number],
+ },
+ },
+ {
+ kind: "weapon",
+ width: 1200,
+ format: "png",
+ data: {
+ guide: exportGuide,
+ metric: "overallPercent",
+ conditionLabel: "Condition 1",
+ group: {
+ id: "w",
+ name: "Weapons",
+ note: "โน้ตอาวุธ",
+ rows: [
+ {
+ id: "1",
+ weapon: { name: "Engulfing Lightning", imageUrl: "/test-art.png" },
+ refinement: 1,
+ overallPercent: "115",
+ personalPercent: "120",
+ note: "คำอธิบายภาษาไทย",
+ },
+ {
+ id: "2",
+ weapon: { name: "The Catch", imageUrl: "/test-art.png" },
+ refinement: 5,
+ overallPercent: null,
+ personalPercent: "100",
+ note: null,
+ },
+ ],
+ } as unknown as PublicGuide["weaponGroups"][number],
+ },
+ },
+ {
+ kind: "team",
+ width: 1080,
+ format: "png",
+ data: {
+ team: {
+ title: "Raiden National",
+ rotationSeconds: "20",
+ combo: "E → Q\nตัวอย่างคอมโบ",
+ members: [1, 2, 3, 4].map((position) => ({
+ position,
+ character,
+ weapon:
+ position === 1
+ ? {
+ name: "Engulfing Lightning",
+ imageUrl: "/test-art.png",
+ rarity: 5,
+ }
+ : null,
+ artifact:
+ position === 1
+ ? {
+ key: "emblem",
+ name: "Emblem",
+ imageUrl: "/test-art.png",
+ rarity: 5,
+ }
+ : null,
+ secondArtifact:
+ position === 1
+ ? {
+ key: "noblesse",
+ name: "Noblesse",
+ imageUrl: "/test-art.png",
+ rarity: 5,
+ }
+ : null,
+ constellation: 0,
+ refinement: 1,
+ damage: "100000",
+ })),
+ } as unknown as PublicGuide["teams"][number],
+ },
+ },
+ {
+ kind: "materials",
+ width: 1600,
+ height: 2000,
+ format: "png",
+ data: {
+ character: exportGuide.character,
+ characterArtUrl: "/test-art.png",
+ artworkOffsetX: 10,
+ artworkOffsetY: -5,
+ artworkScale: 120,
+ topColors: ["#ffbb00", "#112233"],
+ bottomColors: ["#112233", "#ffbb00"],
+ bleeding: [{ id: "1", imageUrl: null, amount: 20, blendMode: "screen" }],
+ },
+ },
+ {
+ kind: "overview",
+ width: 3000,
+ height: 3000,
+ format: "png",
+ data: {
+ character: character as unknown as PublicGuide["character"],
+ artworkUrl: "/test-art.png",
+ role: "Main DPS",
+ topColors: ["#ffbb00", "#112233"],
+ bottomColors: ["#112233", "#ffbb00"],
+ elementColors: ["#aa00ff", "#ff00aa"],
+ elementGradientAngle: 90,
+ blendLayers: [{ id: 1, amount: 20, mode: "screen" }],
+ nameCardCrop: null,
+ nameCardSize: null,
+ elementIconUrls: { Electro: "/test-art.png" },
+ composition: {
+ backgroundUrl: null,
+ fadeColor: "#0e0200",
+ fadeTint: 20,
+ auraUpOffset: 0,
+ characterOffset: { x: 5, y: 0 },
+ fadeOffset: { x: 0, y: 0 },
+ },
+ disclaimerFontSize: null,
+ },
+ },
+ {
+ kind: "artifact",
+ width: 1200,
+ format: "png",
+ data: {
+ guide: exportGuide,
+ sections: ["tierlist", "best-stats", "target-stats", "talents"],
+ recommendedCondition: 0,
+ targetCondition: 0,
+ includeNote: true,
+ },
+ },
+];
diff --git a/lib/image-export/render.test.tsx b/lib/image-export/render.test.tsx
new file mode 100644
index 0000000..2b87783
--- /dev/null
+++ b/lib/image-export/render.test.tsx
@@ -0,0 +1,205 @@
+import { readFile } from "node:fs/promises";
+import { renderToStaticMarkup } from "react-dom/server";
+import sharp from "sharp";
+import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
+import { init, Renderer } from "takumi-js/wasm/no-init";
+import { exportFixtures } from "./fixtures";
+import { renderExport } from "./render";
+import { exportTemplate } from "./templates";
+import { imageSources, loadImageSources } from "./assets";
+
+let renderer: Renderer;
+beforeAll(async () => {
+ await init({ module_or_path: await readFile("public/takumi.wasm") });
+ renderer = new Renderer();
+ for (const [name, path] of [
+ ["Anuphan", "Anuphan.ttf"],
+ ["Genshin Overview", "zh-cn.ttf"],
+ ["Kanit Overview", "Kanit-SemiBold.ttf"],
+ ["Kanit Poster", "Kanit-ExtraBoldItalic.ttf"],
+ ]) {
+ await renderer.registerFont({
+ name,
+ data: await readFile(`public/${path}`),
+ });
+ }
+});
+afterAll(() => renderer.free());
+
+describe("browser WASM export templates", () => {
+ it.each(exportFixtures.map((request) => [request.kind, request] as const))(
+ "renders %s with intended dimensions and visible content",
+ async (_, request) => {
+ const art = await sharp({
+ create: { width: 500, height: 700, channels: 4, background: "#786a9c" },
+ })
+ .png()
+ .toBuffer();
+ const fetchMock = vi
+ .spyOn(globalThis, "fetch")
+ .mockImplementation(async (input) => {
+ const path = new URL(String(input)).pathname;
+ const bytes =
+ path === "/test-art.png" ? art : await readFile(`public${path}`);
+ return new Response(bytes);
+ });
+ try {
+ const bytes = await renderExport(
+ renderer,
+ request,
+ "http://export.test",
+ );
+ const metadata = await sharp(bytes).metadata();
+ expect(metadata.width).toBe(request.width);
+ if (request.height) expect(metadata.height).toBe(request.height);
+ else expect(metadata.height).toBeGreaterThan(200);
+ expect(bytes.length).toBeGreaterThan(10000);
+ const { stdev } = (await sharp(bytes).stats()).channels[0];
+ expect(stdev).toBeGreaterThan(5);
+ if (request.kind === "constellation") {
+ const topBar = await sharp(bytes)
+ .extract({ left: 200, top: 250, width: 1, height: 1 })
+ .removeAlpha()
+ .raw()
+ .toBuffer();
+ expect([...topBar]).toEqual([255, 255, 255]);
+ const highlightedBar = await sharp(bytes)
+ .extract({ left: 200, top: 750, width: 1, height: 1 })
+ .removeAlpha()
+ .raw()
+ .toBuffer();
+ expect(highlightedBar[0]).toBeGreaterThan(highlightedBar[1]);
+ expect(highlightedBar[1]).toBeGreaterThan(highlightedBar[2]);
+ }
+ } finally {
+ fetchMock.mockRestore();
+ }
+ },
+ 30000,
+ );
+ it.each(["tierlist", "best-stats", "target-stats", "talents"] as const)(
+ "renders artifact %s selection",
+ async (section) => {
+ const fixture = exportFixtures.find(
+ (request) => request.kind === "artifact",
+ )!;
+ if (fixture.kind !== "artifact")
+ throw new Error("Missing artifact fixture");
+ const art = await sharp({
+ create: { width: 50, height: 50, channels: 4, background: "#786a9c" },
+ })
+ .png()
+ .toBuffer();
+ const fetchMock = vi
+ .spyOn(globalThis, "fetch")
+ .mockImplementation(async (input) => {
+ const path = new URL(String(input)).pathname;
+ return new Response(
+ path === "/test-art.png" ? art : await readFile(`public${path}`),
+ );
+ });
+ try {
+ const width =
+ section === "best-stats" ? 700 : section === "tierlist" ? 1200 : 800;
+ const height = section === "best-stats" ? 700 : undefined;
+ const bytes = await renderExport(
+ renderer,
+ {
+ ...fixture,
+ width,
+ height,
+ withoutNotes: true,
+ data: { ...fixture.data, sections: [section], includeNote: false },
+ },
+ "http://export.test",
+ );
+ const metadata = await sharp(bytes).metadata();
+ expect(metadata.width).toBe(width);
+ if (height) expect(metadata.height).toBe(height);
+ expect(metadata.height).toBeGreaterThan(150);
+ } finally {
+ fetchMock.mockRestore();
+ }
+ },
+ 30000,
+ );
+ it("encodes a WebP export", async () => {
+ const fetchMock = vi
+ .spyOn(globalThis, "fetch")
+ .mockImplementation(async (input) => {
+ const path = new URL(String(input)).pathname;
+ return new Response(
+ await readFile(
+ path === "/test-art.png"
+ ? "public/icon/Watermark.png"
+ : `public${path}`,
+ ),
+ );
+ });
+ try {
+ const bytes = await renderExport(
+ renderer,
+ { ...exportFixtures[0], format: "webp", withoutNotes: true },
+ "http://export.test",
+ );
+ expect((await sharp(bytes).metadata()).format).toBe("webp");
+ } finally {
+ fetchMock.mockRestore();
+ }
+ }, 30000);
+ it("omits Thai notes before layout", () => {
+ const request = exportFixtures[0];
+ expect(renderToStaticMarkup(exportTemplate(request))).toContain(
+ "โน้ตภาษาไทย",
+ );
+ expect(
+ renderToStaticMarkup(exportTemplate({ ...request, withoutNotes: true })),
+ ).not.toContain("โน้ตภาษาไทย");
+ });
+ it("filters artifact sections and conditions before layout", () => {
+ const request = exportFixtures.find((item) => item.kind === "artifact")!;
+ if (request.kind !== "artifact")
+ throw new Error("Missing artifact fixture");
+ const html = renderToStaticMarkup(
+ exportTemplate({
+ ...request,
+ data: {
+ ...request.data,
+ sections: ["best-stats"],
+ recommendedCondition: 1,
+ includeNote: false,
+ },
+ }),
+ );
+ expect(html).toContain("Second");
+ expect(html).not.toContain("First");
+ expect(html).not.toContain("Late Game");
+ expect(html).not.toContain("Talent Priority");
+ expect(html).not.toContain("artifact-export-right");
+ expect(html).not.toContain("โน้ตโปรไฟล์");
+ });
+ it("collects mask sources and deduplicates artwork", () => {
+ expect(
+ imageSources({
+ type: "container",
+ style: { maskImage: "url('/mask.png')" },
+ children: [{ type: "image", src: "/mask.png" }],
+ }),
+ ).toEqual(["/mask.png"]);
+ });
+ it("rejects missing artwork instead of producing an incomplete image", async () => {
+ const fetchMock = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValue(new Response(null, { status: 404 }));
+ try {
+ await expect(
+ loadImageSources(
+ { type: "image", src: "/missing.png" },
+ "http://export.test",
+ ),
+ ).rejects.toThrow("Image load failed");
+ } finally {
+ fetchMock.mockRestore();
+ }
+ });
+});
diff --git a/lib/image-export/render.ts b/lib/image-export/render.ts
new file mode 100644
index 0000000..43202cb
--- /dev/null
+++ b/lib/image-export/render.ts
@@ -0,0 +1,67 @@
+import { fromJsx } from "takumi-js/helpers/jsx";
+import type { Renderer } from "takumi-js/wasm/no-init";
+import type { ExportRequest } from "./types";
+import { exportTemplate } from "./templates";
+import { exportStyles } from "./styles";
+import { loadImageSources } from "./assets";
+
+const stylesheets = new Map>();
+function stylesheet(origin: string) {
+ let css = stylesheets.get(origin);
+ if (!css) {
+ css = fetch(new URL("/takumi.css", origin), {
+ signal: AbortSignal.timeout(15000),
+ })
+ .then((response) => {
+ if (!response.ok) throw new Error("Unable to load export styles");
+ return response.text();
+ })
+ .catch((error) => {
+ stylesheets.delete(origin);
+ throw error;
+ });
+ stylesheets.set(origin, css);
+ }
+ return css;
+}
+export async function renderExport(
+ renderer: Renderer,
+ request: ExportRequest,
+ origin: string,
+) {
+ const { node, css } = await fromJsx(exportTemplate(request), {
+ defaultStyles: {
+ div: { display: "block" },
+ section: { display: "block" },
+ header: { display: "block" },
+ h1: { display: "block" },
+ h2: { display: "block" },
+ p: { display: "block" },
+ img: { display: "block" },
+ },
+ });
+ const images = await loadImageSources(node, origin);
+ const options = {
+ width: request.width,
+ height: request.height,
+ format: request.format,
+ fontFamilies: [
+ "Anuphan",
+ "Kanit Overview",
+ "Kanit Poster",
+ "Genshin Overview",
+ ],
+ css: [
+ await stylesheet(origin),
+ ...css,
+ ".export-root{font-family:Anuphan;font-size:16px;line-height:1.5;background:var(--background);color:var(--foreground)}",
+ exportStyles(request),
+ ],
+ images,
+ };
+ const measured = await renderer.measure(node, options);
+ return renderer.render(node, {
+ ...options,
+ height: request.height ?? Math.ceil(measured.height),
+ });
+}
diff --git a/lib/image-export/styles.ts b/lib/image-export/styles.ts
new file mode 100644
index 0000000..4b6bcb3
--- /dev/null
+++ b/lib/image-export/styles.ts
@@ -0,0 +1,19 @@
+import type { ExportRequest } from "./types";
+
+export function exportStyles(request: ExportRequest): string {
+ if (request.kind !== "artifact") return "";
+ const only =
+ request.data.sections.length === 1 ? request.data.sections[0] : null;
+ const rules =
+ only === "tierlist"
+ ? ".artifact-export-grid{display:block}.artifact-export-tierlist{display:flex;flex-direction:row;flex-wrap:wrap;gap:20px}.artifact-export-tierlist>div{width:calc(50% - 10px)}"
+ : only === "best-stats"
+ ? ".artifact-export-root{height:100%}.artifact-export-grid{display:block}.artifact-export-main-stats,.artifact-export-substats{width:480px;max-width:100%;margin-inline:auto}.guide-export-watermark{top:auto;bottom:16px}"
+ : only === "target-stats" || only === "talents"
+ ? ".artifact-export-grid{display:block}.artifact-export-right{width:560px;max-width:100%;margin-inline:auto}"
+ : "";
+ return (
+ ".artifact-export-grid{display:flex;align-items:flex-start}.artifact-export-left{flex:1.2;min-width:0}.artifact-export-right{flex:1;min-width:0}.artifact-export-main-stats>div:last-child{display:flex}.artifact-export-main-stats>div:last-child>div{width:33.333333%;flex-shrink:0}" +
+ rules
+ );
+}
diff --git a/lib/image-export/templates/artifact.tsx b/lib/image-export/templates/artifact.tsx
new file mode 100644
index 0000000..f3ddafc
--- /dev/null
+++ b/lib/image-export/templates/artifact.tsx
@@ -0,0 +1,668 @@
+import type { ReactNode } from "react";
+import {
+ Image,
+ GuideExportBranding,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardContent,
+ Badge,
+ Separator,
+ ConditionSwitcher,
+} from "./primitives";
+import { TeamArtifactIcon } from "./team";
+import type { PublicGuide } from "@/lib/guides/queries";
+import { cn } from "@/lib/utils";
+import { artifactTierCount } from "@/lib/guides/artifacts";
+import { elementIconFilter } from "@/lib/catalog/element";
+import {
+ normalizeTargetStat,
+ recommendedStatElement,
+ recommendedStatIconName,
+ statPriorityColorClass,
+ targetStatIconName,
+} from "@/lib/guides/stats";
+import {
+ normalizeTalentPriorities,
+ talentRelationSymbol,
+} from "@/lib/guides/talents";
+function metric(value: string | null, locale?: string) {
+ return value === null
+ ? null
+ : `${Number(value).toLocaleString(locale, { maximumFractionDigits: 2 })}%`;
+}
+export function ArtifactSectionContent({
+ guide,
+ action,
+ exporting = true,
+}: {
+ guide: PublicGuide;
+ action?: ReactNode;
+ exporting?: boolean;
+}) {
+ const profile = guide.artifactProfile;
+ const recommendedConditions = profile?.recommendedConditions ?? [];
+ const recommendedCards = recommendedConditions.map(
+ (condition, conditionIndex) => (
+
+ {exporting && recommendedConditions.length > 1 ? (
+
+ {condition.name || `Condition ${conditionIndex + 1}`}
+
+ ) : null}
+ {condition.note ? (
+
+ {condition.note}
+
+ ) : null}
+ {profile ? (
+
+
+
+ {exporting ? "Best Main Stats" : "ออฟหลักที่ดีที่สุด"}
+
+
+
+
+
+
+
+
+ ) : null}
+ {condition.substats.length ? (
+
+
+
+ {exporting ? "Best Substats" : "ออฟรองที่ดีที่สุด"}
+
+
+ {condition.substats.map((stat, index) => (
+
+ ))}
+
+
+
+ ) : null}
+
+ ),
+ );
+
+ return (
+
+ {exporting ? (
+
+ ) : null}
+
+
+ {" "}
+
+ {exporting ? (
+ recommendedCards
+ ) : (
+
condition.name || `เงื่อนไข ${index + 1}`,
+ )}
+ >
+ {recommendedCards}
+
+ )}
+
+ {guide.artifactGroups.map((group) => (
+
+
+
+ {group.name === "Late Game"
+ ? exporting
+ ? "Late Game"
+ : "เลทเกม"
+ : group.name === "Early Game"
+ ? exporting
+ ? "Early Game"
+ : "ต้นเกม"
+ : group.name}
+
+
+
+ {group.rows.map((row, index) => (
+
+
+
+
+ {artifactTierCount(row.second?.name ?? null)}{" "}
+ {row.first.name}
+ {row.second ? ` + 2 ${row.second.name}` : ""}
+
+ {row.note ? (
+
+ {row.note}
+
+ ) : null}
+
+
+ {Number(row.percent) === 0
+ ? "-%"
+ : metric(
+ row.percent,
+ exporting ? "en-US" : undefined,
+ )}
+
+
+ ))}
+
+
+ ))}
+
+
+ {profile ? (
+
+
+
+
+ {profile.targetConditions.some((group) => group.rows.length) ? (
+ exporting ? (
+ profile.targetConditions
+ .filter((group) => group.rows.length)
+ .map((group, groupIndex) => (
+
+
+ {exporting
+ ? "Recommended Minimum Stats"
+ : "ค่าสถานะต่ำๆที่ควรจะปั้นไว้"}
+
+ {group.note ? (
+
+ {group.note}
+
+ ) : null}
+
+ {group.rows.map((target, index) => (
+
+
+ {targetStatIconName(target.stat) ? (
+
+ ) : null}
+
+ {statLabel(
+ normalizeTargetStat(target.stat) ??
+ target.stat,
+ exporting,
+ )}
+
+
+
+ {target.value}
+
+
+ ))}
+
+
+ ))
+ ) : (
+ group.rows.length)
+ .map(
+ (group, index) =>
+ group.name || `เงื่อนไข ${index + 1}`,
+ )}
+ >
+ {profile.targetConditions
+ .filter((group) => group.rows.length)
+ .map((group, groupIndex) => (
+
+
+ {exporting
+ ? "Recommended Minimum Stats"
+ : "ค่าสถานะต่ำๆที่ควรจะปั้นไว้"}
+
+ {group.note ? (
+
+ {group.note}
+
+ ) : null}
+
+ {group.rows.map((target, index) => (
+
+
+ {targetStatIconName(target.stat) ? (
+
+ ) : null}
+
+ {statLabel(
+ normalizeTargetStat(target.stat) ??
+ target.stat,
+ )}
+
+
+
+ {target.value}
+
+
+ ))}
+
+
+ ))}
+
+ )
+ ) : null}
+ {profile.talents.length ? (
+
+
+ {exporting ? "Talent Priority" : "ลำดับการอัพพรสวรรค์"}
+
+
+ {normalizeTalentPriorities(profile.talents).map(
+ (talent, index, talents) => {
+ const catalogTalent = guide.character.talents.find(
+ (item) => item.type === talent.type,
+ );
+ const talentName = catalogTalent?.name ?? talent.type;
+ return (
+
+
+
+ {catalogTalent?.iconUrl ? (
+
+ ) : (
+
+ {talent.type}
+
+ )}
+
+
+ {talent.level
+ ? exporting
+ ? `Level ${talent.level}+`
+ : `เลเวล ${talent.level}+`
+ : "-"}
+
+
+ {talentName}
+
+
+ {talent.type === "E"
+ ? exporting
+ ? "Elemental Skill"
+ : "สกิลธาตุ"
+ : talent.type === "Q"
+ ? exporting
+ ? "Elemental Burst"
+ : "ท่าไม้ตาย"
+ : exporting
+ ? "Normal Attack"
+ : "ตีปกติ"}
+
+
+ {index < talents.length - 1 ? (
+
+ {talentRelationSymbol(talent.relationToNext)}
+
+ ) : null}
+
+ );
+ },
+ )}
+
+
+ ) : null}
+ {profile.note ? (
+
+
+ {exporting ? "Notes" : "โน็ต"}
+
+
+ {profile.note}
+
+
+ ) : null}
+
+
+
+ ) : null}
+
+
+ );
+}
+
+const thaiStats: Record = {
+ HP: "พลังชีวิต",
+ ATK: "พลังโจมตี",
+ DEF: "พลังป้องกัน",
+ EM: "ความชำนาญธาตุ",
+ ER: "ฟื้นฟูพลังงาน",
+ "CRIT RATE": "อัตราคริ",
+ "CRIT DAMAGE": "ความแรงคริ",
+ "ATK%": "พลังโจมตี%",
+ "HP%": "พลังชีวิต%",
+ "DEF%": "พลังป้องกัน%",
+ "CRIT Rate%/CRIT DMG%": "อัตราคริ/ความแรงคริ",
+ "Pyro DMG%": "ความเสียหายธาตุไฟ%",
+ "Hydro DMG%": "ความเสียหายธาตุน้ำ%",
+ "Cryo DMG%": "ความเสียหายธาตุน้ำแข็ง%",
+ "Electro DMG%": "ความเสียหายธาตุไฟฟ้า%",
+ "Anemo DMG%": "ความเสียหายธาตุลม%",
+ "Geo DMG%": "ความเสียหายธาตุดิน%",
+ "Dendro DMG%": "ความเสียหายธาตุไม้%",
+ "Physical DMG%": "ความเสียหายกายภาพ%",
+ "Healing Bonus%": "โบนัสการรักษา%",
+};
+
+const englishStats: Record = {
+ HP: "HP",
+ ATK: "ATK",
+ DEF: "DEF",
+ EM: "Elemental Mastery",
+ ER: "Energy Recharge",
+ "CRIT RATE": "CRIT Rate",
+ "CRIT DAMAGE": "CRIT DMG",
+ "CRIT Rate%/CRIT DMG%": "CRIT Rate / CRIT DMG",
+};
+
+function statLabel(value: string, exporting = true): string {
+ return (exporting ? englishStats : thaiStats)[value] ?? value;
+}
+
+function ArtifactStat({
+ iconSrc,
+ label,
+ values,
+ exporting = true,
+ elementIconUrls,
+}: {
+ iconSrc: string;
+ label: string;
+ values: string[];
+ exporting?: boolean;
+ elementIconUrls: Record;
+}) {
+ return (
+
+
+
{label}
+
+
+ {values.length ? (
+ values.map((value, index) => (
+
+ ))
+ ) : (
+
-
+ )}
+
+
+ );
+}
+
+function ArtifactStatValue({
+ value,
+ index,
+ exporting = true,
+ elementIconUrls,
+}: {
+ value: string;
+ index: number;
+ exporting?: boolean;
+ elementIconUrls: Record;
+}) {
+ const iconName = recommendedStatIconName(value);
+ const element = recommendedStatElement(value);
+ const elementIconUrl = element ? elementIconUrls[element] : null;
+ const hasIcon = Boolean(iconName) && iconName !== "elemental-bonus";
+ return (
+
+ {elementIconUrl ? (
+
+ ) : hasIcon ? (
+
+ ) : null}
+ {statLabel(value, exporting)}
+
+ );
+}
diff --git a/components/public/constellation-image.tsx b/lib/image-export/templates/constellation.tsx
similarity index 82%
rename from components/public/constellation-image.tsx
rename to lib/image-export/templates/constellation.tsx
index 653ed5f..21081de 100644
--- a/components/public/constellation-image.tsx
+++ b/lib/image-export/templates/constellation.tsx
@@ -1,6 +1,5 @@
-import Image from "next/image";
+import { Image, GuideExportBranding } from "./primitives";
-import { GuideExportBranding } from "@/components/public/guide-export-branding";
import type { PublicGuide } from "@/lib/guides/queries";
import { constellationChartRows } from "@/lib/guides/export-image";
import { cn } from "@/lib/utils";
@@ -37,16 +36,14 @@ export function ConstellationImage({
16
- ? "text-[60px]"
- : "text-[76px]",
+ guide.character.name.length > 16 ? "text-[60px]" : "text-[76px]",
)}
>
{`${guide.character.name}’s Constellations`}
-
- Team Damage
-
+
+ Team Damage
+
@@ -82,7 +79,10 @@ export function ConstellationImage({
{footer ? (
-
+
{footer}
) : null}
diff --git a/lib/image-export/templates/index.tsx b/lib/image-export/templates/index.tsx
new file mode 100644
index 0000000..6004c2f
--- /dev/null
+++ b/lib/image-export/templates/index.tsx
@@ -0,0 +1,110 @@
+import {
+ createElement,
+ isValidElement,
+ type ReactNode,
+ type ReactElement,
+} from "react";
+import type { ExportRequest } from "../types";
+import { ConstellationImage } from "./constellation";
+import { WeaponComparisonImage } from "./weapon";
+import { TeamExportImage } from "./team";
+import { MaterialPoster } from "./materials";
+import { OverviewPoster } from "./overview";
+import { ArtifactSectionContent } from "./artifact";
+
+type Props = {
+ children?: ReactNode;
+ className?: string;
+ [key: string]: unknown;
+};
+// Resolve only our pure functions. Filter sections and notes before layout, without DOM mutation.
+export function resolveTemplate(
+ node: ReactNode,
+ request: ExportRequest,
+): ReactNode {
+ if (Array.isArray(node))
+ return node.map((child) => resolveTemplate(child, request));
+ if (!isValidElement(node)) return node;
+ if (typeof node.type === "function") {
+ return resolveTemplate(
+ (node.type as (props: Props) => ReactNode)(node.props),
+ request,
+ );
+ }
+ const props = { ...node.props };
+ if (request.withoutNotes && "data-export-note" in props) return null;
+ if (request.kind === "artifact") {
+ const { sections, recommendedCondition, targetCondition, includeNote } =
+ request.data;
+ const section = props["data-artifact-export-section"];
+ if (
+ section &&
+ (section === "note"
+ ? !includeNote
+ : !sections.includes(section as (typeof sections)[number]))
+ )
+ return null;
+ if (
+ props["data-recommended-condition"] !== undefined &&
+ Number(props["data-recommended-condition"]) !== recommendedCondition
+ )
+ return null;
+ if (
+ props["data-target-condition"] !== undefined &&
+ Number(props["data-target-condition"]) !== targetCondition
+ )
+ return null;
+ const profile = request.data.guide.artifactProfile;
+ const hasNote =
+ includeNote && !request.withoutNotes && Boolean(profile?.note);
+ const hasTargets =
+ sections.includes("target-stats") &&
+ Boolean(
+ profile?.targetConditions.filter((condition) => condition.rows.length)[
+ targetCondition
+ ],
+ );
+ const hasTalents =
+ sections.includes("talents") && Boolean(profile?.talents.length);
+ if (
+ props.className?.includes("artifact-export-right") &&
+ !hasNote &&
+ !hasTargets &&
+ !hasTalents
+ )
+ return null;
+ }
+ props.children = resolveTemplate(props.children, request);
+ return createElement(node.type, { ...props, key: node.key });
+}
+export function exportTemplate(request: ExportRequest): ReactElement {
+ let content: ReactNode;
+ switch (request.kind) {
+ case "constellation":
+ content = ;
+ break;
+ case "weapon":
+ content = ;
+ break;
+ case "team":
+ content = ;
+ break;
+ case "materials":
+ content = ;
+ break;
+ case "overview":
+ content = ;
+ break;
+ case "artifact":
+ content = ;
+ break;
+ }
+ return (
+
+ {resolveTemplate(content, request)}
+
+ );
+}
diff --git a/lib/image-export/templates/materials.tsx b/lib/image-export/templates/materials.tsx
new file mode 100644
index 0000000..5dc15f2
--- /dev/null
+++ b/lib/image-export/templates/materials.tsx
@@ -0,0 +1,307 @@
+import { Image, Link } from "./primitives";
+
+import type {
+ CatalogAscensionGroup,
+ CatalogBaseStat,
+} from "@/lib/catalog/lunaris";
+import {
+ mergePosterMaterials,
+ type PosterMaterial,
+} from "@/lib/material-poster";
+import { accentGradient } from "@/lib/overview-image";
+import { cn } from "@/lib/utils";
+
+export type PosterBlendMode =
+ | "normal"
+ | "multiply"
+ | "screen"
+ | "overlay"
+ | "darken"
+ | "lighten"
+ | "color-dodge"
+ | "color-burn"
+ | "hard-light"
+ | "soft-light"
+ | "difference"
+ | "exclusion"
+ | "hue"
+ | "saturation"
+ | "color"
+ | "luminosity";
+
+type PosterCharacter = {
+ name: string;
+ nameCardImageUrl: string | null;
+ baseStats: CatalogBaseStat[];
+ ascension: Array<
+ Omit & { items: PosterMaterial[] }
+ >;
+ leveling: Array<
+ Omit & { items: PosterMaterial[] }
+ >;
+};
+
+const POSTER_EXPORT_WIDTH = 1600;
+const POSTER_IMAGE_QUALITY = 100;
+const MAX_OPTIMIZED_IMAGE_WIDTH = 3840;
+
+const statLabels: Record = {
+ hp: "HP",
+ atk: "ATK",
+ def: "DEF",
+ em: "EM",
+};
+
+function statLabel(key: string): string {
+ return statLabels[key.toLowerCase()] ?? key;
+}
+
+function statIconName(key: string): string | null {
+ const normalized = key.toLowerCase().replace(/[^a-z]/gu, "");
+ if (["hp", "atk", "def", "em", "er"].includes(normalized)) return normalized;
+ if (["critrate", "crit", "criticalrate"].includes(normalized))
+ return "critrate";
+ if (["critdmg", "critdamage", "criticaldamage"].includes(normalized))
+ return "critdmg";
+ return null;
+}
+
+function statValue(key: string, value: number | string): string {
+ const formatted =
+ typeof value === "number"
+ ? value.toLocaleString("en-US", { maximumFractionDigits: 2 })
+ : value;
+ return key.includes("%") ? `${formatted}%` : formatted;
+}
+
+export function MaterialPoster({
+ character,
+ characterArtUrl,
+ artworkOffsetX,
+ artworkOffsetY,
+ artworkScale,
+ topColors,
+ bottomColors,
+ interactive = false,
+ linkMaterials = false,
+ className,
+ bleeding = [],
+}: {
+ character: PosterCharacter;
+ characterArtUrl: string | null;
+ artworkOffsetX: number;
+ artworkOffsetY: number;
+ topColors: [string, string];
+ bottomColors: [string, string];
+ interactive?: boolean;
+ linkMaterials?: boolean;
+ onArtworkMove?: (offset: { x: number; y: number }) => void;
+ className?: string;
+ artworkScale: number;
+ bleeding?: Array<{
+ id: string;
+ imageUrl: string | null;
+ amount: number;
+ blendMode: PosterBlendMode;
+ }>;
+}) {
+ const stats = character.baseStats.slice(0, 4);
+ const materials = mergePosterMaterials(
+ [...character.ascension, ...character.leveling].flatMap(
+ (group) => group.items,
+ ),
+ );
+ const artworkImageSize = Math.min(
+ MAX_OPTIMIZED_IMAGE_WIDTH,
+ Math.max(
+ POSTER_EXPORT_WIDTH,
+ Math.ceil((POSTER_EXPORT_WIDTH * artworkScale) / 100),
+ ),
+ );
+ const background = `linear-gradient(180deg, ${topColors[0]} 0%, ${topColors[1]} 22%, rgba(0,0,0,0) 42%, rgba(0,0,0,0) 62%, ${bottomColors[0]} 88%, ${bottomColors[1]} 100%)`;
+
+ return (
+
+ {character.nameCardImageUrl ? (
+
+ ) : null}
+
+ {characterArtUrl ? (
+
+
+ {[...bleeding].reverse().map((layer) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+
+ BASE STATS
+
+
+ {Array.from({ length: 4 }, (_, index) => {
+ const stat = stats[index];
+ const iconName = stat ? statIconName(stat.key) : null;
+ return (
+
+
+ {iconName ? (
+
+ ) : null}
+ {stat ? statLabel(stat.key) : "—"}
+
+
+ {stat ? statValue(stat.key, stat.value) : "—"}
+
+
+ );
+ })}
+
+
+
+
+
+ MATERIALS
+
+
LV.90
+
+ {materials.map((material, index) => {
+ const materialCard = (
+ <>
+
+ {material.imageUrl ? (
+
+ ) : null}
+
+
+
+ ×{material.amount.toLocaleString("en-US")}
+
+
+ >
+ );
+ const className =
+ "aspect-[0.72] overflow-hidden rounded-[1.3cqw] bg-black/95 ring-1 ring-white/10";
+
+ return linkMaterials ? (
+
+ {materialCard}
+
+ ) : (
+
+ {materialCard}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/lib/image-export/templates/overview.tsx b/lib/image-export/templates/overview.tsx
new file mode 100644
index 0000000..1586b36
--- /dev/null
+++ b/lib/image-export/templates/overview.tsx
@@ -0,0 +1,393 @@
+import type { CSSProperties, ReactNode } from "react";
+import { Image, ImageIcon, getImageProps } from "./primitives";
+import { cn } from "@/lib/utils";
+import { elementIconFilter } from "@/lib/catalog/element";
+import {
+ accentGradient,
+ elementOrder,
+ elementGlowColor,
+ normalizeHex,
+ weaponTypeIconPath,
+ type OverviewBlendMode,
+} from "@/lib/overview-image";
+type Character = {
+ key: string;
+ name: string;
+ aliases: string[];
+ element: string | null;
+ rarity: number | null;
+ weaponType: string | null;
+ imageUrl: string;
+ nameCardImageUrl: string | null;
+};
+
+type Composition = {
+ backgroundUrl: string | null;
+ fadeColor: string;
+ fadeTint: number;
+ auraUpOffset: number;
+ characterOffset: Position;
+ fadeOffset: Position;
+};
+
+type BlendLayer = {
+ id: number;
+ mode: OverviewBlendMode;
+ amount: number;
+};
+
+type CropArea = { x: number; y: number; width: number; height: number };
+type Position = { x: number; y: number };
+type PosterLayer = "character" | "fade";
+
+const DISCLAIMER =
+ "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต";
+function OverviewLayer({
+ offset,
+ className,
+ style,
+ children,
+}: {
+ id: PosterLayer;
+ offset: Position;
+ interactive: boolean;
+ className?: string;
+ style?: CSSProperties;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+export function OverviewPoster({
+ artworkUrl,
+ character,
+ role,
+ topColors,
+ bottomColors,
+ elementColors,
+ elementGradientAngle,
+ blendLayers,
+ nameCardCrop,
+ nameCardSize,
+ elementIconUrls,
+ composition,
+ disclaimerFontSize = null,
+ interactive = false,
+ activeLayer = undefined,
+}: {
+ artworkUrl: string | null;
+ character: Character | null;
+ role: string;
+ topColors: [string, string];
+ bottomColors: [string, string];
+ elementColors: [string, string];
+ elementGradientAngle: number;
+ blendLayers: BlendLayer[];
+ nameCardCrop: CropArea | null;
+ nameCardSize: { width: number; height: number } | null;
+ elementIconUrls: Record;
+ composition: Composition;
+ disclaimerFontSize: number | null;
+ interactive?: boolean;
+ activeLayer?: PosterLayer;
+ onLayerMove?: (layer: PosterLayer, delta: Position) => void;
+}) {
+ const weaponIcon = weaponTypeIconPath(character?.weaponType ?? null);
+ const z = {
+ character: 1,
+ fade: 2,
+ aura: 3,
+ badge: 4,
+ text: 5,
+ } as const;
+
+ return (
+
+ {composition.backgroundUrl ? (
+
+ ) : character?.nameCardImageUrl ? (
+ nameCardCrop && nameCardSize ? (
+
+ ) : (
+
+ )
+ ) : null}
+ {artworkUrl ? (
+
+
+ {[...blendLayers].reverse().map((layer) => (
+
+ ))}
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {weaponIcon ? (
+
+ ) : null}
+ {role}
+
+
+
+ {character ? (
+
+
+ {character.name.toLocaleUpperCase("en")}
+
+
+ {elementOrder.map((element) => {
+ const url = elementIconUrls[element];
+ const active = element === character.element;
+ if (!url) return null;
+ if (active) {
+ const { props: maskImageProps } = getImageProps({
+ src: url,
+ alt: "",
+ width: 72,
+ height: 72,
+ });
+ return (
+
+
+
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+
+ {DISCLAIMER}
+
+
+ ) : null}
+
+ );
+}
diff --git a/lib/image-export/templates/primitives.tsx b/lib/image-export/templates/primitives.tsx
new file mode 100644
index 0000000..c144459
--- /dev/null
+++ b/lib/image-export/templates/primitives.tsx
@@ -0,0 +1,161 @@
+/* eslint-disable @next/next/no-img-element -- Pure image-renderer templates never mount in the webpage. */
+import type { ComponentProps, CSSProperties, ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+type ImageProps = Omit, "width" | "height"> & {
+ width?: number;
+ height?: number;
+ fill?: boolean;
+ sizes?: string;
+ unoptimized?: boolean;
+ priority?: boolean;
+ quality?: number;
+};
+// The renderer receives original image sources, not Next.js optimized image URLs.
+export function Image({
+ src,
+ alt = "",
+ width,
+ height,
+ fill,
+ className,
+ style,
+}: ImageProps) {
+ return (
+
+ );
+}
+export function getImageProps({ src }: ImageProps) {
+ return { props: { src } };
+}
+export function Link({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+ [key: string]: unknown;
+}) {
+ return {children}
;
+}
+export function Kbd({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return {children} ;
+}
+export function ArrowUpRightIcon() {
+ return ↗ ;
+}
+export function ImageIcon({
+ className,
+}: {
+ className?: string;
+ [key: string]: unknown;
+}) {
+ return ◇ ;
+}
+export function ConditionSwitcher({
+ children,
+}: {
+ children: ReactNode;
+ label: string;
+ labels: string[];
+}) {
+ return <>{children}>;
+}
+export function Separator() {
+ return
;
+}
+export function Badge({ children }: { children: ReactNode; variant?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+type BoxProps = ComponentProps<"div">;
+export function Card({ className, children, ...props }: BoxProps) {
+ return (
+
+ {children}
+
+ );
+}
+export function CardHeader({ className, ...props }: BoxProps) {
+ return (
+
+ );
+}
+export function CardTitle({ className, ...props }: BoxProps) {
+ return (
+
+ );
+}
+export function CardContent({ className, ...props }: BoxProps) {
+ return
;
+}
+export function GuideExportBranding({
+ backgroundUrl,
+ variant = "standard",
+}: {
+ backgroundUrl: string;
+ variant?: "standard" | "constellation";
+}) {
+ const constellation = variant === "constellation";
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/lib/image-export/templates/team.tsx b/lib/image-export/templates/team.tsx
new file mode 100644
index 0000000..6661787
--- /dev/null
+++ b/lib/image-export/templates/team.tsx
@@ -0,0 +1,279 @@
+import { Image, Link, Kbd, ArrowUpRightIcon } from "./primitives";
+import { cn } from "@/lib/utils";
+import { rarityGradientClass } from "@/lib/catalog/rarity";
+import { elementBackgroundColor } from "@/lib/catalog/element";
+import { isCustomTravelerKey } from "@/lib/catalog/travelers";
+import type { PublicGuide } from "@/lib/guides/queries";
+type Team = PublicGuide["teams"][number];
+type TeamArtifact = {
+ key: string;
+ name: string;
+ imageUrl: string;
+ rarity: number | null;
+};
+
+function ArtifactHalf({
+ artifact,
+ side,
+ eager,
+ linked,
+ sizes,
+}: {
+ artifact: TeamArtifact;
+ side: "left" | "right";
+ eager: boolean;
+ linked: boolean;
+ sizes: string;
+}) {
+ const content = (
+
+
+
+ );
+ const className = cn(
+ "absolute inset-0 overflow-hidden",
+ side === "left"
+ ? "[clip-path:polygon(0_0,0_100%,100%_100%)]"
+ : "[clip-path:polygon(0_0,100%_0,100%_100%)]",
+ rarityGradientClass(artifact.rarity),
+ );
+
+ return linked ? (
+
+ {content}
+
+ ) : (
+ {content}
+ );
+}
+
+export function TeamArtifactIcon({
+ first,
+ second,
+ eager = false,
+ linked = false,
+ className,
+ sizes,
+}: {
+ first: TeamArtifact | null;
+ second: TeamArtifact | null;
+ eager?: boolean;
+ linked?: boolean;
+ className?: string;
+ sizes: string;
+}) {
+ const primary = first ?? second;
+ const secondary = first ? second : null;
+
+ if (!primary) {
+ return (
+
+ );
+ }
+
+ if (!secondary) {
+ const content = (
+ <>
+
+ {linked ? (
+
+
+
+ ) : null}
+ >
+ );
+ const artifactClassName = cn(
+ "relative aspect-square scale-110 overflow-hidden rounded-md bg-muted",
+ rarityGradientClass(primary.rarity),
+ className,
+ );
+
+ return linked ? (
+
+ {content}
+
+ ) : (
+ {content}
+ );
+ }
+
+ return (
+
+
+
+
+ {linked ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export function TeamExportImage({ team }: { team: Team }) {
+ const total = team.members.reduce(
+ (sum, member) => sum + Number(member.damage),
+ 0,
+ );
+ const seconds = Number(team.rotationSeconds);
+ return (
+
+
+
+ {team.members.map((member) => {
+ const damage = Number(member.damage);
+ const percent = total > 0 ? (damage / total) * 100 : 0;
+ return (
+
+
+
+
+
+ {member.weapon ? (
+
+
+
+ ) : null}
+
+
+
+
+
+ {member.character.name}
+
+ C{member.constellation}R{member.refinement}
+
+
+
+ {damage.toLocaleString("en-US")}
+
+
+
+
+
+ {percent.toFixed(1)}%
+
+
+
+
+ );
+ })}
+
+
+
+ DPS:{" "}
+ {seconds > 0
+ ? (total / seconds).toLocaleString("en-US", {
+ maximumFractionDigits: 0,
+ })
+ : "0"}
+
+
+ By YT Genshin Isn't Mobile Game
+
+
+ );
+}
diff --git a/lib/image-export/templates/weapon.tsx b/lib/image-export/templates/weapon.tsx
new file mode 100644
index 0000000..8f657d9
--- /dev/null
+++ b/lib/image-export/templates/weapon.tsx
@@ -0,0 +1,107 @@
+import { Image, GuideExportBranding } from "./primitives";
+import type { PublicGuide } from "@/lib/guides/queries";
+type WeaponGroup = PublicGuide["weaponGroups"][number];
+type DamageMetric = "overallPercent" | "personalPercent";
+
+function formattedPercent(value: string | null): string {
+ if (value === null) return "-%";
+ const numeric = Number(value);
+ if (numeric === 0) return "-%";
+ return `${numeric.toLocaleString("en-US", { maximumFractionDigits: 2 })}%`;
+}
+
+export function WeaponComparisonImage({
+ guide,
+ group,
+ metric,
+ conditionLabel,
+}: {
+ guide: PublicGuide;
+ group: WeaponGroup;
+ metric: DamageMetric;
+ conditionLabel: string;
+}) {
+ const values = group.rows.map((row) => Number(row[metric]) || 0);
+ const maximum = Math.max(100, ...values);
+
+ return (
+
+
+
+
+
+ {group.rows.map((row) => {
+ const numeric = Number(row[metric]) || 0;
+ const width =
+ row[metric] === null
+ ? 28
+ : numeric <= 100
+ ? Math.max(28, Math.max(0, numeric) * 0.5)
+ : Math.min(
+ 90,
+ 50 + ((numeric - 100) / Math.max(1, maximum - 100)) * 40,
+ );
+ return (
+
+
+
+
+
+
+
+ {row.weapon.name} R{row.refinement}
+
+
+
+
+ {formattedPercent(row[metric])}
+
+ {row.note ? (
+
+ {row.note}
+
+ ) : null}
+
+
+
+ );
+ })}
+
+
+ {group.note ? (
+
+ {group.note}
+
+ ) : null}
+
+ );
+}
diff --git a/lib/image-export/types.ts b/lib/image-export/types.ts
new file mode 100644
index 0000000..280a78c
--- /dev/null
+++ b/lib/image-export/types.ts
@@ -0,0 +1,52 @@
+import type { ComponentProps } from "react";
+import type { ConstellationImage } from "./templates/constellation";
+import type { WeaponComparisonImage } from "./templates/weapon";
+import type { TeamExportImage } from "./templates/team";
+import type { MaterialPoster } from "./templates/materials";
+import type { OverviewPoster } from "./templates/overview";
+import type { PublicGuide } from "@/lib/guides/queries";
+
+export type ArtifactExportSection =
+ "tierlist" | "best-stats" | "target-stats" | "talents";
+export type ExportTemplate =
+ | { kind: "constellation"; data: ComponentProps }
+ | { kind: "weapon"; data: ComponentProps }
+ | { kind: "team"; data: ComponentProps }
+ | {
+ kind: "materials";
+ data: Omit<
+ ComponentProps,
+ "onArtworkMove" | "interactive" | "linkMaterials"
+ >;
+ }
+ | {
+ kind: "overview";
+ data: Omit<
+ ComponentProps,
+ "onLayerMove" | "interactive" | "activeLayer"
+ >;
+ }
+ | {
+ kind: "artifact";
+ data: {
+ guide: PublicGuide;
+ sections: ArtifactExportSection[];
+ recommendedCondition: number;
+ targetCondition: number;
+ includeNote: boolean;
+ };
+ };
+export type ExportRequest = ExportTemplate & {
+ width: number;
+ height?: number;
+ format: "png" | "webp";
+ withoutNotes?: boolean;
+};
+export type WorkerRequest = {
+ id: number;
+ request: ExportRequest;
+ origin: string;
+};
+export type WorkerResult =
+ | { id: number; bytes: ArrayBuffer; error?: never }
+ | { id: number; error: string; bytes?: never };
diff --git a/package.json b/package.json
index 1b5612b..e31df37 100644
--- a/package.json
+++ b/package.json
@@ -3,14 +3,14 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev --turbopack",
- "build": "next build",
+ "dev": "bun scripts/prepare-image-export.mjs && next dev --turbopack",
+ "build": "bun scripts/prepare-image-export.mjs && next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "next typegen && tsc --noEmit",
- "test": "vitest run",
- "test:watch": "vitest",
- "test:coverage": "vitest run --coverage",
+ "test": "bun scripts/prepare-image-export.mjs && vitest run",
+ "test:watch": "bun scripts/prepare-image-export.mjs && vitest",
+ "test:coverage": "bun scripts/prepare-image-export.mjs && vitest run --coverage",
"logs": "kubectl logs --namespace buzz-sheet --selector 'app.kubernetes.io/name=buzz-sheet,app.kubernetes.io/component in (web,worker,discord-worker)' --all-containers=true --prefix=true --tail=100 --follow --max-log-requests=10",
"db:generate": "drizzle-kit generate",
"db:migrate": "bun scripts/migrate.ts",
@@ -57,10 +57,12 @@
"sharp": "^0.35.4",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
+ "takumi-js": "2.13.7",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
+ "@tailwindcss/node": "4.3.3",
"@tailwindcss/postcss": "^4",
"@types/bun": "^1.4.0",
"@types/node": "^20",
diff --git a/public/Anuphan-LICENSE.txt b/public/Anuphan-LICENSE.txt
new file mode 100644
index 0000000..796d0d5
--- /dev/null
+++ b/public/Anuphan-LICENSE.txt
@@ -0,0 +1,93 @@
+Copyright 2019 The Anuphan Project Authors (https://github.com/cadsondemak/Anuphan)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/public/Anuphan.ttf b/public/Anuphan.ttf
new file mode 100644
index 0000000..60faaba
Binary files /dev/null and b/public/Anuphan.ttf differ
diff --git a/scripts/prepare-image-export.mjs b/scripts/prepare-image-export.mjs
new file mode 100644
index 0000000..bc15b98
--- /dev/null
+++ b/scripts/prepare-image-export.mjs
@@ -0,0 +1,47 @@
+import { copyFile, readFile, writeFile, readdir } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { compile } from "@tailwindcss/node";
+import ts from "typescript";
+
+await copyFile(
+ fileURLToPath(import.meta.resolve("@takumi-rs/wasm/takumi_wasm_bg.wasm")),
+ "public/takumi.wasm",
+);
+const globals = await readFile("app/globals.css", "utf8");
+const theme = globals.slice(
+ globals.indexOf("@theme inline {"),
+ globals.indexOf("\n}", globals.indexOf("@theme inline {")) + 2,
+);
+const colors = globals.slice(
+ globals.indexOf(":root,\n.dark"),
+ globals.indexOf("\n}", globals.indexOf(":root,\n.dark")) + 2,
+);
+const candidates = new Set();
+const files = (await readdir("lib/image-export/templates"))
+ .filter((name) => name.endsWith(".tsx"))
+ .map((name) => `lib/image-export/templates/${name}`);
+files.push(
+ "lib/catalog/rarity.ts",
+ "lib/catalog/element.ts",
+ "lib/guides/stats.ts",
+);
+for (const path of files) {
+ const source = ts.createSourceFile(
+ path,
+ await readFile(path, "utf8"),
+ ts.ScriptTarget.Latest,
+ true,
+ path.endsWith("tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
+ );
+ function visit(node) {
+ if (ts.isStringLiteralLike(node))
+ node.text.split(/\s+/).forEach((token) => candidates.add(token));
+ ts.forEachChild(node, visit);
+ }
+ visit(source);
+}
+const compiler = await compile(
+ `@import "tailwindcss";\n${theme}\n${colors}\n:root { --font-anuphan: Anuphan; --font-geist-mono: Anuphan; }`,
+ { base: process.cwd(), onDependency() {} },
+);
+await writeFile("public/takumi.css", compiler.build([...candidates]));