feat: original-sized previews for non-web-friendly images (#14446)
* feat(server): extract full-size previews from RAW images * feat(web): load fullsize preview for RAW images when zoomed in * refactor: tweaks for code review * refactor: rename "converted" preview/assets to "fullsize" * feat(web/server): fullsize preview for non-web-friendly images * feat: tweaks for code review * feat(server): require ASSET_DOWNLOAD premission for fullsize previews * test: fix types and interfaces * chore: gen open-api * feat(server): keep only essential exif in fullsize preview * chore: regen openapi * test: revert unnecessary timeout * feat: move full-size preview config to standalone entry * feat(i18n): update en texts * fix: don't return fullsizePath when disabled * test: full-size previews * test(web): full-size previews * chore: make open-api * feat(server): redirect to preview/original URL when fullsize thumbnail not available * fix(server): delete fullsize preview image on thumbnail regen after fullsize preview turned off * refactor(server): AssetRepository.deleteFiles with Kysely * fix(server): type of MediaRepository.writeExif * minor simplification * minor styling changes and condensed wording * simplify * chore: reuild open-api * test(server): fix media.service tests * test(web): fix photo-viewer test * fix(server): use fullsize image when requested * fix file path extension * formatting * use fullsize when zooming back out or when "display original photos" is enabled * simplify condition --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { OutputInfo } from 'sharp';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import {
|
||||
@@ -233,18 +234,26 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
|
||||
expect(mocks.move.create).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.move.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGenerateThumbnails', () => {
|
||||
let rawBuffer: Buffer;
|
||||
let fullsizeBuffer: Buffer;
|
||||
let rawInfo: RawImageInfo;
|
||||
|
||||
beforeEach(() => {
|
||||
fullsizeBuffer = Buffer.from('embedded image data');
|
||||
rawBuffer = Buffer.from('image data');
|
||||
rawInfo = { width: 100, height: 100, channels: 3 };
|
||||
mocks.media.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo as OutputInfo });
|
||||
mocks.media.decodeImage.mockImplementation((path) =>
|
||||
Promise.resolve(
|
||||
path.includes(AssetMediaSize.FULLSIZE)
|
||||
? { data: fullsizeBuffer, info: rawInfo as OutputInfo }
|
||||
: { data: rawBuffer, info: rawInfo as OutputInfo },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip thumbnail generation if asset not found', async () => {
|
||||
@@ -591,15 +600,13 @@ describe(MediaService.name, () => {
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString();
|
||||
const convertedPath = mocks.media.extract.mock.lastCall?.[1].toString();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedPath, {
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(convertedPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
size: 1440,
|
||||
});
|
||||
expect(extractedPath?.endsWith('.tmp')).toBe(true);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath);
|
||||
});
|
||||
|
||||
it('should resize original image if embedded image is too small', async () => {
|
||||
@@ -610,15 +617,14 @@ describe(MediaService.name, () => {
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
const extractedPath = mocks.media.extract.mock.lastCall?.[1].toString();
|
||||
expect(extractedPath).toMatch(/-fullsize\.jpeg$/);
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
size: 1440,
|
||||
});
|
||||
const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString();
|
||||
expect(extractedPath?.endsWith('.tmp')).toBe(true);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath);
|
||||
});
|
||||
|
||||
it('should resize original image if embedded image not found', async () => {
|
||||
@@ -686,6 +692,159 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.media.getImageDimensions).not.toHaveBeenCalled();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should generate full-size preview using embedded JPEG from RAW images when extractEmbedded is true', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: true } });
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageDng);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
const extractedPath = mocks.media.extract.mock.lastCall?.[1].toString();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
});
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
fullsizeBuffer,
|
||||
{
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.JPEG,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
},
|
||||
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||
);
|
||||
});
|
||||
|
||||
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(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageDng);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
});
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
{
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.JPEG,
|
||||
quality: 80,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
},
|
||||
'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg',
|
||||
);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
{
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.JPEG,
|
||||
quality: 80,
|
||||
size: 1440,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
},
|
||||
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate full-size preview from non-web-friendly images', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageHif);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
});
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
{
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.JPEG,
|
||||
quality: 80,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
},
|
||||
'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip generating full-size preview for web-friendly images', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, {
|
||||
colorspace: Colorspace.SRGB,
|
||||
processInvalidImages: false,
|
||||
size: 1440,
|
||||
});
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect encoding options when generating full-size preview', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { fullsize: { enabled: true, format: ImageFormat.WEBP, quality: 90 } },
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageHif);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
});
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
{
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.WEBP,
|
||||
quality: 90,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
},
|
||||
'upload/thumbs/user-id/as/se/asset-id-fullsize.webp',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueVideoConversion', () => {
|
||||
|
||||
Reference in New Issue
Block a user