0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-04 01:09:14 -05:00

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; setAppName(name: string): void;
setContext(message: string): void; setContext(message: string): void;
setLogLevel(level: LogLevel): void; setLogLevel(level: LogLevel): void;
isLevelEnabled(level: LogLevel): boolean;
verbose(message: any, ...args: any): void; verbose(message: any, ...args: any): void;
debug(message: any, ...args: any): void; debug(message: any, ...args: any): void;

View file

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

View file

@ -1,15 +1,16 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { exiftool } from 'exiftool-vendored'; import { exiftool } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import { Duration } from 'luxon';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { promisify } from 'node:util';
import sharp from 'sharp'; import sharp from 'sharp';
import { Colorspace } from 'src/enum'; import { Colorspace, LogLevel } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { import {
IMediaRepository, IMediaRepository,
ImageDimensions, ImageDimensions,
ProbeOptions,
ThumbnailOptions, ThumbnailOptions,
TranscodeCommand, TranscodeCommand,
VideoInfo, VideoInfo,
@ -17,10 +18,22 @@ import {
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
import { handlePromiseError } from 'src/utils/misc'; 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.concurrency(0);
sharp.cache({ files: 0 }); sharp.cache({ files: 0 });
type ProgressEvent = {
frames: number;
currentFps: number;
currentKbps: number;
targetSize: number;
timemark: string;
percent?: number;
};
@Instrumentation() @Instrumentation()
@Injectable() @Injectable()
export class MediaRepository implements IMediaRepository { export class MediaRepository implements IMediaRepository {
@ -65,8 +78,8 @@ export class MediaRepository implements IMediaRepository {
.toFile(output); .toFile(output);
} }
async probe(input: string): Promise<VideoInfo> { async probe(input: string, options?: ProbeOptions): Promise<VideoInfo> {
const results = await probe(input); const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817
return { return {
format: { format: {
formatName: results.format.format_name, formatName: results.format.format_name,
@ -83,10 +96,10 @@ export class MediaRepository implements IMediaRepository {
width: stream.width || 0, width: stream.width || 0,
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
codecType: stream.codec_type, codecType: stream.codec_type,
frameCount: Number.parseInt(stream.nb_frames ?? '0'), frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
rotation: Number.parseInt(`${stream.rotation ?? 0}`), rotation: this.parseInt(stream.rotation),
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', 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 audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio') .filter((stream) => stream.codec_type === 'audio')
@ -94,7 +107,7 @@ export class MediaRepository implements IMediaRepository {
index: stream.index, index: stream.index,
codecType: stream.codec_type, codecType: stream.codec_type,
codecName: stream.codec_name, 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) { private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) {
return ffmpeg(input, { niceness: 10 }) const ffmpegCall = ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions) .inputOptions(options.inputOptions)
.outputOptions(options.outputOptions) .outputOptions(options.outputOptions)
.output(output) .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;
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import {
AudioCodec, AudioCodec,
Colorspace, Colorspace,
ImageFormat, ImageFormat,
LogLevel,
StorageFolder, StorageFolder,
TranscodeHWAccel, TranscodeHWAccel,
TranscodePolicy, TranscodePolicy,
@ -31,7 +32,13 @@ import {
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.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 { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
@ -346,7 +353,9 @@ export class MediaService {
const output = StorageCore.getEncodedVideoPath(asset); const output = StorageCore.getEncodedVideoPath(asset);
this.storageCore.ensureFolders(output); 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 mainVideoStream = this.getMainStream(videoStreams);
const mainAudioStream = this.getMainStream(audioStreams); const mainAudioStream = this.getMainStream(audioStreams);
if (!mainVideoStream || !format.formatName) { 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...`); 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.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } });
await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); 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; return JobStatus.SKIPPED;
} }
let command; let command: TranscodeCommand;
try { try {
const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL()); const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL());
command = config.getCommand(target, mainVideoStream, mainAudioStream); command = config.getCommand(target, mainVideoStream, mainAudioStream);
@ -379,16 +390,20 @@ export class MediaService {
return JobStatus.FAILED; 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 { try {
await this.mediaRepository.transcode(input, output, command); await this.mediaRepository.transcode(input, output, command);
} catch (error) { } catch (error: any) {
this.logger.error(error); this.logger.error(`Error occurred during transcoding: ${error.message}`);
if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) { if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
this.logger.error( return JobStatus.FAILED;
`Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`,
);
} }
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED }); const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream); command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command); await this.mediaRepository.transcode(input, output, command);
@ -555,7 +570,7 @@ export class MediaService {
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch { } 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; this.maliOpenCL = false;
} }
} }

View file

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

View file

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