fix: vaapi tonemap pixel format fixes (#1720)

This commit is contained in:
Christian Benincasa
2026-03-16 12:24:55 -04:00
committed by GitHub
10 changed files with 461 additions and 232 deletions

View File

@@ -1,152 +0,0 @@
import { bootstrapTunarr } from '@/bootstrap.js';
import { globalOptions, setGlobalOptions } from '@/globals.js';
import tmp from 'tmp';
import { container } from '../../container.ts';
import { TranscodeConfig } from '../../db/schema/TranscodeConfig.ts';
import { HttpStreamSource } from '../../stream/types.ts';
import { KEYS } from '../../types/inject.ts';
import { FfmpegVersionResult } from '../ffmpegInfo.ts';
import { FfmpegCommandGenerator } from './FfmpegCommandGenerator.ts';
import { AudioStream, StillImageStream, VideoStream } from './MediaStream.ts';
import { VideoFormats } from './constants.ts';
import {
PixelFormat,
PixelFormatYuv420P,
PixelFormatYuv420P10Le,
} from './format/PixelFormat.ts';
import { AudioInputSource } from './input/AudioInputSource.ts';
import { VideoInputSource } from './input/VideoInputSource.ts';
import { WatermarkInputSource } from './input/WatermarkInputSource.ts';
import { PipelineBuilderFactory } from './pipeline/PipelineBuilderFactory.ts';
import { AudioState } from './state/AudioState.ts';
import { FfmpegState } from './state/FfmpegState.ts';
import { FrameState } from './state/FrameState.ts';
import { FrameSize } from './types.ts';
let dbDir: tmp.DirResult;
beforeAll(async () => {
// const dir = path.join(os.tmpdir(),
dbDir = tmp.dirSync({ unsafeCleanup: true });
setGlobalOptions({
// databaseDirectory: dbDir.name,
database: dbDir.name,
verbose: 0,
log_level: 'debug',
});
await bootstrapTunarr(globalOptions(), undefined, {
system: {
logging: {
logLevel: 'debug',
},
},
settings: {
ffmpeg: {
ffmpegExecutablePath: '/usr/local/bin/ffmpeg7',
ffprobeExecutablePath: '/usr/local/bin/ffprobe7',
},
},
});
});
afterAll(() => {
// dbDir?.removeCallback();
});
describe('FfmpegCommandGenerator', () => {
test('args', async () => {
const pixelFormat: PixelFormat = new PixelFormatYuv420P();
const videoStream = VideoStream.create({
index: 0,
codec: VideoFormats.H264,
profile: 'main',
pixelFormat,
frameSize: FrameSize.create({ width: 640, height: 480 }),
providedSampleAspectRatio: null,
displayAspectRatio: '4/3',
});
const audioState = AudioState.create({
audioEncoder: 'ac3',
audioChannels: 2,
audioBitrate: 192,
audioSampleRate: 48,
audioBufferSize: 50,
audioDuration: 11_000,
});
const target = FrameSize.withDimensions(1280, 720);
const desiredState = new FrameState({
scaledSize: videoStream.squarePixelFrameSize(target),
paddedSize: FrameSize.withDimensions(1280, 720),
isAnamorphic: false,
realtime: true,
videoFormat: VideoFormats.H264,
frameRate: 20,
videoBitrate: 30_000,
deinterlace: true,
pixelFormat: new PixelFormatYuv420P10Le(),
});
const generator = new FfmpegCommandGenerator();
const videoInputFile = new VideoInputSource(
new HttpStreamSource('http://fakesource.com/video'),
[videoStream],
);
const audioInputFile = new AudioInputSource(
videoInputFile.source,
[AudioStream.create({ index: 1, codec: 'flac', channels: 6 })],
audioState,
);
const watermarkInputFile = new WatermarkInputSource(
new HttpStreamSource('http://localhost:8000/images/tunarr.png'),
StillImageStream.create({
index: 0,
frameSize: FrameSize.create({ width: 19, height: -1 }),
}),
{
duration: 0,
enabled: true,
horizontalMargin: 10,
verticalMargin: 10,
position: 'bottom-right',
width: 19,
opacity: 1.0,
},
);
const config: TranscodeConfig = {};
const builder = await container
.get<PipelineBuilderFactory>(KEYS.PipelineBuilderFactory)(config)
.setHardwareAccelerationMode('vaapi')
.setVideoInputSource(videoInputFile)
.setAudioInputSource(audioInputFile)
.setWatermarkInputSource(watermarkInputFile)
.build();
const pipeline = builder.build(
FfmpegState.create({
version: {
versionString: '7.0.1',
isUnknown: false,
} satisfies FfmpegVersionResult,
vaapiDevice: '/dev/dri/renderD128',
}),
desiredState,
);
const result = generator.generateArgs(
videoInputFile,
audioInputFile,
watermarkInputFile,
null,
pipeline.steps,
);
console.log(result.join(' '));
});
});

View File

@@ -1,9 +1,14 @@
import type { FfmpegState } from '@/ffmpeg/builder/state/FfmpegState.js';
import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
import { FrameDataLocation, type FrameSize } from '@/ffmpeg/builder/types.js';
import { isNonEmptyString } from '@tunarr/shared/util';
import { HardwareAccelerationMode } from '../../../db/schema/TranscodeConfig.ts';
import { FilterOption } from './FilterOption.ts';
import { HardwareDownloadFilter } from './HardwareDownloadFilter.ts';
import { HardwareDownloadCudaFilter } from './nvidia/HardwareDownloadCudaFilter.ts';
export class ScaleFilter extends FilterOption {
private hardwareDownloadFilter?: FilterOption;
readonly filter: string;
readonly affectsFrameState = true;
@@ -49,13 +54,23 @@ export class ScaleFilter extends FilterOption {
}
if (this.currentState.frameDataLocation === FrameDataLocation.Hardware) {
scaleFilter = `hwdownload,${scaleFilter}`;
const hwdownload =
this.ffmpegState.decoderHwAccelMode === HardwareAccelerationMode.Cuda
? new HardwareDownloadCudaFilter(this.currentState, null)
: new HardwareDownloadFilter(this.currentState);
this.hardwareDownloadFilter = hwdownload;
const hwdownloadFilter = hwdownload.filter;
if (isNonEmptyString(hwdownloadFilter)) {
scaleFilter = `${hwdownloadFilter},${scaleFilter}`;
}
}
return scaleFilter;
}
nextState(currentState: FrameState): FrameState {
currentState =
this.hardwareDownloadFilter?.nextState(currentState) ?? currentState;
return currentState.update({
scaledSize: this.desiredScaledSize,
paddedSize: this.desiredScaledSize,

View File

@@ -1,8 +1,8 @@
import { TonemapVaapiFilter } from '@/ffmpeg/builder/filter/vaapi/TonemapVaapiFilter.js';
import {
ColorRanges,
ColorTransferFormats,
} from '@/ffmpeg/builder/constants.js';
import { TonemapVaapiFilter } from '@/ffmpeg/builder/filter/vaapi/TonemapVaapiFilter.js';
import {
PixelFormatNv12,
PixelFormatYuv420P,
@@ -107,7 +107,7 @@ describe('TonemapVaapiFilter', () => {
const nextState = filter.nextState(currentState);
expect(nextState.pixelFormat).toMatchPixelFormat(
new PixelFormatNv12(new PixelFormatYuv420P10Le()),
new PixelFormatNv12(new PixelFormatYuv420P()),
);
});
@@ -122,6 +122,8 @@ describe('TonemapVaapiFilter', () => {
const filter = new TonemapVaapiFilter(currentState);
const nextState = filter.nextState(currentState);
expect(nextState.pixelFormat).toBeNull();
expect(nextState.pixelFormat).toMatchPixelFormat(
new PixelFormatNv12(new PixelFormatYuv420P()),
);
});
});

View File

@@ -1,5 +1,9 @@
import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
import { PixelFormatNv12 } from '@/ffmpeg/builder/format/PixelFormat.js';
import {
PixelFormatNv12,
PixelFormatUnknown,
PixelFormatYuv420P,
} from '@/ffmpeg/builder/format/PixelFormat.js';
import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
import { FrameDataLocation } from '@/ffmpeg/builder/types.js';
import { ColorFormat } from '../../format/ColorFormat.ts';
@@ -19,13 +23,15 @@ export class TonemapVaapiFilter extends FilterOption {
public readonly affectsFrameState: boolean = true;
nextState(currentState: FrameState): FrameState {
const currentPixelFormat = currentState.pixelFormat;
const currentPixelFormat = currentState.pixelFormat?.unwrap();
return currentState.update({
colorFormat: ColorFormat.bt709,
frameDataLocation: FrameDataLocation.Hardware,
pixelFormat: currentPixelFormat
? new PixelFormatNv12(currentPixelFormat)
: null,
pixelFormat: new PixelFormatNv12(
(currentPixelFormat && currentPixelFormat.bitDepth === 8
? currentPixelFormat
: new PixelFormatYuv420P()) ?? PixelFormatUnknown(8),
),
});
}
}

View File

@@ -95,6 +95,10 @@ export abstract class HardwarePixelFormat extends BasePixelFormat {
equals(other: PixelFormat): boolean {
return super.equals(other) && this.unwrap().equals(other.unwrap());
}
prettyPrint() {
return `${this.constructor.name}(name=${this.name}, bitDepth=${this.bitDepth}, underlying=${this.underlying.prettyPrint()})`;
}
}
abstract class SoftwarePixelFormat extends BasePixelFormat {

View File

@@ -1,3 +1,4 @@
import { ColorFormat } from '@/ffmpeg/builder/format/ColorFormat.js';
import { TONEMAP_ENABLED, TUNARR_ENV_VARS } from '@/util/env.js';
import { FileStreamSource } from '../../../../stream/types.ts';
import { FfmpegCapabilities } from '../../capabilities/FfmpegCapabilities.ts';
@@ -12,9 +13,15 @@ import {
ColorRanges,
ColorSpaces,
ColorTransferFormats,
VideoFormats,
} from '../../constants.ts';
import { PadFilter } from '../../filter/PadFilter.ts';
import { PadVaapiFilter } from '../../filter/vaapi/PadVaapiFilter.ts';
import { ScaleVaapiFilter } from '../../filter/vaapi/ScaleVaapiFilter.ts';
import { TonemapVaapiFilter } from '../../filter/vaapi/TonemapVaapiFilter.ts';
import {
PixelFormatRgba,
PixelFormatUnknown,
PixelFormatYuv420P,
PixelFormatYuv420P10Le,
} from '../../format/PixelFormat.ts';
@@ -34,11 +41,12 @@ import { AudioState } from '../../state/AudioState.ts';
import {
DefaultPipelineOptions,
FfmpegState,
PipelineOptions,
} from '../../state/FfmpegState.ts';
import { FrameState } from '../../state/FrameState.ts';
import { FrameState, FrameStateOpts } from '../../state/FrameState.ts';
import { FrameSize } from '../../types.ts';
import { Pipeline } from '../Pipeline.ts';
import { VaapiPipelineBuilder } from './VaapiPipelineBuilder.ts';
import { ColorFormat } from '@/ffmpeg/builder/format/ColorFormat.js';
describe('VaapiPipelineBuilder', () => {
test('should work', () => {
@@ -479,6 +487,21 @@ describe('VaapiPipelineBuilder pad', () => {
isUnknown: false,
};
// 16:9 FHD video that exactly fills the target: no padding needed
// squarePixelFrameSize(FHD) = 1920x1080 = paddedSize
function create169FhdVideoStream(): VideoStream {
return VideoStream.create({
index: 0,
codec: 'h264',
profile: 'main',
pixelFormat: new PixelFormatYuv420P(),
frameSize: FrameSize.FHD,
displayAspectRatio: '16:9',
providedSampleAspectRatio: '1:1',
colorFormat: null,
});
}
// 4:3 video that needs pillarboxing to fit in 16:9 FHD:
// squarePixelFrameSize(FHD) = 1440x1080, paddedSize = 1920x1080
function create43VideoStream(): VideoStream {
@@ -664,6 +687,57 @@ describe('VaapiPipelineBuilder pad', () => {
expect(args).toContain('pad_vaapi=w=1920:h=1080');
expect(args).not.toContain('pad=1920:1080');
});
test('skips pad filter when current paddedSize already equals desired paddedSize (pad_vaapi available)', () => {
// 16:9 FHD source fills the target frame exactly — no padding needed
const pipeline = buildWithPad({ videoStream: create169FhdVideoStream() });
const videoFilters =
pipeline.getComplexFilter()!.filterChain.videoFilterSteps;
expect(videoFilters.some((f) => f instanceof PadVaapiFilter)).toBe(false);
expect(videoFilters.some((f) => f instanceof PadFilter)).toBe(false);
const args = pipeline.getCommandArgs().join(' ');
expect(args).not.toContain('pad_vaapi');
expect(args).not.toContain('pad=');
});
test('skips pad filter when current paddedSize already equals desired paddedSize (no pad_vaapi capability)', () => {
const pipeline = buildWithPad({
videoStream: create169FhdVideoStream(),
binaryCapabilities: new FfmpegCapabilities(
new Set(),
new Map(),
new Set(),
new Set(),
),
});
const videoFilters =
pipeline.getComplexFilter()!.filterChain.videoFilterSteps;
expect(videoFilters.some((f) => f instanceof PadVaapiFilter)).toBe(false);
expect(videoFilters.some((f) => f instanceof PadFilter)).toBe(false);
const args = pipeline.getCommandArgs().join(' ');
expect(args).not.toContain('pad_vaapi');
expect(args).not.toContain('pad=');
});
test('skips pad filter when current paddedSize already equals desired paddedSize (hardware decoding disabled)', () => {
const pipeline = buildWithPad({
videoStream: create169FhdVideoStream(),
disableHardwareDecoding: true,
});
const videoFilters =
pipeline.getComplexFilter()!.filterChain.videoFilterSteps;
expect(videoFilters.some((f) => f instanceof PadVaapiFilter)).toBe(false);
expect(videoFilters.some((f) => f instanceof PadFilter)).toBe(false);
const args = pipeline.getCommandArgs().join(' ');
expect(args).not.toContain('pad_vaapi');
expect(args).not.toContain('pad=');
});
});
describe('VaapiPipelineBuilder tonemap', () => {
@@ -707,8 +781,9 @@ describe('VaapiPipelineBuilder tonemap', () => {
function buildWithTonemap(opts: {
videoStream: VideoStream;
binaryCapabilities?: FfmpegCapabilities;
disableHardwareFilters?: boolean;
}) {
pipelineOptions?: Partial<PipelineOptions>;
desiredState?: Partial<FrameStateOpts>;
}): Pipeline {
const capabilities = new VaapiHardwareCapabilities([
new VaapiProfileEntrypoint(
VaapiProfiles.HevcMain10,
@@ -725,7 +800,7 @@ describe('VaapiPipelineBuilder tonemap', () => {
new FfmpegCapabilities(
new Set(),
new Map(),
new Set([KnownFfmpegFilters.TonemapVaapi]),
new Set([KnownFfmpegFilters.TonemapOpencl]),
new Set(),
);
@@ -746,40 +821,40 @@ describe('VaapiPipelineBuilder tonemap', () => {
const state = FfmpegState.create({ version: fakeVersion });
const pipeline = builder.build(
state,
new FrameState({
isAnamorphic: false,
scaledSize: FrameSize.FHD,
paddedSize: FrameSize.FHD,
pixelFormat: new PixelFormatYuv420P(),
videoFormat: 'hevc',
}),
{
...DefaultPipelineOptions,
vaapiDevice: '/dev/dri/renderD128',
disableHardwareFilters: opts.disableHardwareFilters ?? false,
const desiredState = new FrameState({
isAnamorphic: false,
scaledSize: FrameSize.FHD,
paddedSize: FrameSize.FHD,
pixelFormat: new PixelFormatYuv420P(),
videoFormat: VideoFormats.Hevc,
...(opts.desiredState ?? {}),
});
const pipeline = builder.build(state, desiredState, {
...DefaultPipelineOptions,
...(opts.pipelineOptions ?? {}),
vaapiDevice: '/dev/dri/renderD128',
vaapiPipelineOptions: {
tonemapPreference: 'opencl',
...(opts.pipelineOptions?.vaapiPipelineOptions ?? {}),
},
);
});
return pipeline;
}
function hasTonemapFilter(pipeline: ReturnType<typeof buildWithTonemap>) {
const args = pipeline.getCommandArgs().join(' ');
return args.includes('tonemap_vaapi');
function hasVaapiTonemapFilter(pipeline: Pipeline) {
const filterChain =
pipeline.getComplexFilter()?.filterChain.videoFilterSteps ?? [];
return filterChain.some((filter) => filter instanceof TonemapVaapiFilter);
}
function hasOpenclTonemapFilter(
pipeline: ReturnType<typeof buildWithTonemap>,
) {
function hasOpenclTonemapFilter(pipeline: Pipeline) {
const args = pipeline.getCommandArgs().join(' ');
return args.includes('tonemap_opencl');
}
function hasSoftwareTonemapFilter(
pipeline: ReturnType<typeof buildWithTonemap>,
) {
function hasSoftwareTonemapFilter(pipeline: Pipeline) {
const args = pipeline.getCommandArgs().join(' ');
return args.includes('zscale') && args.includes('tonemap=tonemap=hable');
}
@@ -798,10 +873,13 @@ describe('VaapiPipelineBuilder tonemap', () => {
),
});
expect(hasTonemapFilter(pipeline)).to.eq(true);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(true);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
const args = pipeline.getCommandArgs().join(' ');
expect(args).toContain('tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709');
expect(args).toContain('tonemap_opencl=tonemap=hable');
expect(args).toContain('hwmap=derive_device=opencl');
expect(args).toContain('hwmap=derive_device=vaapi:reverse=1');
});
test('applies tonemap filter for HLG (arib-std-b67) content', () => {
@@ -818,7 +896,8 @@ describe('VaapiPipelineBuilder tonemap', () => {
),
});
expect(hasTonemapFilter(pipeline)).to.eq(true);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(true);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
});
test('skips tonemap when TONEMAP_ENABLED is false', () => {
@@ -828,7 +907,7 @@ describe('VaapiPipelineBuilder tonemap', () => {
videoStream: createHdrVideoStream(),
});
expect(hasTonemapFilter(pipeline)).to.eq(false);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
});
test('skips tonemap when content is SDR', () => {
@@ -852,7 +931,7 @@ describe('VaapiPipelineBuilder tonemap', () => {
const pipeline = buildWithTonemap({ videoStream: sdrStream });
expect(hasTonemapFilter(pipeline)).to.eq(false);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
});
test('falls back to software tonemap when neither tonemap_vaapi nor tonemap_opencl is available', () => {
@@ -868,7 +947,7 @@ describe('VaapiPipelineBuilder tonemap', () => {
),
});
expect(hasTonemapFilter(pipeline)).to.eq(false);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(false);
expect(hasSoftwareTonemapFilter(pipeline)).to.eq(true);
});
@@ -878,10 +957,12 @@ describe('VaapiPipelineBuilder tonemap', () => {
const pipeline = buildWithTonemap({
videoStream: createHdrVideoStream(),
disableHardwareFilters: true,
pipelineOptions: {
disableHardwareFilters: true,
},
});
expect(hasTonemapFilter(pipeline)).to.eq(false);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(false);
expect(hasSoftwareTonemapFilter(pipeline)).to.eq(true);
});
@@ -894,7 +975,7 @@ describe('VaapiPipelineBuilder tonemap', () => {
});
const args = pipeline.getCommandArgs().join(' ');
const tonemapIndex = args.indexOf('tonemap_vaapi');
const tonemapIndex = args.indexOf('tonemap_opencl');
const scaleIndex = args.indexOf('scale_vaapi');
expect(tonemapIndex).toBeGreaterThan(-1);
@@ -902,7 +983,7 @@ describe('VaapiPipelineBuilder tonemap', () => {
expect(tonemapIndex).toBeLessThan(scaleIndex);
});
test('falls back to tonemap_opencl when tonemap_vaapi is unavailable', () => {
test('uses tonemap_vaapi when preference is explicitly vaapi and opencl is unavailable', () => {
process.env[TONEMAP_ENABLED] = 'true';
const pipeline = buildWithTonemap({
@@ -910,20 +991,21 @@ describe('VaapiPipelineBuilder tonemap', () => {
binaryCapabilities: new FfmpegCapabilities(
new Set(),
new Map(),
new Set([KnownFfmpegFilters.TonemapOpencl]),
new Set([KnownFfmpegFilters.TonemapVaapi]),
new Set(),
),
pipelineOptions: {
vaapiPipelineOptions: { tonemapPreference: 'vaapi' },
},
});
const args = pipeline.getCommandArgs().join(' ');
expect(hasTonemapFilter(pipeline)).to.eq(false);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(true);
expect(args).toContain('tonemap_opencl=tonemap=hable');
expect(args).toContain('hwmap=derive_device=opencl');
expect(args).toContain('hwmap=derive_device=vaapi:reverse=1');
expect(hasVaapiTonemapFilter(pipeline)).to.eq(true);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(false);
expect(args).toContain('tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709');
});
test('prefers tonemap_vaapi over tonemap_opencl when both are available', () => {
test('prefers tonemap_opencl over tonemap_vaapi when both are available', () => {
process.env[TONEMAP_ENABLED] = 'true';
const pipeline = buildWithTonemap({
@@ -939,8 +1021,8 @@ describe('VaapiPipelineBuilder tonemap', () => {
),
});
expect(hasTonemapFilter(pipeline)).to.eq(true);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(false);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(true);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
});
test('opencl tonemap filter appears before scale in the filter chain', () => {
@@ -976,14 +1058,16 @@ describe('VaapiPipelineBuilder tonemap', () => {
new Set([KnownFfmpegFilters.TonemapOpencl]),
new Set(),
),
disableHardwareFilters: true,
pipelineOptions: {
disableHardwareFilters: true,
},
});
expect(hasOpenclTonemapFilter(pipeline)).to.eq(false);
expect(hasSoftwareTonemapFilter(pipeline)).to.eq(true);
});
test('applies tonemap_vaapi for Dolby Vision content (dvhe codec)', () => {
test('applies tonemap_opencl for Dolby Vision content (dvhe codec)', () => {
process.env[TONEMAP_ENABLED] = 'true';
const dvStream = VideoStream.create({
@@ -1004,8 +1088,8 @@ describe('VaapiPipelineBuilder tonemap', () => {
const pipeline = buildWithTonemap({ videoStream: dvStream });
expect(hasTonemapFilter(pipeline)).to.eq(true);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(false);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(true);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
});
test('applies software tonemap for Dolby Vision (dvhe codec) when hardware filters are disabled', () => {
@@ -1029,10 +1113,12 @@ describe('VaapiPipelineBuilder tonemap', () => {
const pipeline = buildWithTonemap({
videoStream: dvStream,
disableHardwareFilters: true,
pipelineOptions: {
disableHardwareFilters: true,
},
});
expect(hasTonemapFilter(pipeline)).to.eq(false);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(false);
expect(hasSoftwareTonemapFilter(pipeline)).to.eq(true);
});
@@ -1067,8 +1153,208 @@ describe('VaapiPipelineBuilder tonemap', () => {
});
// No hardware tonemap filter, falls back to software
expect(hasTonemapFilter(pipeline)).to.eq(false);
expect(hasVaapiTonemapFilter(pipeline)).to.eq(false);
expect(hasOpenclTonemapFilter(pipeline)).to.eq(false);
expect(hasSoftwareTonemapFilter(pipeline)).to.eq(true);
});
test('yuv420p10le input ensures outputted pixel format is 8-bit nv12', () => {
process.env[TONEMAP_ENABLED] = 'true';
const stream = VideoStream.create({
index: 0,
codec: VideoFormats.Hevc,
profile: 'main 10',
pixelFormat: new PixelFormatYuv420P10Le(),
frameSize: FrameSize.withDimensions(3840, 2076),
displayAspectRatio: '16:9',
providedSampleAspectRatio: '1:1',
colorFormat: new ColorFormat({
colorRange: ColorRanges.Tv,
colorSpace: ColorSpaces.Bt2020nc,
colorPrimaries: ColorPrimaries.Bt2020,
colorTransfer: ColorTransferFormats.Smpte2084,
}),
});
const pipeline = buildWithTonemap({
videoStream: stream,
binaryCapabilities: new FfmpegCapabilities(
new Set(),
new Map(),
new Set([KnownFfmpegFilters.TonemapVaapi]),
new Set(),
),
pipelineOptions: {
vaapiPipelineOptions: { tonemapPreference: 'vaapi' },
},
desiredState: {
scaledSize: stream.squarePixelFrameSize(FrameSize.FourK),
paddedSize: FrameSize.FourK,
},
});
const padFilter = pipeline
.getComplexFilter()!
.filterChain.videoFilterSteps.find((step) => step instanceof PadFilter);
console.log(pipeline.getCommandArgs().join(' '));
expect(padFilter).toBeDefined();
expect(padFilter!.filter).toEqual(
'hwdownload,format=nv12,pad=3840:2160:-1:-1:color=black',
);
});
test('unknown pixel format properly wraps in nv12 after tonemapping', () => {
process.env[TONEMAP_ENABLED] = 'true';
const stream = VideoStream.create({
index: 0,
codec: VideoFormats.Hevc,
profile: 'main 10',
pixelFormat: PixelFormatUnknown(10),
frameSize: FrameSize.withDimensions(3840, 2076),
displayAspectRatio: '16:9',
providedSampleAspectRatio: '1:1',
colorFormat: new ColorFormat({
colorRange: ColorRanges.Tv,
colorSpace: ColorSpaces.Bt2020nc,
colorPrimaries: ColorPrimaries.Bt2020,
colorTransfer: ColorTransferFormats.Smpte2084,
}),
});
const pipeline = buildWithTonemap({
videoStream: stream,
binaryCapabilities: new FfmpegCapabilities(
new Set(),
new Map(),
new Set([KnownFfmpegFilters.TonemapVaapi]),
new Set(),
),
pipelineOptions: {
vaapiPipelineOptions: { tonemapPreference: 'vaapi' },
},
desiredState: {
scaledSize: stream.squarePixelFrameSize(FrameSize.FourK),
paddedSize: FrameSize.FourK,
},
});
const padFilter = pipeline
.getComplexFilter()!
.filterChain.videoFilterSteps.find((step) => step instanceof PadFilter);
expect(padFilter).toBeDefined();
expect(padFilter!.filter).toEqual(
'hwdownload,format=nv12,pad=3840:2160:-1:-1:color=black',
);
});
test('tonemap_vaapi includes format upload prefix when frame data is in software (hardware decoding disabled)', () => {
process.env[TONEMAP_ENABLED] = 'true';
const pipeline = buildWithTonemap({
videoStream: createHdrVideoStream(),
binaryCapabilities: new FfmpegCapabilities(
new Set(),
new Map(),
new Set([KnownFfmpegFilters.TonemapVaapi]),
new Set(),
),
pipelineOptions: {
disableHardwareDecoding: true,
vaapiPipelineOptions: { tonemapPreference: 'vaapi' },
},
});
const args = pipeline.getCommandArgs().join(' ');
expect(args).toContain(
'format=vaapi|nv12|p010le,tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709',
);
});
// This test verifies that software decode triggers a scale_vaapi because of the tonemap
// to ensure we don't excessively move frames from hardware <-> software
test('8-bit yuv420p HDR input uses vaapi tonemap and scale_vaapi (software decode)', () => {
process.env[TONEMAP_ENABLED] = 'true';
// Unusual but valid: 8-bit stream tagged with HDR color metadata
const stream = VideoStream.create({
index: 0,
codec: VideoFormats.Hevc,
// Explicitly trigger a software decode
profile: 'main',
pixelFormat: new PixelFormatYuv420P(),
frameSize: FrameSize.FourK,
displayAspectRatio: '16:9',
providedSampleAspectRatio: '1:1',
colorFormat: new ColorFormat({
colorRange: ColorRanges.Tv,
colorSpace: ColorSpaces.Bt2020nc,
colorPrimaries: ColorPrimaries.Bt2020,
colorTransfer: ColorTransferFormats.Smpte2084,
}),
});
const pipeline = buildWithTonemap({
videoStream: stream,
binaryCapabilities: new FfmpegCapabilities(
new Set(),
new Map(),
new Set([KnownFfmpegFilters.TonemapVaapi]),
new Set(),
),
pipelineOptions: {
vaapiPipelineOptions: { tonemapPreference: 'vaapi' },
},
});
const filters = pipeline.getComplexFilter()!.filterChain.videoFilterSteps;
expect(hasVaapiTonemapFilter(pipeline)).to.eq(true);
const scaleFilter = filters.find(
(filter) => filter instanceof ScaleVaapiFilter,
);
expect(scaleFilter).toBeDefined();
});
// This test verifies that hardware decode also uses scale_vaapi after vaapi tonemap
test('8-bit yuv420p HDR input uses vaapi tonemap and scale_vaapi (hardware decode)', () => {
process.env[TONEMAP_ENABLED] = 'true';
// Unusual but valid: 8-bit stream tagged with HDR color metadata
const stream = VideoStream.create({
index: 0,
codec: VideoFormats.Hevc,
profile: 'main 10',
pixelFormat: new PixelFormatYuv420P(),
frameSize: FrameSize.FourK,
displayAspectRatio: '16:9',
providedSampleAspectRatio: '1:1',
colorFormat: new ColorFormat({
colorRange: ColorRanges.Tv,
colorSpace: ColorSpaces.Bt2020nc,
colorPrimaries: ColorPrimaries.Bt2020,
colorTransfer: ColorTransferFormats.Smpte2084,
}),
});
const pipeline = buildWithTonemap({
videoStream: stream,
binaryCapabilities: new FfmpegCapabilities(
new Set(),
new Map(),
new Set([KnownFfmpegFilters.TonemapVaapi]),
new Set(),
),
pipelineOptions: {
vaapiPipelineOptions: { tonemapPreference: 'vaapi' },
},
});
const filters = pipeline.getComplexFilter()!.filterChain.videoFilterSteps;
expect(hasVaapiTonemapFilter(pipeline)).to.eq(true);
const scaleFilter = filters.find(
(filter) => filter instanceof ScaleVaapiFilter,
);
expect(scaleFilter).toBeDefined();
});
});

View File

@@ -62,6 +62,7 @@ import {
PixelFormats,
} from '../../format/PixelFormat.ts';
import type { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts';
import type { VideoStream } from '../../MediaStream.ts';
import { CopyTimestampInputOption } from '../../options/input/CopyTimestampInputOption.ts';
import {
NoAutoScaleOutputOption,
@@ -356,21 +357,29 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder {
const { videoStream, pipelineOptions } = this.context;
if (
!getBooleanEnvVar(TONEMAP_ENABLED, false) ||
!isHdrContent(videoStream)
) {
if (!this.shouldPerformTonemap(videoStream)) {
return currentState;
}
let filter: FilterOption | undefined;
if (!pipelineOptions.disableHardwareFilters) {
if (this.ffmpegCapabilities.hasFilter(KnownFfmpegFilters.TonemapVaapi)) {
filter = new TonemapVaapiFilter(currentState);
} else if (
this.ffmpegCapabilities.hasFilter(KnownFfmpegFilters.TonemapOpencl)
) {
let preference = pipelineOptions.vaapiPipelineOptions?.tonemapPreference;
const hasOpencl = this.ffmpegCapabilities.hasFilter(
KnownFfmpegFilters.TonemapOpencl,
);
const hasVaapi = this.ffmpegCapabilities.hasFilter(
KnownFfmpegFilters.TonemapVaapi,
);
// Default preference is opencl.
if (!preference) {
preference = 'opencl';
}
if (preference === 'opencl' && hasOpencl) {
filter = new TonemapOpenclFilter(currentState);
} else if (preference === 'vaapi' && hasVaapi) {
filter = new TonemapVaapiFilter(currentState);
}
}
@@ -385,15 +394,26 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder {
}
protected setScale(currentState: FrameState): FrameState {
if (!isVideoPipelineContext(this.context)) {
return currentState;
}
let nextState = currentState;
const { desiredState, ffmpegState, shouldDeinterlace } = this.context;
const { desiredState, ffmpegState, shouldDeinterlace, videoStream } =
this.context;
let scaleOption: FilterOption;
if (
!currentState.scaledSize.equals(desiredState.scaledSize) &&
((ffmpegState.decoderHwAccelMode === HardwareAccelerationMode.None &&
ffmpegState.encoderHwAccelMode === HardwareAccelerationMode.None &&
!shouldDeinterlace) ||
ffmpegState.decoderHwAccelMode !== HardwareAccelerationMode.Vaapi)
// Software decode and no tonemap implies we're already in software. If we're tonemapping but
// performed a software decode, we'll have had to upload to hardware to tonemap anyway (most likely)
// so try to continue on hardware if possible
(ffmpegState.decoderHwAccelMode !== HardwareAccelerationMode.Vaapi &&
!this.shouldPerformTonemap(videoStream) &&
this.canTonemapOnHardware()))
) {
scaleOption = ScaleFilter.create(
currentState,
@@ -428,7 +448,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder {
protected setPad(currentState: FrameState) {
if (
this.desiredState.croppedSize &&
this.desiredState.croppedSize ||
currentState.paddedSize.equals(this.desiredState.paddedSize)
) {
return currentState;
@@ -613,4 +633,18 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder {
!(this.ffmpegState.vaapiDriver ?? '').toLowerCase().startsWith('radeon')
);
}
private shouldPerformTonemap(videoStream: VideoStream) {
return (
getBooleanEnvVar(TONEMAP_ENABLED, false) && isHdrContent(videoStream)
);
}
private canTonemapOnHardware() {
return (
!this.context.pipelineOptions.disableHardwareFilters &&
(this.ffmpegCapabilities.hasFilter(KnownFfmpegFilters.TonemapVaapi) ||
this.ffmpegCapabilities.hasFilter(KnownFfmpegFilters.TonemapOpencl))
);
}
}

View File

@@ -1,6 +1,7 @@
import { HardwareAccelerationMode } from '@/db/schema/TranscodeConfig.js';
import type { FfmpegVersionResult } from '@/ffmpeg/ffmpegInfo.js';
import type { DataProps, Maybe, Nullable } from '@/types/util.js';
import type { TupleToUnion } from '@tunarr/types';
import type { FfmpegLogLevel } from '@tunarr/types/schemas';
import type { Duration } from 'dayjs/plugin/duration.js';
import { merge } from 'lodash-es';
@@ -13,6 +14,15 @@ import {
OutputLocation,
} from '../constants.ts';
export const VaapiTonemapType = ['vaapi', 'opencl'] as const;
export type VaapiTonemapType = TupleToUnion<typeof VaapiTonemapType>;
export type VaapiPipelineOptions = {
// Ordered list of preferred tonemap types. Pipeline will cross
// reference the list based on what is available on the system.
tonemapPreference: VaapiTonemapType;
};
export type PipelineOptions = {
decoderThreadCount: Nullable<number>;
encoderThreadCount: Nullable<number>;
@@ -20,8 +30,11 @@ export type PipelineOptions = {
disableHardwareDecoding?: boolean;
disableHardwareEncoding?: boolean;
disableHardwareFilters?: boolean;
// TODO: Move these into the vaapi pipeline options
vaapiDevice: Nullable<string>;
vaapiDriver: Nullable<string>;
// VAAPI
vaapiPipelineOptions: Nullable<VaapiPipelineOptions>;
};
export const DefaultPipelineOptions: PipelineOptions = {
@@ -33,6 +46,9 @@ export const DefaultPipelineOptions: PipelineOptions = {
disableHardwareFilters: false,
vaapiDevice: null,
vaapiDriver: null,
vaapiPipelineOptions: {
tonemapPreference: 'opencl',
},
};
export const DefaultFfmpegState: Partial<DataProps<FfmpegState>> = {

View File

@@ -34,6 +34,11 @@ export const DefaultFrameState: Omit<
infiniteLoop: false,
};
export type FrameStateOpts = MarkOptional<
FrameStateFields,
keyof typeof DefaultFrameState
>;
export class FrameState {
scaledSize: FrameSize;
paddedSize: FrameSize;
@@ -55,9 +60,7 @@ export class FrameState {
forceSoftwareOverlay = false;
constructor(
fields: MarkOptional<FrameStateFields, keyof typeof DefaultFrameState>,
) {
constructor(fields: FrameStateOpts) {
merge(this, DefaultFrameState, fields);
}

View File

@@ -4,8 +4,8 @@ import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
filter,
findIndex,
first,
indexOf,
isEmpty,
last,
merge,
@@ -202,7 +202,13 @@ export class HlsPlaylistMutator {
const startSequence = first(allSegments)?.startSequence ?? 0;
if (!isEmpty(allSegments)) {
const index = indexOf(items, first(allSegments));
const target = first(allSegments);
const index = target
? findIndex(
items,
(item) => item.type === 'segment' && item.equals(target),
)
: -1;
discontinuitySequence += filter(take(items, index + 1), {
type: 'discontinuity',
}).length;
@@ -267,6 +273,15 @@ class PlaylistSegment {
const match = nth(matches, 1);
return match ? parseInt(match) : null;
}
equals(other: PlaylistSegment) {
return (
this === other ||
(this.startTime.isSame(other.startTime) &&
this.extInf === other.extInf &&
this.line === other.line)
);
}
}
type PlaylistDiscontinuity = {