allow conversion between animated and still

This commit is contained in:
rubikscraft
2022-08-27 17:25:39 +02:00
parent 0a81b3c25d
commit 3762a3b146
17 changed files with 1310 additions and 110 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ yarn-error.log
!.yarn/sdks
!.yarn/versions
.pnp.*
temp

View File

@@ -50,6 +50,8 @@
"rimraf": "^3.0.2",
"rxjs": "^7.5.6",
"sharp": "^0.30.7",
"stream-parser": "^0.3.1",
"thunks": "^4.9.6",
"typeorm": "0.3.7",
"zod": "^3.18.0"
},

View File

@@ -7,6 +7,7 @@ import {
} from 'picsur-shared/dist/dto/mimes.dto';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import { SharpOptions } from 'sharp';
import { SysPreferenceService } from '../../collections/preference-db/sys-preference-db.service';
import { SharpWrapper } from '../../workers/sharp.wrapper';
import { ImageResult } from './imageresult';
@@ -21,14 +22,10 @@ export class ImageConverterService {
targetFiletype: FileType,
options: ImageRequestParams,
): AsyncFailable<ImageResult> {
if (sourceFiletype.category !== sourceFiletype.category) {
return Fail(
FT.Impossible,
"Can't convert from animated to still or vice versa",
);
}
if (sourceFiletype.identifier === targetFiletype.identifier) {
if (
sourceFiletype.identifier === targetFiletype.identifier &&
Object.keys(options).length === 0
) {
return {
filetype: targetFiletype.identifier,
image,
@@ -37,7 +34,9 @@ export class ImageConverterService {
if (targetFiletype.category === SupportedFileTypeCategory.Image) {
return this.convertStill(image, sourceFiletype, targetFiletype, options);
} else if (targetFiletype.category === SupportedFileTypeCategory.Animation) {
} else if (
targetFiletype.category === SupportedFileTypeCategory.Animation
) {
return this.convertStill(image, sourceFiletype, targetFiletype, options);
//return this.convertAnimation(image, targetmime, options);
} else {
@@ -61,7 +60,14 @@ export class ImageConverterService {
const timeLimitMS = ms(timeLimit);
const sharpWrapper = new SharpWrapper(timeLimitMS, memLimit);
const hasStarted = await sharpWrapper.start(image, sourceFiletype);
const sharpOptions: SharpOptions = {
animated: targetFiletype.category === SupportedFileTypeCategory.Animation,
};
const hasStarted = await sharpWrapper.start(
image,
sourceFiletype,
sharpOptions,
);
if (HasFailed(hasStarted)) return hasStarted;
// Do modifications

View File

@@ -41,7 +41,7 @@ export class ImageProcessorService {
image: Buffer,
filetype: FileType,
): AsyncFailable<ImageResult> {
// Apng and gif are stored as is for now
// Webps and gifs are stored as is for now
return {
image: image,
filetype: filetype.identifier,

View File

@@ -3,14 +3,13 @@ import Crypto from 'crypto';
import { fileTypeFromBuffer, FileTypeResult } from 'file-type';
import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto';
import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum';
import { FileType } from 'picsur-shared/dist/dto/mimes.dto';
import { AnimFileType, FileType, ImageFileType, Mime2FileType } from 'picsur-shared/dist/dto/mimes.dto';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import { UsrPreference } from 'picsur-shared/dist/dto/usr-preferences.enum';
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import { FindResult } from 'picsur-shared/dist/types/find-result';
import {
ParseFileType,
ParseMime2FileType
ParseFileType
} from 'picsur-shared/dist/util/parse-mime';
import { IsQOI } from 'qoi-img';
import { ImageDBService } from '../../collections/image-db/image-db.service';
@@ -23,6 +22,7 @@ import { EImageBackend } from '../../models/entities/image.entity';
import { MutexFallBack } from '../../models/util/mutex-fallback';
import { ImageConverterService } from './image-converter.service';
import { ImageProcessorService } from './image-processor.service';
import { WebPInfo } from './webpinfo/webpinfo';
@Injectable()
export class ImageManagerService {
@@ -224,7 +224,21 @@ export class ImageManagerService {
mime = filetypeResult.mime;
}
return ParseMime2FileType(mime ?? 'other/unknown');
if (mime === undefined) mime = "other/unknown";
let filetype: string | undefined;
if (mime === "image/webp") {
const header = await WebPInfo.from(image);
if (header.summary.isAnimated) filetype = AnimFileType.WEBP;
else filetype = ImageFileType.WEBP;
}
if (filetype === undefined) {
const parsed = Mime2FileType(mime);
if (HasFailed(parsed)) return parsed;
filetype = parsed;
}
return ParseFileType(filetype);
}
private getConvertHash(options: object) {

View File

@@ -0,0 +1,53 @@
/*
-- SOURCE: https://github.com/mooyoul/node-webpinfo
-- LICENSE:
The MIT License (MIT)
Copyright © 2018 MooYeol Prescott Lee, http://debug.so <mooyoul@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
import * as Stream from 'stream';
// @ts-ignore
import StreamParser from 'stream-parser';
export interface IStreamParserWritable {
new (...args: any[]): IStreamParserWritableBase;
}
export interface IStreamParserWritableBase {
_bytes(n: number, cb: (buf: Buffer) => void): void;
_skipBytes(n: number, cb: () => void): void;
}
class StreamParserWritableClass extends Stream.Writable {
constructor() {
super();
StreamParser(this);
}
}
// HACK: The "stream-parser" module *patches* prototype of given class on call
// So basically original class does not have any definition about stream-parser injected methods.
// thus that's why we cast type here
export const StreamParserWritable =
StreamParserWritableClass as typeof Stream.Writable & IStreamParserWritable;

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ import {
FT,
HasFailed
} from 'picsur-shared/dist/types';
import { Sharp } from 'sharp';
import { Sharp, SharpOptions } from 'sharp';
import {
SharpWorkerFinishOptions,
SharpWorkerOperation,
@@ -41,7 +41,7 @@ export class SharpWrapper {
private readonly memory_limit: number,
) {}
public async start(image: Buffer, filetype: FileType): AsyncFailable<true> {
public async start(image: Buffer, filetype: FileType, sharpOptions?: SharpOptions): AsyncFailable<true> {
this.worker = fork(SharpWrapper.WORKER_PATH, {
serialization: 'advanced',
timeout: this.instance_timeout,
@@ -80,6 +80,7 @@ export class SharpWrapper {
type: 'init',
image,
filetype,
options: sharpOptions,
});
if (HasFailed(hasSent)) {
this.purge();

View File

@@ -1,5 +1,5 @@
import { FileType } from 'picsur-shared/dist/dto/mimes.dto';
import { Sharp } from 'sharp';
import { Sharp, SharpOptions } from 'sharp';
import { SharpResult } from './universal-sharp';
type MapSharpFunctions<T extends keyof Sharp> = T extends any
@@ -34,6 +34,7 @@ export interface SharpWorkerInitMessage {
type: 'init';
image: Buffer;
filetype: FileType;
options?: SharpOptions;
}
export interface SharpWorkerOperationMessage {

View File

@@ -59,7 +59,7 @@ export class SharpWorker {
}
this.startTime = Date.now();
this.sharpi = UniversalSharpIn(message.image, message.filetype);
this.sharpi = UniversalSharpIn(message.image, message.filetype, message.options);
}
private operation(message: SharpWorkerOperationMessage): void {

View File

@@ -1,5 +1,9 @@
import { BMPdecode, BMPencode } from 'bmp-img';
import { FileType, ImageFileType } from 'picsur-shared/dist/dto/mimes.dto';
import {
AnimFileType,
FileType,
ImageFileType
} from 'picsur-shared/dist/dto/mimes.dto';
import { QOIdecode, QOIencode } from 'qoi-img';
import sharp, { Sharp, SharpOptions } from 'sharp';
@@ -20,11 +24,6 @@ export function UniversalSharpIn(
return bmpSharpIn(image, options);
} else if (filetype.identifier === ImageFileType.QOI) {
return qoiSharpIn(image, options);
// } else if (filetype.identifier === AnimFileType.GIF) {
// return sharp(image, {
// ...options,
// animated: true,
// });
} else {
return sharp(image, options);
}
@@ -95,20 +94,21 @@ export async function UniversalSharpOut(
.tiff({ quality: options?.quality })
.toBuffer({ resolveWithObject: true });
break;
case ImageFileType.WEBP:
result = await image
.webp({ quality: options?.quality })
.toBuffer({ resolveWithObject: true });
break;
case ImageFileType.BMP:
result = await bmpSharpOut(image);
break;
case ImageFileType.QOI:
result = await qoiSharpOut(image);
break;
// case AnimFileType.GIF:
// result = await image.gif().toBuffer({ resolveWithObject: true });
// break;
case ImageFileType.WEBP:
case AnimFileType.WEBP:
result = await image
.webp({ quality: options?.quality })
.toBuffer({ resolveWithObject: true });
break;
case AnimFileType.GIF:
result = await image.gif().toBuffer({ resolveWithObject: true });
break;
default:
throw new Error('Unsupported mime type');
}

View File

@@ -5,7 +5,12 @@
width="0"
#targetcanvas
></canvas>
<img *ngIf="state === 'image'" [src]="imageURL" loading="lazy" />
<img
*ngIf="state === 'image' || state === 'loading'"
[style.display]="state === 'loading' ? 'none' : 'block'"
loading="lazy"
#targetimg
/>
<mat-icon *ngIf="state === 'error'">broken_image</mat-icon>
<mat-spinner

View File

@@ -21,3 +21,10 @@ img {
ngui-inview {
min-height: 1px;
}
mat-icon {
margin: 1rem;
display: block;
text-align: center;
font-size: xxx-large;
}

View File

@@ -33,6 +33,7 @@ export class PicsurImgComponent implements OnChanges {
private readonly logger = new Logger('ZodImgComponent');
@ViewChild('targetcanvas') private canvas: ElementRef<HTMLCanvasElement>;
@ViewChild('targetimg') private img: ElementRef<HTMLImageElement>;
private isInView = false;
@@ -63,6 +64,7 @@ export class PicsurImgComponent implements OnChanges {
if (HasFailed(result)) {
this.state = PicsurImgState.Error;
this.logger.error(result.getReason());
this.changeDetector.markForCheck();
}
})
.catch((e) => this.logger.error);
@@ -83,6 +85,16 @@ export class PicsurImgComponent implements OnChanges {
this.state = PicsurImgState.Canvas;
} else {
const result = await this.apiService.getBuffer(url);
if (HasFailed(result)) return result;
const img = this.img.nativeElement;
const blob = new Blob([result.buffer]);
img.src = URL.createObjectURL(blob);
this.state = PicsurImgState.Image;
}
this.changeDetector.markForCheck();

View File

@@ -5,10 +5,8 @@ import {
AnimFileType,
FileType,
FileType2Ext,
ImageFileType,
SupportedAnimFileTypes,
SupportedFileTypeCategory,
SupportedImageFileTypes
ImageFileType, SupportedFileTypeCategory,
SupportedFileTypes
} from 'picsur-shared/dist/dto/mimes.dto';
import { EImage } from 'picsur-shared/dist/entities/image.entity';
@@ -160,31 +158,16 @@ export class ViewComponent implements OnInit {
key: string;
}[] = [];
if (this.masterFileType.category === SupportedFileTypeCategory.Image) {
newOptions.push(
...SupportedImageFileTypes.map((mime) => {
let ext = FileType2Ext(mime);
if (HasFailed(ext)) ext = 'Error';
return {
value: ext.toUpperCase(),
key: mime,
};
}),
);
} else if (
this.masterFileType.category === SupportedFileTypeCategory.Animation
) {
newOptions.push(
...SupportedAnimFileTypes.map((mime) => {
let ext = FileType2Ext(mime);
if (HasFailed(ext)) ext = 'Error';
return {
value: ext.toUpperCase(),
key: mime,
};
}),
);
}
newOptions.push(
...SupportedFileTypes.map((mime) => {
let ext = FileType2Ext(mime);
if (HasFailed(ext)) ext = 'Error';
return {
value: ext.toUpperCase(),
key: mime,
};
}),
);
return newOptions;
}

View File

@@ -38,6 +38,38 @@ export interface FileType {
// Converters
// -- Mime
const FileType2MimeMap: {
[key in ImageFileType | AnimFileType]: string;
} = {
[AnimFileType.GIF]: 'image/gif',
[AnimFileType.WEBP]: 'image/webp',
// [AnimFileType.APNG]: 'image/apng',
[ImageFileType.QOI]: 'image/x-qoi',
[ImageFileType.JPEG]: 'image/jpeg',
[ImageFileType.PNG]: 'image/png',
[ImageFileType.WEBP]: 'image/webp', // Image webp comes later, so will be default
[ImageFileType.TIFF]: 'image/tiff',
[ImageFileType.BMP]: 'image/bmp',
// [ImageFileType.ICO]: 'image/x-icon',
};
export const Mime2FileType = (mime: string): Failable<string> => {
const entries = Object.entries(FileType2MimeMap).filter(
([k, v]) => v === mime,
);
if (entries.length === 0)
return Fail(FT.Internal, undefined, `Unsupported mime type: ${mime}`);
return entries[0][0];
};
export const FileType2Mime = (filetype: string): Failable<string> => {
const result = FileType2MimeMap[filetype as ImageFileType | AnimFileType];
if (result === undefined)
return Fail(FT.Internal, undefined, `Unsupported filetype: ${filetype}`);
return result;
};
// -- Ext
const FileType2ExtMap: {
@@ -55,9 +87,12 @@ const FileType2ExtMap: {
// [ImageFileType.ICO]: 'ico',
};
const Ext2FileTypeMap: {
[key: string]: string;
} = Object.fromEntries(Object.entries(FileType2ExtMap).map(([k, v]) => [v, k]));
export const Ext2FileType = (ext: string): Failable<string> => {
const entries = Object.entries(FileType2ExtMap).filter(([k, v]) => v === ext);
if (entries.length === 0)
return Fail(FT.Internal, undefined, `Unsupported ext: ${ext}`);
return entries[0][0];
};
export const FileType2Ext = (mime: string): Failable<string> => {
const result = FileType2ExtMap[mime as ImageFileType | AnimFileType];
@@ -65,46 +100,3 @@ export const FileType2Ext = (mime: string): Failable<string> => {
return Fail(FT.Internal, undefined, `Unsupported mime type: ${mime}`);
return result;
};
export const Ext2FileType = (ext: string): Failable<string> => {
const result = Ext2FileTypeMap[ext];
if (result === undefined)
return Fail(FT.Internal, undefined, `Unsupported ext: ${ext}`);
return result;
};
// -- Mime
const FileType2MimeMap: {
[key in ImageFileType | AnimFileType]: string;
} = {
[AnimFileType.GIF]: 'image/gif',
[AnimFileType.WEBP]: 'image/webp',
// [AnimFileType.APNG]: 'image/apng',
[ImageFileType.QOI]: 'image/x-qoi',
[ImageFileType.JPEG]: 'image/jpeg',
[ImageFileType.PNG]: 'image/png',
[ImageFileType.WEBP]: 'image/webp',
[ImageFileType.TIFF]: 'image/tiff',
[ImageFileType.BMP]: 'image/bmp',
// [ImageFileType.ICO]: 'image/x-icon',
};
const Mime2FileTypeMap: {
[key: string]: string;
} = Object.fromEntries(
Object.entries(FileType2MimeMap).map(([k, v]) => [v, k]),
);
export const Mime2FileType = (mime: string): Failable<string> => {
const result = Mime2FileTypeMap[mime as ImageFileType | AnimFileType];
if (result === undefined)
return Fail(FT.Internal, undefined, `Unsupported mime type: ${mime}`);
return result;
};
export const FileType2Mime = (filetype: string): Failable<string> => {
const result = FileType2MimeMap[filetype as ImageFileType | AnimFileType];
if (result === undefined)
return Fail(FT.Internal, undefined, `Unsupported filetype: ${filetype}`);
return result;
};

View File

@@ -4895,7 +4895,7 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:2.6.9":
"debug@npm:2, debug@npm:2.6.9":
version: 2.6.9
resolution: "debug@npm:2.6.9"
dependencies:
@@ -8929,6 +8929,8 @@ __metadata:
rxjs: ^7.5.6
sharp: ^0.30.7
source-map-support: ^0.5.21
stream-parser: ^0.3.1
thunks: ^4.9.6
ts-loader: ^9.3.1
ts-node: ^10.9.1
tsconfig-paths: ^4.1.0
@@ -10811,6 +10813,15 @@ __metadata:
languageName: node
linkType: hard
"stream-parser@npm:^0.3.1":
version: 0.3.1
resolution: "stream-parser@npm:0.3.1"
dependencies:
debug: 2
checksum: 4d86ff8cffe7c7587dc91433fff9dce38a93ea7e9f47560055addc81eae6b6befab22b75643ce539faf325fe2b17d371778242566bed086e75f6cffb1e76c06c
languageName: node
linkType: hard
"stream-wormhole@npm:^1.1.0":
version: 1.1.0
resolution: "stream-wormhole@npm:1.1.0"
@@ -11136,6 +11147,13 @@ __metadata:
languageName: node
linkType: hard
"thunks@npm:^4.9.6":
version: 4.9.6
resolution: "thunks@npm:4.9.6"
checksum: 116b46dfd9426de6d3832e56486bfea55b35a240d2eeaa411fa765f16f93c0243a8d05a9c4fe5d40018b29c5bb84eec09d60cc6cd213724647a254332fdaaf12
languageName: node
linkType: hard
"thunky@npm:^1.0.2":
version: 1.1.0
resolution: "thunky@npm:1.1.0"