mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
chore(server): simplify sharp edit code (#28249)
This commit is contained in:
@@ -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 } },
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user