/** * @monogrid/gainmap-js v3.4.0 * With ❤️, by MONOGRID */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.libultrahdr = {})); })(this, (function (exports) { 'use strict'; /** * JPEG marker constants * Based on JPEG specification and libultrahdr implementation */ /** * JPEG marker prefix - all markers start with this byte */ const MARKER_PREFIX = 0xff; /** * JPEG markers */ const MARKERS = { /** Start of Image */ SOI: 0xd8, /** Application segment 1 (EXIF/XMP) */ APP1: 0xe1, /** Application segment 2 (ICC/MPF) */ APP2: 0xe2, /** Start of Scan */ SOS: 0xda}; /** * XMP namespace identifier for APP1 marker */ const XMP_NAMESPACE = 'http://ns.adobe.com/xap/1.0/\0'; /** * Multi-Picture Format (MPF) generator * Based on CIPA DC-007 specification and libultrahdr multipictureformat.cpp * * MPF is used to embed multiple images in a single JPEG file */ /** * MPF constants from the specification */ const MPF_CONSTANTS = { /** MPF signature "MPF\0" */ SIGNATURE: new Uint8Array([0x4d, 0x50, 0x46, 0x00]), /** Big endian marker "MM" */ BIG_ENDIAN: new Uint8Array([0x4d, 0x4d]), /** TIFF magic number */ TIFF_MAGIC: 0x002a, /** Number of pictures in MPF */ NUM_PICTURES: 2, /** Number of tags to serialize */ TAG_COUNT: 3, /** Size of each tag in bytes */ TAG_SIZE: 12, /** Size of each MP entry in bytes */ MP_ENTRY_SIZE: 16 }; /** * MPF tag identifiers */ const MPF_TAGS = { /** MPF version tag */ VERSION: 0xb000, /** Number of images tag */ NUMBER_OF_IMAGES: 0xb001, /** MP entry tag */ MP_ENTRY: 0xb002 }; /** * MPF tag types */ const MPF_TAG_TYPES = { /** Undefined type */ UNDEFINED: 7, /** Unsigned long type */ ULONG: 4 }; /** * MP entry attributes */ const MP_ENTRY_ATTRIBUTES = { /** JPEG format */ FORMAT_JPEG: 0x00000000, /** Primary image type */ TYPE_PRIMARY: 0x20000000 }; /** * MPF version string */ const MPF_VERSION = new Uint8Array([0x30, 0x31, 0x30, 0x30]); // "0100" /** * Calculate the total size of the MPF structure */ function calculateMpfSize() { return (MPF_CONSTANTS.SIGNATURE.length + // Signature "MPF\0" 2 + // Endianness marker 2 + // TIFF magic number 4 + // Index IFD Offset 2 + // Tag count MPF_CONSTANTS.TAG_COUNT * MPF_CONSTANTS.TAG_SIZE + // Tags 4 + // Attribute IFD offset MPF_CONSTANTS.NUM_PICTURES * MPF_CONSTANTS.MP_ENTRY_SIZE // MP Entries ); } /** * Generate MPF (Multi-Picture Format) data structure * * @param primaryImageSize - Size of the primary image in bytes * @param primaryImageOffset - Offset of the primary image (typically 0 for FII - First Individual Image) * @param secondaryImageSize - Size of the secondary (gain map) image in bytes * @param secondaryImageOffset - Offset of the secondary image from the MP Endian field * @returns Uint8Array containing the MPF data */ function generateMpf(primaryImageSize, primaryImageOffset, secondaryImageSize, secondaryImageOffset) { const mpfSize = calculateMpfSize(); const buffer = new ArrayBuffer(mpfSize); const view = new DataView(buffer); const uint8View = new Uint8Array(buffer); let pos = 0; // Write MPF signature "MPF\0" uint8View.set(MPF_CONSTANTS.SIGNATURE, pos); pos += MPF_CONSTANTS.SIGNATURE.length; // Write endianness marker (big endian "MM") // Using big endian to match the C++ implementation's USE_BIG_ENDIAN uint8View.set(MPF_CONSTANTS.BIG_ENDIAN, pos); const bigEndian = false; // DataView uses little endian by default, so we need to flip this pos += 2; // Write TIFF magic number (0x002A) view.setUint16(pos, MPF_CONSTANTS.TIFF_MAGIC, bigEndian); pos += 2; // Set the Index IFD offset // This offset is from the start of the TIFF header (the endianness marker) // After: endianness (2) + magic (2) + this offset field (4) = 8 bytes const indexIfdOffset = 8; view.setUint32(pos, indexIfdOffset, bigEndian); pos += 4; // Write tag count (3 tags: version, number of images, MP entries) view.setUint16(pos, MPF_CONSTANTS.TAG_COUNT, bigEndian); pos += 2; // Write version tag view.setUint16(pos, MPF_TAGS.VERSION, bigEndian); pos += 2; view.setUint16(pos, MPF_TAG_TYPES.UNDEFINED, bigEndian); pos += 2; view.setUint32(pos, MPF_VERSION.length, bigEndian); pos += 4; uint8View.set(MPF_VERSION, pos); pos += 4; // Version is 4 bytes, embedded in the tag // Write number of images tag view.setUint16(pos, MPF_TAGS.NUMBER_OF_IMAGES, bigEndian); pos += 2; view.setUint16(pos, MPF_TAG_TYPES.ULONG, bigEndian); pos += 2; view.setUint32(pos, 1, bigEndian); // Count = 1 pos += 4; view.setUint32(pos, MPF_CONSTANTS.NUM_PICTURES, bigEndian); pos += 4; // Write MP entry tag view.setUint16(pos, MPF_TAGS.MP_ENTRY, bigEndian); pos += 2; view.setUint16(pos, MPF_TAG_TYPES.UNDEFINED, bigEndian); pos += 2; view.setUint32(pos, MPF_CONSTANTS.MP_ENTRY_SIZE * MPF_CONSTANTS.NUM_PICTURES, bigEndian); pos += 4; // Calculate MP entry offset // The offset is from the start of the MP Endian field (after signature) // Current position is at the value field of MP Entry tag const mpEntryOffset = pos - MPF_CONSTANTS.SIGNATURE.length + 4 + 4; view.setUint32(pos, mpEntryOffset, bigEndian); pos += 4; // Write attribute IFD offset (0 = none) view.setUint32(pos, 0, bigEndian); pos += 4; // Write MP entries for primary image // Attribute format: JPEG (0x00000000) | Type: Primary (0x20000000) view.setUint32(pos, MP_ENTRY_ATTRIBUTES.FORMAT_JPEG | MP_ENTRY_ATTRIBUTES.TYPE_PRIMARY, bigEndian); pos += 4; view.setUint32(pos, primaryImageSize, bigEndian); pos += 4; view.setUint32(pos, primaryImageOffset, bigEndian); pos += 4; view.setUint16(pos, 0, bigEndian); // Dependent image 1 pos += 2; view.setUint16(pos, 0, bigEndian); // Dependent image 2 pos += 2; // Write MP entries for secondary image (gain map) // Attribute format: JPEG only (no type flag) view.setUint32(pos, MP_ENTRY_ATTRIBUTES.FORMAT_JPEG, bigEndian); pos += 4; view.setUint32(pos, secondaryImageSize, bigEndian); pos += 4; view.setUint32(pos, secondaryImageOffset, bigEndian); pos += 4; view.setUint16(pos, 0, bigEndian); // Dependent image 1 pos += 2; view.setUint16(pos, 0, bigEndian); // Dependent image 2 // pos += 2 return uint8View; } /** * XMP metadata generator for gain map images * Based on libultrahdr jpegrutils.cpp implementation */ /** * Item semantic types */ const ITEM_SEMANTIC = { PRIMARY: 'Primary', GAIN_MAP: 'GainMap' }; /** * MIME type for JPEG images */ const MIME_IMAGE_JPEG = 'image/jpeg'; /** * Escape XML special characters */ function escapeXml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Generate XMP metadata for the primary image * * This XMP contains: * - Container directory with references to primary and gain map images * - Gain map version * - Item metadata for both images * * @param secondaryImageLength - Length of the secondary (gain map) JPEG in bytes * @param metadata - Gain map metadata * @returns XMP packet as string */ function generateXmpForPrimaryImage(secondaryImageLength, metadata) { const lines = []; // XMP packet header lines.push(''); lines.push(''); lines.push(' '); lines.push(' '); // Container directory lines.push(' '); lines.push(' '); // Primary image item lines.push(' '); lines.push(' `); lines.push(' '); // Gain map image item lines.push(' '); lines.push(' `); lines.push(' '); lines.push(' '); lines.push(' '); lines.push(' '); lines.push(' '); lines.push(''); lines.push(''); return lines.join('\n'); } /** * Generate XMP metadata for the secondary (gain map) image * * This XMP contains all the gain map parameters: * - Version * - Gain map min/max * - Gamma * - Offset SDR/HDR * - HDR capacity min/max * - Base rendition flag * * @param metadata - Gain map metadata * @returns XMP packet as string */ function generateXmpForSecondaryImage(metadata) { const lines = []; // hdrCapacityMin/Max are already in log2 space (from GainMapEncoderMaterial) // No conversion needed const hdrCapacityMin = metadata.hdrCapacityMin; const hdrCapacityMax = metadata.hdrCapacityMax; // Handle array values - take average if array, or use single value const getAverage = (val) => { if (Array.isArray(val)) { return val.reduce((sum, v) => sum + v, 0) / val.length; } return val; }; const gainMapMinAvg = getAverage(metadata.gainMapMin); const gainMapMaxAvg = getAverage(metadata.gainMapMax); const gammaAvg = getAverage(metadata.gamma); const offsetSdrAvg = getAverage(metadata.offsetSdr); const offsetHdrAvg = getAverage(metadata.offsetHdr); // XMP packet header lines.push(''); lines.push(''); lines.push(' '); lines.push(' '); lines.push(' '); lines.push(''); lines.push(''); return lines.join('\n'); } /** * JPEG assembler for creating JPEG-R (JPEG with gain map) files * Based on libultrahdr jpegr.cpp implementation */ /** * Extract EXIF data from a JPEG if present * * @param jpegData - JPEG file data * @returns Object containing EXIF data and position, or null if not found */ function extractExif(jpegData) { const view = new DataView(jpegData.buffer, jpegData.byteOffset, jpegData.byteLength); // Check for JPEG SOI marker if (view.getUint8(0) !== MARKER_PREFIX || view.getUint8(1) !== MARKERS.SOI) { return null; } let offset = 2; const EXIF_SIGNATURE = 'Exif\0\0'; while (offset < jpegData.length - 1) { // Check for marker prefix if (view.getUint8(offset) !== MARKER_PREFIX) { break; } const marker = view.getUint8(offset + 1); // Check for SOS (Start of Scan) - end of metadata if (marker === MARKERS.SOS) { break; } // Check for APP1 marker (EXIF/XMP) if (marker === MARKERS.APP1) { const length = view.getUint16(offset + 2, false); // Big endian const dataStart = offset + 4; // Check if this APP1 contains EXIF let isExif = true; for (let i = 0; i < EXIF_SIGNATURE.length; i++) { if (dataStart + i >= jpegData.length || jpegData[dataStart + i] !== EXIF_SIGNATURE.charCodeAt(i)) { isExif = false; break; } } if (isExif) { // Found EXIF data const exifSize = length - 2; // Length includes the 2-byte length field itself const exifData = jpegData.slice(dataStart, dataStart + exifSize); return { data: exifData, pos: offset, size: length + 2 // Include marker (2 bytes) + length (2 bytes) + data }; } } // Move to next marker const length = view.getUint16(offset + 2, false); offset += 2 + length; } return null; } /** * Copy JPEG data without EXIF segment * * @param jpegData - Original JPEG data * @param exifPos - Position of EXIF segment * @param exifSize - Size of EXIF segment (including marker and length) * @returns JPEG data without EXIF */ function copyJpegWithoutExif(jpegData, exifPos, exifSize) { const newSize = jpegData.length - exifSize; const result = new Uint8Array(newSize); // Copy data before EXIF result.set(jpegData.subarray(0, exifPos), 0); // Copy data after EXIF result.set(jpegData.subarray(exifPos + exifSize), exifPos); return result; } /** * Write a JPEG marker and its data * * @param buffer - Target buffer * @param pos - Current position in buffer * @param marker - Marker type (without 0xFF prefix) * @param data - Data to write after marker * @returns New position after writing */ function writeMarker(buffer, pos, marker, data) { const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); // Write marker view.setUint8(pos++, MARKER_PREFIX); view.setUint8(pos++, marker); // Write data if present if (data && data.length > 0) { // Write length (big endian, includes the 2-byte length field itself) const length = data.length + 2; view.setUint16(pos, length, false); pos += 2; // Write data buffer.set(data, pos); pos += data.length; } return pos; } /** * Assemble a JPEG-R file (JPEG with embedded gain map) * * The structure is: * 1. Primary image: * - SOI * - APP1 (EXIF if present) * - APP1 (XMP with gain map metadata) * - APP2 (ICC profile if present) * - APP2 (MPF data) * - Rest of primary JPEG data * 2. Secondary image (gain map): * - SOI * - APP1 (XMP with gain map parameters) * - Rest of gain map JPEG data * * @param options - Assembly options * @returns Complete JPEG-R file as Uint8Array */ function assembleJpegWithGainMap(options) { const { sdr, gainMap, metadata, exif: externalExif, icc } = options; // Validate input if (sdr.mimeType !== 'image/jpeg') { throw new Error('SDR image must be JPEG format'); } if (gainMap.mimeType !== 'image/jpeg') { throw new Error('Gain map image must be JPEG format'); } // Check for EXIF in primary image const exifFromJpeg = extractExif(sdr.data); if (exifFromJpeg && externalExif) { throw new Error('Primary image already contains EXIF data, cannot add external EXIF'); } // Prepare primary JPEG (remove embedded EXIF if present) let primaryJpegData = sdr.data; let exifData = externalExif; if (exifFromJpeg) { primaryJpegData = copyJpegWithoutExif(sdr.data, exifFromJpeg.pos, exifFromJpeg.size); exifData = exifFromJpeg.data; } // Generate XMP for secondary image const xmpSecondary = generateXmpForSecondaryImage(metadata); const xmpSecondaryBytes = new TextEncoder().encode(xmpSecondary); // Calculate secondary image size // 2 bytes SOI + 2 bytes marker + 2 bytes length field + namespace + XMP data + gain map data (without SOI) const namespaceBytes = new TextEncoder().encode(XMP_NAMESPACE); const secondaryImageSize = 2 + 2 + 2 + namespaceBytes.length + xmpSecondaryBytes.length + (gainMap.data.length - 2); // Generate XMP for primary image const xmpPrimary = generateXmpForPrimaryImage(secondaryImageSize, metadata); const xmpPrimaryBytes = new TextEncoder().encode(xmpPrimary); const xmpPrimaryData = new Uint8Array(namespaceBytes.length + xmpPrimaryBytes.length); xmpPrimaryData.set(namespaceBytes, 0); xmpPrimaryData.set(xmpPrimaryBytes, namespaceBytes.length); // Calculate MPF size and offset const mpfLength = calculateMpfSize(); // Calculate total size let totalSize = 2; // SOI if (exifData) totalSize += 2 + 2 + exifData.length; // APP1 + length + EXIF totalSize += 2 + 2 + xmpPrimaryData.length; // APP1 + length + XMP primary if (icc) totalSize += 2 + 2 + icc.length; // APP2 + length + ICC totalSize += 2 + 2 + mpfLength; // APP2 + length + MPF totalSize += primaryJpegData.length - 2; // Primary JPEG without SOI totalSize += secondaryImageSize; // Secondary image // Calculate offsets for MPF const primaryImageSize = totalSize - secondaryImageSize; // Offset is from MP Endian field (after APP2 marker + length + MPF signature) const secondaryImageOffset = primaryImageSize - (2 + // SOI (exifData ? 2 + 2 + exifData.length : 0) + 2 + 2 + xmpPrimaryData.length + (icc ? 2 + 2 + icc.length : 0) + 2 + 2 + 4 // APP2 marker + length + MPF signature ); // Generate MPF data const mpfDataActual = generateMpf(primaryImageSize, 0, secondaryImageSize, secondaryImageOffset); // Allocate output buffer const output = new Uint8Array(totalSize); let pos = 0; // === PRIMARY IMAGE === // Write SOI pos = writeMarker(output, pos, MARKERS.SOI); // Write EXIF if present if (exifData) { pos = writeMarker(output, pos, MARKERS.APP1, exifData); } // Write XMP for primary image (already created above) pos = writeMarker(output, pos, MARKERS.APP1, xmpPrimaryData); // Write ICC profile if present if (icc) { pos = writeMarker(output, pos, MARKERS.APP2, icc); } // Write MPF pos = writeMarker(output, pos, MARKERS.APP2, mpfDataActual); // Write rest of primary JPEG (skip SOI) output.set(primaryJpegData.subarray(2), pos); pos += primaryJpegData.length - 2; // === SECONDARY IMAGE (GAIN MAP) === // Write SOI pos = writeMarker(output, pos, MARKERS.SOI); // Write XMP for secondary image const xmpSecondaryData = new Uint8Array(namespaceBytes.length + xmpSecondaryBytes.length); xmpSecondaryData.set(namespaceBytes, 0); xmpSecondaryData.set(xmpSecondaryBytes, namespaceBytes.length); pos = writeMarker(output, pos, MARKERS.APP1, xmpSecondaryData); // Write rest of gain map JPEG (skip SOI) output.set(gainMap.data.subarray(2), pos); // pos += gainMap.data.length - 2 return output; } /** * Encapsulates a Gainmap into a single JPEG file (aka: JPEG-R) with the base map * as the sdr visualization and the gainMap encoded into a MPF (Multi-Picture Format) tag. * * @category Encoding * @group Encoding * * @example * import { compress, encode, findTextureMinMax } from '@monogrid/gainmap-js' * import { encodeJPEGMetadata } from '@monogrid/gainmap-js/libultrahdr' * import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' * * // load an HDR file * const loader = new EXRLoader() * const image = await loader.loadAsync('image.exr') * * // find RAW RGB Max value of a texture * const textureMax = findTextureMinMax(image) * * // Encode the gainmap * const encodingResult = encode({ * image, * maxContentBoost: Math.max.apply(this, textureMax) * }) * * // obtain the RAW RGBA SDR buffer and create an ImageData * const sdrImageData = new ImageData( * encodingResult.sdr.toArray(), * encodingResult.sdr.width, * encodingResult.sdr.height * ) * // obtain the RAW RGBA Gain map buffer and create an ImageData * const gainMapImageData = new ImageData( * encodingResult.gainMap.toArray(), * encodingResult.gainMap.width, * encodingResult.gainMap.height * ) * * // parallel compress the RAW buffers into the specified mimeType * const mimeType = 'image/jpeg' * const quality = 0.9 * * const [sdr, gainMap] = await Promise.all([ * compress({ * source: sdrImageData, * mimeType, * quality, * flipY: true // output needs to be flipped * }), * compress({ * source: gainMapImageData, * mimeType, * quality, * flipY: true // output needs to be flipped * }) * ]) * * // obtain the metadata which will be embedded into * // and XMP tag inside the final JPEG file * const metadata = encodingResult.getMetadata() * * // embed the compressed images + metadata into a single * // JPEG file * const jpeg = encodeJPEGMetadata({ * ...encodingResult, * ...metadata, * sdr, * gainMap * }) * * // `jpeg` will be an `Uint8Array` which can be saved somewhere * * * @param encodingResult - Encoding result containing SDR image, gain map image, and metadata * @returns A Uint8Array representing a JPEG-R file * @throws {Error} If `encodingResult.sdr.mimeType !== 'image/jpeg'` * @throws {Error} If `encodingResult.gainMap.mimeType !== 'image/jpeg'` */ const encodeJPEGMetadata = (encodingResult) => { // Validate input if (encodingResult.sdr.mimeType !== 'image/jpeg') { throw new Error('This function expects an SDR image compressed in jpeg'); } if (encodingResult.gainMap.mimeType !== 'image/jpeg') { throw new Error('This function expects a GainMap image compressed in jpeg'); } // Prepare metadata with proper conversions // The XMP generator handles the log2 conversion internally for gain map min/max values const metadata = { version: '1.0', gainMapMin: encodingResult.gainMapMin, gainMapMax: encodingResult.gainMapMax, gamma: encodingResult.gamma, offsetSdr: encodingResult.offsetSdr, offsetHdr: encodingResult.offsetHdr, hdrCapacityMin: encodingResult.hdrCapacityMin, hdrCapacityMax: encodingResult.hdrCapacityMax, minContentBoost: Array.isArray(encodingResult.gainMapMin) ? Math.pow(2, encodingResult.gainMapMin.reduce((a, b) => a + b, 0) / encodingResult.gainMapMin.length) : Math.pow(2, encodingResult.gainMapMin), maxContentBoost: Array.isArray(encodingResult.gainMapMax) ? Math.pow(2, encodingResult.gainMapMax.reduce((a, b) => a + b, 0) / encodingResult.gainMapMax.length) : Math.pow(2, encodingResult.gainMapMax) }; // Assemble the JPEG with gain map using pure JavaScript return assembleJpegWithGainMap({ sdr: encodingResult.sdr, gainMap: encodingResult.gainMap, metadata }); }; exports.encodeJPEGMetadata = encodeJPEGMetadata; }));