chore(server): simplify sharp edit code (#28249)

This commit is contained in:
Mert
2026-05-06 09:32:49 -04:00
committed by GitHub
parent 42ff3b705d
commit 6580394cfe
2 changed files with 49 additions and 54 deletions
@@ -71,7 +71,7 @@ describe(MediaRepository.name, () => {
describe('applyEdits (single actions)', () => { describe('applyEdits (single actions)', () => {
it('should apply crop edit correctly', async () => { it('should apply crop edit correctly', async () => {
const result = await sut['applyEdits']( const result = sut['applyEdits'](
sharp({ sharp({
create: { create: {
width: 1000, width: 1000,
@@ -98,7 +98,7 @@ describe(MediaRepository.name, () => {
expect(metadata.height).toBe(300); expect(metadata.height).toBe(300);
}); });
it('should apply rotate edit correctly', async () => { it('should apply rotate edit correctly', async () => {
const result = await sut['applyEdits']( const result = sut['applyEdits'](
sharp({ sharp({
create: { create: {
width: 500, width: 500,
@@ -123,7 +123,7 @@ describe(MediaRepository.name, () => {
}); });
it('should apply mirror edit correctly', async () => { 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, action: AssetEditAction.Mirror,
parameters: { 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, 10, 990)).toEqual({ r: 255, g: 255, b: 0 });
expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 0, g: 0, b: 255 }); 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, action: AssetEditAction.Mirror,
parameters: { parameters: {
@@ -170,7 +170,7 @@ describe(MediaRepository.name, () => {
describe('applyEdits (multiple sequential edits)', () => { describe('applyEdits (multiple sequential edits)', () => {
it('should apply horizontal mirror then vertical mirror (equivalent to 180° rotation)', async () => { it('should apply horizontal mirror then vertical mirror (equivalent to 180° rotation)', async () => {
const imageBuffer = await buildTestQuadImage(); 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.Horizontal } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
]); ]);
@@ -188,7 +188,7 @@ describe(MediaRepository.name, () => {
it('should apply rotate 90° then horizontal mirror', async () => { it('should apply rotate 90° then horizontal mirror', async () => {
const imageBuffer = await buildTestQuadImage(); 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.Rotate, parameters: { angle: 90 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
]); ]);
@@ -206,7 +206,7 @@ describe(MediaRepository.name, () => {
it('should apply 180° rotation', async () => { it('should apply 180° rotation', async () => {
const imageBuffer = await buildTestQuadImage(); const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [ const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } }, { action: AssetEditAction.Rotate, parameters: { angle: 180 } },
]); ]);
@@ -223,7 +223,7 @@ describe(MediaRepository.name, () => {
it('should apply 270° rotations', async () => { it('should apply 270° rotations', async () => {
const imageBuffer = await buildTestQuadImage(); const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [ const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } }, { action: AssetEditAction.Rotate, parameters: { angle: 270 } },
]); ]);
@@ -240,7 +240,7 @@ describe(MediaRepository.name, () => {
it('should apply crop then rotate 90°', async () => { it('should apply crop then rotate 90°', async () => {
const imageBuffer = await buildTestQuadImage(); 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.Crop, parameters: { x: 0, y: 0, width: 1000, height: 500 } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }, { action: AssetEditAction.Rotate, parameters: { angle: 90 } },
]); ]);
@@ -256,7 +256,7 @@ describe(MediaRepository.name, () => {
it('should apply rotate 90° then crop', async () => { it('should apply rotate 90° then crop', async () => {
const imageBuffer = await buildTestQuadImage(); 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.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }, { 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 () => { it('should apply vertical mirror then horizontal mirror then rotate 90°', async () => {
const imageBuffer = await buildTestQuadImage(); 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.Vertical } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }, { action: AssetEditAction.Rotate, parameters: { angle: 90 } },
@@ -291,7 +291,7 @@ describe(MediaRepository.name, () => {
it('should apply crop to single quadrant then mirror', async () => { it('should apply crop to single quadrant then mirror', async () => {
const imageBuffer = await buildTestQuadImage(); 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.Crop, parameters: { x: 0, y: 0, width: 500, height: 500 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
]); ]);
@@ -309,7 +309,7 @@ describe(MediaRepository.name, () => {
it('should apply all operations: crop, rotate, mirror', async () => { it('should apply all operations: crop, rotate, mirror', async () => {
const imageBuffer = await buildTestQuadImage(); 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.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }, { action: AssetEditAction.Rotate, parameters: { angle: 90 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
+25 -30
View File
@@ -148,49 +148,45 @@ export class MediaRepository {
} }
} }
async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
const pipeline = await this.getImageDecodingPipeline(input, options); return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
return pipeline.raw().toBuffer({ resolveWithObject: true });
} }
private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise<sharp.Sharp> { private applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): sharp.Sharp {
const affineEditOperations = edits.filter((edit) => edit.action !== 'crop');
const matrix = createAffineMatrix(affineEditOperations);
const crop = edits.find((edit) => edit.action === 'crop'); const crop = edits.find((edit) => edit.action === 'crop');
const dimensions = await pipeline.metadata();
if (crop) { if (crop) {
pipeline = pipeline.extract({ pipeline = pipeline.extract({
left: crop ? Math.round(crop.parameters.x) : 0, left: Math.round(crop.parameters.x),
top: crop ? Math.round(crop.parameters.y) : 0, top: Math.round(crop.parameters.y),
width: crop ? Math.round(crop.parameters.width) : dimensions.width || 0, width: Math.round(crop.parameters.width),
height: crop ? Math.round(crop.parameters.height) : dimensions.height || 0, height: Math.round(crop.parameters.height),
}); });
} }
const { a, b, c, d } = matrix; const affineEditOperations = edits.filter((edit) => edit.action !== 'crop');
if (affineEditOperations.length > 0) {
const { a, b, c, d } = createAffineMatrix(affineEditOperations);
pipeline = pipeline.affine([ pipeline = pipeline.affine([
[a, b], [a, b],
[c, d], [c, d],
]); ]);
}
return pipeline; return pipeline;
} }
async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> { async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
const pipeline = await this.getImageDecodingPipeline(input, options); await this.getImageDecodingPipeline(input, options)
const decoded = pipeline.toFormat(options.format, { .toFormat(options.format, {
quality: options.quality, quality: options.quality,
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp // 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', chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: options.progressive, progressive: options.progressive,
}); })
.toFile(output);
await decoded.toFile(output);
} }
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
let pipeline = sharp(input, { 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 // 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', failOn: options.processInvalidImages ? 'none' : 'error',
@@ -214,7 +210,7 @@ export class MediaRepository {
} }
if (options.edits && options.edits.length > 0) { if (options.edits && options.edits.length > 0) {
pipeline = await this.applyEdits(pipeline, options.edits); pipeline = this.applyEdits(pipeline, options.edits);
} }
if (options.size !== undefined) { if (options.size !== undefined) {
@@ -224,19 +220,18 @@ export class MediaRepository {
} }
async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> { async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> {
const [{ rgbaToThumbHash }, decodingPipeline] = await Promise.all([ const { rgbaToThumbHash } = await import('thumbhash');
import('thumbhash'),
this.getImageDecodingPipeline(input, { const { data, info } = await this.getImageDecodingPipeline(input, {
colorspace: options.colorspace, colorspace: options.colorspace,
processInvalidImages: options.processInvalidImages, processInvalidImages: options.processInvalidImages,
raw: options.raw, raw: options.raw,
edits: options.edits, edits: options.edits,
}), })
]); .resize(100, 100, { fit: 'inside', withoutEnlargement: true })
.raw()
const pipeline = decodingPipeline.resize(100, 100, { fit: 'inside', withoutEnlargement: true }).raw().ensureAlpha(); .ensureAlpha()
.toBuffer({ resolveWithObject: true });
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); return Buffer.from(rgbaToThumbHash(info.width, info.height, data));
} }