diff --git a/backend/package.json b/backend/package.json index b524fa7..33d562c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,17 +31,18 @@ "@vingle/bmp-js": "^0.2.5", "bcrypt": "^5.0.1", "cors": "^2.8.5", + "decode-ico": "^0.4.0", "fastify-helmet": "^7.0.1", "fastify-multipart": "^5.3.1", "fastify-static": "^4.6.1", "file-type": "^17.1.1", - "ico-to-png": "^0.2.1", "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", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.5.5", diff --git a/backend/src/collections/imagedb/imagedb.module.ts b/backend/src/collections/imagedb/imagedb.module.ts index cd9da0a..5ac5eac 100644 --- a/backend/src/collections/imagedb/imagedb.module.ts +++ b/backend/src/collections/imagedb/imagedb.module.ts @@ -2,11 +2,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { EImageBackend } from '../../models/entities/image.entity'; import { ImageDBService } from './imagedb.service'; -import { MimesService } from './mimes.service'; @Module({ imports: [TypeOrmModule.forFeature([EImageBackend])], - providers: [ImageDBService, MimesService], - exports: [ImageDBService, MimesService], + providers: [ImageDBService], + exports: [ImageDBService], }) export class ImageDBModule {} diff --git a/backend/src/collections/imagedb/mimes.service.ts b/backend/src/collections/imagedb/mimes.service.ts deleted file mode 100644 index a7a7c97..0000000 --- a/backend/src/collections/imagedb/mimes.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Fail, Failable } from 'picsur-shared/dist/types'; -import { - FullMime, - SupportedAnimMimes, - SupportedImageMimes, - SupportedMimeCategory -} from '../../models/dto/mimes.dto'; - -@Injectable() -export class MimesService { - public getFullMime(mime: string): Failable { - if (SupportedImageMimes.includes(mime)) { - return { mime, type: SupportedMimeCategory.Image }; - } - if (SupportedAnimMimes.includes(mime)) { - return { mime, type: SupportedMimeCategory.Animation }; - } - return Fail('Unsupported mime type'); - } -} diff --git a/backend/src/managers/imagemanager/imagemanager.service.ts b/backend/src/managers/imagemanager/imagemanager.service.ts index 9832f39..03a5d35 100644 --- a/backend/src/managers/imagemanager/imagemanager.service.ts +++ b/backend/src/managers/imagemanager/imagemanager.service.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { fileTypeFromBuffer, FileTypeResult } from 'file-type'; +import { FullMime } from 'picsur-shared/dist/dto/mimes.dto'; import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types'; +import { ParseMime } from 'picsur-shared/dist/util/parse-mime'; import { ImageDBService } from '../../collections/imagedb/imagedb.service'; -import { MimesService } from '../../collections/imagedb/mimes.service'; -import { FullMime } from '../../models/dto/mimes.dto'; import { EImageBackend } from '../../models/entities/image.entity'; import { ImageProcessorService } from './imageprocessor.service'; @@ -15,7 +15,6 @@ import { ImageProcessorService } from './imageprocessor.service'; export class ImageManagerService { constructor( private readonly imagesService: ImageDBService, - private readonly mimesService: MimesService, private readonly processService: ImageProcessorService, ) {} @@ -35,9 +34,16 @@ export class ImageManagerService { image: Buffer, userid: string, ): AsyncFailable { + let startTime = Date.now(); + + console.log('Uploading image'); + const fullMime = await this.getFullMimeFromBuffer(image); if (HasFailed(fullMime)) return fullMime; + console.log('Got full mime after ' + (Date.now() - startTime) + 'ms'); + startTime = Date.now(); + const processedImage = await this.processService.process( image, fullMime, @@ -45,20 +51,26 @@ export class ImageManagerService { ); if (HasFailed(processedImage)) return processedImage; + console.log('Processed image after ' + (Date.now() - startTime) + 'ms'); + startTime = Date.now(); + const imageEntity = await this.imagesService.create( processedImage, fullMime.mime, ); if (HasFailed(imageEntity)) return imageEntity; + console.log('Created image after ' + (Date.now() - startTime) + 'ms'); + return imageEntity; } private async getFullMimeFromBuffer(image: Buffer): AsyncFailable { const mime: FileTypeResult | undefined = await fileTypeFromBuffer(image); - const fullMime = await this.mimesService.getFullMime( - mime?.mime ?? 'extra/discard', - ); + + console.log(mime); + + const fullMime = ParseMime(mime?.mime ?? 'extra/discard'); return fullMime; } } diff --git a/backend/src/managers/imagemanager/imageprocessor.service.ts b/backend/src/managers/imagemanager/imageprocessor.service.ts index 8e36b0a..47375de 100644 --- a/backend/src/managers/imagemanager/imageprocessor.service.ts +++ b/backend/src/managers/imagemanager/imageprocessor.service.ts @@ -1,22 +1,18 @@ import { Injectable } from '@nestjs/common'; import * as bmp from '@vingle/bmp-js'; -import icoToPng from 'ico-to-png'; -import { AsyncFailable, Fail } from 'picsur-shared/dist/types'; -import sharp from 'sharp'; -import { UsrPreferenceService } from '../../collections/preferencesdb/usrpreferencedb.service'; +import decodeico from 'decode-ico'; import { FullMime, ImageMime, SupportedMimeCategory -} from '../../models/dto/mimes.dto'; +} from 'picsur-shared/dist/dto/mimes.dto'; +import { AsyncFailable, Fail } from 'picsur-shared/dist/types'; +import { QOIColorSpace, QOIencode } from 'qoi-img'; +import sharp from 'sharp'; +import { UsrPreferenceService } from '../../collections/preferencesdb/usrpreferencedb.service'; @Injectable() export class ImageProcessorService { - private readonly PngOptions = { - compressionLevel: 9, - effort: 10, - }; - constructor(private readonly userPref: UsrPreferenceService) {} public async process( @@ -48,22 +44,38 @@ export class ImageProcessorService { mime: FullMime, options: {}, ): AsyncFailable { - let processedImage = image; + let sharpImage: sharp.Sharp; if (mime.mime === ImageMime.ICO) { - processedImage = await icoToPng(processedImage, 512); + sharpImage = this.icoSharp(image); } else if (mime.mime === ImageMime.BMP) { - processedImage = await this.bmpSharp(processedImage) - .png(this.PngOptions) - .toBuffer(); + sharpImage = this.bmpSharp(image); } else { - processedImage = await sharp(processedImage) - .png(this.PngOptions) - .toBuffer(); + sharpImage = sharp(image); } mime.mime = ImageMime.PNG; - return processedImage; + sharpImage = sharpImage.toColorspace('srgb'); + + const metadata = await sharpImage.metadata(); + const pixels = await sharpImage.raw().toBuffer(); + + if ( + metadata.hasAlpha === undefined || + metadata.width === undefined || + metadata.height === undefined + ) + return Fail('Invalid image'); + + // Png can be more efficient than QOI, but its just sooooooo slow + const qoiImage = QOIencode(pixels, { + channels: metadata.hasAlpha ? 4 : 3, + colorSpace: QOIColorSpace.SRGB, + height: metadata.height, + width: metadata.width, + }); + + return qoiImage; } private async processAnimation( @@ -85,4 +97,18 @@ export class ImageProcessorService { }, }); } + + private icoSharp(image: Buffer) { + const result = decodeico(image); + // Get biggest image + const best = result.sort((a, b) => b.width - a.width)[0]; + + return sharp(best.data, { + raw: { + width: best.width, + height: best.height, + channels: 4, + }, + }); + } } diff --git a/backend/src/routes/image/imageroute.controller.ts b/backend/src/routes/image/imageroute.controller.ts index f28b9ca..b7d92fa 100644 --- a/backend/src/routes/image/imageroute.controller.ts +++ b/backend/src/routes/image/imageroute.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, + Head, InternalServerErrorException, Logger, NotFoundException, @@ -46,6 +47,20 @@ export class ImageController { return image.data; } + @Head(':hash') + async headImage( + @Res({ passthrough: true }) res: FastifyReply, + @Param('hash', ImageIdValidator) hash: string, + ) { + const image = await this.imagesService.retrieveInfo(hash); + if (HasFailed(image)) { + this.logger.warn(image.getReason()); + throw new NotFoundException('Could not find image'); + } + + res.type(image.mime); + } + @Get('meta/:hash') @Returns(ImageMetaResponse) async getImageMeta( diff --git a/frontend/angular.json b/frontend/angular.json index 03b7f06..fc1eda7 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -26,7 +26,7 @@ "index": "src/index.html", "main": "src/main.ts", "polyfills": "src/polyfills.ts", - "tsConfig": "tsconfig.json", + "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", "assets": ["src/assets"], "styles": ["src/styles.scss"], @@ -37,7 +37,8 @@ "buffer/", "sha.js" ], - "optimization": true + "optimization": true, + "webWorkerTsConfig": "tsconfig.worker.json" }, "configurations": { "production": { diff --git a/frontend/src/app/components/picsur-img/picsur-img.component.html b/frontend/src/app/components/picsur-img/picsur-img.component.html new file mode 100644 index 0000000..e543e2f --- /dev/null +++ b/frontend/src/app/components/picsur-img/picsur-img.component.html @@ -0,0 +1,9 @@ + + + diff --git a/frontend/src/app/components/picsur-img/picsur-img.component.scss b/frontend/src/app/components/picsur-img/picsur-img.component.scss new file mode 100644 index 0000000..9011850 --- /dev/null +++ b/frontend/src/app/components/picsur-img/picsur-img.component.scss @@ -0,0 +1,19 @@ +:host { + display: block; + // Clip contents + overflow: hidden; + + width: fit-content; + margin-left: auto; + margin-right: auto; +} + +mat-spinner { + margin: auto; +} + +canvas, img { + display: block; + width: 100%; + height: 100%; +} diff --git a/frontend/src/app/components/picsur-img/picsur-img.component.ts b/frontend/src/app/components/picsur-img/picsur-img.component.ts new file mode 100644 index 0000000..b4f0a84 --- /dev/null +++ b/frontend/src/app/components/picsur-img/picsur-img.component.ts @@ -0,0 +1,80 @@ +import { + Component, + ElementRef, + Input, + OnChanges, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { SupportedMimeCategory } from 'picsur-shared/dist/dto/mimes.dto'; +import { HasFailed } from 'picsur-shared/dist/types'; +import { URLRegex } from 'picsur-shared/dist/util/common-regex'; +import { ParseMime } from 'picsur-shared/dist/util/parse-mime'; +import { Logger } from 'src/app/services/logger/logger.service'; +import { QoiWorkerService } from 'src/app/workers/qoi-worker.service'; + +enum PicsurImgState { + Loading = 'loading', + Canvas = 'canvas', + Image = 'image', +} + +@Component({ + selector: 'picsur-img', + templateUrl: './picsur-img.component.html', + styleUrls: ['./picsur-img.component.scss'], +}) +export class PicsurImgComponent implements OnChanges { + private readonly logger = new Logger('ZodImgComponent'); + + @ViewChild('targetcanvas') canvas: ElementRef; + + @Input('src') imageURL: string | undefined; + + public state: PicsurImgState = PicsurImgState.Loading; + + constructor(private qoiWorker: QoiWorkerService) {} + + ngOnChanges(changes: SimpleChanges): void { + let url = this.imageURL ?? ''; + if (URLRegex.test(url)) { + this.update(url).catch((err) => this.logger.error(err)); + } else { + this.state = PicsurImgState.Loading; + } + } + + private async update(url: string) { + const mime = await this.getMime(url); + if (HasFailed(mime)) { + this.logger.error(mime.getReason()); + return; + } + + if (mime.type === SupportedMimeCategory.Image) { + const result = await this.qoiWorker.decode(url); + + const canvas = this.canvas.nativeElement; + canvas.height = result.height; + canvas.width = result.width; + canvas.getContext('2d')?.putImageData(result.data, 0, 0); + + this.state = PicsurImgState.Canvas; + } else if (mime.type === SupportedMimeCategory.Animation) { + this.state = PicsurImgState.Image; + } else { + this.logger.error(`Unsupported mime type: ${mime.type}`); + } + } + + private async getMime(url: string) { + const response = await fetch(url, { + method: 'HEAD', + }); + const mimeHeader = response.headers.get('content-type') ?? ''; + const mime = mimeHeader.split(';')[0]; + + const fullMime = ParseMime(mime); + return fullMime; + } +} diff --git a/frontend/src/app/components/picsur-img/picsur-img.module.ts b/frontend/src/app/components/picsur-img/picsur-img.module.ts new file mode 100644 index 0000000..18ad473 --- /dev/null +++ b/frontend/src/app/components/picsur-img/picsur-img.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { PicsurImgComponent } from './picsur-img.component'; + +@NgModule({ + declarations: [PicsurImgComponent], + imports: [CommonModule, MatProgressSpinnerModule], + exports: [PicsurImgComponent], +}) +export class PicsurImgModule {} diff --git a/frontend/src/app/routes/view/view.component.html b/frontend/src/app/routes/view/view.component.html index 56f09f8..e0469c8 100644 --- a/frontend/src/app/routes/view/view.component.html +++ b/frontend/src/app/routes/view/view.component.html @@ -6,11 +6,14 @@
- +
- +
@@ -25,7 +28,21 @@
- + +
diff --git a/frontend/src/app/routes/view/view.component.scss b/frontend/src/app/routes/view/view.component.scss index 2107755..534b201 100644 --- a/frontend/src/app/routes/view/view.component.scss +++ b/frontend/src/app/routes/view/view.component.scss @@ -6,5 +6,3 @@ .content-border { padding: 1rem; } - - diff --git a/frontend/src/app/routes/view/view.component.ts b/frontend/src/app/routes/view/view.component.ts index e4ec627..1596b84 100644 --- a/frontend/src/app/routes/view/view.component.ts +++ b/frontend/src/app/routes/view/view.component.ts @@ -18,7 +18,6 @@ export class ViewComponent implements OnInit { private utilService: UtilService ) {} - public imageUrl: string = ''; public imageLinks = new ImageLinks(); async ngOnInit() { @@ -28,13 +27,16 @@ export class ViewComponent implements OnInit { return this.utilService.quitError('Invalid image link'); } - const imageMeta = await this.imageService.GetImageMeta(hash); - if (HasFailed(imageMeta)) { - return this.utilService.quitError(imageMeta.getReason()); + const metadata = await this.imageService.GetImageMeta(hash); + if (HasFailed(metadata)) { + return this.utilService.quitError(metadata.getReason()); } - this.imageUrl = this.imageService.GetImageURL(hash); - this.imageLinks = this.imageService.CreateImageLinks(this.imageUrl); + this.imageLinks = this.imageService.CreateImageLinksFromID(hash); + } + + downloadImage() { + this.utilService.downloadFile(this.imageLinks.source); } goBackHome() { diff --git a/frontend/src/app/routes/view/view.module.ts b/frontend/src/app/routes/view/view.module.ts index 20681d6..ae3d1c3 100644 --- a/frontend/src/app/routes/view/view.module.ts +++ b/frontend/src/app/routes/view/view.module.ts @@ -1,13 +1,18 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import Fuse from 'fuse.js'; import { CopyFieldModule } from 'src/app/components/copyfield/copyfield.module'; +import { PicsurImgModule } from 'src/app/components/picsur-img/picsur-img.module'; import { ViewComponent } from './view.component'; import { ViewRoutingModule } from './view.routing.module'; -const a = Fuse; @NgModule({ declarations: [ViewComponent], - imports: [CommonModule, CopyFieldModule, ViewRoutingModule, MatButtonModule], + imports: [ + CommonModule, + CopyFieldModule, + ViewRoutingModule, + MatButtonModule, + PicsurImgModule, + ], }) export class ViewRouteModule {} diff --git a/frontend/src/app/services/api/image.service.ts b/frontend/src/app/services/api/image.service.ts index bfc5bc8..2dae7e0 100644 --- a/frontend/src/app/services/api/image.service.ts +++ b/frontend/src/app/services/api/image.service.ts @@ -41,4 +41,8 @@ export class ImageService { bbcode: `[img]${imageURL}[/img]`, }; } + + public CreateImageLinksFromID(imageID: string): ImageLinks { + return this.CreateImageLinks(this.GetImageURL(imageID)); + } } diff --git a/frontend/src/app/util/qoi/qoi-decode.ts b/frontend/src/app/util/qoi/qoi-decode.ts new file mode 100644 index 0000000..e4db9b2 --- /dev/null +++ b/frontend/src/app/util/qoi/qoi-decode.ts @@ -0,0 +1,182 @@ +/** + * Decode a QOI file given as an ArrayBuffer. + * + * @param {ArrayBuffer} arrayBuffer ArrayBuffer containing the QOI file. + * @param {int|null} [byteOffset] Offset to the start of the QOI file in arrayBuffer + * @param {int|null} [byteLength] Length of the QOI file in bytes + * @param {int|null} [outputChannels] Number of channels to include in the decoded array + * + * @returns {{channels: number, data: Uint8Array, colorspace: number, width: number, error: boolean, height: number}} + */ +export function QOIdecodeJS( + arrayBuffer: ArrayBuffer, + byteOffset: number | null, + byteLength: number | null, + outputChannels: number | null +): { + channels: number; + data: Uint8Array; + colorspace: number; + width: number; + height: number; +} { + if (typeof byteOffset === 'undefined' || byteOffset === null) { + byteOffset = 0; + } + + if (typeof byteLength === 'undefined' || byteLength === null) { + byteLength = arrayBuffer.byteLength - byteOffset; + } + + const uint8 = new Uint8Array(arrayBuffer, byteOffset, byteLength); + + const magic1 = uint8[0]; + const magic2 = uint8[1]; + const magic3 = uint8[2]; + const magic4 = uint8[3]; + + const width = + ((uint8[4] << 24) | (uint8[5] << 16) | (uint8[6] << 8) | uint8[7]) >>> 0; + const height = + ((uint8[8] << 24) | (uint8[9] << 16) | (uint8[10] << 8) | uint8[11]) >>> 0; + + const channels = uint8[12]; + const colorspace = uint8[13]; + + if (typeof outputChannels === 'undefined' || outputChannels === null) { + outputChannels = channels; + } + + if ( + magic1 !== 0x71 || + magic2 !== 0x6f || + magic3 !== 0x69 || + magic4 !== 0x66 + ) { + throw new Error('QOI.decode: The signature of the QOI file is invalid'); + } + + if (channels < 3 || channels > 4) { + throw new Error( + 'QOI.decode: The number of channels declared in the file is invalid' + ); + } + + if (colorspace > 1) { + throw new Error( + 'QOI.decode: The colorspace declared in the file is invalid' + ); + } + + if (outputChannels < 3 || outputChannels > 4) { + throw new Error( + 'QOI.decode: The number of channels for the output is invalid' + ); + } + + const pixelLength = width * height * outputChannels; + const result = new Uint8Array(pixelLength); + + let arrayPosition = 14; + + const index = new Uint8Array(64 * 4); + let indexPosition = 0; + + let red = 0; + let green = 0; + let blue = 0; + let alpha = 255; + + const chunksLength = byteLength - 8; + + let run = 0; + let pixelPosition = 0; + + for ( + ; + pixelPosition < pixelLength && arrayPosition < byteLength - 4; + pixelPosition += outputChannels + ) { + if (run > 0) { + run--; + } else if (arrayPosition < chunksLength) { + const byte1 = uint8[arrayPosition++]; + + if (byte1 === 0b11111110) { + // QOI_OP_RGB + red = uint8[arrayPosition++]; + green = uint8[arrayPosition++]; + blue = uint8[arrayPosition++]; + } else if (byte1 === 0b11111111) { + // QOI_OP_RGBA + red = uint8[arrayPosition++]; + green = uint8[arrayPosition++]; + blue = uint8[arrayPosition++]; + alpha = uint8[arrayPosition++]; + } else if ((byte1 & 0b11000000) === 0b00000000) { + // QOI_OP_INDEX + red = index[byte1 * 4]; + green = index[byte1 * 4 + 1]; + blue = index[byte1 * 4 + 2]; + alpha = index[byte1 * 4 + 3]; + } else if ((byte1 & 0b11000000) === 0b01000000) { + // QOI_OP_DIFF + red += ((byte1 >> 4) & 0b00000011) - 2; + green += ((byte1 >> 2) & 0b00000011) - 2; + blue += (byte1 & 0b00000011) - 2; + + // handle wraparound + red = (red + 256) % 256; + green = (green + 256) % 256; + blue = (blue + 256) % 256; + } else if ((byte1 & 0b11000000) === 0b10000000) { + // QOI_OP_LUMA + const byte2 = uint8[arrayPosition++]; + const greenDiff = (byte1 & 0b00111111) - 32; + const redDiff = greenDiff + ((byte2 >> 4) & 0b00001111) - 8; + const blueDiff = greenDiff + (byte2 & 0b00001111) - 8; + + // handle wraparound + red = (red + redDiff + 256) % 256; + green = (green + greenDiff + 256) % 256; + blue = (blue + blueDiff + 256) % 256; + } else if ((byte1 & 0b11000000) === 0b11000000) { + // QOI_OP_RUN + run = byte1 & 0b00111111; + } + + indexPosition = ((red * 3 + green * 5 + blue * 7 + alpha * 11) % 64) * 4; + index[indexPosition] = red; + index[indexPosition + 1] = green; + index[indexPosition + 2] = blue; + index[indexPosition + 3] = alpha; + } + + if (outputChannels === 4) { + // RGBA + result[pixelPosition] = red; + result[pixelPosition + 1] = green; + result[pixelPosition + 2] = blue; + result[pixelPosition + 3] = alpha; + } else { + // RGB + result[pixelPosition] = red; + result[pixelPosition + 1] = green; + result[pixelPosition + 2] = blue; + } + } + + if (pixelPosition < pixelLength) { + throw new Error('QOI.decode: Incomplete image'); + } + + // checking the 00000001 padding is not required, as per specs + + return { + width: width, + height: height, + colorspace: colorspace, + channels: outputChannels, + data: result, + }; +} diff --git a/frontend/src/app/util/qoi/qoi-encode.ts b/frontend/src/app/util/qoi/qoi-encode.ts new file mode 100644 index 0000000..de00433 --- /dev/null +++ b/frontend/src/app/util/qoi/qoi-encode.ts @@ -0,0 +1,212 @@ +'use strict'; + +/** + * Encode a QOI file. + * + * @param {Uint8Array|Uint8ClampedArray} colorData Array containing the color information for each pixel of the image (left to right, top to bottom) + * @param {object} description + * @param {int} description.width Width of the image + * @param {int} description.height Height of the image + * @param {int} description.channels Number of channels in the image (3: RGB, 4: RGBA) + * @param {int} description.colorspace Colorspace used in the image (0: sRGB with linear alpha, 1: linear) + * + * @returns {ArrayBuffer} ArrayBuffer containing the QOI file content + */ +export function QOIencodeJS( + colorData: Uint8Array | Uint8ClampedArray, + description: { + width: number; + height: number; + channels: number; + colorspace: number; + } +) { + const width = description.width; + const height = description.height; + const channels = description.channels; + const colorspace = description.colorspace; + + let red = 0; + let green = 0; + let blue = 0; + let alpha = 255; + let prevRed = red; + let prevGreen = green; + let prevBlue = blue; + let prevAlpha = alpha; + + let run = 0; + let p = 0; + const pixelLength = width * height * channels; + const pixelEnd = pixelLength - channels; + + if (width < 0 || width >= 4294967296) { + throw new Error('QOI.encode: Invalid description.width'); + } + + if (height < 0 || height >= 4294967296) { + throw new Error('QOI.encode: Invalid description.height'); + } + + if ( + colorData.constructor.name !== 'Uint8Array' && + colorData.constructor.name !== 'Uint8ClampedArray' + ) { + throw new Error( + 'QOI.encode: The provided colorData must be instance of Uint8Array or Uint8ClampedArray' + ); + } + + if (colorData.length !== pixelLength) { + throw new Error('QOI.encode: The length of colorData is incorrect'); + } + + if (channels !== 3 && channels !== 4) { + throw new Error('QOI.encode: Invalid description.channels, must be 3 or 4'); + } + + if (colorspace !== 0 && colorspace !== 1) { + throw new Error( + 'QOI.encode: Invalid description.colorspace, must be 0 or 1' + ); + } + + const maxSize = width * height * (channels + 1) + 14 + 8; + const result = new Uint8Array(maxSize); + const index = new Uint8Array(64 * 4); + + // 0->3 : magic "qoif" + result[p++] = 0x71; + result[p++] = 0x6f; + result[p++] = 0x69; + result[p++] = 0x66; + + // 4->7 : width + result[p++] = (width >> 24) & 0xff; + result[p++] = (width >> 16) & 0xff; + result[p++] = (width >> 8) & 0xff; + result[p++] = width & 0xff; + + // 8->11 : height + result[p++] = (height >> 24) & 0xff; + result[p++] = (height >> 16) & 0xff; + result[p++] = (height >> 8) & 0xff; + result[p++] = height & 0xff; + + // 12 : channels, 13 : colorspace + result[p++] = channels; + result[p++] = colorspace; + + for (let pixelPos = 0; pixelPos < pixelLength; pixelPos += channels) { + if (channels === 4) { + red = colorData[pixelPos]; + green = colorData[pixelPos + 1]; + blue = colorData[pixelPos + 2]; + alpha = colorData[pixelPos + 3]; + } else { + red = colorData[pixelPos]; + green = colorData[pixelPos + 1]; + blue = colorData[pixelPos + 2]; + } + + if ( + prevRed === red && + prevGreen === green && + prevBlue === blue && + prevAlpha === alpha + ) { + run++; + + // reached the maximum run length, or reached the end of colorData + if (run === 62 || pixelPos === pixelEnd) { + // QOI_OP_RUN + result[p++] = 0b11000000 | (run - 1); + run = 0; + } + } else { + if (run > 0) { + // QOI_OP_RUN + result[p++] = 0b11000000 | (run - 1); + run = 0; + } + + const indexPosition = + ((red * 3 + green * 5 + blue * 7 + alpha * 11) % 64) * 4; + + if ( + index[indexPosition] === red && + index[indexPosition + 1] === green && + index[indexPosition + 2] === blue && + index[indexPosition + 3] === alpha + ) { + result[p++] = indexPosition / 4; + } else { + index[indexPosition] = red; + index[indexPosition + 1] = green; + index[indexPosition + 2] = blue; + index[indexPosition + 3] = alpha; + + if (alpha === prevAlpha) { + // ternary with bitmask handles the wraparound + let vr = red - prevRed; + vr = vr & 0b10000000 ? (vr - 256) % 256 : (vr + 256) % 256; + let vg = green - prevGreen; + vg = vg & 0b10000000 ? (vg - 256) % 256 : (vg + 256) % 256; + let vb = blue - prevBlue; + vb = vb & 0b10000000 ? (vb - 256) % 256 : (vb + 256) % 256; + + const vg_r = vr - vg; + const vg_b = vb - vg; + + if (vr > -3 && vr < 2 && vg > -3 && vg < 2 && vb > -3 && vb < 2) { + // QOI_OP_DIFF + result[p++] = + 0b01000000 | ((vr + 2) << 4) | ((vg + 2) << 2) | (vb + 2); + } else if ( + vg_r > -9 && + vg_r < 8 && + vg > -33 && + vg < 32 && + vg_b > -9 && + vg_b < 8 + ) { + // QOI_OP_LUMA + result[p++] = 0b10000000 | (vg + 32); + result[p++] = ((vg_r + 8) << 4) | (vg_b + 8); + } else { + // QOI_OP_RGB + result[p++] = 0b11111110; + result[p++] = red; + result[p++] = green; + result[p++] = blue; + } + } else { + // QOI_OP_RGBA + result[p++] = 0b11111111; + result[p++] = red; + result[p++] = green; + result[p++] = blue; + result[p++] = alpha; + } + } + } + + prevRed = red; + prevGreen = green; + prevBlue = blue; + prevAlpha = alpha; + } + + // 00000001 end marker/padding + result[p++] = 0; + result[p++] = 0; + result[p++] = 0; + result[p++] = 0; + result[p++] = 0; + result[p++] = 0; + result[p++] = 0; + result[p++] = 1; + + // return an ArrayBuffer trimmed to the correct length + return result.buffer.slice(0, p); +} diff --git a/frontend/src/app/util/qoi/readme.md b/frontend/src/app/util/qoi/readme.md new file mode 100644 index 0000000..816f329 --- /dev/null +++ b/frontend/src/app/util/qoi/readme.md @@ -0,0 +1,5 @@ +# QOI.js + +I included it directly because it needed to be typescript compatible + + diff --git a/frontend/src/app/util/util.service.ts b/frontend/src/app/util/util.service.ts index 4effb53..21acaac 100644 --- a/frontend/src/app/util/util.service.ts +++ b/frontend/src/app/util/util.service.ts @@ -47,6 +47,15 @@ export class UtilService { }); } + public downloadFile(url: string) { + const link = document.createElement('a'); + link.href = url; + link.download = url.split('/').pop() ?? ''; + link.click(); + + link.remove(); + } + public async sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/frontend/src/app/workers/qoi-worker.service.ts b/frontend/src/app/workers/qoi-worker.service.ts new file mode 100644 index 0000000..ca4ca99 --- /dev/null +++ b/frontend/src/app/workers/qoi-worker.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class QoiWorkerService { + private worker: Worker | null = null; + private job: Promise | null = null; + + constructor() { + if (typeof Worker !== 'undefined') { + this.worker = new Worker(new URL('./qoi.worker', import.meta.url)); + } else { + this.job = import('./qoi.job').then((m) => m.default); + } + } + + public async decode(url: string): Promise<{ + data: ImageData; + width: number; + height: number; + }> { + if (this.worker && !this.job) { + return new Promise((resolve, reject) => { + const id = Date.now(); + const listener = ({ data }: { data: any }) => { + if (data.id !== id) return; + this.worker!.removeEventListener('message', listener); + + resolve({ + data: data.data, + width: data.width, + height: data.height, + }); + }; + this.worker!.addEventListener('message', listener); + this.worker!.postMessage({ id, url }); + }); + } else if (!this.worker && this.job) { + const job = await this.job; + return job(url); + } else { + throw new Error('No worker available'); + } + } +} diff --git a/frontend/src/app/workers/qoi.job.ts b/frontend/src/app/workers/qoi.job.ts new file mode 100644 index 0000000..821717f --- /dev/null +++ b/frontend/src/app/workers/qoi.job.ts @@ -0,0 +1,28 @@ +import { QOIdecodeJS } from '../util/qoi/qoi-decode'; + +export default async function qoiDecodeJob(url: string): Promise<{ + data: ImageData; + width: number; + height: number; +}> { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch image: ${url}`); + } + + const buffer = await response.arrayBuffer(); + + const image = QOIdecodeJS(buffer, null, null, 4); + + const imageData = new ImageData( + new Uint8ClampedArray(image.data.buffer), + image.width, + image.height + ); + + return { + data: imageData, + width: image.width, + height: image.height, + }; +} diff --git a/frontend/src/app/workers/qoi.worker.ts b/frontend/src/app/workers/qoi.worker.ts new file mode 100644 index 0000000..f8233d2 --- /dev/null +++ b/frontend/src/app/workers/qoi.worker.ts @@ -0,0 +1,17 @@ +/// + +import qoiDecodeJob from './qoi.job'; + +addEventListener('message', async (msg) => { + const { id, url } = msg.data; + if (!id || !url) { + throw new Error('Invalid message'); + } + + const result = await qoiDecodeJob(url); + + postMessage({ + id, + ...result, + }); +}); diff --git a/frontend/src/polyfills.ts b/frontend/src/polyfills.ts index 96457e6..9bfe24f 100644 --- a/frontend/src/polyfills.ts +++ b/frontend/src/polyfills.ts @@ -47,7 +47,6 @@ */ import 'reflect-metadata'; import 'zone.js'; // Included with Angular CLI. - /*************************************************************************************************** * APPLICATION IMPORTS */ diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..f7cf945 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/", + + "target": "es2017", + "module": "es2020", + + "lib": ["es2020", "dom"], + }, + "files": ["src/main.ts", "src/polyfills.ts"], + "include": ["src/**/*.d.ts", "src/app/**/*.ts"], + "exclude": ["src/**/*.worker.ts"] +} diff --git a/frontend/tsconfig.base.json b/frontend/tsconfig.base.json new file mode 100644 index 0000000..6f6e7e3 --- /dev/null +++ b/frontend/tsconfig.base.json @@ -0,0 +1,24 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../tsconfig.base.json", + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + + "target": "es2017", + "module": "es2020", + + "experimentalDecorators": true, + + "sourceMap": true, + "declaration": false, + "importHelpers": true, + "resolveJsonModule": true + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 03ae35a..8b5e500 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,30 +1,11 @@ -/* To learn more about this file see: https://angular.io/config/tsconfig. */ { - "extends": "../tsconfig.base.json", - "compileOnSave": false, - "compilerOptions": { - "baseUrl": "./", - "outDir": "./out-tsc/", - - "target": "es2017", - "module": "es2020", - - "lib": ["es2020", "dom"], - - "experimentalDecorators": true, - - "sourceMap": true, - "declaration": false, - "downlevelIteration": true, - "importHelpers": true, - "resolveJsonModule": true - }, - "files": ["src/main.ts", "src/polyfills.ts"], - "include": ["src/**/*.d.ts", "src/app/**/*.ts"], - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - } + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.worker.json" + } + ] } diff --git a/frontend/tsconfig.worker.json b/frontend/tsconfig.worker.json new file mode 100644 index 0000000..c769c58 --- /dev/null +++ b/frontend/tsconfig.worker.json @@ -0,0 +1,11 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/worker", + "lib": ["es2020", "webworker"], + + "isolatedModules": false + }, + "include": ["src/app/workers/*.worker.ts"] +} diff --git a/backend/src/models/dto/mimes.dto.ts b/shared/src/dto/mimes.dto.ts similarity index 99% rename from backend/src/models/dto/mimes.dto.ts rename to shared/src/dto/mimes.dto.ts index 588619f..9afd0a4 100644 --- a/backend/src/models/dto/mimes.dto.ts +++ b/shared/src/dto/mimes.dto.ts @@ -30,3 +30,4 @@ export interface FullMime { mime: string; type: SupportedMimeCategory; } + diff --git a/shared/src/util/common-regex.ts b/shared/src/util/common-regex.ts index 45707bc..67e7acb 100644 --- a/shared/src/util/common-regex.ts +++ b/shared/src/util/common-regex.ts @@ -1,3 +1,5 @@ export const AlphaNumeric = /^[a-zA-Z0-9]+$/; export const SHA256 = /^[a-f0-9A-F]{64}$/; export const SemVer = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +export const URLRegex = + /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/; diff --git a/shared/src/util/parse-mime.ts b/shared/src/util/parse-mime.ts new file mode 100644 index 0000000..5783194 --- /dev/null +++ b/shared/src/util/parse-mime.ts @@ -0,0 +1,12 @@ +import { FullMime, SupportedAnimMimes, SupportedImageMimes, SupportedMimeCategory } from '../dto/mimes.dto'; +import { Fail, Failable } from '../types'; + +export function ParseMime(mime: string): Failable { + if (SupportedImageMimes.includes(mime)) { + return { mime, type: SupportedMimeCategory.Image }; + } + if (SupportedAnimMimes.includes(mime)) { + return { mime, type: SupportedMimeCategory.Animation }; + } + return Fail('Unsupported mime type'); +} diff --git a/yarn.lock b/yarn.lock index 8606024..162659a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,6 +2482,13 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bl@^4.0.3, bl@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -3286,9 +3293,9 @@ ee-first@1.1.1: integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= electron-to-chromium@^1.4.84: - version "1.4.109" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.109.tgz#74df3109e37f1feed6124f255f52c96aa62feb7f" - integrity sha512-LCF+Oqs2Oqwf8M3oc8T59Wi9C0xpL1qVyqIR6bPTCl8uPvln7G184L39tO4SE4Dyg/Kp1RjAz//BKMvi0uvw4w== + version "1.4.111" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.111.tgz#897613f6504f3f17c9381c7499a635b413e4df4e" + integrity sha512-/s3+fwhKf1YK4k7btOImOzCQLpUjS6MaPf0ODTNuT4eTM1Bg4itBpLkydhOzJmpmH6Z9eXFyuuK5czsmzRzwtw== emoji-regex@^8.0.0: version "8.0.0" @@ -3933,6 +3940,11 @@ file-type@^17.1.1: strtok3 "^7.0.0-alpha.7" token-types "^5.0.0-alpha.2" +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" @@ -4435,15 +4447,6 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -ico-to-png@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/ico-to-png/-/ico-to-png-0.2.1.tgz#d159356bbd9cc042670cf6eae80f45fcc729fd75" - integrity sha512-wP2Jmsj9ZMxi5fIv3VrcQ9w7vmUu4r6ocfMgeDwoHkzG50sY/LYsZcXEZypaD4FkMdjGQU9klNVzxQMMF6rYBw== - dependencies: - decode-ico "^0.4.0" - lodepng "^2.0.0" - resize-image-data "^0.3.0" - iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -5038,13 +5041,6 @@ lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -lodepng@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/lodepng/-/lodepng-2.1.0.tgz#64e9f09a3566658ea4a2abf7615df77a48b805b2" - integrity sha512-7qzjpeyCG7HOFuwXfQjvGP7/NbZTUaDVoExPSoQgm+15J8gUADQ/8f9BD0C0u7G82QIxc+YGaTBf0Zz9RheyKA== - dependencies: - "@canvas/image-data" "^1.0.0" - log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" @@ -6486,6 +6482,13 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qoi-img@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/qoi-img/-/qoi-img-1.0.1.tgz#fc82aceb1b08362016b48f6a267345670d1841fd" + integrity sha512-30MqNNNnaS2bm1xLAF4ZM2Utz1DEC12OT9qxh1+C57qliGhFUY7MxHtvGXN+Ft9CUwVfLch6OyU6oqzaGL2Ajg== + dependencies: + bindings "^1.5.0" + qs@6.9.7: version "6.9.7" resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe" @@ -6679,13 +6682,6 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= -resize-image-data@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/resize-image-data/-/resize-image-data-0.3.1.tgz#2ad715721b4927c18d07fb657c01844ae7c75a0c" - integrity sha512-6hVRn2S6W1cdycreA6Vth5XRN2NnGs7/RnVpxNw/1OCK8aCoevRFH2WprmQRZDnnH3e6awLv2tTIPuv7/7xeGg== - dependencies: - "@canvas/image-data" "^1.0.0" - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -7745,9 +7741,9 @@ uuid@8.3.2, uuid@^8.3.2: integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== v8-compile-cache-lib@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.0.tgz#0582bcb1c74f3a2ee46487ceecf372e46bce53e8" - integrity sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA== + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== v8-compile-cache@^2.0.3: version "2.3.0"