diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 277d676daa22e8d39a2d1a272d694a41dd78b613..3d661f9917b1c93494557a9e52064bc8e6b789e5 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -746,6 +746,21 @@ export const 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 @@ -2413,22 +2428,28 @@ export interface SystemConfigFFmpegDto { 'threads': number; /** * - * @type {string} + * @type {VideoCodec} * @memberof SystemConfigFFmpegDto */ - 'preset': string; + 'targetVideoCodec': VideoCodec; /** * - * @type {string} + * @type {AudioCodec} * @memberof SystemConfigFFmpegDto */ - 'targetVideoCodec': string; + 'targetAudioCodec': AudioCodec; + /** + * + * @type {TranscodePolicy} + * @memberof SystemConfigFFmpegDto + */ + 'transcode': TranscodePolicy; /** * * @type {string} * @memberof SystemConfigFFmpegDto */ - 'targetAudioCodec': string; + 'preset': string; /** * * @type {string} @@ -2447,22 +2468,8 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ '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 + * @enum {string} + */ + +export const TranscodePolicy = { + All: 'all', + Optimal: 'optimal', + Required: 'required', + Disabled: 'disabled' +} as const; + +export type TranscodePolicy = typeof TranscodePolicy[keyof typeof TranscodePolicy]; + + /** * * @export @@ -3027,6 +3050,21 @@ export interface ValidateAccessTokenResponseDto { */ '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 diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 26eeb1c6b627b59ba7927b551e982b0cdc62e8e7..9862f98c4ac126ffc1530c824dfcdbe7f810256e 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -29,6 +29,7 @@ doc/AssetIdsDto.md doc/AssetIdsResponseDto.md doc/AssetResponseDto.md doc/AssetTypeEnum.md +doc/AudioCodec.md doc/AuthDeviceResponseDto.md doc/AuthenticationApi.md doc/ChangePasswordDto.md @@ -108,6 +109,7 @@ doc/TagResponseDto.md doc/TagTypeEnum.md doc/ThumbnailFormat.md doc/TimeGroupEnum.md +doc/TranscodePolicy.md doc/UpdateAlbumDto.md doc/UpdateAssetDto.md doc/UpdateTagDto.md @@ -117,6 +119,7 @@ doc/UserApi.md doc/UserCountResponseDto.md doc/UserResponseDto.md doc/ValidateAccessTokenResponseDto.md +doc/VideoCodec.md git_push.sh lib/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_response_dto.dart lib/model/asset_type_enum.dart +lib/model/audio_codec.dart lib/model/auth_device_response_dto.dart lib/model/change_password_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/thumbnail_format.dart lib/model/time_group_enum.dart +lib/model/transcode_policy.dart lib/model/update_album_dto.dart lib/model/update_asset_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_response_dto.dart lib/model/validate_access_token_response_dto.dart +lib/model/video_codec.dart pubspec.yaml test/add_assets_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_response_dto_test.dart test/asset_type_enum_test.dart +test/audio_codec_test.dart test/auth_device_response_dto_test.dart test/authentication_api_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/thumbnail_format_test.dart test/time_group_enum_test.dart +test/transcode_policy_test.dart test/update_album_dto_test.dart test/update_asset_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_response_dto_test.dart test/validate_access_token_response_dto_test.dart +test/video_codec_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 42ecf4177dfc9cf2caf09d8c38ff0dab13fb4bf5..a78726e089f99e56859b06113cf219435672d3cd 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -199,6 +199,7 @@ Class | Method | HTTP request | Description - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md) - [AssetResponseDto](doc//AssetResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) + - [AudioCodec](doc//AudioCodec.md) - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md) - [ChangePasswordDto](doc//ChangePasswordDto.md) - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md) @@ -268,6 +269,7 @@ Class | Method | HTTP request | Description - [TagTypeEnum](doc//TagTypeEnum.md) - [ThumbnailFormat](doc//ThumbnailFormat.md) - [TimeGroupEnum](doc//TimeGroupEnum.md) + - [TranscodePolicy](doc//TranscodePolicy.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateTagDto](doc//UpdateTagDto.md) @@ -276,6 +278,7 @@ Class | Method | HTTP request | Description - [UserCountResponseDto](doc//UserCountResponseDto.md) - [UserResponseDto](doc//UserResponseDto.md) - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md) + - [VideoCodec](doc//VideoCodec.md) ## Documentation For Authorization diff --git a/mobile/openapi/doc/AudioCodec.md b/mobile/openapi/doc/AudioCodec.md new file mode 100644 index 0000000000000000000000000000000000000000..eef8591857f78520205860fe82460165c8637f7e --- /dev/null +++ b/mobile/openapi/doc/AudioCodec.md @@ -0,0 +1,14 @@ +# openapi.model.AudioCodec + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/SystemConfigFFmpegDto.md b/mobile/openapi/doc/SystemConfigFFmpegDto.md index e2dcb45db1ca87279cd267e82282bedcbd938225..a08261e797ba87da0d81d16a245cbe037aa5aa92 100644 --- a/mobile/openapi/doc/SystemConfigFFmpegDto.md +++ b/mobile/openapi/doc/SystemConfigFFmpegDto.md @@ -10,13 +10,13 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **crf** | **int** | | **threads** | **int** | | +**targetVideoCodec** | [**VideoCodec**](VideoCodec.md) | | +**targetAudioCodec** | [**AudioCodec**](AudioCodec.md) | | +**transcode** | [**TranscodePolicy**](TranscodePolicy.md) | | **preset** | **String** | | -**targetVideoCodec** | **String** | | -**targetAudioCodec** | **String** | | **targetResolution** | **String** | | **maxBitrate** | **String** | | **twoPass** | **bool** | | -**transcode** | **String** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/TranscodePolicy.md b/mobile/openapi/doc/TranscodePolicy.md new file mode 100644 index 0000000000000000000000000000000000000000..bf6b88cd3afcd71496d6f2b8b8a9da5730effac4 --- /dev/null +++ b/mobile/openapi/doc/TranscodePolicy.md @@ -0,0 +1,14 @@ +# openapi.model.TranscodePolicy + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/VideoCodec.md b/mobile/openapi/doc/VideoCodec.md new file mode 100644 index 0000000000000000000000000000000000000000..7b7d95798984cc6101cd4bca5a5123de322f45f4 --- /dev/null +++ b/mobile/openapi/doc/VideoCodec.md @@ -0,0 +1,14 @@ +# openapi.model.VideoCodec + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 47cfa9aa2cef615a8c38807ce79642de36faa873..604f07f19a290fbb0082f2a4f5262e2870f01eaa 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -66,6 +66,7 @@ part 'model/asset_ids_dto.dart'; part 'model/asset_ids_response_dto.dart'; part 'model/asset_response_dto.dart'; part 'model/asset_type_enum.dart'; +part 'model/audio_codec.dart'; part 'model/auth_device_response_dto.dart'; part 'model/change_password_dto.dart'; part 'model/check_duplicate_asset_dto.dart'; @@ -135,6 +136,7 @@ part 'model/tag_response_dto.dart'; part 'model/tag_type_enum.dart'; part 'model/thumbnail_format.dart'; part 'model/time_group_enum.dart'; +part 'model/transcode_policy.dart'; part 'model/update_album_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_tag_dto.dart'; @@ -143,6 +145,7 @@ part 'model/usage_by_user_dto.dart'; part 'model/user_count_response_dto.dart'; part 'model/user_response_dto.dart'; part 'model/validate_access_token_response_dto.dart'; +part 'model/video_codec.dart'; const _delimiters = {'csv': ',', 'ssv': ' ', 'tsv': '\t', 'pipes': '|'}; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 7ba532835829027e1822d11015ec859b1a333364..4ddf1833ab50567902ddad189f4ece92da42c37b 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -227,6 +227,8 @@ class ApiClient { return AssetResponseDto.fromJson(value); case 'AssetTypeEnum': return AssetTypeEnumTypeTransformer().decode(value); + case 'AudioCodec': + return AudioCodecTypeTransformer().decode(value); case 'AuthDeviceResponseDto': return AuthDeviceResponseDto.fromJson(value); case 'ChangePasswordDto': @@ -365,6 +367,8 @@ class ApiClient { return ThumbnailFormatTypeTransformer().decode(value); case 'TimeGroupEnum': return TimeGroupEnumTypeTransformer().decode(value); + case 'TranscodePolicy': + return TranscodePolicyTypeTransformer().decode(value); case 'UpdateAlbumDto': return UpdateAlbumDto.fromJson(value); case 'UpdateAssetDto': @@ -381,6 +385,8 @@ class ApiClient { return UserResponseDto.fromJson(value); case 'ValidateAccessTokenResponseDto': return ValidateAccessTokenResponseDto.fromJson(value); + case 'VideoCodec': + return VideoCodecTypeTransformer().decode(value); default: dynamic match; if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 386e6a7e76fbd1329aff3d00b5717f01e8bf500d..9e7f5c3bead0dd681d554041d86f8116d25520ec 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -58,6 +58,9 @@ String parameterToString(dynamic value) { if (value is AssetTypeEnum) { return AssetTypeEnumTypeTransformer().encode(value).toString(); } + if (value is AudioCodec) { + return AudioCodecTypeTransformer().encode(value).toString(); + } if (value is DeleteAssetStatus) { return DeleteAssetStatusTypeTransformer().encode(value).toString(); } @@ -79,6 +82,12 @@ String parameterToString(dynamic value) { if (value is TimeGroupEnum) { return TimeGroupEnumTypeTransformer().encode(value).toString(); } + if (value is TranscodePolicy) { + return TranscodePolicyTypeTransformer().encode(value).toString(); + } + if (value is VideoCodec) { + return VideoCodecTypeTransformer().encode(value).toString(); + } return value.toString(); } diff --git a/mobile/openapi/lib/model/audio_codec.dart b/mobile/openapi/lib/model/audio_codec.dart new file mode 100644 index 0000000000000000000000000000000000000000..f5b50006a690eec22c2afcb9a1026c1a0759846b --- /dev/null +++ b/mobile/openapi/lib/model/audio_codec.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AudioCodec { + /// Instantiate a new enum with the provided [value]. + const AudioCodec._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const mp3 = AudioCodec._(r'mp3'); + static const aac = AudioCodec._(r'aac'); + static const opus = AudioCodec._(r'opus'); + + /// List of all possible values in this [enum][AudioCodec]. + static const values = [ + mp3, + aac, + opus, + ]; + + static AudioCodec? fromJson(dynamic value) => AudioCodecTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AudioCodec.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AudioCodec] to String, +/// and [decode] dynamic data back to [AudioCodec]. +class AudioCodecTypeTransformer { + factory AudioCodecTypeTransformer() => _instance ??= const AudioCodecTypeTransformer._(); + + const AudioCodecTypeTransformer._(); + + String encode(AudioCodec data) => data.value; + + /// Decodes a [dynamic value][data] to a AudioCodec. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AudioCodec? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'mp3': return AudioCodec.mp3; + case r'aac': return AudioCodec.aac; + case r'opus': return AudioCodec.opus; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AudioCodecTypeTransformer] instance. + static AudioCodecTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index 2cb29e0ce6e68b1da05b7c4f1e143b0c07f25f02..7f21d9d6e1d28cf8901b10712a9ed95812129b4f 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -15,24 +15,26 @@ class SystemConfigFFmpegDto { SystemConfigFFmpegDto({ required this.crf, required this.threads, - required this.preset, required this.targetVideoCodec, required this.targetAudioCodec, + required this.transcode, + required this.preset, required this.targetResolution, required this.maxBitrate, required this.twoPass, - required this.transcode, }); int crf; int threads; - String preset; + VideoCodec targetVideoCodec; + + AudioCodec targetAudioCodec; - String targetVideoCodec; + TranscodePolicy transcode; - String targetAudioCodec; + String preset; String targetResolution; @@ -40,47 +42,45 @@ class SystemConfigFFmpegDto { bool twoPass; - SystemConfigFFmpegDtoTranscodeEnum transcode; - @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegDto && other.crf == crf && other.threads == threads && - other.preset == preset && other.targetVideoCodec == targetVideoCodec && other.targetAudioCodec == targetAudioCodec && + other.transcode == transcode && + other.preset == preset && other.targetResolution == targetResolution && other.maxBitrate == maxBitrate && - other.twoPass == twoPass && - other.transcode == transcode; + other.twoPass == twoPass; @override int get hashCode => // ignore: unnecessary_parenthesis (crf.hashCode) + (threads.hashCode) + - (preset.hashCode) + (targetVideoCodec.hashCode) + (targetAudioCodec.hashCode) + + (transcode.hashCode) + + (preset.hashCode) + (targetResolution.hashCode) + (maxBitrate.hashCode) + - (twoPass.hashCode) + - (transcode.hashCode); + (twoPass.hashCode); @override - String toString() => 'SystemConfigFFmpegDto[crf=$crf, threads=$threads, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, maxBitrate=$maxBitrate, twoPass=$twoPass, transcode=$transcode]'; + String toString() => 'SystemConfigFFmpegDto[crf=$crf, threads=$threads, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, transcode=$transcode, preset=$preset, targetResolution=$targetResolution, maxBitrate=$maxBitrate, twoPass=$twoPass]'; Map toJson() { final json = {}; json[r'crf'] = this.crf; json[r'threads'] = this.threads; - json[r'preset'] = this.preset; json[r'targetVideoCodec'] = this.targetVideoCodec; json[r'targetAudioCodec'] = this.targetAudioCodec; + json[r'transcode'] = this.transcode; + json[r'preset'] = this.preset; json[r'targetResolution'] = this.targetResolution; json[r'maxBitrate'] = this.maxBitrate; json[r'twoPass'] = this.twoPass; - json[r'transcode'] = this.transcode; return json; } @@ -94,13 +94,13 @@ class SystemConfigFFmpegDto { return SystemConfigFFmpegDto( crf: mapValueOfType(json, r'crf')!, threads: mapValueOfType(json, r'threads')!, + targetVideoCodec: VideoCodec.fromJson(json[r'targetVideoCodec'])!, + targetAudioCodec: AudioCodec.fromJson(json[r'targetAudioCodec'])!, + transcode: TranscodePolicy.fromJson(json[r'transcode'])!, preset: mapValueOfType(json, r'preset')!, - targetVideoCodec: mapValueOfType(json, r'targetVideoCodec')!, - targetAudioCodec: mapValueOfType(json, r'targetAudioCodec')!, targetResolution: mapValueOfType(json, r'targetResolution')!, maxBitrate: mapValueOfType(json, r'maxBitrate')!, twoPass: mapValueOfType(json, r'twoPass')!, - transcode: SystemConfigFFmpegDtoTranscodeEnum.fromJson(json[r'transcode'])!, ); } return null; @@ -150,93 +150,13 @@ class SystemConfigFFmpegDto { static const requiredKeys = { 'crf', 'threads', - 'preset', 'targetVideoCodec', 'targetAudioCodec', + 'transcode', + 'preset', 'targetResolution', 'maxBitrate', 'twoPass', - 'transcode', }; } - -class SystemConfigFFmpegDtoTranscodeEnum { - /// Instantiate a new enum with the provided [value]. - const SystemConfigFFmpegDtoTranscodeEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const all = SystemConfigFFmpegDtoTranscodeEnum._(r'all'); - static const optimal = SystemConfigFFmpegDtoTranscodeEnum._(r'optimal'); - static const required_ = SystemConfigFFmpegDtoTranscodeEnum._(r'required'); - static const disabled = SystemConfigFFmpegDtoTranscodeEnum._(r'disabled'); - - /// List of all possible values in this [enum][SystemConfigFFmpegDtoTranscodeEnum]. - static const values = [ - all, - optimal, - required_, - disabled, - ]; - - static SystemConfigFFmpegDtoTranscodeEnum? fromJson(dynamic value) => SystemConfigFFmpegDtoTranscodeEnumTypeTransformer().decode(value); - - static List? listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = SystemConfigFFmpegDtoTranscodeEnum.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [SystemConfigFFmpegDtoTranscodeEnum] to String, -/// and [decode] dynamic data back to [SystemConfigFFmpegDtoTranscodeEnum]. -class SystemConfigFFmpegDtoTranscodeEnumTypeTransformer { - factory SystemConfigFFmpegDtoTranscodeEnumTypeTransformer() => _instance ??= const SystemConfigFFmpegDtoTranscodeEnumTypeTransformer._(); - - const SystemConfigFFmpegDtoTranscodeEnumTypeTransformer._(); - - String encode(SystemConfigFFmpegDtoTranscodeEnum data) => data.value; - - /// Decodes a [dynamic value][data] to a SystemConfigFFmpegDtoTranscodeEnum. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - SystemConfigFFmpegDtoTranscodeEnum? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'all': return SystemConfigFFmpegDtoTranscodeEnum.all; - case r'optimal': return SystemConfigFFmpegDtoTranscodeEnum.optimal; - case r'required': return SystemConfigFFmpegDtoTranscodeEnum.required_; - case r'disabled': return SystemConfigFFmpegDtoTranscodeEnum.disabled; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [SystemConfigFFmpegDtoTranscodeEnumTypeTransformer] instance. - static SystemConfigFFmpegDtoTranscodeEnumTypeTransformer? _instance; -} - - diff --git a/mobile/openapi/lib/model/transcode_policy.dart b/mobile/openapi/lib/model/transcode_policy.dart new file mode 100644 index 0000000000000000000000000000000000000000..c490b5cfffdc7f4a585255e65418896d0d8e4543 --- /dev/null +++ b/mobile/openapi/lib/model/transcode_policy.dart @@ -0,0 +1,91 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class TranscodePolicy { + /// Instantiate a new enum with the provided [value]. + const TranscodePolicy._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const all = TranscodePolicy._(r'all'); + static const optimal = TranscodePolicy._(r'optimal'); + static const required_ = TranscodePolicy._(r'required'); + static const disabled = TranscodePolicy._(r'disabled'); + + /// List of all possible values in this [enum][TranscodePolicy]. + static const values = [ + all, + optimal, + required_, + disabled, + ]; + + static TranscodePolicy? fromJson(dynamic value) => TranscodePolicyTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TranscodePolicy.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [TranscodePolicy] to String, +/// and [decode] dynamic data back to [TranscodePolicy]. +class TranscodePolicyTypeTransformer { + factory TranscodePolicyTypeTransformer() => _instance ??= const TranscodePolicyTypeTransformer._(); + + const TranscodePolicyTypeTransformer._(); + + String encode(TranscodePolicy data) => data.value; + + /// Decodes a [dynamic value][data] to a TranscodePolicy. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + TranscodePolicy? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'all': return TranscodePolicy.all; + case r'optimal': return TranscodePolicy.optimal; + case r'required': return TranscodePolicy.required_; + case r'disabled': return TranscodePolicy.disabled; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [TranscodePolicyTypeTransformer] instance. + static TranscodePolicyTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/video_codec.dart b/mobile/openapi/lib/model/video_codec.dart new file mode 100644 index 0000000000000000000000000000000000000000..784c4acb51a9ba35d80f26a45d149edae2b388a2 --- /dev/null +++ b/mobile/openapi/lib/model/video_codec.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class VideoCodec { + /// Instantiate a new enum with the provided [value]. + const VideoCodec._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const h264 = VideoCodec._(r'h264'); + static const hevc = VideoCodec._(r'hevc'); + static const vp9 = VideoCodec._(r'vp9'); + + /// List of all possible values in this [enum][VideoCodec]. + static const values = [ + h264, + hevc, + vp9, + ]; + + static VideoCodec? fromJson(dynamic value) => VideoCodecTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = VideoCodec.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [VideoCodec] to String, +/// and [decode] dynamic data back to [VideoCodec]. +class VideoCodecTypeTransformer { + factory VideoCodecTypeTransformer() => _instance ??= const VideoCodecTypeTransformer._(); + + const VideoCodecTypeTransformer._(); + + String encode(VideoCodec data) => data.value; + + /// Decodes a [dynamic value][data] to a VideoCodec. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + VideoCodec? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'h264': return VideoCodec.h264; + case r'hevc': return VideoCodec.hevc; + case r'vp9': return VideoCodec.vp9; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [VideoCodecTypeTransformer] instance. + static VideoCodecTypeTransformer? _instance; +} + diff --git a/mobile/openapi/test/audio_codec_test.dart b/mobile/openapi/test/audio_codec_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..a6c61661d621cb5e4a296ab0d9f00fd01bba8d83 --- /dev/null +++ b/mobile/openapi/test/audio_codec_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for AudioCodec +void main() { + + group('test AudioCodec', () { + + }); + +} diff --git a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart index 3305d8d001e08bd10398fe347f1f78459fc34cbd..7f210e9780864e42ddd93f3f0f5a4663cc4c1d81 100644 --- a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart +++ b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart @@ -26,18 +26,23 @@ void main() { // TODO }); - // String preset - test('to test the property `preset`', () async { + // VideoCodec targetVideoCodec + test('to test the property `targetVideoCodec`', () async { // TODO }); - // String targetVideoCodec - test('to test the property `targetVideoCodec`', () async { + // AudioCodec targetAudioCodec + test('to test the property `targetAudioCodec`', () async { // TODO }); - // String targetAudioCodec - test('to test the property `targetAudioCodec`', () async { + // TranscodePolicy transcode + test('to test the property `transcode`', () async { + // TODO + }); + + // String preset + test('to test the property `preset`', () async { // TODO }); @@ -56,11 +61,6 @@ void main() { // TODO }); - // String transcode - test('to test the property `transcode`', () async { - // TODO - }); - }); diff --git a/mobile/openapi/test/transcode_policy_test.dart b/mobile/openapi/test/transcode_policy_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..4a27e2a8814f4c7be98e6013f1df16c6d983a347 --- /dev/null +++ b/mobile/openapi/test/transcode_policy_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for TranscodePolicy +void main() { + + group('test TranscodePolicy', () { + + }); + +} diff --git a/mobile/openapi/test/video_codec_test.dart b/mobile/openapi/test/video_codec_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..8c1e4a17ff6fb637465f49cdd2b54d77aa30b1eb --- /dev/null +++ b/mobile/openapi/test/video_codec_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for VideoCodec +void main() { + + group('test VideoCodec', () { + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 5f664730f016aeea094b61aa1d8f4230367526f2..b35660fda8b36f9f2792af6655894537dcc537e7 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -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" + ] } } } diff --git a/server/src/domain/media/media.repository.ts b/server/src/domain/media/media.repository.ts index c3d7dc0e4176fea5cb938793d19b07ef46727f2d..c6ca835df494e121437d5cb63a2cdb977872b47c 100644 --- a/server/src/domain/media/media.repository.ts +++ b/server/src/domain/media/media.repository.ts @@ -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; diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 010e68a23b22d6bd441a69f6c69ad8633aacb1f0..8a5f1e297f546d473a1a720d225d7ef30eacc878 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -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, + }, + ); + }); }); }); diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 91f25df8776285ae39996fbf0e65a7b84c6b3dc8..cfc04fba117cf87e249137ef4a77cca4f80ddde9 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -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}`); - } - - 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; + 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`); } - - const isVP9 = ffmpeg.targetVideoCodec === 'vp9'; - const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0; - const constrainMaximumBitrate = maxBitrateValue > 0; - - return constrainMaximumBitrate || isVP9; } } diff --git a/server/src/domain/media/media.util.ts b/server/src/domain/media/media.util.ts new file mode 100644 index 0000000000000000000000000000000000000000..bee22e9e65c3050bb52b1b649d1ea7f28c7a5ab1 --- /dev/null +++ b/server/src/domain/media/media.util.ts @@ -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 { + 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()]; + } +} diff --git a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts index 1a641828d39af027b9bd33be7006235bca2d8981..01f9f9ca7f305becb3f5bb2a28088cf548e235f3 100644 --- a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts +++ b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts @@ -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; } diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index dcec26690f471f3a21caaa22f545a80ea1ada7d1..0c440835cd4bfea1075404b1a34645ecba1428c8 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -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({ 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 }, diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index e18eb296e9ea3046ef4fbca9177fc0431e4eddc9..54018df792d1d57f162a46a644f7826885a59b60 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -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({ 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, diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index 8d2a5c5b5cf91babec367826eaec3b30eca8c9ca..8046546132b0d21de67fea5f3cff459e39277688 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -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; oauth: { diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 20bac55d5572125f9c4f55253a40fc09ae549e4c..060c64ae35c139bacbf216425faa8eb5a45073a1 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -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 }, ]; diff --git a/server/src/infra/repositories/media.repository.ts b/server/src/infra/repositories/media.repository.ts index b73b61aaeb425602913f87cfbb0ca93ee833d8ef..4b0345faa4bf30973cb7f124c359cebc0c1462b1 100644 --- a/server/src/infra/repositories/media.repository.ts +++ b/server/src/infra/repositories/media.repository.ts @@ -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(ffmpeg.ffprobe); export class MediaRepository implements IMediaRepository { + private logger = new Logger(MediaRepository.name); + crop(input: string, options: CropOptions): Promise { 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) diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 079fd40d3053e2a74ede72d4ebcffb407b10eca4..a8f30e188828084852f83128d12410f7302a833c 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -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), diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index f1adb8a761baf9a9536046574050acd621dd9a8b..72a05a328a3fc814fe9476ab8cdc3b4ef1289ffe 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -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 }, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index c1a8f7f222ae80e271e9ebe528c9920bd440a589..1292c7481692e55671d0c57c706b6c9aa72d10f6 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -746,6 +746,21 @@ export const 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 @@ -2413,22 +2428,28 @@ export interface SystemConfigFFmpegDto { 'threads': number; /** * - * @type {string} + * @type {VideoCodec} * @memberof SystemConfigFFmpegDto */ - 'preset': string; + 'targetVideoCodec': VideoCodec; /** * - * @type {string} + * @type {AudioCodec} * @memberof SystemConfigFFmpegDto */ - 'targetVideoCodec': string; + 'targetAudioCodec': AudioCodec; + /** + * + * @type {TranscodePolicy} + * @memberof SystemConfigFFmpegDto + */ + 'transcode': TranscodePolicy; /** * * @type {string} * @memberof SystemConfigFFmpegDto */ - 'targetAudioCodec': string; + 'preset': string; /** * * @type {string} @@ -2447,22 +2468,8 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ '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 + * @enum {string} + */ + +export const TranscodePolicy = { + All: 'all', + Optimal: 'optimal', + Required: 'required', + Disabled: 'disabled' +} as const; + +export type TranscodePolicy = typeof TranscodePolicy[keyof typeof TranscodePolicy]; + + /** * * @export @@ -3027,6 +3050,21 @@ export interface ValidateAccessTokenResponseDto { */ '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 diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 0b3d3b9818810032d7ac8bfb5108320fd5f504b4..6112419c0abc1c5973c24f790f2468f300db59be 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -3,7 +3,7 @@ notificationController, NotificationType, } 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 SettingInputField, { SettingInputFieldType } from '../setting-input-field.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." bind:value={ffmpegConfig.targetAudioCodec} options={[ - { value: 'aac', text: 'aac' }, - { value: 'mp3', text: 'mp3' }, - { value: 'opus', text: 'opus' }, + { value: AudioCodec.Aac, text: 'aac' }, + { value: AudioCodec.Mp3, text: 'mp3' }, + { value: AudioCodec.Opus, text: 'opus' }, ]} name="acodec" 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." bind:value={ffmpegConfig.targetVideoCodec} options={[ - { value: 'h264', text: 'h264' }, - { value: 'hevc', text: 'hevc' }, - { value: 'vp9', text: 'vp9' }, + { value: VideoCodec.H264, text: 'h264' }, + { value: VideoCodec.Hevc, text: 'hevc' }, + { value: VideoCodec.Vp9, text: 'vp9' }, ]} name="vcodec" isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)} @@ -167,22 +167,22 @@ />