Refactor image metadata handling for user color embedding
- Updated useUserColor hook to support multiple image formats (PNG, WebP, JPEG, GIF) for color embedding and extraction. - Introduced unified image metadata utilities in imageMetadata.ts for detecting formats and handling color operations. - Added GIF metadata utilities in gifMetadata.ts for reading and writing user color. - Implemented JPEG metadata utilities in jpegMetadata.ts for embedding and extracting user color. - Created WebP metadata utilities in webpMetadata.ts for handling user color in WebP images. - Enhanced error handling and logging for unsupported formats and operations.
This commit is contained in:
@@ -3,10 +3,18 @@ import { MatrixClient } from 'matrix-js-sdk';
|
|||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
import { useMediaAuthentication } from './useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../utils/matrix';
|
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(
|
async function fetchAvatarData(
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
@@ -26,8 +34,8 @@ async function fetchAvatarData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to manage the user's chosen profile color, stored in avatar PNG metadata
|
* Hook to manage the user's chosen profile color, stored in avatar image metadata
|
||||||
* The color is embedded in the PNG tEXt chunk and syncs via the avatar
|
* 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
|
* @returns The current color, a setter function, and loading state
|
||||||
*/
|
*/
|
||||||
export function useUserColor(): [
|
export function useUserColor(): [
|
||||||
@@ -102,23 +110,32 @@ export function useUserColor(): [
|
|||||||
|
|
||||||
console.log('Original avatar size:', avatarData.byteLength, 'bytes');
|
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;
|
let newAvatarData: Uint8Array | null;
|
||||||
if (newColor) {
|
if (newColor) {
|
||||||
newAvatarData = embedColorInPNG(avatarData, newColor);
|
newAvatarData = embedColorInImage(avatarData, newColor);
|
||||||
} else {
|
} else {
|
||||||
newAvatarData = removeColorFromPNG(avatarData);
|
newAvatarData = removeColorFromImage(avatarData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!newAvatarData) {
|
if (!newAvatarData) {
|
||||||
console.error('Failed to modify avatar PNG metadata - not a valid PNG?');
|
console.error('Failed to modify avatar image metadata');
|
||||||
throw new Error('Failed to modify PNG metadata');
|
throw new Error('Failed to modify image metadata');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes');
|
console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes');
|
||||||
|
|
||||||
// Verify the color was embedded correctly before uploading
|
// Verify the color was embedded correctly before uploading
|
||||||
const verifyColor = extractColorFromPNG(newAvatarData);
|
const verifyColor = extractColorFromImage(newAvatarData);
|
||||||
console.log('Verification - embedded color:', verifyColor);
|
console.log('Verification - embedded color:', verifyColor);
|
||||||
|
|
||||||
if (newColor && verifyColor !== newColor) {
|
if (newColor && verifyColor !== newColor) {
|
||||||
@@ -126,11 +143,13 @@ export function useUserColor(): [
|
|||||||
throw new Error('Color embedding verification failed');
|
throw new Error('Color embedding verification failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload modified avatar
|
// Upload modified avatar with correct MIME type
|
||||||
const blob = new Blob([newAvatarData], { type: 'image/png' });
|
const mimeType = getMimeType(format);
|
||||||
|
const extension = getExtension(format);
|
||||||
|
const blob = new Blob([newAvatarData], { type: mimeType });
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
const uploadResponse = await mx.uploadContent(blob, {
|
||||||
name: 'avatar.png',
|
name: `avatar.${extension}`,
|
||||||
type: 'image/png',
|
type: mimeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Uploaded new avatar:', uploadResponse.content_uri);
|
console.log('Uploaded new avatar:', uploadResponse.content_uri);
|
||||||
|
|||||||
312
src/app/utils/gifMetadata.ts
Normal file
312
src/app/utils/gifMetadata.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
215
src/app/utils/imageMetadata.ts
Normal file
215
src/app/utils/imageMetadata.ts
Normal file
@@ -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<string | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
return extractColorFromImage(data);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
292
src/app/utils/jpegMetadata.ts
Normal file
292
src/app/utils/jpegMetadata.ts
Normal file
@@ -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 = '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>';
|
||||||
|
const PAARROT_XMP_END = '<?xpacket end="w"?>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about="" xmlns:paarrot="http://paarrot.app/ns/1.0/">
|
||||||
|
<paarrot:color>${color}</paarrot:color>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>
|
||||||
|
${PAARROT_XMP_END}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract color from XMP data
|
||||||
|
*/
|
||||||
|
function extractColorFromXMP(xmpData: string): string | undefined {
|
||||||
|
const match = xmpData.match(/<paarrot:color>([^<]+)<\/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;
|
||||||
|
}
|
||||||
289
src/app/utils/webpMetadata.ts
Normal file
289
src/app/utils/webpMetadata.ts
Normal file
@@ -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 `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about="" xmlns:paarrot="http://paarrot.app/ns/1.0/">
|
||||||
|
<paarrot:color>${color}</paarrot:color>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>
|
||||||
|
<?xpacket end="w"?>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract color from XMP data
|
||||||
|
*/
|
||||||
|
function extractColorFromXMP(xmpData: string): string | undefined {
|
||||||
|
// Look for paarrot:color tag
|
||||||
|
const match = xmpData.match(/<paarrot:color>([^<]+)<\/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<string | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
return extractColorFromWebP(data);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user