diff --git a/server/src/repositories/media.repository.spec.ts b/server/src/repositories/media.repository.spec.ts index a5380852ee..e8106c0ff9 100644 --- a/server/src/repositories/media.repository.spec.ts +++ b/server/src/repositories/media.repository.spec.ts @@ -71,7 +71,7 @@ describe(MediaRepository.name, () => { describe('applyEdits (single actions)', () => { it('should apply crop edit correctly', async () => { - const result = await sut['applyEdits']( + const result = sut['applyEdits']( sharp({ create: { width: 1000, @@ -98,7 +98,7 @@ describe(MediaRepository.name, () => { expect(metadata.height).toBe(300); }); it('should apply rotate edit correctly', async () => { - const result = await sut['applyEdits']( + const result = sut['applyEdits']( sharp({ create: { width: 500, @@ -123,7 +123,7 @@ describe(MediaRepository.name, () => { }); it('should apply mirror edit correctly', async () => { - const resultHorizontal = await sut['applyEdits'](sharp(await buildTestQuadImage()), [ + const resultHorizontal = sut['applyEdits'](sharp(await buildTestQuadImage()), [ { action: AssetEditAction.Mirror, parameters: { @@ -142,7 +142,7 @@ describe(MediaRepository.name, () => { expect(await getPixelColor(bufferHorizontal, 10, 990)).toEqual({ r: 255, g: 255, b: 0 }); expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 0, g: 0, b: 255 }); - const resultVertical = await sut['applyEdits'](sharp(await buildTestQuadImage()), [ + const resultVertical = sut['applyEdits'](sharp(await buildTestQuadImage()), [ { action: AssetEditAction.Mirror, parameters: { @@ -170,7 +170,7 @@ describe(MediaRepository.name, () => { describe('applyEdits (multiple sequential edits)', () => { it('should apply horizontal mirror then vertical mirror (equivalent to 180° rotation)', async () => { const imageBuffer = await buildTestQuadImage(); - const result = await sut['applyEdits'](sharp(imageBuffer), [ + const result = sut['applyEdits'](sharp(imageBuffer), [ { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, ]); @@ -188,7 +188,7 @@ describe(MediaRepository.name, () => { it('should apply rotate 90° then horizontal mirror', async () => { const imageBuffer = await buildTestQuadImage(); - const result = await sut['applyEdits'](sharp(imageBuffer), [ + const result = sut['applyEdits'](sharp(imageBuffer), [ { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, ]); @@ -206,7 +206,7 @@ describe(MediaRepository.name, () => { it('should apply 180° rotation', async () => { const imageBuffer = await buildTestQuadImage(); - const result = await sut['applyEdits'](sharp(imageBuffer), [ + const result = sut['applyEdits'](sharp(imageBuffer), [ { action: AssetEditAction.Rotate, parameters: { angle: 180 } }, ]); @@ -223,7 +223,7 @@ describe(MediaRepository.name, () => { it('should apply 270° rotations', async () => { const imageBuffer = await buildTestQuadImage(); - const result = await sut['applyEdits'](sharp(imageBuffer), [ + const result = sut['applyEdits'](sharp(imageBuffer), [ { action: AssetEditAction.Rotate, parameters: { angle: 270 } }, ]); @@ -240,7 +240,7 @@ describe(MediaRepository.name, () => { it('should apply crop then rotate 90°', async () => { const imageBuffer = await buildTestQuadImage(); - const result = await sut['applyEdits'](sharp(imageBuffer), [ + const result = sut['applyEdits'](sharp(imageBuffer), [ { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 1000, height: 500 } }, { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, ]); @@ -256,7 +256,7 @@ describe(MediaRepository.name, () => { it('should apply rotate 90° then crop', async () => { const imageBuffer = await buildTestQuadImage(); - const result = await sut['applyEdits'](sharp(imageBuffer), [ + const result = sut['applyEdits'](sharp(imageBuffer), [ { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } }, { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, ]); @@ -272,7 +272,7 @@ describe(MediaRepository.name, () => { it('should apply vertical mirror then horizontal mirror then rotate 90°', async () => { const imageBuffer = await buildTestQuadImage(); - const result = await sut['applyEdits'](sharp(imageBuffer), [ + const result = sut['applyEdits'](sharp(imageBuffer), [ { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, @@ -291,7 +291,7 @@ describe(MediaRepository.name, () => { it('should apply crop to single quadrant then mirror', async () => { const imageBuffer = await buildTestQuadImage(); - const result = await sut['applyEdits'](sharp(imageBuffer), [ + const result = sut['applyEdits'](sharp(imageBuffer), [ { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 500 } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, ]); @@ -309,7 +309,7 @@ describe(MediaRepository.name, () => { it('should apply all operations: crop, rotate, mirror', async () => { const imageBuffer = await buildTestQuadImage(); - const result = await sut['applyEdits'](sharp(imageBuffer), [ + const result = sut['applyEdits'](sharp(imageBuffer), [ { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } }, { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index c1997fd49e..fa08ba8701 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -148,49 +148,45 @@ export class MediaRepository { } } - async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { - const pipeline = await this.getImageDecodingPipeline(input, options); - return pipeline.raw().toBuffer({ resolveWithObject: true }); + decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { + return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); } - private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise { - const affineEditOperations = edits.filter((edit) => edit.action !== 'crop'); - const matrix = createAffineMatrix(affineEditOperations); - + private applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): sharp.Sharp { const crop = edits.find((edit) => edit.action === 'crop'); - const dimensions = await pipeline.metadata(); - if (crop) { pipeline = pipeline.extract({ - left: crop ? Math.round(crop.parameters.x) : 0, - top: crop ? Math.round(crop.parameters.y) : 0, - width: crop ? Math.round(crop.parameters.width) : dimensions.width || 0, - height: crop ? Math.round(crop.parameters.height) : dimensions.height || 0, + left: Math.round(crop.parameters.x), + top: Math.round(crop.parameters.y), + width: Math.round(crop.parameters.width), + height: Math.round(crop.parameters.height), }); } - const { a, b, c, d } = matrix; - pipeline = pipeline.affine([ - [a, b], - [c, d], - ]); + const affineEditOperations = edits.filter((edit) => edit.action !== 'crop'); + if (affineEditOperations.length > 0) { + const { a, b, c, d } = createAffineMatrix(affineEditOperations); + pipeline = pipeline.affine([ + [a, b], + [c, d], + ]); + } return pipeline; } async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { - const pipeline = await this.getImageDecodingPipeline(input, options); - const decoded = pipeline.toFormat(options.format, { - quality: options.quality, - // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp - chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', - progressive: options.progressive, - }); - - await decoded.toFile(output); + await this.getImageDecodingPipeline(input, options) + .toFormat(options.format, { + quality: options.quality, + // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp + chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', + progressive: options.progressive, + }) + .toFile(output); } - private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { + private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { let pipeline = sharp(input, { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes failOn: options.processInvalidImages ? 'none' : 'error', @@ -214,7 +210,7 @@ export class MediaRepository { } if (options.edits && options.edits.length > 0) { - pipeline = await this.applyEdits(pipeline, options.edits); + pipeline = this.applyEdits(pipeline, options.edits); } if (options.size !== undefined) { @@ -224,19 +220,18 @@ export class MediaRepository { } async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { - const [{ rgbaToThumbHash }, decodingPipeline] = await Promise.all([ - import('thumbhash'), - this.getImageDecodingPipeline(input, { - colorspace: options.colorspace, - processInvalidImages: options.processInvalidImages, - raw: options.raw, - edits: options.edits, - }), - ]); + const { rgbaToThumbHash } = await import('thumbhash'); - const pipeline = decodingPipeline.resize(100, 100, { fit: 'inside', withoutEnlargement: true }).raw().ensureAlpha(); - - const { data, info } = await pipeline.toBuffer({ resolveWithObject: true }); + const { data, info } = await this.getImageDecodingPipeline(input, { + colorspace: options.colorspace, + processInvalidImages: options.processInvalidImages, + raw: options.raw, + edits: options.edits, + }) + .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }); return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); }