Initial project import
This commit is contained in:
658
node_modules/@monogrid/gainmap-js/dist/libultrahdr.umd.cjs
generated
vendored
Normal file
658
node_modules/@monogrid/gainmap-js/dist/libultrahdr.umd.cjs
generated
vendored
Normal file
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* @monogrid/gainmap-js v3.4.0
|
||||
* With ❤️, by MONOGRID <gainmap@monogrid.com>
|
||||
*/
|
||||
|
||||
(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, '"')
|
||||
.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('<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>');
|
||||
lines.push('<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.1.2">');
|
||||
lines.push(' <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">');
|
||||
lines.push(' <rdf:Description');
|
||||
lines.push(' xmlns:Container="http://ns.google.com/photos/1.0/container/"');
|
||||
lines.push(' xmlns:Item="http://ns.google.com/photos/1.0/container/item/"');
|
||||
lines.push(' xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/"');
|
||||
lines.push(` hdrgm:Version="${escapeXml(metadata.version)}"`);
|
||||
lines.push(' rdf:about="">');
|
||||
// Container directory
|
||||
lines.push(' <Container:Directory>');
|
||||
lines.push(' <rdf:Seq>');
|
||||
// Primary image item
|
||||
lines.push(' <rdf:li rdf:parseType="Resource">');
|
||||
lines.push(' <Container:Item');
|
||||
lines.push(` Item:Semantic="${ITEM_SEMANTIC.PRIMARY}"`);
|
||||
lines.push(` Item:Mime="${MIME_IMAGE_JPEG}"/>`);
|
||||
lines.push(' </rdf:li>');
|
||||
// Gain map image item
|
||||
lines.push(' <rdf:li rdf:parseType="Resource">');
|
||||
lines.push(' <Container:Item');
|
||||
lines.push(` Item:Semantic="${ITEM_SEMANTIC.GAIN_MAP}"`);
|
||||
lines.push(` Item:Mime="${MIME_IMAGE_JPEG}"`);
|
||||
lines.push(` Item:Length="${secondaryImageLength}"/>`);
|
||||
lines.push(' </rdf:li>');
|
||||
lines.push(' </rdf:Seq>');
|
||||
lines.push(' </Container:Directory>');
|
||||
lines.push(' </rdf:Description>');
|
||||
lines.push(' </rdf:RDF>');
|
||||
lines.push('</x:xmpmeta>');
|
||||
lines.push('<?xpacket end="w"?>');
|
||||
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('<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>');
|
||||
lines.push('<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.1.2">');
|
||||
lines.push(' <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">');
|
||||
lines.push(' <rdf:Description');
|
||||
lines.push(' xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/"');
|
||||
lines.push(` hdrgm:Version="${escapeXml(metadata.version)}"`);
|
||||
lines.push(` hdrgm:GainMapMin="${escapeXml(gainMapMinAvg)}"`);
|
||||
lines.push(` hdrgm:GainMapMax="${escapeXml(gainMapMaxAvg)}"`);
|
||||
lines.push(` hdrgm:Gamma="${escapeXml(gammaAvg)}"`);
|
||||
lines.push(` hdrgm:OffsetSDR="${escapeXml(offsetSdrAvg)}"`);
|
||||
lines.push(` hdrgm:OffsetHDR="${escapeXml(offsetHdrAvg)}"`);
|
||||
lines.push(` hdrgm:HDRCapacityMin="${escapeXml(hdrCapacityMin)}"`);
|
||||
lines.push(` hdrgm:HDRCapacityMax="${escapeXml(hdrCapacityMax)}"`);
|
||||
lines.push(' hdrgm:BaseRenditionIsHDR="False"');
|
||||
lines.push(' rdf:about=""/>');
|
||||
lines.push(' </rdf:RDF>');
|
||||
lines.push('</x:xmpmeta>');
|
||||
lines.push('<?xpacket end="w"?>');
|
||||
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;
|
||||
|
||||
}));
|
||||
Reference in New Issue
Block a user