Cold adversarial review returned BLOCKED on the documentation half. Addressed: - The new decision record carried no lifecycle metadata block, taking the repo from 82/82 to 83/82 and making it invisible to the by-key catalog lookup that #521 established the same day. Added key/status/since/supersedes/superseded-by (ffmpeg.qsv-extra-hw-frames-floor) and regenerated docs/decisions/README.md; decisions_validate.py now reports OK with no legacy-unmigrated notice. - The entry claimed to correct the #350 record but left that record untouched, so the stale "the burst is bounded" claim stayed authoritative for anyone resolving ffmpeg.hls-cold-start-burst. Added a forward-pointing correction note there (hence the [decisions-edit] token on this commit). - The floor was applied silently. QsvPipelineBuilder.SetAccelState now logs a warning naming both the configured and applied value, because raising a deliberately small pool costs additional surfaces (64 NV12 1080p surfaces is roughly 190 MiB, 760 MiB at 4K) on memory-constrained iGPUs. - Narrowed an overstated claim in the entry: 1..63 are untested, not known-bad. We raise them because the risk is a channel serving nothing, not because asking for less is illegitimate. Recorded as a deliberate over-reach with a stated cost. - Corrected a factual error: SubtitleScaleQsvFilter also formats extra_hw_frames but is dead code with no construction site, so it is NOT covered by the guard. - Config-vs-behavior mismatch: Create/UpdateFFmpegProfileHandler now normalize on save so stored rows converge on what the pipeline runs, and the SPA field carries min=64 rather than defaulting the display to 0. Render-time flooring is kept as the net that fixes existing deployments with no migration; the remaining gap for un-resaved rows is recorded as an accepted residual. - Tests strengthened: pinned to the literal measured 64 rather than to the constant (so lowering the floor cannot quietly satisfy them), added a negative-value case, added a deinterlace-upload case, and replaced the narrow ShouldNotContain with a regex asserting EVERY extra_hw_frames occurrence in the command is >= the minimum. Negative control re-run against the strengthened tests: reverting the floor fails 5, with the build verified succeeded first. Full suite green (4086 .NET, 891 web). Review finding that needed no change: the "single point" claim was independently verified — no bypass exists, every FFmpegState construction routes through MaybeQsvExtraHardwareFrames. Refs #350, #516, #519.
282 lines
10 KiB
C#
282 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text.RegularExpressions;
|
|
using ErsatzTV.FFmpeg.Capabilities;
|
|
using ErsatzTV.FFmpeg.Format;
|
|
using ErsatzTV.FFmpeg.OutputFormat;
|
|
using ErsatzTV.FFmpeg.Pipeline;
|
|
using ErsatzTV.FFmpeg.Preset;
|
|
using ErsatzTV.FFmpeg.State;
|
|
using LanguageExt;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using static LanguageExt.Prelude;
|
|
|
|
namespace ErsatzTV.FFmpeg.Tests.Pipeline;
|
|
|
|
[TestFixture]
|
|
public class QsvPipelineBuilderTests
|
|
{
|
|
private readonly ILogger _logger = Substitute.For<ILogger>();
|
|
|
|
[Test]
|
|
public void Qsv_PreferNativeDecoder_Should_Decode_Via_Vaapi_To_Software_Then_Qsv_Encode()
|
|
{
|
|
string command = BuildAndPrint(preferNativeDecoder: true);
|
|
|
|
// VA-API decode, frames downloaded to software (NO hwaccel_output_format)
|
|
command.ShouldContain("-hwaccel vaapi");
|
|
command.ShouldNotContain("-hwaccel_output_format");
|
|
command.ShouldNotContain("-hwaccel qsv");
|
|
// no QSV *decoder* input option (decoder input options sit directly before "-readrate"/"-i";
|
|
// "-c:v h264_qsv -" alone would also match the encoder's "-c:v h264_qsv -low_power ..." output option)
|
|
command.ShouldNotContain("-c:v h264_qsv -readrate");
|
|
// derived-device chain retained for the QSV encoder
|
|
command.ShouldContain("-init_hw_device vaapi=va:/dev/dri/renderD128");
|
|
command.ShouldContain("-init_hw_device qsv=hw@va");
|
|
// software frames re-uploaded before QSV filters/encoder (proves NO bare vpp_qsv on VA-API frames)
|
|
command.ShouldContain("hwupload=extra_hw_frames");
|
|
// QSV encoder still used
|
|
command.ShouldContain("h264_qsv");
|
|
}
|
|
|
|
[Test]
|
|
public void Qsv_Default_Should_Decode_And_Encode_With_Qsv()
|
|
{
|
|
string command = BuildAndPrint(preferNativeDecoder: false);
|
|
|
|
command.ShouldContain("-hwaccel qsv");
|
|
command.ShouldContain("-hwaccel_output_format qsv");
|
|
command.ShouldContain("h264_qsv");
|
|
command.ShouldNotContain("-hwaccel vaapi");
|
|
}
|
|
|
|
[Test]
|
|
public void Qsv_PreferNativeDecoder_Interlaced_Should_Hwupload_Before_Deinterlace_Qsv()
|
|
{
|
|
string command = BuildInterlacedAndPrint();
|
|
|
|
// VA-API decode, software frames
|
|
command.ShouldContain("-hwaccel vaapi");
|
|
command.ShouldNotContain("-hwaccel_output_format");
|
|
|
|
// software frames re-uploaded BEFORE deinterlace_qsv (never a bare deinterlace_qsv on VA-API frames)
|
|
command.ShouldContain("hwupload=extra_hw_frames");
|
|
command.ShouldContain("hwupload=extra_hw_frames=64,deinterlace_qsv");
|
|
// exactly one deinterlace_qsv, and (assertion above) it is preceded by hwupload — so
|
|
// there is no second, bare deinterlace_qsv running on VA-API frames
|
|
(command.Split("deinterlace_qsv").Length - 1).ShouldBe(1);
|
|
|
|
command.ShouldContain("h264_qsv");
|
|
}
|
|
|
|
// ersatztv#529: a stored qsvExtraHardwareFrames of 0 produced hwupload=extra_hw_frames=0, which
|
|
// leaves the QSV pool no headroom. Measured on the deployed FFmpeg 8.1.2: with 0 the filter graph
|
|
// fails with -12 and writes zero segments as soon as the input is not throttled (a work-ahead
|
|
// start, or #350's cold-start burst); with 64 the same command writes segments either way.
|
|
[TestCase(-1)]
|
|
[TestCase(0)]
|
|
[TestCase(1)]
|
|
[TestCase(63)]
|
|
public void Qsv_Should_Never_Upload_With_Less_Than_Minimum_Extra_Hardware_Frames(int configured)
|
|
{
|
|
string command = BuildAndPrint(preferNativeDecoder: true, maybeExtraHardwareFrames: configured);
|
|
|
|
// pinned to the literal value measured against the deployed FFmpeg, not to the constant, so
|
|
// that lowering the floor in code cannot quietly satisfy this test
|
|
command.ShouldContain("hwupload=extra_hw_frames=64");
|
|
|
|
// every upload site in the whole command, not just the one this pipeline happens to emit
|
|
ShouldNeverUploadBelowMinimum(command);
|
|
}
|
|
|
|
[Test]
|
|
public void Qsv_Interlaced_Should_Never_Deinterlace_Upload_Below_Minimum_Extra_Hardware_Frames()
|
|
{
|
|
// the deinterlace upload is a separate formatter fed from QsvPipelineBuilder, so it needs
|
|
// its own guard (ersatztv#529)
|
|
string command = BuildInterlacedAndPrint(maybeExtraHardwareFrames: 0);
|
|
|
|
command.ShouldContain("hwupload=extra_hw_frames=64,deinterlace_qsv");
|
|
ShouldNeverUploadBelowMinimum(command);
|
|
}
|
|
|
|
private static void ShouldNeverUploadBelowMinimum(string command)
|
|
{
|
|
MatchCollection matches = Regex.Matches(command, @"extra_hw_frames=(-?\d+)");
|
|
matches.Count.ShouldBeGreaterThan(0, "expected the pipeline to upload to QSV at all");
|
|
foreach (Match match in matches)
|
|
{
|
|
int.Parse(match.Groups[1].Value)
|
|
.ShouldBeGreaterThanOrEqualTo(FFmpegState.MinimumQsvExtraHardwareFrames, command);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Qsv_Should_Honor_Extra_Hardware_Frames_Above_The_Minimum()
|
|
{
|
|
string command = BuildAndPrint(preferNativeDecoder: true, maybeExtraHardwareFrames: 128);
|
|
|
|
command.ShouldContain("hwupload=extra_hw_frames=128");
|
|
}
|
|
|
|
private string BuildInterlacedAndPrint(Option<int> maybeExtraHardwareFrames = default) =>
|
|
BuildAndPrint(
|
|
preferNativeDecoder: true,
|
|
maybeExtraHardwareFrames,
|
|
ScanKind.Interlaced,
|
|
deinterlace: true);
|
|
|
|
private string BuildAndPrint(
|
|
bool preferNativeDecoder,
|
|
Option<int> maybeExtraHardwareFrames = default,
|
|
ScanKind scanKind = ScanKind.Progressive,
|
|
bool deinterlace = false)
|
|
{
|
|
(VideoInputFile videoInputFile, AudioInputFile audioInputFile, FFmpegState ffmpegState, FrameState desiredState) =
|
|
BuildQsvH264Pipeline(preferNativeDecoder, scanKind, deinterlace);
|
|
|
|
ffmpegState = ffmpegState with { MaybeQsvExtraHardwareFrames = maybeExtraHardwareFrames };
|
|
|
|
var builder = new QsvPipelineBuilder(
|
|
new DefaultFFmpegCapabilities(),
|
|
new DefaultHardwareCapabilities(),
|
|
HardwareAccelerationMode.Qsv,
|
|
videoInputFile,
|
|
audioInputFile,
|
|
None,
|
|
None,
|
|
None,
|
|
Option<GraphicsEngineInput>.None,
|
|
"",
|
|
"",
|
|
_logger);
|
|
|
|
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
|
|
|
|
return PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
|
|
}
|
|
|
|
private static (VideoInputFile, AudioInputFile, FFmpegState, FrameState) BuildQsvH264Pipeline(
|
|
bool preferNativeDecoder,
|
|
ScanKind scanKind,
|
|
bool deinterlace)
|
|
{
|
|
var videoInputFile = new VideoInputFile(
|
|
"/tmp/whatever.mkv",
|
|
new List<VideoStream>
|
|
{
|
|
new(
|
|
0,
|
|
VideoFormat.H264,
|
|
VideoProfile.Main,
|
|
new PixelFormatYuv420P(),
|
|
ColorParams.Default,
|
|
new FrameSize(1920, 1080),
|
|
"1:1",
|
|
"16:9",
|
|
FrameRate.DefaultFrameRate,
|
|
false,
|
|
scanKind)
|
|
});
|
|
|
|
var audioInputFile = new AudioInputFile(
|
|
"/tmp/whatever.mkv",
|
|
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
|
|
new AudioState(
|
|
AudioFormat.Aac,
|
|
2,
|
|
320,
|
|
640,
|
|
48,
|
|
false,
|
|
AudioFilter.None,
|
|
Option<double>.None));
|
|
|
|
var desiredState = new FrameState(
|
|
true,
|
|
false,
|
|
VideoFormat.H264,
|
|
VideoProfile.Main,
|
|
VideoPreset.Unset,
|
|
false,
|
|
new PixelFormatYuv420P(),
|
|
new FrameSize(1280, 720),
|
|
new FrameSize(1280, 720),
|
|
Option<FrameSize>.None,
|
|
FFmpegFilterMode.Software,
|
|
false,
|
|
Option<FrameRate>.None,
|
|
2000,
|
|
4000,
|
|
90_000,
|
|
false,
|
|
deinterlace);
|
|
|
|
var ffmpegState = new FFmpegState(
|
|
false,
|
|
HardwareAccelerationMode.Qsv,
|
|
HardwareAccelerationMode.Qsv,
|
|
Option<string>.None,
|
|
"/dev/dri/renderD128",
|
|
Option<TimeSpan>.None,
|
|
Option<TimeSpan>.None,
|
|
false,
|
|
Option<string>.None,
|
|
Option<string>.None,
|
|
Option<string>.None,
|
|
Option<string>.None,
|
|
Option<string>.None,
|
|
OutputFormatKind.MpegTs,
|
|
Option<string>.None,
|
|
Option<string>.None,
|
|
Option<string>.None,
|
|
Option<string>.None,
|
|
TimeSpan.Zero,
|
|
Option<int>.None,
|
|
Option<int>.None,
|
|
false,
|
|
false,
|
|
"linear",
|
|
false,
|
|
preferNativeDecoder);
|
|
|
|
return (videoInputFile, audioInputFile, ffmpegState, desiredState);
|
|
}
|
|
|
|
private static string PrintCommand(
|
|
Option<VideoInputFile> videoInputFile,
|
|
Option<AudioInputFile> audioInputFile,
|
|
Option<WatermarkInputFile> watermarkInputFile,
|
|
Option<ConcatInputFile> concatInputFile,
|
|
Option<GraphicsEngineInput> graphicsEngineInput,
|
|
FFmpegPipeline pipeline)
|
|
{
|
|
IList<string> arguments = CommandGenerator.GenerateArguments(
|
|
videoInputFile,
|
|
audioInputFile,
|
|
watermarkInputFile,
|
|
concatInputFile,
|
|
graphicsEngineInput,
|
|
pipeline.PipelineSteps,
|
|
pipeline.IsIntelVaapiOrQsv);
|
|
|
|
var command = string.Join(" ", arguments);
|
|
|
|
Console.WriteLine($"Generated command: ffmpeg {string.Join(" ", arguments)}");
|
|
|
|
return command;
|
|
}
|
|
|
|
public class DefaultFFmpegCapabilities() : FFmpegCapabilities(
|
|
string.Empty,
|
|
new System.Collections.Generic.HashSet<string>(),
|
|
new System.Collections.Generic.HashSet<string>(),
|
|
new System.Collections.Generic.HashSet<string>(),
|
|
new System.Collections.Generic.HashSet<string>(),
|
|
new System.Collections.Generic.HashSet<string>(),
|
|
new System.Collections.Generic.HashSet<string>());
|
|
}
|