2023-05-22 20:07:43 +02:00
|
|
|
import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain';
|
2023-07-09 04:43:11 +02:00
|
|
|
import { Logger } from '@nestjs/common';
|
2023-04-04 16:48:02 +02:00
|
|
|
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
2023-06-16 21:54:17 +02:00
|
|
|
import fs from 'fs/promises';
|
2023-02-25 15:12:03 +01:00
|
|
|
import sharp from 'sharp';
|
2023-04-04 16:48:02 +02:00
|
|
|
import { promisify } from 'util';
|
|
|
|
|
|
|
|
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
|
2023-08-02 03:56:10 +02:00
|
|
|
sharp.concurrency(0);
|
2023-02-25 15:12:03 +01:00
|
|
|
|
|
|
|
export class MediaRepository implements IMediaRepository {
|
2023-07-09 04:43:11 +02:00
|
|
|
private logger = new Logger(MediaRepository.name);
|
|
|
|
|
2023-05-17 19:07:17 +02:00
|
|
|
crop(input: string, options: CropOptions): Promise<Buffer> {
|
|
|
|
return sharp(input, { failOnError: false })
|
|
|
|
.extract({
|
|
|
|
left: options.left,
|
|
|
|
top: options.top,
|
|
|
|
width: options.width,
|
|
|
|
height: options.height,
|
|
|
|
})
|
|
|
|
.toBuffer();
|
|
|
|
}
|
|
|
|
|
|
|
|
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
|
2023-02-25 15:12:03 +01:00
|
|
|
switch (options.format) {
|
|
|
|
case 'webp':
|
2023-04-04 03:18:27 +02:00
|
|
|
await sharp(input, { failOnError: false })
|
|
|
|
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
|
|
|
|
.webp()
|
|
|
|
.rotate()
|
|
|
|
.toFile(output);
|
2023-02-25 15:12:03 +01:00
|
|
|
return;
|
|
|
|
|
|
|
|
case 'jpeg':
|
|
|
|
await sharp(input, { failOnError: false })
|
|
|
|
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
|
|
|
|
.jpeg()
|
|
|
|
.rotate()
|
|
|
|
.toFile(output);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-04 03:18:27 +02:00
|
|
|
extractVideoThumbnail(input: string, output: string, size: number) {
|
2023-02-25 15:12:03 +01:00
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
|
|
ffmpeg(input)
|
2023-04-04 03:18:27 +02:00
|
|
|
.outputOptions([
|
|
|
|
'-ss 00:00:00.000',
|
|
|
|
'-frames:v 1',
|
|
|
|
`-vf scale='min(${size},iw)':'min(${size},ih)':force_original_aspect_ratio=increase`,
|
|
|
|
])
|
2023-02-25 15:12:03 +01:00
|
|
|
.output(output)
|
2023-07-09 04:43:11 +02:00
|
|
|
.on('error', (err, stdout, stderr) => {
|
|
|
|
this.logger.error(stderr);
|
|
|
|
reject(err);
|
|
|
|
})
|
2023-02-25 15:12:03 +01:00
|
|
|
.on('end', resolve)
|
|
|
|
.run();
|
|
|
|
});
|
|
|
|
}
|
2023-04-04 16:48:02 +02:00
|
|
|
|
|
|
|
async probe(input: string): Promise<VideoInfo> {
|
|
|
|
const results = await probe(input);
|
|
|
|
|
|
|
|
return {
|
2023-04-06 05:32:59 +02:00
|
|
|
format: {
|
|
|
|
formatName: results.format.format_name,
|
|
|
|
formatLongName: results.format.format_long_name,
|
|
|
|
duration: results.format.duration || 0,
|
|
|
|
},
|
|
|
|
videoStreams: results.streams
|
|
|
|
.filter((stream) => stream.codec_type === 'video')
|
|
|
|
.map((stream) => ({
|
|
|
|
height: stream.height || 0,
|
|
|
|
width: stream.width || 0,
|
2023-08-02 03:56:10 +02:00
|
|
|
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
|
2023-04-06 05:32:59 +02:00
|
|
|
codecType: stream.codec_type,
|
|
|
|
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
|
|
|
|
rotation: Number.parseInt(`${stream.rotation ?? 0}`),
|
|
|
|
})),
|
|
|
|
audioStreams: results.streams
|
|
|
|
.filter((stream) => stream.codec_type === 'audio')
|
|
|
|
.map((stream) => ({
|
|
|
|
codecType: stream.codec_type,
|
|
|
|
codecName: stream.codec_name,
|
|
|
|
})),
|
2023-04-04 16:48:02 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-05-22 20:07:43 +02:00
|
|
|
transcode(input: string, output: string, options: TranscodeOptions): Promise<void> {
|
|
|
|
if (!options.twoPass) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
ffmpeg(input, { niceness: 10 })
|
2023-08-02 03:56:10 +02:00
|
|
|
.inputOptions(options.inputOptions)
|
2023-05-22 20:07:43 +02:00
|
|
|
.outputOptions(options.outputOptions)
|
|
|
|
.output(output)
|
2023-07-09 04:43:11 +02:00
|
|
|
.on('error', (err, stdout, stderr) => {
|
|
|
|
this.logger.error(stderr);
|
|
|
|
reject(err);
|
|
|
|
})
|
2023-05-22 20:07:43 +02:00
|
|
|
.on('end', resolve)
|
|
|
|
.run();
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// two-pass allows for precise control of bitrate at the cost of running twice
|
|
|
|
// recommended for vp9 for better quality and compression
|
2023-04-04 16:48:02 +02:00
|
|
|
return new Promise((resolve, reject) => {
|
2023-04-06 05:32:59 +02:00
|
|
|
ffmpeg(input, { niceness: 10 })
|
2023-08-02 03:56:10 +02:00
|
|
|
.inputOptions(options.inputOptions)
|
2023-05-22 20:07:43 +02:00
|
|
|
.outputOptions(options.outputOptions)
|
|
|
|
.addOptions('-pass', '1')
|
|
|
|
.addOptions('-passlogfile', output)
|
|
|
|
.addOptions('-f null')
|
|
|
|
.output('/dev/null') // first pass output is not saved as only the .log file is needed
|
2023-07-09 04:43:11 +02:00
|
|
|
.on('error', (err, stdout, stderr) => {
|
|
|
|
this.logger.error(stderr);
|
|
|
|
reject(err);
|
|
|
|
})
|
2023-05-22 20:07:43 +02:00
|
|
|
.on('end', () => {
|
|
|
|
// second pass
|
|
|
|
ffmpeg(input, { niceness: 10 })
|
2023-08-02 03:56:10 +02:00
|
|
|
.inputOptions(options.inputOptions)
|
2023-05-22 20:07:43 +02:00
|
|
|
.outputOptions(options.outputOptions)
|
|
|
|
.addOptions('-pass', '2')
|
|
|
|
.addOptions('-passlogfile', output)
|
|
|
|
.output(output)
|
2023-07-09 04:43:11 +02:00
|
|
|
.on('error', (err, stdout, stderr) => {
|
|
|
|
this.logger.error(stderr);
|
|
|
|
reject(err);
|
|
|
|
})
|
2023-05-22 20:07:43 +02:00
|
|
|
.on('end', () => fs.unlink(`${output}-0.log`))
|
2023-05-31 03:52:57 +02:00
|
|
|
.on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true }))
|
2023-05-22 20:07:43 +02:00
|
|
|
.on('end', resolve)
|
|
|
|
.run();
|
|
|
|
})
|
2023-04-04 16:48:02 +02:00
|
|
|
.run();
|
|
|
|
});
|
|
|
|
}
|
2023-06-18 05:22:31 +02:00
|
|
|
|
|
|
|
async generateThumbhash(imagePath: string): Promise<Buffer> {
|
|
|
|
const maxSize = 100;
|
|
|
|
|
|
|
|
const { data, info } = await sharp(imagePath)
|
|
|
|
.resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true })
|
|
|
|
.raw()
|
|
|
|
.ensureAlpha()
|
|
|
|
.toBuffer({ resolveWithObject: true });
|
|
|
|
|
|
|
|
const thumbhash = await import('thumbhash');
|
|
|
|
return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data));
|
|
|
|
}
|
2023-02-25 15:12:03 +01:00
|
|
|
}
|