Skip to content

Commit 9be44ce

Browse files
committed
feat: add imagor library with filters and path builder
1 parent 552ebf4 commit 9be44ce

11 files changed

Lines changed: 403 additions & 0 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ConfigurableModuleBuilder } from '@nestjs/common';
2+
import type { ImagorModuleOptions } from './interfaces';
3+
4+
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } =
5+
new ConfigurableModuleBuilder<ImagorModuleOptions>()
6+
.setClassMethodName('forRoot')
7+
.setExtras(
8+
{
9+
global: true,
10+
},
11+
(definition, extras) => ({
12+
...definition,
13+
global: extras.global,
14+
}),
15+
)
16+
.build();

libs/imagor/src/imagor.module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { ConfigurableModuleClass } from './imagor.module-definition';
3+
import { ImagorService } from './imagor.service';
4+
5+
@Module({
6+
providers: [ImagorService],
7+
exports: [ImagorService],
8+
})
9+
export class ImagorModule extends ConfigurableModuleClass {}

libs/imagor/src/imagor.service.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Inject, Injectable, StreamableFile } from '@nestjs/common';
2+
import { MODULE_OPTIONS_TOKEN } from './imagor.module-definition';
3+
import type { ImagorModuleOptions, Filters } from './interfaces';
4+
import { createHmac } from 'crypto';
5+
import { HttpService } from '@nestjs/axios';
6+
import { ImagorPathBuilder } from './utils';
7+
import { firstValueFrom } from 'rxjs';
8+
9+
@Injectable()
10+
export class ImagorService {
11+
constructor(
12+
@Inject(MODULE_OPTIONS_TOKEN)
13+
private options: ImagorModuleOptions,
14+
private readonly http: HttpService,
15+
) {}
16+
17+
prepare(path: string): ImagorPathBuilder {
18+
const builder = new ImagorPathBuilder(path, this.options.storageRoot);
19+
if (this.options.filters) builder.applyFilters(this.options.filters);
20+
return builder;
21+
}
22+
23+
async buffer(path: string, preset?: string): Promise<Buffer> {
24+
const url = this.buildUrl(path, preset);
25+
const { data } = await firstValueFrom(this.http.get(url, { responseType: 'arraybuffer' }));
26+
return Buffer.from(data);
27+
}
28+
29+
async response(path: string, preset?: string): Promise<StreamableFile> {
30+
const url = this.buildUrl(path, preset);
31+
const { data, headers } = await firstValueFrom(
32+
this.http.get(url, { responseType: 'stream' }),
33+
);
34+
35+
return new StreamableFile(data, {
36+
type: headers['content-type'] as string,
37+
length: headers['content-length'] ? Number(headers['content-length']) : undefined,
38+
});
39+
}
40+
41+
private buildUrl(path: string, presetOrFilters?: string | any): string {
42+
const builder = new ImagorPathBuilder(path, this.options.storageRoot);
43+
44+
if (this.options.filters) builder.applyFilters(this.options.filters);
45+
46+
if (typeof presetOrFilters === 'string') {
47+
builder.applyFilters(this.options.presets?.[presetOrFilters] || {});
48+
} else if (presetOrFilters) {
49+
builder.applyFilters(presetOrFilters);
50+
}
51+
52+
const transformPath = builder.build();
53+
const signature = this.sign(transformPath);
54+
const host = this.options.url.replace(/\/+$/, '');
55+
56+
return `${host}/${signature}/${transformPath}`;
57+
}
58+
59+
private sign(path: string): string {
60+
if (!this.options.secret) return 'unsafe';
61+
62+
return createHmac('sha1', this.options.secret)
63+
.update(path)
64+
.digest('base64')
65+
.replace(/\+/g, '-')
66+
.replace(/\//g, '_');
67+
}
68+
69+
private resolveFilters(localFilters?: Filters): Filters {
70+
return {
71+
...this.options.filters,
72+
...localFilters,
73+
};
74+
}
75+
}

libs/imagor/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ImagorModule } from './imagor.module';
2+
export { ImagorService } from './imagor.service';
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { Format } from './formats.interface';
2+
3+
/**
4+
* Набор фильтров и трансформаций Imagor.
5+
* Порядок применения фильтров в URL обычно соответствует порядку их перечисления.
6+
* @see https://github.com/cshum/imagor#filters
7+
*/
8+
export interface Filters {
9+
/**
10+
* Устанавливает качество выходного изображения.
11+
* @param {number} quality Число от 0 до 100.
12+
*/
13+
quality?: number;
14+
15+
/**
16+
* Принудительно устанавливает формат выходного изображения.
17+
* WebP и AVIF рекомендуются для лучшего сжатия.
18+
*/
19+
format?: Format;
20+
21+
/**
22+
* Если true, автоматически конвертирует изображения с прозрачностью в JPEG,
23+
* заменяя прозрачные области фоном (белым по умолчанию).
24+
*/
25+
autojpg?: boolean;
26+
27+
/** Удаляет EXIF метаданные из выходного изображения. Полезно для приватности и уменьшения размера. */
28+
strip_exif?: boolean;
29+
30+
/** Удаляет ICC профили цвета. */
31+
strip_icc?: boolean;
32+
33+
/**
34+
* Регулирует яркость изображения.
35+
* @param {number} brightness Число от -100 до 100. Положительные — ярче, отрицательные — темнее.
36+
*/
37+
brightness?: number;
38+
39+
/**
40+
* Регулирует контрастность изображения.
41+
* @param {number} contrast Число от -100 до 100.
42+
*/
43+
contrast?: number;
44+
45+
/** Преобразует изображение в черно-белое (grayscale). */
46+
grayscale?: boolean;
47+
48+
/**
49+
* Настройка цветовых каналов RGB.
50+
* @property {number} r Красный (-100 до 100)
51+
* @property {number} g Зеленый (-100 до 100)
52+
* @property {number} b Синий (-100 до 100)
53+
*/
54+
rgb?: { r: number; g: number; b: number };
55+
56+
/**
57+
* Изменяет общую насыщенность цветов.
58+
* @param {number} proportion Число от 0 до 100.
59+
*/
60+
proportion?: number;
61+
62+
/**
63+
* Применяет размытие Гаусса.
64+
* Можно передать число (радиус) или объект для более точной настройки сигмы.
65+
*/
66+
blur?: number | { radius: number; sigma?: number };
67+
68+
/**
69+
* Повышает резкость изображения.
70+
* @property {number} amount Степень резкости.
71+
* @property {number} radius Радиус фильтра.
72+
* @property {number} threshold Порог срабатывания.
73+
*/
74+
sharpen?: {
75+
amount: number;
76+
radius: number;
77+
threshold: number;
78+
};
79+
80+
/**
81+
* Добавляет шум на изображение.
82+
* @param {number} noise Уровень шума от 0 до 100.
83+
*/
84+
noise?: number;
85+
86+
/** Поворачивает изображение на заданный угол по часовой стрелке. */
87+
rotate?: 90 | 180 | 270;
88+
89+
/**
90+
* Определяет цвет заполнения пустых областей при использовании режима 'fit-in'.
91+
* @example 'ff0000' (hex), 'white' (name) или 'auto' (главный цвет изображения).
92+
*/
93+
fill?: string;
94+
95+
/** Устанавливает цвет фона для прозрачных изображений (например, PNG). */
96+
background_color?: string;
97+
98+
/**
99+
* Наложение водяного знака поверх основного изображения.
100+
*/
101+
watermark?: {
102+
/** Путь к файлу водяного знака в хранилище. */
103+
image: string;
104+
/** Позиция по горизонтали или смещение в пикселях. */
105+
x?: number | 'center' | 'left' | 'right';
106+
/** Позиция по вертикали или смещение в пикселях. */
107+
y?: number | 'center' | 'top' | 'bottom';
108+
/** Прозрачность водяного знака (0 - прозрачный, 100 - непрозрачный). */
109+
alpha?: number;
110+
/** Относительная ширина знака в процентах (0.0 - 1.0) от основного изображения. */
111+
w_ratio?: number;
112+
/** Относительная высота знака в процентах (0.0 - 1.0). */
113+
h_ratio?: number;
114+
};
115+
116+
/**
117+
* Указывает точку фокуса для кропа.
118+
* Полезно, если вы знаете координаты лица или важного объекта.
119+
*/
120+
focal?: { x: number; y: number };
121+
122+
/**
123+
* Скругление углов изображения.
124+
* @property {number} radius Радиус скругления в пикселях.
125+
* @property {string} color Цвет заливки углов (например, 'transparent' или 'ffffff').
126+
*/
127+
round_corner?: {
128+
radius: number;
129+
color?: string;
130+
};
131+
132+
/**
133+
* Ограничивает размер файла (в байтах). Imagor будет снижать качество, пока не впишется в лимит.
134+
*/
135+
max_bytes?: number;
136+
137+
/**
138+
* Запрещает увеличивать изображение, если его исходные размеры меньше запрошенных (width/height).
139+
*/
140+
no_upscale?: boolean;
141+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const FORMATS = {
2+
JPEG: 'jpeg',
3+
PNG: 'png',
4+
WEBP: 'webp',
5+
AVIF: 'avif',
6+
JP2: 'jp2',
7+
GIF: 'gif',
8+
} as const;
9+
10+
export type Format = (typeof FORMATS)[keyof typeof FORMATS];
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type * from './module.interface';
2+
export type * from './filters.interface';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Filters } from './filters.interface';
2+
3+
/**
4+
* Опции конфигурации модуля Imagor
5+
*/
6+
export interface ImagorModuleOptions {
7+
/** Базовый URL вашего инстанса Imagor (например, https://imagor.example.com) */
8+
url: string;
9+
10+
/** Секретный ключ для генерации HMAC подписи (безопасные URL) */
11+
secret?: string;
12+
13+
/** Глобальные фильтры, которые будут применяться ко всем изображениям по умолчанию */
14+
filters?: Filters;
15+
16+
/** Базовый путь в S3/хранилище (например, 'products/') */
17+
storageRoot?: string;
18+
19+
/**
20+
* Именованные пресеты для часто используемых трансформаций.
21+
* @example { 'thumb': { width: 150, height: 150, smart: true } }
22+
*/
23+
presets?: Record<string, Filters>;
24+
25+
/** Включает логирование процесса генерации URL для отладки */
26+
debug?: boolean;
27+
}

0 commit comments

Comments
 (0)