UltraHDRLoader.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import {
  2. ClampToEdgeWrapping,
  3. DataTexture,
  4. DataUtils,
  5. FileLoader,
  6. HalfFloatType,
  7. LinearFilter,
  8. LinearMipMapLinearFilter,
  9. LinearSRGBColorSpace,
  10. Loader,
  11. RGBAFormat,
  12. UVMapping,
  13. } from 'three';
  14. // UltraHDR Image Format - https://developer.android.com/media/platform/hdr-image-format
  15. // Originally proposed as gainmap-js using a WASM dependency - https://github.com/MONOGRID/gainmap-js
  16. /**
  17. *
  18. * Short format brief:
  19. *
  20. * [JPEG headers]
  21. * [XMP metadata describing the MPF container and *both* SDR and gainmap images]
  22. * [Optional metadata] [EXIF] [ICC Profile]
  23. * [SDR image]
  24. * [XMP metadata describing only the gainmap image]
  25. * [Gainmap image]
  26. *
  27. * Each section is separated by a 0xFFXX byte followed by a descriptor byte (0xFFE0, 0xFFE1, 0xFFE2.)
  28. * Binary image storages are prefixed with a unique 0xFFD8 16-bit descriptor.
  29. */
  30. /**
  31. * Current feature set:
  32. * - JPEG headers (required)
  33. * - XMP metadata (required)
  34. * - XMP validation (not implemented)
  35. * - EXIF profile (not implemented)
  36. * - ICC profile (not implemented)
  37. * - Binary storage for SDR & HDR images (required)
  38. * - Gainmap metadata (required)
  39. * - Non-JPEG image formats (not implemented)
  40. * - Primary image as an HDR image (not implemented)
  41. */
  42. /* Calculating this SRGB powers is extremely slow for 4K images and can be sufficiently precalculated for a 3-4x speed boost */
  43. const SRGB_TO_LINEAR = Array( 1024 )
  44. .fill( 0 )
  45. .map( ( _, value ) =>
  46. Math.pow( ( value / 255 ) * 0.9478672986 + 0.0521327014, 2.4 )
  47. );
  48. class UltraHDRLoader extends Loader {
  49. constructor( manager ) {
  50. super( manager );
  51. this.type = HalfFloatType;
  52. }
  53. setDataType( value ) {
  54. this.type = value;
  55. return this;
  56. }
  57. parse( buffer, onLoad ) {
  58. const xmpMetadata = {
  59. version: null,
  60. baseRenditionIsHDR: null,
  61. gainMapMin: null,
  62. gainMapMax: null,
  63. gamma: null,
  64. offsetSDR: null,
  65. offsetHDR: null,
  66. hdrCapacityMin: null,
  67. hdrCapacityMax: null,
  68. };
  69. const textDecoder = new TextDecoder();
  70. const data = new DataView( buffer );
  71. const dataAsString = textDecoder.decode( data );
  72. /* Minimal sufficient validation - https://developer.android.com/media/platform/hdr-image-format#signal_of_the_format */
  73. if ( ! dataAsString.includes( 'hdrgm:Version="1.0"' ) ) {
  74. throw new Error( 'THREE.UltraHDRLoader: Not a valid UltraHDR image' );
  75. }
  76. let byteOffset = 0;
  77. const sections = [];
  78. while ( byteOffset < data.byteLength ) {
  79. const byte = data.getUint8( byteOffset );
  80. if ( byte === 0xff ) {
  81. const leadingByte = data.getUint8( byteOffset + 1 );
  82. if (
  83. [
  84. /* Valid section headers */
  85. 0xd8, // SOI
  86. 0xe0, // APP0
  87. 0xe1, // APP1
  88. 0xe2, // APP2
  89. ].includes( leadingByte )
  90. ) {
  91. sections.push( {
  92. sectionType: leadingByte,
  93. section: [ byte, leadingByte ],
  94. sectionOffset: byteOffset + 2,
  95. } );
  96. byteOffset += 2;
  97. } else {
  98. sections[ sections.length - 1 ].section.push( byte, leadingByte );
  99. byteOffset += 2;
  100. }
  101. } else {
  102. sections[ sections.length - 1 ].section.push( byte );
  103. byteOffset ++;
  104. }
  105. }
  106. let primaryImage, gainmapImage;
  107. for ( let i = 0; i < sections.length; i ++ ) {
  108. const { sectionType, section, sectionOffset } = sections[ i ];
  109. if ( sectionType === 0xe0 ) {
  110. /* JPEG Header - no useful information */
  111. } else if ( sectionType === 0xe1 ) {
  112. /* XMP Metadata */
  113. this._parseXMPMetadata(
  114. textDecoder.decode( new Uint8Array( section ) ),
  115. xmpMetadata
  116. );
  117. } else if ( sectionType === 0xe2 ) {
  118. /* Data Sections - MPF / EXIF / ICC Profile */
  119. const sectionData = new DataView(
  120. new Uint8Array( section.slice( 2 ) ).buffer
  121. );
  122. const sectionHeader = sectionData.getUint32( 2, false );
  123. if ( sectionHeader === 0x4d504600 ) {
  124. /* MPF Section */
  125. /* Section contains a list of static bytes and ends with offsets indicating location of SDR and gainmap images */
  126. /* First bytes after header indicate little / big endian ordering (0x49492A00 - LE / 0x4D4D002A - BE) */
  127. /*
  128. ... 60 bytes indicating tags, versions, etc. ...
  129. bytes | bits | description
  130. 4 32 primary image size
  131. 4 32 primary image offset
  132. 2 16 0x0000
  133. 2 16 0x0000
  134. 4 32 0x00000000
  135. 4 32 gainmap image size
  136. 4 32 gainmap image offset
  137. 2 16 0x0000
  138. 2 16 0x0000
  139. */
  140. const mpfLittleEndian = sectionData.getUint32( 6 ) === 0x49492a00;
  141. const mpfBytesOffset = 60;
  142. /* SDR size includes the metadata length, SDR offset is always 0 */
  143. const primaryImageSize = sectionData.getUint32(
  144. mpfBytesOffset,
  145. mpfLittleEndian
  146. );
  147. const primaryImageOffset = sectionData.getUint32(
  148. mpfBytesOffset + 4,
  149. mpfLittleEndian
  150. );
  151. /* Gainmap size is an absolute value starting from its offset, gainmap offset needs 6 bytes padding to take into account 0x00 bytes at the end of XMP */
  152. const gainmapImageSize = sectionData.getUint32(
  153. mpfBytesOffset + 16,
  154. mpfLittleEndian
  155. );
  156. const gainmapImageOffset =
  157. sectionData.getUint32( mpfBytesOffset + 20, mpfLittleEndian ) +
  158. sectionOffset +
  159. 6;
  160. primaryImage = new Uint8Array(
  161. data.buffer,
  162. primaryImageOffset,
  163. primaryImageSize
  164. );
  165. gainmapImage = new Uint8Array(
  166. data.buffer,
  167. gainmapImageOffset,
  168. gainmapImageSize
  169. );
  170. }
  171. }
  172. }
  173. if ( primaryImage && gainmapImage ) {
  174. this._applyGainmapToSDR(
  175. xmpMetadata,
  176. primaryImage,
  177. gainmapImage,
  178. ( hdrBuffer, width, height ) => {
  179. onLoad( {
  180. width,
  181. height,
  182. data: hdrBuffer,
  183. format: RGBAFormat,
  184. type: this.type,
  185. } );
  186. },
  187. ( error ) => {
  188. throw new Error( error );
  189. }
  190. );
  191. } else {
  192. throw new Error( 'THREE.UltraHDRLoader: Could not parse UltraHDR images' );
  193. }
  194. }
  195. load( url, onLoad, onError ) {
  196. const texture = new DataTexture(
  197. this.type === HalfFloatType ? new Uint16Array() : new Float32Array(),
  198. 0,
  199. 0,
  200. RGBAFormat,
  201. this.type,
  202. UVMapping,
  203. ClampToEdgeWrapping,
  204. ClampToEdgeWrapping,
  205. LinearFilter,
  206. LinearMipMapLinearFilter,
  207. 1,
  208. LinearSRGBColorSpace
  209. );
  210. texture.generateMipmaps = true;
  211. texture.flipY = true;
  212. const loader = new FileLoader( this.manager );
  213. loader.setResponseType( 'arraybuffer' );
  214. loader.setRequestHeader( this.requestHeader );
  215. loader.setPath( this.path );
  216. loader.setWithCredentials( this.withCredentials );
  217. loader.load( url, ( buffer ) => {
  218. try {
  219. this.parse(
  220. buffer,
  221. ( texData ) => {
  222. texture.image = {
  223. data: texData.data,
  224. width: texData.width,
  225. height: texData.height,
  226. };
  227. texture.needsUpdate = true;
  228. if ( onLoad ) onLoad( texture, texData );
  229. },
  230. onError
  231. );
  232. } catch ( error ) {
  233. if ( onError ) onError( error );
  234. console.error( error );
  235. }
  236. } );
  237. return texture;
  238. }
  239. _parseXMPMetadata( xmpDataString, xmpMetadata ) {
  240. const domParser = new DOMParser();
  241. const xmpXml = domParser.parseFromString(
  242. xmpDataString.substring(
  243. xmpDataString.indexOf( '<' ),
  244. xmpDataString.lastIndexOf( '>' ) + 1
  245. ),
  246. 'text/xml'
  247. );
  248. /* Determine if given XMP metadata is the primary GContainer descriptor or a gainmap descriptor */
  249. const [ hasHDRContainerDescriptor ] = xmpXml.getElementsByTagName(
  250. 'Container:Directory'
  251. );
  252. if ( hasHDRContainerDescriptor ) {
  253. /* There's not much useful information in the container descriptor besides memory-validation */
  254. } else {
  255. /* Gainmap descriptor - defaults from https://developer.android.com/media/platform/hdr-image-format#HDR_gain_map_metadata */
  256. const [ gainmapNode ] = xmpXml.getElementsByTagName( 'rdf:Description' );
  257. xmpMetadata.version = gainmapNode.getAttribute( 'hdrgm:Version' ) || '1.0';
  258. xmpMetadata.baseRenditionIsHDR =
  259. gainmapNode.getAttribute( 'hdrgm:BaseRenditionIsHDR' ) === 'True';
  260. xmpMetadata.gainMapMin = parseFloat(
  261. gainmapNode.getAttribute( 'hdrgm:GainMapMin' ) || 0.0
  262. );
  263. xmpMetadata.gainMapMax = parseFloat(
  264. gainmapNode.getAttribute( 'hdrgm:GainMapMax' ) || 1.0
  265. );
  266. xmpMetadata.gamma = parseFloat(
  267. gainmapNode.getAttribute( 'hdrgm:Gamma' ) || 1.0
  268. );
  269. xmpMetadata.offsetSDR = parseFloat(
  270. gainmapNode.getAttribute( 'hdrgm:OffsetSDR' ) / ( 1 / 64 )
  271. );
  272. xmpMetadata.offsetHDR = parseFloat(
  273. gainmapNode.getAttribute( 'hdrgm:OffsetHDR' ) / ( 1 / 64 )
  274. );
  275. xmpMetadata.hdrCapacityMin = parseFloat(
  276. gainmapNode.getAttribute( 'hdrgm:HDRCapacityMin' ) || 0.0
  277. );
  278. xmpMetadata.hdrCapacityMax = parseFloat(
  279. gainmapNode.getAttribute( 'hdrgm:HDRCapacityMax' ) || 1.0
  280. );
  281. }
  282. }
  283. _srgbToLinear( value ) {
  284. if ( value / 255 < 0.04045 ) {
  285. return ( value / 255 ) * 0.0773993808;
  286. }
  287. if ( value < 1024 ) {
  288. return SRGB_TO_LINEAR[ ~ ~ value ];
  289. }
  290. return Math.pow( ( value / 255 ) * 0.9478672986 + 0.0521327014, 2.4 );
  291. }
  292. _applyGainmapToSDR(
  293. xmpMetadata,
  294. sdrBuffer,
  295. gainmapBuffer,
  296. onSuccess,
  297. onError
  298. ) {
  299. const getImageDataFromBuffer = ( buffer ) =>
  300. new Promise( ( resolve, reject ) => {
  301. const imageLoader = document.createElement( 'img' );
  302. imageLoader.onload = () => {
  303. const image = {
  304. width: imageLoader.naturalWidth,
  305. height: imageLoader.naturalHeight,
  306. source: imageLoader,
  307. };
  308. URL.revokeObjectURL( imageLoader.src );
  309. resolve( image );
  310. };
  311. imageLoader.onerror = () => {
  312. URL.revokeObjectURL( imageLoader.src );
  313. reject();
  314. };
  315. imageLoader.src = URL.createObjectURL(
  316. new Blob( [ buffer ], { type: 'image/jpeg' } )
  317. );
  318. } );
  319. Promise.all( [
  320. getImageDataFromBuffer( sdrBuffer ),
  321. getImageDataFromBuffer( gainmapBuffer ),
  322. ] )
  323. .then( ( [ sdrImage, gainmapImage ] ) => {
  324. const sdrImageAspect = sdrImage.width / sdrImage.height;
  325. const gainmapImageAspect = gainmapImage.width / gainmapImage.height;
  326. if ( sdrImageAspect !== gainmapImageAspect ) {
  327. onError(
  328. 'THREE.UltraHDRLoader Error: Aspect ratio mismatch between SDR and Gainmap images'
  329. );
  330. return;
  331. }
  332. const canvas = document.createElement( 'canvas' );
  333. const ctx = canvas.getContext( '2d', {
  334. willReadFrequently: true,
  335. colorSpace: 'srgb',
  336. } );
  337. canvas.width = sdrImage.width;
  338. canvas.height = sdrImage.height;
  339. /* Use out-of-the-box interpolation of Canvas API to scale gainmap to fit the SDR resolution */
  340. ctx.drawImage(
  341. gainmapImage.source,
  342. 0,
  343. 0,
  344. gainmapImage.width,
  345. gainmapImage.height,
  346. 0,
  347. 0,
  348. sdrImage.width,
  349. sdrImage.height
  350. );
  351. const gainmapImageData = ctx.getImageData(
  352. 0,
  353. 0,
  354. sdrImage.width,
  355. sdrImage.height,
  356. { colorSpace: 'srgb' }
  357. );
  358. ctx.drawImage( sdrImage.source, 0, 0 );
  359. const sdrImageData = ctx.getImageData(
  360. 0,
  361. 0,
  362. sdrImage.width,
  363. sdrImage.height,
  364. { colorSpace: 'srgb' }
  365. );
  366. /* HDR Recovery formula - https://developer.android.com/media/platform/hdr-image-format#use_the_gain_map_to_create_adapted_HDR_rendition */
  367. let hdrBuffer;
  368. if ( this.type === HalfFloatType ) {
  369. hdrBuffer = new Uint16Array( sdrImageData.data.length ).fill( 23544 );
  370. } else {
  371. hdrBuffer = new Float32Array( sdrImageData.data.length ).fill( 255 );
  372. }
  373. const maxDisplayBoost = Math.sqrt(
  374. Math.pow(
  375. /* 1.8 instead of 2 near-perfectly rectifies approximations introduced by precalculated SRGB_TO_LINEAR values */
  376. 1.8,
  377. xmpMetadata.hdrCapacityMax
  378. )
  379. );
  380. const unclampedWeightFactor =
  381. ( Math.log2( maxDisplayBoost ) - xmpMetadata.hdrCapacityMin ) /
  382. ( xmpMetadata.hdrCapacityMax - xmpMetadata.hdrCapacityMin );
  383. const weightFactor = Math.min(
  384. Math.max( unclampedWeightFactor, 0.0 ),
  385. 1.0
  386. );
  387. const useGammaOne = xmpMetadata.gamma === 1.0;
  388. for (
  389. let pixelIndex = 0;
  390. pixelIndex < sdrImageData.data.length;
  391. pixelIndex += 4
  392. ) {
  393. const x = ( pixelIndex / 4 ) % sdrImage.width;
  394. const y = Math.floor( pixelIndex / 4 / sdrImage.width );
  395. for ( let index = pixelIndex; index < pixelIndex + 3; index ++ ) {
  396. const sdrValue = sdrImageData.data[ index ];
  397. const gainmapIndex = ( y * sdrImage.width + x ) * 4;
  398. const gainmapValue = gainmapImageData.data[ gainmapIndex ] / 255.0;
  399. /* Gamma is 1.0 by default */
  400. const logRecovery = useGammaOne
  401. ? gainmapValue
  402. : Math.pow( gainmapValue, 1.0 / xmpMetadata.gamma );
  403. const logBoost =
  404. xmpMetadata.gainMapMin * ( 1.0 - logRecovery ) +
  405. xmpMetadata.gainMapMax * logRecovery;
  406. const hdrValue =
  407. ( sdrValue + xmpMetadata.offsetSDR ) *
  408. ( logBoost * weightFactor === 0.0
  409. ? 1.0
  410. : Math.pow( 2, logBoost * weightFactor ) ) -
  411. xmpMetadata.offsetHDR;
  412. const linearHDRValue = Math.min(
  413. Math.max(
  414. this._srgbToLinear( hdrValue ),
  415. 0
  416. ),
  417. 65504
  418. );
  419. hdrBuffer[ index ] =
  420. this.type === HalfFloatType
  421. ? DataUtils.toHalfFloat( linearHDRValue )
  422. : linearHDRValue;
  423. }
  424. }
  425. onSuccess( hdrBuffer, sdrImage.width, sdrImage.height );
  426. } )
  427. .catch( () => {
  428. throw new Error(
  429. 'THREE.UltraHDRLoader Error: Could not parse UltraHDR images'
  430. );
  431. } );
  432. }
  433. }
  434. export { UltraHDRLoader };