feat(server): wide gamut thumbnails (#3658)

This commit is contained in:
Mert
2023-09-03 02:21:51 -04:00
committed by GitHub
parent 4bd77d5899
commit 2069293cc1
27 changed files with 405 additions and 61 deletions

View File

@@ -76,6 +76,8 @@ export enum SystemConfigKey {
THUMBNAIL_WEBP_SIZE = 'thumbnail.webpSize',
THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize',
THUMBNAIL_QUALITY = 'thumbnail.quality',
THUMBNAIL_COLORSPACE = 'thumbnail.colorspace',
}
export enum TranscodePolicy {
@@ -117,6 +119,11 @@ export enum CQMode {
ICQ = 'icq',
}
export enum Colorspace {
SRGB = 'srgb',
P3 = 'p3',
}
export interface SystemConfig {
ffmpeg: {
crf: number;
@@ -179,5 +186,7 @@ export interface SystemConfig {
thumbnail: {
webpSize: number;
jpegSize: number;
quality: number;
colorspace: Colorspace;
};
}

View File

@@ -1,8 +1,10 @@
import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain';
import { Colorspace } from '@app/infra/entities';
import { Logger } from '@nestjs/common';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import fs from 'fs/promises';
import sharp from 'sharp';
import { Writable } from 'stream';
import { promisify } from 'util';
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
@@ -11,7 +13,7 @@ sharp.concurrency(0);
export class MediaRepository implements IMediaRepository {
private logger = new Logger(MediaRepository.name);
crop(input: string, options: CropOptions): Promise<Buffer> {
crop(input: string | Buffer, options: CropOptions): Promise<Buffer> {
return sharp(input, { failOn: 'none' })
.extract({
left: options.left,
@@ -23,10 +25,25 @@ export class MediaRepository implements IMediaRepository {
}
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
await sharp(input, { failOn: 'none' })
let colorProfile = options.colorspace;
if (options.colorspace !== Colorspace.SRGB) {
try {
const { space } = await sharp(input).metadata();
// if the image is already in srgb, keep it that way
if (space === 'srgb') {
colorProfile = Colorspace.SRGB;
}
} catch (err) {
this.logger.warn(`Could not determine colorspace of image, defaulting to ${colorProfile} profile`);
}
}
const chromaSubsampling = options.quality >= 80 ? '4:4:4' : '4:2:0'; // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
sharp(input, { failOn: 'none' })
.pipelineColorspace('rgb16')
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.rotate()
.toFormat(options.format)
.withMetadata({ icc: colorProfile })
.toFormat(options.format, { quality: options.quality, chromaSubsampling })
.toFile(output);
}
@@ -61,7 +78,7 @@ export class MediaRepository implements IMediaRepository {
};
}
transcode(input: string, output: string, options: TranscodeOptions): Promise<void> {
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void> {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
ffmpeg(input, { niceness: 10 })
@@ -77,6 +94,10 @@ export class MediaRepository implements IMediaRepository {
});
}
if (typeof output !== 'string') {
throw new Error('Two-pass transcoding does not support writing to a stream');
}
// two-pass allows for precise control of bitrate at the cost of running twice
// recommended for vp9 for better quality and compression
return new Promise((resolve, reject) => {