Files
cinny/src/app/utils/imageMetadata.ts
Max Litruv Boonzaayer fc30d81f8f feat: Enhance user color and banner management
- Updated `useUserColor` hook to support fetching and embedding user color from avatar metadata with authentication.
- Introduced `useUserBanner` hook for managing user banners stored in avatar metadata, including fetching and embedding functionality.
- Added `fetchAndExtractMetadata` utility to retrieve both color and banner from images.
- Implemented `CustomStatusDialog` component for users to set and clear custom status messages.
- Modified `SettingsTab` to include custom status functionality and improved user experience.
- Enhanced image metadata utilities to support banner extraction and embedding in PNG format.
- Updated sidebar settings to handle user profile changes more gracefully.
2026-03-12 10:58:38 +11:00

325 lines
9.1 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';
export type ImageMetadata = {
color?: string;
banner?: string;
};
/** 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);
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);
switch (format) {
case 'png':
return extractMetadataFromPNG(imageData);
// For other formats, extract color only for now
case 'webp':
case 'jpeg':
case 'gif': {
const color = extractColorFromImage(imageData);
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';
}
}
/**
* 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> {
try {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) return undefined;
const data = await response.arrayBuffer();
return extractColorFromImage(data);
} catch {
return undefined;
}
}
/**
* 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> {
try {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) return {};
const data = await response.arrayBuffer();
return extractMetadataFromImage(data);
} catch {
return {};
}
}