diff --git a/mobile/openapi/doc/SystemConfigFFmpegDto.md b/mobile/openapi/doc/SystemConfigFFmpegDto.md index d442d43f8c..e2dcb45db1 100644 Binary files a/mobile/openapi/doc/SystemConfigFFmpegDto.md and b/mobile/openapi/doc/SystemConfigFFmpegDto.md differ 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 0fa2758373..cc11f47445 100644 Binary files a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart and b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart differ 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 dfbb791244..3305d8d001 100644 Binary files a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart and b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 8699ae3a00..0b47f136d3 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 bcb63ccb40..b750797b66 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 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} +