move image converting to child_process

This commit is contained in:
rubikscraft 2022-05-01 23:29:56 +02:00
parent 342e52601e
commit 377e6d7709
No known key found for this signature in database
GPG key ID: 1463EBE9200A5CD4
27 changed files with 1201 additions and 416 deletions

View file

@ -5,5 +5,8 @@
"generateOptions": {
"spec": false
},
"compilerOptions": {
"tsConfigPath": "tsconfig.build.json"
},
"exec": "pog"
}

View file

@ -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"
}
}

View file

@ -38,5 +38,7 @@ export class PreferenceDefaultsService {
this.jwtConfigService.getJwtExpiresIn() ?? '7d',
[SysPreference.BCryptStrength]: () => 12,
[SysPreference.RemoveDerivativesAfter]: () => '7d',
[SysPreference.SaveDerivatives]: () => true,
[SysPreference.AllowEditing]: () => true,
};
}

View file

@ -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';

View file

@ -1,12 +1,12 @@
import { Multipart } from '@fastify/multipart';
import {
BadRequestException,
Injectable,
Logger,
PipeTransform,
Scope
BadRequestException,
Injectable,
Logger,
PipeTransform,
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 })

View file

@ -1,10 +1,10 @@
import fastifyHelmet from '@fastify/helmet';
import * as multipart from '@fastify/multipart';
import { NestFactory, Reflector } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication
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';

View file

@ -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
// 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');
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',
});
}
} catch (e) {
return Fail(e);
}
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
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;
}
}

View file

@ -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 {

View file

@ -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`,
);
return await this.imageFilesService.addDerivative(
imageId,
converted_key,
convertResult.mime,
convertResult.image,
);
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;
}
},
);
}

View file

@ -12,4 +12,6 @@ export const SysPreferenceValueTypes: {
[SysPreference.JwtExpiresIn]: 'string',
[SysPreference.BCryptStrength]: 'number',
[SysPreference.RemoveDerivativesAfter]: 'string',
[SysPreference.SaveDerivatives]: 'boolean',
[SysPreference.AllowEditing]: 'boolean',
};

View file

@ -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';

View file

@ -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);

View file

@ -1,4 +1,4 @@
import { FastifyHelmetOptions } from 'fastify-helmet';
import { FastifyHelmetOptions } from '@fastify/helmet';
export const HelmetOptions: FastifyHelmetOptions = {
contentSecurityPolicy: {

View 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;
}
}

View 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;

View 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();

View 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;
}

View file

@ -1,4 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View file

@ -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"
}
}

View file

@ -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)',
};

View file

@ -69,16 +69,12 @@ export class ViewComponent implements OnInit {
this.masterMime = masterMime;
}
if (this.hasOriginal) {
this.setSelectedValue = 'original';
if (this.masterMime.type === SupportedMimeCategory.Image) {
this.setSelectedValue = ImageMime.JPEG;
} else if (this.masterMime.type === SupportedMimeCategory.Animation) {
this.setSelectedValue = AnimMime.GIF;
} else {
if (this.masterMime.type === SupportedMimeCategory.Image) {
this.setSelectedValue = ImageMime.JPEG;
} else if (this.masterMime.type === SupportedMimeCategory.Animation) {
this.setSelectedValue = AnimMime.GIF;
} else {
this.setSelectedValue = metadata.fileMimes.master;
}
this.setSelectedValue = metadata.fileMimes.master;
}
this.selectedFormat(this.setSelectedValue);

View file

@ -18,6 +18,7 @@ import { ViewRoutingModule } from './view.routing.module';
MatButtonModule,
MatSelectModule,
MatDividerModule,
MatIconModule,
PicsurImgModule,
MatIconModule,
FabModule,

View file

@ -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"
}
}

View file

@ -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",

View file

@ -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({

View file

@ -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',
}

739
yarn.lock

File diff suppressed because it is too large Load diff