From e9722710acc0d9afdf2f0c50ef1c500b594616f5 Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Mon, 22 May 2023 14:07:43 -0400
Subject: [PATCH] feat(server): transcode bitrate and thread settings (#2488)
* support for two-pass transcoding
* added max bitrate and thread to transcode api
* admin page setting desc+bitrate and thread options
* Update web/src/lib/components/admin-page/settings/setting-input-field.svelte
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
* Update web/src/lib/components/admin-page/settings/setting-input-field.svelte
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
* two-pass slider, `crf` and `threads` as numbers
* updated and added transcode tests
* refactored `getFfmpegOptions`
* default `threads`, `maxBitrate` now 0, more tests
* vp9 constant quality mode
* fixed nullable `crf` and `threads`
* fixed two-pass slider, added apiproperty
* optional `desc` for `SettingSelect`
* disable two-pass if settings are incompatible
* fixed test
* transcode interface
---------
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
---
mobile/openapi/doc/SystemConfigFFmpegDto.md | Bin 597 -> 685 bytes
.../lib/model/system_config_f_fmpeg_dto.dart | Bin 8093 -> 8821 bytes
.../test/system_config_f_fmpeg_dto_test.dart | Bin 1128 -> 1423 bytes
server/immich-openapi-specs.json | 14 +-
.../libs/domain/src/media/media.repository.ts | 7 +-
.../domain/src/media/media.service.spec.ts | 205 +++++++++++++++++-
server/libs/domain/src/media/media.service.ts | 77 ++++++-
.../dto/system-config-ffmpeg.dto.ts | 24 +-
.../src/system-config/system-config.core.ts | 5 +-
.../system-config.service.spec.ts | 9 +-
server/libs/domain/test/fixtures.ts | 5 +-
.../src/entities/system-config.entity.ts | 10 +-
.../src/repositories/media.repository.ts | 39 +++-
.../repositories/system-config.repository.ts | 2 +-
web/src/api/open-api/api.ts | 22 +-
.../settings/ffmpeg/ffmpeg-settings.svelte | 46 +++-
.../settings/setting-input-field.svelte | 14 +-
.../admin-page/settings/setting-select.svelte | 11 +-
.../admin-page/settings/setting-switch.svelte | 2 +-
19 files changed, 451 insertions(+), 41 deletions(-)
diff --git a/mobile/openapi/doc/SystemConfigFFmpegDto.md b/mobile/openapi/doc/SystemConfigFFmpegDto.md
index d442d43f8c8ee5c2d18bad8dc2b3b43211aaf56d..e2dcb45db1ca87279cd267e82282bedcbd938225 100644
GIT binary patch
delta 78
zcmcc0vX*tiEBVa45-lx-8U-NY($Xr)C`wICDF%yZX#wRXeptwrn^@tLSyGf(k~*1@
WQBtrxKOnIfrX(ppKWB0q<9PrYyc(te
delta 21
dcmZ3>dX;6uE4JX0qRhPX$()P}C%Uh?qNM!%90jNeAc4sT
zMU*GMWST1k)u~`>3pb>k`M4!QJ4gnJr=|vWnnHd_MrskvO<>(n=jpR_!#%ZvWr8w7
zuUqJx2zi8}J!otZHxD{OxTy;%FNR)%3
o9cmedxu7^iG8p3X&Hg;~Z14a-CKM-$WGci=n0GeI2siQo0BvO2asU7T
delta 98
zcmV-o0GjgvhH(6eC)tpk(44G@zX4G^Px#
delta 35
rcmeC@e!;QfF(X%SNl|8Ax;
@@ -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 71b579c95a..a29187fd13 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 f6dba4007a..9f215c2f0b 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 77dca9f49e..e47ad99630 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 713af9ef3b..53937cc083 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 ab6567cca1..c963709e7f 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 c24374f55b..3d4c5d1571 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 ef7ca0f942..3aa7a9bf0c 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 a977417ba0..4ffd3d6e28 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 bb885c8a2d..b0727830c6 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 ae1ac58b61..e2ce856f5d 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 071cdff0b3..9c463b9eee 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}
+
-