chore: get dimensions directly with ExtractResult

pull/24193/head
Mees Frensel 2025-12-04 15:08:15 +07:00
parent 076cbfe137
commit 33b97776ee
5 changed files with 68 additions and 37 deletions

@ -39,6 +39,7 @@ type ProgressEvent = {
export type ExtractResult = {
buffer: Buffer;
format: RawExtractedFormat;
dimensions: ImageDimensions;
};
@Injectable()
@ -55,28 +56,28 @@ export class MediaRepository {
async extract(input: string): Promise<ExtractResult | null> {
try {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
return { buffer, format: RawExtractedFormat.Jpeg };
return { buffer, format: RawExtractedFormat.Jpeg, dimensions: await this.getImageDimensions(buffer) };
} catch (error: any) {
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
return { buffer, format: RawExtractedFormat.Jpeg };
return { buffer, format: RawExtractedFormat.Jpeg, dimensions: await this.getImageDimensions(buffer) };
} catch (error: any) {
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
return { buffer, format: RawExtractedFormat.Jxl };
return { buffer, format: RawExtractedFormat.Jxl, dimensions: await this.getImageDimensions(buffer) };
} catch (error: any) {
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
return { buffer, format: RawExtractedFormat.Jpeg };
return { buffer, format: RawExtractedFormat.Jpeg, dimensions: await this.getImageDimensions(buffer) };
} catch (error: any) {
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
return null;
@ -269,7 +270,7 @@ export class MediaRepository {
});
}
async getImageDimensions(input: string | Buffer): Promise<ImageDimensions> {
private async getImageDimensions(input: string | Buffer): Promise<ImageDimensions> {
const { width = 0, height = 0 } = await sharp(input).metadata();
return { width, height };
}

@ -599,8 +599,11 @@ describe(MediaService.name, () => {
});
it('should extract embedded image if enabled and available', async () => {
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.extract.mockResolvedValue({
buffer: extractedBuffer,
format: RawExtractedFormat.Jpeg,
dimensions: { width: 3840, height: 2160 },
});
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
@ -615,8 +618,11 @@ describe(MediaService.name, () => {
});
it('should resize original image if embedded image is too small', async () => {
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
mocks.media.extract.mockResolvedValue({
buffer: extractedBuffer,
format: RawExtractedFormat.Jpeg,
dimensions: { width: 1000, height: 1000 },
});
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
@ -641,7 +647,6 @@ describe(MediaService.name, () => {
processInvalidImages: false,
size: 1440,
});
expect(mocks.media.getImageDimensions).not.toHaveBeenCalled();
});
it('should resize original image if embedded image extraction is not enabled', async () => {
@ -657,7 +662,6 @@ describe(MediaService.name, () => {
processInvalidImages: false,
size: 1440,
});
expect(mocks.media.getImageDimensions).not.toHaveBeenCalled();
});
it('should process invalid images if enabled', async () => {
@ -691,7 +695,6 @@ describe(MediaService.name, () => {
expect.objectContaining({ processInvalidImages: false }),
);
expect(mocks.media.getImageDimensions).not.toHaveBeenCalled();
vi.unstubAllEnvs();
});
@ -699,8 +702,11 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.extract.mockResolvedValue({
buffer: extractedBuffer,
format: RawExtractedFormat.Jpeg,
dimensions: { width: 3840, height: 2160 },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@ -731,8 +737,11 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.extract.mockResolvedValue({
buffer: extractedBuffer,
format: RawExtractedFormat.Jxl,
dimensions: { width: 3840, height: 2160 },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@ -771,8 +780,11 @@ describe(MediaService.name, () => {
it('should generate full-size preview directly from RAW images when extractEmbedded is false', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.extract.mockResolvedValue({
buffer: extractedBuffer,
format: RawExtractedFormat.Jpeg,
dimensions: { width: 3840, height: 2160 },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@ -811,8 +823,11 @@ describe(MediaService.name, () => {
it('should generate full-size preview from non-web-friendly images', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.extract.mockResolvedValue({
buffer: extractedBuffer,
format: RawExtractedFormat.Jpeg,
dimensions: { width: 3840, height: 2160 },
});
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
@ -840,8 +855,11 @@ describe(MediaService.name, () => {
it('should skip generating full-size preview for web-friendly images', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.extract.mockResolvedValue({
buffer: extractedBuffer,
format: RawExtractedFormat.Jpeg,
dimensions: { width: 3840, height: 2160 },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@ -863,8 +881,11 @@ describe(MediaService.name, () => {
it('should always generate full-size preview from non-web-friendly panoramas', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.extract.mockResolvedValue({
buffer: extractedBuffer,
format: RawExtractedFormat.Jpeg,
dimensions: { width: 3840, height: 2160 },
});
mocks.metadata.readTags.mockResolvedValue({
ProjectionType: 'equirectangular',
PoseHeadingDegrees: 127,
@ -913,8 +934,11 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { fullsize: { enabled: true, format: ImageFormat.Webp, quality: 90 } },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.extract.mockResolvedValue({
buffer: extractedBuffer,
format: RawExtractedFormat.Jpeg,
dimensions: { width: 3840, height: 2160 },
});
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
@ -1199,9 +1223,8 @@ describe(MediaService.name, () => {
const extracted = Buffer.from('');
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg, dimensions: info });
mocks.media.decodeImage.mockResolvedValue({ data, info });
mocks.media.getImageDimensions.mockResolvedValue(info);
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
@ -1277,8 +1300,7 @@ describe(MediaService.name, () => {
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue(info);
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg, dimensions: info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,

@ -255,7 +255,7 @@ export class MediaService extends BaseService {
private async extractImage(originalPath: string, minSize: number) {
let extracted = await this.mediaRepository.extract(originalPath);
if (extracted && !(await this.shouldUseExtractedImage(extracted.buffer, minSize))) {
if (extracted && !this.shouldUseExtractedImage(extracted.dimensions, minSize)) {
extracted = null;
}
@ -313,14 +313,21 @@ export class MediaService extends BaseService {
];
let fullsizePath: string | undefined;
let fullsizeSize: number | undefined;
const originalSize =
asset.exifInfo.exifImageWidth && asset.exifInfo.exifImageHeight
? Math.min(asset.exifInfo.exifImageWidth, asset.exifInfo.exifImageHeight)
: undefined;
if (convertFullsize) {
// convert a new fullsize image from the same source as the thumbnail
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, image.fullsize.format);
fullsizeSize = originalSize;
const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions };
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format);
fullsizeSize = Math.min(extracted.dimensions.width, extracted.dimensions.height);
this.storageCore.ensureFolders(fullsizePath);
// Write the buffer to disk with essential EXIF data
@ -336,14 +343,14 @@ export class MediaService extends BaseService {
const outputs = await Promise.all(promises);
const originalSize = asset.exifInfo.exifImageHeight;
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR' && originalSize) {
this.copyPanoramaMetadataToThumbnails(
await this.copyPanoramaMetadataToThumbnails(
asset.originalPath,
originalSize,
previewPath,
image.preview.size,
fullsizePath,
fullsizeSize,
);
}
@ -356,6 +363,7 @@ export class MediaService extends BaseService {
previewPath: string,
previewSize: number,
fullsizePath?: string,
fullsizeSize?: number,
) {
const originalTags = await this.metadataRepository.readTags(originalPath);
@ -379,7 +387,7 @@ export class MediaService extends BaseService {
const promises = [
// preview size is min(preview size, original size) so do the same for pano pixel adjustment
scaleAndWriteData(previewPath, Math.min(1, previewSize / originalSize)),
fullsizePath ? scaleAndWriteData(fullsizePath, 1) : Promise.resolve(),
fullsizePath && fullsizeSize ? scaleAndWriteData(fullsizePath, fullsizeSize / originalSize) : Promise.resolve(),
];
await Promise.all(promises);
}
@ -733,8 +741,8 @@ export class MediaService extends BaseService {
}
}
private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) {
const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer);
private shouldUseExtractedImage(extractedDimensions: ImageDimensions, targetSize: number) {
const { width, height } = extractedDimensions;
const extractedSize = Math.min(width, height);
return extractedSize >= targetSize;
}

@ -898,6 +898,7 @@ export const assetStub = {
fileSizeInByte: 5000,
projectionType: 'EQUIRECTANGULAR',
exifImageHeight: 2160,
exifImageWidth: 3840,
} as Exif,
duplicateId: null,
isOffline: false,

@ -12,6 +12,5 @@ export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaReposi
extract: vitest.fn().mockResolvedValue(null),
probe: vitest.fn(),
transcode: vitest.fn(),
getImageDimensions: vitest.fn(),
};
};