feat(all): transcoding improvements (#2171)

* test: rename some fixtures and add text for vertical video conversion

* feat: transcode video asset when audio or container don't match target

* chore: add niceness to the ffmpeg command to allow other processes to be prioritised

* chore: change video conversion queue to one concurrency

* feat: add transcode disabled preset to completely turn off transcoding

* linter

* Change log level and remove unused await

* opps forgot to save

* better logging

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Zack Pollard
2023-04-06 04:32:59 +01:00
committed by GitHub
parent 6f1d0a3caa
commit a5a6bebf0b
11 changed files with 190 additions and 50 deletions

View File

@@ -14,8 +14,21 @@ export interface VideoStreamInfo {
frameCount: number;
}
export interface AudioStreamInfo {
codecName?: string;
codecType?: string;
}
export interface VideoFormat {
formatName?: string;
formatLongName?: string;
duration: number;
}
export interface VideoInfo {
streams: VideoStreamInfo[];
format: VideoFormat;
videoStreams: VideoStreamInfo[];
audioStreams: AudioStreamInfo[];
}
export interface IMediaRepository {

View File

@@ -222,7 +222,7 @@ describe(MediaService.name, () => {
});
it('should transcode the longest stream', async () => {
mediaMock.probe.mockResolvedValue(probeStub.multiple);
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
@@ -237,7 +237,7 @@ describe(MediaService.name, () => {
});
it('should skip a video without any streams', async () => {
mediaMock.probe.mockResolvedValue(probeStub.empty);
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
@@ -249,7 +249,7 @@ describe(MediaService.name, () => {
});
it('should transcode when set to all', async () => {
mediaMock.probe.mockResolvedValue(probeStub.multiple);
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
@@ -260,7 +260,40 @@ describe(MediaService.name, () => {
});
it('should transcode when optimal and too big', async () => {
mediaMock.probe.mockResolvedValue(probeStub.tooBig);
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
);
});
it('should transcode with alternate scaling video is vertical', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=720:-2'],
);
});
it('should transcode when audio doesnt match target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
);
});
it('should transcode when container doesnt match target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
@@ -271,7 +304,7 @@ describe(MediaService.name, () => {
});
it('should not transcode an invalid transcode value', async () => {
mediaMock.probe.mockResolvedValue(probeStub.tooBig);
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).not.toHaveBeenCalled();

View File

@@ -7,7 +7,7 @@ import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { IMediaRepository, VideoStreamInfo } from './media.repository';
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository';
@Injectable()
export class MediaService {
@@ -127,23 +127,27 @@ export class MediaService {
const output = join(outputFolder, `${asset.id}.mp4`);
this.storageRepository.mkdirSync(outputFolder);
const { streams } = await this.mediaRepository.probe(input);
const stream = await this.getLongestStream(streams);
if (!stream) {
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
const mainVideoStream = this.getMainVideoStream(videoStreams);
const mainAudioStream = this.getMainAudioStream(audioStreams);
const containerExtension = format.formatName;
if (!mainVideoStream || !mainAudioStream || !containerExtension) {
return;
}
const { ffmpeg: config } = await this.configCore.getConfig();
const required = this.isTranscodeRequired(stream, config);
const required = this.isTranscodeRequired(mainVideoStream, mainAudioStream, containerExtension, config);
if (!required) {
return;
}
const options = this.getFfmpegOptions(stream, config);
const options = this.getFfmpegOptions(mainVideoStream, config);
this.logger.log(`Start encoding video ${asset.id} ${options}`);
await this.mediaRepository.transcode(input, output, options);
this.logger.log(`Converting Success ${asset.id}`);
this.logger.log(`Encoding success ${asset.id}`);
await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
} catch (error: any) {
@@ -151,32 +155,48 @@ export class MediaService {
}
}
private getLongestStream(streams: VideoStreamInfo[]): VideoStreamInfo | null {
return streams
.filter((stream) => stream.codecType === 'video')
.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
private getMainVideoStream(streams: VideoStreamInfo[]): VideoStreamInfo | null {
return streams.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
}
private isTranscodeRequired(stream: VideoStreamInfo, ffmpegConfig: SystemConfigFFmpegDto): boolean {
if (!stream.height || !stream.width) {
private getMainAudioStream(streams: AudioStreamInfo[]): AudioStreamInfo | null {
return streams[0];
}
private isTranscodeRequired(
videoStream: VideoStreamInfo,
audioStream: AudioStreamInfo,
containerExtension: string,
ffmpegConfig: SystemConfigFFmpegDto,
): boolean {
if (!videoStream.height || !videoStream.width) {
this.logger.error('Skipping transcode, height or width undefined for video stream');
return false;
}
const isTargetVideoCodec = stream.codecName === ffmpegConfig.targetVideoCodec;
const isTargetVideoCodec = videoStream.codecName === ffmpegConfig.targetVideoCodec;
const isTargetAudioCodec = audioStream.codecName === ffmpegConfig.targetAudioCodec;
const isTargetContainer = ['mov,mp4,m4a,3gp,3g2,mj2', 'mp4', 'mov'].includes(containerExtension);
this.logger.debug(audioStream.codecName, audioStream.codecType, containerExtension);
const allTargetsMatching = isTargetVideoCodec && isTargetAudioCodec && isTargetContainer;
const targetResolution = Number.parseInt(ffmpegConfig.targetResolution);
const isLargerThanTargetResolution = Math.min(stream.height, stream.width) > targetResolution;
const isLargerThanTargetResolution = Math.min(videoStream.height, videoStream.width) > targetResolution;
switch (ffmpegConfig.transcode) {
case TranscodePreset.DISABLED:
return false;
case TranscodePreset.ALL:
return true;
case TranscodePreset.REQUIRED:
return !isTargetVideoCodec;
return !allTargetsMatching;
case TranscodePreset.OPTIMAL:
return !isTargetVideoCodec || isLargerThanTargetResolution;
return !allTargetsMatching || isLargerThanTargetResolution;
default:
return false;
@@ -184,8 +204,6 @@ export class MediaService {
}
private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) {
// TODO: If video or audio are already the correct format, don't re-encode, copy the stream
const options = [
`-crf ${ffmpeg.crf}`,
`-preset ${ffmpeg.preset}`,