- {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}
-