diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b1386e..ac3cd21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,47 +26,6 @@ jobs: with: submodules: recursive - - name: 'Setup Emscripten' - uses: mymindstorm/setup-emsdk@v11 - with: - version: 3.1.47 - - - name: 'Setup Python' - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - - name: 'Install Meson & Ninja' - uses: BSFishy/pip-action@v1 - with: - packages: | - meson - ninja - - - name: Write em.txt - uses: "DamianReeves/write-file-action@master" - with: - path: libultrahdr-wasm/em.txt - write-mode: overwrite - contents: | - [binaries] - c = 'emcc' - cpp = 'em++' - ar = 'emar' - nm = 'emnm' - - [host_machine] - system = 'emscripten' - cpu_family = 'wasm32' - cpu = 'wasm32' - endian = 'little' - - - name: 'Build libultrahdr WASM' - run: | - cd libultrahdr-wasm - meson setup build --cross-file=em.txt - meson compile -C build - - name: 'Setup Nodejs' uses: actions/setup-node@v3 with: @@ -85,10 +44,6 @@ jobs: name: build-artifact if-no-files-found: error path: | - libultrahdr-wasm/build/*.ts - libultrahdr-wasm/build/*.js - libultrahdr-wasm/build/*.map - libultrahdr-wasm/build/*.wasm dist/ ################################ @@ -110,6 +65,7 @@ jobs: uses: actions/download-artifact@v4 with: name: build-artifact + path: dist - name: 'Setup Nodejs' uses: actions/setup-node@v3 @@ -154,11 +110,11 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive - - name: 'Download build artifacts' uses: actions/download-artifact@v4 with: name: build-artifact + path: dist - name: 'Setup Nodejs' uses: actions/setup-node@v3 diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml index dc3fb4d..fc74c95 100644 --- a/.github/workflows/publish-wiki.yml +++ b/.github/workflows/publish-wiki.yml @@ -30,6 +30,7 @@ jobs: uses: dawidd6/action-download-artifact@v6 with: name: build-artifact + path: dist run_id: ${{ github.event.workflow_run.id }} workflow_conclusion: success diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 967117c..849af03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,7 @@ jobs: uses: dawidd6/action-download-artifact@v6 # download artifacts with: name: build-artifact + path: dist run_id: ${{ github.event.workflow_run.id }} workflow_conclusion: success diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index f78d83a..93f5575 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -28,6 +28,7 @@ jobs: uses: dawidd6/action-download-artifact@v6 # download artifacts with: name: build-artifact + path: dist run_id: ${{ github.event.workflow_run.id }} - name: 'Download check artifacts' diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 04ad2c2..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "libultrahdr-wasm"] - path = libultrahdr-wasm - url = git@github.com:MONOGRID/libultrahdr-wasm.git diff --git a/.npmignore b/.npmignore index 76ff555..18829c0 100644 --- a/.npmignore +++ b/.npmignore @@ -7,7 +7,3 @@ src reports examples wiki -libultrahdr-wasm/**/* -!libultrahdr-wasm/build/*.ts -!libultrahdr-wasm/build/*.js -!libultrahdr-wasm/build/*.wasm diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..617ef68 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "runtimeExecutable": "/home/daniele/.nvm/versions/node/v22.21.0/bin/node", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/tests/encode/encode.test.ts", + "outFiles": [ + "${workspaceFolder}/**/*.js" + ] + } + ] +} diff --git a/README.md b/README.md index b6b14be..c204c17 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ const metadata = encodingResult.getMetadata() // embed the compressed images + metadata into a single // JPEG file -const jpeg = await encodeJPEGMetadata({ +const jpeg = encodeJPEGMetadata({ ...encodingResult, ...metadata, sdr, @@ -350,62 +350,14 @@ module.exports = defineConfig({ ``` -## Building with full encoding support (libultrahdr-wasm) +## Building -Clone the repository with git submodules recursively: -```bash -$ git clone --recurse-submodules git@github.com:MONOGRID/gainmap-js.git -``` - -Proceed to build the libultrahdr-wasm module following the [documentation found here](https://github.com/MONOGRID/libultrahdr-wasm#building), here's a quick summary - -```bash -$ cd gainmap-js/libultrahdr-wasm/ -``` -Create a meson "cross compile config" named em.txt and place the following content inside: -```ini -[binaries] -c = 'emcc' -cpp = 'em++' -ar = 'emar' -nm = 'emnm' - -[host_machine] -system = 'emscripten' -cpu_family = 'wasm32' -cpu = 'wasm32' -endian = 'little' -``` -Then execute - -```bash -$ meson setup build --cross-file=em.txt -$ meson compile -C build -``` - -After compiling the WASM, head back to the main repository - -```bash -$ cd .. -$ npm i -$ npm run build -``` - -## Building with no encoding support (requires no wasm) - -> :warning: Building the library with decode only capabilities will not allow to run playwright e2e tests with `npm run test` -> this method should only be used by people who would like to customize the "decoding" part of the library but are unable to build the WASM module for some reason (emscripten can be tricky sometimes, I've been there) - -Clone the repository normally: +Clone the repository: ```bash $ git clone git@github.com:MONOGRID/gainmap-js.git $ cd gainmap-js $ npm i -``` - -build with -```bash -$ npm run build --config rollup.config.decodeonly.mjs +$ npm run build ``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 07a2097..c06bf60 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,8 +29,7 @@ const config = defineConfig([ ignores: [ 'node_modules/**/*', 'dist/**/*', - '.vscode/**/*', - 'libultrahdr-wasm/build/**/*' + '.vscode/**/*' ] } ]) diff --git a/examples/encode-and-compress.ts b/examples/encode-and-compress.ts index dcd1024..ff52da9 100644 --- a/examples/encode-and-compress.ts +++ b/examples/encode-and-compress.ts @@ -20,7 +20,7 @@ const encodingResult = await encodeAndCompress({ // embed the compressed images + metadata into a single // JPEG file -const jpeg = await encodeJPEGMetadata({ +const jpeg = encodeJPEGMetadata({ ...encodingResult, sdr: encodingResult.sdr, gainMap: encodingResult.gainMap diff --git a/examples/encode-jpeg-metadata.ts b/examples/encode-jpeg-metadata.ts index 4ec1b61..6ff15c0 100644 --- a/examples/encode-jpeg-metadata.ts +++ b/examples/encode-jpeg-metadata.ts @@ -47,7 +47,7 @@ const metadata = encodingResult.getMetadata() // embed the compressed images + metadata into a single // JPEG file -const jpeg = await encodeJPEGMetadata({ +const jpeg = encodeJPEGMetadata({ ...encodingResult, ...metadata, sdr, diff --git a/examples/worker.ts b/examples/worker.ts index a280c1b..6efcf69 100644 --- a/examples/worker.ts +++ b/examples/worker.ts @@ -30,7 +30,7 @@ const encodingResult = await encodeAndCompress({ // embed the compressed images + metadata into a single // JPEG file -const jpeg = await encodeJPEGMetadata({ +const jpeg = encodeJPEGMetadata({ ...encodingResult, sdr: encodingResult.sdr, gainMap: encodingResult.gainMap diff --git a/libultrahdr-wasm b/libultrahdr-wasm deleted file mode 160000 index b077d0a..0000000 --- a/libultrahdr-wasm +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b077d0a87166f236d13ad505a6a065d646c2b02b diff --git a/rollup.config.decodeonly.mjs b/rollup.config.decodeonly.mjs deleted file mode 100644 index c3158e0..0000000 --- a/rollup.config.decodeonly.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import commonjs from '@rollup/plugin-commonjs' -import json from '@rollup/plugin-json' -import resolve from '@rollup/plugin-node-resolve' -import terser from '@rollup/plugin-terser' -import typescript from '@rollup/plugin-typescript' -import { defineConfig } from 'rollup' -import del from 'rollup-plugin-delete' -// @ts-expect-error untyped library -import istanbul from 'rollup-plugin-istanbul' -import license from 'rollup-plugin-license' - -import pkgJSON from './package.json' with { type: 'json' } - -const { author, name, version } = pkgJSON - -/** @type {import('rollup').OutputOptions} */ -const settings = { - globals: { - three: 'three' - }, - sourcemap: !!process.env.PLAYWRIGHT_TESTING -} - -const configBase = defineConfig({ - external: ['three'] -}) - -/** @type {import('rollup').InputPluginOption[]} */ -const plugins = [ - json(), - typescript({ - tsconfig: 'src/tsconfig.json', - declaration: true, - sourceMap: !!process.env.PLAYWRIGHT_TESTING, - declarationDir: 'dist', - include: ['src/**/*.ts'], - exclude: ['src/libultrahdr.ts', 'src/libultrahdr/**/*.ts', 'src/encode.ts', 'src/encode/**/*.ts', 'src/worker*.ts'] - }), - resolve(), - commonjs({ - include: 'node_modules/**', - extensions: ['.js'], - ignoreGlobal: false, - sourceMap: !!process.env.PLAYWRIGHT_TESTING - }), - license({ - banner: ` - ${name} v${version} - With ❤️, by ${author} - ` - }) -] - -if (process.env.PLAYWRIGHT_TESTING) { - plugins.push( - istanbul({ - include: ['src/**/*.ts'] - - }) - ) -} - -/** @type {import('rollup').InputPluginOption[]} */ -const pluginsMinified = [ - ...plugins, - terser({ - format: { - comments: (node, comment) => { - // Preserve license banner comments - return comment.value.includes('With ❤️, by ') - } - } - }) -] - -/** @type {import('rollup').RollupOptions[]} */ -let configs = [ - defineConfig({ - input: { - decode: './src/decode.ts' - }, - output: { - dir: 'dist', - name, - format: 'es', - ...settings - }, - plugins: [ - del({ targets: 'dist/*' }), - ...plugins - ], - ...configBase - }), - - // ES modules minified - defineConfig({ - input: { - decode: './src/decode.ts' - }, - output: { - dir: 'dist', - entryFileNames: '[name].min.js', - name, - format: 'es', - ...settings - }, - plugins: [ - ...pluginsMinified - ], - ...configBase - }) -] - -// configs to produce when not testing -// with playwright -if (!process.env.PLAYWRIGHT_TESTING) { - configs = configs.concat([ - // decode UMD - defineConfig({ - input: './src/decode.ts', - output: { - format: 'umd', - name, - file: 'dist/decode.umd.js', - ...settings - }, - plugins, - ...configBase - }), - - // decode UMD minified - defineConfig({ - input: './src/decode.ts', - output: { - format: 'umd', - name, - file: 'dist/decode.umd.min.js', - ...settings - }, - plugins: pluginsMinified, - ...configBase - }) - ]) -} - -export default configs diff --git a/rollup.config.mjs b/rollup.config.mjs index 7f0d53c..4c646c3 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -4,7 +4,6 @@ import resolve from '@rollup/plugin-node-resolve' import terser from '@rollup/plugin-terser' import typescript from '@rollup/plugin-typescript' import { defineConfig } from 'rollup' -import copy from 'rollup-plugin-copy' import del from 'rollup-plugin-delete' // @ts-expect-error untyped library import istanbul from 'rollup-plugin-istanbul' @@ -96,11 +95,6 @@ let configs = [ }, plugins: [ del({ targets: 'dist/*' }), - copy({ - targets: [ - { src: 'libultrahdr-wasm/build/libultrahdr-esm.wasm', dest: 'dist' } - ] - }), ...plugins ], ...configBase @@ -123,14 +117,7 @@ let configs = [ format: 'es', ...settings }, - plugins: [ - copy({ - targets: [ - { src: 'libultrahdr-wasm/build/libultrahdr-esm.wasm', dest: 'dist' } - ] - }), - ...pluginsMinified - ], + plugins: pluginsMinified, ...configBase }), diff --git a/src/core/types.ts b/src/core/types.ts index 8166a71..2f883ea 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -69,6 +69,12 @@ export type GainMapMetadata = { gainMapMax: [number, number, number] } +export type GainMapMetadataExtended = GainMapMetadata & { + version: string + maxContentBoost: number + minContentBoost: number +} + /** * */ diff --git a/src/encode/compress.ts b/src/encode/compress.ts index fc05922..e94bbd5 100644 --- a/src/encode/compress.ts +++ b/src/encode/compress.ts @@ -43,7 +43,7 @@ export const compress = async (params: CompressParameters): Promise | Uint8ClampedArray)], { type: params.sourceMimeType }) + imageBitmapSource = new Blob([source], { type: params.sourceMimeType }) } else if (source instanceof ImageData) { imageBitmapSource = source } else { @@ -61,7 +61,9 @@ export const compress = async (params: CompressParameters): Promise mimeType: CompressionMimeType width: number height: number @@ -167,7 +167,7 @@ export type CompressParameters = CompressOptions & ({ /** * Encoded Image Data with a mimeType */ - source: Uint8Array | Uint8ClampedArray + source: Uint8Array | Uint8ClampedArray /** * mimeType of the encoded input */ diff --git a/src/libultrahdr.ts b/src/libultrahdr.ts index fbe9105..b510ae8 100644 --- a/src/libultrahdr.ts +++ b/src/libultrahdr.ts @@ -1,4 +1,2 @@ -export * from '../libultrahdr-wasm/build/libultrahdr' -export * from './libultrahdr/decode-jpeg-metadata' +// Pure JavaScript implementation export * from './libultrahdr/encode-jpeg-metadata' -export * from './libultrahdr/library' diff --git a/src/libultrahdr/decode-jpeg-metadata.ts b/src/libultrahdr/decode-jpeg-metadata.ts deleted file mode 100644 index 2766966..0000000 --- a/src/libultrahdr/decode-jpeg-metadata.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { GainMapMetadata } from '../core/types' -import { getLibrary } from './library' - -/** - * Decodes a JPEG file with an embedded Gainmap and XMP Metadata (aka JPEG-R) - * - * @category Decoding - * @group Decoding - * @deprecated - * @example - * import { decodeJPEGMetadata } from '@monogrid/gainmap-js/libultrahdr' - * - * // fetch a JPEG image containing a gainmap as ArrayBuffer - * const gainmap = new Uint8Array(await (await fetch('gainmap.jpeg')).arrayBuffer()) - * - * // extract data from the JPEG - * const { gainMap, sdr, parsedMetadata } = await decodeJPEGMetadata(gainmap) - * - * @param file A Jpeg file Uint8Array. - * @returns The decoded data - * @throws {Error} if the provided file cannot be parsed or does not contain a valid Gainmap - */ -/* istanbul ignore next */ -export const decodeJPEGMetadata = async (file: Uint8Array) => { - const lib = await getLibrary() - const result = lib.extractJpegR(file, file.length) - if (!result.success) throw new Error(`${result.errorMessage}`) - - const getXMLValue = (xml: string, tag: string, defaultValue?: string): string | [string, string, string] => { - // Check for attribute format first: tag="value" - const attributeMatch = new RegExp(`${tag}="([^"]*)"`, 'i').exec(xml) - if (attributeMatch) return attributeMatch[1] - - // Check for tag format: value or value... - const tagMatch = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, 'i').exec(xml) - if (tagMatch) { - // Check if it contains rdf:li elements - const liValues = tagMatch[1].match(/([^<]*)<\/rdf:li>/g) - if (liValues && liValues.length === 3) { - return liValues.map(v => v.replace(/<\/?rdf:li>/g, '')) as [string, string, string] - } - return tagMatch[1].trim() - } - - if (defaultValue !== undefined) return defaultValue - throw new Error(`Can't find ${tag} in gainmap metadata`) - } - - const metadata = result.metadata as string - - const gainMapMin = getXMLValue(metadata, 'hdrgm:GainMapMin', '0') - const gainMapMax = getXMLValue(metadata, 'hdrgm:GainMapMax') - const gamma = getXMLValue(metadata, 'hdrgm:Gamma', '1') - const offsetSDR = getXMLValue(metadata, 'hdrgm:OffsetSDR', '0.015625') - const offsetHDR = getXMLValue(metadata, 'hdrgm:OffsetHDR', '0.015625') - - // These are always attributes, so we can use a simpler regex - const hdrCapacityMinMatch = /hdrgm:HDRCapacityMin="([^"]*)"/.exec(metadata) - const hdrCapacityMin = hdrCapacityMinMatch ? hdrCapacityMinMatch[1] : '0' - - const hdrCapacityMaxMatch = /hdrgm:HDRCapacityMax="([^"]*)"/.exec(metadata) - if (!hdrCapacityMaxMatch) throw new Error('Incomplete gainmap metadata') - const hdrCapacityMax = hdrCapacityMaxMatch[1] - - const parsedMetadata: GainMapMetadata = { - gainMapMin: Array.isArray(gainMapMin) ? gainMapMin.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(gainMapMin), parseFloat(gainMapMin), parseFloat(gainMapMin)], - gainMapMax: Array.isArray(gainMapMax) ? gainMapMax.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(gainMapMax), parseFloat(gainMapMax), parseFloat(gainMapMax)], - gamma: Array.isArray(gamma) ? gamma.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(gamma), parseFloat(gamma), parseFloat(gamma)], - offsetSdr: Array.isArray(offsetSDR) ? offsetSDR.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(offsetSDR), parseFloat(offsetSDR), parseFloat(offsetSDR)], - offsetHdr: Array.isArray(offsetHDR) ? offsetHDR.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(offsetHDR), parseFloat(offsetHDR), parseFloat(offsetHDR)], - hdrCapacityMin: parseFloat(hdrCapacityMin), - hdrCapacityMax: parseFloat(hdrCapacityMax) - } - - return { - ...result, - parsedMetadata - } -} diff --git a/src/libultrahdr/encode-jpeg-metadata.ts b/src/libultrahdr/encode-jpeg-metadata.ts index adf36ba..0499aec 100644 --- a/src/libultrahdr/encode-jpeg-metadata.ts +++ b/src/libultrahdr/encode-jpeg-metadata.ts @@ -1,6 +1,6 @@ -import { type GainMapMetadata } from '../core/types' +import { GainMapMetadata, GainMapMetadataExtended } from '../core/types' import { type CompressedImage } from '../encode/types' -import { getLibrary } from './library' +import { assembleJpegWithGainMap } from './jpeg-assembler' /** * Encapsulates a Gainmap into a single JPEG file (aka: JPEG-R) with the base map @@ -65,7 +65,7 @@ import { getLibrary } from './library' * * // embed the compressed images + metadata into a single * // JPEG file - * const jpeg = await encodeJPEGMetadata({ + * const jpeg = encodeJPEGMetadata({ * ...encodingResult, * ...metadata, * sdr, @@ -75,27 +75,43 @@ import { getLibrary } from './library' * // `jpeg` will be an `Uint8Array` which can be saved somewhere * * - * @param encodingResult - * @returns an Uint8Array representing a JPEG-R file + * @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'` */ -export const encodeJPEGMetadata = async (encodingResult: GainMapMetadata & { sdr: CompressedImage, gainMap: CompressedImage }) => { - const lib = await getLibrary() +export const encodeJPEGMetadata = (encodingResult: GainMapMetadata & { sdr: CompressedImage, gainMap: CompressedImage }) => { + // 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') + } - 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: GainMapMetadataExtended = { + 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) + } - return lib.appendGainMap( - encodingResult.sdr.width, encodingResult.sdr.height, - encodingResult.sdr.data, encodingResult.sdr.data.length, - encodingResult.gainMap.data, encodingResult.gainMap.data.length, - encodingResult.gainMapMax.reduce((p, n) => p + n, 0) / encodingResult.gainMapMax.length, - encodingResult.gainMapMin.reduce((p, n) => p + n, 0) / encodingResult.gainMapMin.length, - encodingResult.gamma.reduce((p, n) => p + n, 0) / encodingResult.gamma.length, - encodingResult.offsetSdr.reduce((p, n) => p + n, 0) / encodingResult.offsetSdr.length, - encodingResult.offsetHdr.reduce((p, n) => p + n, 0) / encodingResult.offsetHdr.length, - encodingResult.hdrCapacityMin, - encodingResult.hdrCapacityMax - ) as Uint8Array + // Assemble the JPEG with gain map using pure JavaScript + return assembleJpegWithGainMap({ + sdr: encodingResult.sdr, + gainMap: encodingResult.gainMap, + metadata + }) } diff --git a/src/libultrahdr/jpeg-assembler.ts b/src/libultrahdr/jpeg-assembler.ts new file mode 100644 index 0000000..bc29272 --- /dev/null +++ b/src/libultrahdr/jpeg-assembler.ts @@ -0,0 +1,277 @@ +/** + * JPEG assembler for creating JPEG-R (JPEG with gain map) files + * Based on libultrahdr jpegr.cpp implementation + */ + +import { GainMapMetadataExtended } from '../core/types' +import { type CompressedImage } from '../encode/types' +import { MARKER_PREFIX, MARKERS, XMP_NAMESPACE } from './jpeg-markers' +import { calculateMpfSize, generateMpf } from './mpf-generator' +import { generateXmpForPrimaryImage, generateXmpForSecondaryImage } from './xmp-generator' + +/** + * Options for assembling a JPEG with gain map + */ +export interface AssembleJpegOptions { + /** Primary (SDR) JPEG image */ + sdr: CompressedImage + /** Gain map JPEG image */ + gainMap: CompressedImage + /** Gain map metadata */ + metadata: GainMapMetadataExtended + /** Optional EXIF data to embed */ + exif?: Uint8Array + /** Optional ICC color profile */ + icc?: Uint8Array +} + +/** + * 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: Uint8Array) { + 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: Uint8Array, exifPos: number, exifSize: number) { + 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: Uint8Array, pos: number, marker: number, data?: Uint8Array) { + 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 + */ +export function assembleJpegWithGainMap (options: AssembleJpegOptions) { + 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 +} diff --git a/src/libultrahdr/jpeg-markers.ts b/src/libultrahdr/jpeg-markers.ts new file mode 100644 index 0000000..d1da877 --- /dev/null +++ b/src/libultrahdr/jpeg-markers.ts @@ -0,0 +1,53 @@ +/** + * JPEG marker constants + * Based on JPEG specification and libultrahdr implementation + */ + +/** + * JPEG marker prefix - all markers start with this byte + */ +export const MARKER_PREFIX = 0xff + +/** + * JPEG markers + */ +export const MARKERS = { + /** Start of Image */ + SOI: 0xd8, + /** End of Image */ + EOI: 0xd9, + /** Application segment 0 */ + APP0: 0xe0, + /** Application segment 1 (EXIF/XMP) */ + APP1: 0xe1, + /** Application segment 2 (ICC/MPF) */ + APP2: 0xe2, + /** Start of Scan */ + SOS: 0xda, + /** Define Quantization Table */ + DQT: 0xdb, + /** Define Huffman Table */ + DHT: 0xc4, + /** Start of Frame (baseline DCT) */ + SOF0: 0xc0 +} as const + +/** + * XMP namespace identifier for APP1 marker + */ +export const XMP_NAMESPACE = 'http://ns.adobe.com/xap/1.0/\0' + +/** + * EXIF identifier for APP1 marker + */ +export const EXIF_IDENTIFIER = 'Exif\0\0' + +/** + * MPF signature for APP2 marker + */ +export const MPF_SIGNATURE = 'MPF\0' + +/** + * ICC profile identifier for APP2 marker + */ +export const ICC_IDENTIFIER = 'ICC_PROFILE\0' diff --git a/src/libultrahdr/library.ts b/src/libultrahdr/library.ts deleted file mode 100644 index 9e35ab4..0000000 --- a/src/libultrahdr/library.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { MainModule } from '../../libultrahdr-wasm/build/libultrahdr' -// @ts-expect-error untyped -import libultrahdr from '../../libultrahdr-wasm/build/libultrahdr-esm' - -let library: MainModule | undefined - -/** - * Instances the WASM module and returns it, only one module will be created upon multiple calls. - * @category WASM - * @group WASM - * - * @returns - */ -export const getLibrary = async () => { - if (!library) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - library = await libultrahdr() as MainModule - } - return library -} diff --git a/src/libultrahdr/mpf-generator.ts b/src/libultrahdr/mpf-generator.ts new file mode 100644 index 0000000..b4b9599 --- /dev/null +++ b/src/libultrahdr/mpf-generator.ts @@ -0,0 +1,202 @@ +/** + * 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]), + + /** Little endian marker "II" */ + LITTLE_ENDIAN: new Uint8Array([0x49, 0x49]), + + /** 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 +} as const + +/** + * 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 +} as const + +/** + * MPF tag types + */ +const MPF_TAG_TYPES = { + /** Undefined type */ + UNDEFINED: 7, + + /** Unsigned long type */ + ULONG: 4 +} as const + +/** + * MP entry attributes + */ +const MP_ENTRY_ATTRIBUTES = { + /** JPEG format */ + FORMAT_JPEG: 0x00000000, + + /** Primary image type */ + TYPE_PRIMARY: 0x20000000 +} as const + +/** + * MPF version string + */ +const MPF_VERSION = new Uint8Array([0x30, 0x31, 0x30, 0x30]) // "0100" + +/** + * Calculate the total size of the MPF structure + */ +export function calculateMpfSize (): number { + 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 + */ +export function generateMpf (primaryImageSize: number, primaryImageOffset: number, secondaryImageSize: number, secondaryImageOffset: number) { + 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 +} diff --git a/src/libultrahdr/xmp-generator.ts b/src/libultrahdr/xmp-generator.ts new file mode 100644 index 0000000..a3026fe --- /dev/null +++ b/src/libultrahdr/xmp-generator.ts @@ -0,0 +1,148 @@ +/** + * XMP metadata generator for gain map images + * Based on libultrahdr jpegrutils.cpp implementation + */ + +import { type GainMapMetadataExtended } from '../core/types' + +/** + * Item semantic types + */ +const ITEM_SEMANTIC = { + PRIMARY: 'Primary', + GAIN_MAP: 'GainMap' +} as const + +/** + * MIME type for JPEG images + */ +const MIME_IMAGE_JPEG = 'image/jpeg' + +/** + * Escape XML special characters + */ +function escapeXml (str: string | number): string { + 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 + */ +export function generateXmpForPrimaryImage ( + secondaryImageLength: number, + metadata: GainMapMetadataExtended +): string { + const lines: string[] = [] + + // 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 + */ +export function generateXmpForSecondaryImage (metadata: GainMapMetadataExtended): string { + const lines: string[] = [] + + // 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: number | [number, number, number]): number => { + 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') +} diff --git a/tests/encode/encode-and-compress.ts b/tests/encode/encode-and-compress.ts index d622aaa..1cf705a 100644 --- a/tests/encode/encode-and-compress.ts +++ b/tests/encode/encode-and-compress.ts @@ -40,7 +40,7 @@ export const encodeAndCompressInBrowser = async (args: Omit { expect(meta!.gainMapMin, 'gainMapMin is not default value').toEqual([0, 0, 0]) // default value const extracted = await page.evaluate(testMPFExtractorInBrowser, result.jpeg) + // await fs.writeFile('result.jpg', Buffer.from(result.jpeg)) + // await fs.writeFile('extracted-0.jpg', Buffer.from(extracted[0])) + // await fs.writeFile('extracted-1.jpg', Buffer.from(extracted[1])) const resized = await sharp(Buffer.from(extracted[0])) .resize({ width: 500, height: 500, fit: 'inside' }) diff --git a/tests/encode/encode.ts b/tests/encode/encode.ts index 77a67cd..08cb568 100644 --- a/tests/encode/encode.ts +++ b/tests/encode/encode.ts @@ -77,7 +77,7 @@ export const encodeInBrowser = async (args: Omit