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} +