Add frontend qoi rendering

This commit is contained in:
rubikscraft 2022-04-16 16:35:28 +02:00
parent b778b86eae
commit cadb909c40
No known key found for this signature in database
GPG Key ID: 1463EBE9200A5CD4
32 changed files with 842 additions and 124 deletions

View File

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

View File

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

View File

@ -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<FullMime> {
if (SupportedImageMimes.includes(mime)) {
return { mime, type: SupportedMimeCategory.Image };
}
if (SupportedAnimMimes.includes(mime)) {
return { mime, type: SupportedMimeCategory.Animation };
}
return Fail('Unsupported mime type');
}
}

View File

@ -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<EImageBackend> {
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<FullMime> {
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;
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
<canvas
*ngIf="state === 'canvas' || state === 'loading'"
[style.display]="state === 'loading' ? 'none' : 'unset'"
height="0"
width="0"
#targetcanvas
></canvas>
<img *ngIf="state === 'image'" [src]="imageURL" />
<mat-spinner *ngIf="state === 'loading'" color="accent"></mat-spinner>

View File

@ -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%;
}

View File

@ -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<HTMLCanvasElement>;
@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;
}
}

View File

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

View File

@ -6,11 +6,14 @@
</div>
<div class="col-12 py-3">
<img class="uploadedimage" src="{{ imageUrl }}" als="Uploaded" />
<picsur-img
class="uploadedimage"
[src]="imageLinks.source"
></picsur-img>
</div>
<div class="col-12">
<copyfield label="Image URL" [value]="imageUrl"></copyfield>
<copyfield label="Image URL" [value]="imageLinks.source"></copyfield>
</div>
<div class="col-md-6 col-12">
<copyfield label="Markdown" [value]="imageLinks.markdown"></copyfield>
@ -25,7 +28,21 @@
<copyfield label="Rst" [value]="imageLinks.rst"></copyfield>
</div>
<div class="col-12">
<button mat-raised-button color="accent" (click)="goBackHome()">
<button
mat-raised-button
class="mx-1"
color="accent"
(click)="downloadImage()"
>
Download
</button>
<button
mat-raised-button
class="mx-1"
color="primary"
(click)="goBackHome()"
>
Upload Another
</button>
</div>

View File

@ -6,5 +6,3 @@
.content-border {
padding: 1rem;
}

View File

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

View File

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

View File

@ -41,4 +41,8 @@ export class ImageService {
bbcode: `[img]${imageURL}[/img]`,
};
}
public CreateImageLinksFromID(imageID: string): ImageLinks {
return this.CreateImageLinks(this.GetImageURL(imageID));
}
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
# QOI.js
I included it directly because it needed to be typescript compatible
<https://github.com/kchapelier/qoijs>

View File

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

View File

@ -0,0 +1,46 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class QoiWorkerService {
private worker: Worker | null = null;
private job: Promise<Function> | 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');
}
}
}

View File

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

View File

@ -0,0 +1,17 @@
/// <reference lib="webworker" />
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,
});
});

View File

@ -47,7 +47,6 @@
*/
import 'reflect-metadata';
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

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

View File

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

View File

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

View File

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

View File

@ -30,3 +30,4 @@ export interface FullMime {
mime: string;
type: SupportedMimeCategory;
}

View File

@ -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]*))?)/;

View File

@ -0,0 +1,12 @@
import { FullMime, SupportedAnimMimes, SupportedImageMimes, SupportedMimeCategory } from '../dto/mimes.dto';
import { Fail, Failable } from '../types';
export function ParseMime(mime: string): Failable<FullMime> {
if (SupportedImageMimes.includes(mime)) {
return { mime, type: SupportedMimeCategory.Image };
}
if (SupportedAnimMimes.includes(mime)) {
return { mime, type: SupportedMimeCategory.Animation };
}
return Fail('Unsupported mime type');
}

View File

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