move image converting to child_process
This commit is contained in:
parent
342e52601e
commit
377e6d7709
|
@ -5,5 +5,8 @@
|
||||||
"generateOptions": {
|
"generateOptions": {
|
||||||
"spec": false
|
"spec": false
|
||||||
},
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "tsconfig.build.json"
|
||||||
|
},
|
||||||
"exec": "pog"
|
"exec": "pog"
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,15 +11,18 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prebuild": "rimraf dist",
|
"prebuild": "rimraf dist",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start": "nest start --exec \"node --experimental-specifier-resolution=node\"",
|
"start": "nest start --exec \"node --es-module-specifier-resolution=node\"",
|
||||||
"start:dev": "yarn clean && nest start --watch --exec \"node --experimental-specifier-resolution=node\"",
|
"start:dev": "yarn clean && nest start --watch --exec \"node --es-module-specifier-resolution=node\"",
|
||||||
"start:debug": "nest start --debug --watch --exec \"node --experimental-specifier-resolution=node\"",
|
"start:debug": "nest start --debug --watch --exec \"node --es-module-specifier-resolution=node\"",
|
||||||
"start:prod": "node --experimental-specifier-resolution=node dist/main",
|
"start:prod": "node --es-module-specifier-resolution=node dist/main",
|
||||||
"format": "prettier --write \"src/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/helmet": "^8.0.0",
|
||||||
|
"@fastify/multipart": "^6.0.0",
|
||||||
|
"@fastify/static": "^5.0.0",
|
||||||
"@nestjs/common": "^8.4.4",
|
"@nestjs/common": "^8.4.4",
|
||||||
"@nestjs/config": "^2.0.0",
|
"@nestjs/config": "^2.0.0",
|
||||||
"@nestjs/core": "^8.4.4",
|
"@nestjs/core": "^8.4.4",
|
||||||
|
@ -31,22 +34,22 @@
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
"bmp-img": "^1.1.0",
|
"bmp-img": "^1.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"fastify-helmet": "^7.0.1",
|
"fastify-static": "^4.7.0",
|
||||||
"fastify-multipart": "^5.3.1",
|
|
||||||
"fastify-static": "^4.6.1",
|
|
||||||
"file-type": "^17.1.1",
|
"file-type": "^17.1.1",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
|
"p-timeout": "^5.0.2",
|
||||||
"passport": "^0.5.2",
|
"passport": "^0.5.2",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"passport-strategy": "^1.0.0",
|
"passport-strategy": "^1.0.0",
|
||||||
"pg": "^8.7.3",
|
"pg": "^8.7.3",
|
||||||
"picsur-shared": "*",
|
"picsur-shared": "*",
|
||||||
"qoi-img": "^1.0.1",
|
"posix": "^4.2.0",
|
||||||
|
"qoi-img": "^1.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rxjs": "^7.5.5",
|
"rxjs": "^7.5.5",
|
||||||
"sharp": "^0.30.3",
|
"sharp": "^0.30.4",
|
||||||
"typeorm": "0.3.6",
|
"typeorm": "0.3.6",
|
||||||
"zod": "^3.14.4"
|
"zod": "^3.14.4"
|
||||||
},
|
},
|
||||||
|
@ -58,23 +61,23 @@
|
||||||
"@types/cors": "^2.8.12",
|
"@types/cors": "^2.8.12",
|
||||||
"@types/ms": "^0.7.31",
|
"@types/ms": "^0.7.31",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^17.0.24",
|
"@types/node": "^17.0.30",
|
||||||
"@types/passport-jwt": "^3.0.6",
|
"@types/passport-jwt": "^3.0.6",
|
||||||
"@types/passport-local": "^1.0.34",
|
"@types/passport-local": "^1.0.34",
|
||||||
"@types/passport-strategy": "^0.2.35",
|
"@types/passport-strategy": "^0.2.35",
|
||||||
"@types/sharp": "^0.30.2",
|
"@types/sharp": "^0.30.2",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.19.0",
|
"@typescript-eslint/eslint-plugin": "^5.21.0",
|
||||||
"@typescript-eslint/parser": "^5.19.0",
|
"@typescript-eslint/parser": "^5.21.0",
|
||||||
"eslint": "^8.13.0",
|
"eslint": "^8.14.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^2.6.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-loader": "^9.2.8",
|
"ts-loader": "^9.3.0",
|
||||||
"ts-node": "^10.7.0",
|
"ts-node": "^10.7.0",
|
||||||
"tsconfig-paths": "^3.14.1",
|
"tsconfig-paths": "^3.14.1",
|
||||||
"typescript": "4.6.3",
|
"typescript": "4.6.4",
|
||||||
"webpack": "^5.72.0"
|
"webpack": "^5.72.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,5 +38,7 @@ export class PreferenceDefaultsService {
|
||||||
this.jwtConfigService.getJwtExpiresIn() ?? '7d',
|
this.jwtConfigService.getJwtExpiresIn() ?? '7d',
|
||||||
[SysPreference.BCryptStrength]: () => 12,
|
[SysPreference.BCryptStrength]: () => 12,
|
||||||
[SysPreference.RemoveDerivativesAfter]: () => '7d',
|
[SysPreference.RemoveDerivativesAfter]: () => '7d',
|
||||||
|
[SysPreference.SaveDerivatives]: () => true,
|
||||||
|
[SysPreference.AllowEditing]: () => true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { MultipartFields, MultipartFile } from '@fastify/multipart';
|
||||||
import {
|
import {
|
||||||
ArgumentMetadata,
|
ArgumentMetadata,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
@ -8,7 +9,6 @@ import {
|
||||||
Scope
|
Scope
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
import { MultipartFields, MultipartFile } from 'fastify-multipart';
|
|
||||||
import { HasFailed } from 'picsur-shared/dist/types';
|
import { HasFailed } from 'picsur-shared/dist/types';
|
||||||
import { ZodDtoStatic } from 'picsur-shared/dist/util/create-zod-dto';
|
import { ZodDtoStatic } from 'picsur-shared/dist/util/create-zod-dto';
|
||||||
import { MultipartConfigService } from '../../config/early/multipart.config.service';
|
import { MultipartConfigService } from '../../config/early/multipart.config.service';
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Multipart } from '@fastify/multipart';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
@ -6,7 +7,6 @@ import {
|
||||||
Scope
|
Scope
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
import { Multipart } from 'fastify-multipart';
|
|
||||||
import { MultipartConfigService } from '../../config/early/multipart.config.service';
|
import { MultipartConfigService } from '../../config/early/multipart.config.service';
|
||||||
|
|
||||||
@Injectable({ scope: Scope.REQUEST })
|
@Injectable({ scope: Scope.REQUEST })
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
|
import fastifyHelmet from '@fastify/helmet';
|
||||||
|
import * as multipart from '@fastify/multipart';
|
||||||
import { NestFactory, Reflector } from '@nestjs/core';
|
import { NestFactory, Reflector } from '@nestjs/core';
|
||||||
import {
|
import {
|
||||||
FastifyAdapter,
|
FastifyAdapter,
|
||||||
NestFastifyApplication
|
NestFastifyApplication
|
||||||
} from '@nestjs/platform-fastify';
|
} from '@nestjs/platform-fastify';
|
||||||
import fastifyHelmet from 'fastify-helmet';
|
|
||||||
import * as multipart from 'fastify-multipart';
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { UsersService } from './collections/user-db/user-db.service';
|
import { UsersService } from './collections/user-db/user-db.service';
|
||||||
import { HostConfigService } from './config/early/host.config.service';
|
import { HostConfigService } from './config/early/host.config.service';
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { BMPencode } from 'bmp-img';
|
import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto';
|
||||||
import {
|
import {
|
||||||
FullMime,
|
FullMime, SupportedMimeCategory
|
||||||
ImageMime,
|
|
||||||
SupportedMimeCategory
|
|
||||||
} from 'picsur-shared/dist/dto/mimes.dto';
|
} from 'picsur-shared/dist/dto/mimes.dto';
|
||||||
import { AsyncFailable, Fail } from 'picsur-shared/dist/types';
|
import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types';
|
||||||
import { QOIencode } from 'qoi-img';
|
import { SharpWrapper } from '../../workers/sharp.wrapper';
|
||||||
import { Sharp } from 'sharp';
|
|
||||||
import { ImageResult } from './imageresult';
|
import { ImageResult } from './imageresult';
|
||||||
import { UniversalSharp } from './universal-sharp';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImageConverterService {
|
export class ImageConverterService {
|
||||||
|
@ -17,6 +13,7 @@ export class ImageConverterService {
|
||||||
image: Buffer,
|
image: Buffer,
|
||||||
sourcemime: FullMime,
|
sourcemime: FullMime,
|
||||||
targetmime: FullMime,
|
targetmime: FullMime,
|
||||||
|
options: ImageRequestParams,
|
||||||
): AsyncFailable<ImageResult> {
|
): AsyncFailable<ImageResult> {
|
||||||
if (sourcemime.type !== targetmime.type) {
|
if (sourcemime.type !== targetmime.type) {
|
||||||
return Fail("Can't convert from animated to still or vice versa");
|
return Fail("Can't convert from animated to still or vice versa");
|
||||||
|
@ -30,9 +27,9 @@ export class ImageConverterService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetmime.type === SupportedMimeCategory.Image) {
|
if (targetmime.type === SupportedMimeCategory.Image) {
|
||||||
return this.convertStill(image, sourcemime, targetmime);
|
return this.convertStill(image, sourcemime, targetmime, options);
|
||||||
} else if (targetmime.type === SupportedMimeCategory.Animation) {
|
} else if (targetmime.type === SupportedMimeCategory.Animation) {
|
||||||
return this.convertAnimation(image, targetmime);
|
return this.convertAnimation(image, targetmime, options);
|
||||||
} else {
|
} else {
|
||||||
return Fail('Unsupported mime type');
|
return Fail('Unsupported mime type');
|
||||||
}
|
}
|
||||||
|
@ -42,43 +39,58 @@ export class ImageConverterService {
|
||||||
image: Buffer,
|
image: Buffer,
|
||||||
sourcemime: FullMime,
|
sourcemime: FullMime,
|
||||||
targetmime: FullMime,
|
targetmime: FullMime,
|
||||||
|
options: ImageRequestParams,
|
||||||
): AsyncFailable<ImageResult> {
|
): AsyncFailable<ImageResult> {
|
||||||
const sharpImage = UniversalSharp(image, sourcemime);
|
const sharpWrapper = new SharpWrapper();
|
||||||
|
|
||||||
|
const hasStarted = await sharpWrapper.start(image, sourcemime);
|
||||||
|
if (HasFailed(hasStarted)) return hasStarted;
|
||||||
|
|
||||||
// Do modifications
|
// Do modifications
|
||||||
|
if (options.height || options.width) {
|
||||||
|
if (options.height && options.width) {
|
||||||
|
sharpWrapper.operation('resize', {
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
fit: 'fill',
|
||||||
|
kernel: 'cubic',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sharpWrapper.operation('resize', {
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
fit: 'contain',
|
||||||
|
kernel: 'cubic',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.rotate) {
|
||||||
|
sharpWrapper.operation('rotate', options.rotate, {
|
||||||
|
background: 'transparent',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.flipx) {
|
||||||
|
sharpWrapper.operation('flop');
|
||||||
|
}
|
||||||
|
if (options.flipy) {
|
||||||
|
sharpWrapper.operation('flip');
|
||||||
|
}
|
||||||
|
if (options.noalpha) {
|
||||||
|
sharpWrapper.operation('removeAlpha');
|
||||||
|
}
|
||||||
|
if (options.negative) {
|
||||||
|
sharpWrapper.operation('negate');
|
||||||
|
}
|
||||||
|
if (options.greyscale) {
|
||||||
|
sharpWrapper.operation('greyscale');
|
||||||
|
}
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
let result: Buffer;
|
const result = await sharpWrapper.finish(targetmime, options);
|
||||||
|
if (HasFailed(result)) return result;
|
||||||
try {
|
|
||||||
switch (targetmime.mime) {
|
|
||||||
case ImageMime.PNG:
|
|
||||||
result = await sharpImage.png().toBuffer();
|
|
||||||
break;
|
|
||||||
case ImageMime.JPEG:
|
|
||||||
result = await sharpImage.jpeg().toBuffer();
|
|
||||||
break;
|
|
||||||
case ImageMime.TIFF:
|
|
||||||
result = await sharpImage.tiff().toBuffer();
|
|
||||||
break;
|
|
||||||
case ImageMime.WEBP:
|
|
||||||
result = await sharpImage.webp().toBuffer();
|
|
||||||
break;
|
|
||||||
case ImageMime.BMP:
|
|
||||||
result = await this.sharpToBMP(sharpImage);
|
|
||||||
break;
|
|
||||||
case ImageMime.QOI:
|
|
||||||
result = await this.sharpToQOI(sharpImage);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error('Unsupported mime type');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return Fail(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
image: result,
|
image: result.data,
|
||||||
mime: targetmime.mime,
|
mime: targetmime.mime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -86,6 +98,7 @@ export class ImageConverterService {
|
||||||
private async convertAnimation(
|
private async convertAnimation(
|
||||||
image: Buffer,
|
image: Buffer,
|
||||||
targetmime: FullMime,
|
targetmime: FullMime,
|
||||||
|
options: ImageRequestParams,
|
||||||
): AsyncFailable<ImageResult> {
|
): AsyncFailable<ImageResult> {
|
||||||
// Apng and gif are stored as is for now
|
// Apng and gif are stored as is for now
|
||||||
return {
|
return {
|
||||||
|
@ -93,38 +106,4 @@ export class ImageConverterService {
|
||||||
mime: targetmime.mime,
|
mime: targetmime.mime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sharpToBMP(sharpImage: Sharp): Promise<Buffer> {
|
|
||||||
const dimensions = await sharpImage.metadata();
|
|
||||||
if (!dimensions.width || !dimensions.height || !dimensions.channels) {
|
|
||||||
throw new Error('Invalid image');
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = await sharpImage.raw().toBuffer();
|
|
||||||
|
|
||||||
const encoded = BMPencode(raw, {
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
channels: dimensions.channels,
|
|
||||||
});
|
|
||||||
|
|
||||||
return encoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sharpToQOI(sharpImage: Sharp): Promise<Buffer> {
|
|
||||||
const dimensions = await sharpImage.metadata();
|
|
||||||
if (!dimensions.width || !dimensions.height || !dimensions.channels) {
|
|
||||||
throw new Error('Invalid image');
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = await sharpImage.raw().toBuffer();
|
|
||||||
|
|
||||||
const encoded = QOIencode(raw, {
|
|
||||||
height: dimensions.height,
|
|
||||||
width: dimensions.width,
|
|
||||||
channels: dimensions.channels,
|
|
||||||
});
|
|
||||||
|
|
||||||
return encoded;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,26 +35,23 @@ export class ImageProcessorService {
|
||||||
|
|
||||||
sharpImage = sharpImage.toColorspace('srgb');
|
sharpImage = sharpImage.toColorspace('srgb');
|
||||||
|
|
||||||
const metadata = await sharpImage.metadata();
|
const processedImage = await sharpImage.raw().toBuffer({
|
||||||
const pixels = await sharpImage.raw().toBuffer();
|
resolveWithObject: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
metadata.hasAlpha === undefined ||
|
processedImage.info.width >= 32768 ||
|
||||||
metadata.width === undefined ||
|
processedImage.info.height >= 32768
|
||||||
metadata.height === undefined
|
) {
|
||||||
)
|
|
||||||
return Fail('Invalid image');
|
|
||||||
|
|
||||||
if (metadata.width >= 32768 || metadata.height >= 32768) {
|
|
||||||
return Fail('Image too large');
|
return Fail('Image too large');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Png can be more efficient than QOI, but its just sooooooo slow
|
// Png can be more efficient than QOI, but its just sooooooo slow
|
||||||
const qoiImage = QOIencode(pixels, {
|
const qoiImage = QOIencode(processedImage.data, {
|
||||||
channels: metadata.hasAlpha ? 4 : 3,
|
channels: processedImage.info.channels,
|
||||||
colorspace: QOIColorSpace.SRGB,
|
colorspace: QOIColorSpace.SRGB,
|
||||||
height: metadata.height,
|
height: processedImage.info.height,
|
||||||
width: metadata.width,
|
width: processedImage.info.width,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import Crypto from 'crypto';
|
import Crypto from 'crypto';
|
||||||
import { fileTypeFromBuffer, FileTypeResult } from 'file-type';
|
import { fileTypeFromBuffer, FileTypeResult } from 'file-type';
|
||||||
|
import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto';
|
||||||
import { ImageFileType } from 'picsur-shared/dist/dto/image-file-types.dto';
|
import { ImageFileType } from 'picsur-shared/dist/dto/image-file-types.dto';
|
||||||
import { FullMime } from 'picsur-shared/dist/dto/mimes.dto';
|
import { FullMime } from 'picsur-shared/dist/dto/mimes.dto';
|
||||||
|
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.dto';
|
||||||
import { UsrPreference } from 'picsur-shared/dist/dto/usr-preferences.dto';
|
import { UsrPreference } from 'picsur-shared/dist/dto/usr-preferences.dto';
|
||||||
import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types';
|
import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types';
|
||||||
import { ParseMime } from 'picsur-shared/dist/util/parse-mime';
|
import { ParseMime } from 'picsur-shared/dist/util/parse-mime';
|
||||||
import { IsQOI } from 'qoi-img';
|
import { IsQOI } from 'qoi-img';
|
||||||
import { ImageDBService } from '../../collections/image-db/image-db.service';
|
import { ImageDBService } from '../../collections/image-db/image-db.service';
|
||||||
import { ImageFileDBService } from '../../collections/image-db/image-file-db.service';
|
import { ImageFileDBService } from '../../collections/image-db/image-file-db.service';
|
||||||
|
import { SysPreferenceService } from '../../collections/preference-db/sys-preference-db.service';
|
||||||
import { UsrPreferenceService } from '../../collections/preference-db/usr-preference-db.service';
|
import { UsrPreferenceService } from '../../collections/preference-db/usr-preference-db.service';
|
||||||
import { EImageDerivativeBackend } from '../../models/entities/image-derivative.entity';
|
import { EImageDerivativeBackend } from '../../models/entities/image-derivative.entity';
|
||||||
import { EImageFileBackend } from '../../models/entities/image-file.entity';
|
import { EImageFileBackend } from '../../models/entities/image-file.entity';
|
||||||
|
@ -27,6 +30,7 @@ export class ImageManagerService {
|
||||||
private readonly processService: ImageProcessorService,
|
private readonly processService: ImageProcessorService,
|
||||||
private readonly convertService: ImageConverterService,
|
private readonly convertService: ImageConverterService,
|
||||||
private readonly userPref: UsrPreferenceService,
|
private readonly userPref: UsrPreferenceService,
|
||||||
|
private readonly sysPref: SysPreferenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async retrieveInfo(id: string): AsyncFailable<EImageBackend> {
|
public async retrieveInfo(id: string): AsyncFailable<EImageBackend> {
|
||||||
|
@ -84,18 +88,28 @@ export class ImageManagerService {
|
||||||
|
|
||||||
public async getConverted(
|
public async getConverted(
|
||||||
imageId: string,
|
imageId: string,
|
||||||
options: {
|
mime: string,
|
||||||
mime: string;
|
options: ImageRequestParams,
|
||||||
},
|
|
||||||
): AsyncFailable<EImageDerivativeBackend> {
|
): AsyncFailable<EImageDerivativeBackend> {
|
||||||
const targetMime = ParseMime(options.mime);
|
const targetMime = ParseMime(mime);
|
||||||
if (HasFailed(targetMime)) return targetMime;
|
if (HasFailed(targetMime)) return targetMime;
|
||||||
|
|
||||||
const converted_key = this.getConvertHash(options);
|
const converted_key = this.getConvertHash({ mime, ...options });
|
||||||
|
|
||||||
|
const [save_derivatives, allow_editing] = await Promise.all([
|
||||||
|
this.sysPref.getBooleanPreference(SysPreference.SaveDerivatives),
|
||||||
|
this.sysPref.getBooleanPreference(SysPreference.AllowEditing),
|
||||||
|
]);
|
||||||
|
if (HasFailed(save_derivatives)) return save_derivatives;
|
||||||
|
if (HasFailed(allow_editing)) return allow_editing;
|
||||||
|
|
||||||
return MutexFallBack(
|
return MutexFallBack(
|
||||||
converted_key,
|
converted_key,
|
||||||
() => this.imageFilesService.getDerivative(imageId, converted_key),
|
() => {
|
||||||
|
if (save_derivatives)
|
||||||
|
return this.imageFilesService.getDerivative(imageId, converted_key);
|
||||||
|
else return Promise.resolve(null);
|
||||||
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const masterImage = await this.getMaster(imageId);
|
const masterImage = await this.getMaster(imageId);
|
||||||
if (HasFailed(masterImage)) return masterImage;
|
if (HasFailed(masterImage)) return masterImage;
|
||||||
|
@ -108,6 +122,7 @@ export class ImageManagerService {
|
||||||
masterImage.data,
|
masterImage.data,
|
||||||
sourceMime,
|
sourceMime,
|
||||||
targetMime,
|
targetMime,
|
||||||
|
allow_editing ? options : {},
|
||||||
);
|
);
|
||||||
if (HasFailed(convertResult)) return convertResult;
|
if (HasFailed(convertResult)) return convertResult;
|
||||||
|
|
||||||
|
@ -117,12 +132,21 @@ export class ImageManagerService {
|
||||||
} in ${Date.now() - startTime}ms`,
|
} in ${Date.now() - startTime}ms`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (save_derivatives) {
|
||||||
return await this.imageFilesService.addDerivative(
|
return await this.imageFilesService.addDerivative(
|
||||||
imageId,
|
imageId,
|
||||||
converted_key,
|
converted_key,
|
||||||
convertResult.mime,
|
convertResult.mime,
|
||||||
convertResult.image,
|
convertResult.image,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
const derivative = new EImageDerivativeBackend();
|
||||||
|
derivative.mime = convertResult.mime;
|
||||||
|
derivative.data = convertResult.image;
|
||||||
|
derivative.image_id = imageId;
|
||||||
|
derivative.key = converted_key;
|
||||||
|
return derivative;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,4 +12,6 @@ export const SysPreferenceValueTypes: {
|
||||||
[SysPreference.JwtExpiresIn]: 'string',
|
[SysPreference.JwtExpiresIn]: 'string',
|
||||||
[SysPreference.BCryptStrength]: 'number',
|
[SysPreference.BCryptStrength]: 'number',
|
||||||
[SysPreference.RemoveDerivativesAfter]: 'string',
|
[SysPreference.RemoveDerivativesAfter]: 'string',
|
||||||
|
[SysPreference.SaveDerivatives]: 'boolean',
|
||||||
|
[SysPreference.AllowEditing]: 'boolean',
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { MultipartFile } from 'fastify-multipart';
|
import { MultipartFile } from '@fastify/multipart';
|
||||||
import { AsyncFailable, Fail } from 'picsur-shared/dist/types';
|
import { AsyncFailable, Fail } from 'picsur-shared/dist/types';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|
|
@ -6,11 +6,13 @@ import {
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Res
|
Res
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FastifyReply } from 'fastify';
|
import { FastifyReply } from 'fastify';
|
||||||
import {
|
import {
|
||||||
ImageMetaResponse,
|
ImageMetaResponse,
|
||||||
|
ImageRequestParams,
|
||||||
ImageUploadResponse
|
ImageUploadResponse
|
||||||
} from 'picsur-shared/dist/dto/api/image.dto';
|
} from 'picsur-shared/dist/dto/api/image.dto';
|
||||||
import { HasFailed } from 'picsur-shared/dist/types';
|
import { HasFailed } from 'picsur-shared/dist/types';
|
||||||
|
@ -39,6 +41,7 @@ export class ImageController {
|
||||||
// But we need it here to set the mime type
|
// But we need it here to set the mime type
|
||||||
@Res({ passthrough: true }) res: FastifyReply,
|
@Res({ passthrough: true }) res: FastifyReply,
|
||||||
@ImageFullIdParam() fullid: ImageFullId,
|
@ImageFullIdParam() fullid: ImageFullId,
|
||||||
|
@Query() params: ImageRequestParams,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
if (fullid.type === 'original') {
|
if (fullid.type === 'original') {
|
||||||
const image = await this.imagesService.getOriginal(fullid.id);
|
const image = await this.imagesService.getOriginal(fullid.id);
|
||||||
|
@ -51,12 +54,14 @@ export class ImageController {
|
||||||
return image.data;
|
return image.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = await this.imagesService.getConverted(fullid.id, {
|
const image = await this.imagesService.getConverted(
|
||||||
mime: fullid.mime,
|
fullid.id,
|
||||||
});
|
fullid.mime,
|
||||||
|
params,
|
||||||
|
);
|
||||||
if (HasFailed(image)) {
|
if (HasFailed(image)) {
|
||||||
this.logger.warn(image.getReason());
|
this.logger.warn(image.getReason());
|
||||||
throw new NotFoundException('Could not find image');
|
throw new NotFoundException('Failed to get image');
|
||||||
}
|
}
|
||||||
|
|
||||||
res.type(image.mime);
|
res.type(image.mime);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { FastifyHelmetOptions } from 'fastify-helmet';
|
import { FastifyHelmetOptions } from '@fastify/helmet';
|
||||||
|
|
||||||
export const HelmetOptions: FastifyHelmetOptions = {
|
export const HelmetOptions: FastifyHelmetOptions = {
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
|
|
191
backend/src/workers/sharp.wrapper.ts
Normal file
191
backend/src/workers/sharp.wrapper.ts
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { ChildProcess, fork } from 'child_process';
|
||||||
|
import pTimeout from 'p-timeout';
|
||||||
|
import path from 'path';
|
||||||
|
import { FullMime } from 'picsur-shared/dist/dto/mimes.dto';
|
||||||
|
import {
|
||||||
|
AsyncFailable,
|
||||||
|
Fail,
|
||||||
|
Failable,
|
||||||
|
HasFailed
|
||||||
|
} from 'picsur-shared/dist/types';
|
||||||
|
import { Sharp } from 'sharp';
|
||||||
|
import {
|
||||||
|
SharpWorkerFinishOptions,
|
||||||
|
SharpWorkerOperation,
|
||||||
|
SharpWorkerRecieveMessage,
|
||||||
|
SharpWorkerResultMessage,
|
||||||
|
SharpWorkerSendMessage,
|
||||||
|
SupportedSharpWorkerFunctions
|
||||||
|
} from './sharp/sharp.message';
|
||||||
|
import { SharpResult } from './sharp/universal-sharp';
|
||||||
|
|
||||||
|
const moduleURL = new URL(import.meta.url);
|
||||||
|
const __dirname = path.dirname(moduleURL.pathname);
|
||||||
|
|
||||||
|
export class SharpWrapper {
|
||||||
|
private readonly workerID: number = Math.floor(Math.random() * 100000);
|
||||||
|
private readonly logger: Logger = new Logger('SharpWrapper' + this.workerID);
|
||||||
|
|
||||||
|
private static readonly PROMISE_TIMEOUT = 10000;
|
||||||
|
private static readonly INSTANCE_TIMEOUT = 10000;
|
||||||
|
private static readonly MEMORY_LIMIT = 512;
|
||||||
|
private static readonly WORKER_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
'./sharp',
|
||||||
|
'sharp.worker.js',
|
||||||
|
);
|
||||||
|
|
||||||
|
private worker: ChildProcess | null = null;
|
||||||
|
|
||||||
|
public async start(image: Buffer, mime: FullMime): AsyncFailable<true> {
|
||||||
|
this.worker = fork(SharpWrapper.WORKER_PATH, {
|
||||||
|
serialization: 'advanced',
|
||||||
|
timeout: SharpWrapper.INSTANCE_TIMEOUT,
|
||||||
|
env: {
|
||||||
|
MEMORY_LIMIT_MB: SharpWrapper.MEMORY_LIMIT.toString(),
|
||||||
|
},
|
||||||
|
stdio: 'overlapped',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker.stdout?.pipe(process.stdout);
|
||||||
|
this.worker.stderr?.pipe(process.stderr);
|
||||||
|
|
||||||
|
this.worker.on('error', (error) => {
|
||||||
|
this.logger.error(`Worker ${this.workerID} error: ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker.on('close', (code, signal) => {
|
||||||
|
this.logger.verbose(
|
||||||
|
`Worker ${this.workerID} exited with code ${code} and signal ${signal}`,
|
||||||
|
);
|
||||||
|
this.purge();
|
||||||
|
});
|
||||||
|
|
||||||
|
const isReady = await this.waitReady();
|
||||||
|
if (HasFailed(isReady)) {
|
||||||
|
this.purge();
|
||||||
|
return isReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSent = this.sendToWorker({
|
||||||
|
type: 'init',
|
||||||
|
image,
|
||||||
|
mime,
|
||||||
|
});
|
||||||
|
if (HasFailed(hasSent)) {
|
||||||
|
this.purge();
|
||||||
|
return hasSent;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(`Worker ${this.workerID} initialized`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public operation<Operation extends SupportedSharpWorkerFunctions>(
|
||||||
|
operation: Operation,
|
||||||
|
...parameters: Parameters<Sharp[Operation]>
|
||||||
|
): Failable<true> {
|
||||||
|
if (!this.worker) {
|
||||||
|
return Fail('Worker is not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSent = this.sendToWorker({
|
||||||
|
type: 'operation',
|
||||||
|
operation: {
|
||||||
|
name: operation,
|
||||||
|
parameters,
|
||||||
|
} as SharpWorkerOperation,
|
||||||
|
});
|
||||||
|
if (HasFailed(hasSent)) {
|
||||||
|
this.purge();
|
||||||
|
return hasSent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async finish(
|
||||||
|
targetMime: FullMime,
|
||||||
|
options?: SharpWorkerFinishOptions,
|
||||||
|
): AsyncFailable<SharpResult> {
|
||||||
|
if (!this.worker) {
|
||||||
|
return Fail('Worker is not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSent = this.sendToWorker({
|
||||||
|
type: 'finish',
|
||||||
|
mime: targetMime,
|
||||||
|
options: options ?? {},
|
||||||
|
});
|
||||||
|
if (HasFailed(hasSent)) {
|
||||||
|
this.purge();
|
||||||
|
return hasSent;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const finishPromise = new Promise<SharpWorkerResultMessage>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
if (!this.worker) return reject('Worker is not initialized');
|
||||||
|
|
||||||
|
this.worker.once('message', (message: SharpWorkerRecieveMessage) => {
|
||||||
|
if (message.type === 'result') {
|
||||||
|
resolve(message);
|
||||||
|
} else reject('Unknown message type');
|
||||||
|
});
|
||||||
|
this.worker.once('close', () => reject('Worker closed'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await pTimeout(
|
||||||
|
finishPromise,
|
||||||
|
SharpWrapper.PROMISE_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.verbose(
|
||||||
|
`Worker ${this.workerID} finished in ${result.processingTime}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.purge();
|
||||||
|
|
||||||
|
return result.result;
|
||||||
|
} catch (error) {
|
||||||
|
this.purge();
|
||||||
|
return Fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitReady(): AsyncFailable<true> {
|
||||||
|
try {
|
||||||
|
const waitReadyPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
if (!this.worker) return reject('Worker is not initialized');
|
||||||
|
|
||||||
|
this.worker.once('message', (message: SharpWorkerRecieveMessage) => {
|
||||||
|
if (message.type === 'ready') resolve();
|
||||||
|
else reject('Unknown message type');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await pTimeout(waitReadyPromise, SharpWrapper.PROMISE_TIMEOUT);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return Fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendToWorker(message: SharpWorkerSendMessage): Failable<true> {
|
||||||
|
if (!this.worker) {
|
||||||
|
return Fail('Worker is not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.worker.send(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private purge() {
|
||||||
|
this.worker?.kill();
|
||||||
|
this.worker?.removeAllListeners();
|
||||||
|
this.worker = null;
|
||||||
|
}
|
||||||
|
}
|
68
backend/src/workers/sharp/sharp.message.ts
Normal file
68
backend/src/workers/sharp/sharp.message.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { FullMime } from 'picsur-shared/dist/dto/mimes.dto';
|
||||||
|
import { Sharp } from 'sharp';
|
||||||
|
import { SharpResult } from './universal-sharp';
|
||||||
|
|
||||||
|
type MapSharpFunctions<T extends keyof Sharp> = T extends any
|
||||||
|
? Sharp[T] extends (...args: any) => any
|
||||||
|
? {
|
||||||
|
name: T;
|
||||||
|
parameters: Parameters<Sharp[T]>;
|
||||||
|
}
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type SupportedSharpWorkerFunctions =
|
||||||
|
| 'toColorspace'
|
||||||
|
| 'resize'
|
||||||
|
| 'rotate'
|
||||||
|
| 'flip'
|
||||||
|
| 'flop'
|
||||||
|
| 'removeAlpha'
|
||||||
|
| 'negate'
|
||||||
|
| 'greyscale';
|
||||||
|
|
||||||
|
export type SharpWorkerOperation = MapSharpFunctions<SupportedSharpWorkerFunctions>;
|
||||||
|
|
||||||
|
export interface SharpWorkerFinishOptions {
|
||||||
|
quality?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
|
||||||
|
export interface SharpWorkerInitMessage {
|
||||||
|
type: 'init';
|
||||||
|
image: Buffer;
|
||||||
|
mime: FullMime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharpWorkerOperationMessage {
|
||||||
|
type: 'operation';
|
||||||
|
operation: SharpWorkerOperation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharpWorkerFinishMessage {
|
||||||
|
type: 'finish';
|
||||||
|
mime: FullMime;
|
||||||
|
options: SharpWorkerFinishOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharpWorkerReadyMessage {
|
||||||
|
type: 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharpWorkerResultMessage {
|
||||||
|
type: 'result';
|
||||||
|
processingTime: number;
|
||||||
|
result: SharpResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulators
|
||||||
|
|
||||||
|
export type SharpWorkerSendMessage =
|
||||||
|
| SharpWorkerInitMessage
|
||||||
|
| SharpWorkerOperationMessage
|
||||||
|
| SharpWorkerFinishMessage;
|
||||||
|
|
||||||
|
export type SharpWorkerRecieveMessage =
|
||||||
|
| SharpWorkerResultMessage
|
||||||
|
| SharpWorkerReadyMessage;
|
120
backend/src/workers/sharp/sharp.worker.ts
Normal file
120
backend/src/workers/sharp/sharp.worker.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import { FullMime } from 'picsur-shared/dist/dto/mimes.dto';
|
||||||
|
// @ts-ignore
|
||||||
|
import posix from 'posix';
|
||||||
|
import { Sharp } from 'sharp';
|
||||||
|
import {
|
||||||
|
SharpWorkerFinishOptions,
|
||||||
|
SharpWorkerInitMessage,
|
||||||
|
SharpWorkerOperationMessage,
|
||||||
|
SharpWorkerRecieveMessage,
|
||||||
|
SharpWorkerSendMessage
|
||||||
|
} from './sharp.message';
|
||||||
|
import { UniversalSharpIn, UniversalSharpOut } from './universal-sharp';
|
||||||
|
|
||||||
|
export class SharpWorker {
|
||||||
|
private startTime: number = 0;
|
||||||
|
private sharpi: Sharp | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setup() {
|
||||||
|
if (process.send === undefined) {
|
||||||
|
return this.purge('This is not a worker process');
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryLimit = parseInt(process.env['MEMORY_LIMIT_MB'] ?? '');
|
||||||
|
|
||||||
|
if (isNaN(memoryLimit) || memoryLimit <= 0) {
|
||||||
|
return this.purge('MEMORY_LIMIT_MB environment variable is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
posix.setrlimit('data', {
|
||||||
|
soft: 1000 * 1000 * memoryLimit,
|
||||||
|
hard: 1000 * 1000 * memoryLimit,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('message', this.messageHandler.bind(this));
|
||||||
|
|
||||||
|
this.sendMessage({
|
||||||
|
type: 'ready',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private messageHandler(message: SharpWorkerSendMessage): void {
|
||||||
|
if (message.type === 'init') {
|
||||||
|
this.init(message);
|
||||||
|
} else if (message.type === 'operation') {
|
||||||
|
this.operation(message);
|
||||||
|
} else if (message.type === 'finish') {
|
||||||
|
this.finish(message.mime, message.options);
|
||||||
|
} else {
|
||||||
|
return this.purge('Unknown message type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(message: SharpWorkerInitMessage): void {
|
||||||
|
if (this.sharpi !== null) {
|
||||||
|
return this.purge('Already initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.sharpi = UniversalSharpIn(message.image, message.mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private operation(message: SharpWorkerOperationMessage): void {
|
||||||
|
if (this.sharpi === null) {
|
||||||
|
return this.purge('Not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = message.operation;
|
||||||
|
message.operation.parameters;
|
||||||
|
|
||||||
|
this.sharpi = (this.sharpi[operation.name] as any)(...operation.parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finish(
|
||||||
|
mime: FullMime,
|
||||||
|
options: SharpWorkerFinishOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.sharpi === null) {
|
||||||
|
return this.purge('Not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharpi = this.sharpi;
|
||||||
|
this.sharpi = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await UniversalSharpOut(sharpi, mime, options);
|
||||||
|
const processingTime = Date.now() - this.startTime;
|
||||||
|
|
||||||
|
this.sendMessage({
|
||||||
|
type: 'result',
|
||||||
|
processingTime,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return this.purge(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendMessage(message: SharpWorkerRecieveMessage): void {
|
||||||
|
if (process.send === undefined) {
|
||||||
|
return this.purge('This is not a worker process');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private purge(reason: any): void {
|
||||||
|
if (typeof reason === 'string') {
|
||||||
|
console.error(new Error(reason));
|
||||||
|
} else {
|
||||||
|
console.error(reason);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new SharpWorker();
|
159
backend/src/workers/sharp/universal-sharp.ts
Normal file
159
backend/src/workers/sharp/universal-sharp.ts
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
import { BMPdecode, BMPencode } from 'bmp-img';
|
||||||
|
import { FullMime, ImageMime } from 'picsur-shared/dist/dto/mimes.dto';
|
||||||
|
import { QOIdecode, QOIencode } from 'qoi-img';
|
||||||
|
import sharp, { Sharp, SharpOptions } from 'sharp';
|
||||||
|
|
||||||
|
export interface SharpResult {
|
||||||
|
data: Buffer;
|
||||||
|
info: sharp.OutputInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UniversalSharpIn(
|
||||||
|
image: Buffer,
|
||||||
|
mime: FullMime,
|
||||||
|
options?: SharpOptions,
|
||||||
|
): Sharp {
|
||||||
|
// if (mime.mime === ImageMime.ICO) {
|
||||||
|
// return icoSharpIn(image, options);
|
||||||
|
// } else
|
||||||
|
if (mime.mime === ImageMime.BMP) {
|
||||||
|
return bmpSharpIn(image, options);
|
||||||
|
} else if (mime.mime === ImageMime.QOI) {
|
||||||
|
return qoiSharpIn(image, options);
|
||||||
|
} else {
|
||||||
|
return sharp(image, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmpSharpIn(image: Buffer, options?: SharpOptions) {
|
||||||
|
const bitmap = BMPdecode(image);
|
||||||
|
return sharp(bitmap.pixels, {
|
||||||
|
...options,
|
||||||
|
raw: {
|
||||||
|
width: bitmap.width,
|
||||||
|
height: bitmap.height,
|
||||||
|
channels: bitmap.channels,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// function icoSharpIn(image: Buffer, options?: SharpOptions) {
|
||||||
|
// const result = decodeico(image);
|
||||||
|
// // Get biggest image
|
||||||
|
// const best = result.sort((a, b) => b.width - a.width)[0];
|
||||||
|
|
||||||
|
// return sharp(best.data, {
|
||||||
|
// ...options,
|
||||||
|
// raw: {
|
||||||
|
// width: best.width,
|
||||||
|
// height: best.height,
|
||||||
|
// channels: 4,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
function qoiSharpIn(image: Buffer, options?: SharpOptions) {
|
||||||
|
const result = QOIdecode(image);
|
||||||
|
|
||||||
|
return sharp(result.pixels, {
|
||||||
|
...options,
|
||||||
|
raw: {
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
channels: result.channels,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function UniversalSharpOut(
|
||||||
|
image: Sharp,
|
||||||
|
mime: FullMime,
|
||||||
|
options?: {
|
||||||
|
quality?: number;
|
||||||
|
},
|
||||||
|
): Promise<SharpResult> {
|
||||||
|
let result: SharpResult | undefined;
|
||||||
|
|
||||||
|
switch (mime.mime) {
|
||||||
|
case ImageMime.PNG:
|
||||||
|
result = await image
|
||||||
|
.png({ quality: options?.quality })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
break;
|
||||||
|
case ImageMime.JPEG:
|
||||||
|
result = await image
|
||||||
|
.jpeg({ quality: options?.quality })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
break;
|
||||||
|
case ImageMime.TIFF:
|
||||||
|
result = await image
|
||||||
|
.tiff({ quality: options?.quality })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
break;
|
||||||
|
case ImageMime.WEBP:
|
||||||
|
result = await image
|
||||||
|
.webp({ quality: options?.quality })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
break;
|
||||||
|
case ImageMime.BMP:
|
||||||
|
result = await bmpSharpOut(image);
|
||||||
|
break;
|
||||||
|
case ImageMime.QOI:
|
||||||
|
result = await qoiSharpOut(image);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported mime type');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmpSharpOut(sharpImage: Sharp): Promise<SharpResult> {
|
||||||
|
const raw = await sharpImage.raw().toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
if (raw.info.channels === 1) no1Channel(raw);
|
||||||
|
|
||||||
|
const encoded = BMPencode(raw.data, {
|
||||||
|
width: raw.info.width,
|
||||||
|
height: raw.info.height,
|
||||||
|
channels: raw.info.channels,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: encoded,
|
||||||
|
info: raw.info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function qoiSharpOut(sharpImage: Sharp): Promise<SharpResult> {
|
||||||
|
const raw = await sharpImage.raw().toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
if (raw.info.channels === 1) no1Channel(raw);
|
||||||
|
|
||||||
|
const encoded = QOIencode(raw.data, {
|
||||||
|
width: raw.info.width,
|
||||||
|
height: raw.info.height,
|
||||||
|
channels: raw.info.channels,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: encoded,
|
||||||
|
info: raw.info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function no1Channel(input: SharpResult): SharpResult {
|
||||||
|
const old = input.data;
|
||||||
|
input.data = Buffer.alloc(input.info.width * input.info.height * 3);
|
||||||
|
|
||||||
|
for (let i = 0; i < old.length; i++) {
|
||||||
|
input.data[i * 3] = old[i];
|
||||||
|
input.data[i * 3 + 1] = old[i];
|
||||||
|
input.data[i * 3 + 2] = old[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
input.info.channels = 3;
|
||||||
|
input.info.size = input.data.length;
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
"picsur-shared": "*",
|
"picsur-shared": "*",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "~7.5.5",
|
"rxjs": "~7.5.5",
|
||||||
"tslib": "^2.3.1",
|
"tslib": "^2.4.0",
|
||||||
"zod": "^3.14.4",
|
"zod": "^3.14.4",
|
||||||
"zone.js": "~0.11.5"
|
"zone.js": "~0.11.5"
|
||||||
},
|
},
|
||||||
|
@ -42,8 +42,8 @@
|
||||||
"@fontsource/material-icons": "^4.5.4",
|
"@fontsource/material-icons": "^4.5.4",
|
||||||
"@fontsource/material-icons-outlined": "^4.5.4",
|
"@fontsource/material-icons-outlined": "^4.5.4",
|
||||||
"@fontsource/roboto": "^4.5.5",
|
"@fontsource/roboto": "^4.5.5",
|
||||||
"@types/node": "^17.0.24",
|
"@types/node": "^17.0.30",
|
||||||
"@types/validator": "^13.7.2",
|
"@types/validator": "^13.7.2",
|
||||||
"typescript": "4.6.3"
|
"typescript": "4.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,4 +7,6 @@ export const SysPreferenceFriendlyNames: {
|
||||||
[SysPreference.JwtExpiresIn]: 'JWT Expiry Time',
|
[SysPreference.JwtExpiresIn]: 'JWT Expiry Time',
|
||||||
[SysPreference.BCryptStrength]: 'BCrypt Strength',
|
[SysPreference.BCryptStrength]: 'BCrypt Strength',
|
||||||
[SysPreference.RemoveDerivativesAfter]: 'Cached Images Expiry Time',
|
[SysPreference.RemoveDerivativesAfter]: 'Cached Images Expiry Time',
|
||||||
|
[SysPreference.SaveDerivatives]: 'Cache Trancoded Images',
|
||||||
|
[SysPreference.AllowEditing]: 'Allow images to be edited (e.g. resize)',
|
||||||
};
|
};
|
||||||
|
|
|
@ -69,9 +69,6 @@ export class ViewComponent implements OnInit {
|
||||||
this.masterMime = masterMime;
|
this.masterMime = masterMime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hasOriginal) {
|
|
||||||
this.setSelectedValue = 'original';
|
|
||||||
} else {
|
|
||||||
if (this.masterMime.type === SupportedMimeCategory.Image) {
|
if (this.masterMime.type === SupportedMimeCategory.Image) {
|
||||||
this.setSelectedValue = ImageMime.JPEG;
|
this.setSelectedValue = ImageMime.JPEG;
|
||||||
} else if (this.masterMime.type === SupportedMimeCategory.Animation) {
|
} else if (this.masterMime.type === SupportedMimeCategory.Animation) {
|
||||||
|
@ -79,7 +76,6 @@ export class ViewComponent implements OnInit {
|
||||||
} else {
|
} else {
|
||||||
this.setSelectedValue = metadata.fileMimes.master;
|
this.setSelectedValue = metadata.fileMimes.master;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.selectedFormat(this.setSelectedValue);
|
this.selectedFormat(this.setSelectedValue);
|
||||||
this.updateFormatOptions();
|
this.updateFormatOptions();
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { ViewRoutingModule } from './view.routing.module';
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatDividerModule,
|
MatDividerModule,
|
||||||
|
MatIconModule,
|
||||||
PicsurImgModule,
|
PicsurImgModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
FabModule,
|
FabModule,
|
||||||
|
|
|
@ -13,9 +13,11 @@
|
||||||
"setversion": "./support/setversion.sh"
|
"setversion": "./support/setversion.sh"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"minimist": "npm:minimist-lite"
|
"minimist": "npm:minimist-lite",
|
||||||
|
"fastify-static": "npm:@fastify/static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"fastify-static": "npm:@fastify/static",
|
||||||
"minimist": "npm:minimist-lite"
|
"minimist": "npm:minimist-lite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
"zod": "^3.14.4"
|
"zod": "^3.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^17.0.24",
|
"@types/node": "^17.0.30",
|
||||||
"typescript": "4.6.3"
|
"typescript": "4.6.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -rf ./dist",
|
"clean": "rm -rf ./dist",
|
||||||
|
|
|
@ -3,6 +3,33 @@ import { EImageSchema } from '../../entities/image.entity';
|
||||||
import { createZodDto } from '../../util/create-zod-dto';
|
import { createZodDto } from '../../util/create-zod-dto';
|
||||||
import { ImageFileType } from '../image-file-types.dto';
|
import { ImageFileType } from '../image-file-types.dto';
|
||||||
|
|
||||||
|
const parseBool = (value: unknown): boolean | null => {
|
||||||
|
if (value === 'true' || value === '1' || value === 'yes') return true;
|
||||||
|
if (value === 'false' || value === '0' || value === 'no') return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImageRequestParamsSchema = z
|
||||||
|
.object({
|
||||||
|
height: z.preprocess(Number, z.number().int().min(1).max(32767)),
|
||||||
|
width: z.preprocess(Number, z.number().int().min(1).max(32767)),
|
||||||
|
rotate: z.preprocess(
|
||||||
|
Number,
|
||||||
|
z.number().int().multipleOf(90).min(0).max(360),
|
||||||
|
),
|
||||||
|
flipx: z.preprocess(parseBool, z.boolean()),
|
||||||
|
flipy: z.preprocess(parseBool, z.boolean()),
|
||||||
|
greyscale: z.preprocess(parseBool, z.boolean()),
|
||||||
|
noalpha: z.preprocess(parseBool, z.boolean()),
|
||||||
|
negative: z.preprocess(parseBool, z.boolean()),
|
||||||
|
quality: z.preprocess(Number, z.number().int().min(1).max(100)),
|
||||||
|
})
|
||||||
|
.partial();
|
||||||
|
|
||||||
|
export class ImageRequestParams extends createZodDto(
|
||||||
|
ImageRequestParamsSchema,
|
||||||
|
) {}
|
||||||
|
|
||||||
export const ImageMetaResponseSchema = z.object({
|
export const ImageMetaResponseSchema = z.object({
|
||||||
image: EImageSchema,
|
image: EImageSchema,
|
||||||
fileMimes: z.object({
|
fileMimes: z.object({
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
|
|
||||||
// This enum is only here to make accessing the values easier, and type checking in the backend
|
// This enum is only here to make accessing the values easier, and type checking in the backend
|
||||||
export enum SysPreference {
|
export enum SysPreference {
|
||||||
JwtSecret = 'jwt_secret',
|
JwtSecret = 'jwt_secret',
|
||||||
JwtExpiresIn = 'jwt_expires_in',
|
JwtExpiresIn = 'jwt_expires_in',
|
||||||
BCryptStrength = 'bcrypt_strength',
|
BCryptStrength = 'bcrypt_strength',
|
||||||
|
|
||||||
|
SaveDerivatives = 'save_derivatives',
|
||||||
RemoveDerivativesAfter = 'remove_derivatives_after',
|
RemoveDerivativesAfter = 'remove_derivatives_after',
|
||||||
|
AllowEditing = 'allow_editing',
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue