|
|
@@ -29,12 +29,13 @@ import {
|
|
|
*/
|
|
|
|
|
|
|
|
|
-// Calculating this SRGB powers is extremely slow for 4K images and can be sufficiently precalculated for a 3-4x speed boost
|
|
|
-const SRGB_TO_LINEAR = Array( 1024 )
|
|
|
- .fill( 0 )
|
|
|
- .map( ( _, value ) =>
|
|
|
- Math.pow( ( value / 255 ) * 0.9478672986 + 0.0521327014, 2.4 )
|
|
|
- );
|
|
|
+// Pre-calculated sRGB to linear lookup table for values 0-1023
|
|
|
+const SRGB_TO_LINEAR = new Float64Array( 1024 );
|
|
|
+for ( let i = 0; i < 1024; i ++ ) {
|
|
|
+
|
|
|
+ SRGB_TO_LINEAR[ i ] = Math.pow( ( i / 255 ) * 0.9478672986 + 0.0521327014, 2.4 );
|
|
|
+
|
|
|
+}
|
|
|
|
|
|
/**
|
|
|
* A loader for the Ultra HDR Image Format.
|
|
|
@@ -107,6 +108,8 @@ class UltraHDRLoader extends Loader {
|
|
|
*/
|
|
|
parse( buffer, onLoad ) {
|
|
|
|
|
|
+ console.time( 'UltraHDRLoader' );
|
|
|
+
|
|
|
const xmpMetadata = {
|
|
|
version: null,
|
|
|
baseRenditionIsHDR: null,
|
|
|
@@ -120,53 +123,69 @@ class UltraHDRLoader extends Loader {
|
|
|
};
|
|
|
const textDecoder = new TextDecoder();
|
|
|
|
|
|
- const data = new DataView( buffer );
|
|
|
+ const bytes = new Uint8Array( buffer );
|
|
|
|
|
|
- let byteOffset = 0;
|
|
|
const sections = [];
|
|
|
|
|
|
- while ( byteOffset < data.byteLength ) {
|
|
|
+ // JPEG segment-aware scanner using length headers
|
|
|
+ let offset = 0;
|
|
|
+
|
|
|
+ while ( offset < bytes.length - 1 ) {
|
|
|
|
|
|
- const byte = data.getUint8( byteOffset );
|
|
|
+ // Find marker prefix
|
|
|
+ if ( bytes[ offset ] !== 0xff ) {
|
|
|
|
|
|
- if ( byte === 0xff ) {
|
|
|
+ offset ++;
|
|
|
+ continue;
|
|
|
|
|
|
- const leadingByte = data.getUint8( byteOffset + 1 );
|
|
|
+ }
|
|
|
|
|
|
- if (
|
|
|
- [
|
|
|
- /* Valid section headers */
|
|
|
- 0xd8, // SOI
|
|
|
- 0xe0, // APP0
|
|
|
- 0xe1, // APP1
|
|
|
- 0xe2, // APP2
|
|
|
- ].includes( leadingByte )
|
|
|
- ) {
|
|
|
+ const markerType = bytes[ offset + 1 ];
|
|
|
|
|
|
- sections.push( {
|
|
|
- sectionType: leadingByte,
|
|
|
- section: [ byte, leadingByte ],
|
|
|
- sectionOffset: byteOffset + 2,
|
|
|
- } );
|
|
|
+ // SOI (0xD8) - Start of Image, no length field
|
|
|
+ if ( markerType === 0xd8 ) {
|
|
|
|
|
|
- byteOffset += 2;
|
|
|
+ sections.push( {
|
|
|
+ sectionType: markerType,
|
|
|
+ section: bytes.subarray( offset, offset + 2 ),
|
|
|
+ sectionOffset: offset + 2,
|
|
|
+ } );
|
|
|
|
|
|
- } else {
|
|
|
+ offset += 2;
|
|
|
+ continue;
|
|
|
|
|
|
- sections[ sections.length - 1 ].section.push( byte, leadingByte );
|
|
|
+ }
|
|
|
|
|
|
- byteOffset += 2;
|
|
|
+ // APP0-APP2 segments have length headers
|
|
|
+ if ( markerType === 0xe0 || markerType === 0xe1 || markerType === 0xe2 ) {
|
|
|
|
|
|
- }
|
|
|
+ // Length is stored as big-endian 16-bit value (includes length bytes, excludes marker)
|
|
|
+ const segmentLength = ( bytes[ offset + 2 ] << 8 ) | bytes[ offset + 3 ];
|
|
|
+ const segmentEnd = offset + 2 + segmentLength;
|
|
|
|
|
|
- } else {
|
|
|
+ sections.push( {
|
|
|
+ sectionType: markerType,
|
|
|
+ section: bytes.subarray( offset, segmentEnd ),
|
|
|
+ sectionOffset: offset + 2,
|
|
|
+ } );
|
|
|
+
|
|
|
+ offset = segmentEnd;
|
|
|
+ continue;
|
|
|
|
|
|
- sections[ sections.length - 1 ].section.push( byte );
|
|
|
+ }
|
|
|
|
|
|
- byteOffset ++;
|
|
|
+ // Skip other markers with length fields (0xC0-0xFE range, except RST and EOI)
|
|
|
+ if ( markerType >= 0xc0 && markerType <= 0xfe && markerType !== 0xd9 && ( markerType < 0xd0 || markerType > 0xd7 ) ) {
|
|
|
+
|
|
|
+ const segmentLength = ( bytes[ offset + 2 ] << 8 ) | bytes[ offset + 3 ];
|
|
|
+ offset += 2 + segmentLength;
|
|
|
+ continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
+ // EOI (0xD9) or RST markers (0xD0-0xD7) - no length field
|
|
|
+ offset += 2;
|
|
|
+
|
|
|
}
|
|
|
|
|
|
let primaryImage, gainmapImage;
|
|
|
@@ -190,9 +209,7 @@ class UltraHDRLoader extends Loader {
|
|
|
|
|
|
/* Data Sections - MPF / EXIF / ICC Profile */
|
|
|
|
|
|
- const sectionData = new DataView(
|
|
|
- new Uint8Array( section.slice( 2 ) ).buffer
|
|
|
- );
|
|
|
+ const sectionData = new DataView( section.buffer, section.byteOffset + 2, section.byteLength - 2 );
|
|
|
const sectionHeader = sectionData.getUint32( 2, false );
|
|
|
|
|
|
if ( sectionHeader === 0x4d504600 ) {
|
|
|
@@ -243,13 +260,13 @@ class UltraHDRLoader extends Loader {
|
|
|
6;
|
|
|
|
|
|
primaryImage = new Uint8Array(
|
|
|
- data.buffer,
|
|
|
+ buffer,
|
|
|
primaryImageOffset,
|
|
|
primaryImageSize
|
|
|
);
|
|
|
|
|
|
gainmapImage = new Uint8Array(
|
|
|
- data.buffer,
|
|
|
+ buffer,
|
|
|
gainmapImageOffset,
|
|
|
gainmapImageSize
|
|
|
);
|
|
|
@@ -275,6 +292,8 @@ class UltraHDRLoader extends Loader {
|
|
|
gainmapImage,
|
|
|
( hdrBuffer, width, height ) => {
|
|
|
|
|
|
+ console.timeEnd( 'UltraHDRLoader' );
|
|
|
+
|
|
|
onLoad( {
|
|
|
width,
|
|
|
height,
|
|
|
@@ -447,46 +466,14 @@ class UltraHDRLoader extends Loader {
|
|
|
onError
|
|
|
) {
|
|
|
|
|
|
- const getImageDataFromBuffer = ( buffer ) =>
|
|
|
- new Promise( ( resolve, reject ) => {
|
|
|
-
|
|
|
- const imageLoader = document.createElement( 'img' );
|
|
|
-
|
|
|
- imageLoader.onload = () => {
|
|
|
-
|
|
|
- const image = {
|
|
|
- width: imageLoader.naturalWidth,
|
|
|
- height: imageLoader.naturalHeight,
|
|
|
- source: imageLoader,
|
|
|
- };
|
|
|
+ const decodeImage = ( data ) => createImageBitmap( new Blob( [ data ], { type: 'image/jpeg' } ) );
|
|
|
|
|
|
- URL.revokeObjectURL( imageLoader.src );
|
|
|
-
|
|
|
- resolve( image );
|
|
|
-
|
|
|
- };
|
|
|
-
|
|
|
- imageLoader.onerror = () => {
|
|
|
-
|
|
|
- URL.revokeObjectURL( imageLoader.src );
|
|
|
-
|
|
|
- reject();
|
|
|
-
|
|
|
- };
|
|
|
-
|
|
|
- imageLoader.src = URL.createObjectURL(
|
|
|
- new Blob( [ buffer ], { type: 'image/jpeg' } )
|
|
|
- );
|
|
|
-
|
|
|
- } );
|
|
|
-
|
|
|
- Promise.all( [
|
|
|
- getImageDataFromBuffer( sdrBuffer ),
|
|
|
- getImageDataFromBuffer( gainmapBuffer ),
|
|
|
- ] )
|
|
|
+ Promise.all( [ decodeImage( sdrBuffer ), decodeImage( gainmapBuffer ) ] )
|
|
|
.then( ( [ sdrImage, gainmapImage ] ) => {
|
|
|
|
|
|
- const sdrImageAspect = sdrImage.width / sdrImage.height;
|
|
|
+ const sdrWidth = sdrImage.width;
|
|
|
+ const sdrHeight = sdrImage.height;
|
|
|
+ const sdrImageAspect = sdrWidth / sdrHeight;
|
|
|
const gainmapImageAspect = gainmapImage.width / gainmapImage.height;
|
|
|
|
|
|
if ( sdrImageAspect !== gainmapImageAspect ) {
|
|
|
@@ -505,50 +492,39 @@ class UltraHDRLoader extends Loader {
|
|
|
colorSpace: 'srgb',
|
|
|
} );
|
|
|
|
|
|
- canvas.width = sdrImage.width;
|
|
|
- canvas.height = sdrImage.height;
|
|
|
+ canvas.width = sdrWidth;
|
|
|
+ canvas.height = sdrHeight;
|
|
|
|
|
|
/* Use out-of-the-box interpolation of Canvas API to scale gainmap to fit the SDR resolution */
|
|
|
ctx.drawImage(
|
|
|
- gainmapImage.source,
|
|
|
+ gainmapImage,
|
|
|
0,
|
|
|
0,
|
|
|
gainmapImage.width,
|
|
|
gainmapImage.height,
|
|
|
0,
|
|
|
0,
|
|
|
- sdrImage.width,
|
|
|
- sdrImage.height
|
|
|
+ sdrWidth,
|
|
|
+ sdrHeight
|
|
|
);
|
|
|
const gainmapImageData = ctx.getImageData(
|
|
|
0,
|
|
|
0,
|
|
|
- sdrImage.width,
|
|
|
- sdrImage.height,
|
|
|
+ sdrWidth,
|
|
|
+ sdrHeight,
|
|
|
{ colorSpace: 'srgb' }
|
|
|
);
|
|
|
|
|
|
- ctx.drawImage( sdrImage.source, 0, 0 );
|
|
|
+ ctx.drawImage( sdrImage, 0, 0 );
|
|
|
const sdrImageData = ctx.getImageData(
|
|
|
0,
|
|
|
0,
|
|
|
- sdrImage.width,
|
|
|
- sdrImage.height,
|
|
|
+ sdrWidth,
|
|
|
+ sdrHeight,
|
|
|
{ colorSpace: 'srgb' }
|
|
|
);
|
|
|
|
|
|
/* HDR Recovery formula - https://developer.android.com/media/platform/hdr-image-format#use_the_gain_map_to_create_adapted_HDR_rendition */
|
|
|
- let hdrBuffer;
|
|
|
-
|
|
|
- if ( this.type === HalfFloatType ) {
|
|
|
-
|
|
|
- hdrBuffer = new Uint16Array( sdrImageData.data.length ).fill( 15360 );
|
|
|
-
|
|
|
- } else {
|
|
|
-
|
|
|
- hdrBuffer = new Float32Array( sdrImageData.data.length ).fill( 1.0 );
|
|
|
-
|
|
|
- }
|
|
|
|
|
|
const maxDisplayBoost = Math.sqrt(
|
|
|
Math.pow(
|
|
|
@@ -564,55 +540,58 @@ class UltraHDRLoader extends Loader {
|
|
|
Math.max( unclampedWeightFactor, 0.0 ),
|
|
|
1.0
|
|
|
);
|
|
|
- const useGammaOne = xmpMetadata.gamma === 1.0;
|
|
|
|
|
|
- for (
|
|
|
- let pixelIndex = 0;
|
|
|
- pixelIndex < sdrImageData.data.length;
|
|
|
- pixelIndex += 4
|
|
|
- ) {
|
|
|
+ const sdrData = sdrImageData.data;
|
|
|
+ const gainmapData = gainmapImageData.data;
|
|
|
+ const dataLength = sdrData.length;
|
|
|
+ const gainMapMin = xmpMetadata.gainMapMin;
|
|
|
+ const gainMapMax = xmpMetadata.gainMapMax;
|
|
|
+ const offsetSDR = xmpMetadata.offsetSDR;
|
|
|
+ const offsetHDR = xmpMetadata.offsetHDR;
|
|
|
+ const invGamma = 1.0 / xmpMetadata.gamma;
|
|
|
+ const useGammaOne = xmpMetadata.gamma === 1.0;
|
|
|
+ const isHalfFloat = this.type === HalfFloatType;
|
|
|
+ const toHalfFloat = DataUtils.toHalfFloat;
|
|
|
|
|
|
- const x = ( pixelIndex / 4 ) % sdrImage.width;
|
|
|
- const y = Math.floor( pixelIndex / 4 / sdrImage.width );
|
|
|
+ const hdrBuffer = isHalfFloat
|
|
|
+ ? new Uint16Array( dataLength ).fill( 15360 )
|
|
|
+ : new Float32Array( dataLength ).fill( 1.0 );
|
|
|
|
|
|
- for ( let channelIndex = 0; channelIndex < 3; channelIndex ++ ) {
|
|
|
+ for ( let i = 0; i < dataLength; i += 4 ) {
|
|
|
|
|
|
- const sdrValue = sdrImageData.data[ pixelIndex + channelIndex ];
|
|
|
+ for ( let c = 0; c < 3; c ++ ) {
|
|
|
|
|
|
- const gainmapIndex = ( y * sdrImage.width + x ) * 4 + channelIndex;
|
|
|
- const gainmapValue = gainmapImageData.data[ gainmapIndex ] / 255.0;
|
|
|
+ const idx = i + c;
|
|
|
+ const sdrValue = sdrData[ idx ];
|
|
|
+ const gainmapValue = gainmapData[ idx ] * 0.00392156862745098; // 1/255
|
|
|
|
|
|
- /* Gamma is 1.0 by default */
|
|
|
const logRecovery = useGammaOne
|
|
|
? gainmapValue
|
|
|
- : Math.pow( gainmapValue, 1.0 / xmpMetadata.gamma );
|
|
|
+ : Math.pow( gainmapValue, invGamma );
|
|
|
|
|
|
- const logBoost =
|
|
|
- xmpMetadata.gainMapMin * ( 1.0 - logRecovery ) +
|
|
|
- xmpMetadata.gainMapMax * logRecovery;
|
|
|
+ const logBoost = gainMapMin + ( gainMapMax - gainMapMin ) * logRecovery;
|
|
|
|
|
|
const hdrValue =
|
|
|
- ( sdrValue + xmpMetadata.offsetSDR ) *
|
|
|
+ ( sdrValue + offsetSDR ) *
|
|
|
( logBoost * weightFactor === 0.0
|
|
|
? 1.0
|
|
|
: Math.pow( 2, logBoost * weightFactor ) ) -
|
|
|
- xmpMetadata.offsetHDR;
|
|
|
+ offsetHDR;
|
|
|
|
|
|
const linearHDRValue = Math.min(
|
|
|
Math.max( this._srgbToLinear( hdrValue ), 0 ),
|
|
|
65504
|
|
|
);
|
|
|
|
|
|
- hdrBuffer[ pixelIndex + channelIndex ] =
|
|
|
- this.type === HalfFloatType
|
|
|
- ? DataUtils.toHalfFloat( linearHDRValue )
|
|
|
- : linearHDRValue;
|
|
|
+ hdrBuffer[ idx ] = isHalfFloat
|
|
|
+ ? toHalfFloat( linearHDRValue )
|
|
|
+ : linearHDRValue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
- onSuccess( hdrBuffer, sdrImage.width, sdrImage.height );
|
|
|
+ onSuccess( hdrBuffer, sdrWidth, sdrHeight );
|
|
|
|
|
|
} )
|
|
|
.catch( () => {
|