mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	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:
		| @@ -4929,6 +4929,14 @@ | ||||
|           "OTHER" | ||||
|         ] | ||||
|       }, | ||||
|       "AudioCodec": { | ||||
|         "type": "string", | ||||
|         "enum": [ | ||||
|           "mp3", | ||||
|           "aac", | ||||
|           "opus" | ||||
|         ] | ||||
|       }, | ||||
|       "AuthDeviceResponseDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
| @@ -6347,13 +6355,16 @@ | ||||
|           "threads": { | ||||
|             "type": "integer" | ||||
|           }, | ||||
|           "preset": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "targetVideoCodec": { | ||||
|             "type": "string" | ||||
|             "$ref": "#/components/schemas/VideoCodec" | ||||
|           }, | ||||
|           "targetAudioCodec": { | ||||
|             "$ref": "#/components/schemas/AudioCodec" | ||||
|           }, | ||||
|           "transcode": { | ||||
|             "$ref": "#/components/schemas/TranscodePolicy" | ||||
|           }, | ||||
|           "preset": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "targetResolution": { | ||||
| @@ -6364,27 +6375,18 @@ | ||||
|           }, | ||||
|           "twoPass": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "transcode": { | ||||
|             "type": "string", | ||||
|             "enum": [ | ||||
|               "all", | ||||
|               "optimal", | ||||
|               "required", | ||||
|               "disabled" | ||||
|             ] | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "crf", | ||||
|           "threads", | ||||
|           "preset", | ||||
|           "targetVideoCodec", | ||||
|           "targetAudioCodec", | ||||
|           "transcode", | ||||
|           "preset", | ||||
|           "targetResolution", | ||||
|           "maxBitrate", | ||||
|           "twoPass", | ||||
|           "transcode" | ||||
|           "twoPass" | ||||
|         ] | ||||
|       }, | ||||
|       "SystemConfigJobDto": { | ||||
| @@ -6604,6 +6606,15 @@ | ||||
|           "month" | ||||
|         ] | ||||
|       }, | ||||
|       "TranscodePolicy": { | ||||
|         "type": "string", | ||||
|         "enum": [ | ||||
|           "all", | ||||
|           "optimal", | ||||
|           "required", | ||||
|           "disabled" | ||||
|         ] | ||||
|       }, | ||||
|       "UpdateAlbumDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
| @@ -6804,6 +6815,14 @@ | ||||
|         "required": [ | ||||
|           "authStatus" | ||||
|         ] | ||||
|       }, | ||||
|       "VideoCodec": { | ||||
|         "type": "string", | ||||
|         "enum": [ | ||||
|           "h264", | ||||
|           "hevc", | ||||
|           "vp9" | ||||
|         ] | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -39,10 +39,22 @@ export interface CropOptions { | ||||
| } | ||||
|  | ||||
| export interface TranscodeOptions { | ||||
|   inputOptions: string[]; | ||||
|   outputOptions: string[]; | ||||
|   twoPass: boolean; | ||||
| } | ||||
|  | ||||
| export interface BitrateDistribution { | ||||
|   max: number; | ||||
|   target: number; | ||||
|   min: number; | ||||
|   unit: string; | ||||
| } | ||||
|  | ||||
| export interface VideoCodecSWConfig { | ||||
|   getOptions(stream: VideoStreamInfo): TranscodeOptions; | ||||
| } | ||||
|  | ||||
| export interface IMediaRepository { | ||||
|   // image | ||||
|   resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>; | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { AssetType, SystemConfigKey } from '@app/infra/entities'; | ||||
| import { AssetType, SystemConfigKey, TranscodePolicy, VideoCodec } from '@app/infra/entities'; | ||||
| import { | ||||
|   assetEntityStub, | ||||
|   newAssetRepositoryMock, | ||||
| @@ -104,6 +104,13 @@ describe(MediaService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('handleGenerateJpegThumbnail', () => { | ||||
|     it('should skip thumbnail generation if asset not found', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([]); | ||||
|       await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id }); | ||||
|       expect(mediaMock.resize).not.toHaveBeenCalled(); | ||||
|       expect(assetMock.save).not.toHaveBeenCalledWith(); | ||||
|     }); | ||||
|  | ||||
|     it('should generate a thumbnail for an image', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); | ||||
|       await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id }); | ||||
| @@ -142,15 +149,22 @@ describe(MediaService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('handleGenerateWebpThumbnail', () => { | ||||
|     it('should skip thumbnail generation if asset not found', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([]); | ||||
|       await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id }); | ||||
|       expect(mediaMock.resize).not.toHaveBeenCalled(); | ||||
|       expect(assetMock.save).not.toHaveBeenCalledWith(); | ||||
|     }); | ||||
|  | ||||
|     it('should skip thumbnail generate if resize path is missing', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]); | ||||
|       await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.noResizePath.id }); | ||||
|       await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.noResizePath.id }); | ||||
|       expect(mediaMock.resize).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should generate a thumbnail', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); | ||||
|       await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.image.id }); | ||||
|       await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id }); | ||||
|  | ||||
|       expect(mediaMock.resize).toHaveBeenCalledWith( | ||||
|         '/uploads/user-id/thumbs/path.ext', | ||||
| @@ -162,6 +176,12 @@ describe(MediaService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('handleGenerateThumbhashThumbnail', () => { | ||||
|     it('should skip thumbhash generation if asset not found', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([]); | ||||
|       await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id }); | ||||
|       expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should skip thumbhash generation if resize path is missing', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]); | ||||
|       await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.noResizePath.id }); | ||||
| @@ -219,6 +239,20 @@ describe(MediaService.name, () => { | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|     }); | ||||
|  | ||||
|     it('should skip transcoding if asset not found', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.probe).not.toHaveBeenCalled(); | ||||
|       expect(mediaMock.transcode).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should skip transcoding if non-video asset', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.image.id }); | ||||
|       expect(mediaMock.probe).not.toHaveBeenCalled(); | ||||
|       expect(mediaMock.transcode).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should transcode the longest stream', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); | ||||
| @@ -232,6 +266,7 @@ describe(MediaService.name, () => { | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -261,13 +296,14 @@ describe(MediaService.name, () => { | ||||
|  | ||||
|     it('should transcode when set to all', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -283,12 +319,13 @@ describe(MediaService.name, () => { | ||||
|  | ||||
|     it('should transcode when optimal and too big', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -306,7 +343,7 @@ describe(MediaService.name, () => { | ||||
|     it('should not scale resolution if no target resolution', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); | ||||
|       configMock.load.mockResolvedValue([ | ||||
|         { key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }, | ||||
|         { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }, | ||||
|         { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, | ||||
|       ]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
| @@ -314,6 +351,7 @@ describe(MediaService.name, () => { | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -329,13 +367,14 @@ describe(MediaService.name, () => { | ||||
|  | ||||
|     it('should transcode with alternate scaling video is vertical', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -352,13 +391,14 @@ describe(MediaService.name, () => { | ||||
|  | ||||
|     it('should transcode when audio doesnt match target', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -375,13 +415,14 @@ describe(MediaService.name, () => { | ||||
|  | ||||
|     it('should transcode when container doesnt match target', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -404,6 +445,22 @@ describe(MediaService.name, () => { | ||||
|       expect(mediaMock.transcode).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should not transcode if transcoding is disabled', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should not transcode if target codec is invalid', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'invalid' }]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should set max bitrate if above 0', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]); | ||||
| @@ -413,6 +470,7 @@ describe(MediaService.name, () => { | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -441,6 +499,7 @@ describe(MediaService.name, () => { | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -466,6 +525,7 @@ describe(MediaService.name, () => { | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -480,11 +540,12 @@ describe(MediaService.name, () => { | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should configure preset for vp9', async () => { | ||||
|     it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([ | ||||
|         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' }, | ||||
|         { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, | ||||
|         { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }, | ||||
|         { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, | ||||
|         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, | ||||
|       ]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
| @@ -492,6 +553,7 @@ describe(MediaService.name, () => { | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec vp9', | ||||
|             '-acodec aac', | ||||
| @@ -500,7 +562,64 @@ describe(MediaService.name, () => { | ||||
|             '-vf scale=-2:720', | ||||
|             '-cpu-used 5', | ||||
|             '-row-mt 1', | ||||
|             '-threads 2', | ||||
|             '-b:v 3104k', | ||||
|             '-minrate 1552k', | ||||
|             '-maxrate 4500k', | ||||
|           ], | ||||
|           twoPass: true, | ||||
|         }, | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should configure preset for vp9', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([ | ||||
|         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, | ||||
|         { key: SystemConfigKey.FFMPEG_PRESET, value: 'slow' }, | ||||
|       ]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec vp9', | ||||
|             '-acodec aac', | ||||
|             '-movflags faststart', | ||||
|             '-fps_mode passthrough', | ||||
|             '-vf scale=-2:720', | ||||
|             '-cpu-used 2', | ||||
|             '-row-mt 1', | ||||
|             '-crf 23', | ||||
|             '-b:v 0', | ||||
|           ], | ||||
|           twoPass: false, | ||||
|         }, | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should not configure preset for vp9 if invalid', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([ | ||||
|         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, | ||||
|         { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, | ||||
|       ]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec vp9', | ||||
|             '-acodec aac', | ||||
|             '-movflags faststart', | ||||
|             '-fps_mode passthrough', | ||||
|             '-vf scale=-2:720', | ||||
|             '-row-mt 1', | ||||
|             '-crf 23', | ||||
|             '-b:v 0', | ||||
|           ], | ||||
| @@ -512,7 +631,7 @@ describe(MediaService.name, () => { | ||||
|     it('should configure threads if above 0', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([ | ||||
|         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' }, | ||||
|         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, | ||||
|         { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, | ||||
|       ]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
| @@ -521,6 +640,7 @@ describe(MediaService.name, () => { | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec vp9', | ||||
|             '-acodec aac', | ||||
| @@ -538,7 +658,7 @@ describe(MediaService.name, () => { | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => { | ||||
|     it('should disable thread pooling for h264 if thread limit is above 0', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
| @@ -547,6 +667,7 @@ describe(MediaService.name, () => { | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
| @@ -563,5 +684,86 @@ describe(MediaService.name, () => { | ||||
|         }, | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should omit thread flags for h264 if thread limit is at or below 0', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 }]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec h264', | ||||
|             '-acodec aac', | ||||
|             '-movflags faststart', | ||||
|             '-fps_mode passthrough', | ||||
|             '-vf scale=-2:720', | ||||
|             '-preset ultrafast', | ||||
|             '-crf 23', | ||||
|           ], | ||||
|           twoPass: false, | ||||
|         }, | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should disable thread pooling for hevc if thread limit is above 0', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([ | ||||
|         { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, | ||||
|         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, | ||||
|       ]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec hevc', | ||||
|             '-acodec aac', | ||||
|             '-movflags faststart', | ||||
|             '-fps_mode passthrough', | ||||
|             '-vf scale=-2:720', | ||||
|             '-preset ultrafast', | ||||
|             '-threads 2', | ||||
|             '-x265-params "pools=none"', | ||||
|             '-x265-params "frame-threads=2"', | ||||
|             '-crf 23', | ||||
|           ], | ||||
|           twoPass: false, | ||||
|         }, | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should omit thread flags for hevc if thread limit is at or below 0', async () => { | ||||
|       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); | ||||
|       configMock.load.mockResolvedValue([ | ||||
|         { key: SystemConfigKey.FFMPEG_THREADS, value: 0 }, | ||||
|         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, | ||||
|       ]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); | ||||
|       await sut.handleVideoConversion({ id: assetEntityStub.video.id }); | ||||
|       expect(mediaMock.transcode).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/encoded-video/user-id/asset-id.mp4', | ||||
|         { | ||||
|           inputOptions: [], | ||||
|           outputOptions: [ | ||||
|             '-vcodec hevc', | ||||
|             '-acodec aac', | ||||
|             '-movflags faststart', | ||||
|             '-fps_mode passthrough', | ||||
|             '-vf scale=-2:720', | ||||
|             '-preset ultrafast', | ||||
|             '-crf 23', | ||||
|           ], | ||||
|           twoPass: false, | ||||
|         }, | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										191
									
								
								server/src/domain/media/media.util.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								server/src/domain/media/media.util.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | ||||
| import { SystemConfigFFmpegDto } from '../system-config/dto'; | ||||
| import { BitrateDistribution, TranscodeOptions, VideoCodecSWConfig, VideoStreamInfo } from './media.repository'; | ||||
|  | ||||
| class BaseConfig implements VideoCodecSWConfig { | ||||
|   constructor(protected config: SystemConfigFFmpegDto) {} | ||||
|  | ||||
|   getOptions(stream: VideoStreamInfo) { | ||||
|     const options = { | ||||
|       inputOptions: this.getBaseInputOptions(), | ||||
|       outputOptions: this.getBaseOutputOptions(), | ||||
|       twoPass: this.eligibleForTwoPass(), | ||||
|     } as TranscodeOptions; | ||||
|     const filters = this.getFilterOptions(stream); | ||||
|     if (filters.length > 0) { | ||||
|       options.outputOptions.push(`-vf ${filters.join(',')}`); | ||||
|     } | ||||
|     options.outputOptions.push(...this.getPresetOptions()); | ||||
|     options.outputOptions.push(...this.getThreadOptions()); | ||||
|     options.outputOptions.push(...this.getBitrateOptions()); | ||||
|  | ||||
|     return options; | ||||
|   } | ||||
|  | ||||
|   getBaseInputOptions(): string[] { | ||||
|     return []; | ||||
|   } | ||||
|  | ||||
|   getBaseOutputOptions() { | ||||
|     return [ | ||||
|       `-vcodec ${this.config.targetVideoCodec}`, | ||||
|       `-acodec ${this.config.targetAudioCodec}`, | ||||
|       // Makes a second pass moving the moov atom to the beginning of | ||||
|       // the file for improved playback speed. | ||||
|       '-movflags faststart', | ||||
|       '-fps_mode passthrough', | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   getFilterOptions(stream: VideoStreamInfo) { | ||||
|     const options = []; | ||||
|     if (this.shouldScale(stream)) { | ||||
|       options.push(`scale=${this.getScaling(stream)}`); | ||||
|     } | ||||
|  | ||||
|     return options; | ||||
|   } | ||||
|  | ||||
|   getPresetOptions() { | ||||
|     return [`-preset ${this.config.preset}`]; | ||||
|   } | ||||
|  | ||||
|   getBitrateOptions() { | ||||
|     const bitrates = this.getBitrateDistribution(); | ||||
|     if (this.eligibleForTwoPass()) { | ||||
|       return [ | ||||
|         `-b:v ${bitrates.target}${bitrates.unit}`, | ||||
|         `-minrate ${bitrates.min}${bitrates.unit}`, | ||||
|         `-maxrate ${bitrates.max}${bitrates.unit}`, | ||||
|       ]; | ||||
|     } else if (bitrates.max > 0) { | ||||
|       // -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate | ||||
|       return [ | ||||
|         `-crf ${this.config.crf}`, | ||||
|         `-maxrate ${bitrates.max}${bitrates.unit}`, | ||||
|         `-bufsize ${bitrates.max * 2}${bitrates.unit}`, | ||||
|       ]; | ||||
|     } else { | ||||
|       return [`-crf ${this.config.crf}`]; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   getThreadOptions(): Array<string> { | ||||
|     if (this.config.threads <= 0) { | ||||
|       return []; | ||||
|     } | ||||
|     return [`-threads ${this.config.threads}`]; | ||||
|   } | ||||
|  | ||||
|   eligibleForTwoPass() { | ||||
|     if (!this.config.twoPass) { | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     return this.isBitrateConstrained() || this.config.targetVideoCodec === 'vp9'; | ||||
|   } | ||||
|  | ||||
|   getBitrateDistribution() { | ||||
|     const max = this.getMaxBitrateValue(); | ||||
|     const target = Math.ceil(max / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod | ||||
|     const min = target / 2; | ||||
|     const unit = this.getBitrateUnit(); | ||||
|  | ||||
|     return { max, target, min, unit } as BitrateDistribution; | ||||
|   } | ||||
|  | ||||
|   getTargetResolution(stream: VideoStreamInfo) { | ||||
|     if (this.config.targetResolution === 'original') { | ||||
|       return Math.min(stream.height, stream.width); | ||||
|     } | ||||
|  | ||||
|     return Number.parseInt(this.config.targetResolution); | ||||
|   } | ||||
|  | ||||
|   shouldScale(stream: VideoStreamInfo) { | ||||
|     return Math.min(stream.height, stream.width) > this.getTargetResolution(stream); | ||||
|   } | ||||
|  | ||||
|   getScaling(stream: VideoStreamInfo) { | ||||
|     const targetResolution = this.getTargetResolution(stream); | ||||
|     return this.isVideoVertical(stream) ? `${targetResolution}:-2` : `-2:${targetResolution}`; | ||||
|   } | ||||
|  | ||||
|   isVideoRotated(stream: VideoStreamInfo) { | ||||
|     return Math.abs(stream.rotation) === 90; | ||||
|   } | ||||
|  | ||||
|   isVideoVertical(stream: VideoStreamInfo) { | ||||
|     return stream.height > stream.width || this.isVideoRotated(stream); | ||||
|   } | ||||
|  | ||||
|   isBitrateConstrained() { | ||||
|     return this.getMaxBitrateValue() > 0; | ||||
|   } | ||||
|  | ||||
|   getBitrateUnit() { | ||||
|     const maxBitrate = this.getMaxBitrateValue(); | ||||
|     return this.config.maxBitrate.trim().substring(maxBitrate.toString().length); // use inputted unit if provided | ||||
|   } | ||||
|  | ||||
|   getMaxBitrateValue() { | ||||
|     return Number.parseInt(this.config.maxBitrate) || 0; | ||||
|   } | ||||
|  | ||||
|   getPresetIndex() { | ||||
|     const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; | ||||
|     return presets.indexOf(this.config.preset); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class H264Config extends BaseConfig { | ||||
|   getThreadOptions() { | ||||
|     if (this.config.threads <= 0) { | ||||
|       return []; | ||||
|     } | ||||
|     return [ | ||||
|       ...super.getThreadOptions(), | ||||
|       '-x264-params "pools=none"', | ||||
|       `-x264-params "frame-threads=${this.config.threads}"`, | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class HEVCConfig extends BaseConfig { | ||||
|   getThreadOptions() { | ||||
|     if (this.config.threads <= 0) { | ||||
|       return []; | ||||
|     } | ||||
|     return [ | ||||
|       ...super.getThreadOptions(), | ||||
|       '-x265-params "pools=none"', | ||||
|       `-x265-params "frame-threads=${this.config.threads}"`, | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class VP9Config extends BaseConfig { | ||||
|   getPresetOptions() { | ||||
|     const speed = Math.min(this.getPresetIndex(), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads | ||||
|     if (speed >= 0) { | ||||
|       return [`-cpu-used ${speed}`]; | ||||
|     } | ||||
|     return []; | ||||
|   } | ||||
|  | ||||
|   getBitrateOptions() { | ||||
|     const bitrates = this.getBitrateDistribution(); | ||||
|     if (this.eligibleForTwoPass()) { | ||||
|       return [ | ||||
|         `-b:v ${bitrates.target}${bitrates.unit}`, | ||||
|         `-minrate ${bitrates.min}${bitrates.unit}`, | ||||
|         `-maxrate ${bitrates.max}${bitrates.unit}`, | ||||
|       ]; | ||||
|     } | ||||
|  | ||||
|     return [`-crf ${this.config.crf}`, `-b:v ${bitrates.max}${bitrates.unit}`]; | ||||
|   } | ||||
|  | ||||
|   getThreadOptions() { | ||||
|     return ['-row-mt 1', ...super.getThreadOptions()]; | ||||
|   } | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { TranscodePreset } from '@app/infra/entities'; | ||||
| import { AudioCodec, TranscodePolicy, VideoCodec } from '@app/infra/entities'; | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { Type } from 'class-transformer'; | ||||
| import { IsBoolean, IsEnum, IsInt, IsString, Max, Min } from 'class-validator'; | ||||
| @@ -20,11 +20,13 @@ export class SystemConfigFFmpegDto { | ||||
|   @IsString() | ||||
|   preset!: string; | ||||
|  | ||||
|   @IsString() | ||||
|   targetVideoCodec!: string; | ||||
|   @IsEnum(VideoCodec) | ||||
|   @ApiProperty({ enumName: 'VideoCodec', enum: VideoCodec }) | ||||
|   targetVideoCodec!: VideoCodec; | ||||
|  | ||||
|   @IsString() | ||||
|   targetAudioCodec!: string; | ||||
|   @IsEnum(AudioCodec) | ||||
|   @ApiProperty({ enumName: 'AudioCodec', enum: AudioCodec }) | ||||
|   targetAudioCodec!: AudioCodec; | ||||
|  | ||||
|   @IsString() | ||||
|   targetResolution!: string; | ||||
| @@ -35,6 +37,7 @@ export class SystemConfigFFmpegDto { | ||||
|   @IsBoolean() | ||||
|   twoPass!: boolean; | ||||
|  | ||||
|   @IsEnum(TranscodePreset) | ||||
|   transcode!: TranscodePreset; | ||||
|   @IsEnum(TranscodePolicy) | ||||
|   @ApiProperty({ enumName: 'TranscodePolicy', enum: TranscodePolicy }) | ||||
|   transcode!: TranscodePolicy; | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import { | ||||
|   AudioCodec, | ||||
|   SystemConfig, | ||||
|   SystemConfigEntity, | ||||
|   SystemConfigKey, | ||||
|   SystemConfigValue, | ||||
|   TranscodePreset, | ||||
|   TranscodePolicy, | ||||
|   VideoCodec, | ||||
| } from '@app/infra/entities'; | ||||
| import { BadRequestException, Injectable, Logger } from '@nestjs/common'; | ||||
| import * as _ from 'lodash'; | ||||
| @@ -19,12 +21,12 @@ const defaults = Object.freeze<SystemConfig>({ | ||||
|     crf: 23, | ||||
|     threads: 0, | ||||
|     preset: 'ultrafast', | ||||
|     targetVideoCodec: 'h264', | ||||
|     targetAudioCodec: 'aac', | ||||
|     targetVideoCodec: VideoCodec.H264, | ||||
|     targetAudioCodec: AudioCodec.AAC, | ||||
|     targetResolution: '720', | ||||
|     maxBitrate: '0', | ||||
|     twoPass: false, | ||||
|     transcode: TranscodePreset.REQUIRED, | ||||
|     transcode: TranscodePolicy.REQUIRED, | ||||
|   }, | ||||
|   job: { | ||||
|     [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, | ||||
|   | ||||
| @@ -1,4 +1,11 @@ | ||||
| import { SystemConfig, SystemConfigEntity, SystemConfigKey, TranscodePreset } from '@app/infra/entities'; | ||||
| import { | ||||
|   AudioCodec, | ||||
|   SystemConfig, | ||||
|   SystemConfigEntity, | ||||
|   SystemConfigKey, | ||||
|   TranscodePolicy, | ||||
|   VideoCodec, | ||||
| } from '@app/infra/entities'; | ||||
| import { BadRequestException } from '@nestjs/common'; | ||||
| import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test'; | ||||
| import { IJobRepository, JobName, QueueName } from '../job'; | ||||
| @@ -28,12 +35,12 @@ const updatedConfig = Object.freeze<SystemConfig>({ | ||||
|     crf: 30, | ||||
|     threads: 0, | ||||
|     preset: 'ultrafast', | ||||
|     targetAudioCodec: 'aac', | ||||
|     targetAudioCodec: AudioCodec.AAC, | ||||
|     targetResolution: '720', | ||||
|     targetVideoCodec: 'h264', | ||||
|     targetVideoCodec: VideoCodec.H264, | ||||
|     maxBitrate: '0', | ||||
|     twoPass: false, | ||||
|     transcode: TranscodePreset.REQUIRED, | ||||
|     transcode: TranscodePolicy.REQUIRED, | ||||
|   }, | ||||
|   oauth: { | ||||
|     autoLaunch: true, | ||||
|   | ||||
| @@ -51,24 +51,36 @@ export enum SystemConfigKey { | ||||
|   STORAGE_TEMPLATE = 'storageTemplate.template', | ||||
| } | ||||
|  | ||||
| export enum TranscodePreset { | ||||
| export enum TranscodePolicy { | ||||
|   ALL = 'all', | ||||
|   OPTIMAL = 'optimal', | ||||
|   REQUIRED = 'required', | ||||
|   DISABLED = 'disabled', | ||||
| } | ||||
|  | ||||
| export enum VideoCodec { | ||||
|   H264 = 'h264', | ||||
|   HEVC = 'hevc', | ||||
|   VP9 = 'vp9', | ||||
| } | ||||
|  | ||||
| export enum AudioCodec { | ||||
|   MP3 = 'mp3', | ||||
|   AAC = 'aac', | ||||
|   OPUS = 'opus', | ||||
| } | ||||
|  | ||||
| export interface SystemConfig { | ||||
|   ffmpeg: { | ||||
|     crf: number; | ||||
|     threads: number; | ||||
|     preset: string; | ||||
|     targetVideoCodec: string; | ||||
|     targetAudioCodec: string; | ||||
|     targetVideoCodec: VideoCodec; | ||||
|     targetAudioCodec: AudioCodec; | ||||
|     targetResolution: string; | ||||
|     maxBitrate: string; | ||||
|     twoPass: boolean; | ||||
|     transcode: TranscodePreset; | ||||
|     transcode: TranscodePolicy; | ||||
|   }; | ||||
|   job: Record<QueueName, { concurrency: number }>; | ||||
|   oauth: { | ||||
|   | ||||
| @@ -65,7 +65,6 @@ const providers: Provider[] = [ | ||||
|   { provide: IJobRepository, useClass: JobRepository }, | ||||
|   { provide: IKeyRepository, useClass: APIKeyRepository }, | ||||
|   { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, | ||||
|   { provide: IMediaRepository, useClass: MediaRepository }, | ||||
|   { provide: IPartnerRepository, useClass: PartnerRepository }, | ||||
|   { provide: IPersonRepository, useClass: PersonRepository }, | ||||
|   { provide: ISearchRepository, useClass: TypesenseRepository }, | ||||
| @@ -74,6 +73,7 @@ const providers: Provider[] = [ | ||||
|   { provide: IStorageRepository, useClass: FilesystemProvider }, | ||||
|   { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, | ||||
|   { provide: ITagRepository, useClass: TagRepository }, | ||||
|   { provide: IMediaRepository, useClass: MediaRepository }, | ||||
|   { provide: IUserRepository, useClass: UserRepository }, | ||||
|   { provide: IUserTokenRepository, useClass: UserTokenRepository }, | ||||
| ]; | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain'; | ||||
| import { Logger } from '@nestjs/common'; | ||||
| import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; | ||||
| import fs from 'fs/promises'; | ||||
| import sharp from 'sharp'; | ||||
| @@ -7,6 +8,8 @@ import { promisify } from 'util'; | ||||
| const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe); | ||||
|  | ||||
| export class MediaRepository implements IMediaRepository { | ||||
|   private logger = new Logger(MediaRepository.name); | ||||
|  | ||||
|   crop(input: string, options: CropOptions): Promise<Buffer> { | ||||
|     return sharp(input, { failOnError: false }) | ||||
|       .extract({ | ||||
| @@ -47,7 +50,10 @@ export class MediaRepository implements IMediaRepository { | ||||
|           `-vf scale='min(${size},iw)':'min(${size},ih)':force_original_aspect_ratio=increase`, | ||||
|         ]) | ||||
|         .output(output) | ||||
|         .on('error', reject) | ||||
|         .on('error', (err, stdout, stderr) => { | ||||
|           this.logger.error(stderr); | ||||
|           reject(err); | ||||
|         }) | ||||
|         .on('end', resolve) | ||||
|         .run(); | ||||
|     }); | ||||
| @@ -87,7 +93,10 @@ export class MediaRepository implements IMediaRepository { | ||||
|         ffmpeg(input, { niceness: 10 }) | ||||
|           .outputOptions(options.outputOptions) | ||||
|           .output(output) | ||||
|           .on('error', reject) | ||||
|           .on('error', (err, stdout, stderr) => { | ||||
|             this.logger.error(stderr); | ||||
|             reject(err); | ||||
|           }) | ||||
|           .on('end', resolve) | ||||
|           .run(); | ||||
|       }); | ||||
| @@ -102,7 +111,10 @@ export class MediaRepository implements IMediaRepository { | ||||
|         .addOptions('-passlogfile', output) | ||||
|         .addOptions('-f null') | ||||
|         .output('/dev/null') // first pass output is not saved as only the .log file is needed | ||||
|         .on('error', reject) | ||||
|         .on('error', (err, stdout, stderr) => { | ||||
|           this.logger.error(stderr); | ||||
|           reject(err); | ||||
|         }) | ||||
|         .on('end', () => { | ||||
|           // second pass | ||||
|           ffmpeg(input, { niceness: 10 }) | ||||
| @@ -110,7 +122,10 @@ export class MediaRepository implements IMediaRepository { | ||||
|             .addOptions('-pass', '2') | ||||
|             .addOptions('-passlogfile', output) | ||||
|             .output(output) | ||||
|             .on('error', reject) | ||||
|             .on('error', (err, stdout, stderr) => { | ||||
|               this.logger.error(stderr); | ||||
|               reject(err); | ||||
|             }) | ||||
|             .on('end', () => fs.unlink(`${output}-0.log`)) | ||||
|             .on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true })) | ||||
|             .on('end', resolve) | ||||
|   | ||||
| @@ -60,7 +60,7 @@ export class AppService { | ||||
|       [JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(), | ||||
|       [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), | ||||
|       [JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data), | ||||
|       [JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data), | ||||
|       [JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWebpThumbnail(data), | ||||
|       [JobName.GENERATE_THUMBHASH_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbhashThumbnail(data), | ||||
|       [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), | ||||
|       [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import { | ||||
|   AssetEntity, | ||||
|   AssetFaceEntity, | ||||
|   AssetType, | ||||
|   AudioCodec, | ||||
|   ExifEntity, | ||||
|   PartnerEntity, | ||||
|   PersonEntity, | ||||
| @@ -27,9 +28,10 @@ import { | ||||
|   SystemConfig, | ||||
|   TagEntity, | ||||
|   TagType, | ||||
|   TranscodePreset, | ||||
|   TranscodePolicy, | ||||
|   UserEntity, | ||||
|   UserTokenEntity, | ||||
|   VideoCodec, | ||||
| } from '@app/infra/entities'; | ||||
|  | ||||
| const today = new Date(); | ||||
| @@ -685,12 +687,12 @@ export const systemConfigStub = { | ||||
|       crf: 23, | ||||
|       threads: 0, | ||||
|       preset: 'ultrafast', | ||||
|       targetAudioCodec: 'aac', | ||||
|       targetAudioCodec: AudioCodec.AAC, | ||||
|       targetResolution: '720', | ||||
|       targetVideoCodec: 'h264', | ||||
|       targetVideoCodec: VideoCodec.H264, | ||||
|       maxBitrate: '0', | ||||
|       twoPass: false, | ||||
|       transcode: TranscodePreset.REQUIRED, | ||||
|       transcode: TranscodePolicy.REQUIRED, | ||||
|     }, | ||||
|     job: { | ||||
|       [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user