Files
ersatztv/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
T
timothyandtimothy ed8b602445
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m23s
feat(735): bound the numeric FFmpeg profile fields with a 422, and expose readrate pacing (#847)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 22:05:38 +00:00

901 lines
34 KiB
C#

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Encoder;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.InputOption;
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;
[TestFixture]
public class PipelineBuilderBaseTests
{
private readonly ILogger _logger = Substitute.For<ILogger>();
[Test]
public void Incorrect_Video_Codec_Should_Use_Encoder()
{
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.Progressive)
});
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.Hevc,
VideoProfile.Main,
VideoPreset.Unset,
false,
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
FFmpegFilterMode.HardwareIfPossible,
false,
Option<FrameRate>.None,
2000,
4000,
90_000,
false,
false);
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
TimeSpan.FromSeconds(1),
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,
"clip",
false);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
HardwareAccelerationMode.None,
videoInputFile,
audioInputFile,
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
result.PipelineSteps.ShouldContain(ps => ps is EncoderLibx265);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
public void Aac_6_Channel_Should_Specify_Audio_Channels()
{
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.Progressive)
});
var audioInputFile = new AudioInputFile(
"/tmp/whatever.mkv",
new List<AudioStream> { new(1, AudioFormat.Aac, 6) },
new AudioState(
AudioFormat.Aac,
6,
320,
640,
48,
false,
AudioFilter.None,
Option<double>.None));
var desiredState = new FrameState(
true,
false,
VideoFormat.Hevc,
VideoProfile.Main,
VideoPreset.Unset,
false,
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
FFmpegFilterMode.HardwareIfPossible,
false,
Option<FrameRate>.None,
2000,
4000,
90_000,
false,
false);
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
TimeSpan.FromSeconds(1),
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,
"clip",
false);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
HardwareAccelerationMode.None,
videoInputFile,
audioInputFile,
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
result.PipelineSteps.ShouldContain(ps => ps is EncoderLibx265);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
public void Concat_Test()
{
var resolution = new FrameSize(1920, 1080);
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", resolution);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
HardwareAccelerationMode.None,
None,
None,
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
string command = PrintCommand(None, None, None, concatInputFile, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -readrate 1.0 -stream_loop -1 -i http://localhost:8080/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
public void Wrap_Segmenter_Test()
{
var resolution = new FrameSize(1920, 1080);
var concatInputFile = new ConcatInputFile(
"http://localhost:8080/iptv/channel/1.m3u8?mode=segmenter",
resolution);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
HardwareAccelerationMode.None,
None,
None,
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.WrapSegmenter(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
string command = PrintCommand(None, None, None, concatInputFile, None, result);
command.ShouldBe(
"-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i http://localhost:8080/iptv/channel/1.m3u8?mode=segmenter -map 0 -c copy -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts pipe:1");
}
[Test]
public void HlsDirect_Test()
{
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.Interlaced)
});
var audioInputFile = new AudioInputFile(
"/tmp/whatever.mkv",
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
new AudioState(
AudioFormat.Copy,
None,
None,
None,
None,
false,
AudioFilter.None,
Option<double>.None));
var desiredState = new FrameState(
true,
false,
VideoFormat.Copy,
VideoProfile.Main,
VideoPreset.Unset,
false,
Option<IPixelFormat>.None,
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
FFmpegFilterMode.HardwareIfPossible,
false,
Option<FrameRate>.None,
2000,
4000,
90_000,
false,
false);
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
Option<TimeSpan>.None,
Option<TimeSpan>.None,
false,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
OutputFormatKind.Mp4,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
TimeSpan.Zero,
Option<int>.None,
Option<int>.None,
false,
false,
"clip",
false);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
HardwareAccelerationMode.None,
videoInputFile,
audioInputFile,
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
result.PipelineSteps.ShouldContain(ps => ps is EncoderCopyVideo);
result.PipelineSteps.ShouldContain(ps => ps is EncoderCopyAudio);
videoInputFile.InputOptions.ShouldContain(io => io is ReadrateInputOption);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
// 0.4.0 reference: "-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -ss 00:14:33.6195516 -i /tmp/whatever.mkv -map 0:0 -map 0:a -c:v copy -flags cgop -sc_threshold 0 -c:a copy -movflags +faststart -metadata service_provider="ErsatzTV" -metadata service_name="ErsatzTV" -t 00:06:39.6934484 -f mpegts -mpegts_flags +initial_discontinuity pipe:1"
command.ShouldBe(
"-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1");
}
[Test]
public void HlsDirect_Test_All_Audio_Streams()
{
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.Progressive)
});
Option<AudioInputFile> audioInputFile = Option<AudioInputFile>.None;
var desiredState = new FrameState(
true,
false,
VideoFormat.Copy,
VideoProfile.Main,
VideoPreset.Unset,
false,
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
FFmpegFilterMode.HardwareIfPossible,
false,
Option<FrameRate>.None,
2000,
4000,
90_000,
false,
false);
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
Option<TimeSpan>.None,
Option<TimeSpan>.None,
false,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
OutputFormatKind.Mp4,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
TimeSpan.Zero,
Option<int>.None,
Option<int>.None,
false,
false,
"clip",
false);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
HardwareAccelerationMode.None,
videoInputFile,
audioInputFile,
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
result.PipelineSteps.ShouldContain(ps => ps is EncoderCopyVideo);
result.PipelineSteps.ShouldContain(ps => ps is EncoderCopyAudio);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1");
}
[Test]
public void Resize_Image_Test()
{
var height = 200;
var videoInputFile = new VideoInputFile(
"/test/input/file.png",
new List<VideoStream>
{
new(
0,
string.Empty,
string.Empty,
Option<IPixelFormat>.None,
ColorParams.Default,
FrameSize.Unknown,
string.Empty,
string.Empty,
Option<FrameRate>.None,
true,
ScanKind.Progressive)
});
var pipelineBuilder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
HardwareAccelerationMode.None,
videoInputFile,
Option<AudioInputFile>.None,
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = pipelineBuilder.Resize("/test/output/file.jpg", new FrameSize(-1, height));
string command = PrintCommand(videoInputFile, None, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -loglevel error -i /test/input/file.png -vf scale=-1:200:force_original_aspect_ratio=decrease /test/output/file.jpg");
}
[Test]
public void Realtime_Input_Should_Burst_Initial_Segments_When_Option_Is_Supported()
{
string command = BuildRealtimeCommand(new BurstCapableFFmpegCapabilities());
// burst covers the first two 4s segments, then the 1.05 throttle resumes (ersatztv#350).
// anchor on the input path so this can't be satisfied by some other input carrying the
// burst; audio and video share one file here, so exactly one input is expected
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -i /tmp/whatever.mkv");
Regex.Matches(command, "-readrate_initial_burst 8").Count.ShouldBe(1);
}
[Test]
public void Realtime_Input_Should_Not_Burst_When_Option_Is_Unsupported()
{
string command = BuildRealtimeCommand(new DefaultFFmpegCapabilities());
command.ShouldContain("-readrate 1.05 -i");
command.ShouldNotContain("-readrate_initial_burst");
}
[Test]
public void Realtime_Input_Should_Not_Burst_A_Still_Image()
{
// a still image is paced by the realtime filter, so bursting would only run audio ahead
string command = BuildRealtimeCommand(new BurstCapableFFmpegCapabilities(), stillImage: true);
// the positive anchor keeps this from passing vacuously if the helper ever stops
// producing a realtime audio input at all
command.ShouldContain("-readrate 1.05");
command.ShouldNotContain("-readrate_initial_burst");
}
[Test]
public void Concat_Should_Never_Burst()
{
// concat reads already-written segments from the running segmenter; bursting would
// gallop through them
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", new FrameSize(1920, 1080));
var builder = new SoftwarePipelineBuilder(
new BurstCapableFFmpegCapabilities(),
HardwareAccelerationMode.None,
None,
None,
None,
None,
concatInputFile,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
string command = PrintCommand(None, None, None, concatInputFile, None, result);
command.ShouldContain("-readrate 1.0");
command.ShouldNotContain("-readrate_initial_burst");
}
[Test]
public void Realtime_Input_Should_Catch_Up_When_Option_Is_Supported()
{
string command = BuildRealtimeCommand(new CatchupCapableFFmpegCapabilities());
// -readrate paces an input off its furthest-behind stream, so a sparse stream sharing the
// input pins throughput below realtime; catchup lets it recover (ersatztv#726). anchor on
// the input path so this can't be satisfied by some other input carrying the option
// this overlaps Bitmap_Subtitle_Burn_In_... by design: that one pins the #726 MECHANISM on a
// bitmap pipeline, this one pins the plain no-subtitle shape plus the uniqueness guard below
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -readrate_catchup 6.0 -i /tmp/whatever.mkv");
Regex.Matches(command, Regex.Escape("-readrate_catchup 6.0")).Count.ShouldBe(1);
}
[Test]
public void Realtime_Input_Should_Not_Catch_Up_A_Still_Image()
{
// mirrors the burst's still-image exclusion (ersatztv#350): the video input takes no readrate
// at all, so catchup would only reach the separate audio input and run it ahead of a graph
// that the realtime filter is already pacing. pinned so the divergence can't reappear silently
string command = BuildRealtimeCommand(new CatchupCapableFFmpegCapabilities(), stillImage: true);
// the positive anchor keeps this from passing vacuously if the helper ever stops
// producing a realtime audio input at all
command.ShouldContain("-readrate 1.05");
command.ShouldNotContain("-readrate_catchup");
}
[Test]
public void Realtime_Input_Should_Not_Catch_Up_When_Option_Is_Unsupported()
{
// an older binary silently keeps today's behavior rather than failing to start
string command = BuildRealtimeCommand(new BurstCapableFFmpegCapabilities());
// the positive anchor keeps this from passing vacuously if the helper ever stops
// producing a realtime input at all
command.ShouldContain("-readrate 1.05");
command.ShouldNotContain("-readrate_catchup");
}
[Test]
public void Concat_Should_Never_Catch_Up()
{
// concat reads already-written segments from the running segmenter at a flat 1.0; it has no
// sparse stream to lag on, and letting it catch up would gallop through the segments
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", new FrameSize(1920, 1080));
var builder = new SoftwarePipelineBuilder(
new CatchupCapableFFmpegCapabilities(),
HardwareAccelerationMode.None,
None,
None,
None,
None,
concatInputFile,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
string command = PrintCommand(None, None, None, concatInputFile, None, result);
command.ShouldContain("-readrate 1.0");
command.ShouldNotContain("-readrate_catchup");
}
[Test]
public void Bitmap_Subtitle_Burn_In_Should_Catch_Up_On_The_Shared_Video_Input()
{
// THE #726 regression test. an embedded bitmap subtitle is read through the SAME -i as the
// video (SubtitleInputFile carries the video's path and resolves to a stream specifier on
// that input), and being sparse it drags that input's pacing down to ~0.53x realtime.
// this must be built on a BITMAP subtitle: a text subtitle is fetched by the libass filter
// outside the demuxer, so the same assertions would pass vacuously while the bug is present.
string command = BuildRealtimeCommand(new CatchupCapableFFmpegCapabilities(), imageSubtitle: true);
// the mechanism itself: subtitle stream 2 resolves onto input 0 -- the VIDEO's input -- so it
// is read through the throttled demuxer that catchup is being applied to. if the subtitle
// ever moves to an input of its own this label changes and the test fails, which is the point
command.ShouldContain("[0:0][0:2]overlay");
// ...so the catchup has to be on that input
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -readrate_catchup 6.0 -i /tmp/whatever.mkv");
}
// ersatztv#735: the pacing values became operator-tunable profile fields. these pin that a
// configured value actually reaches the command line -- the defaults above are the OTHER half
// of the same guard, and they are what an unset profile still gets
[Test]
public void Realtime_Input_Should_Use_A_Configured_ReadRate_And_Catchup()
{
string command = BuildRealtimeCommand(
new CatchupCapableFFmpegCapabilities(),
readRate: 1.5,
readRateCatchup: 4.0);
command.ShouldContain("-readrate 1.5 -readrate_initial_burst 8 -readrate_catchup 4.0 -i /tmp/whatever.mkv");
command.ShouldNotContain("-readrate 1.05");
command.ShouldNotContain("-readrate_catchup 6.0");
}
// the write path rejects an out-of-range value with a 422, so this only fires for a row written
// out of band -- but FFmpeg must never see the unbounded value either way
[Test]
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRate()
{
string command = BuildRealtimeCommand(
new CatchupCapableFFmpegCapabilities(),
readRate: 9.0,
readRateCatchup: 0.1);
// 9.0 clamps to the 2.0 ceiling, and 0.1 is raised to the resolved base rate, because a
// catchup below it could never let a lagging input recover
command.ShouldContain("-readrate 2.0 -readrate_initial_burst 8 -readrate_catchup 2.0 -i /tmp/whatever.mkv");
}
// ...and the catchup CEILING isolated from the base rate, which the case above cannot show:
// there both clamps land on the same 2.0, so either one alone would satisfy it
[Test]
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRateCatchup()
{
string command = BuildRealtimeCommand(
new CatchupCapableFFmpegCapabilities(),
readRate: 1.2,
readRateCatchup: 15.0);
command.ShouldContain("-readrate 1.2 -readrate_initial_burst 8 -readrate_catchup 10.0 -i /tmp/whatever.mkv");
}
private string BuildRealtimeCommand(
IFFmpegCapabilities capabilities,
bool stillImage = false,
bool imageSubtitle = false,
Option<double> readRate = default,
Option<double> readRateCatchup = default)
{
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,
stillImage,
ScanKind.Progressive)
});
var desiredState = new FrameState(
true,
false,
VideoFormat.Hevc,
VideoProfile.Main,
VideoPreset.Unset,
false,
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
FFmpegFilterMode.HardwareIfPossible,
false,
Option<FrameRate>.None,
2000,
4000,
90_000,
false,
false);
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
TimeSpan.FromSeconds(1),
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,
"clip",
false,
MaybeReadRate: readRate,
MaybeReadRateCatchup: readRateCatchup);
// a *separate* audio input matters here: for a still image the video input takes no readrate
// at all, so only a distinct audio input can prove the burst was suppressed (this is the
// song shape — cover art plus an audio file)
var audioInputFile = new AudioInputFile(
stillImage ? "/tmp/whatever.mp3" : "/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));
// an embedded bitmap subtitle carries the VIDEO's path, which is how it ends up sharing the
// video's single throttled -i rather than getting one of its own (ersatztv#726)
Option<SubtitleInputFile> subtitleInputFile = imageSubtitle
? new SubtitleInputFile(
"/tmp/whatever.mkv",
new List<MediaStream> { new(2, "dvdsub", StreamKind.Subtitle) },
SubtitleMethod.Burn)
: Option<SubtitleInputFile>.None;
var builder = new SoftwarePipelineBuilder(
capabilities,
HardwareAccelerationMode.None,
videoInputFile,
audioInputFile,
None,
subtitleInputFile,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
return PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
}
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>());
public class BurstCapableFFmpegCapabilities() : 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> { FFmpegKnownOption.ReadrateInitialBurst.Name },
new System.Collections.Generic.HashSet<string>());
// a binary new enough for -readrate_catchup also has -readrate_initial_burst, so this models a
// real ffmpeg rather than an impossible catchup-without-burst one
public class CatchupCapableFFmpegCapabilities() : 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>
{
FFmpegKnownOption.ReadrateInitialBurst.Name,
FFmpegKnownOption.ReadrateCatchup.Name
},
new System.Collections.Generic.HashSet<string>());
}