diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 98e21da07..175645b89 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -34,6 +34,7 @@ jobs: sudo apt-get update sudo apt-get install imagemagick sudo apt-get install ffmpeg + sudo apt-get install libheif-plugin-libde265 - uses: actions/checkout@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 964d7baed..338b30178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [2.31.0] - Not released +### Added +- Added JPEG Ultra HDR / JPEG_R image preview generation. For supported JPEG + images with gain maps, the server now creates a second set of JPEG previews + with gain maps and stores them as HDR-specific preview variants. + + Attachments with generated HDR previews expose `meta.hdr: true`, and clients + can request the HDR version with `GET /v4/attachments/:id/image?variant=hdr`. + The `variant=hdr` query parameter is safe to send for any image attachment: if + an HDR preview is not available, the endpoint falls back to the regular SDR + preview. HDR preview responses include `variant: "hdr"`. ## [2.30.0] - 2026-04-29 ### Added diff --git a/app/controllers/api/v1/AttachmentsController.js b/app/controllers/api/v1/AttachmentsController.js index 4923fc9d8..4d69344e3 100644 --- a/app/controllers/api/v1/AttachmentsController.js +++ b/app/controllers/api/v1/AttachmentsController.js @@ -194,6 +194,10 @@ export default class AttachmentsController { throw new ValidationException('Invalid format value'); } + if ('variant' in query && query.variant !== 'hdr') { + throw new ValidationException('Invalid variant value'); + } + const width = 'width' in query ? Number.parseInt(query.width, 10) : undefined; const height = 'height' in query ? Number.parseInt(query.height, 10) : undefined; @@ -206,6 +210,7 @@ export default class AttachmentsController { const asRedirect = 'redirect' in query; const withDownload = 'download' in query; + const preferHdr = query.variant === 'hdr'; const attachment = await dbAdapter.getAttachmentById(attId); @@ -235,7 +240,8 @@ export default class AttachmentsController { } else { // Visual types, 'image' and 'video' - const previews = attachment.previews[type]; + const useHdrPreview = type === 'image' && preferHdr && attachment.previews.imageHDR; + const previews = useHdrPreview ? attachment.previews.imageHDR : attachment.previews[type]; const { variant, width: resWidth, @@ -248,11 +254,17 @@ export default class AttachmentsController { response.width = prv.w; response.height = prv.h; + if (useHdrPreview) { + response.variant = 'hdr'; + } + // With imgproxy, we can resize images (except some types) and change // their format if ( useImgProxy && type === 'image' && + // imgproxy doesn't support HDR images + !useHdrPreview && // We don't need to resize SVGs attachment.fileExtension !== 'svg' && // We should not resize (probably) animated legacy GIFs diff --git a/app/serializers/v2/attachment.ts b/app/serializers/v2/attachment.ts index f5eef0c5c..361ab4c7c 100644 --- a/app/serializers/v2/attachment.ts +++ b/app/serializers/v2/attachment.ts @@ -139,8 +139,12 @@ function serializeAttachmentV2(att: Attachment): SerializedAttachmentV2 { function serializeAttachmentV4(att: Attachment): SerializedAttachmentV4 { const maxVar = att.maxSizedVariant('image'); const maxPrv = att.previews.image?.[maxVar ?? '-']; + const meta: MediaMetaData = { + ...att.meta, + ...(att.previews.imageHDR ? { hdr: true } : {}), + }; - const isMetaEmpty = Object.keys(att.meta).length === 0; + const isMetaEmpty = Object.keys(meta).length === 0; const isPreviewSizesDifferent = maxPrv && (maxPrv.w !== att.width || maxPrv.h !== att.height); return { @@ -148,8 +152,10 @@ function serializeAttachmentV4(att: Attachment): SerializedAttachmentV4 { mediaType: att.mediaType, fileName: att.fileName, fileSize: att.fileSize, - previewTypes: Object.keys(att.previews).sort() as (keyof MediaPreviews)[], - meta: isMetaEmpty ? undefined : att.meta, + previewTypes: Object.keys(att.previews) + .filter((type) => type !== 'imageHDR') + .sort() as (keyof MediaPreviews)[], + meta: isMetaEmpty ? undefined : meta, width: att.width ?? undefined, height: att.height ?? undefined, diff --git a/app/support/media-files/jpeg-hdr.ts b/app/support/media-files/jpeg-hdr.ts new file mode 100644 index 000000000..853bf9b21 --- /dev/null +++ b/app/support/media-files/jpeg-hdr.ts @@ -0,0 +1,548 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; + +import { exiftoolPath } from 'exiftool-vendored'; + +import { runImageMagick } from '../image-magick'; +import { spawnAsync } from '../spawn-async'; + +type CreateJpegHdrPreviewOptions = { + sourcePath: string; + targetPath: string; + width: number; + height: number; + quality?: number; +}; + +export type JpegHdrPreviewInfo = { + numberOfImages?: number; + mpImage2Length?: number; + directoryItemLength?: number; + gainMapImageLength?: number; + iccProfileDescription?: string; + validate?: string; + warnings: string[]; +}; + +const MPF_SEGMENT_LENGTH = 90; +const JPEG_SOI = Buffer.from([0xff, 0xd8]); +const JPEG_MARKER_PREFIX = 0xff; + +/** + * Creates a JPEG Ultra HDR preview from a JPEG source with an embedded gain map. + * + * Returns false when the source does not expose a JPEG gain map. Throws on + * unexpected processing or validation failures. + */ +export async function createJpegHdrPreview({ + sourcePath, + targetPath, + width, + height, + quality = 90, +}: CreateJpegHdrPreviewOptions): Promise { + const workDir = await mkdtemp(join(dirname(targetPath), '.hdr-')); + + try { + const gainMapPath = join(workDir, 'gain-map.jpg'); + const resizedGainMapPath = join(workDir, 'gain-map-resized.jpg'); + const iccPath = join(workDir, 'profile.icc'); + const basePath = join(workDir, 'base.jpg'); + + const gainMap = await extractGainMap(sourcePath); + + if (!gainMap) { + return false; + } + + await writeFile(gainMapPath, gainMap); + + const sourceSize = await identifySize(sourcePath); + const gainMapSize = await identifySize(gainMapPath); + // The gain map must keep the same relative scale as in the original file. + const gainMapTarget = scaledSize({ width, height }, sourceSize, gainMapSize); + + const hasIcc = await extractIccProfile(sourcePath, iccPath); + + await createCleanBaseJpeg( + sourcePath, + basePath, + width, + height, + quality, + hasIcc ? iccPath : null, + ); + await resizeGainMap(gainMapPath, resizedGainMapPath, gainMapTarget.width, gainMapTarget.height); + + const resizedGainMapSize = await stat(resizedGainMapPath).then((s) => s.size); + const baseWithXmpPath = join(workDir, 'base-with-xmp.jpg'); + await insertApp1Xmp(basePath, baseWithXmpPath, resizedGainMapSize); + await assembleMpfJpeg(baseWithXmpPath, resizedGainMapPath, targetPath); + + // The generated file must be self-consistent before it enters previews storage. + const info = await inspectJpegHdrPreview(targetPath); + const { validate, numberOfImages, mpImage2Length, directoryItemLength, gainMapImageLength } = + info; + + return ( + validate === 'OK' && + numberOfImages === 2 && + mpImage2Length === resizedGainMapSize && + directoryItemLength === resizedGainMapSize && + gainMapImageLength === resizedGainMapSize + ); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +} + +/** + * Reads the pieces that prove a generated JPEG is usable as an Ultra HDR preview. + */ +export async function inspectJpegHdrPreview(filePath: string): Promise { + const [tags, validation] = await Promise.all([ + exiftool([ + '-G1', + '-a', + '-s', + '-MPF:all', + '-MPImage2:all', + '-XMP-GContainer:all', + '-ICC_Profile:ProfileDescription', + '-Google:GainMapImage', + filePath, + ]), + exiftool(['-G1', '-a', '-s', '-warning', '-validate', filePath]), + ]); + + return { + numberOfImages: intTag(tags, 'NumberOfImages'), + mpImage2Length: intTagInGroup(tags, 'MPImage2', 'MPImageLength'), + directoryItemLength: intTag(tags, 'DirectoryItemLength') ?? intTag(tags, 'DirectoryLength'), + gainMapImageLength: binaryLength(tags, 'GainMapImage') ?? binaryLength(tags, 'MPImage2'), + iccProfileDescription: stringTag(tags, 'ProfileDescription'), + validate: stringTag(validation, 'Validate'), + warnings: tagValues(validation, 'Warning'), + }; +} + +/** + * Extracts the JPEG gain map payload from known ExifTool tag names. + */ +async function extractGainMap(sourcePath: string): Promise { + const exe = await exiftoolPath(); + const gainMap = await extractGainMapTag(exe, sourcePath, 'GainMapImage'); + + if (gainMap) { + return gainMap; + } + + return extractGainMapTag(exe, sourcePath, 'MPImage2'); +} + +/** + * Extracts one binary ExifTool tag and accepts only JPEG payloads. + */ +async function extractGainMapTag( + exe: string, + sourcePath: string, + tag: string, +): Promise { + try { + const { stdout } = await spawnAsync(exe, ['-b', `-${tag}`, sourcePath], { binary: true }); + return isJpeg(stdout) ? stdout : null; + } catch { + return null; + } +} + +/** + * Checks for the JPEG start-of-image marker. + */ +function isJpeg(data: Buffer): boolean { + return data.length >= JPEG_SOI.length && data.subarray(0, JPEG_SOI.length).equals(JPEG_SOI); +} + +/** + * Extracts the source ICC profile into a standalone profile file. + */ +async function extractIccProfile(sourcePath: string, targetPath: string): Promise { + try { + await runImageMagick('convert', [sourcePath, targetPath]); + return true; + } catch { + return false; + } +} + +/** + * Creates the primary JPEG preview without EXIF/GPS/private metadata. + */ +async function createCleanBaseJpeg( + sourcePath: string, + targetPath: string, + width: number, + height: number, + quality: number, + iccPath: string | null, +): Promise { + await runImageMagick('convert', [ + sourcePath, + '-auto-orient', + ['-resize', `${width}!x${height}!`], + // Drop all profiles first, then put back only the original ICC profile. + '+profile', + '*', + ...(iccPath ? ['-profile', iccPath] : []), + ['-quality', quality.toString()], + targetPath, + ]); +} + +/** + * Resizes the extracted gain map to match the primary preview scale. + */ +async function resizeGainMap( + sourcePath: string, + targetPath: string, + width: number, + height: number, +): Promise { + await runImageMagick('convert', [ + sourcePath, + ['-resize', `${width}!x${height}!`], + ['-quality', '90'], + targetPath, + ]); +} + +/** + * Returns ImageMagick-reported pixel dimensions. + */ +async function identifySize(filePath: string): Promise<{ width: number; height: number }> { + const { stdout } = await runImageMagick('identify', ['-format', '%w %h', filePath]); + const match = stdout.trim().match(/^(\d+)\s+(\d+)$/); + + if (!match) { + throw new Error(`Cannot identify image size: ${filePath}`); + } + + const width = parseInt(match[1], 10); + const height = parseInt(match[2], 10); + + if (width <= 0 || height <= 0) { + throw new Error(`Invalid image size ${width}x${height}: ${filePath}`); + } + + return { width, height }; +} + +/** + * Scales a subject size by the same ratio as source -> target. + */ +function scaledSize( + target: { width: number; height: number }, + source: { width: number; height: number }, + subject: { width: number; height: number }, +): { width: number; height: number } { + return { + width: Math.max(1, Math.round((target.width * subject.width) / source.width)), + height: Math.max(1, Math.round((target.height * subject.height) / source.height)), + }; +} + +/** + * Inserts APP1 XMP metadata into the primary JPEG. + */ +async function insertApp1Xmp( + basePath: string, + targetPath: string, + gainMapLength: number, +): Promise { + const base = await readFile(basePath); + const xmp = buildPrimaryXmp(gainMapLength); + const insertOffset = findMetadataInsertOffset(base); + await writeFile( + targetPath, + Buffer.concat([base.subarray(0, insertOffset), xmp, base.subarray(insertOffset)]), + ); +} + +/** + * Builds the final Ultra HDR JPEG: primary JPEG + MPF APP2 + appended gain map. + */ +async function assembleMpfJpeg( + basePath: string, + gainMapPath: string, + targetPath: string, +): Promise { + const base = await readFile(basePath); + const gainMap = await readFile(gainMapPath); + const insertOffset = findMetadataInsertOffset(base); + // MPF stores the final primary image size, including the MPF segment itself. + const primaryLength = base.length + MPF_SEGMENT_LENGTH; + const mpf = buildMpfSegment({ + mpfOffset: insertOffset, + primaryLength, + gainMapLength: gainMap.length, + }); + + await writeFile( + targetPath, + Buffer.concat([base.subarray(0, insertOffset), mpf, base.subarray(insertOffset), gainMap]), + ); +} + +/** + * Builds the APP1 XMP segment that describes the appended gain map. + */ +function buildPrimaryXmp(gainMapLength: number): Buffer { + const xml = + `\n` + + `\n` + + `\n` + + `\n` + + `` + + `` + + `image/jpegPrimary` + + `` + + `` + + `${gainMapLength}image/jpeg` + + `GainMap` + + `\n\n` + + `` + + `1.0` + + `\n\n\n`; + const payload = Buffer.concat([ + // Standard APP1 XMP namespace prefix. + Buffer.from('http://ns.adobe.com/xap/1.0/\0', 'latin1'), + Buffer.from(xml, 'utf8'), + ]); + + return jpegSegment(0xe1, payload); +} + +/** + * Builds the APP2 MPF segment with entries for primary image and gain map. + */ +function buildMpfSegment({ + mpfOffset, + primaryLength, + gainMapLength, +}: { + mpfOffset: number; + primaryLength: number; + gainMapLength: number; +}): Buffer { + const dataLength = 86; + const data = Buffer.alloc(dataLength); + data.write('MPF\0', 0, 'latin1'); + + // MPF contains a TIFF-like little-endian directory after the "MPF\0" header. + const tiff = 4; + data.write('II', tiff, 'latin1'); + data.writeUInt16LE(42, tiff + 2); + data.writeUInt32LE(8, tiff + 4); + + const ifd = tiff + 8; + data.writeUInt16LE(3, ifd); + let entry = ifd + 2; + + // MPFVersion, NumberOfImages, and MPEntry array. + writeIfdEntry(data, entry, 0xb000, 7, 4, Buffer.from('0100', 'latin1')); + entry += 12; + writeIfdEntry(data, entry, 0xb001, 4, 1, 2); + entry += 12; + writeIfdEntry(data, entry, 0xb002, 7, 32, 50); + entry += 12; + data.writeUInt32LE(0, entry); + + // MP entries are stored at the offset advertised by the MPEntry IFD tag. + const mpEntries = tiff + 50; + writeMpEntry(data, mpEntries, { + attributes: 0x00030000, + size: primaryLength, + offset: 0, + }); + writeMpEntry(data, mpEntries + 16, { + attributes: 0, + size: gainMapLength, + offset: mpImageOffset(primaryLength, mpfOffset), + }); + + return jpegSegment(0xe2, data); +} + +/** + * Returns the gain map MPF offset relative to the TIFF header start. + */ +function mpImageOffset(primaryLength: number, mpfOffset: number): number { + const offset = primaryLength - (mpfOffset + 8); + + if (offset < 0) { + throw new Error('Invalid MPF image offset'); + } + + return offset; +} + +/** + * Writes one 12-byte TIFF IFD entry into the MPF payload. + */ +function writeIfdEntry( + data: Buffer, + offset: number, + tag: number, + type: number, + count: number, + value: number | Buffer, +): void { + data.writeUInt16LE(tag, offset); + data.writeUInt16LE(type, offset + 2); + data.writeUInt32LE(count, offset + 4); + + if (Buffer.isBuffer(value)) { + value.copy(data, offset + 8); + } else { + data.writeUInt32LE(value, offset + 8); + } +} + +/** + * Writes one 16-byte MP image entry. + */ +function writeMpEntry( + data: Buffer, + offset: number, + { + attributes, + size, + offset: imageOffset, + }: { + attributes: number; + size: number; + offset: number; + }, +): void { + data.writeUInt32LE(attributes, offset); + data.writeUInt32LE(size, offset + 4); + data.writeUInt32LE(imageOffset, offset + 8); + data.writeUInt16LE(0, offset + 12); + data.writeUInt16LE(0, offset + 14); +} + +/** + * Wraps payload bytes into a JPEG APP segment. + */ +function jpegSegment(marker: number, payload: Buffer): Buffer { + const segment = Buffer.alloc(4 + payload.length); + segment[0] = 0xff; + segment[1] = marker; + segment.writeUInt16BE(payload.length + 2, 2); + payload.copy(segment, 4); + return segment; +} + +/** + * Finds the offset before JPEG image data/table segments where metadata belongs. + */ +function findMetadataInsertOffset(jpeg: Buffer): number { + if (!isJpeg(jpeg)) { + throw new Error('Not a JPEG file'); + } + + let offset = 2; + + while (offset < jpeg.length) { + if (offset + 4 > jpeg.length) { + throw new Error(`Truncated JPEG marker at offset ${offset}`); + } + + if (jpeg[offset] !== JPEG_MARKER_PREFIX) { + throw new Error(`Invalid JPEG marker at offset ${offset}`); + } + + const marker = jpeg[offset + 1]; + + // Stop before scan/image data or baseline/progressive/table segments. + if ( + marker === 0xda || + marker === 0xdb || + marker === 0xc0 || + marker === 0xc2 || + marker === 0xc4 || + marker === 0xd9 + ) { + return offset; + } + + offset += 2 + jpeg.readUInt16BE(offset + 2); + } + + throw new Error('Cannot find JPEG metadata insert offset'); +} + +/** + * Runs ExifTool and returns text output. + */ +async function exiftool(args: string[]): Promise { + const exe = await exiftoolPath(); + const { stdout } = await spawnAsync(exe, args); + return stdout; +} + +/** + * Reads the first integer value from an ExifTool tag. + */ +function intTag(output: string, tag: string): number | undefined { + const value = stringTag(output, tag); + return value ? firstInteger(value) : undefined; +} + +/** + * Reads the first integer value from a tag in a specific ExifTool group. + */ +function intTagInGroup(output: string, group: string, tag: string): number | undefined { + const value = stringTagInGroup(output, group, tag); + return value ? firstInteger(value) : undefined; +} + +/** + * Reads the byte length from ExifTool binary tag output. + */ +function binaryLength(output: string, tag: string): number | undefined { + const value = stringTag(output, tag); + return value ? firstInteger(value) : undefined; +} + +/** + * Extracts the first decimal integer from a string. + */ +function firstInteger(value: string): number | undefined { + const match = value.match(/\d+/); + return match ? parseInt(match[0], 10) : undefined; +} + +/** + * Reads the first ExifTool tag value regardless of group. + */ +function stringTag(output: string, tag: string): string | undefined { + return tagValues(output, tag)[0]; +} + +/** + * Reads the first ExifTool tag value from the given group. + */ +function stringTagInGroup(output: string, group: string, tag: string): string | undefined { + const re = new RegExp(`^\\[${group}\\]\\s+${tag}\\s+:\\s+(.+)$`, 'gm'); + return [...output.matchAll(re)].map((m) => m[1])[0]; +} + +/** + * Reads all ExifTool tag values with the given tag name. + */ +function tagValues(output: string, tag: string): string[] { + const re = new RegExp(`^\\[[^\\]]+\\]\\s+${tag}\\s+:\\s+(.+)$`, 'gm'); + return [...output.matchAll(re)].map((m) => m[1]); +} diff --git a/app/support/media-files/process.ts b/app/support/media-files/process.ts index 8f185e34a..7458efade 100644 --- a/app/support/media-files/process.ts +++ b/app/support/media-files/process.ts @@ -23,6 +23,7 @@ import { } from './types'; import { getImagePreviewSizes, getVideoPreviewSizes } from './geometry'; import { setExtension } from './file-ext'; +import { createJpegHdrPreview } from './jpeg-hdr'; const __dirname = nodeDirname(import.meta.url); @@ -107,7 +108,16 @@ export async function processMediaFile( if (info.type === 'image') { const [imagePreviews, files] = await processImage(info, localFilePath); - return { mediaType: 'image', ...commonResult, previews: { image: imagePreviews }, files }; + const [imageHDRPreviews, hdrFiles] = await processImageHdr(info, localFilePath, imagePreviews); + return { + mediaType: 'image', + ...commonResult, + previews: { + image: imagePreviews, + ...(imageHDRPreviews ? { imageHDR: imageHDRPreviews } : {}), + }, + files: { ...files, ...hdrFiles }, + }; } if (info.type === 'audio') { @@ -278,6 +288,79 @@ async function processImage( return [previews, filesToUpload]; } +async function processImageHdr( + info: MediaInfoImage, + localFilePath: string, + imagePreviews: VisualPreviews, +): Promise<[VisualPreviews | null, FilesToUpload]> { + if (info.format !== 'jpeg') { + return [null, {}]; + } + + const quality = currentConfig().attachments.previews.imagePreviewQuality; + const previews: VisualPreviews = {}; + const filesToUpload: FilesToUpload = {}; + const entries = Object.entries(imagePreviews); + + if (entries.length === 0) { + return [null, {}]; + } + + const [[firstVariant, firstPreview]] = entries; + const firstCreated = await createHdrPreview( + localFilePath, + quality, + firstVariant, + firstPreview, + previews, + filesToUpload, + ); + + if (!firstCreated) { + return [null, {}]; + } + + await Promise.all( + entries + .slice(1) + .map(([variant, preview]) => + createHdrPreview(localFilePath, quality, variant, preview, previews, filesToUpload), + ), + ); + + return [previews, filesToUpload]; +} + +async function createHdrPreview( + localFilePath: string, + quality: number, + variant: string, + { w, h }: { w: number; h: number }, + previews: VisualPreviews, + filesToUpload: FilesToUpload, +): Promise { + const hdrVariant = hdrVariantName(variant); + const targetPath = tmpFileVariant(localFilePath, hdrVariant, 'jpg'); + const created = await createJpegHdrPreview({ + sourcePath: localFilePath, + targetPath, + width: w, + height: h, + quality, + }); + + if (created) { + previews[hdrVariant] = { w, h, ext: 'jpg' }; + filesToUpload[hdrVariant] = { path: targetPath, ext: 'jpg' }; + } + + return created; +} + +function hdrVariantName(variant: string): string { + return variant ? `${variant}-hdr` : 'original-hdr'; +} + async function processAudio( info: MediaInfoAudio, localFilePath: string, diff --git a/app/support/media-files/types.ts b/app/support/media-files/types.ts index d6d3f4a91..a959b7a58 100644 --- a/app/support/media-files/types.ts +++ b/app/support/media-files/types.ts @@ -53,6 +53,7 @@ export type MediaInfo = MediaInfoImage | MediaInfoVideo | MediaInfoAudio | Media export type MediaMetaData = { animatedImage?: true; + hdr?: true; silent?: true; inProgress?: true; originalExtension?: string; @@ -60,6 +61,7 @@ export type MediaMetaData = { export type MediaPreviews = { image?: VisualPreviews; + imageHDR?: VisualPreviews; video?: VisualPreviews; audio?: NonVisualPreviews; }; diff --git a/test/fixtures/media-files/README.txt b/test/fixtures/media-files/README.txt index 268f5d106..4cded7032 100644 --- a/test/fixtures/media-files/README.txt +++ b/test/fixtures/media-files/README.txt @@ -8,4 +8,7 @@ The "music" audio has CC0 license and taken from Wikimedia Commons: https://commons.wikimedia.org/wiki/File:Piermic_-_Improvisation_with_Sopranino_Recorder_(2015).mp3 The "polyphon" video has CC0 license and taken from Wikimedia Commons: -https://commons.wikimedia.org/wiki/File:Polyphon-_Kreuz-Polka.ogv \ No newline at end of file +https://commons.wikimedia.org/wiki/File:Polyphon-_Kreuz-Polka.ogv + +The "Ultra_HDR_Samples_Originals_01.jpg" has CC Attribution 4.0 International license and taken from: +https://github.com/MishaalRahmanGH/Ultra_HDR_Samples diff --git a/test/fixtures/media-files/Ultra_HDR_Samples_Originals_01.jpg b/test/fixtures/media-files/Ultra_HDR_Samples_Originals_01.jpg new file mode 100644 index 000000000..3355b466f Binary files /dev/null and b/test/fixtures/media-files/Ultra_HDR_Samples_Originals_01.jpg differ diff --git a/test/functional/attachments.js b/test/functional/attachments.js index 54f87948c..9fd53120a 100644 --- a/test/functional/attachments.js +++ b/test/functional/attachments.js @@ -221,6 +221,57 @@ describe('Attachments', () => { }); }); + it(`should expose HDR image previews when available`, async () => { + const filePath = path.join( + __dirname, + '../fixtures/media-files/Ultra_HDR_Samples_Originals_01.jpg', + ); + const data = new FormData(); + data.append('file', await fileFrom(filePath, 'image/jpeg')); + const resp = await performJSONRequest('POST', '/v4/attachments', data, authHeaders(luna)); + const { id } = resp.attachments; + const attObj = await dbAdapter.getAttachmentById(id); + + expect(resp.attachments, 'to satisfy', { + mediaType: 'image', + previewTypes: ['image'], + meta: { hdr: true }, + }); + + expect(attObj.previews, 'to satisfy', { + image: { + p4: { ext: 'webp' }, + }, + imageHDR: { + 'p4-hdr': { ext: 'jpg' }, + }, + }); + + const sdrPreview = await performJSONRequest( + 'GET', + `/v4/attachments/${id}/image?width=1000&height=1000`, + ); + expect(sdrPreview, 'to satisfy', { + url: expect.it('to end with', `/p4/${id}.webp`), + mimeType: 'image/webp', + width: 2305, + height: 1735, + }); + expect(sdrPreview, 'not to have key', 'variant'); + + const hdrPreview = await performJSONRequest( + 'GET', + `/v4/attachments/${id}/image?variant=hdr&width=1000&height=1000`, + ); + expect(hdrPreview, 'to satisfy', { + url: expect.it('to end with', `/p4-hdr/${id}.jpg`), + mimeType: 'image/jpeg', + width: 2305, + height: 1735, + variant: 'hdr', + }); + }); + it(`should create mp3 audio attachment`, async () => { const filePath = path.join(__dirname, '../fixtures/media-files/music.mp3'); const data = new FormData(); @@ -1050,6 +1101,31 @@ describe('Attachments', () => { }); }); + it(`should fall back to SDR image preview for HDR variant request`, async () => { + const resp = await performJSONRequest( + 'GET', + `/v4/attachments/${att.id}/image?variant=hdr&width=100&height=100`, + ); + expect(resp, 'to satisfy', { + url: att.getFileUrl('thumbnails'), + mimeType: 'image/webp', + width: 525, + height: 175, + }); + expect(resp, 'not to have key', 'variant'); + }); + + it(`should return an error for unsupported preview variant`, async () => { + const resp = await performJSONRequest( + 'GET', + `/v4/attachments/${att.id}/image?variant=unknown`, + ); + expect(resp, 'to satisfy', { + err: 'Invalid variant value', + __httpCode: 422, + }); + }); + it(`should return 'image' preview that is bigger than the original`, async () => { const resp = await performJSONRequest( 'GET', @@ -1122,6 +1198,41 @@ describe('Attachments', () => { }); }); + describe("'HDR image' type", () => { + /** @type {Attachment} */ + let hdrAtt; + + before(async () => { + const filePath = path.join( + __dirname, + '../fixtures/media-files/Ultra_HDR_Samples_Originals_01.jpg', + ); + const data = new FormData(); + data.append('file', await fileFrom(filePath, 'image/jpeg')); + const resp = await performJSONRequest('POST', '/v4/attachments', data, authHeaders(luna)); + const { id } = resp.attachments; + hdrAtt = await dbAdapter.getAttachmentById(id); + }); + + describe(`when the imgproxy is turned on`, () => { + withModifiedConfig({ attachments: { useImgProxy: true } }); + + it(`should not use imgproxy for HDR preview`, async () => { + const resp = await performJSONRequest( + 'GET', + `/v4/attachments/${hdrAtt.id}/image?variant=hdr&width=1000&height=1000&format=avif`, + ); + expect(resp, 'to satisfy', { + url: hdrAtt.getFileUrl('p4-hdr', 'jpg'), + mimeType: 'image/jpeg', + width: 2305, + height: 1735, + variant: 'hdr', + }); + }); + }); + }); + describe("'audio' type", () => { before(async () => { const filePath = path.join(__dirname, '../fixtures/media-files/music.mp3'); diff --git a/test/integration/models/jpeg-hdr.js b/test/integration/models/jpeg-hdr.js new file mode 100644 index 000000000..656056be9 --- /dev/null +++ b/test/integration/models/jpeg-hdr.js @@ -0,0 +1,106 @@ +import { copyFile, mkdtemp, rm } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import { describe, it } from 'mocha'; +import expect from 'unexpected'; +import { exiftoolPath } from 'exiftool-vendored'; + +import { nodeDirname } from '../../../app/support/node-dirname'; +import { runImageMagick } from '../../../app/support/image-magick'; +import { spawnAsync } from '../../../app/support/spawn-async'; +import { processMediaFile } from '../../../app/support/media-files/process'; +import { + createJpegHdrPreview, + inspectJpegHdrPreview, +} from '../../../app/support/media-files/jpeg-hdr'; + +const __dirname = nodeDirname(import.meta.url); + +describe('JPEG HDR previews', () => { + const sourcePath = join( + __dirname, + '../../fixtures/media-files/Ultra_HDR_Samples_Originals_01.jpg', + ); + + it('should create a valid clean Ultra HDR JPEG preview', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'freefeed-hdr-')); + + try { + const targetPath = join(workDir, 'preview.jpg'); + const created = await createJpegHdrPreview({ + sourcePath, + targetPath, + width: 1020, + height: 768, + quality: 95, + }); + + expect(created, 'to be true'); + + const info = await inspectJpegHdrPreview(targetPath); + expect(info, 'to satisfy', { + numberOfImages: 2, + mpImage2Length: expect.it('to be greater than', 0), + directoryItemLength: expect.it('to be greater than', 0), + gainMapImageLength: expect.it('to be greater than', 0), + iccProfileDescription: 'Display P3', + validate: 'OK', + warnings: [], + }); + expect(info.mpImage2Length, 'to be', info.directoryItemLength); + expect(info.gainMapImageLength, 'to be', info.directoryItemLength); + + const { stdout: dimensions } = await runImageMagick('identify', [ + '-format', + '%w %h', + targetPath, + ]); + expect(dimensions, 'to be', '1020 768'); + + const { stdout: metadata } = await spawnAsync(await exiftoolPath(), [ + '-G1', + '-a', + '-s', + '-GPS:all', + '-EXIF:all', + targetPath, + ]); + expect(metadata, 'not to contain', '[GPS]'); + expect(metadata, 'not to contain', '[ExifIFD]'); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); + + it('should create HDR previews during image media processing', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'freefeed-hdr-process-')); + + try { + const localPath = join(workDir, 'ultra-hdr.jpg'); + await copyFile(sourcePath, localPath); + + const result = await processMediaFile(localPath, 'ultra-hdr.jpg'); + const { image, imageHDR } = result.previews; + + expect(imageHDR, 'to be defined'); + + for (const [variant, preview] of Object.entries(image)) { + const hdrVariant = `${variant}-hdr`; + expect(imageHDR, 'to have key', hdrVariant); + expect(imageHDR[hdrVariant], 'to equal', { ...preview, ext: 'jpg' }); + expect(result.files, 'to have key', hdrVariant); + } + + const [[maxVariant]] = Object.entries(imageHDR).sort((a, b) => b[1].w - a[1].w); + const info = await inspectJpegHdrPreview(result.files[maxVariant].path); + expect(info, 'to satisfy', { + numberOfImages: 2, + validate: 'OK', + warnings: [], + }); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); +});