refactor(server): video transcode processor (#2163)

* refactor(server): video transcode processor

* refactor: rename shouldRotate to isVideoVertical, remove unnecessary await

* refactor: rename getOptions to getFfmpegOptions to be clearer in that context

* fix: optimal preset converting vertical videos already smaller than target resolution

---------

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
Jason Rasmussen
2023-04-04 10:48:02 -04:00
committed by GitHub
parent ec6a7ae97c
commit 48393c215b
9 changed files with 347 additions and 196 deletions

View File

@@ -1,3 +1,4 @@
import { AssetType, SystemConfigKey } from '@app/infra/entities';
import _ from 'lodash';
import {
assetEntityStub,
@@ -6,17 +7,21 @@ import {
newJobRepositoryMock,
newMediaRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
probeStub,
} from '../../test';
import { IAssetRepository, WithoutProperty } from '../asset';
import { ICommunicationRepository } from '../communication';
import { IJobRepository, JobName } from '../job';
import { IStorageRepository } from '../storage';
import { ISystemConfigRepository } from '../system-config';
import { IMediaRepository } from './media.repository';
import { MediaService } from './media.service';
describe(MediaService.name, () => {
let sut: MediaService;
let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let mediaMock: jest.Mocked<IMediaRepository>;
@@ -24,11 +29,12 @@ describe(MediaService.name, () => {
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
communicationMock = newCommunicationRepositoryMock();
jobMock = newJobRepositoryMock();
mediaMock = newMediaRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new MediaService(assetMock, communicationMock, jobMock, mediaMock, storageMock);
sut = new MediaService(assetMock, communicationMock, jobMock, mediaMock, storageMock, configMock);
});
it('should be defined', () => {
@@ -169,4 +175,106 @@ describe(MediaService.name, () => {
expect(mediaMock.resize).toHaveBeenCalled();
});
});
describe('handleQueueVideoConversion', () => {
it('should queue all video assets', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.video]);
await sut.handleQueueVideoConversion({ force: true });
expect(assetMock.getAll).toHaveBeenCalledWith({ type: AssetType.VIDEO });
expect(assetMock.getWithout).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.VIDEO_CONVERSION,
data: { asset: assetEntityStub.video },
});
});
it('should queue all video assets without encoded videos', async () => {
assetMock.getWithout.mockResolvedValue([assetEntityStub.video]);
await sut.handleQueueVideoConversion({});
expect(assetMock.getAll).not.toHaveBeenCalled();
expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.ENCODED_VIDEO);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.VIDEO_CONVERSION,
data: { asset: assetEntityStub.video },
});
});
it('should log an error', async () => {
assetMock.getAll.mockRejectedValue(new Error('database unavailable'));
await sut.handleQueueVideoConversion({ force: true });
expect(assetMock.getAll).toHaveBeenCalled();
});
});
describe('handleVideoConversion', () => {
it('should log an error', async () => {
mediaMock.transcode.mockRejectedValue(new Error('unable to transcode'));
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(storageMock.mkdirSync).toHaveBeenCalled();
});
it('should transcode the longest stream', async () => {
mediaMock.probe.mockResolvedValue(probeStub.multiple);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext');
expect(configMock.load).toHaveBeenCalled();
expect(storageMock.mkdirSync).toHaveBeenCalled();
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'],
);
});
it('should skip a video without any streams', async () => {
mediaMock.probe.mockResolvedValue(probeStub.empty);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
it('should skip a video without any height', async () => {
mediaMock.probe.mockResolvedValue(probeStub.noHeight);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
it('should transcode when set to all', async () => {
mediaMock.probe.mockResolvedValue(probeStub.multiple);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]);
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'],
);
});
it('should transcode when optimal and too big', async () => {
mediaMock.probe.mockResolvedValue(probeStub.tooBig);
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 not transcode an invalid transcode value', async () => {
mediaMock.probe.mockResolvedValue(probeStub.tooBig);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
});
});