/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; import {convertToCodePoints} from '@app/utils/EmojiCodepointUtils'; import sharp from 'sharp'; const EMOJI_SPRITES = { nonDiversityPerRow: 42, diversityPerRow: 10, pickerPerRow: 11, pickerCount: 50, } as const; const EMOJI_SIZE = 32; const TWEMOJI_CDN = 'https://fluxerstatic.com/emoji'; const SPRITE_SCALES = [1, 2] as const; interface EmojiObject { surrogates: string; skins?: Array<{surrogates: string}>; } interface EmojiEntry { surrogates: string; } const svgCache = new Map(); async function fetchTwemojiSVG(codepoint: string): Promise { if (svgCache.has(codepoint)) { return svgCache.get(codepoint) ?? null; } const url = `${TWEMOJI_CDN}/${codepoint}.svg`; try { const response = await fetch(url); if (!response.ok) { console.error(`Twemoji ${codepoint} returned ${response.status}`); svgCache.set(codepoint, null); return null; } const body = await response.text(); svgCache.set(codepoint, body); return body; } catch (err) { console.error(`Failed to fetch Twemoji ${codepoint}:`, err); svgCache.set(codepoint, null); return null; } } function fixSVGSize(svg: string, size: number): string { return svg.replace(/]*)>/i, ``); } async function renderSVGToBuffer(svgContent: string, size: number): Promise { const fixed = fixSVGSize(svgContent, size); return sharp(Buffer.from(fixed)).resize(size, size).png().toBuffer(); } function hslToRgb(h: number, s: number, l: number): [number, number, number] { h = ((h % 360) + 360) % 360; h /= 360; let r: number, g: number, b: number; if (s === 0) { r = g = b = l; } else { const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; const hueToRgb = (p: number, q: number, t: number): number => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; r = hueToRgb(p, q, h + 1 / 3); g = hueToRgb(p, q, h); b = hueToRgb(p, q, h - 1 / 3); } return [ Math.round(Math.min(1, Math.max(0, r)) * 255), Math.round(Math.min(1, Math.max(0, g)) * 255), Math.round(Math.min(1, Math.max(0, b)) * 255), ]; } async function createPlaceholder(size: number): Promise { const h = Math.random() * 360; const [r, g, b] = hslToRgb(h, 0.7, 0.6); const radius = Math.floor(size * 0.4); const cx = Math.floor(size / 2); const cy = Math.floor(size / 2); const svg = ` `; return sharp(Buffer.from(svg)).png().toBuffer(); } async function loadEmojiImage(surrogate: string, size: number): Promise { const codepoint = convertToCodePoints(surrogate); const svg = await fetchTwemojiSVG(codepoint); if (svg) { try { return await renderSVGToBuffer(svg, size); } catch (error) { console.error(`Failed to render SVG for ${codepoint}:`, error); } } if (codepoint.includes('-200d-')) { const basePart = codepoint.split('-200d-')[0]; const baseSvg = await fetchTwemojiSVG(basePart); if (baseSvg) { try { return await renderSVGToBuffer(baseSvg, size); } catch (error) { console.error(`Failed to render base SVG for ${basePart}:`, error); } } } console.error(`Missing SVG for ${codepoint} (${surrogate}), using placeholder`); return createPlaceholder(size); } async function renderSpriteSheet( emojiEntries: Array, perRow: number, fileNameBase: string, outputDir: string, ): Promise { if (perRow <= 0) { throw new Error('perRow must be > 0'); } const rows = Math.ceil(emojiEntries.length / perRow); for (const scale of SPRITE_SCALES) { const size = EMOJI_SIZE * scale; const dstW = perRow * size; const dstH = rows * size; const compositeOps: Array = []; for (let i = 0; i < emojiEntries.length; i++) { const item = emojiEntries[i]; const emojiBuffer = await loadEmojiImage(item.surrogates, size); const row = Math.floor(i / perRow); const col = i % perRow; const x = col * size; const y = row * size; compositeOps.push({ input: emojiBuffer, left: x, top: y, }); } const sheet = await sharp({ create: { width: dstW, height: dstH, channels: 4, background: {r: 0, g: 0, b: 0, alpha: 0}, }, }) .composite(compositeOps) .png() .toBuffer(); const suffix = scale !== 1 ? `@${scale}x` : ''; const outPath = join(outputDir, `${fileNameBase}${suffix}.png`); writeFileSync(outPath, sheet); console.log(`Wrote ${outPath}`); } } async function generateMainSpriteSheet( emojiData: Record>, outputDir: string, ): Promise { const base: Array = []; for (const objs of Object.values(emojiData)) { for (const obj of objs) { base.push({surrogates: obj.surrogates}); } } await renderSpriteSheet(base, EMOJI_SPRITES.nonDiversityPerRow, 'spritesheet-emoji', outputDir); } async function generateDiversitySpriteSheets( emojiData: Record>, outputDir: string, ): Promise { const skinTones = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}']; for (let skinIndex = 0; skinIndex < skinTones.length; skinIndex++) { const skinTone = skinTones[skinIndex]; const skinCodepoint = convertToCodePoints(skinTone); const skinEntries: Array = []; for (const objs of Object.values(emojiData)) { for (const obj of objs) { if (obj.skins && obj.skins.length > skinIndex && obj.skins[skinIndex].surrogates) { skinEntries.push({surrogates: obj.skins[skinIndex].surrogates}); } } } if (skinEntries.length === 0) { continue; } await renderSpriteSheet(skinEntries, EMOJI_SPRITES.diversityPerRow, `spritesheet-${skinCodepoint}`, outputDir); } } async function generatePickerSpriteSheet(outputDir: string): Promise { const basicEmojis = [ '\u{1F600}', '\u{1F603}', '\u{1F604}', '\u{1F601}', '\u{1F606}', '\u{1F605}', '\u{1F602}', '\u{1F923}', '\u{1F60A}', '\u{1F607}', '\u{1F642}', '\u{1F609}', '\u{1F60C}', '\u{1F60D}', '\u{1F970}', '\u{1F618}', '\u{1F617}', '\u{1F619}', '\u{1F61A}', '\u{1F60B}', '\u{1F61B}', '\u{1F61D}', '\u{1F61C}', '\u{1F92A}', '\u{1F928}', '\u{1F9D0}', '\u{1F913}', '\u{1F60E}', '\u{1F973}', '\u{1F60F}', ]; const entries: Array = basicEmojis.map((e) => ({surrogates: e})); await renderSpriteSheet(entries, EMOJI_SPRITES.pickerPerRow, 'spritesheet-picker', outputDir); } async function main(): Promise { const scriptDir = import.meta.dirname; const appDir = join(scriptDir, '..'); const outputDir = join(appDir, 'src', 'assets', 'emoji-sprites'); mkdirSync(outputDir, {recursive: true}); const emojiDataPath = join(appDir, 'src', 'data', 'emojis.json'); const emojiData: Record> = JSON.parse(readFileSync(emojiDataPath, 'utf-8')); console.log('Generating main sprite sheet...'); await generateMainSpriteSheet(emojiData, outputDir); console.log('Generating diversity sprite sheets...'); await generateDiversitySpriteSheets(emojiData, outputDir); console.log('Generating picker sprite sheet...'); await generatePickerSpriteSheet(outputDir); console.log('Emoji sprites generated successfully.'); } main().catch((err) => { console.error('Error:', err); process.exit(1); });