refactor(server): modularize getFfmpegOptions (#3138)

* refactored `getFfmpegOptions`

refactor transcoding, make separate service

* fixed enum casing

* use `Logger` instead of `console.log`

* review suggestions

* use enum for `getHandler`

* fixed formatting

* Update server/src/domain/media/media.util.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Update server/src/domain/media/media.util.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* More specific imports, renamed codec classes

* simplified code

* removed unused import

* added tests

* added base implementation for bitrate and threads

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Mert
2023-07-08 22:43:11 -04:00
committed by GitHub
parent 27018e4ab6
commit 8349a28ed8
33 changed files with 1131 additions and 345 deletions

View File

@@ -1,5 +1,5 @@
import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { AssetEntity, AssetType, TranscodePolicy, VideoCodec } from '@app/infra/entities';
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
import { join } from 'path';
import { IAssetRepository, WithoutProperty } from '../asset';
import { usePagination } from '../domain.util';
@@ -9,6 +9,7 @@ import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config
import { SystemConfigCore } from '../system-config/system-config.core';
import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant';
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository';
import { H264Config, HEVCConfig, VP9Config } from './media.util';
@Injectable()
export class MediaService {
@@ -82,7 +83,7 @@ export class MediaService {
return true;
}
async handleGenerateWepbThumbnail({ id }: IEntityJob) {
async handleGenerateWebpThumbnail({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || !asset.resizePath) {
return false;
@@ -152,11 +153,16 @@ export class MediaService {
return false;
}
const outputOptions = this.getFfmpegOptions(mainVideoStream, config);
const twoPass = this.eligibleForTwoPass(config);
let transcodeOptions;
try {
transcodeOptions = this.getCodecConfig(config).getOptions(mainVideoStream);
} catch (err) {
this.logger.error(`An error occurred while configuring transcoding options: ${err}`);
return false;
}
this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`);
await this.mediaRepository.transcode(input, output, { outputOptions, twoPass });
this.logger.log(`Start encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`);
await this.mediaRepository.transcode(input, output, transcodeOptions);
this.logger.log(`Encoding success ${asset.id}`);
@@ -199,16 +205,16 @@ export class MediaService {
const isLargerThanTargetRes = scalingEnabled && Math.min(videoStream.height, videoStream.width) > targetRes;
switch (ffmpegConfig.transcode) {
case TranscodePreset.DISABLED:
case TranscodePolicy.DISABLED:
return false;
case TranscodePreset.ALL:
case TranscodePolicy.ALL:
return true;
case TranscodePreset.REQUIRED:
case TranscodePolicy.REQUIRED:
return !allTargetsMatching;
case TranscodePreset.OPTIMAL:
case TranscodePolicy.OPTIMAL:
return !allTargetsMatching || isLargerThanTargetRes;
default:
@@ -216,99 +222,16 @@ export class MediaService {
}
}
private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) {
const options = [
`-vcodec ${ffmpeg.targetVideoCodec}`,
`-acodec ${ffmpeg.targetAudioCodec}`,
// Makes a second pass moving the moov atom to the beginning of
// the file for improved playback speed.
'-movflags faststart',
'-fps_mode passthrough',
];
// video dimensions
const videoIsRotated = Math.abs(stream.rotation) === 90;
const scalingEnabled = ffmpeg.targetResolution !== 'original';
const targetResolution = Number.parseInt(ffmpeg.targetResolution);
const isVideoVertical = stream.height > stream.width || videoIsRotated;
const scaling = isVideoVertical ? `${targetResolution}:-2` : `-2:${targetResolution}`;
const shouldScale = scalingEnabled && Math.min(stream.height, stream.width) > targetResolution;
// video codec
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
const isH264 = ffmpeg.targetVideoCodec === 'h264';
const isH265 = ffmpeg.targetVideoCodec === 'hevc';
// transcode efficiency
const limitThreads = ffmpeg.threads > 0;
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
const constrainMaximumBitrate = maxBitrateValue > 0;
const bitrateUnit = ffmpeg.maxBitrate.trim().substring(maxBitrateValue.toString().length); // use inputted unit if provided
if (shouldScale) {
options.push(`-vf scale=${scaling}`);
private getCodecConfig(config: SystemConfigFFmpegDto) {
switch (config.targetVideoCodec) {
case VideoCodec.H264:
return new H264Config(config);
case VideoCodec.HEVC:
return new HEVCConfig(config);
case VideoCodec.VP9:
return new VP9Config(config);
default:
throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`);
}
if (isH264 || isH265) {
options.push(`-preset ${ffmpeg.preset}`);
}
if (isVP9) {
// vp9 doesn't have presets, but does have a similar setting -cpu-used, from 0-5, 0 being the slowest
const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
const speed = Math.min(presets.indexOf(ffmpeg.preset), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads
if (speed >= 0) {
options.push(`-cpu-used ${speed}`);
}
options.push('-row-mt 1'); // better multithreading
}
if (limitThreads) {
options.push(`-threads ${ffmpeg.threads}`);
// x264 and x265 handle threads differently than one might expect
// https://x265.readthedocs.io/en/latest/cli.html#cmdoption-pools
if (isH264 || isH265) {
options.push(`-${isH265 ? 'x265' : 'x264'}-params "pools=none"`);
options.push(`-${isH265 ? 'x265' : 'x264'}-params "frame-threads=${ffmpeg.threads}"`);
}
}
// two-pass mode for x264/x265 uses bitrate ranges, so it requires a max bitrate from which to derive a target and min bitrate
if (constrainMaximumBitrate && ffmpeg.twoPass) {
const targetBitrateValue = Math.ceil(maxBitrateValue / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod
const minBitrateValue = targetBitrateValue / 2;
options.push(`-b:v ${targetBitrateValue}${bitrateUnit}`);
options.push(`-minrate ${minBitrateValue}${bitrateUnit}`);
options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`);
} else if (constrainMaximumBitrate || isVP9) {
// for vp9, these flags work for both one-pass and two-pass
options.push(`-crf ${ffmpeg.crf}`);
if (isVP9) {
options.push(`-b:v ${maxBitrateValue}${bitrateUnit}`);
} else {
options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`);
// -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate
// needed for -maxrate to be enforced
options.push(`-bufsize ${maxBitrateValue * 2}${bitrateUnit}`);
}
} else {
options.push(`-crf ${ffmpeg.crf}`);
}
return options;
}
private eligibleForTwoPass(ffmpeg: SystemConfigFFmpegDto) {
if (!ffmpeg.twoPass) {
return false;
}
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
const constrainMaximumBitrate = maxBitrateValue > 0;
return constrainMaximumBitrate || isVP9;
}
}