diff --git a/mobile/openapi/lib/model/transcode_policy.dart b/mobile/openapi/lib/model/transcode_policy.dart index 246c239f7b..7d586bc2dc 100644 Binary files a/mobile/openapi/lib/model/transcode_policy.dart and b/mobile/openapi/lib/model/transcode_policy.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a6d34c6e35..7c8bff2cda 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9923,6 +9923,7 @@ "enum": [ "all", "optimal", + "bitrate", "required", "disabled" ], diff --git a/open-api/typescript-sdk/client/api.ts b/open-api/typescript-sdk/client/api.ts index 2d7cac04a6..967e24cf1e 100644 --- a/open-api/typescript-sdk/client/api.ts +++ b/open-api/typescript-sdk/client/api.ts @@ -4370,6 +4370,7 @@ export type TranscodeHWAccel = typeof TranscodeHWAccel[keyof typeof TranscodeHWA export const TranscodePolicy = { All: 'all', Optimal: 'optimal', + Bitrate: 'bitrate', Required: 'required', Disabled: 'disabled' } as const; diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index e4b6020174..6406b2887a 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -557,6 +557,37 @@ describe(MediaService.name, () => { ); }); + it('should transcode when policy Bitrate and bitrate higher than max bitrate', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.BITRATE }, + { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '30M' }, + ]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-c:v h264', + '-c:a aac', + '-movflags faststart', + '-fps_mode passthrough', + '-map 0:0', + '-map 0:1', + '-v verbose', + '-vf scale=-2:720,format=yuv420p', + '-preset ultrafast', + '-crf 23', + '-maxrate 30M', + '-bufsize 60M', + ], + twoPass: false, + }, + ); + }); + it('should not scale resolution if no target resolution', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); configMock.load.mockResolvedValue([ diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index dc6f3eed20..6108ebf8da 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -264,6 +264,7 @@ export class MediaService { const mainVideoStream = this.getMainStream(videoStreams); const mainAudioStream = this.getMainStream(audioStreams); const containerExtension = format.formatName; + const bitrate = format.bitrate; if (!mainVideoStream || !containerExtension) { return false; } @@ -275,7 +276,14 @@ export class MediaService { const { ffmpeg: config } = await this.configCore.getConfig(); - const required = this.isTranscodeRequired(asset, mainVideoStream, mainAudioStream, containerExtension, config); + const required = this.isTranscodeRequired( + asset, + mainVideoStream, + mainAudioStream, + containerExtension, + config, + bitrate, + ); if (!required) { if (asset.encodedVideoPath) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); @@ -326,6 +334,7 @@ export class MediaService { audioStream: AudioStreamInfo | null, containerExtension: string, ffmpegConfig: SystemConfigFFmpegDto, + bitrate: number, ): boolean { const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(videoStream.codecName as VideoCodec); const isTargetContainer = ['mov,mp4,m4a,3gp,3g2,mj2', 'mp4', 'mov'].includes(containerExtension); @@ -342,6 +351,7 @@ export class MediaService { const scalingEnabled = ffmpegConfig.targetResolution !== 'original'; const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const isLargerThanTargetRes = scalingEnabled && Math.min(videoStream.height, videoStream.width) > targetRes; + const isLargerThanTargetBitrate = bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate); switch (ffmpegConfig.transcode) { case TranscodePolicy.DISABLED: @@ -356,6 +366,9 @@ export class MediaService { case TranscodePolicy.OPTIMAL: return !allTargetsMatching || isLargerThanTargetRes || videoStream.isHDR; + case TranscodePolicy.BITRATE: + return !allTargetsMatching || isLargerThanTargetBitrate || videoStream.isHDR; + default: return false; } @@ -424,4 +437,20 @@ export class MediaService { return true; } } + + parseBitrateToBps(bitrateString: string) { + const bitrateValue = Number.parseInt(bitrateString); + + if (isNaN(bitrateValue)) { + return 0; + } + + if (bitrateString.toLowerCase().endsWith('k')) { + return bitrateValue * 1000; // Kilobits per second to bits per second + } else if (bitrateString.toLowerCase().endsWith('m')) { + return bitrateValue * 1000000; // Megabits per second to bits per second + } else { + return bitrateValue; + } + } } diff --git a/server/src/domain/repositories/media.repository.ts b/server/src/domain/repositories/media.repository.ts index 480f4f3ebb..72da627bf5 100644 --- a/server/src/domain/repositories/media.repository.ts +++ b/server/src/domain/repositories/media.repository.ts @@ -32,6 +32,7 @@ export interface VideoFormat { formatName?: string; formatLongName?: string; duration: number; + bitrate: number; } export interface VideoInfo { diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index f07dd760b9..209d0db19c 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -108,6 +108,7 @@ export enum SystemConfigKey { export enum TranscodePolicy { ALL = 'all', OPTIMAL = 'optimal', + BITRATE = 'bitrate', REQUIRED = 'required', DISABLED = 'disabled', } diff --git a/server/src/infra/repositories/media.repository.ts b/server/src/infra/repositories/media.repository.ts index 94816d550a..884c24bf9b 100644 --- a/server/src/infra/repositories/media.repository.ts +++ b/server/src/infra/repositories/media.repository.ts @@ -47,6 +47,7 @@ export class MediaRepository implements IMediaRepository { formatName: results.format.format_name, formatLongName: results.format.format_long_name, duration: results.format.duration || 0, + bitrate: results.format.bit_rate ?? 0, }, videoStreams: results.streams .filter((stream) => stream.codec_type === 'video') diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 969e857211..ee6b767ef0 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -4,6 +4,7 @@ const probeStubDefaultFormat: VideoFormat = { formatName: 'mov,mp4,m4a,3gp,3g2,mj2', formatLongName: 'QuickTime / MOV', duration: 0, + bitrate: 0, }; const probeStubDefaultVideoStream: VideoStreamInfo[] = [ @@ -87,6 +88,15 @@ export const probeStub = { }, ], }), + videoStream40Mbps: Object.freeze({ + ...probeStubDefault, + format: { + formatName: 'mov,mp4,m4a,3gp,3g2,mj2', + formatLongName: 'QuickTime / MOV', + duration: 0, + bitrate: 40000000, + }, + }), videoStreamHDR: Object.freeze({ ...probeStubDefault, videoStreams: [ @@ -157,6 +167,7 @@ export const probeStub = { formatName: 'matroska,webm', formatLongName: 'Matroska / WebM', duration: 0, + bitrate: 0, }, }), }; 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 0dc6a85a16..f24a9cad65 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 @@ -183,6 +183,10 @@ value: TranscodePolicy.Optimal, text: 'Videos higher than target resolution or not in an accepted format', }, + { + value: TranscodePolicy.Bitrate, + text: 'Videos higher than max bitrate or not in an accepted format', + }, { value: TranscodePolicy.Required, text: 'Only videos not in an accepted format',