// This file's extension is .tsx because JSX is used for satori to generate images // Warnings below are disabled since satori is not Next.JS and is turned into an image anyways /* eslint-disable jsx-a11y/alt-text */ /* eslint-disable @next/next/no-img-element */ import type { ReactNode } from "react"; import fs from "fs/promises"; import path from "path"; import sharp from "sharp"; import { fileTypeFromBuffer } from "file-type"; import satori, { Font } from "satori"; import { Mii } from "@prisma/client"; const MIN_IMAGE_DIMENSIONS = [128, 128]; const MAX_IMAGE_DIMENSIONS = [8000, 8000]; const MAX_IMAGE_SIZE = 8 * 1024 * 1024; // 8 MB const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; //#region Image validation export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> { if (!file || file.size == 0) return { valid: false, error: "Empty image file" }; if (file.size > MAX_IMAGE_SIZE) return { valid: false, error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` }; try { const buffer = Buffer.from(await file.arrayBuffer()); // Check mime type const fileType = await fileTypeFromBuffer(buffer); if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime)) return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" }; let metadata: sharp.Metadata; try { metadata = await sharp(buffer).metadata(); } catch { return { valid: false, error: "Invalid or corrupted image file" }; } // Check image dimensions if ( !metadata.width || !metadata.height || metadata.width < MIN_IMAGE_DIMENSIONS[0] || metadata.width > MAX_IMAGE_DIMENSIONS[0] || metadata.height < MIN_IMAGE_DIMENSIONS[1] || metadata.height > MAX_IMAGE_DIMENSIONS[1] ) { return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 8000x8000" }; } return { valid: true }; } catch (error) { console.error("Error validating image:", error); return { valid: false, error: "Failed to process image file", status: 500 }; } } //#endregion //#region Generating 'metadata' image type const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const fontCache: Record = { regular: null, medium: null, semiBold: null, bold: null, extraBold: null, black: null, }; // Load fonts only once and cache them const loadFonts = async (): Promise => { const weights = [ ["regular", 400], ["medium", 500], ["semiBold", 600], ["bold", 700], ["extraBold", 800], ["black", 900], ] as const; return Promise.all( weights.map(async ([weight, value]) => { if (!fontCache[weight]) { const filePath = path.join(process.cwd(), `public/fonts/lexend-${weight}.ttf`); const data = await fs.readFile(filePath); fontCache[weight] = { name: "Lexend", data, weight: value, }; } return fontCache[weight]!; }), ); }; export async function generateMetadataImage(mii: Mii, author: string): Promise<{ buffer?: Buffer; error?: string; status?: number }> { const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString()); // Load assets concurrently const [miiImage, qrCodeImage, fonts] = await Promise.all([ // Read and convert the images to data URI fs.readFile(path.join(miiUploadsDirectory, "mii.png")).then((buffer) => sharp(buffer) // extend to fix shadow bug on landscape pictures .extend({ left: 16, right: 16, background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .toBuffer() .then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`), ), mii.platform === "THREE_DS" ? fs.readFile(path.join(miiUploadsDirectory, "qr-code.png")).then((buffer) => sharp(buffer) .toBuffer() .then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`), ) : Promise.resolve(null), loadFonts(), ]); const jsx: ReactNode = (
{/* Mii portrait */}
{/* QR code */} {mii.platform === "THREE_DS" ? (
) : (
Switch Guide

To fully create the Mii, visit the site for instructions.

View Steps
)}
{/* Mii name */} {mii.name} {/* Tags */}
{mii.tags.map((tag) => ( {tag} ))}
{/* Author */}
By{" "} {author}
{/* Watermark */}
{/* I tried using text-orange-400 but it wasn't correct..? */} TomodachiShare
); const svg = await satori(jsx, { width: 600, height: 400, fonts, }); // Convert .svg to .png const buffer = await sharp(Buffer.from(svg)).png().toBuffer(); // Store the file try { const fileLocation = path.join(miiUploadsDirectory, "metadata.png"); await fs.writeFile(fileLocation, buffer); } catch (error) { console.error("Error storing 'metadata' image type", error); return { error: `Failed to store metadata image for ${mii.id}`, status: 500 }; } return { buffer }; } //#endregion