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/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/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) 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/src/services/media.service.ts b/server/src/services/media.service.ts index 7036bd32e831c..7057c3eb0b51d 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -432,9 +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 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 || !supportedPixelFormats.includes(stream.pixelFormat); switch (ffmpegConfig.transcode) { case TranscodePolicy.DISABLED: { 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, diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 678e8cb15a48e..267975ad09062 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; 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 }], 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), )} /> + +