diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 3aafcc8513..7d8986c3ae 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1393,10 +1393,15 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', { - inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), + inputOptions: expect.arrayContaining([ + '-hwaccel cuda', + '-hwaccel_output_format cuda', + '-noautorotate', + '-threads 1', + ]), outputOptions: expect.arrayContaining([ expect.stringContaining( - 'hwupload=derive_device=vulkan,scale_vulkan=w=1280:h=720,libplacebo=color_primaries=bt709:color_trc=bt709:colorspace=bt709:deband=true:deband_iterations=3:deband_radius=8:deband_threshold=6:downscaler=none:format=yuv420p:tonemapping=clip:upscaler=none,hwupload=derive_device=cuda', + 'scale_cuda=-2:720,hwupload=derive_device=vulkan,libplacebo=color_primaries=bt709:color_trc=bt709:colorspace=bt709:deband=true:deband_iterations=3:deband_radius=8:deband_threshold=6:downscaler=none:format=yuv420p:tonemapping=clip:upscaler=none,hwupload=derive_device=cuda', ), ]), twoPass: false, @@ -1739,6 +1744,7 @@ describe(MediaService.name, () => { it('should set options for rkmpp', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([ { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, @@ -1772,6 +1778,7 @@ describe(MediaService.name, () => { it('should set vbr options for rkmpp when max bitrate is enabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); configMock.load.mockResolvedValue([ { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, @@ -1794,6 +1801,7 @@ describe(MediaService.name, () => { it('should set cqp options for rkmpp when max bitrate is disabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([ { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, @@ -1867,6 +1875,33 @@ describe(MediaService.name, () => { }, ); }); + + it('should use software decoding and tone-mapping if opencl is not available', async () => { + storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => false, isCharacterDevice: () => false }); + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, + { key: SystemConfigKey.FFMPEG_ACCEL_DECODE, value: true }, + { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, + { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, + ]); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + 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: expect.arrayContaining([ + expect.stringContaining( + 'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ), + ]), + twoPass: false, + }, + ); + }); }); it('should tonemap when policy is required and video is hdr', async () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index e493b22f95..1d6fda4bbd 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -36,9 +36,11 @@ import { AV1Config, H264Config, HEVCConfig, - NVENCConfig, + NvencHwDecodeConfig, + NvencSwDecodeConfig, QSVConfig, - RKMPPConfig, + RkmppHwDecodeConfig, + RkmppSwDecodeConfig, ThumbnailConfig, VAAPIConfig, VP9Config, @@ -495,7 +497,7 @@ export class MediaService { let handler: VideoCodecHWConfig; switch (config.accel) { case TranscodeHWAccel.NVENC: { - handler = new NVENCConfig(config); + handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config); break; } case TranscodeHWAccel.QSV: { @@ -507,7 +509,10 @@ export class MediaService { break; } case TranscodeHWAccel.RKMPP: { - handler = new RKMPPConfig(config, await this.getDevices(), await this.hasOpenCL()); + handler = + config.accelDecode && (await this.hasOpenCL()) + ? new RkmppHwDecodeConfig(config, await this.getDevices()) + : new RkmppSwDecodeConfig(config, await this.getDevices()); break; } default: { diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index be95dba6f2..818c862be8 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -442,17 +442,13 @@ export class AV1Config extends BaseConfig { } } -export class NVENCConfig extends BaseHWConfig { +export class NvencSwDecodeConfig extends BaseHWConfig { getSupportedCodecs() { return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1]; } getBaseInputOptions() { - if (!this.config.accelDecode) { - return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda', ...this.getInputThreadOptions()]; - } - - return ['-hwaccel cuda', '-hwaccel_output_format cuda', ...this.getInputThreadOptions()]; + return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']; } getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { @@ -474,48 +470,15 @@ export class NVENCConfig extends BaseHWConfig { } getFilterOptions(videoStream: VideoStreamInfo) { - const options = []; - if (!this.config.accelDecode) { - options.push(...this.getToneMapping(videoStream), 'format=nv12', 'hwupload_cuda'); - if (this.shouldScale(videoStream)) { - options.push(`scale_cuda=${this.getScaling(videoStream)}`); - } - return options; - } - - options.push('hwupload=derive_device=vulkan'); + const options = this.getToneMapping(videoStream); + options.push('format=nv12', 'hwupload_cuda'); if (this.shouldScale(videoStream)) { - const { width, height } = this.getSize(videoStream); - options.push(`scale_vulkan=w=${width}:h=${height}`); + options.push(`scale_cuda=${this.getScaling(videoStream)}`); } - options.push(...this.getToneMapping(videoStream), 'hwupload=derive_device=cuda'); return options; } - getToneMapping(videoStream: VideoStreamInfo) { - if (!this.config.accelDecode) { - return super.getToneMapping(videoStream); - } - - const colors = this.getColors(); - const libplaceboOptions = [ - `color_primaries=${colors.primaries}`, - `color_trc=${colors.transfer}`, - `colorspace=${colors.matrix}`, - 'deband=true', - 'deband_iterations=3', - 'deband_radius=8', - 'deband_threshold=6', - 'downscaler=none', - 'format=yuv420p', - `tonemapping=${this.shouldToneMap(videoStream) ? this.config.tonemap : 'clip'}`, - 'upscaler=none', - ]; - - return [`libplacebo=${libplaceboOptions.join(':')}`]; - } - getPresetOptions() { let presetIndex = this.getPresetIndex(); if (presetIndex < 0) { @@ -545,11 +508,7 @@ export class NVENCConfig extends BaseHWConfig { } } - getInputThreadOptions() { - return [`-threads ${this.config.threads <= 0 ? 1 : this.config.threads}`]; - } - - getOutputThreadOptions() { + getThreadOptions() { return []; } @@ -562,6 +521,48 @@ export class NVENCConfig extends BaseHWConfig { } } +export class NvencHwDecodeConfig extends NvencSwDecodeConfig { + getBaseInputOptions() { + return ['-hwaccel cuda', '-hwaccel_output_format cuda', '-noautorotate', ...this.getInputThreadOptions()]; + } + + getFilterOptions(videoStream: VideoStreamInfo) { + const options = []; + if (this.shouldScale(videoStream)) { + options.push(`scale_cuda=${this.getScaling(videoStream)}`); + } + options.push('hwupload=derive_device=vulkan', ...this.getToneMapping(videoStream), 'hwupload=derive_device=cuda'); + return options; + } + + getToneMapping(videoStream: VideoStreamInfo) { + const colors = this.getColors(); + const libplaceboOptions = [ + `color_primaries=${colors.primaries}`, + `color_trc=${colors.transfer}`, + `colorspace=${colors.matrix}`, + 'deband=true', + 'deband_iterations=3', + 'deband_radius=8', + 'deband_threshold=6', + 'downscaler=none', + 'format=yuv420p', + `tonemapping=${this.shouldToneMap(videoStream) ? this.config.tonemap : 'clip'}`, + 'upscaler=none', + ]; + + return [`libplacebo=${libplaceboOptions.join(':')}`]; + } + + getInputThreadOptions() { + return [`-threads ${this.config.threads <= 0 ? 1 : this.config.threads}`]; + } + + getOutputThreadOptions() { + return []; + } +} + export class QSVConfig extends BaseHWConfig { getBaseInputOptions() { if (this.devices.length === 0) { @@ -705,51 +706,22 @@ export class VAAPIConfig extends BaseHWConfig { } } -export class RKMPPConfig extends BaseHWConfig { - private hasOpenCL: boolean; - +export class RkmppSwDecodeConfig extends BaseHWConfig { constructor( protected config: SystemConfigFFmpegDto, devices: string[] = [], - hasOpenCL: boolean = false, ) { super(config, devices); - this.hasOpenCL = hasOpenCL; } eligibleForTwoPass(): boolean { return false; } - getBaseInputOptions(videoStream: VideoStreamInfo) { + getBaseInputOptions(): string[] { if (this.devices.length === 0) { throw new Error('No RKMPP device found'); } - return !this.config.accelDecode || (this.shouldToneMap(videoStream) && !this.hasOpenCL) - ? [] // disable hardware decoding & filters - : ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; - } - - getFilterOptions(videoStream: VideoStreamInfo) { - if (!this.config.accelDecode) { - return super.getFilterOptions(videoStream); - } - - if (this.shouldToneMap(videoStream)) { - if (!this.hasOpenCL) { - return super.getFilterOptions(videoStream); - } - const colors = this.getColors(); - return [ - `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`, - 'hwmap=derive_device=opencl:mode=read', - `tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`, - 'hwmap=derive_device=rkmpp:mode=write:reverse=1', - 'format=drm_prime', - ]; - } else if (this.shouldScale(videoStream)) { - return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`]; - } return []; } @@ -787,3 +759,29 @@ export class RKMPPConfig extends BaseHWConfig { return `${this.config.targetVideoCodec}_rkmpp`; } } + +export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig { + getBaseInputOptions() { + if (this.devices.length === 0) { + throw new Error('No RKMPP device found'); + } + + return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; + } + + getFilterOptions(videoStream: VideoStreamInfo) { + if (this.shouldToneMap(videoStream)) { + const colors = this.getColors(); + return [ + `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`, + 'hwmap=derive_device=opencl:mode=read', + `tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`, + 'hwmap=derive_device=rkmpp:mode=write:reverse=1', + 'format=drm_prime', + ]; + } else if (this.shouldScale(videoStream)) { + return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`]; + } + return []; + } +}