From 1e97f717346e9f49fa89078c34d81075d223ab6b Mon Sep 17 00:00:00 2001 From: Hely0n Date: Mon, 6 Jan 2025 15:24:45 +0100 Subject: [PATCH 1/8] transcodeHDR config --- open-api/immich-openapi-specs.json | 4 ++++ server/src/config.ts | 2 ++ server/src/dtos/system-config.dto.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2686d4f96d69f..2c3170fd94ed9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11862,6 +11862,9 @@ } ] }, + "transcodeHDR": { + "type": "boolean" + }, "twoPass": { "type": "boolean" } @@ -11887,6 +11890,7 @@ "threads", "tonemap", "transcode", + "transcodeHDR", "twoPass" ], "type": "object" diff --git a/server/src/config.ts b/server/src/config.ts index 26589742003e7..4524239753cb9 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -33,6 +33,7 @@ export interface SystemConfig { acceptedContainers: VideoContainer[]; targetResolution: string; maxBitrate: string; + transcodeHDR: boolean; bframes: number; refs: number; gopSize: number; @@ -182,6 +183,7 @@ export const defaults = Object.freeze({ acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM], targetResolution: '720', maxBitrate: '0', + transcodeHDR: true, bframes: -1, refs: 0, gopSize: 0, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 350918254542a..e12e87bd21821 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -106,6 +106,9 @@ export class SystemConfigFFmpegDto { @IsString() maxBitrate!: string; + @ValidateBoolean() + transcodeHDR!: boolean; + @IsInt() @Min(-1) @Max(16) From 71430c3a125c0b4ecd3ec586bd403926f894b1cb Mon Sep 17 00:00:00 2001 From: Hely0n Date: Mon, 6 Jan 2025 15:24:56 +0100 Subject: [PATCH 2/8] transcodeHDR logic --- server/src/services/media.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 7036bd32e831c..12183f2bb3134 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -434,7 +434,8 @@ export class MediaService extends BaseService { const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate); const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec); - const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p'); + const isTargetDynamicRange = !ffmpegConfig.transcodeHDR || !stream.isHDR; + const isRequired = !isTargetVideoCodec || !isTargetDynamicRange || !stream.pixelFormat.includes('420p'); switch (ffmpegConfig.transcode) { case TranscodePolicy.DISABLED: { From 0e504da2412b4d0a72ecde95137cbca4cc9699a8 Mon Sep 17 00:00:00 2001 From: Hely0n Date: Mon, 6 Jan 2025 15:54:44 +0100 Subject: [PATCH 3/8] transcodeHDR Web setting (EN & DE) --- i18n/de.json | 4 +++- i18n/en.json | 6 ++++-- .../admin-page/settings/ffmpeg/ffmpeg-settings.svelte | 8 ++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index 7d6bffba91749..154d60d25cc3c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -318,8 +318,10 @@ "transcoding_threads_description": "Höhere Werte führen zu einer schnelleren Codierung, lassen dem Server aber weniger Spielraum für die Verarbeitung anderer Aufgaben, solange dies aktiv ist. Dieser Wert sollte nicht höher sein als die Anzahl der CPU-Kerne. Nutzt die maximale Auslastung, wenn der Wert auf 0 gesetzt ist.", "transcoding_tone_mapping": "Farbton-Mapping", "transcoding_tone_mapping_description": "Versucht, das Aussehen von HDR-Videos bei der Konvertierung in SDR beizubehalten. Jeder Algorithmus geht unterschiedliche Kompromisse bei Farbe, Details und Helligkeit ein. Hable bewahrt Details, Mobius bewahrt die Farbe und Reinhard bewahrt die Helligkeit.", + "transcoding_transcode_hdr": "HDR-Videos transkodieren", + "transcoding_transcode_hdr_setting_description": "Erzwinge die Transkodierung von HDR-Videos für optimale Kompatibilität (empfohlen)", "transcoding_transcode_policy": "Transcodierungsrichtlinie", - "transcoding_transcode_policy_description": "Richtlinie, wann ein Video transkodiert werden soll. HDR-Videos werden immer transkodiert (außer wenn die Transkodierung deaktiviert ist).", + "transcoding_transcode_policy_description": "Richtlinie, wann ein Video transkodiert werden soll. HDR-Videos werden immer transkodiert, es sei denn, diese Option wurde deaktiviert oder die Transkodierung ist insgesamt ausgeschaltet.", "transcoding_two_pass_encoding": "Two-Pass Codierung", "transcoding_two_pass_encoding_setting_description": "Führt eine Transkodierung in zwei Durchgängen durch, um besser kodierte Videos zu erzeugen. Wenn die maximale Bitrate aktiviert ist (erforderlich für die Verwendung mit H.264 und HEVC), verwendet dieser Modus einen Bitratenbereich, der auf der maximalen Bitrate basiert, und ignoriert CRF. Für VP9 kann CRF verwendet werden, wenn die maximale Bitrate deaktiviert ist.", "transcoding_video_codec": "Video-Codec", diff --git a/i18n/en.json b/i18n/en.json index 2abc586c2375a..23444353519d4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -322,8 +322,10 @@ "transcoding_threads_description": "Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0.", "transcoding_tone_mapping": "Tone-mapping", "transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.", + "transcoding_transcode_hdr": "Transcode HDR Videos", + "transcoding_transcode_hdr_setting_description": "Force transcoding of HDR videos for optimal compatibility (recommended)", "transcoding_transcode_policy": "Transcode policy", - "transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).", + "transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded unless disabled below or transcoding is disabled in general.", "transcoding_two_pass_encoding": "Two-pass encoding", "transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.", "transcoding_video_codec": "Video codec", @@ -1344,4 +1346,4 @@ "yes": "Yes", "you_dont_have_any_shared_links": "You don't have any shared links", "zoom_image": "Zoom Image" -} \ No newline at end of file +} 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 58c4be0ca0d9e..057772ae22ded 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 @@ -151,6 +151,14 @@ sortBy(savedConfig.ffmpeg.acceptedContainers), )} /> + + From ac50c1217355dbf59be00e8055aff35b639d2260 Mon Sep 17 00:00:00 2001 From: Hely0n Date: Mon, 6 Jan 2025 20:56:21 +0100 Subject: [PATCH 4/8] precise pixelformat check --- server/src/services/media.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 12183f2bb3134..f0cb8d7b8d324 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -432,10 +432,11 @@ export class MediaService extends BaseService { const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes; const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate); + const supportedPixelFormats: string[] = ["yuv420p", "yuvj420p", "yuva420p", "yuv420p10le"] const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec); const isTargetDynamicRange = !ffmpegConfig.transcodeHDR || !stream.isHDR; - const isRequired = !isTargetVideoCodec || !isTargetDynamicRange || !stream.pixelFormat.includes('420p'); + const isRequired = !isTargetVideoCodec || !isTargetDynamicRange || !supportedPixelFormats.includes(stream.pixelFormat); switch (ffmpegConfig.transcode) { case TranscodePolicy.DISABLED: { From 90aaefa2cc81f34f528edecfefce9720fe19f411 Mon Sep 17 00:00:00 2001 From: Hely0n Date: Mon, 6 Jan 2025 21:24:02 +0100 Subject: [PATCH 5/8] preserve HDR if needed --- server/src/utils/media.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 678e8cb15a48e..40c12b1c612bb 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -159,9 +159,13 @@ export class BaseConfig implements VideoCodecSWConfig { options.push(`scale=${this.getScaling(videoStream)}`); } - options.push(...this.getToneMapping(videoStream)); - if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) { - options.push(`format=yuv420p`); + if (!this.config.transcodeHDR && videoStream.isHDR) { + options.push(`format=yuv420p10le`) + } else { + options.push(...this.getToneMapping(videoStream)); + if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) { + options.push(`format=yuv420p`); + } } return options; From 86bf4d1d5cfd969713143956cc8a907863e8562c Mon Sep 17 00:00:00 2001 From: Hely0n Date: Mon, 6 Jan 2025 21:49:32 +0100 Subject: [PATCH 6/8] Add new setting to test and config-file --- docs/docs/install/config-file.md | 1 + server/src/services/system-config.service.spec.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index f5d2680658393..5c5789c631269 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -23,6 +23,7 @@ The default configuration looks like this: "acceptedContainers": ["mov", "ogg", "webm"], "targetResolution": "720", "maxBitrate": "0", + "transcodeHDR": true, "bframes": -1, "refs": 0, "gopSize": 0, diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 2a20f329330ae..961dae2ecfb97 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -62,6 +62,7 @@ const updatedConfig = Object.freeze({ acceptedVideoCodecs: [VideoCodec.H264], acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM], maxBitrate: '0', + transcodeHDR: true, bframes: -1, refs: 0, gopSize: 0, From 38f903d317f31a7179617c987e70e92ab807a870 Mon Sep 17 00:00:00 2001 From: hely0n Date: Tue, 7 Jan 2025 19:07:07 +0100 Subject: [PATCH 7/8] Adjust unit tests --- server/src/services/media.service.spec.ts | 14 +++++++------- server/test/fixtures/media.stub.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 36a9045677460..c3f60c2849550 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -2323,9 +2323,9 @@ describe(MediaService.name, () => { ); }); - it('should tonemap when policy is required and video is hdr', async () => { + it('should tonemap when policy is required, video is hdr and transcodeHDR is enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED, transcodeHDR: true } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -2343,9 +2343,9 @@ describe(MediaService.name, () => { ); }); - it('should tonemap when policy is optimal and video is hdr', async () => { + it('should tonemap when policy is optimal, video is hdr and transcodeHDR is enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL, transcodeHDR: true } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -2363,9 +2363,9 @@ describe(MediaService.name, () => { ); }); - it('should transcode when policy is required and video is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + it('should transcode when policy is required and pixelformat is not supported', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamYuv444p); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED, transcodeHDR: true } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index de11c23f0a961..3acb3046d1d58 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -182,6 +182,22 @@ export const probeStub = { }, ], }), + videoStreamYuv444p: Object.freeze({ + ...probeStubDefault, + videoStreams: [ + { + index: 0, + height: 480, + width: 480, + codecName: 'h264', + frameCount: 100, + rotation: 0, + isHDR: false, + bitrate: 0, + pixelFormat: 'yuv444p', + }, + ], + }), audioStreamAac: Object.freeze({ ...probeStubDefault, audioStreams: [{ index: 1, codecName: 'aac', frameCount: 100 }], From 5942d9b52c90cd09345532c8ab2842dccd241429 Mon Sep 17 00:00:00 2001 From: hely0n Date: Tue, 7 Jan 2025 19:09:15 +0100 Subject: [PATCH 8/8] fix formatting --- server/src/services/media.service.ts | 5 +++-- server/src/utils/media.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index f0cb8d7b8d324..7057c3eb0b51d 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -432,11 +432,12 @@ export class MediaService extends BaseService { const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes; const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate); - const supportedPixelFormats: string[] = ["yuv420p", "yuvj420p", "yuva420p", "yuv420p10le"] + const supportedPixelFormats: string[] = ['yuv420p', 'yuvj420p', 'yuva420p', 'yuv420p10le']; const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec); const isTargetDynamicRange = !ffmpegConfig.transcodeHDR || !stream.isHDR; - const isRequired = !isTargetVideoCodec || !isTargetDynamicRange || !supportedPixelFormats.includes(stream.pixelFormat); + const isRequired = + !isTargetVideoCodec || !isTargetDynamicRange || !supportedPixelFormats.includes(stream.pixelFormat); switch (ffmpegConfig.transcode) { case TranscodePolicy.DISABLED: { diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 40c12b1c612bb..267975ad09062 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -160,7 +160,7 @@ export class BaseConfig implements VideoCodecSWConfig { } if (!this.config.transcodeHDR && videoStream.isHDR) { - options.push(`format=yuv420p10le`) + options.push(`format=yuv420p10le`); } else { options.push(...this.getToneMapping(videoStream)); if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {