feat : add image generator
This commit is contained in:
@@ -34,7 +34,7 @@ export default async function OverviewImagePage() {
|
|||||||
สร้างภาพ Overview
|
สร้างภาพ Overview
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
สร้างและดาวน์โหลดภาพ PNG ขนาด 4K โดยไม่เปลี่ยนภาพใน Guide
|
สร้างและดาวน์โหลดภาพ PNG ขนาด 3000×3000 โดยไม่เปลี่ยนภาพใน Guide
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<OverviewImageGenerator
|
<OverviewImageGenerator
|
||||||
|
|||||||
@@ -9,6 +9,14 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Kanit Overview";
|
||||||
|
src: url("/Kanit-SemiBold.ttf") format("truetype");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,13 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
accentGradient,
|
accentGradient,
|
||||||
|
detectThemeColorsFromPixels,
|
||||||
|
elementGlowColor,
|
||||||
|
elementGradientColors,
|
||||||
|
elementOrder,
|
||||||
normalizeHex,
|
normalizeHex,
|
||||||
overviewFileName,
|
overviewFileName,
|
||||||
|
overviewBlendModes,
|
||||||
weaponTypeIconPath,
|
weaponTypeIconPath,
|
||||||
} from "@/lib/overview-image";
|
} from "@/lib/overview-image";
|
||||||
|
|
||||||
@@ -42,10 +47,37 @@ describe("overview image generator", () => {
|
|||||||
expect(weaponTypeIconPath(null)).toBeNull();
|
expect(weaponTypeIconPath(null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps every element to gradient and fixed glow colors", () => {
|
||||||
|
for (const element of elementOrder) {
|
||||||
|
const [start, end] = elementGradientColors(element);
|
||||||
|
expect(normalizeHex(start)).toBe(start);
|
||||||
|
expect(normalizeHex(end)).toBe(end);
|
||||||
|
expect(normalizeHex(elementGlowColor(element))).toBe(
|
||||||
|
elementGlowColor(element),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(elementGradientColors("Unknown")).toEqual(["#FFFFFF", "#FFFFFF"]);
|
||||||
|
expect(elementGlowColor(null)).toBe("#FFFFFF");
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a safe PNG filename", () => {
|
it("creates a safe PNG filename", () => {
|
||||||
expect(overviewFileName("Traveler / Pyro", "Main DPS")).toBe(
|
expect(overviewFileName("Traveler / Pyro", "Main DPS")).toBe(
|
||||||
"overview-Traveler-Pyro-main-dps.png",
|
"overview-Traveler-Pyro-main-dps.png",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports every Tailwind blend mode and detects top and bottom themes", () => {
|
||||||
|
expect(overviewBlendModes.map((mode) => mode.value)).toHaveLength(18);
|
||||||
|
const pixels = new Uint8ClampedArray([
|
||||||
|
255, 0, 0, 255, 255, 0, 0, 255,
|
||||||
|
255, 255, 0, 255, 255, 255, 0, 255,
|
||||||
|
0, 0, 255, 255, 0, 0, 255, 255,
|
||||||
|
0, 255, 255, 255, 0, 255, 255, 255,
|
||||||
|
]);
|
||||||
|
expect(detectThemeColorsFromPixels(pixels, 2, 4)).toEqual({
|
||||||
|
top: ["#FF0000", "#FFFF00"],
|
||||||
|
bottom: ["#0000FF", "#00FFFF"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,29 @@ export const overviewRoles = ["Main DPS", "Sub DPS", "Support"] as const;
|
|||||||
|
|
||||||
export type OverviewRole = (typeof overviewRoles)[number];
|
export type OverviewRole = (typeof overviewRoles)[number];
|
||||||
|
|
||||||
|
export const overviewBlendModes = [
|
||||||
|
{ value: "normal", label: "Normal" },
|
||||||
|
{ value: "multiply", label: "Multiply" },
|
||||||
|
{ value: "screen", label: "Screen" },
|
||||||
|
{ value: "overlay", label: "Overlay" },
|
||||||
|
{ value: "darken", label: "Darken" },
|
||||||
|
{ value: "lighten", label: "Lighten" },
|
||||||
|
{ value: "color-dodge", label: "Color Dodge" },
|
||||||
|
{ value: "color-burn", label: "Color Burn" },
|
||||||
|
{ value: "hard-light", label: "Hard Light" },
|
||||||
|
{ value: "soft-light", label: "Soft Light" },
|
||||||
|
{ value: "difference", label: "Difference" },
|
||||||
|
{ value: "exclusion", label: "Exclusion" },
|
||||||
|
{ value: "hue", label: "Hue" },
|
||||||
|
{ value: "saturation", label: "Saturation" },
|
||||||
|
{ value: "color", label: "Color" },
|
||||||
|
{ value: "luminosity", label: "Luminosity" },
|
||||||
|
{ value: "plus-darker", label: "Plus Darker" },
|
||||||
|
{ value: "plus-lighter", label: "Plus Lighter" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OverviewBlendMode = (typeof overviewBlendModes)[number]["value"];
|
||||||
|
|
||||||
export const elementOrder = [
|
export const elementOrder = [
|
||||||
"Pyro",
|
"Pyro",
|
||||||
"Hydro",
|
"Hydro",
|
||||||
@@ -12,6 +35,47 @@ export const elementOrder = [
|
|||||||
"Geo",
|
"Geo",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export type OverviewElement = (typeof elementOrder)[number];
|
||||||
|
|
||||||
|
const elementGradientPresets: Record<
|
||||||
|
OverviewElement,
|
||||||
|
readonly [string, string]
|
||||||
|
> = {
|
||||||
|
Pyro: ["#FF1E00", "#FFA200"],
|
||||||
|
Hydro: ["#147BFF", "#14D0FF"],
|
||||||
|
Anemo: ["#20BFA4", "#8CFFE0"],
|
||||||
|
Electro: ["#7B3FC6", "#D29BFF"],
|
||||||
|
Dendro: ["#5B9E21", "#C2E84A"],
|
||||||
|
Cryo: ["#5CBBD7", "#C3F7FF"],
|
||||||
|
Geo: ["#C87913", "#FFE06A"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const elementGlowColors: Record<OverviewElement, string> = {
|
||||||
|
Pyro: "#FF1414",
|
||||||
|
Hydro: "#49C5F1",
|
||||||
|
Anemo: "#63C9AD",
|
||||||
|
Electro: "#BF86DD",
|
||||||
|
Dendro: "#9CBD33",
|
||||||
|
Cryo: "#9ACDE1",
|
||||||
|
Geo: "#F0AF2D",
|
||||||
|
};
|
||||||
|
|
||||||
|
function isOverviewElement(value: string | null | undefined): value is OverviewElement {
|
||||||
|
return elementOrder.some((element) => element === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elementGradientColors(
|
||||||
|
element: string | null | undefined,
|
||||||
|
): readonly [string, string] {
|
||||||
|
return isOverviewElement(element)
|
||||||
|
? elementGradientPresets[element]
|
||||||
|
: ["#FFFFFF", "#FFFFFF"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elementGlowColor(element: string | null | undefined): string {
|
||||||
|
return isOverviewElement(element) ? elementGlowColors[element] : "#FFFFFF";
|
||||||
|
}
|
||||||
|
|
||||||
const weaponIconNames: Record<string, string> = {
|
const weaponIconNames: Record<string, string> = {
|
||||||
WEAPON_SWORD_ONE_HAND: "sword.png",
|
WEAPON_SWORD_ONE_HAND: "sword.png",
|
||||||
WEAPON_CLAYMORE: "claymore.png",
|
WEAPON_CLAYMORE: "claymore.png",
|
||||||
@@ -26,6 +90,89 @@ export function normalizeHex(value: string): string | null {
|
|||||||
return trimmed.toUpperCase();
|
return trimmed.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ThemeColorBin = {
|
||||||
|
red: number;
|
||||||
|
green: number;
|
||||||
|
blue: number;
|
||||||
|
weight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function colorDistance(left: ThemeColorBin, right: ThemeColorBin): number {
|
||||||
|
return Math.sqrt(
|
||||||
|
(left.red - right.red) ** 2 +
|
||||||
|
(left.green - right.green) ** 2 +
|
||||||
|
(left.blue - right.blue) ** 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function binColor(red: number, green: number, blue: number): string {
|
||||||
|
return [red, green, blue]
|
||||||
|
.map((channel) => Math.round(channel / 32) * 32)
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionThemeColors(
|
||||||
|
pixels: ArrayLike<number>,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
startY: number,
|
||||||
|
endY: number,
|
||||||
|
): [string, string] {
|
||||||
|
const bins = new Map<string, ThemeColorBin>();
|
||||||
|
const step = Math.max(1, Math.floor(Math.max(width, height) / 48));
|
||||||
|
for (let y = startY; y < endY; y += step) {
|
||||||
|
for (let x = 0; x < width; x += step) {
|
||||||
|
const offset = (y * width + x) * 4;
|
||||||
|
const alpha = (pixels[offset + 3] ?? 0) / 255;
|
||||||
|
if (alpha < 0.15) continue;
|
||||||
|
const red = pixels[offset] ?? 0;
|
||||||
|
const green = pixels[offset + 1] ?? 0;
|
||||||
|
const blue = pixels[offset + 2] ?? 0;
|
||||||
|
const brightness = Math.max(red, green, blue) / 255;
|
||||||
|
const minimum = Math.min(red, green, blue) / 255;
|
||||||
|
const saturation = brightness === 0 ? 0 : (brightness - minimum) / brightness;
|
||||||
|
const weight = alpha * (0.35 + saturation * 0.65) * (0.5 + brightness * 0.5);
|
||||||
|
const key = binColor(red, green, blue);
|
||||||
|
const current = bins.get(key) ?? { red: 0, green: 0, blue: 0, weight: 0 };
|
||||||
|
current.red += red * weight;
|
||||||
|
current.green += green * weight;
|
||||||
|
current.blue += blue * weight;
|
||||||
|
current.weight += weight;
|
||||||
|
bins.set(key, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = [...bins.values()]
|
||||||
|
.map((color) => ({
|
||||||
|
...color,
|
||||||
|
red: color.red / color.weight,
|
||||||
|
green: color.green / color.weight,
|
||||||
|
blue: color.blue / color.weight,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.weight - left.weight);
|
||||||
|
const primary = colors[0] ?? { red: 233, green: 165, blue: 26, weight: 0 };
|
||||||
|
const secondary =
|
||||||
|
colors.find((color) => colorDistance(primary, color) >= 56) ?? primary;
|
||||||
|
|
||||||
|
const toHex = (color: ThemeColorBin) =>
|
||||||
|
`#${[color.red, color.green, color.blue]
|
||||||
|
.map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
|
||||||
|
.join("")}`.toUpperCase();
|
||||||
|
return [toHex(primary), toHex(secondary)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectThemeColorsFromPixels(
|
||||||
|
pixels: ArrayLike<number>,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): { top: [string, string]; bottom: [string, string] } {
|
||||||
|
const midpoint = Math.max(1, Math.floor(height / 2));
|
||||||
|
return {
|
||||||
|
top: regionThemeColors(pixels, width, height, 0, midpoint),
|
||||||
|
bottom: regionThemeColors(pixels, width, height, midpoint, height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mixHex(hex: string, target: number, amount: number): string {
|
function mixHex(hex: string, target: number, amount: number): string {
|
||||||
const normalized = normalizeHex(hex);
|
const normalized = normalizeHex(hex);
|
||||||
if (!normalized) return hex;
|
if (!normalized) return hex;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 574 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
Reference in New Issue
Block a user