Skip to content

Commit 0e26101

Browse files
committed
perf(backend:files): enhance thumbnail generation to use WebP format and optimize image processing settings
1 parent a4b94a1 commit 0e26101

File tree

4 files changed

+69
-31
lines changed

4 files changed

+69
-31
lines changed

backend/src/applications/files/files.controller.spec.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe(FilesController.name, () => {
2020
const fakeUser: any = { id: 1, login: 'john', role: 1 }
2121
const fakeSpace: any = { id: 42, key: 'space-key', url: '/space/a', realPath: '/data/space/a', realBasePath: '/data/space' }
2222
const fakeReq: any = { user: fakeUser, space: fakeSpace, headers: {}, method: 'GET', ip: '127.0.0.1' }
23-
const fakeRes: any = { header: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis(), send: jest.fn() }
23+
const fakeRes: any = { header: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis(), type: jest.fn().mockReturnThis(), send: jest.fn() }
2424

2525
// Mocks
2626
const filesMethodsMock = {
@@ -142,20 +142,36 @@ describe(FilesController.name, () => {
142142
filesMethodsMock.genThumbnail.mockResolvedValue(stream)
143143

144144
// pass undefined to exercise controller default parameter
145-
const result = await filesController.genThumbnail(fakeSpace, undefined as unknown as number)
145+
const result = await filesController.genThumbnail(fakeSpace, undefined as unknown as number, fakeRes)
146146

147147
expect(filesMethodsMock.genThumbnail).toHaveBeenCalledWith(fakeSpace, 256)
148-
expect(result).toBe(stream)
148+
expect(fakeRes.type).toHaveBeenCalled()
149+
expect(fakeRes.send).toHaveBeenCalledWith(stream)
150+
expect(result).toBeUndefined()
149151
})
150152

151153
it('genThumbnail() should pass provided size', async () => {
152154
const stream = {} as any
153155
filesMethodsMock.genThumbnail.mockResolvedValue(stream)
154156

155-
const result = await filesController.genThumbnail(fakeSpace, 512)
157+
const result = await filesController.genThumbnail(fakeSpace, 512, fakeRes)
156158

157159
expect(filesMethodsMock.genThumbnail).toHaveBeenCalledWith(fakeSpace, 512)
158-
expect(result).toBe(stream)
160+
expect(fakeRes.type).toHaveBeenCalled()
161+
expect(fakeRes.send).toHaveBeenCalledWith(stream)
162+
expect(result).toBeUndefined()
163+
})
164+
165+
it('genThumbnail() should reduce size larger than 1024', async () => {
166+
const stream = {} as any
167+
filesMethodsMock.genThumbnail.mockResolvedValue(stream)
168+
169+
const result = await filesController.genThumbnail(fakeSpace, 2048, fakeRes)
170+
171+
expect(filesMethodsMock.genThumbnail).toHaveBeenCalledWith(fakeSpace, 1024)
172+
expect(fakeRes.type).toHaveBeenCalled()
173+
expect(fakeRes.send).toHaveBeenCalledWith(stream)
174+
expect(result).toBeUndefined()
159175
})
160176
})
161177

backend/src/applications/files/files.controller.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,26 @@
44
* See the LICENSE file for licensing details
55
*/
66

7-
import { Body, Controller, Copy, Delete, Get, Head, Logger, Move, Post, Query, Req, Res, Search, StreamableFile, UseGuards } from '@nestjs/common'
7+
import {
8+
Body,
9+
Controller,
10+
Copy,
11+
Delete,
12+
Get,
13+
Head,
14+
Logger,
15+
Move,
16+
ParseIntPipe,
17+
Post,
18+
Query,
19+
Req,
20+
Res,
21+
Search,
22+
StreamableFile,
23+
UseGuards
24+
} from '@nestjs/common'
825
import { FastifyReply } from 'fastify'
26+
import { webpMimeType } from '../../common/image'
927
import { SkipSpaceGuard } from '../spaces/decorators/space-skip-guard.decorator'
1028
import { SkipSpacePermissionsCheck } from '../spaces/decorators/space-skip-permissions.decorator'
1129
import { GetSpace } from '../spaces/decorators/space.decorator'
@@ -89,8 +107,15 @@ export class FilesController {
89107
}
90108

91109
@Get(`${FILES_ROUTE.OPERATION}/${FILE_OPERATION.THUMBNAIL}/*`)
92-
async genThumbnail(@GetSpace() space: SpaceEnv, @Query('size') size: number = 256): Promise<StreamableFile> {
93-
return this.filesMethods.genThumbnail(space, size)
110+
async genThumbnail(
111+
@GetSpace() space: SpaceEnv,
112+
@Query('size', new ParseIntPipe({ optional: true })) size = 256,
113+
@Res() res: FastifyReply
114+
): Promise<StreamableFile> {
115+
if (size > 1024) size = 1024
116+
const thumb = await this.filesMethods.genThumbnail(space, size)
117+
res.type(webpMimeType)
118+
return res.send(thumb)
94119
}
95120

96121
// TASKS OPERATIONS

backend/src/applications/files/services/files-methods.service.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { HttpException, HttpStatus, Injectable, Logger, StreamableFile } from '@nestjs/common'
88
import { FastifyReply } from 'fastify'
99
import path from 'node:path'
10-
import { pngMimeType } from '../../../common/image'
10+
import { Readable } from 'node:stream'
1111
import { FastifySpaceRequest } from '../../spaces/interfaces/space-request.interface'
1212
import { SpaceEnv } from '../../spaces/models/space-env.model'
1313
import { SpacesManager } from '../../spaces/services/spaces-manager.service'
@@ -146,10 +146,9 @@ export class FilesMethods {
146146
}
147147
}
148148

149-
async genThumbnail(space: SpaceEnv, size: number): Promise<StreamableFile> {
149+
async genThumbnail(space: SpaceEnv, size: number): Promise<Readable> {
150150
try {
151-
const pngStream = await this.filesManager.generateThumbnail(space, size)
152-
return new StreamableFile(pngStream, { type: pngMimeType })
151+
return await this.filesManager.generateThumbnail(space, size)
153152
} catch (e) {
154153
this.handleError(space, this.genThumbnail.name, e)
155154
}

backend/src/common/image.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,32 @@
66

77
import { Resvg } from '@resvg/resvg-js'
88
import fs from 'node:fs/promises'
9+
import os from 'node:os'
910
import path from 'node:path'
1011
import { Readable } from 'node:stream'
1112
import sharp from 'sharp'
1213

1314
export const pngMimeType = 'image/png'
15+
export const webpMimeType = 'image/webp'
1416
export const svgMimeType = 'image/svg+xml'
1517
sharp.cache(false)
18+
sharp.concurrency(Math.min(2, os.cpus()?.length || 1))
1619

1720
export async function generateThumbnail(filePath: string, size: number): Promise<Readable> {
18-
const image = sharp(filePath, { autoOrient: true })
19-
let { width, height } = await image.metadata()
20-
21-
if (!width || !height) throw new Error('Invalid image dimensions')
22-
23-
// Calculate the new dimensions, maintaining the aspect ratio
24-
if (width > height) {
25-
if (width > size) {
26-
height = Math.round((height * size) / width)
27-
width = size
28-
}
29-
} else {
30-
if (height > size) {
31-
width = Math.round((width * size) / height)
32-
height = size
33-
}
34-
}
35-
36-
return image.resize(width, height, { fit: 'inside' }).png({ compressionLevel: 0 })
21+
return sharp(filePath, {
22+
sequentialRead: true, // sequential read = more efficient I/O
23+
limitInputPixels: 268e6 // protects against extremely large images
24+
})
25+
.rotate()
26+
.resize({
27+
width: size,
28+
height: size,
29+
fit: 'inside',
30+
kernel: 'mitchell',
31+
withoutEnlargement: true,
32+
fastShrinkOnLoad: true // true by default, added for clarity
33+
})
34+
.webp({ quality: 80, effort: 0, alphaQuality: 90 })
3735
}
3836

3937
export async function generateAvatar(initials: string): Promise<Buffer> {

0 commit comments

Comments
 (0)