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)', () => {
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 } },
+36 -41
View File
@@ -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<sharp.Sharp> {
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<void> {
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<Buffer> {
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));
}