473 lines
14 KiB
TypeScript
473 lines
14 KiB
TypeScript
/**
|
|
* 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<K, V> {
|
|
private cache: Map<K, V>;
|
|
|
|
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<string, { metadata: ImageMetadata; timestamp: number }>(100);
|
|
const METADATA_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
|
// In-flight fetch tracking to prevent duplicate parallel requests
|
|
const inflightColorFetches = new Map<string, Promise<string | undefined>>();
|
|
const inflightMetadataFetches = new Map<string, Promise<ImageMetadata>>();
|
|
|
|
/**
|
|
* 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<string | undefined> {
|
|
// 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<ImageMetadata> {
|
|
// 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;
|
|
}
|