diff --git a/server/src/ffmpeg/builder/constants.ts b/server/src/ffmpeg/builder/constants.ts index 2de0b6aca..7987bf056 100644 --- a/server/src/ffmpeg/builder/constants.ts +++ b/server/src/ffmpeg/builder/constants.ts @@ -82,6 +82,7 @@ export const OutputFormatTypes = { HlsDirectV2: 'hls_direct_v2', Nut: 'nut', Dash: 'dash', + Null: 'null', } as const; export const ColorRanges = { @@ -195,6 +196,14 @@ export function MpegDashOutputFormat( }; } +export const NullOutputFormat = { + type: OutputFormatTypes.Null, +} as const satisfies NullOutputFormat; + +export type NullOutputFormat = { + type: typeof OutputFormatTypes.Null; +}; + export type OutputFormat = | HlsOutputFormat | HlsDirectOutputFormat @@ -202,7 +211,8 @@ export type OutputFormat = | MkvOutputFormat | MpegDashOutputFormat | Mp4OutputFormat - | MpegTsOutputFormat; + | MpegTsOutputFormat + | NullOutputFormat; export const OneDayMillis = 7 * 24 * 60 * 60 * 1000; export const FiveMinutesMillis = 5 * 60 * 60 * 1000; diff --git a/server/src/ffmpeg/builder/options/NullOutputFormat.ts b/server/src/ffmpeg/builder/options/NullOutputFormat.ts new file mode 100644 index 000000000..10164cf5b --- /dev/null +++ b/server/src/ffmpeg/builder/options/NullOutputFormat.ts @@ -0,0 +1,7 @@ +import { OutputOption } from './OutputOption.ts'; + +export class NullOutputFormat extends OutputOption { + options(): string[] { + return ['-f', 'null']; + } +} diff --git a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts index d20456904..78c274c3f 100644 --- a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts @@ -97,6 +97,7 @@ import { StandardFormatFlags, ThreadCountOption, } from '../options/GlobalOption.ts'; +import { NullOutputFormat } from '../options/NullOutputFormat.ts'; import { ClosedGopOutputOption, DoNotMapMetadataOutputOption, @@ -868,6 +869,8 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { } case OutputFormatTypes.Dash: throw new Error('MPEG-DASH streaming is not yet implemented'); + case OutputFormatTypes.Null: + this.pipelineSteps.push(new NullOutputFormat()); } if ( diff --git a/server/src/ffmpeg/builder/pipeline/hardware/QsvPipelineBuilder.test.ts b/server/src/ffmpeg/builder/pipeline/hardware/QsvPipelineBuilder.test.ts index 470b31bd5..de5d74255 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/QsvPipelineBuilder.test.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/QsvPipelineBuilder.test.ts @@ -24,42 +24,108 @@ import { FrameState } from '../../state/FrameState.ts'; import { FrameSize } from '../../types.ts'; import { QsvPipelineBuilder } from './QsvPipelineBuilder.ts'; +// Shared test fixtures + +const ffmpegVersion = { + versionString: 'n7.0.2-15-g0458a86656-20240904', + majorVersion: 7, + minorVersion: 0, + patchVersion: 2, + isUnknown: false, +} as const; + +function makeVaapiCapabilities( + profiles: VaapiProfileEntrypoint[] = [], +): VaapiHardwareCapabilities { + return new VaapiHardwareCapabilities(profiles); +} + +function makeH264DecodeEncodeCapabilities(): VaapiHardwareCapabilities { + return new VaapiHardwareCapabilities([ + new VaapiProfileEntrypoint(VaapiProfiles.H264Main, VaapiEntrypoint.Decode), + new VaapiProfileEntrypoint(VaapiProfiles.H264Main, VaapiEntrypoint.Encode), + ]); +} + +function makeEmptyBinaryCapabilities(): FfmpegCapabilities { + return new FfmpegCapabilities(new Set(), new Map(), new Set()); +} + +function makeH264VideoInputNonSquare(): VideoInputSource { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + displayAspectRatio: '16:9', + frameSize: FrameSize.withDimensions(1920, 900), + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + }), + ); +} + +function makeH264ProfileVideoInput(): VideoInputSource { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + profile: 'main', + displayAspectRatio: '16:9', + frameSize: FrameSize.withDimensions(1920, 900), + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + }), + ); +} + +function makeWatermark(duration = 5): WatermarkInputSource { + return new WatermarkInputSource( + new FileStreamSource('/path/to/watermark.jpg'), + StillImageStream.create({ + frameSize: FrameSize.withDimensions(800, 600), + index: 0, + }), + { + duration, + enabled: true, + horizontalMargin: 5, + opacity: 100, + position: 'bottom-right', + verticalMargin: 5, + width: 10, + }, + ); +} + +function makePgsSubtitles(): SubtitlesInputSource { + return new SubtitlesInputSource( + new FileStreamSource('/path/to/video.mkv'), + [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], + SubtitleMethods.Burn, + ); +} + +function makeDesiredFrameState( + video: VideoInputSource, + overrides?: Partial[0]>, +): FrameState { + return new FrameState({ + isAnamorphic: false, + scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD), + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + ...overrides, + }); +} + describe('QsvPipelineBuilder', () => { test('should work', () => { - const capabilities = new VaapiHardwareCapabilities([]); - const binaryCapabilities = new FfmpegCapabilities( - new Set(), - new Map(), - new Set(), - ); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeVaapiCapabilities(); + const binaryCapabilities = makeEmptyBinaryCapabilities(); + const video = makeH264VideoInputNonSquare(); + const watermark = makeWatermark(); const builder = new QsvPipelineBuilder( capabilities, @@ -68,23 +134,10 @@ describe('QsvPipelineBuilder', () => { null, null, watermark, - new SubtitlesInputSource( - new FileStreamSource('/path/to/video.mkv'), - [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - SubtitleMethods.Burn, - ), + makePgsSubtitles(), ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, @@ -98,53 +151,61 @@ describe('QsvPipelineBuilder', () => { DefaultPipelineOptions, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "qsv=hw:hw,child_device_type=vaapi", + "-filter_hw_device", + "hw", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]setpts=PTS-STARTPTS,fps=24,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-r", + "24", + "-fps_mode", + "cfr", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('should work, decoding disabled', () => { - const capabilities = new VaapiHardwareCapabilities([ - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Decode, - ), - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Encode, - ), - ]); - const binaryCapabilities = new FfmpegCapabilities( - new Set(), - new Map(), - new Set(), - ); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = makeEmptyBinaryCapabilities(); + const video = makeH264VideoInputNonSquare(); + const watermark = makeWatermark(); const builder = new QsvPipelineBuilder( capabilities, @@ -153,84 +214,72 @@ describe('QsvPipelineBuilder', () => { null, null, watermark, - new SubtitlesInputSource( - new FileStreamSource('/path/to/video.mkv'), - [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - SubtitleMethods.Burn, - ), + makePgsSubtitles(), ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, - new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - videoFormat: 'h264', - }), + makeDesiredFrameState(video, { videoFormat: 'h264' }), { ...DefaultPipelineOptions, disableHardwareDecoding: true }, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "qsv=hw:hw,child_device_type=vaapi", + "-filter_hw_device", + "hw", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]setpts=PTS-STARTPTS,fps=24,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_qsv", + "-low_power", + "0", + "-look_ahead", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('should work, encoding disabled', () => { - const capabilities = new VaapiHardwareCapabilities([ - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Decode, - ), - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Encode, - ), - ]); - const binaryCapabilities = new FfmpegCapabilities( - new Set(), - new Map(), - new Set(), - ); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - profile: 'main', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 0, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = makeEmptyBinaryCapabilities(); + const video = makeH264ProfileVideoInput(); + const watermark = makeWatermark(0); const builder = new QsvPipelineBuilder( capabilities, @@ -242,77 +291,71 @@ describe('QsvPipelineBuilder', () => { null, ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, - new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - videoFormat: 'h264', - }), + makeDesiredFrameState(video, { videoFormat: 'h264' }), { ...DefaultPipelineOptions, disableHardwareEncoding: true }, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-hwaccel", + "qsv", + "-hwaccel_output_format", + "qsv", + "-init_hw_device", + "qsv=hw:hw,child_device_type=vaapi", + "-filter_hw_device", + "hw", + "-c:v", + "h264_qsv", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]setpts=PTS-STARTPTS,fps=24,hwdownload,format=nv12,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('should work, filters disabled', () => { - const capabilities = new VaapiHardwareCapabilities([ - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Decode, - ), - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Encode, - ), - ]); - const binaryCapabilities = new FfmpegCapabilities( - new Set(), - new Map(), - new Set(), - ); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - profile: 'main', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = makeEmptyBinaryCapabilities(); + const video = makeH264ProfileVideoInput(); + const watermark = makeWatermark(); const builder = new QsvPipelineBuilder( capabilities, @@ -321,36 +364,70 @@ describe('QsvPipelineBuilder', () => { null, null, watermark, - new SubtitlesInputSource( - new FileStreamSource('/path/to/video.mkv'), - [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - SubtitleMethods.Burn, - ), + makePgsSubtitles(), ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, - new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - videoFormat: 'h264', - }), + makeDesiredFrameState(video, { videoFormat: 'h264' }), { ...DefaultPipelineOptions, disableHardwareFilters: true }, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-hwaccel", + "qsv", + "-hwaccel_output_format", + "qsv", + "-init_hw_device", + "qsv=hw:hw,child_device_type=vaapi", + "-filter_hw_device", + "hw", + "-c:v", + "h264_qsv", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]setpts=PTS-STARTPTS,fps=24,hwdownload,format=nv12,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_qsv", + "-low_power", + "0", + "-look_ahead", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); }); diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.test.ts b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.test.ts index dd37c6cc2..5527b45cc 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.test.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.test.ts @@ -1,6 +1,10 @@ +import { ColorFormat } from '@/ffmpeg/builder/format/ColorFormat.js'; import { TONEMAP_ENABLED } from '@/util/env.js'; import { FileStreamSource } from '../../../../stream/types.ts'; -import { FfmpegCapabilities } from '../../capabilities/FfmpegCapabilities.ts'; +import { + EmptyFfmpegCapabilities, + FfmpegCapabilities, +} from '../../capabilities/FfmpegCapabilities.ts'; import { VaapiEntrypoint, VaapiHardwareCapabilities, @@ -38,44 +42,121 @@ import { import { FrameState } from '../../state/FrameState.ts'; import { FrameSize } from '../../types.ts'; import { VaapiPipelineBuilder } from './VaapiPipelineBuilder.ts'; -import { ColorFormat } from '@/ffmpeg/builder/format/ColorFormat.js'; + +// Shared test fixtures + +const ffmpegVersion = { + versionString: 'n7.0.2-15-g0458a86656-20240904', + majorVersion: 7, + minorVersion: 0, + patchVersion: 2, + isUnknown: false, +} as const; + +function makeVaapiCapabilities( + profiles: VaapiProfileEntrypoint[] = [], +): VaapiHardwareCapabilities { + return new VaapiHardwareCapabilities(profiles); +} + +function makeH264DecodeEncodeCapabilities(): VaapiHardwareCapabilities { + return new VaapiHardwareCapabilities([ + new VaapiProfileEntrypoint(VaapiProfiles.H264Main, VaapiEntrypoint.Decode), + new VaapiProfileEntrypoint(VaapiProfiles.H264Main, VaapiEntrypoint.Encode), + ]); +} + +function makeH264VideoInputNonSquare(): VideoInputSource { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + displayAspectRatio: '16:9', + frameSize: FrameSize.withDimensions(1920, 900), + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + }), + ); +} + +function makeH264VideoInputFhd(): VideoInputSource { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + displayAspectRatio: '16:9', + frameSize: FrameSize.FHD, + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + }), + ); +} + +function makeH264ProfileVideoInput( + frameSize = FrameSize.withDimensions(1920, 900), +): VideoInputSource { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + profile: 'main', + displayAspectRatio: '16:9', + frameSize, + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + }), + ); +} + +function makeWatermark(opacity = 100, duration = 5): WatermarkInputSource { + return new WatermarkInputSource( + new FileStreamSource('/path/to/watermark.jpg'), + StillImageStream.create({ + frameSize: FrameSize.withDimensions(800, 600), + index: 0, + }), + { + duration, + enabled: true, + horizontalMargin: 5, + opacity, + position: 'bottom-right', + verticalMargin: 5, + width: 10, + }, + ); +} + +function makePgsSubtitles(source?: FileStreamSource): SubtitlesInputSource { + return new SubtitlesInputSource( + source ?? new FileStreamSource('/path/to/video.mkv'), + [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], + SubtitleMethods.Burn, + ); +} + +function makeDesiredFrameState( + video: VideoInputSource, + overrides?: Partial[0]>, +): FrameState { + return new FrameState({ + isAnamorphic: false, + scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD), + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + ...overrides, + }); +} describe('VaapiPipelineBuilder', () => { test('should work', () => { - const capabilities = new VaapiHardwareCapabilities([]); - const binaryCapabilities = new FfmpegCapabilities( - new Set(), - new Map(), - new Set(), - ); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeVaapiCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264VideoInputNonSquare(); + const watermark = makeWatermark(); const builder = new VaapiPipelineBuilder( capabilities, @@ -83,83 +164,65 @@ describe('VaapiPipelineBuilder', () => { video, null, watermark, - new SubtitlesInputSource( - new FileStreamSource('/path/to/video.mkv'), - [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - SubtitleMethods.Burn, - ), + makePgsSubtitles(), null, ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, - new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - }), + makeDesiredFrameState(video), DefaultPipelineOptions, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]pad=1920:1080:-1:-1:color=black[v];[0:5]format=vaapi|yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,scale=1920:1080:force_original_aspect_ratio=decrease[sub];[1:0]scale=192:-1,format=yuva420p[wm];[v][sub]overlay=x=(W-w)/2:y=(H-h)/2:format=0[vsub];[vsub][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('should work, decoding disabled', () => { - const capabilities = new VaapiHardwareCapabilities([ - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Decode, - ), - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Encode, - ), - ]); - const binaryCapabilities = new FfmpegCapabilities( - new Set(), - new Map(), - new Set(), - ); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264VideoInputNonSquare(); + const watermark = makeWatermark(); const builder = new VaapiPipelineBuilder( capabilities, @@ -167,85 +230,68 @@ describe('VaapiPipelineBuilder', () => { video, null, watermark, - new SubtitlesInputSource( - new FileStreamSource('/path/to/video.mkv'), - [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - SubtitleMethods.Burn, - ), + makePgsSubtitles(), null, ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, - new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - videoFormat: 'h264', - }), + makeDesiredFrameState(video, { videoFormat: 'h264' }), { ...DefaultPipelineOptions, disableHardwareDecoding: true }, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]pad=1920:1080:-1:-1:color=black[v];[0:5]format=vaapi|yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,scale=1920:1080:force_original_aspect_ratio=decrease[sub];[1:0]scale=192:-1,format=yuva420p[wm];[v][sub]overlay=x=(W-w)/2:y=(H-h)/2:format=0[vsub];[vsub][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm];[vwm]format=nv12|p010le|vaapi,hwupload=extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "h264_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('should work, encoding disabled', () => { - const capabilities = new VaapiHardwareCapabilities([ - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Decode, - ), - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Encode, - ), - ]); - const binaryCapabilities = new FfmpegCapabilities( - new Set(), - new Map(), - new Set(), - ); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - profile: 'main', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 0, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264ProfileVideoInput(); + const watermark = makeWatermark(100, 0); const builder = new VaapiPipelineBuilder( capabilities, @@ -253,86 +299,71 @@ describe('VaapiPipelineBuilder', () => { video, null, watermark, - // new SubtitlesInputSource( - // new FileStreamSource('/path/to/video.mkv'), - // [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - // SubtitleMethods.Burn, - // ), null, null, ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, - new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - videoFormat: 'h264', - }), + makeDesiredFrameState(video, { videoFormat: 'h264' }), { ...DefaultPipelineOptions, disableHardwareEncoding: true }, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=nv12,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-pix_fmt", + "nv12", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('should work, filters disabled', () => { - const capabilities = new VaapiHardwareCapabilities([ - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Decode, - ), - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Encode, - ), - ]); - const binaryCapabilities = new FfmpegCapabilities( - new Set(), - new Map(), - new Set(), - ); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - profile: 'main', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264ProfileVideoInput(); + const watermark = makeWatermark(); const builder = new VaapiPipelineBuilder( capabilities, @@ -340,56 +371,70 @@ describe('VaapiPipelineBuilder', () => { video, null, watermark, - new SubtitlesInputSource( - new FileStreamSource('/path/to/video.mkv'), - [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - SubtitleMethods.Burn, - ), + makePgsSubtitles(), null, ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, - new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0].squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - videoFormat: 'h264', - }), + makeDesiredFrameState(video, { videoFormat: 'h264' }), { ...DefaultPipelineOptions, disableHardwareFilters: true }, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=nv12,pad=1920:1080:-1:-1:color=black[v];[0:5]format=vaapi|yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,scale=1920:1080:force_original_aspect_ratio=decrease[sub];[1:0]scale=192:-1,format=yuva420p[wm];[v][sub]overlay=x=(W-w)/2:y=(H-h)/2:format=0[vsub];[vsub][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm];[vwm]format=nv12,hwupload=extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "h264_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('basic audio-only stream', () => { - const capabilities = new VaapiHardwareCapabilities([ - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Decode, - ), - new VaapiProfileEntrypoint( - VaapiProfiles.H264Main, - VaapiEntrypoint.Encode, - ), - ]); - const binaryCapabilities = new FfmpegCapabilities( - new Set(), - new Map(), - new Set(), - ); + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; const video = VideoInputSource.withStream( new FileStreamSource('/path/to/image.png'), @@ -424,16 +469,7 @@ describe('VaapiPipelineBuilder', () => { null, ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, @@ -447,7 +483,613 @@ describe('VaapiPipelineBuilder', () => { { ...DefaultPipelineOptions, disableHardwareFilters: true }, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-readrate", + "1", + "-i", + "/path/to/image.png", + "-readrate", + "1", + "-i", + "/path/to/song.flac", + "-filter_complex", + "[0:0]scale=1920:1080:flags=fast_bilinear:force_original_aspect_ratio=decrease,setsar=1,pad=1920:1080:-1:-1:color=black,loop=-1:1[v];[1:0]aresample=async=1[a];[v]format=nv12,hwupload=extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "[a]", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "h264_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "aac", + "-b:a", + "192k", + "-maxrate:a", + "192k", + "-bufsize:a", + "384k", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + describe('scale behavior', () => { + test('does not emit unnecessary scale filter when input is already at desired size (software decode)', () => { + // Bug 2: VaapiPipelineBuilder.setScale() else branch creates ScaleVaapiFilter unconditionally. + // When input size == desired size AND pixelFormat is null (which it always is for non-CUDA paths), + // ScaleVaapiFilter.genFilter() returns '' → isNonEmptyString guard prevents filter from being added. + // This test documents the current behavior (no scale filter for equal sizes with software decode). + const capabilities = makeVaapiCapabilities(); // no decode/encode capabilities + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264VideoInputFhd(); // Already at FHD + + const builder = new VaapiPipelineBuilder( + capabilities, + binaryCapabilities, + video, + null, + null, + null, + null, + ); + + const state = FfmpegState.create({ version: ffmpegVersion }); + + // Desired output is also FHD - sizes are equal + const out = builder.build( + state, + new FrameState({ + isAnamorphic: false, + scaledSize: FrameSize.FHD, + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + }), + DefaultPipelineOptions, + ); + + // Document current behavior: ScaleVaapiFilter is in the else branch but + // genFilter() returns '' for equal sizes with null pixelFormat → no scale emitted + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-map", + "0:0", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('does not emit unnecessary scale filter when VAAPI-decoded input is already at desired size', () => { + // Bug 2 variant: VAAPI hardware decode path, equal sizes. + // setScale else branch fires because decoderMode===VAAPI makes the if-condition false. + // ScaleVaapiFilter.genFilter() returns '' for equal sizes with null pixelFormat → no filter. + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264VideoInputFhd(); // Already at FHD + + const builder = new VaapiPipelineBuilder( + capabilities, + binaryCapabilities, + video, + null, + null, + null, + null, + ); + + // Pass vaapiDevice so hardware path activates + const state = FfmpegState.create({ + version: ffmpegVersion, + vaapiDevice: '/dev/dri/renderD128', + }); + + const out = builder.build( + state, + new FrameState({ + isAnamorphic: false, + scaledSize: FrameSize.FHD, + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + videoFormat: 'h264', + }), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-vaapi_device", + "/dev/dri/renderD128", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]format=nv12|p010le|vaapi,hwupload=extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "h264_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + }); + + describe('watermark opacity handling', () => { + test('does not emit opacity filter for fully opaque watermark (opacity=100)', () => { + // opacity=100 → inRange(100, 0, 100) = false → no opacity filter (correct) + // Also consistent with NVIDIA which uses: opacity !== 100 → false → no filter + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264ProfileVideoInput(); + const watermark = makeWatermark(100); + + const builder = new VaapiPipelineBuilder( + capabilities, + binaryCapabilities, + video, + null, + watermark, + null, + null, + ); + + const state = FfmpegState.create({ version: ffmpegVersion }); + + const out = builder.build( + state, + makeDesiredFrameState(video, { videoFormat: 'h264' }), + DefaultPipelineOptions, + ); + + const args = out.getCommandArgs().join(' '); + expect(args).not.toContain('opacity'); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=nv12,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm];[vwm]format=nv12,hwupload=extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "h264_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('emits opacity filter for opacity=50 (partially transparent watermark)', () => { + // opacity=50 → inRange(50, 0, 100) = true → opacity filter added + // Bug 3 note: VAAPI passes opacity directly (50), not divided by 100 like Software/NVIDIA + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264ProfileVideoInput(); + const watermark = makeWatermark(50); + + const builder = new VaapiPipelineBuilder( + capabilities, + binaryCapabilities, + video, + null, + watermark, + null, + null, + ); + + const state = FfmpegState.create({ version: ffmpegVersion }); + + const out = builder.build( + state, + makeDesiredFrameState(video, { videoFormat: 'h264' }), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=nv12,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.5,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm];[vwm]format=nv12,hwupload=extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "h264_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('emits opacity filter for opacity=0 (fully transparent watermark)', () => { + // Bug 3: VAAPI uses inRange(opacity, 0, 100) → inRange(0, 0, 100) = true → filter added + // NVIDIA uses opacity !== 100 → 0 !== 100 = true → filter added + // Both are consistent here. VAAPI passes 0 directly (not 0/100=0). + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264ProfileVideoInput(); + const watermark = makeWatermark(0); + + const builder = new VaapiPipelineBuilder( + capabilities, + binaryCapabilities, + video, + null, + watermark, + null, + null, + ); + + const state = FfmpegState.create({ version: ffmpegVersion }); + + const out = builder.build( + state, + makeDesiredFrameState(video, { videoFormat: 'h264' }), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=nv12,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm];[vwm]format=nv12,hwupload=extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "h264_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + }); + + describe('software overlay flag (Bug 1 - forceSoftwareOverlay direct mutation)', () => { + test('radeonsi driver forces software overlay for watermarks', () => { + // Bug 1: VaapiPipelineBuilder.setupVideoFilters() directly mutates currentState: + // currentState.forceSoftwareOverlay = forceSoftwareOverlay; + // The correct approach is: currentState = currentState.update({ forceSoftwareOverlay }) + // This test documents that the radeonsi path correctly activates software overlay + // (the mutation works despite being a bug pattern). + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264ProfileVideoInput(); + const watermark = makeWatermark(); + + const builder = new VaapiPipelineBuilder( + capabilities, + binaryCapabilities, + video, + null, + watermark, + makePgsSubtitles(), + null, + ); + + // BUG 1: vaapiDriver='radeonsi' sets forceSoftwareOverlay=true via direct mutation + const state = FfmpegState.create({ + version: ffmpegVersion, + vaapiDriver: 'radeonsi', + vaapiDevice: '/dev/dri/renderD128', + }); + + const out = builder.build( + state, + makeDesiredFrameState(video, { videoFormat: 'h264' }), + DefaultPipelineOptions, + ); + + // With radeonsi, software overlay path should be used (no VAAPI overlay filters) + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-hwaccel", + "vaapi", + "-vaapi_device", + "/dev/dri/renderD128", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=nv12,pad=1920:1080:-1:-1:color=black[v];[0:5]format=vaapi|yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,scale=1920:1080:force_original_aspect_ratio=decrease[sub];[1:0]scale=192:-1,format=yuva420p[wm];[v][sub]overlay=x=(W-w)/2:y=(H-h)/2:format=0[vsub];[vsub][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm];[vwm]format=nv12,hwupload=extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "h264_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('watermark + subtitle forces software overlay', () => { + // Bug 1: The condition (hasWatermark && hasSubtitleOverlay()) → forceSoftwareOverlay=true + // is applied via direct mutation. This test documents the behavior. + const capabilities = makeH264DecodeEncodeCapabilities(); + const binaryCapabilities = EmptyFfmpegCapabilities; + const video = makeH264ProfileVideoInput(); + const watermark = makeWatermark(); + + const builder = new VaapiPipelineBuilder( + capabilities, + binaryCapabilities, + video, + null, + watermark, + makePgsSubtitles(), + null, + ); + + const state = FfmpegState.create({ + version: ffmpegVersion, + vaapiDevice: '/dev/dri/renderD128', + }); + + const out = builder.build( + state, + makeDesiredFrameState(video, { videoFormat: 'h264' }), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-hwaccel", + "vaapi", + "-vaapi_device", + "/dev/dri/renderD128", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=nv12,pad=1920:1080:-1:-1:color=black[v];[0:5]format=vaapi|yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,scale=1920:1080:force_original_aspect_ratio=decrease[sub];[1:0]scale=192:-1,format=yuva420p[wm];[v][sub]overlay=x=(W-w)/2:y=(H-h)/2:format=0[vsub];[vsub][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm];[vwm]format=nv12,hwupload=extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "h264_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); }); }); @@ -579,6 +1221,51 @@ describe('VaapiPipelineBuilder tonemap', () => { const args = pipeline.getCommandArgs().join(' '); expect(args).toContain('tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709'); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709,scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('applies tonemap filter for HLG (arib-std-b67) content', () => { @@ -596,6 +1283,51 @@ describe('VaapiPipelineBuilder tonemap', () => { }); expect(hasTonemapFilter(pipeline)).to.eq(true); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709,scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('skips tonemap when TONEMAP_ENABLED is false', () => { @@ -606,6 +1338,51 @@ describe('VaapiPipelineBuilder tonemap', () => { }); expect(hasTonemapFilter(pipeline)).to.eq(false); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('skips tonemap when content is SDR', () => { @@ -630,6 +1407,51 @@ describe('VaapiPipelineBuilder tonemap', () => { const pipeline = buildWithTonemap({ videoStream: sdrStream }); expect(hasTonemapFilter(pipeline)).to.eq(false); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('skips tonemap when ffmpeg lacks both tonemap_vaapi and tonemap_opencl filters', () => { @@ -647,6 +1469,51 @@ describe('VaapiPipelineBuilder tonemap', () => { expect(hasTonemapFilter(pipeline)).to.eq(false); expect(hasOpenclTonemapFilter(pipeline)).to.eq(false); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('skips tonemap when hardware filters are disabled', () => { @@ -658,6 +1525,51 @@ describe('VaapiPipelineBuilder tonemap', () => { }); expect(hasTonemapFilter(pipeline)).to.eq(false); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('tonemap filter appears before scale in the filter chain', () => { @@ -674,6 +1586,51 @@ describe('VaapiPipelineBuilder tonemap', () => { expect(tonemapIndex).toBeGreaterThan(-1); expect(scaleIndex).toBeGreaterThan(-1); expect(tonemapIndex).toBeLessThan(scaleIndex); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709,scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('falls back to tonemap_opencl when tonemap_vaapi is unavailable', () => { @@ -695,6 +1652,51 @@ describe('VaapiPipelineBuilder tonemap', () => { expect(args).toContain('tonemap_opencl=tonemap=hable'); expect(args).toContain('hwmap=derive_device=opencl'); expect(args).toContain('hwmap=derive_device=vaapi:reverse=1'); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]hwmap=derive_device=opencl,tonemap_opencl=tonemap=hable:desat=0:t=bt709:m=bt709:p=bt709:format=nv12,hwmap=derive_device=vaapi:reverse=1,scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('prefers tonemap_vaapi over tonemap_opencl when both are available', () => { @@ -715,6 +1717,51 @@ describe('VaapiPipelineBuilder tonemap', () => { expect(hasTonemapFilter(pipeline)).to.eq(true); expect(hasOpenclTonemapFilter(pipeline)).to.eq(false); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709,scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('opencl tonemap filter appears before scale in the filter chain', () => { @@ -737,6 +1784,51 @@ describe('VaapiPipelineBuilder tonemap', () => { expect(tonemapIndex).toBeGreaterThan(-1); expect(scaleIndex).toBeGreaterThan(-1); expect(tonemapIndex).toBeLessThan(scaleIndex); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]hwmap=derive_device=opencl,tonemap_opencl=tonemap=hable:desat=0:t=bt709:m=bt709:p=bt709:format=nv12,hwmap=derive_device=vaapi:reverse=1,scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('skips opencl tonemap when hardware filters are disabled', () => { @@ -754,5 +1846,141 @@ describe('VaapiPipelineBuilder tonemap', () => { }); expect(hasOpenclTonemapFilter(pipeline)).to.eq(false); + expect(pipeline.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-extra_hw_frames", + "64", + "-hwaccel_output_format", + "vaapi", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]scale_vaapi=1920:1080:extra_hw_frames=64:force_divisible_by=2,setsar=1[v];[v]scale_vaapi=format=nv12:extra_hw_frames=64[vpf]", + "-map", + "[vpf]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-noautoscale", + "-c:v", + "hevc_vaapi", + "-sei", + "-a53_cc", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + describe('tonemapHdr state flag', () => { + test('Bug 6: ffmpegState.tonemapHdr should be true after VAAPI tonemapping activates', () => { + process.env[TONEMAP_ENABLED] = 'true'; + + const capabilities = new VaapiHardwareCapabilities([ + new VaapiProfileEntrypoint( + VaapiProfiles.HevcMain10, + VaapiEntrypoint.Decode, + ), + new VaapiProfileEntrypoint(VaapiProfiles.HevcMain, VaapiEntrypoint.Encode), + ]); + const binaryCapabilities = new FfmpegCapabilities( + new Set(), + new Map(), + new Set([KnownFfmpegFilters.TonemapVaapi]), + ); + const video = VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + createHdrVideoStream(), + ); + const builder = new VaapiPipelineBuilder( + capabilities, + binaryCapabilities, + video, + null, + null, + null, + null, + ); + const state = FfmpegState.create({ version: fakeVersion }); + builder.build( + state, + new FrameState({ + isAnamorphic: false, + scaledSize: FrameSize.FHD, + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + videoFormat: 'hevc', + }), + { ...DefaultPipelineOptions, vaapiDevice: '/dev/dri/renderD128' }, + ); + + // Bug 6 (before fix): state.tonemapHdr === false even though tonemap_vaapi filter is active + // After fix: state.tonemapHdr === true + expect(state.tonemapHdr).toBe(true); + }); + + test('ffmpegState.tonemapHdr remains false when TONEMAP_ENABLED is not set', () => { + // No process.env[TONEMAP_ENABLED] — default is false, no tonemapping + const capabilities = new VaapiHardwareCapabilities([ + new VaapiProfileEntrypoint( + VaapiProfiles.HevcMain10, + VaapiEntrypoint.Decode, + ), + new VaapiProfileEntrypoint(VaapiProfiles.HevcMain, VaapiEntrypoint.Encode), + ]); + const binaryCapabilities = new FfmpegCapabilities( + new Set(), + new Map(), + new Set([KnownFfmpegFilters.TonemapVaapi]), + ); + const video = VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + createHdrVideoStream(), + ); + const builder = new VaapiPipelineBuilder( + capabilities, + binaryCapabilities, + video, + null, + null, + null, + null, + ); + const state = FfmpegState.create({ version: fakeVersion }); + builder.build( + state, + new FrameState({ + isAnamorphic: false, + scaledSize: FrameSize.FHD, + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + videoFormat: 'hevc', + }), + { ...DefaultPipelineOptions, vaapiDevice: '/dev/dri/renderD128' }, + ); + + expect(state.tonemapHdr).toBe(false); + }); }); }); diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts index fec51bac6..1a03620cc 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts @@ -36,7 +36,7 @@ import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js'; import type { Nullable } from '@/types/util.js'; import { TONEMAP_ENABLED, getBooleanEnvVar } from '@/util/env.js'; import { isDefined, isNonEmptyString } from '@/util/index.js'; -import { every, head, inRange, isUndefined } from 'lodash-es'; +import { every, head, isUndefined } from 'lodash-es'; import { P, match } from 'ts-pattern'; import { H264VaapiEncoder, @@ -182,7 +182,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { (this.context.hasWatermark && this.context.hasSubtitleOverlay()) || ffmpegState.vaapiDriver === 'radeonsi'; - currentState.forceSoftwareOverlay = forceSoftwareOverlay; + currentState = currentState.update({ forceSoftwareOverlay }); if ( currentState.frameDataLocation === FrameDataLocation.Software && @@ -373,6 +373,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { if (filter) { const nextState = filter.nextState(currentState); this.videoInputSource.filterSteps.push(filter); + this.context.ffmpegState.tonemapHdr = true; return nextState; } @@ -382,7 +383,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { protected setScale(currentState: FrameState): FrameState { let nextState = currentState; const { desiredState, ffmpegState, shouldDeinterlace } = this.context; - let scaleOption: FilterOption; + let scaleOption: FilterOption | undefined; if ( !currentState.scaledSize.equals(desiredState.scaledSize) && ((ffmpegState.decoderHwAccelMode === HardwareAccelerationMode.None && @@ -397,7 +398,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { desiredState.paddedSize, // desiredState.croppedSize ); - } else { + } else if (!currentState.scaledSize.equals(desiredState.scaledSize)) { scaleOption = new ScaleVaapiFilter( currentState.update({ pixelFormat: @@ -413,7 +414,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { ); } - if (isNonEmptyString(scaleOption.filter)) { + if (scaleOption && isNonEmptyString(scaleOption.filter)) { nextState = scaleOption.nextState(currentState); this.videoInputSource.filterSteps.push(scaleOption); } @@ -528,7 +529,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { ); } - if (inRange(watermarkInput.watermark.opacity, 0, 100)) { + if (watermarkInput.watermark.opacity !== 100) { // opacity watermarkInput.filterSteps.push( new WatermarkOpacityFilter(watermarkInput.watermark.opacity), diff --git a/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.test.ts b/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.test.ts index 2246460b5..f60d048ba 100644 --- a/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.test.ts +++ b/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.test.ts @@ -11,6 +11,7 @@ import { ColorRanges, ColorSpaces, ColorTransferFormats, + NullOutputFormat, } from '../../constants.ts'; import { DeinterlaceFilter } from '../../filter/DeinterlaceFilter.ts'; import { LibplaceboTonemapFilter } from '../../filter/LibplaceboTonemapFilter.ts'; @@ -37,38 +38,133 @@ import { FrameState } from '../../state/FrameState.ts'; import { FrameSize } from '../../types.ts'; import { NvidiaPipelineBuilder } from './NvidiaPipelineBuilder.ts'; +// Shared test fixtures + +const ffmpegVersion = { + versionString: 'n7.0.2-15-g0458a86656-20240904', + majorVersion: 7, + minorVersion: 0, + patchVersion: 2, + isUnknown: false, +} as const; + +function makeH264VideoInput(frameSize = FrameSize.FHD) { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + displayAspectRatio: '16:9', + frameSize, + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + colorFormat: ColorFormat.unknown, + }), + ); +} + +function makeH264VideoInputNonSquare() { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + displayAspectRatio: '16:9', + frameSize: FrameSize.withDimensions(1920, 900), + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + colorFormat: ColorFormat.unknown, + }), + ); +} + +function makeH264VideoInput720p() { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + displayAspectRatio: '16:9', + frameSize: FrameSize.SevenTwenty, + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + colorFormat: ColorFormat.unknown, + }), + ); +} + +const hdrColorFormat = new ColorFormat({ + colorRange: ColorRanges.Tv, + colorSpace: ColorSpaces.Bt2020nc, + colorTransfer: ColorTransferFormats.Smpte2084, + colorPrimaries: ColorPrimaries.Bt2020, +}); + +function makeHdrVideoInput() { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/hdr-video.mkv'), + VideoStream.create({ + codec: 'hevc', + displayAspectRatio: '16:9', + frameSize: FrameSize.FHD, + index: 0, + pixelFormat: new PixelFormatYuv420P10Le(), + providedSampleAspectRatio: null, + colorFormat: hdrColorFormat, + }), + ); +} + +function makeWatermark(opacity = 100) { + return new WatermarkInputSource( + new FileStreamSource('/path/to/watermark.jpg'), + StillImageStream.create({ + frameSize: FrameSize.withDimensions(800, 600), + index: 0, + }), + { + duration: 5, + enabled: true, + horizontalMargin: 5, + opacity, + position: 'bottom-right', + verticalMargin: 5, + width: 10, + }, + ); +} + +function makePgsSubtitles(source?: FileStreamSource) { + return new SubtitlesInputSource( + source ?? new FileStreamSource('/path/to/video.mkv'), + [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], + SubtitleMethods.Burn, + ); +} + +function makeDesiredFrameState( + video: VideoInputSource, + overrides?: Partial[0]>, +) { + return new FrameState({ + isAnamorphic: false, + scaledSize: video.streams[0]!.squarePixelFrameSize(FrameSize.FHD), + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + ...overrides, + }); +} + +function makeNvidiaCapabilities(smArch = 75) { + return new NvidiaHardwareCapabilities('RTX 2080 Ti', smArch); +} + describe('NvidiaPipelineBuilder', () => { test('should work', () => { - const capabilities = new NvidiaHardwareCapabilities('RTX 2080 Ti', 75); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - colorFormat: ColorFormat.unknown, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeNvidiaCapabilities(); + const video = makeH264VideoInputNonSquare(); + const videoSource = new FileStreamSource('/path/to/video.mkv'); + const watermark = makeWatermark(); const builder = new NvidiaPipelineBuilder( capabilities, @@ -77,43 +173,74 @@ describe('NvidiaPipelineBuilder', () => { null, null, watermark, - new SubtitlesInputSource( - new FileStreamSource('/path/to/video.mkv'), - [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - SubtitleMethods.Burn, - ), + makePgsSubtitles(), ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, - new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0]!.squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - }), + makeDesiredFrameState(video), DefaultPipelineOptions, ); const thread = out.steps.find((step) => step instanceof ThreadCountOption); expect(thread).toBeInstanceOf(ThreadCountOption); expect(thread?.options()).toEqual(['-threads', '1']); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v];[0:5]format=yuva420p,scale=1920:1080:force_original_aspect_ratio=decrease,hwupload_cuda[sub];[1:0]scale=192:-1,format=yuva420p[wm];[v][sub]overlay_cuda=x=(W-w)/2:y=(H-h)/2,hwdownload,format=yuv420p[vsub];[vsub][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-bsf:v", + "h264_metadata=crop_bottom=8", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('should work software decode', () => { - const capabilities = new NvidiaHardwareCapabilities('RTX 2080 Ti', 75); + const capabilities = makeNvidiaCapabilities(); const video = VideoInputSource.withStream( new FileStreamSource('/path/to/video.mp2'), VideoStream.create({ @@ -139,13 +266,7 @@ describe('NvidiaPipelineBuilder', () => { ); const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, + version: ffmpegVersion, softwareDeinterlaceFilter: 'none', }); @@ -167,39 +288,54 @@ describe('NvidiaPipelineBuilder', () => { (step) => step instanceof DeinterlaceFilter, ); expect(deinterlace?.filter).toBe('yadif=1'); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "mpeg2video", + "-readrate", + "1", + "-i", + "/path/to/video.mp2", + "-filter_complex", + "[0:0]yadif=1,hwupload_cuda,scale_cuda=1920:1080:format=nv12:force_original_aspect_ratio=decrease,setsar=1,hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-sc_threshold", + "1000000000", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('should work with hardware filters disabled', () => { - const capabilities = new NvidiaHardwareCapabilities('RTX 2080 Ti', 75); - const video = VideoInputSource.withStream( - new FileStreamSource('/path/to/video.mkv'), - VideoStream.create({ - codec: 'h264', - displayAspectRatio: '16:9', - frameSize: FrameSize.withDimensions(1920, 900), - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - colorFormat: ColorFormat.unknown, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const capabilities = makeNvidiaCapabilities(); + const video = makeH264VideoInputNonSquare(); + const watermark = makeWatermark(); const builder = new NvidiaPipelineBuilder( capabilities, @@ -208,74 +344,73 @@ describe('NvidiaPipelineBuilder', () => { null, null, watermark, - new SubtitlesInputSource( - new FileStreamSource('/path/to/video.mkv'), - [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - SubtitleMethods.Burn, - ), + makePgsSubtitles(), ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - }); + const state = FfmpegState.create({ version: ffmpegVersion }); - const out = builder.build( - state, - new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0]!.squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - }), - { - ...DefaultPipelineOptions, - disableHardwareFilters: true, - }, - ); + const out = builder.build(state, makeDesiredFrameState(video), { + ...DefaultPipelineOptions, + disableHardwareFilters: true, + }); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=cuda|nv12,pad=1920:1080:-1:-1:color=black,format=yuv420p[v];[0:5]format=yuva420p,scale=1920:1080:force_original_aspect_ratio=decrease[sub];[1:0]scale=192:-1,format=yuva420p[wm];[v][sub]overlay=x=(W-w)/2:y=(H-h)/2:format=0[vsub];[vsub][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-bsf:v", + "h264_metadata=crop_bottom=8", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('updates pixel format for non-scaled input', () => { - const capabilities = new NvidiaHardwareCapabilities('RTX 2080 Ti', 75); - + const capabilities = makeNvidiaCapabilities(); const videoSource = new FileStreamSource('/path/to/video.mkv'); - - const video = VideoInputSource.withStream( - videoSource, - VideoStream.create({ - codec: 'h264', - displayAspectRatio: '16:9', - frameSize: FrameSize.FHD, - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - colorFormat: ColorFormat.unknown, - }), - ); - - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const video = makeH264VideoInput(); + const watermark = makeWatermark(); const builder = new NvidiaPipelineBuilder( capabilities, @@ -284,22 +419,12 @@ describe('NvidiaPipelineBuilder', () => { null, null, watermark, - new SubtitlesInputSource( - videoSource, - [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], - SubtitleMethods.Burn, - ), + makePgsSubtitles(videoSource), ); const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), + version: ffmpegVersion, + outputFormat: NullOutputFormat, }); const out = builder.build( @@ -314,51 +439,57 @@ describe('NvidiaPipelineBuilder', () => { DefaultPipelineOptions, ); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]yadif_cuda,scale_cuda=iw*sar:ih,setsar=1,scale_cuda=1920:1080:format=nv12:force_original_aspect_ratio=decrease,hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v];[0:5]format=yuva420p,scale=1920:1080:force_original_aspect_ratio=decrease,hwupload_cuda[sub];[1:0]scale=192:-1,format=yuva420p[wm];[v][sub]overlay_cuda=x=(W-w)/2:y=(H-h)/2,hwdownload,format=yuv420p[vsub];[vsub][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-c:a", + "copy", + "-f", + "null", + "pipe:1", + ] + `); }); describe('HDR tonemapping', () => { - const hdrColorFormat = new ColorFormat({ - colorRange: ColorRanges.Tv, - colorSpace: ColorSpaces.Bt2020nc, - colorTransfer: ColorTransferFormats.Smpte2084, - colorPrimaries: ColorPrimaries.Bt2020, - }); - - const capabilities = new NvidiaHardwareCapabilities('RTX 2080 Ti', 75); - - const ffmpegVersion = { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - } as const; - - function makeHdrVideoInput() { - return VideoInputSource.withStream( - new FileStreamSource('/path/to/hdr-video.mkv'), - VideoStream.create({ - codec: 'hevc', - displayAspectRatio: '16:9', - frameSize: FrameSize.FHD, - index: 0, - pixelFormat: new PixelFormatYuv420P10Le(), - providedSampleAspectRatio: null, - colorFormat: hdrColorFormat, - }), - ); - } - - function makeDesiredFrameState(video: VideoInputSource) { - return new FrameState({ - isAnamorphic: false, - scaledSize: video.streams[0]!.squarePixelFrameSize(FrameSize.FHD), - paddedSize: FrameSize.FHD, - pixelFormat: new PixelFormatYuv420P(), - }); - } - afterEach(() => { vi.unstubAllEnvs(); }); @@ -376,7 +507,7 @@ describe('NvidiaPipelineBuilder', () => { const video = makeHdrVideoInput(); const builder = new NvidiaPipelineBuilder( - capabilities, + makeNvidiaCapabilities(), binaryCapabilities, video, null, @@ -399,6 +530,54 @@ describe('NvidiaPipelineBuilder', () => { expect(tonemapFilter).toBeInstanceOf(LibplaceboTonemapFilter); expect(tonemapFilter?.filter).toContain('libplacebo=tonemapping=auto'); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda=nv", + "-init_hw_device", + "vulkan=vk@nv", + "-hwaccel", + "vulkan", + "-hwaccel_output_format", + "vulkan", + "-readrate", + "1", + "-i", + "/path/to/hdr-video.mkv", + "-filter_complex", + "[0:0]libplacebo=tonemapping=auto:colorspace=bt709:color_primaries=bt709:color_trc=bt709:format=nv12,hwupload_cuda,scale_cuda=iw*sar:ih,setsar=1,scale_cuda=1920:1080:format=nv12:force_original_aspect_ratio=decrease,hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('does not tonemap HDR content when Vulkan hwaccel is not available', () => { @@ -413,7 +592,7 @@ describe('NvidiaPipelineBuilder', () => { const video = makeHdrVideoInput(); const builder = new NvidiaPipelineBuilder( - capabilities, + makeNvidiaCapabilities(), noVulkanCapabilities, video, null, @@ -435,6 +614,52 @@ describe('NvidiaPipelineBuilder', () => { ); expect(tonemapFilter).toBeUndefined(); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/hdr-video.mkv", + "-filter_complex", + "[0:0]scale_cuda=format=p010le:passthrough=1,scale_cuda=1920:1080:format=nv12:force_original_aspect_ratio=decrease,setsar=1,hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('does not tonemap HDR content when libplacebo filter is not available', () => { @@ -449,7 +674,7 @@ describe('NvidiaPipelineBuilder', () => { const video = makeHdrVideoInput(); const builder = new NvidiaPipelineBuilder( - capabilities, + makeNvidiaCapabilities(), noLibplaceboCapabilities, video, null, @@ -471,6 +696,52 @@ describe('NvidiaPipelineBuilder', () => { ); expect(tonemapFilter).toBeUndefined(); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/hdr-video.mkv", + "-filter_complex", + "[0:0]scale_cuda=format=p010le:passthrough=1,scale_cuda=1920:1080:format=nv12:force_original_aspect_ratio=decrease,setsar=1,hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); test('does not tonemap SDR content even with Vulkan and libplacebo capabilities', () => { @@ -496,7 +767,7 @@ describe('NvidiaPipelineBuilder', () => { ); const builder = new NvidiaPipelineBuilder( - capabilities, + makeNvidiaCapabilities(), binaryCapabilities, video, null, @@ -523,9 +794,57 @@ describe('NvidiaPipelineBuilder', () => { ); expect(tonemapFilter).toBeUndefined(); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/sdr-video.mkv", + "-filter_complex", + "[0:0]scale_cuda=iw*sar:ih,setsar=1,scale_cuda=1920:1080:format=nv12:force_original_aspect_ratio=decrease,hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); - test('does not tonemap HDR content when TUNARR_DISABLE_VULKAN is not set', () => { + test('does not tonemap HDR content when TONEMAP_ENABLED is not set', () => { + // No env var stubs - TONEMAP_ENABLED defaults to false + // getBooleanEnvVar(TONEMAP_ENABLED, false) returns false → no tonemapping const binaryCapabilities = new FfmpegCapabilities( new Set(), new Map(), @@ -535,7 +854,7 @@ describe('NvidiaPipelineBuilder', () => { const video = makeHdrVideoInput(); const builder = new NvidiaPipelineBuilder( - capabilities, + makeNvidiaCapabilities(), binaryCapabilities, video, null, @@ -557,43 +876,224 @@ describe('NvidiaPipelineBuilder', () => { ); expect(tonemapFilter).toBeUndefined(); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/hdr-video.mkv", + "-filter_complex", + "[0:0]scale_cuda=format=p010le:passthrough=1,scale_cuda=1920:1080:format=nv12:force_original_aspect_ratio=decrease,setsar=1,hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); }); - }); - test('intermittent watermark, set format on hardware scale, do not set format on hwdownload', async () => { - const capabilities = new NvidiaHardwareCapabilities('RTX 2080 Ti', 75); + test('uses VulkanDecoder when tonemapping with HDR content', () => { + vi.stubEnv(TUNARR_ENV_VARS.TONEMAP_ENABLED, 'true'); + vi.stubEnv(TUNARR_ENV_VARS.DISABLE_VULKAN, 'false'); - const videoSource = new FileStreamSource('/path/to/video.mkv'); + const binaryCapabilities = new FfmpegCapabilities( + new Set(), + new Map(), + new Set(['libplacebo']), + new Set(['vulkan']), + ); + const video = makeHdrVideoInput(); - const video = VideoInputSource.withStream( - videoSource, - VideoStream.create({ - codec: 'h264', - displayAspectRatio: '16:9', - frameSize: FrameSize.SevenTwenty, - index: 0, - pixelFormat: new PixelFormatYuv420P(), - providedSampleAspectRatio: null, - colorFormat: ColorFormat.unknown, - }), - ); + const builder = new NvidiaPipelineBuilder( + makeNvidiaCapabilities(), + binaryCapabilities, + video, + null, + null, + null, + null, + ); - const watermark = new WatermarkInputSource( - new FileStreamSource('/path/to/watermark.jpg'), - StillImageStream.create({ - frameSize: FrameSize.withDimensions(800, 600), - index: 0, - }), - { - duration: 5, - enabled: true, - horizontalMargin: 5, - opacity: 100, - position: 'bottom-right', - verticalMargin: 5, - width: 10, - }, - ); + const out = builder.build( + FfmpegState.create({ version: ffmpegVersion }), + makeDesiredFrameState(video), + DefaultPipelineOptions, + ); + + // Verify VulkanDecoder appears in command (hwaccel vulkan), not cuda + const args = out.getCommandArgs().join(' '); + expect(args).toContain('vulkan'); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda=nv", + "-init_hw_device", + "vulkan=vk@nv", + "-hwaccel", + "vulkan", + "-hwaccel_output_format", + "vulkan", + "-readrate", + "1", + "-i", + "/path/to/hdr-video.mkv", + "-filter_complex", + "[0:0]libplacebo=tonemapping=auto:colorspace=bt709:color_primaries=bt709:color_trc=bt709:format=nv12,hwupload_cuda,scale_cuda=iw*sar:ih,setsar=1,scale_cuda=1920:1080:format=nv12:force_original_aspect_ratio=decrease,hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('Bug 4: does not initialize Vulkan decoder when desiredState.pixelFormat is null', () => { + vi.stubEnv(TUNARR_ENV_VARS.TONEMAP_ENABLED, 'true'); + vi.stubEnv(TUNARR_ENV_VARS.DISABLE_VULKAN, 'false'); + + const binaryCapabilities = new FfmpegCapabilities( + new Set(), + new Map(), + new Set(['libplacebo']), + new Set(['vulkan']), + ); + const video = makeHdrVideoInput(); + + const builder = new NvidiaPipelineBuilder( + makeNvidiaCapabilities(), + binaryCapabilities, + video, + null, + null, + null, + null, + ); + + const state = FfmpegState.create({ version: ffmpegVersion }); + + // desiredState with null pixelFormat — the bug trigger: + // needsTonemapWithVulkan becomes true → VulkanDecoder used, + // but setTonemap() early-returns (line 808) leaving Vulkan init without any tonemap filter + const desiredState = makeDesiredFrameState(video, { pixelFormat: null }); + + const out = builder.build(state, desiredState, DefaultPipelineOptions); + + // After fix: no vulkan init args, ImplicitNvidiaDecoder (CUDA) used instead + const args = out.getCommandArgs(); + expect(args.join(' ')).not.toContain('vulkan'); // fixed: no vulkan when pixelFormat is null + expect(args.join(' ')).not.toContain('libplacebo'); // no tonemap filter (pixelFormat is null) + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/hdr-video.mkv", + "-filter_complex", + "[0:0]scale_cuda=format=p010le:passthrough=1,scale_cuda=1920:1080:force_original_aspect_ratio=decrease,setsar=1,hwdownload,format=p010le,format=yuv420p10le,pad=1920:1080:-1:-1:color=black,hwupload_cuda[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + }); + + test('intermittent watermark, set format on hardware scale, do not set format on hwdownload', async () => { + const capabilities = makeNvidiaCapabilities(); + const videoSource = new FileStreamSource('/path/to/video.mkv'); + const video = makeH264VideoInput720p(); + const watermark = makeWatermark(); const builder = new NvidiaPipelineBuilder( capabilities, @@ -605,16 +1105,7 @@ describe('NvidiaPipelineBuilder', () => { null, ); - const state = FfmpegState.create({ - version: { - versionString: 'n7.0.2-15-g0458a86656-20240904', - majorVersion: 7, - minorVersion: 0, - patchVersion: 2, - isUnknown: false, - }, - // start: +dayjs.duration(0), - }); + const state = FfmpegState.create({ version: ffmpegVersion }); const out = builder.build( state, @@ -635,6 +1126,138 @@ describe('NvidiaPipelineBuilder', () => { }); }); - console.log(out.getCommandArgs().join(' ')); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]scale_cuda=iw*sar:ih,setsar=1,scale_cuda=1920:1080:format=nv12:force_original_aspect_ratio=decrease,hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "h264_nvenc", + "-rc-lookahead", + "20", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + describe('pixel format handling (Bug 5 - potential double hardware download)', () => { + test('software encoder with watermark does not double-download when hardware decode is used', () => { + // Bug 5: NvidiaPipelineBuilder.setPixelFormat() has two back-to-back conditional blocks + // that can both add HardwareDownloadCudaFilter when watermark present + hw decode + sw encode. + // The first block (lines 488-499): fires when encoderMode=None && watermark && Hardware + // The second block (lines 501-522): fires when Hardware && encoderMode=None + // Both conditions can be true simultaneously, potentially producing a double download. + const capabilities = makeNvidiaCapabilities(); + const video = makeH264VideoInputNonSquare(); + const watermark = makeWatermark(); + + const builder = new NvidiaPipelineBuilder( + capabilities, + EmptyFfmpegCapabilities, + video, + null, + null, + watermark, + null, + ); + + const state = FfmpegState.create({ version: ffmpegVersion }); + + const out = builder.build(state, makeDesiredFrameState(video), { + ...DefaultPipelineOptions, + disableHardwareEncoding: true, + }); + + // Document behavior: snapshot captures that double-download does NOT occur + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-threads", + "1", + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-init_hw_device", + "cuda", + "-hwaccel", + "cuda", + "-hwaccel_output_format", + "cuda", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]hwdownload,format=nv12,format=yuv420p,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + + // Explicit guard: verify exactly one hwdownload (not two) + const filterComplex = out + .getCommandArgs() + .find((arg) => arg.startsWith('[')); + expect(filterComplex).not.toMatch(/hwdownload.*hwdownload/); + expect(filterComplex?.match(/hwdownload/g)?.length).toBe(1); + }); }); }); diff --git a/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.ts index 334df2b49..58fc8312b 100644 --- a/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/nvidia/NvidiaPipelineBuilder.ts @@ -117,6 +117,7 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { const needsTonemapWithVulkan = getBooleanEnvVar(TUNARR_ENV_VARS.TONEMAP_ENABLED, false) && canDecode && + !!desiredState.pixelFormat && this.ffmpegCapabilities.hasHardwareAccel( HardwareAccelerationMode.Vulkan, ) && @@ -486,40 +487,38 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { // TODO color params -- wow there's a lot of stuff to account for!!! - if ( - this.ffmpegState.encoderHwAccelMode === HardwareAccelerationMode.None && - this.watermarkInputSource && - currentState.frameDataLocation === FrameDataLocation.Hardware - ) { - const hwDownloadFilter = new HardwareDownloadCudaFilter( - currentState, - null, - ); - currentState = hwDownloadFilter.nextState(currentState); - steps.push(hwDownloadFilter); - } - if ( currentState.frameDataLocation === FrameDataLocation.Hardware && this.ffmpegState.encoderHwAccelMode === HardwareAccelerationMode.None ) { - if (!currentState.pixelFormat?.equals(desiredFormat)) { - this.logger.trace( - "Pixel format %s doesn't equal format %s", - currentState.pixelFormat?.prettyPrint(), - desiredFormat.prettyPrint(), + if (this.watermarkInputSource) { + // Watermark overlay will handle format; download to native CUDA format + const hwDownloadFilter = new HardwareDownloadCudaFilter( + currentState, + null, ); - const formatFilter = new FormatCudaFilter(desiredFormat); - currentState = formatFilter.nextState(currentState); - steps.push(formatFilter); - } + currentState = hwDownloadFilter.nextState(currentState); + steps.push(hwDownloadFilter); + } else { + // No watermark: reformat if needed, then download to desired format + if (!currentState.pixelFormat?.equals(desiredFormat)) { + this.logger.trace( + "Pixel format %s doesn't equal format %s", + currentState.pixelFormat?.prettyPrint(), + desiredFormat.prettyPrint(), + ); + const formatFilter = new FormatCudaFilter(desiredFormat); + currentState = formatFilter.nextState(currentState); + steps.push(formatFilter); + } - const hwDownloadFilter = new HardwareDownloadCudaFilter( - currentState, - desiredFormat, - ); - currentState = hwDownloadFilter.nextState(currentState); - steps.push(hwDownloadFilter); + const hwDownloadFilter = new HardwareDownloadCudaFilter( + currentState, + desiredFormat, + ); + currentState = hwDownloadFilter.nextState(currentState); + steps.push(hwDownloadFilter); + } } if (!currentState.pixelFormat?.equals(desiredFormat)) { diff --git a/server/src/ffmpeg/builder/pipeline/software/SoftwarePipelineBuilder.test.ts b/server/src/ffmpeg/builder/pipeline/software/SoftwarePipelineBuilder.test.ts new file mode 100644 index 000000000..706d4b056 --- /dev/null +++ b/server/src/ffmpeg/builder/pipeline/software/SoftwarePipelineBuilder.test.ts @@ -0,0 +1,824 @@ +import { FileStreamSource } from '../../../../stream/types.ts'; +import { EmptyFfmpegCapabilities } from '../../capabilities/FfmpegCapabilities.ts'; +import { PixelFormatYuv420P } from '../../format/PixelFormat.ts'; +import { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts'; +import { VideoInputSource } from '../../input/VideoInputSource.ts'; +import { WatermarkInputSource } from '../../input/WatermarkInputSource.ts'; +import { + EmbeddedSubtitleStream, + StillImageStream, + SubtitleMethods, + VideoStream, +} from '../../MediaStream.ts'; +import { + DefaultPipelineOptions, + FfmpegState, +} from '../../state/FfmpegState.ts'; +import { FrameState } from '../../state/FrameState.ts'; +import { FrameSize } from '../../types.ts'; +import { SoftwarePipelineBuilder } from './SoftwarePipelineBuilder.ts'; + +// Shared test fixtures + +const ffmpegVersion = { + versionString: 'n7.0.2', + majorVersion: 7, + minorVersion: 0, + patchVersion: 2, + isUnknown: false, +} as const; + +function makeH264VideoInput(frameSize = FrameSize.FHD): VideoInputSource { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + displayAspectRatio: '16:9', + frameSize, + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + }), + ); +} + +function makeH264VideoInputFourK(): VideoInputSource { + return VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + displayAspectRatio: '16:9', + frameSize: FrameSize.FourK, + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + }), + ); +} + +function makeWatermark(opacity = 100, duration = 5): WatermarkInputSource { + return new WatermarkInputSource( + new FileStreamSource('/path/to/watermark.jpg'), + StillImageStream.create({ + frameSize: FrameSize.withDimensions(800, 600), + index: 0, + }), + { + duration, + enabled: true, + horizontalMargin: 5, + opacity, + position: 'bottom-right', + verticalMargin: 5, + width: 10, + }, + ); +} + +function makePgsSubtitles( + source?: FileStreamSource, +): SubtitlesInputSource { + return new SubtitlesInputSource( + source ?? new FileStreamSource('/path/to/video.mkv'), + [new EmbeddedSubtitleStream('pgs', 5, SubtitleMethods.Burn)], + SubtitleMethods.Burn, + ); +} + +function makeTextSubtitles( + source?: FileStreamSource, +): SubtitlesInputSource { + // 'subrip' (SRT) is a text-based subtitle codec, not image-based + return new SubtitlesInputSource( + source ?? new FileStreamSource('/path/to/video.mkv'), + [new EmbeddedSubtitleStream('subrip', 5, SubtitleMethods.Burn)], + SubtitleMethods.Burn, + ); +} + +function makeDefaultFfmpegState(): FfmpegState { + return FfmpegState.create({ version: ffmpegVersion }); +} + +function makeDesiredFrameState( + video: VideoInputSource, + overrides?: Partial[0]>, +): FrameState { + return new FrameState({ + isAnamorphic: false, + scaledSize: video.streams[0]!.squarePixelFrameSize(FrameSize.FHD), + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + ...overrides, + }); +} + +describe('SoftwarePipelineBuilder', () => { + test('basic H264 to H264 transcode', () => { + const video = makeH264VideoInput(); + + const builder = new SoftwarePipelineBuilder( + video, + null, + null, + null, + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + makeDesiredFrameState(video), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]scale=iw*sar:ih,setsar=1,scale=1920:1080:flags=fast_bilinear:force_original_aspect_ratio=decrease,pad=1920:1080:-1:-1:color=black[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('scale down from 4K to 1080p', () => { + const video = makeH264VideoInputFourK(); + + const builder = new SoftwarePipelineBuilder( + video, + null, + null, + null, + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + // Desired output is FHD, input is 4K → scale down + new FrameState({ + isAnamorphic: false, + scaledSize: FrameSize.FHD, + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + }), + DefaultPipelineOptions, + ); + + const args = out.getCommandArgs().join(' '); + expect(args).toContain('scale'); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]scale=iw*sar:ih,setsar=1,scale=1920:1080:flags=fast_bilinear[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('scale down from non-square aspect ratio to FHD with padding', () => { + // 1920x900 → 1920x1080 with pad + const video = VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'h264', + displayAspectRatio: '16:9', + frameSize: FrameSize.withDimensions(1920, 900), + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + }), + ); + + const builder = new SoftwarePipelineBuilder( + video, + null, + null, + null, + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + makeDesiredFrameState(video), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]pad=1920:1080:-1:-1:color=black[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('with watermark overlay', () => { + const video = makeH264VideoInput(); + const watermark = makeWatermark(); + + const builder = new SoftwarePipelineBuilder( + video, + null, + watermark, + null, + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + makeDesiredFrameState(video), + DefaultPipelineOptions, + ); + + const args = out.getCommandArgs().join(' '); + expect(args).toContain('overlay'); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]scale=iw*sar:ih,setsar=1,scale=1920:1080:flags=fast_bilinear:force_original_aspect_ratio=decrease,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('with watermark overlay at opacity=50', () => { + const video = makeH264VideoInput(); + const watermark = makeWatermark(50); + + const builder = new SoftwarePipelineBuilder( + video, + null, + watermark, + null, + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + makeDesiredFrameState(video), + DefaultPipelineOptions, + ); + + const args = out.getCommandArgs().join(' '); + expect(args).toContain('colorchannelmixer'); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]scale=iw*sar:ih,setsar=1,scale=1920:1080:flags=fast_bilinear:force_original_aspect_ratio=decrease,pad=1920:1080:-1:-1:color=black[v];[1:0]scale=192:-1,format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.5[wm];[v][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('with image-based subtitle burn-in (PGS)', () => { + const videoSource = new FileStreamSource('/path/to/video.mkv'); + const video = makeH264VideoInput(); + + const builder = new SoftwarePipelineBuilder( + video, + null, + null, + makePgsSubtitles(videoSource), + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + makeDesiredFrameState(video), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]scale=iw*sar:ih,setsar=1,scale=1920:1080:flags=fast_bilinear:force_original_aspect_ratio=decrease,pad=1920:1080:-1:-1:color=black[v];[0:5]scale=1920:1080:force_original_aspect_ratio=decrease[sub];[v][sub]overlay=x=(W-w)/2:y=(H-h)/2:format=0[vsub]", + "-map", + "[vsub]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('with text subtitle burn-in (SRT/subrip)', () => { + const videoSource = new FileStreamSource('/path/to/video.mkv'); + const video = makeH264VideoInput(); + + const builder = new SoftwarePipelineBuilder( + video, + null, + null, + makeTextSubtitles(videoSource), + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + makeDesiredFrameState(video), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-copyts", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]scale=iw*sar:ih,setsar=1,scale=1920:1080:flags=fast_bilinear:force_original_aspect_ratio=decrease,pad=1920:1080:-1:-1:color=black,subtitles=/path/to/video.mkv[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('deinterlace', () => { + const video = makeH264VideoInput(); + + const builder = new SoftwarePipelineBuilder( + video, + null, + null, + null, + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + new FrameState({ + isAnamorphic: false, + scaledSize: FrameSize.FHD, + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + deinterlace: true, + }), + DefaultPipelineOptions, + ); + + const args = out.getCommandArgs().join(' '); + expect(args).toContain('yadif'); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-filter_complex", + "[0:0]yadif=1[v]", + "-map", + "[v]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('copy passthrough (video format = copy)', () => { + const video = makeH264VideoInput(); + + const builder = new SoftwarePipelineBuilder( + video, + null, + null, + null, + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + new FrameState({ + isAnamorphic: false, + scaledSize: FrameSize.FHD, + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + videoFormat: 'copy', + }), + DefaultPipelineOptions, + ); + + const args = out.getCommandArgs().join(' '); + expect(args).toContain('copy'); + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-map", + "0:0", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-c:v", + "copy", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('all options combined: scale + watermark + image subtitles', () => { + const videoSource = new FileStreamSource('/path/to/video.mkv'); + // Use 4K input so scaling is needed + const video = makeH264VideoInputFourK(); + const watermark = makeWatermark(75); + + const builder = new SoftwarePipelineBuilder( + video, + null, + watermark, + makePgsSubtitles(videoSource), + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + new FrameState({ + isAnamorphic: false, + scaledSize: FrameSize.FHD, + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + }), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "h264", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-i", + "/path/to/watermark.jpg", + "-filter_complex", + "[0:0]scale=iw*sar:ih,setsar=1,scale=1920:1080:flags=fast_bilinear[v];[0:5]scale=1920:1080:force_original_aspect_ratio=decrease[sub];[1:0]scale=192:-1,format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.75[wm];[v][sub]overlay=x=(W-w)/2:y=(H-h)/2:format=0[vsub];[vsub][wm]overlay=x=W-w-96:y=H-h-54:format=0:enable='between(t,0,5)'[vwm]", + "-map", + "[vwm]", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); + + test('HEVC to H264 transcode', () => { + const video = VideoInputSource.withStream( + new FileStreamSource('/path/to/video.mkv'), + VideoStream.create({ + codec: 'hevc', + displayAspectRatio: '16:9', + frameSize: FrameSize.FHD, + index: 0, + pixelFormat: new PixelFormatYuv420P(), + providedSampleAspectRatio: null, + }), + ); + + const builder = new SoftwarePipelineBuilder( + video, + null, + null, + null, + null, + EmptyFfmpegCapabilities, + ); + + const out = builder.build( + makeDefaultFfmpegState(), + new FrameState({ + isAnamorphic: false, + scaledSize: FrameSize.FHD, + paddedSize: FrameSize.FHD, + pixelFormat: new PixelFormatYuv420P(), + videoFormat: 'h264', + }), + DefaultPipelineOptions, + ); + + expect(out.getCommandArgs()).toMatchInlineSnapshot(` + [ + "-nostdin", + "-hide_banner", + "-nostats", + "-loglevel", + "error", + "-fflags", + "+genpts+discardcorrupt+igndts", + "-c:v", + "hevc", + "-readrate", + "1", + "-i", + "/path/to/video.mkv", + "-map", + "0:0", + "-map", + "0:a", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-flags", + "cgop", + "-movflags", + "+faststart", + "-sc_threshold", + "0", + "-c:a", + "copy", + "-f", + "mpegts", + "pipe:1", + ] + `); + }); +}); diff --git a/server/src/ffmpeg/builder/state/FfmpegState.ts b/server/src/ffmpeg/builder/state/FfmpegState.ts index 88327b02f..18007b2ba 100644 --- a/server/src/ffmpeg/builder/state/FfmpegState.ts +++ b/server/src/ffmpeg/builder/state/FfmpegState.ts @@ -71,7 +71,7 @@ export class FfmpegState { softwareDeinterlaceFilter: string = 'yadif=1'; vaapiDevice: Nullable = null; vaapiDriver: Nullable = null; - outputFormat: OutputFormat = MpegTsOutputFormat; // TODO: No + outputFormat: OutputFormat = MpegTsOutputFormat; outputLocation: OutputLocation = OutputLocation.Stdout; ptsOffset?: number; tonemapHdr: boolean = false;