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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
901 lines
34 KiB
C#
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>());
|
|
}
|