diff --git a/app/admin/generate/image/page.tsx b/app/admin/generate/image/page.tsx index f6e9145..cfabbcd 100644 --- a/app/admin/generate/image/page.tsx +++ b/app/admin/generate/image/page.tsx @@ -34,7 +34,7 @@ export default async function OverviewImagePage() { สร้างภาพ Overview

- สร้างและดาวน์โหลดภาพ PNG ขนาด 4K โดยไม่เปลี่ยนภาพใน Guide + สร้างและดาวน์โหลดภาพ PNG ขนาด 3000×3000 โดยไม่เปลี่ยนภาพใน Guide

= [ + ...overviewBlendModes, +]; + const DISCLAIMER = "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต"; @@ -99,24 +136,72 @@ function OverviewPoster({ role, topColors, bottomColors, + elementColors, + elementGradientAngle, + blendLayers, nameCardCrop, nameCardSize, elementIconUrls, captureRef, + composition, }: { artworkUrl: string | null; character: Character | null; role: OverviewRole; topColors: [string, string]; bottomColors: [string, string]; + elementColors: [string, string]; + elementGradientAngle: number; + blendLayers: BlendLayer[]; nameCardCrop: CropArea | null; nameCardSize: { width: number; height: number } | null; elementIconUrls: Record; captureRef?: Ref; + composition: Composition; }) { const weaponIcon = weaponTypeIconPath(character?.weaponType ?? null); - const normalizedBottom = normalizeHex(bottomColors[1]) ?? "#E9A51A"; - const nameSize = (character?.name.length ?? 0) > 16 ? "5.6cqw" : "7.2cqw"; + const z = { + character: 1, + fade: 2, + aura: 3, + badge: 4, + text: 5, + } as const; + const titleRef = useRef(null); + const disclaimerRef = useRef(null); + + useEffect(() => { + let cancelled = false; + const fit = () => { + if (cancelled || !titleRef.current || !disclaimerRef.current) return; + const title = titleRef.current; + const disclaimer = disclaimerRef.current; + const width = title.parentElement!.getBoundingClientRect().width; + title.style.fontSize = `${width / 10}px`; + if (title.scrollWidth > width) { + title.style.fontSize = `${((width / 10) * width) / title.scrollWidth}px`; + } + const textWidth = title.getBoundingClientRect().width; + const context = document.createElement("canvas").getContext("2d"); + if (!context) return; + context.font = '100px "Genshin Overview"'; + const longest = Math.max( + ...DISCLAIMER.split("\n").map( + (line) => context.measureText(line).width, + ), + ); + disclaimer.style.width = `${textWidth}px`; + disclaimer.style.fontSize = `${Math.min(width * 0.024, (textWidth * 100) / longest)}px`; + }; + const observer = new ResizeObserver(fit); + if (titleRef.current?.parentElement) + observer.observe(titleRef.current.parentElement); + void document.fonts.ready.then(fit); + return () => { + cancelled = true; + observer.disconnect(); + }; + }, [character?.name]); return (
- {character?.nameCardImageUrl ? ( + {composition.backgroundUrl ? ( + + ) : character?.nameCardImageUrl ? ( nameCardCrop && nameCardSize ? ( ) : ( @@ -148,12 +243,12 @@ function OverviewPoster({ fill loading="eager" sizes="1024px" - className="object-cover" + className="object-cover opacity-70" /> ) ) : null} {artworkUrl ? ( - <> +
- - + {[...blendLayers].reverse().map((layer) => ( + + ))} +
) : (
)} - +
+ +
+
-
+ className="absolute inset-0 pointer-events-none" + style={{ zIndex: z.aura }} + > +
+
+ +
+
+
-
- - -
- {weaponIcon ? ( - - ) : null} - {role} + className="absolute inset-0 pointer-events-none" + style={{ zIndex: z.badge }} + > + +
+ {weaponIcon ? ( + + ) : null} + {role} +
{character ? ( -
+

{character.name.toLocaleUpperCase("en")}

-
+
{elementOrder.map((element) => { const url = elementIconUrls[element]; const active = element === character.element; - return url ? ( + if (!url) return null; + if (active) { + const { props: maskImageProps } = getImageProps({ + src: url, + alt: "", + width: 72, + height: 72, + }); + return ( + + + + + ); + } + return ( - ) : null; + ); })}
-

+

{DISCLAIMER}

@@ -337,6 +505,100 @@ function ColorField({ ); } +function SortableBlendLayerRow({ + layer, + onUpdate, + onRemove, +}: { + layer: BlendLayer; + onUpdate: (patch: Partial>) => void; + onRemove: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: layer.id }); + const selectedMode = blendModeItems.find((mode) => mode.value === layer.mode); + + return ( +
+ +
+ { + if (value) onUpdate({ mode: value.value }); + }} + itemToStringLabel={(item) => item.label} + itemToStringValue={(item) => item.value} + filter={(item, query) => + `${item.label} ${item.value}` + .toLocaleLowerCase("en") + .includes(query.trim().toLocaleLowerCase("en")) + } + > + + + ไม่พบ Blend mode + + {(item) => ( + + {item.label} + + )} + + + + + + ความเข้ม {layer.amount}% + + onUpdate({ amount: Number(event.target.value) })} + /> + +
+ +
+ ); +} + export function OverviewImageGenerator({ characters, elementIconUrls, @@ -352,8 +614,9 @@ export function OverviewImageGenerator({ const [cropArea, setCropArea] = useState(null); const [cropZoom, setCropZoom] = useState(1); const [nameCardCrop, setNameCardCrop] = useState(null); - const [nameCardDraftCrop, setNameCardDraftCrop] = - useState(null); + const [nameCardDraftCrop, setNameCardDraftCrop] = useState( + null, + ); const [nameCardSize, setNameCardSize] = useState<{ width: number; height: number; @@ -365,8 +628,36 @@ export function OverviewImageGenerator({ const [topColorEnd, setTopColorEnd] = useState("#FFD84A"); const [bottomColorStart, setBottomColorStart] = useState("#E9A51A"); const [bottomColorEnd, setBottomColorEnd] = useState("#FFD84A"); + const [elementColorStart, setElementColorStart] = useState("#FFFFFF"); + const [elementColorEnd, setElementColorEnd] = useState("#FFFFFF"); + const [elementGradientAngle, setElementGradientAngle] = useState(101); + const [blendLayers, setBlendLayers] = useState([ + { id: 1, mode: "color-burn", amount: 100 }, + ]); + const nextBlendLayerId = useRef(2); + const blendLayerSensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); const [exporting, setExporting] = useState(false); - + const [backgroundUrl, setBackgroundUrl] = useState(null); + const [fadeColor, setFadeColor] = useState("#0E0200"); + const [fadeTint, setFadeTint] = useState(100); + const [auraUpOffset, setAuraUpOffset] = useState(4); + const composition: Composition = { + backgroundUrl, + fadeColor, + fadeTint, + auraUpOffset, + }; + useEffect( + () => () => { + if (backgroundUrl) URL.revokeObjectURL(backgroundUrl); + }, + [backgroundUrl], + ); useEffect( () => () => { if (artworkUrl) URL.revokeObjectURL(artworkUrl); @@ -374,6 +665,65 @@ export function OverviewImageGenerator({ [artworkUrl], ); + function detectArtworkTheme( + source: CanvasImageSource, + width: number, + height: number, + ) { + const maxDimension = 64; + const scale = Math.min(1, maxDimension / Math.max(width, height)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(width * scale)); + canvas.height = Math.max(1, Math.round(height * scale)); + const context = canvas.getContext("2d"); + if (!context) return; + context.drawImage(source, 0, 0, width, height, 0, 0, canvas.width, canvas.height); + const theme = detectThemeColorsFromPixels( + context.getImageData(0, 0, canvas.width, canvas.height).data, + canvas.width, + canvas.height, + ); + setTopColorStart(theme.top[0]); + setTopColorEnd(theme.top[1]); + setBottomColorStart(theme.bottom[0]); + setBottomColorEnd(theme.bottom[1]); + } + + function addBlendLayer() { + setBlendLayers((current) => [ + ...current, + { + id: nextBlendLayerId.current++, + mode: "normal", + amount: 100, + }, + ]); + } + + function updateBlendLayer(id: number, patch: Partial>) { + setBlendLayers((current) => + current.map((layer) => (layer.id === id ? { ...layer, ...patch } : layer)), + ); + } + + function removeBlendLayer(id: number) { + setBlendLayers((current) => current.filter((layer) => layer.id !== id)); + } + + function handleBlendLayerDragEnd({ active, over }: DragEndEvent) { + if (!over || active.id === over.id) return; + setBlendLayers((current) => { + const oldIndex = current.findIndex( + (layer) => String(layer.id) === String(active.id), + ); + const newIndex = current.findIndex( + (layer) => String(layer.id) === String(over.id), + ); + if (oldIndex === -1 || newIndex === -1) return current; + return arrayMove(current, oldIndex, newIndex); + }); + } + useEffect(() => { const url = character?.nameCardImageUrl; if (!url) return; @@ -410,23 +760,29 @@ export function OverviewImageGenerator({ [pendingCrop], ); - async function selectArtwork(event: ChangeEvent) { + async function selectArtwork( + event: ChangeEvent, + target: "artwork" | "background" = "artwork", + ) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const url = URL.createObjectURL(file); try { - await new Promise((resolve, reject) => { + const image = await new Promise((resolve, reject) => { const image = new window.Image(); - image.onload = () => resolve(); + image.onload = () => resolve(image); image.onerror = reject; image.src = url; }); + if (target === "artwork") { + detectArtworkTheme(image, image.naturalWidth, image.naturalHeight); + } setArtworkError(null); setCropArea(null); setCropZoom(1); - setPendingCrop({ url }); + setPendingCrop({ url, target }); } catch { URL.revokeObjectURL(url); setArtworkError("ไม่สามารถอ่านไฟล์ภาพนี้ได้"); @@ -460,13 +816,19 @@ export function OverviewImageGenerator({ canvas.width, canvas.height, ); + if (pendingCrop.target === "artwork") { + detectArtworkTheme(canvas, canvas.width, canvas.height); + } const blob = await new Promise((resolve, reject) => canvas.toBlob( - (value) => (value ? resolve(value) : reject(new Error("Crop failed"))), + (value) => + value ? resolve(value) : reject(new Error("Crop failed")), "image/png", ), ); - setArtworkUrl(URL.createObjectURL(blob)); + if (pendingCrop.target === "background") + setBackgroundUrl(URL.createObjectURL(blob)); + else setArtworkUrl(URL.createObjectURL(blob)); setPendingCrop(null); setArtworkError(null); } catch { @@ -505,7 +867,10 @@ export function OverviewImageGenerator({ ); setCropArea(null); setCropZoom(1); - setPendingCrop({ url: URL.createObjectURL(blob) }); + setPendingCrop({ + url: URL.createObjectURL(blob), + target: pendingCrop.target, + }); } catch { toast.error("หมุนภาพไม่สำเร็จ กรุณาลองอีกครั้ง"); } @@ -520,7 +885,10 @@ export function OverviewImageGenerator({ !normalizeHex(topColorStart) || !normalizeHex(topColorEnd) || !normalizeHex(bottomColorStart) || - !normalizeHex(bottomColorEnd) + !normalizeHex(bottomColorEnd) || + !normalizeHex(elementColorStart) || + !normalizeHex(elementColorEnd) || + !normalizeHex(fadeColor) ) return; @@ -531,9 +899,9 @@ export function OverviewImageGenerator({ const blob = await domToBlob(node, { backgroundColor: "#000000", type: "image/png", - width: 1024, - height: 1024, - scale: 4, + width: 1000, + height: 1000, + scale: 3, }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -553,22 +921,26 @@ export function OverviewImageGenerator({ const ready = Boolean( character && - artworkUrl && - normalizeHex(topColorStart) && - normalizeHex(topColorEnd) && - normalizeHex(bottomColorStart) && - normalizeHex(bottomColorEnd) && - weaponTypeIconPath(character.weaponType), + artworkUrl && + normalizeHex(topColorStart) && + normalizeHex(topColorEnd) && + normalizeHex(bottomColorStart) && + normalizeHex(bottomColorEnd) && + normalizeHex(elementColorStart) && + normalizeHex(elementColorEnd) && + normalizeHex(fadeColor) && + weaponTypeIconPath(character.weaponType), ); return ( -
- - - ตั้งค่าภาพ - ไฟล์ภาพจะใช้เฉพาะในเบราว์เซอร์นี้ - - +
+
+ + + ตั้งค่าภาพ + ไฟล์ภาพจะใช้เฉพาะในเบราว์เซอร์นี้ + + ตัวละคร @@ -576,6 +948,11 @@ export function OverviewImageGenerator({ items={characters} value={character} onValueChange={(value) => { + if (value?.element !== character?.element) { + const [start, end] = elementGradientColors(value?.element); + setElementColorStart(start); + setElementColorEnd(end); + } setCharacter(value); setNameCardCrop(null); setNameCardSize(null); @@ -649,7 +1026,7 @@ export function OverviewImageGenerator({ type="file" accept="image/png,image/jpeg,image/webp" aria-invalid={Boolean(artworkError)} - onChange={selectArtwork} + onChange={(event) => selectArtwork(event)} /> ทุกภาพจะเปิดหน้าต่างให้ครอป ซูม และจัดตำแหน่งก่อนใช้งาน @@ -658,24 +1035,183 @@ export function OverviewImageGenerator({ - Role - { - const selected = value[0] as OverviewRole | undefined; - if (selected) setRole(selected); - }} - variant="outline" - className="flex-wrap justify-start" - > - {overviewRoles.map((value) => ( - - {value} - - ))} - + + อัปโหลดพื้นหลัง + + selectArtwork(event, "background")} + /> + {backgroundUrl ? ( + + ) : null} + + ครอปและซูมพื้นหลังเป็นสี่เหลี่ยมก่อนใช้งาน + + + + + + + + ธีมและเอฟเฟกต์ + + สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด + + + + + + + + ความเข้มสี Fade {fadeTint}% + + setFadeTint(Number(event.target.value))} + /> + + + + เลื่อน Aura ด้านบนขึ้น {auraUpOffset}% + + + setAuraUpOffset(Number(event.target.value)) + } + /> + + + Role + { + const selected = value[0] as OverviewRole | undefined; + if (selected) setRole(selected); + }} + variant="outline" + className="flex-wrap justify-start" + > + {overviewRoles.map((value) => ( + + {value} + + ))} + + + + Blend mode + + layer.id)} + strategy={verticalListSortingStrategy} + > +
+ {blendLayers.map((layer) => ( + updateBlendLayer(layer.id, patch)} + onRemove={() => removeBlendLayer(layer.id)} + /> + ))} +
+
+
+
+ +
+ + เพิ่มได้หลายแบบ และลากที่ไอคอนเพื่อจัดลำดับการผสมสี + +
+
+
+ + + +
+
+ + + + ตัวอย่าง + + ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง + + + +
+ +
+
+
+ + + + สีธีม + + สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด และแก้ไขเองได้ + + + + - - - - - - - - - - ตัวอย่าง - ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง - - -
- -
+ + + + มุมสีไล่ระดับธาตุ {elementGradientAngle}° + + + setElementGradientAngle(Number(event.target.value)) + } + /> + +
@@ -859,7 +1402,10 @@ export function OverviewImageGenerator({ ซูม
-
) : null} -