mirror of
https://github.com/immich-app/immich.git
synced 2025-03-01 15:11:21 +01:00
feat(server): only transcode streams that require it (#7106)
This commit is contained in:
parent
b823dfffdc
commit
5ff68d4cdb
7 changed files with 169 additions and 97 deletions
|
@ -704,8 +704,35 @@ describe(MediaService.name, () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should transcode when audio doesnt match target', async () => {
|
it('should copy video stream when video matches target', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3);
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }]);
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||||
|
{
|
||||||
|
inputOptions: [],
|
||||||
|
outputOptions: [
|
||||||
|
'-c:v copy',
|
||||||
|
'-c:a aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-fps_mode passthrough',
|
||||||
|
'-map 0:0',
|
||||||
|
'-map 0:1',
|
||||||
|
'-tag:v hvc1',
|
||||||
|
'-v verbose',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-crf 23',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should copy audio stream when audio matches target', async () => {
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||||
|
@ -716,7 +743,7 @@ describe(MediaService.name, () => {
|
||||||
inputOptions: [],
|
inputOptions: [],
|
||||||
outputOptions: [
|
outputOptions: [
|
||||||
'-c:v h264',
|
'-c:v h264',
|
||||||
'-c:a aac',
|
'-c:a copy',
|
||||||
'-movflags faststart',
|
'-movflags faststart',
|
||||||
'-fps_mode passthrough',
|
'-fps_mode passthrough',
|
||||||
'-map 0:0',
|
'-map 0:0',
|
||||||
|
@ -758,11 +785,11 @@ describe(MediaService.name, () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not transcode an invalid transcode value', async () => {
|
it('should throw an exception if transcode value is invalid', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
|
||||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrow();
|
||||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1106,7 +1133,7 @@ describe(MediaService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should disable thread pooling for hevc if thread limit is above 0', async () => {
|
it('should disable thread pooling for hevc if thread limit is above 0', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||||
configMock.load.mockResolvedValue([
|
configMock.load.mockResolvedValue([
|
||||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||||
|
@ -1140,7 +1167,7 @@ describe(MediaService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should omit thread flags for hevc if thread limit is at or below 0', async () => {
|
it('should omit thread flags for hevc if thread limit is at or below 0', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||||
configMock.load.mockResolvedValue([
|
configMock.load.mockResolvedValue([
|
||||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 },
|
{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 },
|
||||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||||
|
@ -1756,7 +1783,7 @@ describe(MediaService.name, () => {
|
||||||
|
|
||||||
it('should set vbr options for rkmpp when max bitrate is enabled', async () => {
|
it('should set vbr options for rkmpp when max bitrate is enabled', async () => {
|
||||||
storageMock.readdir.mockResolvedValue(['renderD128']);
|
storageMock.readdir.mockResolvedValue(['renderD128']);
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||||
configMock.load.mockResolvedValue([
|
configMock.load.mockResolvedValue([
|
||||||
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP },
|
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP },
|
||||||
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
|
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Colorspace,
|
Colorspace,
|
||||||
TranscodeHWAccel,
|
TranscodeHWAccel,
|
||||||
TranscodePolicy,
|
TranscodePolicy,
|
||||||
|
TranscodeTarget,
|
||||||
VideoCodec,
|
VideoCodec,
|
||||||
} from '@app/infra/entities';
|
} from '@app/infra/entities';
|
||||||
import { ImmichLogger } from '@app/infra/logger';
|
import { ImmichLogger } from '@app/infra/logger';
|
||||||
|
@ -197,7 +198,7 @@ export class MediaService {
|
||||||
}
|
}
|
||||||
const mainAudioStream = this.getMainStream(audioStreams);
|
const mainAudioStream = this.getMainStream(audioStreams);
|
||||||
const config = { ...ffmpeg, targetResolution: size.toString() };
|
const config = { ...ffmpeg, targetResolution: size.toString() };
|
||||||
const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
|
const options = new ThumbnailConfig(config).getOptions(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
|
||||||
await this.mediaRepository.transcode(asset.originalPath, path, options);
|
await this.mediaRepository.transcode(asset.originalPath, path, options);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -267,7 +268,6 @@ export class MediaService {
|
||||||
const mainVideoStream = this.getMainStream(videoStreams);
|
const mainVideoStream = this.getMainStream(videoStreams);
|
||||||
const mainAudioStream = this.getMainStream(audioStreams);
|
const mainAudioStream = this.getMainStream(audioStreams);
|
||||||
const containerExtension = format.formatName;
|
const containerExtension = format.formatName;
|
||||||
const bitrate = format.bitrate;
|
|
||||||
if (!mainVideoStream || !containerExtension) {
|
if (!mainVideoStream || !containerExtension) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -279,15 +279,8 @@ export class MediaService {
|
||||||
|
|
||||||
const { ffmpeg: config } = await this.configCore.getConfig();
|
const { ffmpeg: config } = await this.configCore.getConfig();
|
||||||
|
|
||||||
const required = this.isTranscodeRequired(
|
const target = this.getTranscodeTarget(config, mainVideoStream, mainAudioStream);
|
||||||
asset,
|
if (target === TranscodeTarget.NONE) {
|
||||||
mainVideoStream,
|
|
||||||
mainAudioStream,
|
|
||||||
containerExtension,
|
|
||||||
config,
|
|
||||||
bitrate,
|
|
||||||
);
|
|
||||||
if (!required) {
|
|
||||||
if (asset.encodedVideoPath) {
|
if (asset.encodedVideoPath) {
|
||||||
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] } });
|
||||||
|
@ -299,13 +292,15 @@ export class MediaService {
|
||||||
|
|
||||||
let transcodeOptions;
|
let transcodeOptions;
|
||||||
try {
|
try {
|
||||||
transcodeOptions = await this.getCodecConfig(config).then((c) => c.getOptions(mainVideoStream, mainAudioStream));
|
transcodeOptions = await this.getCodecConfig(config).then((c) =>
|
||||||
|
c.getOptions(target, mainVideoStream, mainAudioStream),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`An error occurred while configuring transcoding options: ${error}`);
|
this.logger.error(`An error occurred while configuring transcoding options: ${error}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Start encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`);
|
this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`);
|
||||||
try {
|
try {
|
||||||
await this.mediaRepository.transcode(input, output, transcodeOptions);
|
await this.mediaRepository.transcode(input, output, transcodeOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -316,11 +311,13 @@ export class MediaService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
config.accel = TranscodeHWAccel.DISABLED;
|
config.accel = TranscodeHWAccel.DISABLED;
|
||||||
transcodeOptions = await this.getCodecConfig(config).then((c) => c.getOptions(mainVideoStream, mainAudioStream));
|
transcodeOptions = await this.getCodecConfig(config).then((c) =>
|
||||||
|
c.getOptions(target, mainVideoStream, mainAudioStream),
|
||||||
|
);
|
||||||
await this.mediaRepository.transcode(input, output, transcodeOptions);
|
await this.mediaRepository.transcode(input, output, transcodeOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Encoding success ${asset.id}`);
|
this.logger.log(`Successfully encoded ${asset.id}`);
|
||||||
|
|
||||||
await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
|
await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
|
||||||
|
|
||||||
|
@ -331,55 +328,88 @@ export class MediaService {
|
||||||
return streams.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
|
return streams.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private isTranscodeRequired(
|
private getTranscodeTarget(
|
||||||
asset: AssetEntity,
|
config: SystemConfigFFmpegDto,
|
||||||
videoStream: VideoStreamInfo,
|
videoStream: VideoStreamInfo | null,
|
||||||
audioStream: AudioStreamInfo | null,
|
audioStream: AudioStreamInfo | null,
|
||||||
containerExtension: string,
|
): TranscodeTarget {
|
||||||
ffmpegConfig: SystemConfigFFmpegDto,
|
if (videoStream == null && audioStream == null) {
|
||||||
bitrate: number,
|
return TranscodeTarget.NONE;
|
||||||
): boolean {
|
}
|
||||||
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(videoStream.codecName as VideoCodec);
|
|
||||||
const isTargetContainer = ['mov,mp4,m4a,3gp,3g2,mj2', 'mp4', 'mov'].includes(containerExtension);
|
|
||||||
const isTargetAudioCodec =
|
|
||||||
audioStream == null || ffmpegConfig.acceptedAudioCodecs.includes(audioStream.codecName as AudioCodec);
|
|
||||||
|
|
||||||
this.logger.verbose(
|
const isAudioTranscodeRequired = this.isAudioTranscodeRequired(config, audioStream);
|
||||||
`${asset.id}: AudioCodecName ${audioStream?.codecName ?? 'None'}, AudioStreamCodecType ${
|
const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream);
|
||||||
audioStream?.codecType ?? 'None'
|
|
||||||
}, containerExtension ${containerExtension}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const allTargetsMatching = isTargetVideoCodec && isTargetAudioCodec && isTargetContainer;
|
if (isAudioTranscodeRequired && isVideoTranscodeRequired) {
|
||||||
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
return TranscodeTarget.ALL;
|
||||||
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
}
|
||||||
const isLargerThanTargetRes = scalingEnabled && Math.min(videoStream.height, videoStream.width) > targetRes;
|
|
||||||
const isLargerThanTargetBitrate = bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
|
if (isAudioTranscodeRequired) {
|
||||||
|
return TranscodeTarget.AUDIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideoTranscodeRequired) {
|
||||||
|
return TranscodeTarget.VIDEO;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TranscodeTarget.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: AudioStreamInfo | null): boolean {
|
||||||
|
if (stream == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
switch (ffmpegConfig.transcode) {
|
switch (ffmpegConfig.transcode) {
|
||||||
case TranscodePolicy.DISABLED: {
|
case TranscodePolicy.DISABLED: {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
case TranscodePolicy.ALL: {
|
case TranscodePolicy.ALL: {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
case TranscodePolicy.REQUIRED:
|
||||||
case TranscodePolicy.REQUIRED: {
|
case TranscodePolicy.OPTIMAL:
|
||||||
return !allTargetsMatching || videoStream.isHDR;
|
|
||||||
}
|
|
||||||
|
|
||||||
case TranscodePolicy.OPTIMAL: {
|
|
||||||
return !allTargetsMatching || isLargerThanTargetRes || videoStream.isHDR;
|
|
||||||
}
|
|
||||||
|
|
||||||
case TranscodePolicy.BITRATE: {
|
case TranscodePolicy.BITRATE: {
|
||||||
return !allTargetsMatching || isLargerThanTargetBitrate || videoStream.isHDR;
|
return !ffmpegConfig.acceptedAudioCodecs.includes(stream.codecName as AudioCodec);
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
|
throw new Error(`Unsupported transcode policy: ${ffmpegConfig.transcode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo | null): boolean {
|
||||||
|
if (stream == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
||||||
|
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
||||||
|
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;
|
||||||
|
const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
|
||||||
|
|
||||||
|
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec);
|
||||||
|
const isRequired = !isTargetVideoCodec || stream.isHDR;
|
||||||
|
|
||||||
|
switch (ffmpegConfig.transcode) {
|
||||||
|
case TranscodePolicy.DISABLED: {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
case TranscodePolicy.ALL: {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case TranscodePolicy.REQUIRED: {
|
||||||
|
return isRequired;
|
||||||
|
}
|
||||||
|
case TranscodePolicy.OPTIMAL: {
|
||||||
|
return isRequired || isLargerThanTargetRes;
|
||||||
|
}
|
||||||
|
case TranscodePolicy.BITRATE: {
|
||||||
|
return isRequired || isLargerThanTargetBitrate;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(`Unsupported transcode policy: ${ffmpegConfig.transcode}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { CQMode, ToneMapping, TranscodeHWAccel, VideoCodec } from '@app/infra/entities';
|
import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from '@app/infra/entities';
|
||||||
import {
|
import {
|
||||||
AudioStreamInfo,
|
AudioStreamInfo,
|
||||||
BitrateDistribution,
|
BitrateDistribution,
|
||||||
|
@ -12,16 +12,19 @@ class BaseConfig implements VideoCodecSWConfig {
|
||||||
presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
||||||
constructor(protected config: SystemConfigFFmpegDto) {}
|
constructor(protected config: SystemConfigFFmpegDto) {}
|
||||||
|
|
||||||
getOptions(videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
||||||
const options = {
|
const options = {
|
||||||
inputOptions: this.getBaseInputOptions(),
|
inputOptions: this.getBaseInputOptions(),
|
||||||
outputOptions: [...this.getBaseOutputOptions(videoStream, audioStream), '-v verbose'],
|
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
|
||||||
twoPass: this.eligibleForTwoPass(),
|
twoPass: this.eligibleForTwoPass(),
|
||||||
} as TranscodeOptions;
|
} as TranscodeOptions;
|
||||||
const filters = this.getFilterOptions(videoStream);
|
if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) {
|
||||||
if (filters.length > 0) {
|
const filters = this.getFilterOptions(videoStream);
|
||||||
options.outputOptions.push(`-vf ${filters.join(',')}`);
|
if (filters.length > 0) {
|
||||||
|
options.outputOptions.push(`-vf ${filters.join(',')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
options.outputOptions.push(...this.getPresetOptions(), ...this.getThreadOptions(), ...this.getBitrateOptions());
|
options.outputOptions.push(...this.getPresetOptions(), ...this.getThreadOptions(), ...this.getBitrateOptions());
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
|
@ -31,10 +34,10 @@ class BaseConfig implements VideoCodecSWConfig {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseOutputOptions(videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
||||||
const options = [
|
const options = [
|
||||||
`-c:v ${this.getVideoCodec()}`,
|
`-c:v ${[TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy'}`,
|
||||||
`-c:a ${this.getAudioCodec()}`,
|
`-c:a ${[TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy'}`,
|
||||||
// Makes a second pass moving the moov atom to the
|
// Makes a second pass moving the moov atom to the
|
||||||
// beginning of the file for improved playback speed.
|
// beginning of the file for improved playback speed.
|
||||||
'-movflags faststart',
|
'-movflags faststart',
|
||||||
|
@ -398,14 +401,14 @@ export class NVENCConfig extends BaseHWConfig {
|
||||||
return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'];
|
return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'];
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseOutputOptions(videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
||||||
const options = [
|
const options = [
|
||||||
// below settings recommended from https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#command-line-for-latency-tolerant-high-quality-transcoding
|
// below settings recommended from https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#command-line-for-latency-tolerant-high-quality-transcoding
|
||||||
'-tune hq',
|
'-tune hq',
|
||||||
'-qmin 0',
|
'-qmin 0',
|
||||||
'-rc-lookahead 20',
|
'-rc-lookahead 20',
|
||||||
'-i_qfactor 0.75',
|
'-i_qfactor 0.75',
|
||||||
...super.getBaseOutputOptions(videoStream, audioStream),
|
...super.getBaseOutputOptions(target, videoStream, audioStream),
|
||||||
];
|
];
|
||||||
if (this.getBFrames() > 0) {
|
if (this.getBFrames() > 0) {
|
||||||
options.push('-b_ref_mode middle', '-b_qfactor 1.1');
|
options.push('-b_ref_mode middle', '-b_qfactor 1.1');
|
||||||
|
@ -483,8 +486,8 @@ export class QSVConfig extends BaseHWConfig {
|
||||||
return [`-init_hw_device qsv=hw${qsvString}`, '-filter_hw_device hw'];
|
return [`-init_hw_device qsv=hw${qsvString}`, '-filter_hw_device hw'];
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseOutputOptions(videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
||||||
const options = super.getBaseOutputOptions(videoStream, audioStream);
|
const options = super.getBaseOutputOptions(target, videoStream, audioStream);
|
||||||
// VP9 requires enabling low power mode https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/33583803e107b6d532def0f9d949364b01b6ad5a
|
// VP9 requires enabling low power mode https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/33583803e107b6d532def0f9d949364b01b6ad5a
|
||||||
if (this.config.targetVideoCodec === VideoCodec.VP9) {
|
if (this.config.targetVideoCodec === VideoCodec.VP9) {
|
||||||
options.push('-low_power 1');
|
options.push('-low_power 1');
|
||||||
|
@ -604,11 +607,13 @@ export class VAAPIConfig extends BaseHWConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RKMPPConfig extends BaseHWConfig {
|
export class RKMPPConfig extends BaseHWConfig {
|
||||||
getOptions(videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo): TranscodeOptions {
|
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo): TranscodeOptions {
|
||||||
const options = super.getOptions(videoStream, audioStream);
|
const options = super.getOptions(target, videoStream, audioStream);
|
||||||
options.ffmpegPath = 'ffmpeg_mpp';
|
options.ffmpegPath = 'ffmpeg_mpp';
|
||||||
options.ldLibraryPath = '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp';
|
options.ldLibraryPath = '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp';
|
||||||
options.outputOptions.push(...this.getSizeOptions(videoStream));
|
if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) {
|
||||||
|
options.outputOptions.push(...this.getSizeOptions(videoStream));
|
||||||
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { VideoCodec } from '@app/infra/entities';
|
import { TranscodeTarget, VideoCodec } from '@app/infra/entities';
|
||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
|
|
||||||
export const IMediaRepository = 'IMediaRepository';
|
export const IMediaRepository = 'IMediaRepository';
|
||||||
|
@ -16,15 +16,14 @@ export interface VideoStreamInfo {
|
||||||
width: number;
|
width: number;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
codecName?: string;
|
codecName?: string;
|
||||||
codecType?: string;
|
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
isHDR: boolean;
|
isHDR: boolean;
|
||||||
|
bitrate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudioStreamInfo {
|
export interface AudioStreamInfo {
|
||||||
index: number;
|
index: number;
|
||||||
codecName?: string;
|
codecName?: string;
|
||||||
codecType?: string;
|
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +63,7 @@ export interface BitrateDistribution {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoCodecSWConfig {
|
export interface VideoCodecSWConfig {
|
||||||
getOptions(videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeOptions;
|
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
|
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
|
||||||
|
|
|
@ -118,6 +118,13 @@ export enum TranscodePolicy {
|
||||||
DISABLED = 'disabled',
|
DISABLED = 'disabled',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum TranscodeTarget {
|
||||||
|
NONE,
|
||||||
|
AUDIO,
|
||||||
|
VIDEO,
|
||||||
|
ALL,
|
||||||
|
}
|
||||||
|
|
||||||
export enum VideoCodec {
|
export enum VideoCodec {
|
||||||
H264 = 'h264',
|
H264 = 'h264',
|
||||||
HEVC = 'hevc',
|
HEVC = 'hevc',
|
||||||
|
|
|
@ -60,6 +60,7 @@ export class MediaRepository implements IMediaRepository {
|
||||||
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
|
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
|
||||||
rotation: Number.parseInt(`${stream.rotation ?? 0}`),
|
rotation: Number.parseInt(`${stream.rotation ?? 0}`),
|
||||||
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'),
|
||||||
})),
|
})),
|
||||||
audioStreams: results.streams
|
audioStreams: results.streams
|
||||||
.filter((stream) => stream.codec_type === 'audio')
|
.filter((stream) => stream.codec_type === 'audio')
|
||||||
|
|
43
server/test/fixtures/media.stub.ts
vendored
43
server/test/fixtures/media.stub.ts
vendored
|
@ -13,16 +13,14 @@ const probeStubDefaultVideoStream: VideoStreamInfo[] = [
|
||||||
height: 1080,
|
height: 1080,
|
||||||
width: 1920,
|
width: 1920,
|
||||||
codecName: 'hevc',
|
codecName: 'hevc',
|
||||||
codecType: 'video',
|
|
||||||
frameCount: 100,
|
frameCount: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
isHDR: false,
|
isHDR: false,
|
||||||
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const probeStubDefaultAudioStream: AudioStreamInfo[] = [
|
const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ index: 1, codecName: 'mp3', frameCount: 100 }];
|
||||||
{ index: 1, codecName: 'aac', codecType: 'audio', frameCount: 100 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const probeStubDefault: VideoInfo = {
|
const probeStubDefault: VideoInfo = {
|
||||||
format: probeStubDefaultFormat,
|
format: probeStubDefaultFormat,
|
||||||
|
@ -41,20 +39,20 @@ export const probeStub = {
|
||||||
height: 1080,
|
height: 1080,
|
||||||
width: 400,
|
width: 400,
|
||||||
codecName: 'hevc',
|
codecName: 'hevc',
|
||||||
codecType: 'video',
|
|
||||||
frameCount: 100,
|
frameCount: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
isHDR: false,
|
isHDR: false,
|
||||||
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 1,
|
index: 1,
|
||||||
height: 1080,
|
height: 1080,
|
||||||
width: 400,
|
width: 400,
|
||||||
codecName: 'h7000',
|
codecName: 'h7000',
|
||||||
codecType: 'video',
|
|
||||||
frameCount: 99,
|
frameCount: 99,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
isHDR: false,
|
isHDR: false,
|
||||||
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
@ -66,10 +64,10 @@ export const probeStub = {
|
||||||
height: 0,
|
height: 0,
|
||||||
width: 400,
|
width: 400,
|
||||||
codecName: 'hevc',
|
codecName: 'hevc',
|
||||||
codecType: 'video',
|
|
||||||
frameCount: 100,
|
frameCount: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
isHDR: false,
|
isHDR: false,
|
||||||
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
@ -81,21 +79,16 @@ export const probeStub = {
|
||||||
height: 2160,
|
height: 2160,
|
||||||
width: 3840,
|
width: 3840,
|
||||||
codecName: 'h264',
|
codecName: 'h264',
|
||||||
codecType: 'video',
|
|
||||||
frameCount: 100,
|
frameCount: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
isHDR: false,
|
isHDR: false,
|
||||||
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
videoStream40Mbps: Object.freeze<VideoInfo>({
|
videoStream40Mbps: Object.freeze<VideoInfo>({
|
||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
format: {
|
videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }],
|
||||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
|
||||||
formatLongName: 'QuickTime / MOV',
|
|
||||||
duration: 0,
|
|
||||||
bitrate: 40_000_000,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
videoStreamHDR: Object.freeze<VideoInfo>({
|
videoStreamHDR: Object.freeze<VideoInfo>({
|
||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
|
@ -105,10 +98,10 @@ export const probeStub = {
|
||||||
height: 480,
|
height: 480,
|
||||||
width: 480,
|
width: 480,
|
||||||
codecName: 'h264',
|
codecName: 'h264',
|
||||||
codecType: 'video',
|
|
||||||
frameCount: 100,
|
frameCount: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
isHDR: true,
|
isHDR: true,
|
||||||
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
@ -120,10 +113,10 @@ export const probeStub = {
|
||||||
height: 2160,
|
height: 2160,
|
||||||
width: 3840,
|
width: 3840,
|
||||||
codecName: 'h264',
|
codecName: 'h264',
|
||||||
codecType: 'video',
|
|
||||||
frameCount: 100,
|
frameCount: 100,
|
||||||
rotation: 90,
|
rotation: 90,
|
||||||
isHDR: false,
|
isHDR: false,
|
||||||
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
@ -135,10 +128,10 @@ export const probeStub = {
|
||||||
height: 355,
|
height: 355,
|
||||||
width: 1586,
|
width: 1586,
|
||||||
codecName: 'h264',
|
codecName: 'h264',
|
||||||
codecType: 'video',
|
|
||||||
frameCount: 100,
|
frameCount: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
isHDR: false,
|
isHDR: false,
|
||||||
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
@ -150,16 +143,16 @@ export const probeStub = {
|
||||||
height: 1586,
|
height: 1586,
|
||||||
width: 355,
|
width: 355,
|
||||||
codecName: 'h264',
|
codecName: 'h264',
|
||||||
codecType: 'video',
|
|
||||||
frameCount: 100,
|
frameCount: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
isHDR: false,
|
isHDR: false,
|
||||||
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
audioStreamMp3: Object.freeze<VideoInfo>({
|
audioStreamAac: Object.freeze<VideoInfo>({
|
||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
audioStreams: [{ index: 1, codecType: 'audio', codecName: 'aac', frameCount: 100 }],
|
audioStreams: [{ index: 1, codecName: 'aac', frameCount: 100 }],
|
||||||
}),
|
}),
|
||||||
matroskaContainer: Object.freeze<VideoInfo>({
|
matroskaContainer: Object.freeze<VideoInfo>({
|
||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
|
@ -170,4 +163,14 @@ export const probeStub = {
|
||||||
bitrate: 0,
|
bitrate: 0,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
videoStreamVp9: Object.freeze<VideoInfo>({
|
||||||
|
...probeStubDefault,
|
||||||
|
videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'vp9' }],
|
||||||
|
format: {
|
||||||
|
formatName: 'matroska,webm',
|
||||||
|
formatLongName: 'Matroska / WebM',
|
||||||
|
duration: 0,
|
||||||
|
bitrate: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue