move image converting to child_process
This commit is contained in:
parent
342e52601e
commit
377e6d7709
|
@ -5,5 +5,8 @@
|
|||
"generateOptions": {
|
||||
"spec": false
|
||||
},
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "tsconfig.build.json"
|
||||
},
|
||||
"exec": "pog"
|
||||
}
|
||||
|
|
|
@ -11,15 +11,18 @@
|
|||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"start": "nest start --exec \"node --experimental-specifier-resolution=node\"",
|
||||
"start:dev": "yarn clean && nest start --watch --exec \"node --experimental-specifier-resolution=node\"",
|
||||
"start:debug": "nest start --debug --watch --exec \"node --experimental-specifier-resolution=node\"",
|
||||
"start:prod": "node --experimental-specifier-resolution=node dist/main",
|
||||
"start": "nest start --exec \"node --es-module-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 --es-module-specifier-resolution=node\"",
|
||||
"start:prod": "node --es-module-specifier-resolution=node dist/main",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"clean": "rimraf dist",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/helmet": "^8.0.0",
|
||||
"@fastify/multipart": "^6.0.0",
|
||||
"@fastify/static": "^5.0.0",
|
||||
"@nestjs/common": "^8.4.4",
|
||||
"@nestjs/config": "^2.0.0",
|
||||
"@nestjs/core": "^8.4.4",
|
||||
|
@ -31,22 +34,22 @@
|
|||
"bcrypt": "^5.0.1",
|
||||
"bmp-img": "^1.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"fastify-helmet": "^7.0.1",
|
||||
"fastify-multipart": "^5.3.1",
|
||||
"fastify-static": "^4.6.1",
|
||||
"fastify-static": "^4.7.0",
|
||||
"file-type": "^17.1.1",
|
||||
"ms": "^2.1.3",
|
||||
"p-timeout": "^5.0.2",
|
||||
"passport": "^0.5.2",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-strategy": "^1.0.0",
|
||||
"pg": "^8.7.3",
|
||||
"picsur-shared": "*",
|
||||
"qoi-img": "^1.0.1",
|
||||
"posix": "^4.2.0",
|
||||
"qoi-img": "^1.1.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.5.5",
|
||||
"sharp": "^0.30.3",
|
||||
"sharp": "^0.30.4",
|
||||
"typeorm": "0.3.6",
|
||||
"zod": "^3.14.4"
|
||||
},
|
||||
|
@ -58,23 +61,23 @@
|
|||
"@types/cors": "^2.8.12",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^17.0.24",
|
||||
"@types/node": "^17.0.30",
|
||||
"@types/passport-jwt": "^3.0.6",
|
||||
"@types/passport-local": "^1.0.34",
|
||||
"@types/passport-strategy": "^0.2.35",
|
||||
"@types/sharp": "^0.30.2",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.19.0",
|
||||
"@typescript-eslint/parser": "^5.19.0",
|
||||
"eslint": "^8.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.21.0",
|
||||
"@typescript-eslint/parser": "^5.21.0",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"prettier": "^2.6.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.2.8",
|
||||
"ts-loader": "^9.3.0",
|
||||
"ts-node": "^10.7.0",
|
||||
"tsconfig-paths": "^3.14.1",
|
||||
"typescript": "4.6.3",
|
||||
"typescript": "4.6.4",
|
||||
"webpack": "^5.72.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,5 +38,7 @@ export class PreferenceDefaultsService {
|
|||
this.jwtConfigService.getJwtExpiresIn() ?? '7d',
|
||||
[SysPreference.BCryptStrength]: () => 12,
|
||||
[SysPreference.RemoveDerivativesAfter]: () => '7d',
|
||||
[SysPreference.SaveDerivatives]: () => true,
|
||||
[SysPreference.AllowEditing]: () => true,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { MultipartFields, MultipartFile } from '@fastify/multipart';
|
||||
import {
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
|
@ -8,7 +9,6 @@ import {
|
|||
Scope
|
||||
} from '@nestjs/common';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { MultipartFields, MultipartFile } from 'fastify-multipart';
|
||||
import { HasFailed } from 'picsur-shared/dist/types';
|
||||
import { ZodDtoStatic } from 'picsur-shared/dist/util/create-zod-dto';
|
||||
import { MultipartConfigService } from '../../config/early/multipart.config.service';
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Multipart } from '@fastify/multipart';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
|
@ -6,7 +7,6 @@ import {
|
|||
Scope
|
||||
} from '@nestjs/common';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { Multipart } from 'fastify-multipart';
|
||||
import { MultipartConfigService } from '../../config/early/multipart.config.service';
|
||||
|
||||
@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 {
|
||||
FastifyAdapter,
|
||||
NestFastifyApplication
|
||||
} from '@nestjs/platform-fastify';
|
||||
import fastifyHelmet from 'fastify-helmet';
|
||||
import * as multipart from 'fastify-multipart';
|
||||
import { AppModule } from './app.module';
|
||||
import { UsersService } from './collections/user-db/user-db.service';
|
||||
import { HostConfigService } from './config/early/host.config.service';
|
||||
|
|
|
@ -1,15 +1,11 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BMPencode } from 'bmp-img';
|
||||
import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto';
|
||||
import {
|
||||
FullMime,
|
||||
ImageMime,
|
||||
SupportedMimeCategory
|
||||
FullMime, SupportedMimeCategory
|
||||
} from 'picsur-shared/dist/dto/mimes.dto';
|
||||
import { AsyncFailable, Fail } from 'picsur-shared/dist/types';
|
||||
import { QOIencode } from 'qoi-img';
|
||||
import { Sharp } from 'sharp';
|
||||
import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types';
|
||||
import { SharpWrapper } from '../../workers/sharp.wrapper';
|
||||
import { ImageResult } from './imageresult';
|
||||
import { UniversalSharp } from './universal-sharp';
|
||||
|
||||
@Injectable()
|
||||
export class ImageConverterService {
|
||||
|
@ -17,6 +13,7 @@ export class ImageConverterService {
|
|||
image: Buffer,
|
||||
sourcemime: FullMime,
|
||||
targetmime: FullMime,
|
||||
options: ImageRequestParams,
|
||||
): AsyncFailable<ImageResult> {
|
||||
if (sourcemime.type !== targetmime.type) {
|
||||
return Fail("Can't convert from animated to still or vice versa");
|
||||
|
@ -30,9 +27,9 @@ export class ImageConverterService {
|
|||
}
|
||||
|
||||
if (targetmime.type === SupportedMimeCategory.Image) {
|
||||
return this.convertStill(image, sourcemime, targetmime);
|
||||
return this.convertStill(image, sourcemime, targetmime, options);
|
||||
} else if (targetmime.type === SupportedMimeCategory.Animation) {
|
||||
return this.convertAnimation(image, targetmime);
|
||||
return this.convertAnimation(image, targetmime, options);
|
||||
} else {
|
||||
return Fail('Unsupported mime type');
|
||||
}
|
||||
|
@ -42,43 +39,58 @@ export class ImageConverterService {
|
|||
image: Buffer,
|
||||
sourcemime: FullMime,
|
||||
targetmime: FullMime,
|
||||
options: ImageRequestParams,
|
||||
): 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
|
||||
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
|
||||
let result: Buffer;
|
||||
|
||||
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);
|
||||
}
|
||||
const result = await sharpWrapper.finish(targetmime, options);
|
||||
if (HasFailed(result)) return result;
|
||||
|
||||
return {
|
||||
image: result,
|
||||
image: result.data,
|
||||
mime: targetmime.mime,
|
||||
};
|
||||
}
|
||||
|
@ -86,6 +98,7 @@ export class ImageConverterService {
|
|||
private async convertAnimation(
|
||||
image: Buffer,
|
||||
targetmime: FullMime,
|
||||
options: ImageRequestParams,
|
||||
): AsyncFailable<ImageResult> {
|
||||
// Apng and gif are stored as is for now
|
||||
return {
|
||||
|
@ -93,38 +106,4 @@ export class ImageConverterService {
|
|||
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');
|
||||
|
||||
const metadata = await sharpImage.metadata();
|
||||
const pixels = await sharpImage.raw().toBuffer();
|
||||
const processedImage = await sharpImage.raw().toBuffer({
|
||||
resolveWithObject: true,
|
||||
});
|
||||
|
||||
if (
|
||||
metadata.hasAlpha === undefined ||
|
||||
metadata.width === undefined ||
|
||||
metadata.height === undefined
|
||||
)
|
||||
return Fail('Invalid image');
|
||||
|
||||
if (metadata.width >= 32768 || metadata.height >= 32768) {
|
||||
processedImage.info.width >= 32768 ||
|
||||
processedImage.info.height >= 32768
|
||||
) {
|
||||
return Fail('Image too large');
|
||||
}
|
||||
|
||||
// Png can be more efficient than QOI, but its just sooooooo slow
|
||||
const qoiImage = QOIencode(pixels, {
|
||||
channels: metadata.hasAlpha ? 4 : 3,
|
||||
const qoiImage = QOIencode(processedImage.data, {
|
||||
channels: processedImage.info.channels,
|
||||
colorspace: QOIColorSpace.SRGB,
|
||||
height: metadata.height,
|
||||
width: metadata.width,
|
||||
height: processedImage.info.height,
|
||||
width: processedImage.info.width,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import Crypto from 'crypto';
|
||||
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 { 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 { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types';
|
||||
import { ParseMime } from 'picsur-shared/dist/util/parse-mime';
|
||||
import { IsQOI } from 'qoi-img';
|
||||
import { ImageDBService } from '../../collections/image-db/image-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 { EImageDerivativeBackend } from '../../models/entities/image-derivative.entity';
|
||||
import { EImageFileBackend } from '../../models/entities/image-file.entity';
|
||||
|
@ -27,6 +30,7 @@ export class ImageManagerService {
|
|||
private readonly processService: ImageProcessorService,
|
||||
private readonly convertService: ImageConverterService,
|
||||
private readonly userPref: UsrPreferenceService,
|
||||
private readonly sysPref: SysPreferenceService,
|
||||
) {}
|
||||
|
||||
public async retrieveInfo(id: string): AsyncFailable<EImageBackend> {
|
||||
|
@ -84,18 +88,28 @@ export class ImageManagerService {
|
|||
|
||||
public async getConverted(
|
||||
imageId: string,
|
||||
options: {
|
||||
mime: string;
|
||||
},
|
||||
mime: string,
|
||||
options: ImageRequestParams,
|
||||
): AsyncFailable<EImageDerivativeBackend> {
|
||||
const targetMime = ParseMime(options.mime);
|
||||
const targetMime = ParseMime(mime);
|
||||
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(
|
||||
converted_key,
|
||||
() => this.imageFilesService.getDerivative(imageId, converted_key),
|
||||
() => {
|
||||
if (save_derivatives)
|
||||
return this.imageFilesService.getDerivative(imageId, converted_key);
|
||||
else return Promise.resolve(null);
|
||||
},
|
||||
async () => {
|
||||
const masterImage = await this.getMaster(imageId);
|
||||
if (HasFailed(masterImage)) return masterImage;
|
||||
|
@ -108,6 +122,7 @@ export class ImageManagerService {
|
|||
masterImage.data,
|
||||
sourceMime,
|
||||
targetMime,
|
||||
allow_editing ? options : {},
|
||||
);
|
||||
if (HasFailed(convertResult)) return convertResult;
|
||||
|
||||
|
@ -117,12 +132,21 @@ export class ImageManagerService {
|
|||
} in ${Date.now() - startTime}ms`,
|
||||
);
|
||||
|
||||
if (save_derivatives) {
|
||||
return await this.imageFilesService.addDerivative(
|
||||
imageId,
|
||||
converted_key,
|
||||
convertResult.mime,
|
||||
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.BCryptStrength]: 'number',
|
||||
[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 { z } from 'zod';
|
||||
|
||||
|
|
|
@ -6,11 +6,13 @@ import {
|
|||
Logger,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Query,
|
||||
Res
|
||||
} from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import {
|
||||
ImageMetaResponse,
|
||||
ImageRequestParams,
|
||||
ImageUploadResponse
|
||||
} from 'picsur-shared/dist/dto/api/image.dto';
|
||||
import { HasFailed } from 'picsur-shared/dist/types';
|
||||
|
@ -39,6 +41,7 @@ export class ImageController {
|
|||
// But we need it here to set the mime type
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
@ImageFullIdParam() fullid: ImageFullId,
|
||||
@Query() params: ImageRequestParams,
|
||||
): Promise<Buffer> {
|
||||
if (fullid.type === 'original') {
|
||||
const image = await this.imagesService.getOriginal(fullid.id);
|
||||
|
@ -51,12 +54,14 @@ export class ImageController {
|
|||
return image.data;
|
||||
}
|
||||
|
||||
const image = await this.imagesService.getConverted(fullid.id, {
|
||||
mime: fullid.mime,
|
||||
});
|
||||
const image = await this.imagesService.getConverted(
|
||||
fullid.id,
|
||||
fullid.mime,
|
||||
params,
|
||||
);
|
||||
if (HasFailed(image)) {
|
||||
this.logger.warn(image.getReason());
|
||||
throw new NotFoundException('Could not find image');
|
||||
throw new NotFoundException('Failed to get image');
|
||||
}
|
||||
|
||||
res.type(image.mime);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FastifyHelmetOptions } from 'fastify-helmet';
|
||||
import { FastifyHelmetOptions } from '@fastify/helmet';
|
||||
|
||||
export const HelmetOptions: FastifyHelmetOptions = {
|
||||
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",
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
"picsur-shared": "*",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "~7.5.5",
|
||||
"tslib": "^2.3.1",
|
||||
"tslib": "^2.4.0",
|
||||
"zod": "^3.14.4",
|
||||
"zone.js": "~0.11.5"
|
||||
},
|
||||
|
@ -42,8 +42,8 @@
|
|||
"@fontsource/material-icons": "^4.5.4",
|
||||
"@fontsource/material-icons-outlined": "^4.5.4",
|
||||
"@fontsource/roboto": "^4.5.5",
|
||||
"@types/node": "^17.0.24",
|
||||
"@types/node": "^17.0.30",
|
||||
"@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.BCryptStrength]: 'BCrypt Strength',
|
||||
[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;
|
||||
}
|
||||
|
||||
if (this.hasOriginal) {
|
||||
this.setSelectedValue = 'original';
|
||||
} else {
|
||||
if (this.masterMime.type === SupportedMimeCategory.Image) {
|
||||
this.setSelectedValue = ImageMime.JPEG;
|
||||
} else if (this.masterMime.type === SupportedMimeCategory.Animation) {
|
||||
|
@ -79,7 +76,6 @@ export class ViewComponent implements OnInit {
|
|||
} else {
|
||||
this.setSelectedValue = metadata.fileMimes.master;
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedFormat(this.setSelectedValue);
|
||||
this.updateFormatOptions();
|
||||
|
|
|
@ -18,6 +18,7 @@ import { ViewRoutingModule } from './view.routing.module';
|
|||
MatButtonModule,
|
||||
MatSelectModule,
|
||||
MatDividerModule,
|
||||
MatIconModule,
|
||||
PicsurImgModule,
|
||||
MatIconModule,
|
||||
FabModule,
|
||||
|
|
|
@ -13,9 +13,11 @@
|
|||
"setversion": "./support/setversion.sh"
|
||||
},
|
||||
"resolutions": {
|
||||
"minimist": "npm:minimist-lite"
|
||||
"minimist": "npm:minimist-lite",
|
||||
"fastify-static": "npm:@fastify/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify-static": "npm:@fastify/static",
|
||||
"minimist": "npm:minimist-lite"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
"zod": "^3.14.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^17.0.24",
|
||||
"typescript": "4.6.3"
|
||||
"@types/node": "^17.0.30",
|
||||
"typescript": "4.6.4"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf ./dist",
|
||||
|
|
|
@ -3,6 +3,33 @@ import { EImageSchema } from '../../entities/image.entity';
|
|||
import { createZodDto } from '../../util/create-zod-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({
|
||||
image: EImageSchema,
|
||||
fileMimes: z.object({
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
|
||||
// This enum is only here to make accessing the values easier, and type checking in the backend
|
||||
export enum SysPreference {
|
||||
JwtSecret = 'jwt_secret',
|
||||
JwtExpiresIn = 'jwt_expires_in',
|
||||
BCryptStrength = 'bcrypt_strength',
|
||||
|
||||
SaveDerivatives = 'save_derivatives',
|
||||
RemoveDerivativesAfter = 'remove_derivatives_after',
|
||||
AllowEditing = 'allow_editing',
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue