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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user