feat(server): better transcoding logs (#13000)

* better transcoding logs

* pr feedback
This commit is contained in:
Mert 2024-09-27 18:10:39 -04:00 committed by GitHub
parent 7579bc4359
commit 4248594ac5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 308 additions and 210 deletions

View File

@ -6,6 +6,7 @@ export interface ILoggerRepository {
setAppName(name: string): void;
setContext(message: string): void;
setLogLevel(level: LogLevel): void;
isLevelEnabled(level: LogLevel): boolean;
verbose(message: any, ...args: any): void;
debug(message: any, ...args: any): void;

View File

@ -62,6 +62,10 @@ export interface TranscodeCommand {
inputOptions: string[];
outputOptions: string[];
twoPass: boolean;
progress: {
frameCount: number;
percentInterval: number;
};
}
export interface BitrateDistribution {
@ -79,6 +83,10 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig {
getSupportedCodecs(): Array<VideoCodec>;
}
export interface ProbeOptions {
countFrames: boolean;
}
export interface IMediaRepository {
// image
extract(input: string, output: string): Promise<boolean>;
@ -87,6 +95,6 @@ export interface IMediaRepository {
getImageDimensions(input: string): Promise<ImageDimensions>;
// video
probe(input: string): Promise<VideoInfo>;
probe(input: string, options?: ProbeOptions): Promise<VideoInfo>;
transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise<void>;
}

View File

@ -1,15 +1,16 @@
import { Inject, Injectable } from '@nestjs/common';
import { exiftool } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import { Duration } from 'luxon';
import fs from 'node:fs/promises';
import { Writable } from 'node:stream';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { Colorspace } from 'src/enum';
import { Colorspace, LogLevel } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
IMediaRepository,
ImageDimensions,
ProbeOptions,
ThumbnailOptions,
TranscodeCommand,
VideoInfo,
@ -17,10 +18,22 @@ import {
import { Instrumentation } from 'src/utils/instrumentation';
import { handlePromiseError } from 'src/utils/misc';
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
const probe = (input: string, options: string[]): Promise<FfprobeData> =>
new Promise((resolve, reject) =>
ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))),
);
sharp.concurrency(0);
sharp.cache({ files: 0 });
type ProgressEvent = {
frames: number;
currentFps: number;
currentKbps: number;
targetSize: number;
timemark: string;
percent?: number;
};
@Instrumentation()
@Injectable()
export class MediaRepository implements IMediaRepository {
@ -65,8 +78,8 @@ export class MediaRepository implements IMediaRepository {
.toFile(output);
}
async probe(input: string): Promise<VideoInfo> {
const results = await probe(input);
async probe(input: string, options?: ProbeOptions): Promise<VideoInfo> {
const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817
return {
format: {
formatName: results.format.format_name,
@ -83,10 +96,10 @@ export class MediaRepository implements IMediaRepository {
width: stream.width || 0,
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
codecType: stream.codec_type,
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
rotation: Number.parseInt(`${stream.rotation ?? 0}`),
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
rotation: this.parseInt(stream.rotation),
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
bitrate: Number.parseInt(stream.bit_rate ?? '0'),
bitrate: this.parseInt(stream.bit_rate),
})),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')
@ -94,7 +107,7 @@ export class MediaRepository implements IMediaRepository {
index: stream.index,
codecType: stream.codec_type,
codecName: stream.codec_name,
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
})),
};
}
@ -156,10 +169,37 @@ export class MediaRepository implements IMediaRepository {
}
private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) {
return ffmpeg(input, { niceness: 10 })
const ffmpegCall = ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions)
.output(output)
.on('error', (error, stdout, stderr) => this.logger.error(stderr || error));
.on('start', (command: string) => this.logger.debug(command))
.on('error', (error, _, stderr) => this.logger.error(stderr || error));
const { frameCount, percentInterval } = options.progress;
const frameInterval = Math.ceil(frameCount / (100 / percentInterval));
if (this.logger.isLevelEnabled(LogLevel.DEBUG) && frameCount && frameInterval) {
let lastProgressFrame: number = 0;
ffmpegCall.on('progress', (progress: ProgressEvent) => {
if (progress.frames - lastProgressFrame < frameInterval) {
return;
}
lastProgressFrame = progress.frames;
const percent = ((progress.frames / frameCount) * 100).toFixed(2);
const ms = Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000;
const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : '';
const outputText = output instanceof Writable ? 'stream' : output.split('/').pop();
this.logger.debug(
`Transcoding ${percent}% done${duration ? `, estimated ${duration} remaining` : ''} for output ${outputText}`,
);
});
}
return ffmpegCall;
}
private parseInt(value: string | number | undefined): number {
return Number.parseInt(value as string) || 0;
}
}

