Merge 33b97776ee into 5ade152bc5
commit
0fd353df5f
|
|
@ -39,6 +39,7 @@ type ProgressEvent = {
|
||||||
export type ExtractResult = {
|
export type ExtractResult = {
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
format: RawExtractedFormat;
|
format: RawExtractedFormat;
|
||||||
|
dimensions: ImageDimensions;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -55,28 +56,28 @@ export class MediaRepository {
|
||||||
async extract(input: string): Promise<ExtractResult | null> {
|
async extract(input: string): Promise<ExtractResult | null> {
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
|
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) {
|
} catch (error: any) {
|
||||||
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
|
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
|
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) {
|
} catch (error: any) {
|
||||||
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
|
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
|
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) {
|
} catch (error: any) {
|
||||||
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
|
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
|
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) {
|
} catch (error: any) {
|
||||||
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
|
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -121,19 +122,15 @@ export class MediaRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyTagGroup(tagGroup: string, source: string, target: string): Promise<boolean> {
|
async writeTags(tags: WriteTags, output: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await exiftool.write(
|
await exiftool.write(output, tags, {
|
||||||
target,
|
ignoreMinorErrors: true,
|
||||||
{},
|
writeArgs: ['-overwrite_original'],
|
||||||
{
|
});
|
||||||
ignoreMinorErrors: true,
|
|
||||||
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
|
this.logger.warn(`Could not write tags to image: ${error.message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -273,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();
|
const { width = 0, height = 0 } = await sharp(input).metadata();
|
||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,21 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||||
|
|
||||||
AndroidMake?: string;
|
AndroidMake?: string;
|
||||||
AndroidModel?: string;
|
AndroidModel?: string;
|
||||||
|
|
||||||
|
UsePanoramaViewer?: boolean;
|
||||||
|
ProjectionType?: string;
|
||||||
|
PoseHeadingDegrees?: number;
|
||||||
|
PosePitchDegrees?: number;
|
||||||
|
PoseRollDegrees?: number;
|
||||||
|
InitialViewHeadingDegrees?: number;
|
||||||
|
InitialViewPitchDegrees?: number;
|
||||||
|
InitialViewRollDegrees?: number;
|
||||||
|
CroppedAreaImageWidthPixels?: number;
|
||||||
|
CroppedAreaImageHeightPixels?: number;
|
||||||
|
FullPanoWidthPixels?: number;
|
||||||
|
FullPanoHeightPixels?: number;
|
||||||
|
CroppedAreaLeftPixels?: number;
|
||||||
|
CroppedAreaTopPixels?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
|
||||||
|
|
@ -599,8 +599,11 @@ describe(MediaService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should extract embedded image if enabled and available', async () => {
|
it('should extract embedded image if enabled and available', async () => {
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({
|
||||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
buffer: extractedBuffer,
|
||||||
|
format: RawExtractedFormat.Jpeg,
|
||||||
|
dimensions: { width: 3840, height: 2160 },
|
||||||
|
});
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
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 () => {
|
it('should resize original image if embedded image is too small', async () => {
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({
|
||||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
|
buffer: extractedBuffer,
|
||||||
|
format: RawExtractedFormat.Jpeg,
|
||||||
|
dimensions: { width: 1000, height: 1000 },
|
||||||
|
});
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||||
|
|
||||||
|
|
@ -641,7 +647,6 @@ describe(MediaService.name, () => {
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
size: 1440,
|
size: 1440,
|
||||||
});
|
});
|
||||||
expect(mocks.media.getImageDimensions).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should resize original image if embedded image extraction is not enabled', async () => {
|
it('should resize original image if embedded image extraction is not enabled', async () => {
|
||||||
|
|
@ -657,7 +662,6 @@ describe(MediaService.name, () => {
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
size: 1440,
|
size: 1440,
|
||||||
});
|
});
|
||||||
expect(mocks.media.getImageDimensions).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should process invalid images if enabled', async () => {
|
it('should process invalid images if enabled', async () => {
|
||||||
|
|
@ -691,7 +695,6 @@ describe(MediaService.name, () => {
|
||||||
expect.objectContaining({ processInvalidImages: false }),
|
expect.objectContaining({ processInvalidImages: false }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.media.getImageDimensions).not.toHaveBeenCalled();
|
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -699,8 +702,11 @@ describe(MediaService.name, () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
|
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
|
||||||
});
|
});
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({
|
||||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
buffer: extractedBuffer,
|
||||||
|
format: RawExtractedFormat.Jpeg,
|
||||||
|
dimensions: { width: 3840, height: 2160 },
|
||||||
|
});
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||||
|
|
||||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||||
|
|
@ -731,8 +737,11 @@ describe(MediaService.name, () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
|
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
|
||||||
});
|
});
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl });
|
mocks.media.extract.mockResolvedValue({
|
||||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
buffer: extractedBuffer,
|
||||||
|
format: RawExtractedFormat.Jxl,
|
||||||
|
dimensions: { width: 3840, height: 2160 },
|
||||||
|
});
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||||
|
|
||||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
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 () => {
|
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.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } });
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({
|
||||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
buffer: extractedBuffer,
|
||||||
|
format: RawExtractedFormat.Jpeg,
|
||||||
|
dimensions: { width: 3840, height: 2160 },
|
||||||
|
});
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||||
|
|
||||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
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 () => {
|
it('should generate full-size preview from non-web-friendly images', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({
|
||||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
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.
|
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
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 () => {
|
it('should skip generating full-size preview for web-friendly images', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({
|
||||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
buffer: extractedBuffer,
|
||||||
|
format: RawExtractedFormat.Jpeg,
|
||||||
|
dimensions: { width: 3840, height: 2160 },
|
||||||
|
});
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||||
|
|
||||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||||
|
|
@ -863,9 +881,17 @@ describe(MediaService.name, () => {
|
||||||
|
|
||||||
it('should always generate full-size preview from non-web-friendly panoramas', async () => {
|
it('should always generate full-size preview from non-web-friendly panoramas', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
|
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({
|
||||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
buffer: extractedBuffer,
|
||||||
mocks.media.copyTagGroup.mockResolvedValue(true);
|
format: RawExtractedFormat.Jpeg,
|
||||||
|
dimensions: { width: 3840, height: 2160 },
|
||||||
|
});
|
||||||
|
mocks.metadata.readTags.mockResolvedValue({
|
||||||
|
ProjectionType: 'equirectangular',
|
||||||
|
PoseHeadingDegrees: 127,
|
||||||
|
FullPanoWidthPixels: 3840,
|
||||||
|
FullPanoHeightPixels: 2160,
|
||||||
|
});
|
||||||
|
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.panoramaTif);
|
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.panoramaTif);
|
||||||
|
|
||||||
|
|
@ -892,10 +918,14 @@ describe(MediaService.name, () => {
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2);
|
expect(mocks.media.writeTags).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.media.copyTagGroup).toHaveBeenCalledWith(
|
expect(mocks.media.writeTags).toHaveBeenCalledWith(
|
||||||
'XMP-GPano',
|
{
|
||||||
assetStub.panoramaTif.originalPath,
|
ProjectionType: 'equirectangular',
|
||||||
|
PoseHeadingDegrees: 127,
|
||||||
|
FullPanoWidthPixels: 2560,
|
||||||
|
FullPanoHeightPixels: 1440,
|
||||||
|
},
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -904,8 +934,11 @@ describe(MediaService.name, () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
image: { fullsize: { enabled: true, format: ImageFormat.Webp, quality: 90 } },
|
image: { fullsize: { enabled: true, format: ImageFormat.Webp, quality: 90 } },
|
||||||
});
|
});
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({
|
||||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
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.
|
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
||||||
|
|
||||||
|
|
@ -1190,9 +1223,8 @@ describe(MediaService.name, () => {
|
||||||
const extracted = Buffer.from('');
|
const extracted = Buffer.from('');
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
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.decodeImage.mockResolvedValue({ data, info });
|
||||||
mocks.media.getImageDimensions.mockResolvedValue(info);
|
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||||
JobStatus.Success,
|
JobStatus.Success,
|
||||||
|
|
@ -1268,8 +1300,7 @@ describe(MediaService.name, () => {
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg, dimensions: info });
|
||||||
mocks.media.getImageDimensions.mockResolvedValue(info);
|
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||||
JobStatus.Success,
|
JobStatus.Success,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,26 @@ interface UpsertFileOptions {
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PANORAMA_CONSTANTS = [
|
||||||
|
'UsePanoramaViewer',
|
||||||
|
'ProjectionType',
|
||||||
|
'PoseHeadingDegrees',
|
||||||
|
'PosePitchDegrees',
|
||||||
|
'PoseRollDegrees',
|
||||||
|
'InitialViewHeadingDegrees',
|
||||||
|
'InitialViewPitchDegrees',
|
||||||
|
'InitialViewRollDegrees',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PANORAMA_SCALABLES = [
|
||||||
|
'CroppedAreaImageWidthPixels',
|
||||||
|
'CroppedAreaImageHeightPixels',
|
||||||
|
'FullPanoWidthPixels',
|
||||||
|
'FullPanoHeightPixels',
|
||||||
|
'CroppedAreaLeftPixels',
|
||||||
|
'CroppedAreaTopPixels',
|
||||||
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService extends BaseService {
|
export class MediaService extends BaseService {
|
||||||
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
||||||
|
|
@ -237,7 +257,7 @@ export class MediaService extends BaseService {
|
||||||
|
|
||||||
private async extractImage(originalPath: string, minSize: number) {
|
private async extractImage(originalPath: string, minSize: number) {
|
||||||
let extracted = await this.mediaRepository.extract(originalPath);
|
let extracted = await this.mediaRepository.extract(originalPath);
|
||||||
if (extracted && !(await this.shouldUseExtractedImage(extracted.buffer, minSize))) {
|
if (extracted && !this.shouldUseExtractedImage(extracted.dimensions, minSize)) {
|
||||||
extracted = null;
|
extracted = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,14 +315,21 @@ export class MediaService extends BaseService {
|
||||||
];
|
];
|
||||||
|
|
||||||
let fullsizePath: string | undefined;
|
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) {
|
if (convertFullsize) {
|
||||||
// convert a new fullsize image from the same source as the thumbnail
|
// convert a new fullsize image from the same source as the thumbnail
|
||||||
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, image.fullsize.format);
|
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, image.fullsize.format);
|
||||||
|
fullsizeSize = originalSize;
|
||||||
const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions };
|
const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions };
|
||||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
||||||
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
||||||
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format);
|
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format);
|
||||||
|
fullsizeSize = Math.min(extracted.dimensions.width, extracted.dimensions.height);
|
||||||
this.storageCore.ensureFolders(fullsizePath);
|
this.storageCore.ensureFolders(fullsizePath);
|
||||||
|
|
||||||
// Write the buffer to disk with essential EXIF data
|
// Write the buffer to disk with essential EXIF data
|
||||||
|
|
@ -318,19 +345,55 @@ export class MediaService extends BaseService {
|
||||||
|
|
||||||
const outputs = await Promise.all(promises);
|
const outputs = await Promise.all(promises);
|
||||||
|
|
||||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR' && originalSize) {
|
||||||
const promises = [
|
await this.copyPanoramaMetadataToThumbnails(
|
||||||
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewPath),
|
asset.originalPath,
|
||||||
fullsizePath
|
originalSize,
|
||||||
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizePath)
|
previewPath,
|
||||||
: Promise.resolve(),
|
image.preview.size,
|
||||||
];
|
fullsizePath,
|
||||||
await Promise.all(promises);
|
fullsizeSize,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer };
|
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async copyPanoramaMetadataToThumbnails(
|
||||||
|
originalPath: string,
|
||||||
|
originalSize: number,
|
||||||
|
previewPath: string,
|
||||||
|
previewSize: number,
|
||||||
|
fullsizePath?: string,
|
||||||
|
fullsizeSize?: number,
|
||||||
|
) {
|
||||||
|
const originalTags = await this.metadataRepository.readTags(originalPath);
|
||||||
|
|
||||||
|
const scaleAndWriteData = async (thumbnailPath: string, scaleRatio: number) => {
|
||||||
|
const newTags = {} as Record<string, string | number | boolean>;
|
||||||
|
|
||||||
|
for (const key of PANORAMA_CONSTANTS) {
|
||||||
|
if (key in originalTags && originalTags[key]) {
|
||||||
|
newTags[key] = originalTags[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of PANORAMA_SCALABLES) {
|
||||||
|
if (key in originalTags && originalTags[key]) {
|
||||||
|
newTags[key] = Math.round(originalTags[key] * scaleRatio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mediaRepository.writeTags(newTags, thumbnailPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 && fullsizeSize ? scaleAndWriteData(fullsizePath, fullsizeSize / originalSize) : Promise.resolve(),
|
||||||
|
];
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
||||||
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
|
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
|
||||||
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
|
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
|
||||||
|
|
@ -680,8 +743,8 @@ export class MediaService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) {
|
private shouldUseExtractedImage(extractedDimensions: ImageDimensions, targetSize: number) {
|
||||||
const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer);
|
const { width, height } = extractedDimensions;
|
||||||
const extractedSize = Math.min(width, height);
|
const extractedSize = Math.min(width, height);
|
||||||
return extractedSize >= targetSize;
|
return extractedSize >= targetSize;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -892,6 +892,8 @@ export const assetStub = {
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
projectionType: 'EQUIRECTANGULAR',
|
projectionType: 'EQUIRECTANGULAR',
|
||||||
|
exifImageHeight: 2160,
|
||||||
|
exifImageWidth: 3840,
|
||||||
} as Exif,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaReposi
|
||||||
return {
|
return {
|
||||||
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
writeTags: vitest.fn().mockResolvedValue(true),
|
||||||
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
||||||
decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }),
|
decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }),
|
||||||
extract: vitest.fn().mockResolvedValue(null),
|
extract: vitest.fn().mockResolvedValue(null),
|
||||||
probe: vitest.fn(),
|
probe: vitest.fn(),
|
||||||
transcode: vitest.fn(),
|
transcode: vitest.fn(),
|
||||||
getImageDimensions: vitest.fn(),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue