diff --git a/src/app/hooks/useUserColor.ts b/src/app/hooks/useUserColor.ts index e44d0ec..33cddc9 100644 --- a/src/app/hooks/useUserColor.ts +++ b/src/app/hooks/useUserColor.ts @@ -3,10 +3,18 @@ import { MatrixClient } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useMediaAuthentication } from './useMediaAuthentication'; import { mxcUrlToHttp } from '../utils/matrix'; -import { embedColorInPNG, extractColorFromPNG, fetchAndExtractColor, removeColorFromPNG } from '../utils/pngMetadata'; +import { + embedColorInImage, + extractColorFromImage, + removeColorFromImage, + fetchAndExtractColor, + detectImageFormat, + getMimeType, + getExtension, +} from '../utils/imageMetadata'; /** - * Fetches the user's current avatar as raw PNG data + * Fetches the user's current avatar as raw image data */ async function fetchAvatarData( mx: MatrixClient, @@ -26,8 +34,8 @@ async function fetchAvatarData( } /** - * Hook to manage the user's chosen profile color, stored in avatar PNG metadata - * The color is embedded in the PNG tEXt chunk and syncs via the avatar + * Hook to manage the user's chosen profile color, stored in avatar image metadata + * The color is embedded in PNG tEXt chunk or WebP XMP chunk and syncs via the avatar * @returns The current color, a setter function, and loading state */ export function useUserColor(): [ @@ -102,23 +110,32 @@ export function useUserColor(): [ console.log('Original avatar size:', avatarData.byteLength, 'bytes'); - // Modify PNG metadata + // Detect image format + const format = detectImageFormat(avatarData); + console.log('Detected image format:', format); + + if (format === 'unknown') { + console.error('Unsupported avatar image format - must be PNG, WebP, JPEG, or GIF'); + throw new Error('Unsupported image format'); + } + + // Modify image metadata let newAvatarData: Uint8Array | null; if (newColor) { - newAvatarData = embedColorInPNG(avatarData, newColor); + newAvatarData = embedColorInImage(avatarData, newColor); } else { - newAvatarData = removeColorFromPNG(avatarData); + newAvatarData = removeColorFromImage(avatarData); } if (!newAvatarData) { - console.error('Failed to modify avatar PNG metadata - not a valid PNG?'); - throw new Error('Failed to modify PNG metadata'); + console.error('Failed to modify avatar image metadata'); + throw new Error('Failed to modify image metadata'); } console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes'); // Verify the color was embedded correctly before uploading - const verifyColor = extractColorFromPNG(newAvatarData); + const verifyColor = extractColorFromImage(newAvatarData); console.log('Verification - embedded color:', verifyColor); if (newColor && verifyColor !== newColor) { @@ -126,11 +143,13 @@ export function useUserColor(): [ throw new Error('Color embedding verification failed'); } - // Upload modified avatar - const blob = new Blob([newAvatarData], { type: 'image/png' }); + // Upload modified avatar with correct MIME type + const mimeType = getMimeType(format); + const extension = getExtension(format); + const blob = new Blob([newAvatarData], { type: mimeType }); const uploadResponse = await mx.uploadContent(blob, { - name: 'avatar.png', - type: 'image/png', + name: `avatar.${extension}`, + type: mimeType, }); console.log('Uploaded new avatar:', uploadResponse.content_uri); diff --git a/src/app/utils/gifMetadata.ts b/src/app/utils/gifMetadata.ts new file mode 100644 index 0000000..0895272 --- /dev/null +++ b/src/app/utils/gifMetadata.ts @@ -0,0 +1,312 @@ +/** + * Utilities for reading and writing GIF metadata (Application Extension blocks) + * Used to embed user color in avatar images + * + * GIF structure: + * - Header: GIF87a or GIF89a (6 bytes) + * - Logical Screen Descriptor (7 bytes) + * - Optional Global Color Table + * - Extension blocks and image data + * - Trailer: 0x3B + * + * Application Extension format: + * - 0x21 (Extension Introducer) + * - 0xFF (Application Extension Label) + * - 0x0B (Block size - always 11) + * - 8 bytes: Application Identifier + * - 3 bytes: Application Authentication Code + * - Data sub-blocks (size byte + data, terminated by 0x00) + */ + +/* eslint-disable no-bitwise */ +/* eslint-disable no-plusplus */ +/* eslint-disable no-console */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-continue */ + +/** GIF signature bytes */ +const GIF87A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]); // "GIF87a" +const GIF89A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); // "GIF89a" + +/** Our custom application identifier (8 chars) */ +const PAARROT_APP_ID = 'PAARROT\0'; // 8 bytes, null-padded +const PAARROT_APP_ID_BYTES = new TextEncoder().encode(PAARROT_APP_ID); + +/** Application authentication code (3 bytes) */ +const PAARROT_AUTH_CODE = new Uint8Array([0x31, 0x2E, 0x30]); // "1.0" + +/** + * Check if data is a valid GIF file + */ +function isGIF(data: Uint8Array): boolean { + if (data.length < 6) return false; + + // Check GIF87a + let is87a = true; + for (let i = 0; i < 6; i++) { + if (data[i] !== GIF87A_SIGNATURE[i]) { + is87a = false; + break; + } + } + if (is87a) return true; + + // Check GIF89a + let is89a = true; + for (let i = 0; i < 6; i++) { + if (data[i] !== GIF89A_SIGNATURE[i]) { + is89a = false; + break; + } + } + return is89a; +} + +/** + * Get the size of the Global Color Table if present + */ +function getGlobalColorTableSize(data: Uint8Array): number { + // Logical Screen Descriptor starts at offset 6 + // Packed byte is at offset 10 + const packedByte = data[10]; + const hasGCT = (packedByte & 0x80) !== 0; + if (!hasGCT) return 0; + + const gctSize = packedByte & 0x07; + return 3 * (2 ** (gctSize + 1)); +} + +/** + * Find all Application Extension blocks + */ +function findAppExtensions(data: Uint8Array, startOffset: number): Array<{ + offset: number; + length: number; + appId: string; + authCode: Uint8Array; + dataBlocks: Uint8Array[]; +}> { + const extensions: Array<{ + offset: number; + length: number; + appId: string; + authCode: Uint8Array; + dataBlocks: Uint8Array[]; + }> = []; + + let offset = startOffset; + + while (offset < data.length - 1) { + // Look for Extension Introducer (0x21) + if (data[offset] === 0x21 && data[offset + 1] === 0xFF) { + // Application Extension + const blockSize = data[offset + 2]; + if (blockSize !== 0x0B) { + offset++; + continue; + } + + const appId = new TextDecoder().decode(data.slice(offset + 3, offset + 11)); + const authCode = data.slice(offset + 11, offset + 14); + + // Read data sub-blocks + let subOffset = offset + 14; + const dataBlocks: Uint8Array[] = []; + let totalLength = 14; // header length + + while (subOffset < data.length && data[subOffset] !== 0x00) { + const subBlockSize = data[subOffset]; + dataBlocks.push(data.slice(subOffset + 1, subOffset + 1 + subBlockSize)); + subOffset += 1 + subBlockSize; + totalLength += 1 + subBlockSize; + } + + // Add terminator + totalLength += 1; + + extensions.push({ + offset, + length: totalLength, + appId, + authCode, + dataBlocks, + }); + + offset = subOffset + 1; + } else if (data[offset] === 0x3B) { + // Trailer - end of GIF + break; + } else { + offset++; + } + } + + return extensions; +} + +/** + * Create a PAARROT Application Extension block with color data + */ +function createPaarrotExtension(color: string): Uint8Array { + const colorBytes = new TextEncoder().encode(color); + + // Calculate total size + // 0x21 + 0xFF + 0x0B + appId(8) + authCode(3) + sub-block(1 + colorBytes.length) + 0x00 + const totalSize = 2 + 1 + 8 + 3 + 1 + colorBytes.length + 1; + const extension = new Uint8Array(totalSize); + + let offset = 0; + extension[offset++] = 0x21; // Extension Introducer + extension[offset++] = 0xFF; // Application Extension Label + extension[offset++] = 0x0B; // Block size (always 11) + + // Application Identifier (8 bytes) + extension.set(PAARROT_APP_ID_BYTES, offset); + offset += 8; + + // Application Authentication Code (3 bytes) + extension.set(PAARROT_AUTH_CODE, offset); + offset += 3; + + // Data sub-block + extension[offset++] = colorBytes.length; + extension.set(colorBytes, offset); + offset += colorBytes.length; + + // Block terminator + extension[offset] = 0x00; + + return extension; +} + +/** + * Extract paarrot color from GIF image data + */ +export function extractColorFromGIF(imageData: ArrayBuffer | Uint8Array): string | undefined { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + console.log('[extractColorFromGIF] Input size:', data.length, 'bytes'); + + if (!isGIF(data)) { + console.error('[extractColorFromGIF] Not a GIF file'); + return undefined; + } + + const gctSize = getGlobalColorTableSize(data); + const startOffset = 13 + gctSize; // Header(6) + LSD(7) + GCT + + const extensions = findAppExtensions(data, startOffset); + console.log('[extractColorFromGIF] Found', extensions.length, 'application extensions'); + + for (const ext of extensions) { + if (ext.appId.startsWith('PAARROT')) { + if (ext.dataBlocks.length > 0) { + const color = new TextDecoder().decode(ext.dataBlocks[0]); + console.log('[extractColorFromGIF] Found color:', color); + return color; + } + } + } + + console.log('[extractColorFromGIF] No color found'); + return undefined; +} + +/** + * Embed paarrot color into GIF image data + */ +export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + console.log('[embedColorInGIF] Input size:', data.length, 'bytes'); + + if (!isGIF(data)) { + console.error('[embedColorInGIF] Not a GIF file'); + return null; + } + + const gctSize = getGlobalColorTableSize(data); + const insertOffset = 13 + gctSize; // After Header + LSD + GCT + + const extensions = findAppExtensions(data, insertOffset); + console.log('[embedColorInGIF] Found', extensions.length, 'application extensions'); + + // Find existing PAARROT extension + let existingExt: { offset: number; length: number } | null = null; + for (const ext of extensions) { + if (ext.appId.startsWith('PAARROT')) { + existingExt = { offset: ext.offset, length: ext.length }; + break; + } + } + + // Create new extension + const newExtension = createPaarrotExtension(color); + + console.log('[embedColorInGIF] Created extension, size:', newExtension.length, 'bytes'); + console.log('[embedColorInGIF] Color being embedded:', color); + + let newData: Uint8Array; + + if (existingExt) { + // Replace existing extension + console.log('[embedColorInGIF] Replacing existing extension at offset', existingExt.offset); + newData = new Uint8Array(data.length - existingExt.length + newExtension.length); + newData.set(data.slice(0, existingExt.offset), 0); + newData.set(newExtension, existingExt.offset); + newData.set( + data.slice(existingExt.offset + existingExt.length), + existingExt.offset + newExtension.length + ); + } else { + // Insert after GCT (before any other blocks) + console.log('[embedColorInGIF] Inserting new extension at offset', insertOffset); + newData = new Uint8Array(data.length + newExtension.length); + newData.set(data.slice(0, insertOffset), 0); + newData.set(newExtension, insertOffset); + newData.set(data.slice(insertOffset), insertOffset + newExtension.length); + } + + console.log('[embedColorInGIF] Output size:', newData.length, 'bytes'); + + return newData; +} + +/** + * Remove paarrot color from GIF image data + */ +export function removeColorFromGIF(imageData: ArrayBuffer | Uint8Array): Uint8Array | null { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + if (!isGIF(data)) { + return null; + } + + const gctSize = getGlobalColorTableSize(data); + const startOffset = 13 + gctSize; + + const extensions = findAppExtensions(data, startOffset); + + // Find existing PAARROT extension + let existingExt: { offset: number; length: number } | null = null; + for (const ext of extensions) { + if (ext.appId.startsWith('PAARROT')) { + existingExt = { offset: ext.offset, length: ext.length }; + break; + } + } + + if (!existingExt) { + return data; + } + + // Remove the extension + const newData = new Uint8Array(data.length - existingExt.length); + newData.set(data.slice(0, existingExt.offset), 0); + newData.set( + data.slice(existingExt.offset + existingExt.length), + existingExt.offset + ); + + return newData; +} diff --git a/src/app/utils/imageMetadata.ts b/src/app/utils/imageMetadata.ts new file mode 100644 index 0000000..98af550 --- /dev/null +++ b/src/app/utils/imageMetadata.ts @@ -0,0 +1,215 @@ +/** + * Unified image metadata utilities for embedding/extracting user color + * Supports PNG, WebP, JPEG, and GIF formats + */ + +/* eslint-disable no-plusplus */ +/* eslint-disable no-console */ + +import { embedColorInPNG, extractColorFromPNG, removeColorFromPNG } from './pngMetadata'; +import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './webpMetadata'; +import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata'; +import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata'; + +/** 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; + } +} + +/** + * 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; + } +} + +/** + * 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 PNG and WebP) + * @param url - HTTP URL to fetch the image from + * @returns The color string if found, or undefined + */ +export async function fetchAndExtractColor(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) return undefined; + + const data = await response.arrayBuffer(); + return extractColorFromImage(data); + } catch { + return undefined; + } +} diff --git a/src/app/utils/jpegMetadata.ts b/src/app/utils/jpegMetadata.ts new file mode 100644 index 0000000..a5e642d --- /dev/null +++ b/src/app/utils/jpegMetadata.ts @@ -0,0 +1,292 @@ +/** + * Utilities for reading and writing JPEG metadata (XMP/APP1 segments) + * Used to embed user color in avatar images + * + * JPEG structure: + * - Starts with FFD8 (SOI marker) + * - Contains segments: FF + marker type + length (2 bytes BE) + data + * - APP1 (FFE1) is used for EXIF/XMP data + * - Ends with FFD9 (EOI marker) + */ + +/* eslint-disable no-bitwise */ +/* eslint-disable no-plusplus */ +/* eslint-disable no-console */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-continue */ + +/** JPEG signature bytes */ +const JPEG_SOI = new Uint8Array([0xFF, 0xD8]); // Start of Image + +/** XMP namespace identifier in APP1 segment */ +const XMP_NAMESPACE = 'http://ns.adobe.com/xap/1.0/\0'; +const XMP_NAMESPACE_BYTES = new TextEncoder().encode(XMP_NAMESPACE); + +/** Custom XMP namespace for paarrot */ +const PAARROT_XMP_START = ''; +const PAARROT_XMP_END = ''; + +/** + * Check if data is a valid JPEG file + */ +function isJPEG(data: Uint8Array): boolean { + if (data.length < 2) return false; + return data[0] === 0xFF && data[1] === 0xD8; +} + +/** + * Read a 16-bit big-endian integer from a buffer + */ +function readUint16BE(data: Uint8Array, offset: number): number { + return (data[offset] << 8) | data[offset + 1]; +} + +/** + * Write a 16-bit big-endian integer + */ +function writeUint16BE(value: number): Uint8Array { + return new Uint8Array([(value >> 8) & 0xff, value & 0xff]); +} + +/** + * Create XMP data containing the color value + */ +function createXMPData(color: string): string { + return `${PAARROT_XMP_START} + + + + ${color} + + + +${PAARROT_XMP_END}`; +} + +/** + * Extract color from XMP data + */ +function extractColorFromXMP(xmpData: string): string | undefined { + const match = xmpData.match(/([^<]+)<\/paarrot:color>/); + if (match) { + return match[1]; + } + return undefined; +} + +/** + * Parse JPEG segments + */ +function parseSegments(data: Uint8Array): Array<{ marker: number; data: Uint8Array; offset: number; length: number }> { + const segments: Array<{ marker: number; data: Uint8Array; offset: number; length: number }> = []; + let offset = 2; // Skip SOI marker + + while (offset < data.length - 1) { + // Find next marker (0xFF followed by non-zero, non-0xFF byte) + if (data[offset] !== 0xFF) { + offset++; + continue; + } + + const marker = data[offset + 1]; + + // Skip padding bytes (0xFF) + if (marker === 0xFF || marker === 0x00) { + offset++; + continue; + } + + // EOI (End of Image) or SOS (Start of Scan) - stop parsing + if (marker === 0xD9 || marker === 0xDA) { + break; + } + + // Markers without length field (RST0-RST7, SOI, EOI) + if ((marker >= 0xD0 && marker <= 0xD7) || marker === 0xD8 || marker === 0xD9) { + offset += 2; + continue; + } + + // Read segment length (includes the 2 length bytes) + const segmentLength = readUint16BE(data, offset + 2); + const segmentData = data.slice(offset + 4, offset + 2 + segmentLength); + + segments.push({ + marker, + data: segmentData, + offset, + length: 2 + segmentLength, // marker (2) + length field + data + }); + + offset += 2 + segmentLength; + } + + return segments; +} + +/** + * Check if APP1 segment contains paarrot XMP data + */ +function isPaarrotXMP(segmentData: Uint8Array): boolean { + // Check for XMP namespace + if (segmentData.length < XMP_NAMESPACE_BYTES.length) return false; + + for (let i = 0; i < XMP_NAMESPACE_BYTES.length; i++) { + if (segmentData[i] !== XMP_NAMESPACE_BYTES[i]) return false; + } + + const xmpString = new TextDecoder().decode(segmentData.slice(XMP_NAMESPACE_BYTES.length)); + return xmpString.includes('paarrot:color') || xmpString.includes('paarrot.app'); +} + +/** + * Extract paarrot color from JPEG image data + */ +export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): string | undefined { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + console.log('[extractColorFromJPEG] Input size:', data.length, 'bytes'); + + if (!isJPEG(data)) { + console.error('[extractColorFromJPEG] Not a JPEG file'); + return undefined; + } + + const segments = parseSegments(data); + console.log('[extractColorFromJPEG] Found', segments.length, 'segments'); + + for (const segment of segments) { + // APP1 marker (0xE1) is used for XMP + if (segment.marker === 0xE1) { + // Check for XMP namespace + if (segment.data.length >= XMP_NAMESPACE_BYTES.length) { + let isXMP = true; + for (let i = 0; i < XMP_NAMESPACE_BYTES.length; i++) { + if (segment.data[i] !== XMP_NAMESPACE_BYTES[i]) { + isXMP = false; + break; + } + } + + if (isXMP) { + const xmpString = new TextDecoder().decode(segment.data.slice(XMP_NAMESPACE_BYTES.length)); + const color = extractColorFromXMP(xmpString); + if (color) { + console.log('[extractColorFromJPEG] Found color:', color); + return color; + } + } + } + } + } + + console.log('[extractColorFromJPEG] No color found'); + return undefined; +} + +/** + * Embed paarrot color into JPEG image data + */ +export function embedColorInJPEG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + console.log('[embedColorInJPEG] Input size:', data.length, 'bytes'); + + if (!isJPEG(data)) { + console.error('[embedColorInJPEG] Not a JPEG file'); + return null; + } + + const segments = parseSegments(data); + console.log('[embedColorInJPEG] Found', segments.length, 'segments'); + + // Find existing paarrot XMP segment + let existingSegment: { offset: number; length: number } | null = null; + + for (const segment of segments) { + if (segment.marker === 0xE1 && isPaarrotXMP(segment.data)) { + existingSegment = { offset: segment.offset, length: segment.length }; + break; + } + } + + // Create XMP APP1 segment + const xmpData = createXMPData(color); + const xmpBytes = new TextEncoder().encode(xmpData); + const segmentData = new Uint8Array(XMP_NAMESPACE_BYTES.length + xmpBytes.length); + segmentData.set(XMP_NAMESPACE_BYTES, 0); + segmentData.set(xmpBytes, XMP_NAMESPACE_BYTES.length); + + // APP1 segment: FF E1 + length (2 bytes) + data + const segmentLength = 2 + segmentData.length; // length field + data + const app1Segment = new Uint8Array(4 + segmentData.length); + app1Segment[0] = 0xFF; + app1Segment[1] = 0xE1; + app1Segment.set(writeUint16BE(segmentLength), 2); + app1Segment.set(segmentData, 4); + + console.log('[embedColorInJPEG] Created APP1 segment, size:', app1Segment.length, 'bytes'); + console.log('[embedColorInJPEG] Color being embedded:', color); + + let newData: Uint8Array; + + if (existingSegment) { + // Replace existing segment + console.log('[embedColorInJPEG] Replacing existing segment at offset', existingSegment.offset); + newData = new Uint8Array(data.length - existingSegment.length + app1Segment.length); + newData.set(data.slice(0, existingSegment.offset), 0); + newData.set(app1Segment, existingSegment.offset); + newData.set( + data.slice(existingSegment.offset + existingSegment.length), + existingSegment.offset + app1Segment.length + ); + } else { + // Insert after SOI marker (at offset 2) + console.log('[embedColorInJPEG] Inserting new segment after SOI'); + newData = new Uint8Array(data.length + app1Segment.length); + newData.set(JPEG_SOI, 0); + newData.set(app1Segment, 2); + newData.set(data.slice(2), 2 + app1Segment.length); + } + + console.log('[embedColorInJPEG] Output size:', newData.length, 'bytes'); + + return newData; +} + +/** + * Remove paarrot color from JPEG image data + */ +export function removeColorFromJPEG(imageData: ArrayBuffer | Uint8Array): Uint8Array | null { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + if (!isJPEG(data)) { + return null; + } + + const segments = parseSegments(data); + + // Find existing paarrot XMP segment + let existingSegment: { offset: number; length: number } | null = null; + + for (const segment of segments) { + if (segment.marker === 0xE1 && isPaarrotXMP(segment.data)) { + existingSegment = { offset: segment.offset, length: segment.length }; + break; + } + } + + if (!existingSegment) { + return data; + } + + // Remove the segment + const newData = new Uint8Array(data.length - existingSegment.length); + newData.set(data.slice(0, existingSegment.offset), 0); + newData.set( + data.slice(existingSegment.offset + existingSegment.length), + existingSegment.offset + ); + + return newData; +} diff --git a/src/app/utils/webpMetadata.ts b/src/app/utils/webpMetadata.ts new file mode 100644 index 0000000..bd9b25b --- /dev/null +++ b/src/app/utils/webpMetadata.ts @@ -0,0 +1,289 @@ +/** + * Utilities for reading and writing WebP metadata (custom XMP chunks) + * Used to embed user color in avatar images + * + * WebP uses RIFF container format: + * - File starts with "RIFF" (4 bytes) + file size (4 bytes LE) + "WEBP" (4 bytes) + * - Chunks: chunk ID (4 bytes) + chunk size (4 bytes LE) + chunk data (+ padding byte if odd size) + */ + +/* eslint-disable no-bitwise */ +/* eslint-disable no-plusplus */ +/* eslint-disable no-console */ +/* eslint-disable no-restricted-syntax */ + +/** RIFF signature bytes */ +const RIFF_SIGNATURE = new Uint8Array([0x52, 0x49, 0x46, 0x46]); // "RIFF" +const WEBP_SIGNATURE = new Uint8Array([0x57, 0x45, 0x42, 0x50]); // "WEBP" + +/** Key used to store user color in metadata */ +export const PAARROT_COLOR_KEY = 'paarrot:color'; + +/** Custom chunk ID for paarrot color - using XMP_ as it's a standard metadata chunk */ +const XMP_CHUNK_ID = new Uint8Array([0x58, 0x4D, 0x50, 0x20]); // "XMP " + +/** + * Read a 32-bit little-endian integer from a buffer + */ +function readUint32LE(data: Uint8Array, offset: number): number { + return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24); +} + +/** + * Write a 32-bit little-endian integer to a buffer + */ +function writeUint32LE(value: number): Uint8Array { + return new Uint8Array([ + value & 0xff, + (value >> 8) & 0xff, + (value >> 16) & 0xff, + (value >> 24) & 0xff, + ]); +} + +/** + * Check if data is a valid WebP file + */ +function isWebP(data: Uint8Array): boolean { + if (data.length < 12) return false; + + // Check RIFF signature + for (let i = 0; i < 4; i++) { + if (data[i] !== RIFF_SIGNATURE[i]) return false; + } + + // Check WEBP signature at offset 8 + for (let i = 0; i < 4; i++) { + if (data[8 + i] !== WEBP_SIGNATURE[i]) return false; + } + + return true; +} + +/** + * Create XMP data containing the color value + */ +function createXMPData(color: string): string { + return ` + + + + ${color} + + + +`; +} + +/** + * Extract color from XMP data + */ +function extractColorFromXMP(xmpData: string): string | undefined { + // Look for paarrot:color tag + const match = xmpData.match(/([^<]+)<\/paarrot:color>/); + if (match) { + return match[1]; + } + return undefined; +} + +/** + * Parse WebP chunks from image data + */ +function parseChunks(data: Uint8Array): Array<{ id: string; data: Uint8Array; offset: number; length: number }> { + const chunks: Array<{ id: string; data: Uint8Array; offset: number; length: number }> = []; + let offset = 12; // Skip RIFF header (4) + size (4) + WEBP (4) + + while (offset + 8 <= data.length) { + const id = new TextDecoder().decode(data.slice(offset, offset + 4)); + const size = readUint32LE(data, offset + 4); + const chunkData = data.slice(offset + 8, offset + 8 + size); + + // Chunk length includes padding byte if size is odd + const paddedSize = size + (size % 2); + + chunks.push({ + id, + data: chunkData, + offset, + length: 8 + paddedSize, // header (8) + data + optional padding + }); + + offset += 8 + paddedSize; + } + + return chunks; +} + +/** + * Extract paarrot color from WebP image data + * @param imageData - Raw WebP image data as ArrayBuffer or Uint8Array + * @returns The color string if found, or undefined + */ +export function extractColorFromWebP(imageData: ArrayBuffer | Uint8Array): string | undefined { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + console.log('[extractColorFromWebP] Input size:', data.length, 'bytes'); + + if (!isWebP(data)) { + console.error('[extractColorFromWebP] Not a WebP file'); + return undefined; + } + + const chunks = parseChunks(data); + console.log('[extractColorFromWebP] Found', chunks.length, 'chunks:', chunks.map(c => c.id).join(', ')); + + for (const chunk of chunks) { + if (chunk.id === 'XMP ') { + const xmpString = new TextDecoder().decode(chunk.data); + const color = extractColorFromXMP(xmpString); + if (color) { + console.log('[extractColorFromWebP] Found color:', color); + return color; + } + } + } + + console.log('[extractColorFromWebP] No color found'); + return undefined; +} + +/** + * Embed paarrot color into WebP image data + * @param imageData - Raw WebP image data as ArrayBuffer or Uint8Array + * @param color - The color string to embed (e.g., '#ff5500') + * @returns New WebP data with embedded color, or null if not a valid WebP + */ +export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + console.log('[embedColorInWebP] Input size:', data.length, 'bytes'); + + if (!isWebP(data)) { + console.error('[embedColorInWebP] Not a WebP file'); + return null; + } + + const chunks = parseChunks(data); + console.log('[embedColorInWebP] Found', chunks.length, 'chunks:', chunks.map(c => c.id).join(', ')); + + // Find existing XMP chunk with our color + let existingXMPChunk: { offset: number; length: number } | null = null; + + for (const chunk of chunks) { + if (chunk.id === 'XMP ') { + const xmpString = new TextDecoder().decode(chunk.data); + if (xmpString.includes('paarrot:color') || xmpString.includes('paarrot.app')) { + existingXMPChunk = { offset: chunk.offset, length: chunk.length }; + break; + } + } + } + + // Create XMP chunk with color + const xmpData = createXMPData(color); + const xmpBytes = new TextEncoder().encode(xmpData); + const xmpPaddedSize = xmpBytes.length + (xmpBytes.length % 2); // Add padding byte if odd + + // XMP chunk: ID (4) + size (4) + data + optional padding + const xmpChunk = new Uint8Array(8 + xmpPaddedSize); + xmpChunk.set(XMP_CHUNK_ID, 0); + xmpChunk.set(writeUint32LE(xmpBytes.length), 4); + xmpChunk.set(xmpBytes, 8); + // Padding byte is already 0 from Uint8Array initialization + + console.log('[embedColorInWebP] Created XMP chunk, size:', xmpChunk.length, 'bytes'); + console.log('[embedColorInWebP] Color being embedded:', color); + + let newData: Uint8Array; + + if (existingXMPChunk) { + // Replace existing chunk + console.log('[embedColorInWebP] Replacing existing XMP chunk at offset', existingXMPChunk.offset); + newData = new Uint8Array(data.length - existingXMPChunk.length + xmpChunk.length); + newData.set(data.slice(0, existingXMPChunk.offset), 0); + newData.set(xmpChunk, existingXMPChunk.offset); + newData.set( + data.slice(existingXMPChunk.offset + existingXMPChunk.length), + existingXMPChunk.offset + xmpChunk.length + ); + } else { + // Insert new chunk at end (before any trailing data) + console.log('[embedColorInWebP] Inserting new XMP chunk at end'); + newData = new Uint8Array(data.length + xmpChunk.length); + newData.set(data, 0); + newData.set(xmpChunk, data.length); + } + + // Update RIFF file size (at offset 4) + const newFileSize = newData.length - 8; // RIFF size excludes first 8 bytes + newData.set(writeUint32LE(newFileSize), 4); + + console.log('[embedColorInWebP] Output size:', newData.length, 'bytes'); + + return newData; +} + +/** + * Remove paarrot color from WebP image data + * @param imageData - Raw WebP image data as ArrayBuffer or Uint8Array + * @returns New WebP data without the color chunk, or null if not a valid WebP + */ +export function removeColorFromWebP(imageData: ArrayBuffer | Uint8Array): Uint8Array | null { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + if (!isWebP(data)) { + return null; + } + + const chunks = parseChunks(data); + + // Find existing XMP chunk with our color + let existingXMPChunk: { offset: number; length: number } | null = null; + + for (const chunk of chunks) { + if (chunk.id === 'XMP ') { + const xmpString = new TextDecoder().decode(chunk.data); + if (xmpString.includes('paarrot:color') || xmpString.includes('paarrot.app')) { + existingXMPChunk = { offset: chunk.offset, length: chunk.length }; + break; + } + } + } + + if (!existingXMPChunk) { + // No color to remove + return data; + } + + // Build new WebP data without the XMP chunk + const newData = new Uint8Array(data.length - existingXMPChunk.length); + newData.set(data.slice(0, existingXMPChunk.offset), 0); + newData.set( + data.slice(existingXMPChunk.offset + existingXMPChunk.length), + existingXMPChunk.offset + ); + + // Update RIFF file size + const newFileSize = newData.length - 8; + newData.set(writeUint32LE(newFileSize), 4); + + return newData; +} + +/** + * Fetch and extract color from a WebP URL + * @param url - HTTP URL to fetch the WebP from + * @returns The color string if found, or undefined + */ +export async function fetchAndExtractColorFromWebP(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) return undefined; + + const data = await response.arrayBuffer(); + return extractColorFromWebP(data); + } catch { + return undefined; + } +}