1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02: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:
Mert 2023-07-08 22:43:11 -04:00 committed by GitHub
parent 27018e4ab6
commit 8349a28ed8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 701 additions and 228 deletions

View File

@ -746,6 +746,21 @@ export const AssetTypeEnum = {
export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum]; export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum];
/**
*
* @export
* @enum {string}
*/
export const AudioCodec = {
Mp3: 'mp3',
Aac: 'aac',
Opus: 'opus'
} as const;
export type AudioCodec = typeof AudioCodec[keyof typeof AudioCodec];
/** /**
* *
* @export * @export
@ -2411,24 +2426,30 @@ export interface SystemConfigFFmpegDto {
* @memberof SystemConfigFFmpegDto * @memberof SystemConfigFFmpegDto
*/ */
'threads': number; 'threads': number;
/**
*
* @type {VideoCodec}
* @memberof SystemConfigFFmpegDto
*/
'targetVideoCodec': VideoCodec;
/**
*
* @type {AudioCodec}
* @memberof SystemConfigFFmpegDto
*/
'targetAudioCodec': AudioCodec;
/**
*
* @type {TranscodePolicy}
* @memberof SystemConfigFFmpegDto
*/
'transcode': TranscodePolicy;
/** /**
* *
* @type {string} * @type {string}
* @memberof SystemConfigFFmpegDto * @memberof SystemConfigFFmpegDto
*/ */
'preset': string; 'preset': string;
/**
*
* @type {string}
* @memberof SystemConfigFFmpegDto
*/
'targetVideoCodec': string;
/**
*
* @type {string}
* @memberof SystemConfigFFmpegDto
*/
'targetAudioCodec': string;
/** /**
* *
* @type {string} * @type {string}
@ -2447,22 +2468,8 @@ export interface SystemConfigFFmpegDto {
* @memberof SystemConfigFFmpegDto * @memberof SystemConfigFFmpegDto
*/ */
'twoPass': boolean; 'twoPass': boolean;
/**
*
* @type {string}
* @memberof SystemConfigFFmpegDto
*/
'transcode': SystemConfigFFmpegDtoTranscodeEnum;
} }
export const SystemConfigFFmpegDtoTranscodeEnum = {
All: 'all',
Optimal: 'optimal',
Required: 'required',
Disabled: 'disabled'
} as const;
export type SystemConfigFFmpegDtoTranscodeEnum = typeof SystemConfigFFmpegDtoTranscodeEnum[keyof typeof SystemConfigFFmpegDtoTranscodeEnum];
/** /**
* *
@ -2749,6 +2756,22 @@ export const TimeGroupEnum = {
export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum]; export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum];
/**
*
* @export
* @enum {string}
*/
export const TranscodePolicy = {
All: 'all',
Optimal: 'optimal',
Required: 'required',
Disabled: 'disabled'
} as const;
export type TranscodePolicy = typeof TranscodePolicy[keyof typeof TranscodePolicy];
/** /**
* *
* @export * @export
@ -3027,6 +3050,21 @@ export interface ValidateAccessTokenResponseDto {
*/ */
'authStatus': boolean; 'authStatus': boolean;
} }
/**
*
* @export
* @enum {string}
*/
export const VideoCodec = {
H264: 'h264',
Hevc: 'hevc',
Vp9: 'vp9'
} as const;
export type VideoCodec = typeof VideoCodec[keyof typeof VideoCodec];
/** /**
* APIKeyApi - axios parameter creator * APIKeyApi - axios parameter creator

View File

@ -29,6 +29,7 @@ doc/AssetIdsDto.md
doc/AssetIdsResponseDto.md doc/AssetIdsResponseDto.md
doc/AssetResponseDto.md doc/AssetResponseDto.md
doc/AssetTypeEnum.md doc/AssetTypeEnum.md
doc/AudioCodec.md
doc/AuthDeviceResponseDto.md doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md doc/AuthenticationApi.md
doc/ChangePasswordDto.md doc/ChangePasswordDto.md
@ -108,6 +109,7 @@ doc/TagResponseDto.md
doc/TagTypeEnum.md doc/TagTypeEnum.md
doc/ThumbnailFormat.md doc/ThumbnailFormat.md
doc/TimeGroupEnum.md doc/TimeGroupEnum.md
doc/TranscodePolicy.md
doc/UpdateAlbumDto.md doc/UpdateAlbumDto.md
doc/UpdateAssetDto.md doc/UpdateAssetDto.md
doc/UpdateTagDto.md doc/UpdateTagDto.md
@ -117,6 +119,7 @@ doc/UserApi.md
doc/UserCountResponseDto.md doc/UserCountResponseDto.md
doc/UserResponseDto.md doc/UserResponseDto.md
doc/ValidateAccessTokenResponseDto.md doc/ValidateAccessTokenResponseDto.md
doc/VideoCodec.md
git_push.sh git_push.sh
lib/api.dart lib/api.dart
lib/api/album_api.dart lib/api/album_api.dart
@ -164,6 +167,7 @@ lib/model/asset_ids_dto.dart
lib/model/asset_ids_response_dto.dart lib/model/asset_ids_response_dto.dart
lib/model/asset_response_dto.dart lib/model/asset_response_dto.dart
lib/model/asset_type_enum.dart lib/model/asset_type_enum.dart
lib/model/audio_codec.dart
lib/model/auth_device_response_dto.dart lib/model/auth_device_response_dto.dart
lib/model/change_password_dto.dart lib/model/change_password_dto.dart
lib/model/check_duplicate_asset_dto.dart lib/model/check_duplicate_asset_dto.dart
@ -233,6 +237,7 @@ lib/model/tag_response_dto.dart
lib/model/tag_type_enum.dart lib/model/tag_type_enum.dart
lib/model/thumbnail_format.dart lib/model/thumbnail_format.dart
lib/model/time_group_enum.dart lib/model/time_group_enum.dart
lib/model/transcode_policy.dart
lib/model/update_album_dto.dart lib/model/update_album_dto.dart
lib/model/update_asset_dto.dart lib/model/update_asset_dto.dart
lib/model/update_tag_dto.dart lib/model/update_tag_dto.dart
@ -241,6 +246,7 @@ lib/model/usage_by_user_dto.dart
lib/model/user_count_response_dto.dart lib/model/user_count_response_dto.dart
lib/model/user_response_dto.dart lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart lib/model/validate_access_token_response_dto.dart
lib/model/video_codec.dart
pubspec.yaml pubspec.yaml
test/add_assets_dto_test.dart test/add_assets_dto_test.dart
test/add_assets_response_dto_test.dart test/add_assets_response_dto_test.dart
@ -268,6 +274,7 @@ test/asset_ids_dto_test.dart
test/asset_ids_response_dto_test.dart test/asset_ids_response_dto_test.dart
test/asset_response_dto_test.dart test/asset_response_dto_test.dart
test/asset_type_enum_test.dart test/asset_type_enum_test.dart
test/audio_codec_test.dart
test/auth_device_response_dto_test.dart test/auth_device_response_dto_test.dart
test/authentication_api_test.dart test/authentication_api_test.dart
test/change_password_dto_test.dart test/change_password_dto_test.dart
@ -347,6 +354,7 @@ test/tag_response_dto_test.dart
test/tag_type_enum_test.dart test/tag_type_enum_test.dart
test/thumbnail_format_test.dart test/thumbnail_format_test.dart
test/time_group_enum_test.dart test/time_group_enum_test.dart
test/transcode_policy_test.dart
test/update_album_dto_test.dart test/update_album_dto_test.dart
test/update_asset_dto_test.dart test/update_asset_dto_test.dart
test/update_tag_dto_test.dart test/update_tag_dto_test.dart
@ -356,3 +364,4 @@ test/user_api_test.dart
test/user_count_response_dto_test.dart test/user_count_response_dto_test.dart
test/user_response_dto_test.dart test/user_response_dto_test.dart
test/validate_access_token_response_dto_test.dart test/validate_access_token_response_dto_test.dart
test/video_codec_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

BIN
mobile/openapi/doc/AudioCodec.md generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/TranscodePolicy.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/VideoCodec.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/model/audio_codec.dart generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/model/video_codec.dart generated Normal file

Binary file not shown.

BIN
mobile/openapi/test/audio_codec_test.dart generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/test/video_codec_test.dart generated Normal file

Binary file not shown.

View File

@ -4929,6 +4929,14 @@
"OTHER" "OTHER"
] ]
}, },
"AudioCodec": {
"type": "string",
"enum": [
"mp3",
"aac",
"opus"
]
},
"AuthDeviceResponseDto": { "AuthDeviceResponseDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -6347,13 +6355,16 @@
"threads": { "threads": {
"type": "integer" "type": "integer"
}, },
"preset": {
"type": "string"
},
"targetVideoCodec": { "targetVideoCodec": {
"type": "string" "$ref": "#/components/schemas/VideoCodec"
}, },
"targetAudioCodec": { "targetAudioCodec": {
"$ref": "#/components/schemas/AudioCodec"
},
"transcode": {
"$ref": "#/components/schemas/TranscodePolicy"
},
"preset": {
"type": "string" "type": "string"
}, },
"targetResolution": { "targetResolution": {
@ -6364,27 +6375,18 @@
}, },
"twoPass": { "twoPass": {
"type": "boolean" "type": "boolean"
},
"transcode": {
"type": "string",
"enum": [
"all",
"optimal",
"required",
"disabled"
]
} }
}, },
"required": [ "required": [
"crf", "crf",
"threads", "threads",
"preset",
"targetVideoCodec", "targetVideoCodec",
"targetAudioCodec", "targetAudioCodec",
"transcode",
"preset",
"targetResolution", "targetResolution",
"maxBitrate", "maxBitrate",
"twoPass", "twoPass"
"transcode"
] ]
}, },
"SystemConfigJobDto": { "SystemConfigJobDto": {
@ -6604,6 +6606,15 @@
"month" "month"
] ]
}, },
"TranscodePolicy": {
"type": "string",
"enum": [
"all",
"optimal",
"required",
"disabled"
]
},
"UpdateAlbumDto": { "UpdateAlbumDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -6804,6 +6815,14 @@
"required": [ "required": [
"authStatus" "authStatus"
] ]
},
"VideoCodec": {
"type": "string",
"enum": [
"h264",
"hevc",
"vp9"
]
} }
} }
} }

View File

@ -39,10 +39,22 @@ export interface CropOptions {
} }
export interface TranscodeOptions { export interface TranscodeOptions {
inputOptions: string[];
outputOptions: string[]; outputOptions: string[];
twoPass: boolean; twoPass: boolean;
} }
export interface BitrateDistribution {
max: number;
target: number;
min: number;
unit: string;
}
export interface VideoCodecSWConfig {
getOptions(stream: VideoStreamInfo): TranscodeOptions;
}
export interface IMediaRepository { export interface IMediaRepository {
// image // image
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>; resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;

View File

@ -1,4 +1,4 @@
import { AssetType, SystemConfigKey } from '@app/infra/entities'; import { AssetType, SystemConfigKey, TranscodePolicy, VideoCodec } from '@app/infra/entities';
import { import {
assetEntityStub, assetEntityStub,
newAssetRepositoryMock, newAssetRepositoryMock,
@ -104,6 +104,13 @@ describe(MediaService.name, () => {
}); });
describe('handleGenerateJpegThumbnail', () => { 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 () => { it('should generate a thumbnail for an image', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id }); await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
@ -142,15 +149,22 @@ describe(MediaService.name, () => {
}); });
describe('handleGenerateWebpThumbnail', () => { 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 () => { it('should skip thumbnail generate if resize path is missing', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]); assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.noResizePath.id }); await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.noResizePath.id });
expect(mediaMock.resize).not.toHaveBeenCalled(); expect(mediaMock.resize).not.toHaveBeenCalled();
}); });
it('should generate a thumbnail', async () => { it('should generate a thumbnail', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.image.id }); await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id });
expect(mediaMock.resize).toHaveBeenCalledWith( expect(mediaMock.resize).toHaveBeenCalledWith(
'/uploads/user-id/thumbs/path.ext', '/uploads/user-id/thumbs/path.ext',
@ -162,6 +176,12 @@ describe(MediaService.name, () => {
}); });
describe('handleGenerateThumbhashThumbnail', () => { 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 () => { it('should skip thumbhash generation if resize path is missing', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]); assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.noResizePath.id }); await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.noResizePath.id });
@ -219,6 +239,20 @@ describe(MediaService.name, () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); 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 () => { it('should transcode the longest stream', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
@ -232,6 +266,7 @@ describe(MediaService.name, () => {
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-acodec aac',
@ -261,13 +296,14 @@ describe(MediaService.name, () => {
it('should transcode when set to all', async () => { it('should transcode when set to all', async () => {
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); 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]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id }); await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-acodec aac',
@ -283,12 +319,13 @@ describe(MediaService.name, () => {
it('should transcode when optimal and too big', async () => { it('should transcode when optimal and too big', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); 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 }); await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-acodec aac',
@ -306,7 +343,7 @@ describe(MediaService.name, () => {
it('should not scale resolution if no target resolution', async () => { it('should not scale resolution if no target resolution', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([ configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }, { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL },
{ key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' },
]); ]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id }); await sut.handleVideoConversion({ id: assetEntityStub.video.id });
@ -314,6 +351,7 @@ describe(MediaService.name, () => {
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-acodec aac',
@ -329,13 +367,14 @@ describe(MediaService.name, () => {
it('should transcode with alternate scaling video is vertical', async () => { it('should transcode with alternate scaling video is vertical', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); 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]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id }); await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-acodec aac',
@ -352,13 +391,14 @@ describe(MediaService.name, () => {
it('should transcode when audio doesnt match target', async () => { it('should transcode when audio doesnt match target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3); 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]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id }); await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-acodec aac',
@ -375,13 +415,14 @@ describe(MediaService.name, () => {
it('should transcode when container doesnt match target', async () => { it('should transcode when container doesnt match target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); 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]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id }); await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-acodec aac',
@ -404,6 +445,22 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).not.toHaveBeenCalled(); 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 () => { it('should set max bitrate if above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]);
@ -413,6 +470,7 @@ describe(MediaService.name, () => {
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-acodec aac',
@ -441,6 +499,7 @@ describe(MediaService.name, () => {
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-acodec aac',
@ -466,6 +525,7 @@ describe(MediaService.name, () => {
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-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); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' }, { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
]); ]);
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id }); await sut.handleVideoConversion({ id: assetEntityStub.video.id });
@ -492,6 +553,7 @@ describe(MediaService.name, () => {
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec vp9', '-vcodec vp9',
'-acodec aac', '-acodec aac',
@ -500,7 +562,64 @@ describe(MediaService.name, () => {
'-vf scale=-2:720', '-vf scale=-2:720',
'-cpu-used 5', '-cpu-used 5',
'-row-mt 1', '-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', '-crf 23',
'-b:v 0', '-b:v 0',
], ],
@ -512,7 +631,7 @@ describe(MediaService.name, () => {
it('should configure threads if above 0', async () => { it('should configure threads if above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ 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 }, { key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
]); ]);
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
@ -521,6 +640,7 @@ describe(MediaService.name, () => {
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec vp9', '-vcodec vp9',
'-acodec aac', '-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); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
@ -547,6 +667,7 @@ describe(MediaService.name, () => {
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
{ {
inputOptions: [],
outputOptions: [ outputOptions: [
'-vcodec h264', '-vcodec h264',
'-acodec aac', '-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,
},
);
});
}); });
}); });

View File

@ -1,5 +1,5 @@
import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities'; import { AssetEntity, AssetType, TranscodePolicy, VideoCodec } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
import { join } from 'path'; import { join } from 'path';
import { IAssetRepository, WithoutProperty } from '../asset'; import { IAssetRepository, WithoutProperty } from '../asset';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
@ -9,6 +9,7 @@ import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config
import { SystemConfigCore } from '../system-config/system-config.core'; import { SystemConfigCore } from '../system-config/system-config.core';
import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant'; import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant';
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository'; import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository';
import { H264Config, HEVCConfig, VP9Config } from './media.util';
@Injectable() @Injectable()
export class MediaService { export class MediaService {
@ -82,7 +83,7 @@ export class MediaService {
return true; return true;
} }
async handleGenerateWepbThumbnail({ id }: IEntityJob) { async handleGenerateWebpThumbnail({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || !asset.resizePath) { if (!asset || !asset.resizePath) {
return false; return false;
@ -152,11 +153,16 @@ export class MediaService {
return false; return false;
} }
const outputOptions = this.getFfmpegOptions(mainVideoStream, config); let transcodeOptions;
const twoPass = this.eligibleForTwoPass(config); 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}`); this.logger.log(`Start encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`);
await this.mediaRepository.transcode(input, output, { outputOptions, twoPass }); await this.mediaRepository.transcode(input, output, transcodeOptions);
this.logger.log(`Encoding success ${asset.id}`); this.logger.log(`Encoding success ${asset.id}`);
@ -199,16 +205,16 @@ export class MediaService {
const isLargerThanTargetRes = scalingEnabled && Math.min(videoStream.height, videoStream.width) > targetRes; const isLargerThanTargetRes = scalingEnabled && Math.min(videoStream.height, videoStream.width) > targetRes;
switch (ffmpegConfig.transcode) { switch (ffmpegConfig.transcode) {
case TranscodePreset.DISABLED: case TranscodePolicy.DISABLED:
return false; return false;
case TranscodePreset.ALL: case TranscodePolicy.ALL:
return true; return true;
case TranscodePreset.REQUIRED: case TranscodePolicy.REQUIRED:
return !allTargetsMatching; return !allTargetsMatching;
case TranscodePreset.OPTIMAL: case TranscodePolicy.OPTIMAL:
return !allTargetsMatching || isLargerThanTargetRes; return !allTargetsMatching || isLargerThanTargetRes;
default: default:
@ -216,99 +222,16 @@ export class MediaService {
} }
} }
private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) { private getCodecConfig(config: SystemConfigFFmpegDto) {
const options = [ switch (config.targetVideoCodec) {
`-vcodec ${ffmpeg.targetVideoCodec}`, case VideoCodec.H264:
`-acodec ${ffmpeg.targetAudioCodec}`, return new H264Config(config);
// Makes a second pass moving the moov atom to the beginning of case VideoCodec.HEVC:
// the file for improved playback speed. return new HEVCConfig(config);
'-movflags faststart', case VideoCodec.VP9:
'-fps_mode passthrough', return new VP9Config(config);
]; default:
throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`);
// 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}`);
} }
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;
} }
} }

View 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()];
}
}

View File

@ -1,4 +1,4 @@
import { TranscodePreset } from '@app/infra/entities'; import { AudioCodec, TranscodePolicy, VideoCodec } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsString, Max, Min } from 'class-validator'; import { IsBoolean, IsEnum, IsInt, IsString, Max, Min } from 'class-validator';
@ -20,11 +20,13 @@ export class SystemConfigFFmpegDto {
@IsString() @IsString()
preset!: string; preset!: string;
@IsString() @IsEnum(VideoCodec)
targetVideoCodec!: string; @ApiProperty({ enumName: 'VideoCodec', enum: VideoCodec })
targetVideoCodec!: VideoCodec;
@IsString() @IsEnum(AudioCodec)
targetAudioCodec!: string; @ApiProperty({ enumName: 'AudioCodec', enum: AudioCodec })
targetAudioCodec!: AudioCodec;
@IsString() @IsString()
targetResolution!: string; targetResolution!: string;
@ -35,6 +37,7 @@ export class SystemConfigFFmpegDto {
@IsBoolean() @IsBoolean()
twoPass!: boolean; twoPass!: boolean;
@IsEnum(TranscodePreset) @IsEnum(TranscodePolicy)
transcode!: TranscodePreset; @ApiProperty({ enumName: 'TranscodePolicy', enum: TranscodePolicy })
transcode!: TranscodePolicy;
} }

View File

@ -1,9 +1,11 @@
import { import {
AudioCodec,
SystemConfig, SystemConfig,
SystemConfigEntity, SystemConfigEntity,
SystemConfigKey, SystemConfigKey,
SystemConfigValue, SystemConfigValue,
TranscodePreset, TranscodePolicy,
VideoCodec,
} from '@app/infra/entities'; } from '@app/infra/entities';
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as _ from 'lodash'; import * as _ from 'lodash';
@ -19,12 +21,12 @@ const defaults = Object.freeze<SystemConfig>({
crf: 23, crf: 23,
threads: 0, threads: 0,
preset: 'ultrafast', preset: 'ultrafast',
targetVideoCodec: 'h264', targetVideoCodec: VideoCodec.H264,
targetAudioCodec: 'aac', targetAudioCodec: AudioCodec.AAC,
targetResolution: '720', targetResolution: '720',
maxBitrate: '0', maxBitrate: '0',
twoPass: false, twoPass: false,
transcode: TranscodePreset.REQUIRED, transcode: TranscodePolicy.REQUIRED,
}, },
job: { job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 5 }, [QueueName.BACKGROUND_TASK]: { concurrency: 5 },

View File

@ -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 { BadRequestException } from '@nestjs/common';
import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test'; import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test';
import { IJobRepository, JobName, QueueName } from '../job'; import { IJobRepository, JobName, QueueName } from '../job';
@ -28,12 +35,12 @@ const updatedConfig = Object.freeze<SystemConfig>({
crf: 30, crf: 30,
threads: 0, threads: 0,
preset: 'ultrafast', preset: 'ultrafast',
targetAudioCodec: 'aac', targetAudioCodec: AudioCodec.AAC,
targetResolution: '720', targetResolution: '720',
targetVideoCodec: 'h264', targetVideoCodec: VideoCodec.H264,
maxBitrate: '0', maxBitrate: '0',
twoPass: false, twoPass: false,
transcode: TranscodePreset.REQUIRED, transcode: TranscodePolicy.REQUIRED,
}, },
oauth: { oauth: {
autoLaunch: true, autoLaunch: true,

View File

@ -51,24 +51,36 @@ export enum SystemConfigKey {
STORAGE_TEMPLATE = 'storageTemplate.template', STORAGE_TEMPLATE = 'storageTemplate.template',
} }
export enum TranscodePreset { export enum TranscodePolicy {
ALL = 'all', ALL = 'all',
OPTIMAL = 'optimal', OPTIMAL = 'optimal',
REQUIRED = 'required', REQUIRED = 'required',
DISABLED = 'disabled', DISABLED = 'disabled',
} }
export enum VideoCodec {
H264 = 'h264',
HEVC = 'hevc',
VP9 = 'vp9',
}
export enum AudioCodec {
MP3 = 'mp3',
AAC = 'aac',
OPUS = 'opus',
}
export interface SystemConfig { export interface SystemConfig {
ffmpeg: { ffmpeg: {
crf: number; crf: number;
threads: number; threads: number;
preset: string; preset: string;
targetVideoCodec: string; targetVideoCodec: VideoCodec;
targetAudioCodec: string; targetAudioCodec: AudioCodec;
targetResolution: string; targetResolution: string;
maxBitrate: string; maxBitrate: string;
twoPass: boolean; twoPass: boolean;
transcode: TranscodePreset; transcode: TranscodePolicy;
}; };
job: Record<QueueName, { concurrency: number }>; job: Record<QueueName, { concurrency: number }>;
oauth: { oauth: {

View File

@ -65,7 +65,6 @@ const providers: Provider[] = [
{ provide: IJobRepository, useClass: JobRepository }, { provide: IJobRepository, useClass: JobRepository },
{ provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository },
{ provide: IMediaRepository, useClass: MediaRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository }, { provide: IPersonRepository, useClass: PersonRepository },
{ provide: ISearchRepository, useClass: TypesenseRepository }, { provide: ISearchRepository, useClass: TypesenseRepository },
@ -74,6 +73,7 @@ const providers: Provider[] = [
{ provide: IStorageRepository, useClass: FilesystemProvider }, { provide: IStorageRepository, useClass: FilesystemProvider },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: ITagRepository, useClass: TagRepository }, { provide: ITagRepository, useClass: TagRepository },
{ provide: IMediaRepository, useClass: MediaRepository },
{ provide: IUserRepository, useClass: UserRepository }, { provide: IUserRepository, useClass: UserRepository },
{ provide: IUserTokenRepository, useClass: UserTokenRepository }, { provide: IUserTokenRepository, useClass: UserTokenRepository },
]; ];

View File

@ -1,4 +1,5 @@
import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain'; import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain';
import { Logger } from '@nestjs/common';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import fs from 'fs/promises'; import fs from 'fs/promises';
import sharp from 'sharp'; import sharp from 'sharp';
@ -7,6 +8,8 @@ import { promisify } from 'util';
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe); const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
export class MediaRepository implements IMediaRepository { export class MediaRepository implements IMediaRepository {
private logger = new Logger(MediaRepository.name);
crop(input: string, options: CropOptions): Promise<Buffer> { crop(input: string, options: CropOptions): Promise<Buffer> {
return sharp(input, { failOnError: false }) return sharp(input, { failOnError: false })
.extract({ .extract({
@ -47,7 +50,10 @@ export class MediaRepository implements IMediaRepository {
`-vf scale='min(${size},iw)':'min(${size},ih)':force_original_aspect_ratio=increase`, `-vf scale='min(${size},iw)':'min(${size},ih)':force_original_aspect_ratio=increase`,
]) ])
.output(output) .output(output)
.on('error', reject) .on('error', (err, stdout, stderr) => {
this.logger.error(stderr);
reject(err);
})
.on('end', resolve) .on('end', resolve)
.run(); .run();
}); });
@ -87,7 +93,10 @@ export class MediaRepository implements IMediaRepository {
ffmpeg(input, { niceness: 10 }) ffmpeg(input, { niceness: 10 })
.outputOptions(options.outputOptions) .outputOptions(options.outputOptions)
.output(output) .output(output)
.on('error', reject) .on('error', (err, stdout, stderr) => {
this.logger.error(stderr);
reject(err);
})
.on('end', resolve) .on('end', resolve)
.run(); .run();
}); });
@ -102,7 +111,10 @@ export class MediaRepository implements IMediaRepository {
.addOptions('-passlogfile', output) .addOptions('-passlogfile', output)
.addOptions('-f null') .addOptions('-f null')
.output('/dev/null') // first pass output is not saved as only the .log file is needed .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', () => { .on('end', () => {
// second pass // second pass
ffmpeg(input, { niceness: 10 }) ffmpeg(input, { niceness: 10 })
@ -110,7 +122,10 @@ export class MediaRepository implements IMediaRepository {
.addOptions('-pass', '2') .addOptions('-pass', '2')
.addOptions('-passlogfile', output) .addOptions('-passlogfile', output)
.output(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.unlink(`${output}-0.log`))
.on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true })) .on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true }))
.on('end', resolve) .on('end', resolve)

View File

@ -60,7 +60,7 @@ export class AppService {
[JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(), [JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(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.GENERATE_THUMBHASH_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbhashThumbnail(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),

View File

@ -19,6 +19,7 @@ import {
AssetEntity, AssetEntity,
AssetFaceEntity, AssetFaceEntity,
AssetType, AssetType,
AudioCodec,
ExifEntity, ExifEntity,
PartnerEntity, PartnerEntity,
PersonEntity, PersonEntity,
@ -27,9 +28,10 @@ import {
SystemConfig, SystemConfig,
TagEntity, TagEntity,
TagType, TagType,
TranscodePreset, TranscodePolicy,
UserEntity, UserEntity,
UserTokenEntity, UserTokenEntity,
VideoCodec,
} from '@app/infra/entities'; } from '@app/infra/entities';
const today = new Date(); const today = new Date();
@ -685,12 +687,12 @@ export const systemConfigStub = {
crf: 23, crf: 23,
threads: 0, threads: 0,
preset: 'ultrafast', preset: 'ultrafast',
targetAudioCodec: 'aac', targetAudioCodec: AudioCodec.AAC,
targetResolution: '720', targetResolution: '720',
targetVideoCodec: 'h264', targetVideoCodec: VideoCodec.H264,
maxBitrate: '0', maxBitrate: '0',
twoPass: false, twoPass: false,
transcode: TranscodePreset.REQUIRED, transcode: TranscodePolicy.REQUIRED,
}, },
job: { job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 5 }, [QueueName.BACKGROUND_TASK]: { concurrency: 5 },

View File

@ -746,6 +746,21 @@ export const AssetTypeEnum = {
export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum]; export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum];
/**
*
* @export
* @enum {string}
*/
export const AudioCodec = {
Mp3: 'mp3',
Aac: 'aac',
Opus: 'opus'
} as const;
export type AudioCodec = typeof AudioCodec[keyof typeof AudioCodec];
/** /**
* *
* @export * @export
@ -2411,24 +2426,30 @@ export interface SystemConfigFFmpegDto {
* @memberof SystemConfigFFmpegDto * @memberof SystemConfigFFmpegDto
*/ */
'threads': number; 'threads': number;
/**
*
* @type {VideoCodec}
* @memberof SystemConfigFFmpegDto
*/
'targetVideoCodec': VideoCodec;
/**
*
* @type {AudioCodec}
* @memberof SystemConfigFFmpegDto
*/
'targetAudioCodec': AudioCodec;
/**
*
* @type {TranscodePolicy}
* @memberof SystemConfigFFmpegDto
*/
'transcode': TranscodePolicy;
/** /**
* *
* @type {string} * @type {string}
* @memberof SystemConfigFFmpegDto * @memberof SystemConfigFFmpegDto
*/ */
'preset': string; 'preset': string;
/**
*
* @type {string}
* @memberof SystemConfigFFmpegDto
*/
'targetVideoCodec': string;
/**
*
* @type {string}
* @memberof SystemConfigFFmpegDto
*/
'targetAudioCodec': string;
/** /**
* *
* @type {string} * @type {string}
@ -2447,22 +2468,8 @@ export interface SystemConfigFFmpegDto {
* @memberof SystemConfigFFmpegDto * @memberof SystemConfigFFmpegDto
*/ */
'twoPass': boolean; 'twoPass': boolean;
/**
*
* @type {string}
* @memberof SystemConfigFFmpegDto
*/
'transcode': SystemConfigFFmpegDtoTranscodeEnum;
} }
export const SystemConfigFFmpegDtoTranscodeEnum = {
All: 'all',
Optimal: 'optimal',
Required: 'required',
Disabled: 'disabled'
} as const;
export type SystemConfigFFmpegDtoTranscodeEnum = typeof SystemConfigFFmpegDtoTranscodeEnum[keyof typeof SystemConfigFFmpegDtoTranscodeEnum];
/** /**
* *
@ -2749,6 +2756,22 @@ export const TimeGroupEnum = {
export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum]; export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum];
/**
*
* @export
* @enum {string}
*/
export const TranscodePolicy = {
All: 'all',
Optimal: 'optimal',
Required: 'required',
Disabled: 'disabled'
} as const;
export type TranscodePolicy = typeof TranscodePolicy[keyof typeof TranscodePolicy];
/** /**
* *
* @export * @export
@ -3027,6 +3050,21 @@ export interface ValidateAccessTokenResponseDto {
*/ */
'authStatus': boolean; 'authStatus': boolean;
} }
/**
*
* @export
* @enum {string}
*/
export const VideoCodec = {
H264: 'h264',
Hevc: 'hevc',
Vp9: 'vp9'
} as const;
export type VideoCodec = typeof VideoCodec[keyof typeof VideoCodec];
/** /**
* APIKeyApi - axios parameter creator * APIKeyApi - axios parameter creator

View File

@ -3,7 +3,7 @@
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { api, SystemConfigFFmpegDto, SystemConfigFFmpegDtoTranscodeEnum } from '@api'; import { api, AudioCodec, SystemConfigFFmpegDto, TranscodePolicy, VideoCodec } from '@api';
import SettingButtonsRow from '../setting-buttons-row.svelte'; import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSelect from '../setting-select.svelte'; import SettingSelect from '../setting-select.svelte';
@ -113,9 +113,9 @@
desc="Opus is the highest quality option, but has lower compatibility with old devices or software." desc="Opus is the highest quality option, but has lower compatibility with old devices or software."
bind:value={ffmpegConfig.targetAudioCodec} bind:value={ffmpegConfig.targetAudioCodec}
options={[ options={[
{ value: 'aac', text: 'aac' }, { value: AudioCodec.Aac, text: 'aac' },
{ value: 'mp3', text: 'mp3' }, { value: AudioCodec.Mp3, text: 'mp3' },
{ value: 'opus', text: 'opus' }, { value: AudioCodec.Opus, text: 'opus' },
]} ]}
name="acodec" name="acodec"
isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)} isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
@ -126,9 +126,9 @@
desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files." desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files."
bind:value={ffmpegConfig.targetVideoCodec} bind:value={ffmpegConfig.targetVideoCodec}
options={[ options={[
{ value: 'h264', text: 'h264' }, { value: VideoCodec.H264, text: 'h264' },
{ value: 'hevc', text: 'hevc' }, { value: VideoCodec.Hevc, text: 'hevc' },
{ value: 'vp9', text: 'vp9' }, { value: VideoCodec.Vp9, text: 'vp9' },
]} ]}
name="vcodec" name="vcodec"
isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)} isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
@ -167,22 +167,22 @@
/> />
<SettingSelect <SettingSelect
label="TRANSCODE" label="TRANSCODE POLICY"
desc="Policy for when a video should be transcoded." desc="Policy for when a video should be transcoded."
bind:value={ffmpegConfig.transcode} bind:value={ffmpegConfig.transcode}
name="transcode" name="transcode"
options={[ options={[
{ value: SystemConfigFFmpegDtoTranscodeEnum.All, text: 'All videos' }, { value: TranscodePolicy.All, text: 'All videos' },
{ {
value: SystemConfigFFmpegDtoTranscodeEnum.Optimal, value: TranscodePolicy.Optimal,
text: 'Videos higher than target resolution or not in the desired format', text: 'Videos higher than target resolution or not in the desired format',
}, },
{ {
value: SystemConfigFFmpegDtoTranscodeEnum.Required, value: TranscodePolicy.Required,
text: 'Only videos not in the desired format', text: 'Only videos not in the desired format',
}, },
{ {
value: SystemConfigFFmpegDtoTranscodeEnum.Disabled, value: TranscodePolicy.Disabled,
text: "Don't transcode any videos, may break playback on some clients", text: "Don't transcode any videos, may break playback on some clients",
}, },
]} ]}