View File

@ -349,7 +349,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
expect.objectContaining({
inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
'-fps_mode vfr',
@ -359,7 +359,7 @@ describe(MediaService.name, () => {
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p`,
],
twoPass: false,
},
}),
);
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
@ -377,7 +377,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
expect.objectContaining({
inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
'-fps_mode vfr',
@ -387,7 +387,7 @@ describe(MediaService.name, () => {
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`,
],
twoPass: false,
},
}),
);
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
@ -407,7 +407,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
expect.objectContaining({
inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
'-fps_mode vfr',
@ -417,7 +417,7 @@ describe(MediaService.name, () => {
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`,
],
twoPass: false,
},
}),
);
});
@ -430,11 +430,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringContaining('scale=-2:1440')]),
twoPass: false,
},
}),
);
});
@ -731,21 +731,22 @@ describe(MediaService.name, () => {
it('should transcode the longest stream', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.video]);
loggerMock.isLevelEnabled.mockReturnValue(false);
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext');
expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false });
expect(systemMock.get).toHaveBeenCalled();
expect(storageMock.mkdirSync).toHaveBeenCalled();
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-map 0:0', '-map 0:1']),
twoPass: false,
},
}),
);
});
@ -771,11 +772,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
twoPass: false,
},
}),
);
});
@ -786,11 +787,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
twoPass: false,
},
}),
);
});
@ -801,11 +802,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
twoPass: false,
},
}),
);
});
@ -816,11 +817,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('scale')]),
twoPass: false,
},
}),
);
});
@ -832,11 +833,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:720/)]),
twoPass: false,
},
}),
);
});
@ -848,11 +849,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=720:-2/)]),
twoPass: false,
},
}),
);
});
@ -864,11 +865,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:354/)]),
twoPass: false,
},
}),
);
});
@ -880,11 +881,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=354:-2/)]),
twoPass: false,
},
}),
);
});
@ -898,11 +899,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v copy', '-c:a aac']),
twoPass: false,
},
}),
);
});
@ -920,11 +921,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining(['-tag:v hvc1']),
twoPass: false,
},
}),
);
});
@ -942,11 +943,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v copy', '-tag:v hvc1']),
twoPass: false,
},
}),
);
});
@ -958,11 +959,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']),
twoPass: false,
},
}),
);
});
@ -973,11 +974,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']),
twoPass: false,
},
}),
);
});
@ -1036,11 +1037,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']),
twoPass: false,
},
}),
);
});
@ -1052,11 +1053,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']),
twoPass: true,
},
}),
);
});
@ -1068,11 +1069,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']),
twoPass: false,
},
}),
);
});
@ -1090,11 +1091,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']),
twoPass: true,
},
}),
);
});
@ -1112,11 +1113,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-maxrate')]),
twoPass: true,
},
}),
);
});
@ -1128,11 +1129,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-cpu-used 2']),
twoPass: false,
},
}),
);
});
@ -1144,11 +1145,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-cpu-used')]),
twoPass: false,
},
}),
);
});
@ -1160,11 +1161,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-threads 2']),
twoPass: false,
},
}),
);
});
@ -1176,11 +1177,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-threads 1', '-x264-params frame-threads=1:pools=none']),
twoPass: false,
},
}),
);
});
@ -1192,11 +1193,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]),
twoPass: false,
},
}),
);
});
@ -1208,11 +1209,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v hevc', '-threads 1', '-x265-params frame-threads=1:pools=none']),
twoPass: false,
},
}),
);
});
@ -1224,11 +1225,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]),
twoPass: false,
},
}),
);
});
@ -1240,7 +1241,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
'-c:v av1',
@ -1255,7 +1256,7 @@ describe(MediaService.name, () => {
'-crf 23',
]),
twoPass: false,
},
}),
);
});
@ -1267,11 +1268,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-preset 4']),
twoPass: false,
},
}),
);
});
@ -1283,11 +1284,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-svtav1-params mbr=2M']),
twoPass: false,
},
}),
);
});
@ -1299,11 +1300,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-svtav1-params lp=4']),
twoPass: false,
},
}),
);
});
@ -1315,11 +1316,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-svtav1-params lp=4:mbr=2M']),
twoPass: false,
},
}),
);
});
@ -1361,7 +1362,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.arrayContaining([
'-tune hq',
@ -1382,7 +1383,7 @@ describe(MediaService.name, () => {
'-cq:v 23',
]),
twoPass: false,
},
}),
);
});
@ -1400,11 +1401,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]),
twoPass: false,
},
}),
);
});
@ -1416,11 +1417,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.arrayContaining(['-cq:v 23', '-maxrate 10000k', '-bufsize 6897k']),
twoPass: false,
},
}),
);
});
@ -1432,11 +1433,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.not.stringContaining('-maxrate'),
twoPass: false,
},
}),
);
});
@ -1448,11 +1449,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]),
twoPass: false,
},
}),
);
});
@ -1464,11 +1465,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]),
twoPass: false,
},
}),
);
});
@ -1482,7 +1483,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel cuda',
'-hwaccel_output_format cuda',
@ -1491,7 +1492,7 @@ describe(MediaService.name, () => {
]),
outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]),
twoPass: false,
},
}),
);
});
@ -1505,7 +1506,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']),
outputOptions: expect.arrayContaining([
expect.stringContaining(
@ -1513,7 +1514,7 @@ describe(MediaService.name, () => {
),
]),
twoPass: false,
},
}),
);
});
@ -1526,7 +1527,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']),
outputOptions: expect.arrayContaining([
`-c:v h264_qsv`,
@ -1547,7 +1548,7 @@ describe(MediaService.name, () => {
'-bufsize 20000k',
]),
twoPass: false,
},
}),
);
});
@ -1566,14 +1567,14 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device qsv=hw,child_device=/dev/dri/renderD128',
'-filter_hw_device hw',
]),
outputOptions: expect.any(Array),
twoPass: false,
},
}),
);
});
@ -1586,11 +1587,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]),
twoPass: false,
},
}),
);
});
@ -1603,11 +1604,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']),
outputOptions: expect.arrayContaining(['-low_power 1']),
twoPass: false,
},
}),
);
});
@ -1633,7 +1634,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel qsv',
'-hwaccel_output_format qsv',
@ -1645,7 +1646,7 @@ describe(MediaService.name, () => {
expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'),
]),
twoPass: false,
},
}),
);
});
@ -1662,7 +1663,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel qsv',
'-hwaccel_output_format qsv',
@ -1675,7 +1676,7 @@ describe(MediaService.name, () => {
),
]),
twoPass: false,
},
}),
);
});
@ -1691,11 +1692,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']),
outputOptions: expect.any(Array),
twoPass: false,
},
}),
);
});
@ -1708,7 +1709,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device accel',
@ -1728,7 +1729,7 @@ describe(MediaService.name, () => {
'-rc_mode 1',
]),
twoPass: false,
},
}),
);
});
@ -1741,7 +1742,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device accel',
@ -1754,7 +1755,7 @@ describe(MediaService.name, () => {
'-rc_mode 3',
]),
twoPass: false,
},
}),
);
});
@ -1767,7 +1768,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device accel',
@ -1780,7 +1781,7 @@ describe(MediaService.name, () => {
'-rc_mode 1',
]),
twoPass: false,
},
}),
);
});
@ -1793,14 +1794,14 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device accel',
]),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-compression_level')]),
twoPass: false,
},
}),
);
});
@ -1813,14 +1814,14 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/card1',
'-filter_hw_device accel',
]),
outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]),
twoPass: false,
},
}),
);
});
@ -1833,14 +1834,14 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD130',
'-filter_hw_device accel',
]),
outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]),
twoPass: false,
},
}),
);
});
@ -1855,14 +1856,14 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device accel',
]),
outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]),
twoPass: false,
},
}),
);
});
@ -1877,11 +1878,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenLastCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264']),
twoPass: false,
},
}),
);
});
@ -1904,7 +1905,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel rkmpp',
'-hwaccel_output_format drm_prime',
@ -1927,7 +1928,7 @@ describe(MediaService.name, () => {
'-qp_init 23',
]),
twoPass: false,
},
}),
);
});
@ -1948,11 +1949,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([`-c:v hevc_rkmpp`, '-level 153', '-rc_mode AVBR', '-b:v 10000k']),
twoPass: false,
},
}),
);
});
@ -1968,11 +1969,11 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([`-c:v h264_rkmpp`, '-level 51', '-rc_mode CQP', '-qp_init 30']),
twoPass: false,
},
}),
);
});
@ -1988,7 +1989,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([
expect.stringContaining(
@ -1996,7 +1997,7 @@ describe(MediaService.name, () => {
),
]),
twoPass: false,
},
}),
);
});
@ -2012,7 +2013,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: [],
outputOptions: expect.arrayContaining([
expect.stringContaining(
@ -2020,7 +2021,7 @@ describe(MediaService.name, () => {
),
]),
twoPass: false,
},
}),
);
});
@ -2036,7 +2037,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: [],
outputOptions: expect.arrayContaining([
expect.stringContaining(
@ -2044,10 +2045,9 @@ describe(MediaService.name, () => {
),
]),
twoPass: false,
},
}),
);
});
});
it('should tonemap when policy is required and video is hdr', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
@ -2057,7 +2057,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
'-c:v h264',
@ -2065,7 +2065,7 @@ describe(MediaService.name, () => {
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p',
]),
twoPass: false,
},
}),
);
});
@ -2077,7 +2077,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
'-c:v h264',
@ -2085,7 +2085,7 @@ describe(MediaService.name, () => {
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p',
]),
twoPass: false,
},
}),
);
});
@ -2097,7 +2097,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
'-c:v h264',
@ -2105,10 +2105,43 @@ describe(MediaService.name, () => {
'-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p',
]),
twoPass: false,
}),
);
});
it('should count frames for progress when log level is debug', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
loggerMock.isLevelEnabled.mockReturnValue(true);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true });
expect(mediaMock.transcode).toHaveBeenCalledWith(
assetStub.video.originalPath,
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
twoPass: false,
progress: {
frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount,
percentInterval: expect.any(Number),
},
},
);
});
it('should not count frames for progress when log level is not debug', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
loggerMock.isLevelEnabled.mockReturnValue(false);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false });
});
});
describe('isSRGB', () => {
it('should return true for srgb colorspace', () => {
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as ExifEntity };

View File

@ -11,6 +11,7 @@ import {
AudioCodec,
Colorspace,
ImageFormat,
LogLevel,
StorageFolder,
TranscodeHWAccel,
TranscodePolicy,
@ -31,7 +32,13 @@ import {
QueueName,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface';
import {
AudioStreamInfo,
IMediaRepository,
TranscodeCommand,
VideoFormat,
VideoStreamInfo,
} from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
@ -346,7 +353,9 @@ export class MediaService {
const output = StorageCore.getEncodedVideoPath(asset);
this.storageCore.ensureFolders(output);
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs
});
const mainVideoStream = this.getMainStream(videoStreams);
const mainAudioStream = this.getMainStream(audioStreams);
if (!mainVideoStream || !format.formatName) {
@ -365,12 +374,14 @@ export class MediaService {
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } });
await this.assetRepository.update({ id: asset.id, encodedVideoPath: null });
} else {
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
}
return JobStatus.SKIPPED;
}
let command;
let command: TranscodeCommand;
try {
const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL());
command = config.getCommand(target, mainVideoStream, mainAudioStream);
@ -379,16 +390,20 @@ export class MediaService {
return JobStatus.FAILED;
}
this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`);
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
this.logger.log(`Encoding video ${asset.id} without hardware acceleration`);
} else {
this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`);
}
try {
await this.mediaRepository.transcode(input, output, command);
} catch (error) {
this.logger.error(error);
if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) {
this.logger.error(
`Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`,
);
} catch (error: any) {
this.logger.error(`Error occurred during transcoding: ${error.message}`);
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
return JobStatus.FAILED;
}
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command);
@ -555,7 +570,7 @@ export class MediaService {
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch {
this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.');
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding');
this.maliOpenCL = false;
}
}

View File

@ -80,6 +80,7 @@ export class BaseConfig implements VideoCodecSWConfig {
inputOptions: this.getBaseInputOptions(videoStream),
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
twoPass: this.eligibleForTwoPass(),
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
} as TranscodeCommand;
if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) {
const filters = this.getFilterOptions(videoStream);

View File

@ -6,7 +6,7 @@ export const newLoggerRepositoryMock = (): Mocked<ILoggerRepository> => {
setLogLevel: vitest.fn(),
setContext: vitest.fn(),
setAppName: vitest.fn(),
isLevelEnabled: vitest.fn(),
verbose: vitest.fn(),
debug: vitest.fn(),
log: vitest.fn(),