diff --git a/mobile/openapi/doc/SystemConfigFFmpegDto.md b/mobile/openapi/doc/SystemConfigFFmpegDto.md
index d442d43f8..e2dcb45db 100644
--- a/mobile/openapi/doc/SystemConfigFFmpegDto.md
+++ b/mobile/openapi/doc/SystemConfigFFmpegDto.md
@@ -8,11 +8,14 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
-**crf** | **String** | |
+**crf** | **int** | |
+**threads** | **int** | |
**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/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart
index 0fa275837..cc11f4744 100644
--- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart
+++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart
@@ -14,14 +14,19 @@ class SystemConfigFFmpegDto {
/// Returns a new [SystemConfigFFmpegDto] instance.
SystemConfigFFmpegDto({
required this.crf,
+ required this.threads,
required this.preset,
required this.targetVideoCodec,
required this.targetAudioCodec,
required this.targetResolution,
+ required this.maxBitrate,
+ required this.twoPass,
required this.transcode,
});
- String crf;
+ int crf;
+
+ int threads;
String preset;
@@ -31,37 +36,50 @@ class SystemConfigFFmpegDto {
String targetResolution;
+ String maxBitrate;
+
+ 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.targetResolution == targetResolution &&
+ other.maxBitrate == maxBitrate &&
+ other.twoPass == twoPass &&
other.transcode == transcode;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(crf.hashCode) +
+ (threads.hashCode) +
(preset.hashCode) +
(targetVideoCodec.hashCode) +
(targetAudioCodec.hashCode) +
(targetResolution.hashCode) +
+ (maxBitrate.hashCode) +
+ (twoPass.hashCode) +
(transcode.hashCode);
@override
- String toString() => 'SystemConfigFFmpegDto[crf=$crf, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, transcode=$transcode]';
+ String toString() => 'SystemConfigFFmpegDto[crf=$crf, threads=$threads, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, maxBitrate=$maxBitrate, twoPass=$twoPass, transcode=$transcode]';
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'targetResolution'] = this.targetResolution;
+ json[r'maxBitrate'] = this.maxBitrate;
+ json[r'twoPass'] = this.twoPass;
json[r'transcode'] = this.transcode;
return json;
}
@@ -85,11 +103,14 @@ class SystemConfigFFmpegDto {
}());
return SystemConfigFFmpegDto(
- crf: mapValueOfType(json, r'crf')!,
+ crf: mapValueOfType(json, r'crf')!,
+ threads: mapValueOfType(json, r'threads')!,
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'])!,
);
}
@@ -139,10 +160,13 @@ class SystemConfigFFmpegDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = {
'crf',
+ 'threads',
'preset',
'targetVideoCodec',
'targetAudioCodec',
'targetResolution',
+ 'maxBitrate',
+ 'twoPass',
'transcode',
};
}
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 dfbb79124..3305d8d00 100644
--- a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart
+++ b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart
@@ -16,11 +16,16 @@ void main() {
// final instance = SystemConfigFFmpegDto();
group('test SystemConfigFFmpegDto', () {
- // String crf
+ // int crf
test('to test the property `crf`', () async {
// TODO
});
+ // int threads
+ test('to test the property `threads`', () async {
+ // TODO
+ });
+
// String preset
test('to test the property `preset`', () async {
// TODO
@@ -41,6 +46,16 @@ void main() {
// TODO
});
+ // String maxBitrate
+ test('to test the property `maxBitrate`', () async {
+ // TODO
+ });
+
+ // bool twoPass
+ test('to test the property `twoPass`', () async {
+ // TODO
+ });
+
// String transcode
test('to test the property `transcode`', () async {
// TODO
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index 8699ae3a0..0b47f136d 100644
--- a/server/immich-openapi-specs.json
+++ b/server/immich-openapi-specs.json
@@ -5349,7 +5349,10 @@
"type": "object",
"properties": {
"crf": {
- "type": "string"
+ "type": "integer"
+ },
+ "threads": {
+ "type": "integer"
},
"preset": {
"type": "string"
@@ -5363,6 +5366,12 @@
"targetResolution": {
"type": "string"
},
+ "maxBitrate": {
+ "type": "string"
+ },
+ "twoPass": {
+ "type": "boolean"
+ },
"transcode": {
"type": "string",
"enum": [
@@ -5375,10 +5384,13 @@
},
"required": [
"crf",
+ "threads",
"preset",
"targetVideoCodec",
"targetAudioCodec",
"targetResolution",
+ "maxBitrate",
+ "twoPass",
"transcode"
]
},
diff --git a/server/libs/domain/src/media/media.repository.ts b/server/libs/domain/src/media/media.repository.ts
index bcb63ccb4..b750797b6 100644
--- a/server/libs/domain/src/media/media.repository.ts
+++ b/server/libs/domain/src/media/media.repository.ts
@@ -38,6 +38,11 @@ export interface CropOptions {
height: number;
}
+export interface TranscodeOptions {
+ outputOptions: string[];
+ twoPass: boolean;
+}
+
export interface IMediaRepository {
// image
extractThumbnailFromExif(input: string, output: string): Promise;
@@ -47,5 +52,5 @@ export interface IMediaRepository {
// video
extractVideoThumbnail(input: string, output: string, size: number): Promise;
probe(input: string): Promise;
- transcode(input: string, output: string, options: any): Promise;
+ transcode(input: string, output: string, options: TranscodeOptions): Promise;
}
diff --git a/server/libs/domain/src/media/media.service.spec.ts b/server/libs/domain/src/media/media.service.spec.ts
index 71b579c95..a29187fd1 100644
--- a/server/libs/domain/src/media/media.service.spec.ts
+++ b/server/libs/domain/src/media/media.service.spec.ts
@@ -253,7 +253,10 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
- ['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart'],
+ {
+ outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
+ twoPass: false,
+ },
);
});
@@ -276,7 +279,10 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
- ['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart'],
+ {
+ outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
+ twoPass: false,
+ },
);
});
@@ -287,7 +293,17 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
- ['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
+ {
+ outputOptions: [
+ '-vcodec h264',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=-2:720',
+ '-preset ultrafast',
+ '-crf 23',
+ ],
+ twoPass: false,
+ },
);
});
@@ -298,7 +314,17 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
- ['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=720:-2'],
+ {
+ outputOptions: [
+ '-vcodec h264',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=720:-2',
+ '-preset ultrafast',
+ '-crf 23',
+ ],
+ twoPass: false,
+ },
);
});
@@ -309,7 +335,17 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
- ['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
+ {
+ outputOptions: [
+ '-vcodec h264',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=-2:720',
+ '-preset ultrafast',
+ '-crf 23',
+ ],
+ twoPass: false,
+ },
);
});
@@ -320,7 +356,17 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
- ['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
+ {
+ outputOptions: [
+ '-vcodec h264',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=-2:720',
+ '-preset ultrafast',
+ '-crf 23',
+ ],
+ twoPass: false,
+ },
);
});
@@ -330,5 +376,152 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ asset: assetEntityStub.video });
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' }]);
+ await sut.handleVideoConversion({ asset: assetEntityStub.video });
+ expect(mediaMock.transcode).toHaveBeenCalledWith(
+ '/original/path.ext',
+ 'upload/encoded-video/user-id/asset-id.mp4',
+ {
+ outputOptions: [
+ '-vcodec h264',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=-2:720',
+ '-preset ultrafast',
+ '-crf 23',
+ '-maxrate 4500k',
+ ],
+ twoPass: false,
+ },
+ );
+ });
+
+ it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => {
+ mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
+ configMock.load.mockResolvedValue([
+ { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
+ { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
+ ]);
+ await sut.handleVideoConversion({ asset: assetEntityStub.video });
+ expect(mediaMock.transcode).toHaveBeenCalledWith(
+ '/original/path.ext',
+ 'upload/encoded-video/user-id/asset-id.mp4',
+ {
+ outputOptions: [
+ '-vcodec h264',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=-2:720',
+ '-preset ultrafast',
+ '-b:v 3104k',
+ '-minrate 1552k',
+ '-maxrate 4500k',
+ ],
+ twoPass: true,
+ },
+ );
+ });
+
+ it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
+ mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
+ configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]);
+ await sut.handleVideoConversion({ asset: assetEntityStub.video });
+ expect(mediaMock.transcode).toHaveBeenCalledWith(
+ '/original/path.ext',
+ 'upload/encoded-video/user-id/asset-id.mp4',
+ {
+ outputOptions: [
+ '-vcodec h264',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=-2:720',
+ '-preset ultrafast',
+ '-crf 23',
+ ],
+ twoPass: false,
+ },
+ );
+ });
+
+ it('should configure preset for vp9', async () => {
+ mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
+ configMock.load.mockResolvedValue([
+ { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
+ { key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
+ ]);
+ await sut.handleVideoConversion({ asset: assetEntityStub.video });
+ expect(mediaMock.transcode).toHaveBeenCalledWith(
+ '/original/path.ext',
+ 'upload/encoded-video/user-id/asset-id.mp4',
+ {
+ outputOptions: [
+ '-vcodec vp9',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=-2:720',
+ '-cpu-used 5',
+ '-row-mt 1',
+ '-threads 2',
+ '-crf 23',
+ '-b:v 0',
+ ],
+ twoPass: false,
+ },
+ );
+ });
+
+ 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_THREADS, value: 2 },
+ ]);
+ await sut.handleVideoConversion({ asset: assetEntityStub.video });
+ expect(mediaMock.transcode).toHaveBeenCalledWith(
+ '/original/path.ext',
+ 'upload/encoded-video/user-id/asset-id.mp4',
+ {
+ outputOptions: [
+ '-vcodec vp9',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=-2:720',
+ '-cpu-used 5',
+ '-row-mt 1',
+ '-threads 2',
+ '-crf 23',
+ '-b:v 0',
+ ],
+ twoPass: false,
+ },
+ );
+ });
+
+ it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => {
+ mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
+ configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
+ await sut.handleVideoConversion({ asset: assetEntityStub.video });
+ expect(mediaMock.transcode).toHaveBeenCalledWith(
+ '/original/path.ext',
+ 'upload/encoded-video/user-id/asset-id.mp4',
+ {
+ outputOptions: [
+ '-vcodec h264',
+ '-acodec aac',
+ '-movflags faststart',
+ '-vf scale=-2:720',
+ '-preset ultrafast',
+ '-threads 2',
+ '-x264-params "pools=none"',
+ '-x264-params "frame-threads=2"',
+ '-crf 23',
+ ],
+ twoPass: false,
+ },
+ );
+ });
});
});
diff --git a/server/libs/domain/src/media/media.service.ts b/server/libs/domain/src/media/media.service.ts
index f6dba4007..9f215c2f0 100644
--- a/server/libs/domain/src/media/media.service.ts
+++ b/server/libs/domain/src/media/media.service.ts
@@ -165,10 +165,11 @@ export class MediaService {
return;
}
- const options = this.getFfmpegOptions(mainVideoStream, config);
+ const outputOptions = this.getFfmpegOptions(mainVideoStream, config);
+ const twoPass = this.eligibleForTwoPass(config);
- this.logger.log(`Start encoding video ${asset.id} ${options}`);
- await this.mediaRepository.transcode(input, output, options);
+ this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`);
+ await this.mediaRepository.transcode(input, output, { outputOptions, twoPass });
this.logger.log(`Encoding success ${asset.id}`);
@@ -231,8 +232,6 @@ export class MediaService {
private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) {
const options = [
- `-crf ${ffmpeg.crf}`,
- `-preset ${ffmpeg.preset}`,
`-vcodec ${ffmpeg.targetVideoCodec}`,
`-acodec ${ffmpeg.targetAudioCodec}`,
// Makes a second pass moving the moov atom to the beginning of
@@ -240,17 +239,81 @@ export class MediaService {
`-movflags faststart`,
];
+ // video dimensions
const videoIsRotated = Math.abs(stream.rotation) === 90;
const targetResolution = Number.parseInt(ffmpeg.targetResolution);
-
const isVideoVertical = stream.height > stream.width || videoIsRotated;
const scaling = isVideoVertical ? `${targetResolution}:-2` : `-2:${targetResolution}`;
-
const shouldScale = 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}`);
+ options.push(`${isVP9 ? '-b:v' : '-maxrate'} ${maxBitrateValue}${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;
+ }
}
diff --git a/server/libs/domain/src/system-config/dto/system-config-ffmpeg.dto.ts b/server/libs/domain/src/system-config/dto/system-config-ffmpeg.dto.ts
index 77dca9f49..e47ad9963 100644
--- a/server/libs/domain/src/system-config/dto/system-config-ffmpeg.dto.ts
+++ b/server/libs/domain/src/system-config/dto/system-config-ffmpeg.dto.ts
@@ -1,9 +1,21 @@
-import { IsEnum, IsString } from 'class-validator';
+import { IsEnum, IsString, IsInt, IsBoolean, Min, Max } from 'class-validator';
import { TranscodePreset } from '@app/infra/entities';
+import { Type } from 'class-transformer';
+import { ApiProperty } from '@nestjs/swagger';
export class SystemConfigFFmpegDto {
- @IsString()
- crf!: string;
+ @IsInt()
+ @Min(0)
+ @Max(51)
+ @Type(() => Number)
+ @ApiProperty({ type: 'integer' })
+ crf!: number;
+
+ @IsInt()
+ @Min(0)
+ @Type(() => Number)
+ @ApiProperty({ type: 'integer' })
+ threads!: number;
@IsString()
preset!: string;
@@ -17,6 +29,12 @@ export class SystemConfigFFmpegDto {
@IsString()
targetResolution!: string;
+ @IsString()
+ maxBitrate!: string;
+
+ @IsBoolean()
+ twoPass!: boolean;
+
@IsEnum(TranscodePreset)
transcode!: TranscodePreset;
}
diff --git a/server/libs/domain/src/system-config/system-config.core.ts b/server/libs/domain/src/system-config/system-config.core.ts
index 713af9ef3..53937cc08 100644
--- a/server/libs/domain/src/system-config/system-config.core.ts
+++ b/server/libs/domain/src/system-config/system-config.core.ts
@@ -9,11 +9,14 @@ export type SystemConfigValidator = (config: SystemConfig) => void | Promise {
it('should merge the overrides', async () => {
configMock.load.mockResolvedValue([
- { key: SystemConfigKey.FFMPEG_CRF, value: 'a new value' },
+ { key: SystemConfigKey.FFMPEG_CRF, value: 30 },
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
]);
diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts
index ab6567cca..c963709e7 100644
--- a/server/libs/domain/test/fixtures.ts
+++ b/server/libs/domain/test/fixtures.ts
@@ -479,11 +479,14 @@ export const keyStub = {
export const systemConfigStub = {
defaults: Object.freeze({
ffmpeg: {
- crf: '23',
+ crf: 23,
+ threads: 0,
preset: 'ultrafast',
targetAudioCodec: 'aac',
targetResolution: '720',
targetVideoCodec: 'h264',
+ maxBitrate: '0',
+ twoPass: false,
transcode: TranscodePreset.REQUIRED,
},
oauth: {
diff --git a/server/libs/infra/src/entities/system-config.entity.ts b/server/libs/infra/src/entities/system-config.entity.ts
index c24374f55..3d4c5d157 100644
--- a/server/libs/infra/src/entities/system-config.entity.ts
+++ b/server/libs/infra/src/entities/system-config.entity.ts
@@ -1,7 +1,7 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('system_config')
-export class SystemConfigEntity {
+export class SystemConfigEntity {
@PrimaryColumn()
key!: SystemConfigKey;
@@ -14,10 +14,13 @@ export type SystemConfigValue = any;
// dot notation matches path in `SystemConfig`
export enum SystemConfigKey {
FFMPEG_CRF = 'ffmpeg.crf',
+ FFMPEG_THREADS = 'ffmpeg.threads',
FFMPEG_PRESET = 'ffmpeg.preset',
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
FFMPEG_TARGET_RESOLUTION = 'ffmpeg.targetResolution',
+ FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate',
+ FFMPEG_TWO_PASS = 'ffmpeg.twoPass',
FFMPEG_TRANSCODE = 'ffmpeg.transcode',
OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
@@ -42,11 +45,14 @@ export enum TranscodePreset {
export interface SystemConfig {
ffmpeg: {
- crf: string;
+ crf: number;
+ threads: number;
preset: string;
targetVideoCodec: string;
targetAudioCodec: string;
targetResolution: string;
+ maxBitrate: string;
+ twoPass: boolean;
transcode: TranscodePreset;
};
oauth: {
diff --git a/server/libs/infra/src/repositories/media.repository.ts b/server/libs/infra/src/repositories/media.repository.ts
index ef7ca0f94..3aa7a9bf0 100644
--- a/server/libs/infra/src/repositories/media.repository.ts
+++ b/server/libs/infra/src/repositories/media.repository.ts
@@ -1,8 +1,9 @@
-import { CropOptions, IMediaRepository, ResizeOptions, VideoInfo } from '@app/domain';
+import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain';
import { exiftool } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import sharp from 'sharp';
import { promisify } from 'util';
+import fs from 'fs/promises';
const probe = promisify(ffmpeg.ffprobe);
@@ -85,14 +86,40 @@ export class MediaRepository implements IMediaRepository {
};
}
- transcode(input: string, output: string, options: string[]): Promise {
+ transcode(input: string, output: string, options: TranscodeOptions): Promise {
+ if (!options.twoPass) {
+ return new Promise((resolve, reject) => {
+ ffmpeg(input, { niceness: 10 })
+ .outputOptions(options.outputOptions)
+ .output(output)
+ .on('error', reject)
+ .on('end', resolve)
+ .run();
+ });
+ }
+
+ // two-pass allows for precise control of bitrate at the cost of running twice
+ // recommended for vp9 for better quality and compression
return new Promise((resolve, reject) => {
ffmpeg(input, { niceness: 10 })
- //
- .outputOptions(options)
- .output(output)
+ .outputOptions(options.outputOptions)
+ .addOptions('-pass', '1')
+ .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('end', resolve)
+ .on('end', () => {
+ // second pass
+ ffmpeg(input, { niceness: 10 })
+ .outputOptions(options.outputOptions)
+ .addOptions('-pass', '2')
+ .addOptions('-passlogfile', output)
+ .output(output)
+ .on('error', reject)
+ .on('end', () => fs.unlink(`${output}-0.log`))
+ .on('end', resolve)
+ .run();
+ })
.run();
});
}
diff --git a/server/libs/infra/src/repositories/system-config.repository.ts b/server/libs/infra/src/repositories/system-config.repository.ts
index a977417ba..4ffd3d6e2 100644
--- a/server/libs/infra/src/repositories/system-config.repository.ts
+++ b/server/libs/infra/src/repositories/system-config.repository.ts
@@ -9,7 +9,7 @@ export class SystemConfigRepository implements ISystemConfigRepository {
private repository: Repository,
) {}
- load(): Promise[]> {
+ load(): Promise[]> {
return this.repository.find();
}
diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts
index bb885c8a2..b0727830c 100644
--- a/web/src/api/open-api/api.ts
+++ b/web/src/api/open-api/api.ts
@@ -2112,10 +2112,16 @@ export interface SystemConfigDto {
export interface SystemConfigFFmpegDto {
/**
*
- * @type {string}
+ * @type {number}
* @memberof SystemConfigFFmpegDto
*/
- 'crf': string;
+ 'crf': number;
+ /**
+ *
+ * @type {number}
+ * @memberof SystemConfigFFmpegDto
+ */
+ 'threads': number;
/**
*
* @type {string}
@@ -2140,6 +2146,18 @@ export interface SystemConfigFFmpegDto {
* @memberof SystemConfigFFmpegDto
*/
'targetResolution': string;
+ /**
+ *
+ * @type {string}
+ * @memberof SystemConfigFFmpegDto
+ */
+ 'maxBitrate': string;
+ /**
+ *
+ * @type {boolean}
+ * @memberof SystemConfigFFmpegDto
+ */
+ 'twoPass': boolean;
/**
*
* @type {string}
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 ae1ac58b6..e2ce856f5 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
@@ -7,6 +7,7 @@
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSelect from '../setting-select.svelte';
+ import SettingSwitch from '../setting-switch.svelte';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
@@ -80,21 +81,34 @@
-
+
+
+
+
+
+
diff --git a/web/src/lib/components/admin-page/settings/setting-input-field.svelte b/web/src/lib/components/admin-page/settings/setting-input-field.svelte
index 071cdff0b..9c463b9ee 100644
--- a/web/src/lib/components/admin-page/settings/setting-input-field.svelte
+++ b/web/src/lib/components/admin-page/settings/setting-input-field.svelte
@@ -12,8 +12,9 @@
import { fly } from 'svelte/transition';
export let inputType: SettingInputFieldType;
- export let value: string;
+ export let value: string | number;
export let label = '';
+ export let desc = '';
export let required = false;
export let disabled = false;
export let isEdited = false;
@@ -39,8 +40,17 @@
{/if}
+
+ {#if desc}
+
+ {desc}
+
+ {/if}
+
{/if}
+
+ {#if desc}
+
+ {desc}
+
+ {/if}
+
-