diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 866e44aecc..ddd857d101 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -358,6 +358,7 @@ Class | Method | HTTP request | Description - [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md) - [AssetEditAction](doc//AssetEditAction.md) - [AssetEditActionCrop](doc//AssetEditActionCrop.md) + - [AssetEditActionFilter](doc//AssetEditActionFilter.md) - [AssetEditActionListDto](doc//AssetEditActionListDto.md) - [AssetEditActionListDtoEditsInner](doc//AssetEditActionListDtoEditsInner.md) - [AssetEditActionMirror](doc//AssetEditActionMirror.md) @@ -427,6 +428,7 @@ Class | Method | HTTP request | Description - [ExifResponseDto](doc//ExifResponseDto.md) - [FaceDto](doc//FaceDto.md) - [FacialRecognitionConfig](doc//FacialRecognitionConfig.md) + - [FilterParameters](doc//FilterParameters.md) - [FoldersResponse](doc//FoldersResponse.md) - [FoldersUpdate](doc//FoldersUpdate.md) - [ImageFormat](doc//ImageFormat.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 04634b7da6..b097234b4a 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -98,6 +98,7 @@ part 'model/asset_delta_sync_dto.dart'; part 'model/asset_delta_sync_response_dto.dart'; part 'model/asset_edit_action.dart'; part 'model/asset_edit_action_crop.dart'; +part 'model/asset_edit_action_filter.dart'; part 'model/asset_edit_action_list_dto.dart'; part 'model/asset_edit_action_list_dto_edits_inner.dart'; part 'model/asset_edit_action_mirror.dart'; @@ -167,6 +168,7 @@ part 'model/email_notifications_update.dart'; part 'model/exif_response_dto.dart'; part 'model/face_dto.dart'; part 'model/facial_recognition_config.dart'; +part 'model/filter_parameters.dart'; part 'model/folders_response.dart'; part 'model/folders_update.dart'; part 'model/image_format.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 891fcb4335..d19236715f 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -242,6 +242,8 @@ class ApiClient { return AssetEditActionTypeTransformer().decode(value); case 'AssetEditActionCrop': return AssetEditActionCrop.fromJson(value); + case 'AssetEditActionFilter': + return AssetEditActionFilter.fromJson(value); case 'AssetEditActionListDto': return AssetEditActionListDto.fromJson(value); case 'AssetEditActionListDtoEditsInner': @@ -380,6 +382,8 @@ class ApiClient { return FaceDto.fromJson(value); case 'FacialRecognitionConfig': return FacialRecognitionConfig.fromJson(value); + case 'FilterParameters': + return FilterParameters.fromJson(value); case 'FoldersResponse': return FoldersResponse.fromJson(value); case 'FoldersUpdate': diff --git a/mobile/openapi/lib/model/asset_edit_action.dart b/mobile/openapi/lib/model/asset_edit_action.dart index 12aacfb68a..78031773d1 100644 --- a/mobile/openapi/lib/model/asset_edit_action.dart +++ b/mobile/openapi/lib/model/asset_edit_action.dart @@ -26,12 +26,14 @@ class AssetEditAction { static const crop = AssetEditAction._(r'crop'); static const rotate = AssetEditAction._(r'rotate'); static const mirror = AssetEditAction._(r'mirror'); + static const filter = AssetEditAction._(r'filter'); /// List of all possible values in this [enum][AssetEditAction]. static const values = [ crop, rotate, mirror, + filter, ]; static AssetEditAction? fromJson(dynamic value) => AssetEditActionTypeTransformer().decode(value); @@ -73,6 +75,7 @@ class AssetEditActionTypeTransformer { case r'crop': return AssetEditAction.crop; case r'rotate': return AssetEditAction.rotate; case r'mirror': return AssetEditAction.mirror; + case r'filter': return AssetEditAction.filter; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/asset_edit_action_filter.dart b/mobile/openapi/lib/model/asset_edit_action_filter.dart new file mode 100644 index 0000000000..216958e67f --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_filter.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionFilter { + /// Returns a new [AssetEditActionFilter] instance. + AssetEditActionFilter({ + required this.action, + required this.parameters, + }); + + AssetEditAction action; + + FilterParameters parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionFilter && + other.action == action && + other.parameters == parameters; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (parameters.hashCode); + + @override + String toString() => 'AssetEditActionFilter[action=$action, parameters=$parameters]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'parameters'] = this.parameters; + return json; + } + + /// Returns a new [AssetEditActionFilter] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionFilter? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionFilter"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionFilter( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: FilterParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionFilter.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionFilter.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionFilter-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionFilter.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + diff --git a/mobile/openapi/lib/model/filter_parameters.dart b/mobile/openapi/lib/model/filter_parameters.dart new file mode 100644 index 0000000000..5ea09067a7 --- /dev/null +++ b/mobile/openapi/lib/model/filter_parameters.dart @@ -0,0 +1,208 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FilterParameters { + /// Returns a new [FilterParameters] instance. + FilterParameters({ + required this.bOffset, + required this.bbBias, + required this.bgBias, + required this.brBias, + required this.gOffset, + required this.gbBias, + required this.ggBias, + required this.grBias, + required this.rOffset, + required this.rbBias, + required this.rgBias, + required this.rrBias, + }); + + /// B Offset (-255 -> 255) + /// + /// Minimum value: -255 + /// Maximum value: 255 + num bOffset; + + /// BB Bias + num bbBias; + + /// BG Bias + num bgBias; + + /// BR Bias + num brBias; + + /// G Offset (-255 -> 255) + /// + /// Minimum value: -255 + /// Maximum value: 255 + num gOffset; + + /// GB Bias + num gbBias; + + /// GG Bias + num ggBias; + + /// GR Bias + num grBias; + + /// R Offset (-255 -> 255) + /// + /// Minimum value: -255 + /// Maximum value: 255 + num rOffset; + + /// RB Bias + num rbBias; + + /// RG Bias + num rgBias; + + /// RR Bias + num rrBias; + + @override + bool operator ==(Object other) => identical(this, other) || other is FilterParameters && + other.bOffset == bOffset && + other.bbBias == bbBias && + other.bgBias == bgBias && + other.brBias == brBias && + other.gOffset == gOffset && + other.gbBias == gbBias && + other.ggBias == ggBias && + other.grBias == grBias && + other.rOffset == rOffset && + other.rbBias == rbBias && + other.rgBias == rgBias && + other.rrBias == rrBias; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (bOffset.hashCode) + + (bbBias.hashCode) + + (bgBias.hashCode) + + (brBias.hashCode) + + (gOffset.hashCode) + + (gbBias.hashCode) + + (ggBias.hashCode) + + (grBias.hashCode) + + (rOffset.hashCode) + + (rbBias.hashCode) + + (rgBias.hashCode) + + (rrBias.hashCode); + + @override + String toString() => 'FilterParameters[bOffset=$bOffset, bbBias=$bbBias, bgBias=$bgBias, brBias=$brBias, gOffset=$gOffset, gbBias=$gbBias, ggBias=$ggBias, grBias=$grBias, rOffset=$rOffset, rbBias=$rbBias, rgBias=$rgBias, rrBias=$rrBias]'; + + Map toJson() { + final json = {}; + json[r'bOffset'] = this.bOffset; + json[r'bbBias'] = this.bbBias; + json[r'bgBias'] = this.bgBias; + json[r'brBias'] = this.brBias; + json[r'gOffset'] = this.gOffset; + json[r'gbBias'] = this.gbBias; + json[r'ggBias'] = this.ggBias; + json[r'grBias'] = this.grBias; + json[r'rOffset'] = this.rOffset; + json[r'rbBias'] = this.rbBias; + json[r'rgBias'] = this.rgBias; + json[r'rrBias'] = this.rrBias; + return json; + } + + /// Returns a new [FilterParameters] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FilterParameters? fromJson(dynamic value) { + upgradeDto(value, "FilterParameters"); + if (value is Map) { + final json = value.cast(); + + return FilterParameters( + bOffset: num.parse('${json[r'bOffset']}'), + bbBias: num.parse('${json[r'bbBias']}'), + bgBias: num.parse('${json[r'bgBias']}'), + brBias: num.parse('${json[r'brBias']}'), + gOffset: num.parse('${json[r'gOffset']}'), + gbBias: num.parse('${json[r'gbBias']}'), + ggBias: num.parse('${json[r'ggBias']}'), + grBias: num.parse('${json[r'grBias']}'), + rOffset: num.parse('${json[r'rOffset']}'), + rbBias: num.parse('${json[r'rbBias']}'), + rgBias: num.parse('${json[r'rgBias']}'), + rrBias: num.parse('${json[r'rrBias']}'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FilterParameters.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FilterParameters.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FilterParameters-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FilterParameters.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'bOffset', + 'bbBias', + 'bgBias', + 'brBias', + 'gOffset', + 'gbBias', + 'ggBias', + 'grBias', + 'rOffset', + 'rbBias', + 'rgBias', + 'rrBias', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 378bb4c474..d92e837ef4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -15797,7 +15797,8 @@ "enum": [ "crop", "rotate", - "mirror" + "mirror", + "filter" ], "type": "string" }, @@ -15820,6 +15821,25 @@ ], "type": "object" }, + "AssetEditActionFilter": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ] + }, + "parameters": { + "$ref": "#/components/schemas/FilterParameters" + } + }, + "required": [ + "action", + "parameters" + ], + "type": "object" + }, "AssetEditActionListDto": { "properties": { "edits": { @@ -15834,6 +15854,9 @@ }, { "$ref": "#/components/schemas/AssetEditActionMirror" + }, + { + "$ref": "#/components/schemas/AssetEditActionFilter" } ] }, @@ -15902,6 +15925,9 @@ }, { "$ref": "#/components/schemas/AssetEditActionMirror" + }, + { + "$ref": "#/components/schemas/AssetEditActionFilter" } ] }, @@ -17545,6 +17571,79 @@ ], "type": "object" }, + "FilterParameters": { + "properties": { + "bOffset": { + "description": "B Offset (-255 -> 255)", + "maximum": 255, + "minimum": -255, + "type": "number" + }, + "bbBias": { + "description": "BB Bias", + "type": "number" + }, + "bgBias": { + "description": "BG Bias", + "type": "number" + }, + "brBias": { + "description": "BR Bias", + "type": "number" + }, + "gOffset": { + "description": "G Offset (-255 -> 255)", + "maximum": 255, + "minimum": -255, + "type": "number" + }, + "gbBias": { + "description": "GB Bias", + "type": "number" + }, + "ggBias": { + "description": "GG Bias", + "type": "number" + }, + "grBias": { + "description": "GR Bias", + "type": "number" + }, + "rOffset": { + "description": "R Offset (-255 -> 255)", + "maximum": 255, + "minimum": -255, + "type": "number" + }, + "rbBias": { + "description": "RB Bias", + "type": "number" + }, + "rgBias": { + "description": "RG Bias", + "type": "number" + }, + "rrBias": { + "description": "RR Bias", + "type": "number" + } + }, + "required": [ + "bOffset", + "bbBias", + "bgBias", + "brBias", + "gOffset", + "gbBias", + "ggBias", + "grBias", + "rOffset", + "rbBias", + "rgBias", + "rrBias" + ], + "type": "object" + }, "FoldersResponse": { "properties": { "enabled": { diff --git a/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch b/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch index 7c0010a354..47c03ca33b 100644 --- a/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch +++ b/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch @@ -4,7 +4,7 @@ AssetEditAction action; -- MirrorParameters parameters; +- FilterParameters parameters; + Map parameters; @override @@ -13,7 +13,7 @@ return AssetEditActionListDtoEditsInner( action: AssetEditAction.fromJson(json[r'action'])!, -- parameters: MirrorParameters.fromJson(json[r'parameters'])!, +- parameters: FilterParameters.fromJson(json[r'parameters'])!, + parameters: json[r'parameters'], ); } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d2ae00ed93..a8e3fcaa16 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -637,14 +637,44 @@ export type AssetEditActionMirror = { action: AssetEditAction; parameters: MirrorParameters; }; +export type FilterParameters = { + /** B Offset (-255 -> 255) */ + bOffset: number; + /** BB Bias */ + bbBias: number; + /** BG Bias */ + bgBias: number; + /** BR Bias */ + brBias: number; + /** G Offset (-255 -> 255) */ + gOffset: number; + /** GB Bias */ + gbBias: number; + /** GG Bias */ + ggBias: number; + /** GR Bias */ + grBias: number; + /** R Offset (-255 -> 255) */ + rOffset: number; + /** RB Bias */ + rbBias: number; + /** RG Bias */ + rgBias: number; + /** RR Bias */ + rrBias: number; +}; +export type AssetEditActionFilter = { + action: AssetEditAction; + parameters: FilterParameters; +}; export type AssetEditsDto = { assetId: string; /** list of edits */ - edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; + edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter)[]; }; export type AssetEditActionListDto = { /** list of edits */ - edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; + edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter)[]; }; export type AssetMetadataResponseDto = { key: string; @@ -5870,7 +5900,8 @@ export enum AssetJobName { export enum AssetEditAction { Crop = "crop", Rotate = "rotate", - Mirror = "mirror" + Mirror = "mirror", + Filter = "filter" } export enum MirrorAxis { Horizontal = "horizontal", diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts index 56bd09f3ea..5097c94271 100644 --- a/server/src/dtos/editing.dto.ts +++ b/server/src/dtos/editing.dto.ts @@ -1,12 +1,13 @@ import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer'; -import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator'; +import { ArrayMinSize, IsEnum, IsInt, IsNumber, Max, Min, ValidateNested } from 'class-validator'; import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation'; export enum AssetEditAction { Crop = 'crop', Rotate = 'rotate', Mirror = 'mirror', + Filter = 'filter', } export enum MirrorAxis { @@ -48,6 +49,68 @@ export class MirrorParameters { axis!: MirrorAxis; } +// Sharp supports a 3x3 matrix for color manipulation and rgb offsets +// The matrix representation of a filter is as follows: +// | rrBias rgBias rbBias | | r_offset | +// Image x | grBias ggBias gbBias | + | g_offset | +// | brBias bgBias bbBias | | b_offset | + +export class FilterParameters { + @IsNumber() + @ApiProperty({ description: 'RR Bias' }) + rrBias!: number; + + @IsNumber() + @ApiProperty({ description: 'RG Bias' }) + rgBias!: number; + + @IsNumber() + @ApiProperty({ description: 'RB Bias' }) + rbBias!: number; + + @IsNumber() + @ApiProperty({ description: 'GR Bias' }) + grBias!: number; + + @IsNumber() + @ApiProperty({ description: 'GG Bias' }) + ggBias!: number; + + @IsNumber() + @ApiProperty({ description: 'GB Bias' }) + gbBias!: number; + + @IsNumber() + @ApiProperty({ description: 'BR Bias' }) + brBias!: number; + + @IsNumber() + @ApiProperty({ description: 'BG Bias' }) + bgBias!: number; + + @IsNumber() + @ApiProperty({ description: 'BB Bias' }) + bbBias!: number; + + @IsInt() + @Min(-255) + @Max(255) + @ApiProperty({ description: 'R Offset (-255 -> 255)' }) + rOffset!: number; + + @IsInt() + @Min(-255) + @Max(255) + @ApiProperty({ description: 'G Offset (-255 -> 255)' }) + gOffset!: number; + + @IsInt() + @Min(-255) + @Max(255) + @ApiProperty({ description: 'B Offset (-255 -> 255)' }) + bOffset!: number; +} + class AssetEditActionBase { @IsEnum(AssetEditAction) @ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction' }) @@ -74,6 +137,12 @@ export class AssetEditActionMirror extends AssetEditActionBase { @ApiProperty({ type: MirrorParameters }) parameters!: MirrorParameters; } +export class AssetEditActionFilter extends AssetEditActionBase { + @ValidateNested() + @Type(() => FilterParameters) + @ApiProperty({ type: FilterParameters }) + parameters!: FilterParameters; +} export type AssetEditActionItem = | { @@ -87,25 +156,31 @@ export type AssetEditActionItem = | { action: AssetEditAction.Mirror; parameters: MirrorParameters; + } + | { + action: AssetEditAction.Filter; + parameters: FilterParameters; }; export type AssetEditActionParameter = { [AssetEditAction.Crop]: CropParameters; [AssetEditAction.Rotate]: RotateParameters; [AssetEditAction.Mirror]: MirrorParameters; + [AssetEditAction.Filter]: FilterParameters; }; -type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror; +type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter; const actionToClass: Record> = { [AssetEditAction.Crop]: AssetEditActionCrop, [AssetEditAction.Rotate]: AssetEditActionRotate, [AssetEditAction.Mirror]: AssetEditActionMirror, + [AssetEditAction.Filter]: AssetEditActionFilter, } as const; const getActionClass = (item: { action: AssetEditAction }): ClassConstructor => actionToClass[item.action]; -@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop) +@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop, AssetEditActionFilter) export class AssetEditActionListDto { /** list of edits */ @ArrayMinSize(1) diff --git a/server/src/repositories/media.repository.spec.ts b/server/src/repositories/media.repository.spec.ts index a5380852ee..ccb70d38c7 100644 --- a/server/src/repositories/media.repository.spec.ts +++ b/server/src/repositories/media.repository.spec.ts @@ -165,6 +165,38 @@ describe(MediaRepository.name, () => { // bottom-right should now be top-right (blue) expect(await getPixelColor(bufferVertical, 990, 990)).toEqual({ r: 0, g: 255, b: 0 }); }); + + it('should apply filter edit correctly', async () => { + const resultHorizontal = await sut['applyEdits'](sharp(await buildTestQuadImage()), [ + { + action: AssetEditAction.Filter, + parameters: { + rrBias: 1, + rgBias: 0.5, + rbBias: 0.5, + grBias: 0.5, + ggBias: 1, + gbBias: 0.5, + brBias: 0.5, + bgBias: 0.5, + bbBias: 1, + rOffset: 5, + gOffset: 10, + bOffset: 15, + }, + }, + ]); + + const bufferHorizontal = await resultHorizontal.toBuffer(); + const metadataHorizontal = await resultHorizontal.metadata(); + expect(metadataHorizontal.width).toBe(1000); + expect(metadataHorizontal.height).toBe(1000); + + expect(await getPixelColor(bufferHorizontal, 10, 10)).toEqual({ r: 255, g: 137, b: 142 }); + expect(await getPixelColor(bufferHorizontal, 990, 10)).toEqual({ r: 132, g: 255, b: 142 }); + expect(await getPixelColor(bufferHorizontal, 10, 990)).toEqual({ r: 132, g: 137, b: 255 }); + expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 255, g: 255, b: 255 }); + }); }); describe('applyEdits (multiple sequential edits)', () => { @@ -307,12 +339,29 @@ describe(MediaRepository.name, () => { expect(await getPixelColor(buffer, 490, 490)).toEqual({ r: 255, g: 0, b: 0 }); }); - it('should apply all operations: crop, rotate, mirror', async () => { + it('should apply all operations: crop, rotate, mirror, filter', async () => { const imageBuffer = await buildTestQuadImage(); const result = await 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 } }, + { + action: AssetEditAction.Filter, + parameters: { + rrBias: 1, + rgBias: 0, + rbBias: 0, + grBias: 0, + ggBias: 1, + gbBias: 0, + brBias: 0, + bgBias: 0, + bbBias: 1, + rOffset: -10, + gOffset: 20, + bOffset: -30, + }, + }, ]); const buffer = await result.png().toBuffer(); @@ -320,8 +369,8 @@ describe(MediaRepository.name, () => { expect(metadata.width).toBe(1000); expect(metadata.height).toBe(500); - expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 }); - expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 }); + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 245, g: 20, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 20, b: 225 }); }); }); diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 33025e73cf..2a1a69b6c7 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -19,6 +19,7 @@ import { TranscodeCommand, VideoInfo, } from 'src/types'; +import { convertColorFilterToMatricies } from 'src/utils/color_filter'; import { handlePromiseError } from 'src/utils/misc'; import { createAffineMatrix } from 'src/utils/transform'; @@ -167,6 +168,12 @@ export class MediaRepository { [c, d], ]); + const filter = edits.find((edit) => edit.action === 'filter'); + if (filter) { + const { biasMatrix, offsetMatrix } = convertColorFilterToMatricies(filter.parameters); + pipeline = pipeline.recomb(biasMatrix).linear([1, 1, 1], offsetMatrix); + } + return pipeline; } diff --git a/server/src/utils/color_filter.ts b/server/src/utils/color_filter.ts new file mode 100644 index 0000000000..9ed7a1bdd4 --- /dev/null +++ b/server/src/utils/color_filter.ts @@ -0,0 +1,14 @@ +import { Matrix3x3 } from 'sharp'; +import { FilterParameters } from 'src/dtos/editing.dto'; + +export function convertColorFilterToMatricies(filter: FilterParameters) { + const biasMatrix: Matrix3x3 = [ + [filter.rrBias, filter.rgBias, filter.rbBias], + [filter.grBias, filter.ggBias, filter.gbBias], + [filter.brBias, filter.bgBias, filter.bbBias], + ]; + + const offsetMatrix = [filter.rOffset, filter.gOffset, filter.bOffset]; + + return { biasMatrix, offsetMatrix }; +}