mirror of
https://github.com/immich-app/immich.git
synced 2025-01-01 08:31:59 +00:00
fix(server): use srgb pipeline for srgb images (#4101)
* added color-related exif fields * remove metadata check, conditional pipe colorspace * check exif metadata for srgb * added migration * updated e2e fixture * uncased srgb check, search substrings * extracted exif logic into separate function * handle images with no bit depth or color metadata * added unit tests
This commit is contained in:
parent
9676412875
commit
56cf9464af
7 changed files with 155 additions and 17 deletions
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
AssetType,
|
AssetType,
|
||||||
Colorspace,
|
Colorspace,
|
||||||
|
ExifEntity,
|
||||||
SystemConfigKey,
|
SystemConfigKey,
|
||||||
ToneMapping,
|
ToneMapping,
|
||||||
TranscodeHWAccel,
|
TranscodeHWAccel,
|
||||||
|
@ -204,6 +205,25 @@ describe(MediaService.name, () => {
|
||||||
|
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/as/se/asset-id.jpeg', {
|
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/as/se/asset-id.jpeg', {
|
||||||
|
size: 1440,
|
||||||
|
format: 'jpeg',
|
||||||
|
quality: 80,
|
||||||
|
colorspace: Colorspace.SRGB,
|
||||||
|
});
|
||||||
|
expect(assetMock.save).toHaveBeenCalledWith({
|
||||||
|
id: 'asset-id',
|
||||||
|
resizePath: 'upload/thumbs/user-id/asset-id.jpeg',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([
|
||||||
|
{ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity },
|
||||||
|
]);
|
||||||
|
await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
|
||||||
|
|
||||||
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||||
|
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||||
size: 1440,
|
size: 1440,
|
||||||
format: 'jpeg',
|
format: 'jpeg',
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
@ -287,7 +307,7 @@ describe(MediaService.name, () => {
|
||||||
format: 'webp',
|
format: 'webp',
|
||||||
size: 250,
|
size: 250,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.SRGB,
|
||||||
});
|
});
|
||||||
expect(assetMock.save).toHaveBeenCalledWith({
|
expect(assetMock.save).toHaveBeenCalledWith({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
|
@ -296,6 +316,22 @@ describe(MediaService.name, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([
|
||||||
|
{ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity },
|
||||||
|
]);
|
||||||
|
await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id });
|
||||||
|
|
||||||
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||||
|
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.webp', {
|
||||||
|
format: 'webp',
|
||||||
|
size: 250,
|
||||||
|
quality: 80,
|
||||||
|
colorspace: Colorspace.P3,
|
||||||
|
});
|
||||||
|
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: 'upload/thumbs/user-id/asset-id.webp' });
|
||||||
|
});
|
||||||
|
|
||||||
describe('handleGenerateThumbhashThumbnail', () => {
|
describe('handleGenerateThumbhashThumbnail', () => {
|
||||||
it('should skip thumbhash generation if asset not found', async () => {
|
it('should skip thumbhash generation if asset not found', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([]);
|
assetMock.getByIds.mockResolvedValue([]);
|
||||||
|
@ -1539,4 +1575,51 @@ describe(MediaService.name, () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isSRGB', () => {
|
||||||
|
it('should return true for srgb colorspace', () => {
|
||||||
|
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as ExifEntity };
|
||||||
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for srgb profile description', () => {
|
||||||
|
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as ExifEntity };
|
||||||
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for 8-bit image with no colorspace metadata', () => {
|
||||||
|
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as ExifEntity };
|
||||||
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for image with no colorspace or bit depth metadata', () => {
|
||||||
|
const asset = { ...assetStub.image, exifInfo: {} as ExifEntity };
|
||||||
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-srgb colorspace', () => {
|
||||||
|
const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as ExifEntity };
|
||||||
|
expect(sut.isSRGB(asset)).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-srgb profile description', () => {
|
||||||
|
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as ExifEntity };
|
||||||
|
expect(sut.isSRGB(asset)).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for 16-bit image with no colorspace metadata', () => {
|
||||||
|
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as ExifEntity };
|
||||||
|
expect(sut.isSRGB(asset)).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for 16-bit image with sRGB colorspace', () => {
|
||||||
|
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as ExifEntity };
|
||||||
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for 16-bit image with sRGB profile', () => {
|
||||||
|
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as ExifEntity };
|
||||||
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { AssetEntity, AssetType, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities';
|
import { AssetEntity, AssetType, Colorspace, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities';
|
||||||
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
|
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
|
||||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||||
import { usePagination } from '../domain.util';
|
import { usePagination } from '../domain.util';
|
||||||
|
@ -163,8 +163,9 @@ export class MediaService {
|
||||||
async generateImageThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
|
async generateImageThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
|
||||||
const { thumbnail } = await this.configCore.getConfig();
|
const { thumbnail } = await this.configCore.getConfig();
|
||||||
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
|
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
|
||||||
const thumbnailOptions = { format, size, colorspace: thumbnail.colorspace, quality: thumbnail.quality };
|
|
||||||
const path = this.ensureThumbnailPath(asset, format);
|
const path = this.ensureThumbnailPath(asset, format);
|
||||||
|
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
|
||||||
|
const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
|
||||||
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
|
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
@ -384,4 +385,17 @@ export class MediaService {
|
||||||
ensureEncodedVideoPath(asset: AssetEntity, extension: string): string {
|
ensureEncodedVideoPath(asset: AssetEntity, extension: string): string {
|
||||||
return this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.${extension}`);
|
return this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.${extension}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSRGB(asset: AssetEntity): boolean {
|
||||||
|
const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {};
|
||||||
|
if (colorspace || profileDescription) {
|
||||||
|
return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb'));
|
||||||
|
} else if (bitsPerSample) {
|
||||||
|
// assume sRGB for 8-bit images with no color profile or colorspace metadata
|
||||||
|
return bitsPerSample === 8;
|
||||||
|
} else {
|
||||||
|
// assume sRGB for images with no relevant metadata
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,6 +81,15 @@ export class ExifEntity {
|
||||||
@Column({ type: 'varchar', nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
exposureTime!: string | null;
|
exposureTime!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
profileDescription!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
colorspace!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true })
|
||||||
|
bitsPerSample!: number | null;
|
||||||
|
|
||||||
/* Video info */
|
/* Video info */
|
||||||
@Column({ type: 'float8', nullable: true })
|
@Column({ type: 'float8', nullable: true })
|
||||||
fps?: number | null;
|
fps?: number | null;
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddExifColorSpace1694750975773 implements MigrationInterface {
|
||||||
|
name = 'AddExifColorSpace1694750975773'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" ADD "profileDescription" character varying`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" ADD "colorspace" character varying`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" ADD "bitsPerSample" integer`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "bitsPerSample"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "colorspace"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "profileDescription"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -26,24 +26,12 @@ export class MediaRepository implements IMediaRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
|
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
|
||||||
let colorProfile = options.colorspace;
|
|
||||||
if (options.colorspace !== Colorspace.SRGB) {
|
|
||||||
try {
|
|
||||||
const { space } = await sharp(input).metadata();
|
|
||||||
// if the image is already in srgb, keep it that way
|
|
||||||
if (space === 'srgb') {
|
|
||||||
colorProfile = Colorspace.SRGB;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Could not determine colorspace of image, defaulting to ${colorProfile} profile`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const chromaSubsampling = options.quality >= 80 ? '4:4:4' : '4:2:0'; // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
const chromaSubsampling = options.quality >= 80 ? '4:4:4' : '4:2:0'; // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
||||||
await sharp(input, { failOn: 'none' })
|
await sharp(input, { failOn: 'none' })
|
||||||
.pipelineColorspace('rgb16')
|
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
|
||||||
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
|
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
|
||||||
.rotate()
|
.rotate()
|
||||||
.withMetadata({ icc: colorProfile })
|
.withMetadata({ icc: options.colorspace })
|
||||||
.toFormat(options.format, { quality: options.quality, chromaSubsampling })
|
.toFormat(options.format, { quality: options.quality, chromaSubsampling })
|
||||||
.toFile(output);
|
.toFile(output);
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,9 +42,11 @@ interface ImmichTags extends Tags {
|
||||||
MotionPhotoVersion?: number;
|
MotionPhotoVersion?: number;
|
||||||
MotionPhotoPresentationTimestampUs?: number;
|
MotionPhotoPresentationTimestampUs?: number;
|
||||||
MediaGroupUUID?: string;
|
MediaGroupUUID?: string;
|
||||||
|
ImagePixelDepth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
|
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
|
||||||
|
// exiftool returns strings when it fails to parse non-string values, so this is used where a string is not expected
|
||||||
const validate = <T>(value: T): T | null => (typeof value === 'string' ? null : value ?? null);
|
const validate = <T>(value: T): T | null => (typeof value === 'string' ? null : value ?? null);
|
||||||
|
|
||||||
export class MetadataExtractionProcessor {
|
export class MetadataExtractionProcessor {
|
||||||
|
@ -289,6 +291,8 @@ export class MetadataExtractionProcessor {
|
||||||
<ExifEntity>{
|
<ExifEntity>{
|
||||||
// altitude: tags.GPSAltitude ?? null,
|
// altitude: tags.GPSAltitude ?? null,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
bitsPerSample: this.getBitsPerSample(tags),
|
||||||
|
colorspace: tags.ColorSpace ?? null,
|
||||||
dateTimeOriginal: exifDate(firstDateTime(tags)) ?? asset.fileCreatedAt,
|
dateTimeOriginal: exifDate(firstDateTime(tags)) ?? asset.fileCreatedAt,
|
||||||
exifImageHeight: validate(tags.ImageHeight),
|
exifImageHeight: validate(tags.ImageHeight),
|
||||||
exifImageWidth: validate(tags.ImageWidth),
|
exifImageWidth: validate(tags.ImageWidth),
|
||||||
|
@ -306,10 +310,29 @@ export class MetadataExtractionProcessor {
|
||||||
model: tags.Model ?? null,
|
model: tags.Model ?? null,
|
||||||
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
|
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
|
||||||
orientation: validate(tags.Orientation)?.toString() ?? null,
|
orientation: validate(tags.Orientation)?.toString() ?? null,
|
||||||
|
profileDescription: tags.ProfileDescription || tags.ProfileName || null,
|
||||||
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
|
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
|
||||||
timeZone: tags.tz,
|
timeZone: tags.tz,
|
||||||
},
|
},
|
||||||
tags,
|
tags,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBitsPerSample(tags: ImmichTags): number | null {
|
||||||
|
const bitDepthTags = [
|
||||||
|
tags.BitsPerSample,
|
||||||
|
tags.ComponentBitDepth,
|
||||||
|
tags.ImagePixelDepth,
|
||||||
|
tags.BitDepth,
|
||||||
|
tags.ColorBitDepth,
|
||||||
|
// `numericTags` doesn't parse values like '12 12 12'
|
||||||
|
].map((tag) => (typeof tag === 'string' ? Number.parseInt(tag) : tag));
|
||||||
|
|
||||||
|
let bitsPerSample = bitDepthTags.find((tag) => typeof tag === 'number' && !Number.isNaN(tag)) ?? null;
|
||||||
|
if (bitsPerSample && bitsPerSample >= 24 && bitsPerSample % 3 === 0) {
|
||||||
|
bitsPerSample /= 3; // converts per-pixel bit depth to per-channel
|
||||||
|
}
|
||||||
|
|
||||||
|
return bitsPerSample;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
3
server/test/fixtures/shared-link.stub.ts
vendored
3
server/test/fixtures/shared-link.stub.ts
vendored
|
@ -225,6 +225,9 @@ export const sharedLinkStub = {
|
||||||
fps: 100,
|
fps: 100,
|
||||||
asset: null as any,
|
asset: null as any,
|
||||||
exifTextSearchableColumn: '',
|
exifTextSearchableColumn: '',
|
||||||
|
profileDescription: 'sRGB',
|
||||||
|
bitsPerSample: 8,
|
||||||
|
colorspace: 'sRGB',
|
||||||
},
|
},
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
|
|
Loading…
Reference in a new issue