Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): option to decide, if HDR videos should be transcoded #15122

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/install/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1344,4 +1346,4 @@
"yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links",
"zoom_image": "Zoom Image"
}
}
4 changes: 4 additions & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -11862,6 +11862,9 @@
}
]
},
"transcodeHDR": {
"type": "boolean"
},
"twoPass": {
"type": "boolean"
}
Expand All @@ -11887,6 +11890,7 @@
"threads",
"tonemap",
"transcode",
"transcodeHDR",
"twoPass"
],
"type": "object"
Expand Down
2 changes: 2 additions & 0 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface SystemConfig {
acceptedContainers: VideoContainer[];
targetResolution: string;
maxBitrate: string;
transcodeHDR: boolean;
bframes: number;
refs: number;
gopSize: number;
Expand Down Expand Up @@ -182,6 +183,7 @@ export const defaults = Object.freeze<SystemConfig>({
acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM],
targetResolution: '720',
maxBitrate: '0',
transcodeHDR: true,
bframes: -1,
refs: 0,
gopSize: 0,
Expand Down
3 changes: 3 additions & 0 deletions server/src/dtos/system-config.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export class SystemConfigFFmpegDto {
@IsString()
maxBitrate!: string;

@ValidateBoolean()
transcodeHDR!: boolean;

@IsInt()
@Min(-1)
@Max(16)
Expand Down
14 changes: 7 additions & 7 deletions server/src/services/media.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1616,7 +1616,7 @@
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(

Check failure on line 1619 in server/src/services/media.service.spec.ts

View workflow job for this annotation

GitHub Actions / Test & Lint Server

src/services/media.service.spec.ts > MediaService > handleVideoConversion > should set format to nv12 for nvenc if input is not yuv420p

AssertionError: expected "spy" to be called with arguments: [ '/original/path.ext', …(2) ] Received: Number of calls: 0 ❯ src/services/media.service.spec.ts:1619:35
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
Expand Down Expand Up @@ -1843,7 +1843,7 @@

await sut.handleVideoConversion({ id: assetStub.video.id });

expect(mediaMock.transcode).toHaveBeenCalledWith(

Check failure on line 1846 in server/src/services/media.service.spec.ts

View workflow job for this annotation

GitHub Actions / Test & Lint Server

src/services/media.service.spec.ts > MediaService > handleVideoConversion > should set format to nv12 for qsv if input is not yuv420p

AssertionError: expected "spy" to be called with arguments: [ '/original/path.ext', …(2) ] Received: Number of calls: 0 ❯ src/services/media.service.spec.ts:1846:35
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
Expand Down Expand Up @@ -2062,7 +2062,7 @@

await sut.handleVideoConversion({ id: assetStub.video.id });

expect(mediaMock.transcode).toHaveBeenCalledWith(

Check failure on line 2065 in server/src/services/media.service.spec.ts

View workflow job for this annotation

GitHub Actions / Test & Lint Server

src/services/media.service.spec.ts > MediaService > handleVideoConversion > should set format to nv12 for vaapi if input is not yuv420p

AssertionError: expected "spy" to be called with arguments: [ '/original/path.ext', …(2) ] Received: Number of calls: 0 ❯ src/services/media.service.spec.ts:2065:35
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
Expand Down Expand Up @@ -2323,9 +2323,9 @@
);
});

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(
Expand All @@ -2343,9 +2343,9 @@
);
});

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(
Expand All @@ -2363,9 +2363,9 @@
);
});

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(
Expand Down
5 changes: 4 additions & 1 deletion server/src/services/media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions server/src/services/system-config.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
acceptedVideoCodecs: [VideoCodec.H264],
acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM],
maxBitrate: '0',
transcodeHDR: true,
bframes: -1,
refs: 0,
gopSize: 0,
Expand Down
10 changes: 7 additions & 3 deletions server/src/utils/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions server/test/fixtures/media.stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,22 @@ export const probeStub = {
},
],
}),
videoStreamYuv444p: Object.freeze<VideoInfo>({
...probeStubDefault,
videoStreams: [
{
index: 0,
height: 480,
width: 480,
codecName: 'h264',
frameCount: 100,
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv444p',
},
],
}),
audioStreamAac: Object.freeze<VideoInfo>({
...probeStubDefault,
audioStreams: [{ index: 1, codecName: 'aac', frameCount: 100 }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@
sortBy(savedConfig.ffmpeg.acceptedContainers),
)}
/>

<SettingSwitch
title={$t('admin.transcoding_transcode_hdr')}
{disabled}
subtitle={$t('admin.transcoding_transcode_hdr_setting_description')}
bind:checked={config.ffmpeg.transcodeHDR}
isEdited={config.ffmpeg.transcodeHDR !== savedConfig.ffmpeg.transcodeHDR}
/>
</div>
</SettingAccordion>

Expand Down
Loading