import { ImageResponse } from "@vercel/og"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import React from "react"; import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils"; import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresaber-utils"; import { StarIcon } from "../../components/star-icon"; import { GlobeIcon } from "../../components/globe-icon"; import NodeCache from "node-cache"; import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player"; import { Jimp } from "jimp"; import { extractColors } from "extract-colors"; import { Config } from "@ssr/common/config"; const cache = new NodeCache({ stdTTL: 60 * 60, checkperiod: 120 }); const imageOptions = { width: 1200, height: 630 }; export class ImageService { /** * Gets the average color of an image * * @param src the image url * @returns the average color * @private */ public static async getAverageImageColor(src: string): Promise<{ color: string } | undefined> { src = decodeURIComponent(src); return await this.fetchWithCache<{ color: string }>(`average_color-${src}`, async () => { try { const image = await Jimp.read(src); // Load image using Jimp const { width, height, data } = image.bitmap; // Access image dimensions and pixel data // Convert the Buffer data to Uint8ClampedArray const uint8ClampedArray = new Uint8ClampedArray(data); // Extract the colors using extract-colors const colors = await extractColors({ data: uint8ClampedArray, width, height }); // Return the most dominant color, or fallback if none found if (colors && colors.length > 0) { return { color: colors[2].hex }; // Returning the third most dominant color } return { color: "#fff", // Fallback color in case no colors are found }; } catch (error) { console.error("Error fetching image or extracting colors:", error); return { color: "#fff", // Fallback color in case of an error }; } }); } /** * Fetches data with caching. * * @param cacheKey The key used for caching. * @param fetchFn The function to fetch data if it's not in cache. */ private static async fetchWithCache( cacheKey: string, fetchFn: () => Promise ): Promise { if (cache.has(cacheKey)) { return cache.get(cacheKey); } const data = await fetchFn(); if (data) { cache.set(cacheKey, data); } return data; } /** * The base of the OpenGraph image * * @param children the content of the image */ public static BaseImage({ children }: { children: React.ReactNode }) { return (
{children}
); } /** * Renders the change for a stat. * * @param change the amount of change * @param format the function to format the value */ private static renderDailyChange(change: number, format: (value: number) => string = formatNumberWithCommas) { if (change === 0) { return null; } return (

0 ? "text-green-400" : "text-red-400"}`}> {change > 0 ? "+" : ""} {format(change)}

); } /** * Generates the OpenGraph image for the player * * @param id the player's id */ public static async generatePlayerImage(id: string) { const player = await this.fetchWithCache(`player-${id}`, async () => { const token = await scoresaberService.lookupPlayer(id); return token ? await getScoreSaberPlayerFromToken(token, Config.apiUrl) : undefined; }); if (!player) { return undefined; } const { statisticChange } = player; const { daily } = statisticChange ?? {}; const rankChange = daily?.countryRank ?? 0; const countryRankChange = daily?.rank ?? 0; const ppChange = daily?.pp ?? 0; return new ImageResponse( ( {/* Player Avatar */} Player's Avatar {/* Player Stats */}
{/* Player Name */}

{player.name}

{/* Player PP */}

{formatPp(player.pp)}pp

{this.renderDailyChange(ppChange)}
{/* Player Stats */}
{/* Player Rank */}

#{formatNumberWithCommas(player.rank)}

{this.renderDailyChange(rankChange)}
{/* Player Country Rank */}
Player's Country

#{formatNumberWithCommas(player.countryRank)}

{this.renderDailyChange(countryRankChange)}
{/* Joined Date */}

Joined ScoreSaber in{" "} {player.joinedDate.toLocaleString("en-US", { timeZone: "Europe/London", month: "long", year: "numeric", })}

), imageOptions ); } /** * Generates the OpenGraph image for the leaderboard * * @param id the leaderboard's id */ public static async generateLeaderboardImage(id: string) { const leaderboard = await this.fetchWithCache(`leaderboard-${id}`, () => scoresaberService.lookupLeaderboard(id) ); if (!leaderboard) { return undefined; } const ranked = leaderboard.stars > 0; return new ImageResponse( ( {/* Leaderboard Cover Image */} Leaderboard Cover {/* Leaderboard Name */}

{leaderboard.songName} {leaderboard.songSubName}

{/* Leaderboard Stars */} {ranked && (

{leaderboard.stars}

)} {/* Leaderboard Difficulty */}

{getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty)}

{/* Leaderboard Author */}

Mapped by {leaderboard.levelAuthorName}

), imageOptions ); } }