/** * Unified image metadata utilities for embedding/extracting user color and banner * Supports PNG, WebP, JPEG, and GIF formats */ /* eslint-disable no-plusplus */ /* eslint-disable no-console */ import { embedColorInPNG, extractColorFromPNG, removeColorFromPNG, embedMetadataInPNG, extractMetadataFromPNG, extractBannerFromPNG, } from './pngMetadata'; import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './webpMetadata'; import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata'; import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata'; /** * LRU Cache implementation with max size limit */ class LRUCache { private cache: Map; private maxSize: number; constructor(maxSize: number) { this.cache = new Map(); this.maxSize = maxSize; } get(key: K): V | undefined { const value = this.cache.get(key); if (value !== undefined) { this.cache.delete(key); this.cache.set(key, value); } return value; } set(key: K, value: V): void { if (this.cache.has(key)) { this.cache.delete(key); } else if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; if (firstKey !== undefined) { this.cache.delete(firstKey); } } this.cache.set(key, value); } clear(): void { this.cache.clear(); } } /** * Gradient configuration for profile styling * All colors support RGBA format (e.g., #FF550080 for 50% opacity) */ export type ProfileGradient = { /** CSS gradient direction (e.g., "to bottom", "45deg", "to bottom right") */ direction: string; /** Start color in hex format with optional alpha (#RRGGBB or #RRGGBBAA) */ startColor: string; /** End color in hex format with optional alpha (#RRGGBB or #RRGGBBAA) */ stopColor: string; }; export type ImageMetadata = { /** Profile name color */ color?: string; /** Banner MXC URL */ banner?: string; /** Avatar border color with optional transparency (#RRGGBB or #RRGGBBAA) */ avatarBorderColor?: string; /** Profile card gradient */ gradient?: ProfileGradient; }; /** PNG signature bytes */ const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; /** RIFF/WebP signature bytes */ const RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46]; // "RIFF" const WEBP_OFFSET_SIGNATURE = [0x57, 0x45, 0x42, 0x50]; // "WEBP" at offset 8 /** JPEG signature */ const JPEG_SIGNATURE = [0xFF, 0xD8]; /** GIF signatures */ const GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; // "GIF87a" const GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; // "GIF89a" export type ImageFormat = 'png' | 'webp' | 'jpeg' | 'gif' | 'unknown'; /** * Detect image format from raw data */ export function detectImageFormat(data: ArrayBuffer | Uint8Array): ImageFormat { const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); if (bytes.length < 12) return 'unknown'; // Check PNG signature let isPNG = true; for (let i = 0; i < 8; i++) { if (bytes[i] !== PNG_SIGNATURE[i]) { isPNG = false; break; } } if (isPNG) return 'png'; // Check WebP signature (RIFF + WEBP) let isRIFF = true; for (let i = 0; i < 4; i++) { if (bytes[i] !== RIFF_SIGNATURE[i]) { isRIFF = false; break; } } if (isRIFF) { let isWebP = true; for (let i = 0; i < 4; i++) { if (bytes[8 + i] !== WEBP_OFFSET_SIGNATURE[i]) { isWebP = false; break; } } if (isWebP) return 'webp'; } // Check JPEG signature (FFD8) if (bytes[0] === JPEG_SIGNATURE[0] && bytes[1] === JPEG_SIGNATURE[1]) { return 'jpeg'; } // Check GIF signatures let isGIF87a = true; for (let i = 0; i < 6; i++) { if (bytes[i] !== GIF87A_SIGNATURE[i]) { isGIF87a = false; break; } } if (isGIF87a) return 'gif'; let isGIF89a = true; for (let i = 0; i < 6; i++) { if (bytes[i] !== GIF89A_SIGNATURE[i]) { isGIF89a = false; break; } } if (isGIF89a) return 'gif'; return 'unknown'; } /** * Extract paarrot color from image data (PNG, WebP, JPEG, or GIF) * @param imageData - Raw image data as ArrayBuffer or Uint8Array * @returns The color string if found, or undefined */ export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined { const format = detectImageFormat(imageData); console.log('[extractColorFromImage] Detected format:', format); switch (format) { case 'png': return extractColorFromPNG(imageData); case 'webp': return extractColorFromWebP(imageData); case 'jpeg': return extractColorFromJPEG(imageData); case 'gif': return extractColorFromGIF(imageData); default: console.warn('[extractColorFromImage] Unknown image format'); return undefined; } } /** * Extract paarrot banner URL from image data (any supported format) * @param imageData - Raw image data as ArrayBuffer or Uint8Array * @returns The banner MXC URL if found, or undefined */ export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined { const format = detectImageFormat(imageData); switch (format) { case 'png': return extractBannerFromPNG(imageData); // For now, only PNG supports banner. Add other formats later default: console.warn('[extractBannerFromImage] Format not yet supported for banner'); return undefined; } } /** * Extract all paarrot metadata from image data (any supported format) * @param imageData - Raw image data as ArrayBuffer or Uint8Array * @returns Object with color and banner if found */ export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata { const format = detectImageFormat(imageData); console.log('[extractMetadataFromImage] Detected format:', format); switch (format) { case 'png': const pngMetadata = extractMetadataFromPNG(imageData); console.log('[extractMetadataFromImage] PNG metadata:', pngMetadata); return pngMetadata; // For other formats, extract color only for now case 'webp': case 'jpeg': case 'gif': { const color = extractColorFromImage(imageData); console.log('[extractMetadataFromImage] Non-PNG color:', color); return color ? { color } : {}; } default: console.warn('[extractMetadataFromImage] Unknown image format'); return {}; } } /** * Embed paarrot color into image data (PNG, WebP, JPEG, or GIF) * @param imageData - Raw image data as ArrayBuffer or Uint8Array * @param color - The color string to embed (e.g., '#ff5500') * @returns New image data with embedded color, or null if not a supported format */ export function embedColorInImage(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null { const format = detectImageFormat(imageData); switch (format) { case 'png': return embedColorInPNG(imageData, color); case 'webp': return embedColorInWebP(imageData, color); case 'jpeg': return embedColorInJPEG(imageData, color); case 'gif': return embedColorInGIF(imageData, color); default: console.warn('[embedColorInImage] Unknown image format, cannot embed color'); return null; } } /** * Embed paarrot metadata (color and/or banner) into image data * Preserves existing metadata that is not being updated * @param imageData - Raw image data as ArrayBuffer or Uint8Array * @param metadata - Object with color and/or banner to embed * @returns New image data with embedded metadata, or null if not a supported format */ export function embedMetadataInImage( imageData: ArrayBuffer | Uint8Array, metadata: ImageMetadata ): Uint8Array | null { const format = detectImageFormat(imageData); switch (format) { case 'png': return embedMetadataInPNG(imageData, metadata); // For other formats, only embed color for now case 'webp': case 'jpeg': case 'gif': if (metadata.color) { return embedColorInImage(imageData, metadata.color); } return null; default: console.warn('[embedMetadataInImage] Unknown image format, cannot embed metadata'); return null; } } /** * Remove paarrot color from image data (PNG, WebP, JPEG, or GIF) * @param imageData - Raw image data as ArrayBuffer or Uint8Array * @returns New image data without the color, or null if not a supported format */ export function removeColorFromImage(imageData: ArrayBuffer | Uint8Array): Uint8Array | null { const format = detectImageFormat(imageData); switch (format) { case 'png': return removeColorFromPNG(imageData); case 'webp': return removeColorFromWebP(imageData); case 'jpeg': return removeColorFromJPEG(imageData); case 'gif': return removeColorFromGIF(imageData); default: console.warn('[removeColorFromImage] Unknown image format'); return null; } } /** * Get the MIME type for an image format */ export function getMimeType(format: ImageFormat): string { switch (format) { case 'png': return 'image/png'; case 'webp': return 'image/webp'; case 'jpeg': return 'image/jpeg'; case 'gif': return 'image/gif'; default: return 'application/octet-stream'; } } /** * Get the file extension for an image format */ export function getExtension(format: ImageFormat): string { switch (format) { case 'png': return 'png'; case 'webp': return 'webp'; case 'jpeg': return 'jpg'; case 'gif': return 'gif'; default: return 'bin'; } } // LRU cache for avatar metadata to avoid refetching const avatarMetadataCache = new LRUCache(100); const METADATA_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes // In-flight fetch tracking to prevent duplicate parallel requests const inflightColorFetches = new Map>(); const inflightMetadataFetches = new Map>(); /** * Fetch and extract color from an image URL (supports all formats) * @param url - HTTP URL to fetch the image from * @param accessToken - Optional access token for authenticated media * @returns The color string if found, or undefined */ export async function fetchAndExtractColor(url: string, accessToken?: string | null): Promise { // Check if full metadata is cached const cached = avatarMetadataCache.get(url); if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) { console.log('[fetchAndExtractColor] Using cached color for', url, ':', cached.metadata.color); return cached.metadata.color; } // Check if fetch already in progress const inflightKey = `${url}:${accessToken || ''}`; const existingFetch = inflightColorFetches.get(inflightKey); if (existingFetch) { console.log('[fetchAndExtractColor] Reusing in-flight fetch for', url); return existingFetch; } console.log('[fetchAndExtractColor] Fetching and extracting color from', url); const fetchPromise = (async () => { try { const response = await fetch(url, { method: 'GET', headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, }); console.log('[fetchAndExtractColor] Fetch response status:', response.ok, response.status); if (!response.ok) return undefined; const data = await response.arrayBuffer(); console.log('[fetchAndExtractColor] Downloaded', data.byteLength, 'bytes'); const color = extractColorFromImage(data); console.log('[fetchAndExtractColor] Extracted color:', color); // Don't cache here - fetchAndExtractMetadata will cache full metadata // This avoids cache collision where color-only fetch overwrites full metadata return color; } catch (error) { console.error('[fetchAndExtractColor] Error fetching/extracting color:', error); return undefined; } finally { inflightColorFetches.delete(inflightKey); } })(); inflightColorFetches.set(inflightKey, fetchPromise); return fetchPromise; } /** * Fetch and extract all metadata from an image URL * @param url - HTTP URL to fetch the image from * @param accessToken - Optional access token for authenticated media * @returns Object with color and banner if found */ export async function fetchAndExtractMetadata(url: string, accessToken?: string | null): Promise { // Check cache first const cached = avatarMetadataCache.get(url); if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) { console.log('[fetchAndExtractMetadata] Using cached metadata for', url, ':', cached.metadata); return cached.metadata; } // Check if fetch already in progress const inflightKey = `${url}:${accessToken || ''}`; const existingFetch = inflightMetadataFetches.get(inflightKey); if (existingFetch) { console.log('[fetchAndExtractMetadata] Reusing in-flight fetch for', url); return existingFetch; } console.log('[fetchAndExtractMetadata] Fetching metadata from', url); const fetchPromise = (async () => { try { const response = await fetch(url, { method: 'GET', headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, }); console.log('[fetchAndExtractMetadata] Fetch response status:', response.ok, response.status); if (!response.ok) return {}; const data = await response.arrayBuffer(); console.log('[fetchAndExtractMetadata] Downloaded', data.byteLength, 'bytes'); const metadata = extractMetadataFromImage(data); console.log('[fetchAndExtractMetadata] Extracted metadata:', metadata); // Cache the result avatarMetadataCache.set(url, { metadata, timestamp: Date.now() }); return metadata; } catch { return {}; } finally { inflightMetadataFetches.delete(inflightKey); } })(); inflightMetadataFetches.set(inflightKey, fetchPromise); return fetchPromise; }