Browse Source

add new graphics engine (#2265)

* spike new graphics engine

* fix remote watermarks; add graphics engine to vaapi

* add graphics engine to qsv
pull/2266/head
Jason Dove 4 days ago committed by GitHub
parent
commit
036b6e63c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  3. 38
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  4. 2
      ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs
  5. 8
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs
  6. 8
      ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumberHandler.cs
  7. 8
      ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs
  8. 30
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  9. 8
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs
  10. 6
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  11. 100
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  12. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  13. 6
      ErsatzTV.Core/Interfaces/FFmpeg/PlayoutItemResult.cs
  14. 14
      ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs
  15. 10
      ErsatzTV.Core/Interfaces/Streaming/IGraphicsElement.cs
  16. 8
      ErsatzTV.Core/Interfaces/Streaming/IGraphicsEngine.cs
  17. 23
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  18. 23
      ErsatzTV.FFmpeg/CommandGenerator.cs
  19. 12
      ErsatzTV.FFmpeg/Decoder/DecoderBase.cs
  20. 62
      ErsatzTV.FFmpeg/Filter/ComplexFilter.cs
  21. 9
      ErsatzTV.FFmpeg/Filter/Cuda/OverlayGraphicsEngineCudaFilter.cs
  22. 11
      ErsatzTV.FFmpeg/Filter/OverlayGraphicsEngineFilter.cs
  23. 4
      ErsatzTV.FFmpeg/FilterChain.cs
  24. 16
      ErsatzTV.FFmpeg/Format/ConcatInputFormat.cs
  25. 2
      ErsatzTV.FFmpeg/Format/PixelFormat.cs
  26. 11
      ErsatzTV.FFmpeg/InputFile.cs
  27. 2
      ErsatzTV.FFmpeg/InputOption/CopyTimestampInputOption.cs
  28. 12
      ErsatzTV.FFmpeg/InputOption/DoNotIgnoreLoopInputOption.cs
  29. 1
      ErsatzTV.FFmpeg/InputOption/IInputOption.cs
  30. 7
      ErsatzTV.FFmpeg/InputOption/InfiniteLoopInputOption.cs
  31. 12
      ErsatzTV.FFmpeg/InputOption/LavfiInputOption.cs
  32. 30
      ErsatzTV.FFmpeg/InputOption/RawVideoInputOption.cs
  33. 10
      ErsatzTV.FFmpeg/InputOption/ReadrateInputOption.cs
  34. 12
      ErsatzTV.FFmpeg/InputOption/StreamSeekInputOption.cs
  35. 2
      ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs
  36. 1
      ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs
  37. 45
      ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs
  38. 15
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  39. 8
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs
  40. 1
      ErsatzTV.FFmpeg/Pipeline/PipelineContext.cs
  41. 41
      ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs
  42. 33
      ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs
  43. 39
      ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs
  44. 2
      ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs
  45. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  46. 73
      ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs
  47. 163
      ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs
  48. 8
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  49. 1
      ErsatzTV/Startup.cs

4
CHANGELOG.md

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add *experimental* graphics engine
- Permanent watermarks will use new graphics engine
- Intermittent watermarks will still use normal overlay pipeline (for now)
## [25.4.0] - 2025-08-05
### Added

5
ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs

@ -9,6 +9,7 @@ using ErsatzTV.Core.FFmpeg; @@ -9,6 +9,7 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@ -19,6 +20,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -19,6 +20,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
{
private readonly IClient _client;
private readonly IConfigElementRepository _configElementRepository;
private readonly IGraphicsEngine _graphicsEngine;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
@ -41,6 +43,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -41,6 +43,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
ILogger<HlsSessionWorkerV2> sessionWorkerV2Logger,
IFFmpegSegmenterService ffmpegSegmenterService,
IConfigElementRepository configElementRepository,
IGraphicsEngine graphicsEngine,
IHostApplicationLifetime hostApplicationLifetime,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
@ -54,6 +57,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -54,6 +57,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_sessionWorkerV2Logger = sessionWorkerV2Logger;
_ffmpegSegmenterService = ffmpegSegmenterService;
_configElementRepository = configElementRepository;
_graphicsEngine = graphicsEngine;
_hostApplicationLifetime = hostApplicationLifetime;
_workerChannel = workerChannel;
}
@ -122,6 +126,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -122,6 +126,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
request.Host),
_ => new HlsSessionWorker(
_serviceScopeFactory,
_graphicsEngine,
_client,
_hlsPlaylistFilter,
_configElementRepository,

38
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO.Pipelines;
using System.Text;
using System.Timers;
using Bugsnag;
@ -13,6 +14,7 @@ using ErsatzTV.Core.FFmpeg; @@ -13,6 +14,7 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
@ -22,6 +24,7 @@ namespace ErsatzTV.Application.Streaming; @@ -22,6 +24,7 @@ namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorker : IHlsSessionWorker
{
private static int _workAheadCount;
private readonly IGraphicsEngine _graphicsEngine;
private readonly IClient _client;
private readonly IConfigElementRepository _configElementRepository;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
@ -44,6 +47,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -44,6 +47,7 @@ public class HlsSessionWorker : IHlsSessionWorker
public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory,
IGraphicsEngine graphicsEngine,
IClient client,
IHlsPlaylistFilter hlsPlaylistFilter,
IConfigElementRepository configElementRepository,
@ -53,6 +57,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -53,6 +57,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_graphicsEngine = graphicsEngine;
_client = client;
_hlsPlaylistFilter = hlsPlaylistFilter;
_configElementRepository = configElementRepository;
@ -446,15 +451,32 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -446,15 +451,32 @@ public class HlsSessionWorker : IHlsSessionWorker
{
await TrimAndDelete(cancellationToken);
var maybePipe = Option<Pipe>.None;
var stdErrBuffer = new StringBuilder();
Command process = processModel.Process;
_logger.LogDebug("ffmpeg hls arguments {FFmpegArguments}", process.Arguments);
try
{
BufferedCommandResult commandResult = await process
var processWithPipe = process;
foreach (var graphicsEngineContext in processModel.GraphicsEngineContext)
{
var pipe = new Pipe();
processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(pipe.Reader.AsStream()));
// fire and forget graphics engine task
_ = _graphicsEngine.Run(
graphicsEngineContext,
pipe.Writer,
cancellationToken);
}
CommandResult commandResult = await processWithPipe
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
.ExecuteAsync(cancellationToken);
if (commandResult.ExitCode == 0)
{
@ -468,8 +490,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -468,8 +490,7 @@ public class HlsSessionWorker : IHlsSessionWorker
else
{
// detect the non-zero exit code and transcode the ffmpeg error message instead
string errorMessage = commandResult.StandardError;
string errorMessage = stdErrBuffer.ToString();
if (string.IsNullOrWhiteSpace(errorMessage))
{
errorMessage = $"Unknown FFMPEG error; exit code {commandResult.ExitCode}";
@ -479,7 +500,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -479,7 +500,7 @@ public class HlsSessionWorker : IHlsSessionWorker
"HLS process for channel {Channel} has terminated unsuccessfully with exit code {ExitCode}: {StandardError}",
_channelNumber,
commandResult.ExitCode,
commandResult.StandardError);
stdErrBuffer.ToString());
Either<BaseError, PlayoutItemProcessModel> maybeOfflineProcess = await _mediator.Send(
new GetErrorProcess(
@ -523,6 +544,13 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -523,6 +544,13 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogInformation("Terminating HLS session for channel {Channel}", _channelNumber);
return false;
}
finally
{
foreach (var pipe in maybePipe)
{
await pipe.Writer.CompleteAsync();
}
}
}
}
catch (Exception ex)

2
ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs

@ -1,9 +1,11 @@ @@ -1,9 +1,11 @@
using CliWrap;
using ErsatzTV.Core.Interfaces.Streaming;
namespace ErsatzTV.Application.Streaming;
public record PlayoutItemProcessModel(
Command Process,
Option<GraphicsEngineContext> GraphicsEngineContext,
Option<TimeSpan> MaybeDuration,
DateTimeOffset Until,
bool IsComplete);

8
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -37,6 +38,11 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo @@ -37,6 +38,11 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, Option<TimeSpan>.None, DateTimeOffset.MaxValue, true);
return new PlayoutItemProcessModel(
process,
Option<GraphicsEngineContext>.None,
Option<TimeSpan>.None,
DateTimeOffset.MaxValue,
true);
}
}

8
ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumberHandler.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -32,6 +33,11 @@ public class GetConcatSegmenterProcessByChannelNumberHandler( @@ -32,6 +33,11 @@ public class GetConcatSegmenterProcessByChannelNumberHandler(
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, Option<TimeSpan>.None, DateTimeOffset.MaxValue, true);
return new PlayoutItemProcessModel(
process,
Option<GraphicsEngineContext>.None,
Option<TimeSpan>.None,
DateTimeOffset.MaxValue,
true);
}
}

8
ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@ -37,6 +38,11 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess> @@ -37,6 +38,11 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
return new PlayoutItemProcessModel(process, request.MaybeDuration, request.Until, true);
return new PlayoutItemProcessModel(
process,
Option<GraphicsEngineContext>.None,
request.MaybeDuration,
request.Until,
true);
}
}

30
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -318,7 +318,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -318,7 +318,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
effectiveNow,
duration);
Command process = await _ffmpegProcessService.ForPlayoutItem(
PlayoutItemResult playoutItemResult = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
saveReports,
@ -352,7 +352,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -352,7 +352,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<string>.None,
_ => { });
var result = new PlayoutItemProcessModel(process, duration, finish, true);
var result = new PlayoutItemProcessModel(
playoutItemResult.Process,
playoutItemResult.GraphicsEngineContext,
duration,
finish,
true);
return Right<BaseError, PlayoutItemProcessModel>(result);
}
@ -390,7 +395,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -390,7 +395,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
return new PlayoutItemProcessModel(offlineProcess, maybeDuration, finish, true);
return new PlayoutItemProcessModel(
offlineProcess,
Option<GraphicsEngineContext>.None,
maybeDuration,
finish,
true);
case PlayoutItemDoesNotExistOnDisk:
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
@ -404,7 +414,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -404,7 +414,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
return new PlayoutItemProcessModel(doesNotExistProcess, maybeDuration, finish, true);
return new PlayoutItemProcessModel(
doesNotExistProcess,
Option<GraphicsEngineContext>.None,
maybeDuration,
finish,
true);
default:
Command errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
@ -418,7 +433,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -418,7 +433,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
return new PlayoutItemProcessModel(errorProcess, maybeDuration, finish, true);
return new PlayoutItemProcessModel(
errorProcess,
Option<GraphicsEngineContext>.None,
maybeDuration,
finish,
true);
}
}

8
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -38,6 +39,11 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW @@ -38,6 +39,11 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
request.Host,
request.AccessToken);
return new PlayoutItemProcessModel(process, Option<TimeSpan>.None, DateTimeOffset.MaxValue, true);
return new PlayoutItemProcessModel(
process,
Option<GraphicsEngineContext>.None,
Option<TimeSpan>.None,
DateTimeOffset.MaxValue,
true);
}
}

6
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -108,7 +108,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -108,7 +108,7 @@ public class PrepareTroubleshootingPlaybackHandler(
outPoint = inPoint + duration;
}
Command process = await ffmpegProcessService.ForPlayoutItem(
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
true,
@ -149,7 +149,9 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -149,7 +149,9 @@ public class PrepareTroubleshootingPlaybackHandler(
FileSystemLayout.TranscodeTroubleshootingFolder,
_ => { });
return process;
// TODO: graphics engine?
return playoutItemResult.Process;
}
private static async Task<List<Subtitle>> GetSelectedSubtitle(MediaItem mediaItem, PrepareTroubleshootingPlayback request)

100
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -4,6 +4,7 @@ using ErsatzTV.Core.Domain; @@ -4,6 +4,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format;
@ -44,7 +45,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -44,7 +45,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_logger = logger;
}
public async Task<Command> ForPlayoutItem(
public async Task<PlayoutItemResult> ForPlayoutItem(
string ffmpegPath,
string ffprobePath,
bool saveReports,
@ -344,6 +345,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -344,6 +345,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
}).Flatten();
Option<WatermarkInputFile> watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
// use graphics engine for permanent watermarks
foreach (var options in watermarkOptions.Where(o => o.Watermark.Map(wm => wm.Mode is ChannelWatermarkMode.Permanent).IfNone(false)))
{
watermarkInputFile = Option<WatermarkInputFile>.None;
graphicsEngineInput = new GraphicsEngineInput();
WatermarkElementContext watermark = new WatermarkElementContext(options);
graphicsEngineContext = new GraphicsEngineContext(
[watermark],
channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24),
finish - now);
}
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind);
@ -451,6 +470,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -451,6 +470,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkInputFile,
subtitleInputFile,
Option<ConcatInputFile>.None,
graphicsEngineInput,
VaapiDisplayName(hwAccel, vaapiDisplay),
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
@ -462,7 +482,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -462,7 +482,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
pipelineAction?.Invoke(pipeline);
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline);
var command = GetCommand(
ffmpegPath,
videoInputFile,
audioInputFile,
watermarkInputFile,
Option<ConcatInputFile>.None,
graphicsEngineInput,
pipeline);
return new PlayoutItemResult(command, graphicsEngineContext);
}
public async Task<Command> ForError(
@ -614,9 +643,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -614,9 +643,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
hwAccel,
videoInputFile,
audioInputFile,
None,
Option<WatermarkInputFile>.None,
subtitleInputFile,
Option<ConcatInputFile>.None,
Option<GraphicsEngineInput>.None,
VaapiDisplayName(hwAccel, vaapiDisplay),
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
@ -626,7 +656,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -626,7 +656,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, pipeline);
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline);
}
public async Task<Command> ConcatChannel(
@ -644,14 +674,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -644,14 +674,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
HardwareAccelerationMode.None,
None,
None,
None,
None,
Option<VideoInputFile>.None,
Option<AudioInputFile>.None,
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
concatInputFile,
Option<GraphicsEngineInput>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
@ -660,7 +691,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -660,7 +691,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
concatInputFile,
FFmpegState.Concat(saveReports, channel.Name));
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline);
}
public async Task<Command> ConcatSegmenterChannel(
@ -731,6 +762,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -731,6 +762,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<SubtitleInputFile> subtitleInputFile = Option<SubtitleInputFile>.None;
Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None;
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, FillerKind.None);
@ -805,6 +837,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -805,6 +837,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
vaapiDisplay,
vaapiDriver,
vaapiDevice,
@ -817,7 +850,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -817,7 +850,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
// copy video input options to concat input
concatInputFile.InputOptions.AddRange(videoInputFile.InputOptions);
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline);
}
public async Task<Command> WrapSegmenter(
@ -840,14 +873,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -840,14 +873,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
HardwareAccelerationMode.None,
None,
None,
None,
None,
Option<VideoInputFile>.None,
Option<AudioInputFile>.None,
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
concatInputFile,
Option<GraphicsEngineInput>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
@ -856,7 +890,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -856,7 +890,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
concatInputFile,
FFmpegState.Concat(saveReports, channel.Name));
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline);
}
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
@ -882,20 +916,21 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -882,20 +916,21 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
HardwareAccelerationMode.None,
videoInputFile,
None,
None,
None,
Option<AudioInputFile>.None,
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
Option<ConcatInputFile>.None,
Option<GraphicsEngineInput>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));
return GetCommand(ffmpegPath, videoInputFile, None, None, None, pipeline, false);
return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false);
}
public Task<Either<BaseError, string>> GenerateSongImage(
@ -954,20 +989,21 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -954,20 +989,21 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
HardwareAccelerationMode.None,
videoInputFile,
None,
None,
None,
Option<AudioInputFile>.None,
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
Option<ConcatInputFile>.None,
Option<GraphicsEngineInput>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, seek);
return GetCommand(ffmpegPath, videoInputFile, None, None, None, pipeline, false);
return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false);
}
private static Option<WatermarkInputFile> GetWatermarkInputFile(
@ -1041,6 +1077,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1041,6 +1077,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
FFmpegPipeline pipeline,
bool log = true)
{
@ -1067,6 +1104,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1067,6 +1104,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
audioInputFile,
watermarkInputFile,
concatInputFile,
graphicsEngineInput,
pipeline.PipelineSteps,
pipeline.IsIntelVaapiOrQsv);

2
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -9,7 +9,7 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg; @@ -9,7 +9,7 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg;
public interface IFFmpegProcessService
{
Task<Command> ForPlayoutItem(
Task<PlayoutItemResult> ForPlayoutItem(
string ffmpegPath,
string ffprobePath,
bool saveReports,

6
ErsatzTV.Core/Interfaces/FFmpeg/PlayoutItemResult.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using CliWrap;
using ErsatzTV.Core.Interfaces.Streaming;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
public record PlayoutItemResult(Command Process, Option<GraphicsEngineContext> GraphicsEngineContext);

14
ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Interfaces.Streaming;
public record GraphicsEngineContext(
List<GraphicsElementContext> Elements,
Resolution FrameSize,
int FrameRate,
TimeSpan Duration);
public abstract record GraphicsElementContext;
public record WatermarkElementContext(WatermarkOptions Options) : GraphicsElementContext;

10
ErsatzTV.Core/Interfaces/Streaming/IGraphicsElement.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Streaming;
public interface IGraphicsElement
{
Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken);
void Draw(object context, TimeSpan timestamp);
}

8
ErsatzTV.Core/Interfaces/Streaming/IGraphicsEngine.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using System.IO.Pipelines;
namespace ErsatzTV.Core.Interfaces.Streaming;
public interface IGraphicsEngine
{
Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken);
}

23
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -106,6 +106,7 @@ public class PipelineBuilderBaseTests @@ -106,6 +106,7 @@ public class PipelineBuilderBaseTests
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
@ -114,7 +115,7 @@ public class PipelineBuilderBaseTests @@ -114,7 +115,7 @@ public class PipelineBuilderBaseTests
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
result.PipelineSteps.ShouldContain(ps => ps is EncoderLibx265);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -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");
}
@ -203,6 +204,7 @@ public class PipelineBuilderBaseTests @@ -203,6 +204,7 @@ public class PipelineBuilderBaseTests
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
@ -211,7 +213,7 @@ public class PipelineBuilderBaseTests @@ -211,7 +213,7 @@ public class PipelineBuilderBaseTests
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
result.PipelineSteps.ShouldContain(ps => ps is EncoderLibx265);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -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");
}
@ -230,6 +232,7 @@ public class PipelineBuilderBaseTests @@ -230,6 +232,7 @@ public class PipelineBuilderBaseTests
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
@ -237,7 +240,7 @@ public class PipelineBuilderBaseTests @@ -237,7 +240,7 @@ public class PipelineBuilderBaseTests
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
string command = PrintCommand(None, None, None, concatInputFile, result);
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");
@ -259,6 +262,7 @@ public class PipelineBuilderBaseTests @@ -259,6 +262,7 @@ public class PipelineBuilderBaseTests
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
@ -266,7 +270,7 @@ public class PipelineBuilderBaseTests @@ -266,7 +270,7 @@ public class PipelineBuilderBaseTests
result.PipelineSteps.Count.ShouldBeGreaterThan(0);
string command = PrintCommand(None, None, None, concatInputFile, result);
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");
@ -356,6 +360,7 @@ public class PipelineBuilderBaseTests @@ -356,6 +360,7 @@ public class PipelineBuilderBaseTests
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
@ -366,7 +371,7 @@ public class PipelineBuilderBaseTests @@ -366,7 +371,7 @@ public class PipelineBuilderBaseTests
result.PipelineSteps.ShouldContain(ps => ps is EncoderCopyAudio);
videoInputFile.InputOptions.ShouldContain(io => io is ReadrateInputOption);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
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(
@ -447,6 +452,7 @@ public class PipelineBuilderBaseTests @@ -447,6 +452,7 @@ public class PipelineBuilderBaseTests
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
@ -456,7 +462,7 @@ public class PipelineBuilderBaseTests @@ -456,7 +462,7 @@ public class PipelineBuilderBaseTests
result.PipelineSteps.ShouldContain(ps => ps is EncoderCopyVideo);
result.PipelineSteps.ShouldContain(ps => ps is EncoderCopyAudio);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -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 -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
@ -493,13 +499,14 @@ public class PipelineBuilderBaseTests @@ -493,13 +499,14 @@ public class PipelineBuilderBaseTests
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, result);
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");
@ -510,6 +517,7 @@ public class PipelineBuilderBaseTests @@ -510,6 +517,7 @@ public class PipelineBuilderBaseTests
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
FFmpegPipeline pipeline)
{
IList<string> arguments = CommandGenerator.GenerateArguments(
@ -517,6 +525,7 @@ public class PipelineBuilderBaseTests @@ -517,6 +525,7 @@ public class PipelineBuilderBaseTests
audioInputFile,
watermarkInputFile,
concatInputFile,
graphicsEngineInput,
pipeline.PipelineSteps,
pipeline.IsIntelVaapiOrQsv);

23
ErsatzTV.FFmpeg/CommandGenerator.cs

@ -16,6 +16,7 @@ public static class CommandGenerator @@ -16,6 +16,7 @@ public static class CommandGenerator
Option<AudioInputFile> maybeAudioInputFile,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<ConcatInputFile> maybeConcatInputFile,
Option<GraphicsEngineInput> maybeGraphicsEngineInput,
IList<IPipelineStep> pipelineSteps,
bool isIntelVaapiOrQsv)
{
@ -36,7 +37,7 @@ public static class CommandGenerator @@ -36,7 +37,7 @@ public static class CommandGenerator
arguments.AddRange(step.InputOptions(videoInputFile));
}
arguments.AddRange(new[] { "-i", videoInputFile.Path });
arguments.AddRange(["-i", videoInputFile.Path]);
}
foreach (AudioInputFile audioInputFile in maybeAudioInputFile)
@ -50,22 +51,20 @@ public static class CommandGenerator @@ -50,22 +51,20 @@ public static class CommandGenerator
arguments.AddRange(step.InputOptions(audioInputFile));
}
arguments.AddRange(new[] { "-i", audioInputFile.Path });
arguments.AddRange(["-i", audioInputFile.Path]);
}
}
foreach (WatermarkInputFile watermarkInputFile in maybeWatermarkInputFile)
{
if (!includedPaths.Contains(watermarkInputFile.Path))
if (includedPaths.Add(watermarkInputFile.Path))
{
includedPaths.Add(watermarkInputFile.Path);
foreach (IInputOption step in watermarkInputFile.InputOptions)
{
arguments.AddRange(step.InputOptions(watermarkInputFile));
}
arguments.AddRange(new[] { "-i", watermarkInputFile.Path });
arguments.AddRange(["-i", watermarkInputFile.Path]);
}
}
@ -76,7 +75,17 @@ public static class CommandGenerator @@ -76,7 +75,17 @@ public static class CommandGenerator
arguments.AddRange(step.InputOptions(concatInputFile));
}
arguments.AddRange(new[] { "-i", concatInputFile.Path });
arguments.AddRange(["-i", concatInputFile.Path]);
}
foreach (GraphicsEngineInput graphicsEngineInput in maybeGraphicsEngineInput)
{
foreach (IInputOption step in graphicsEngineInput.InputOptions)
{
arguments.AddRange(step.InputOptions(graphicsEngineInput));
}
arguments.AddRange(["-i", graphicsEngineInput.Path]);
}
foreach (IPipelineStep step in pipelineSteps)

12
ErsatzTV.FFmpeg/Decoder/DecoderBase.cs

@ -6,11 +6,11 @@ namespace ErsatzTV.FFmpeg.Decoder; @@ -6,11 +6,11 @@ namespace ErsatzTV.FFmpeg.Decoder;
public abstract class DecoderBase : IDecoder
{
protected abstract FrameDataLocation OutputFrameDataLocation { get; }
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public virtual string[] InputOptions(InputFile inputFile) => new[] { "-c:v", Name };
public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions => Array.Empty<string>();
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public virtual string[] InputOptions(InputFile inputFile) => ["-c:v", Name];
public string[] FilterOptions => [];
public string[] OutputOptions => [];
public virtual FrameState NextState(FrameState currentState) =>
currentState with { FrameDataLocation = OutputFrameDataLocation };
@ -22,6 +22,8 @@ public abstract class DecoderBase : IDecoder @@ -22,6 +22,8 @@ public abstract class DecoderBase : IDecoder
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false;
protected static int InputBitDepth(InputFile inputFile)
{
var bitDepth = 8;

62
ErsatzTV.FFmpeg/Filter/ComplexFilter.cs

@ -7,6 +7,7 @@ public class ComplexFilter : IPipelineStep @@ -7,6 +7,7 @@ public class ComplexFilter : IPipelineStep
{
private readonly Option<AudioInputFile> _maybeAudioInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
private readonly Option<GraphicsEngineInput> _maybeGraphicsEngineInput;
private readonly Option<VideoInputFile> _maybeVideoInputFile;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly PipelineContext _pipelineContext;
@ -16,6 +17,7 @@ public class ComplexFilter : IPipelineStep @@ -16,6 +17,7 @@ public class ComplexFilter : IPipelineStep
Option<AudioInputFile> maybeAudioInputFile,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile,
Option<GraphicsEngineInput> maybeGraphicsEngineInput,
PipelineContext pipelineContext,
FilterChain filterChain)
{
@ -23,6 +25,7 @@ public class ComplexFilter : IPipelineStep @@ -23,6 +25,7 @@ public class ComplexFilter : IPipelineStep
_maybeAudioInputFile = maybeAudioInputFile;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
_maybeGraphicsEngineInput = maybeGraphicsEngineInput;
_pipelineContext = pipelineContext;
FilterChain = filterChain;
@ -32,12 +35,12 @@ public class ComplexFilter : IPipelineStep @@ -32,12 +35,12 @@ public class ComplexFilter : IPipelineStep
// for testing
public FilterChain FilterChain { get; }
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public string[] InputOptions(InputFile inputFile) => Array.Empty<string>();
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => [];
public string[] FilterOptions { get; }
public string[] OutputOptions => Array.Empty<string>();
public string[] OutputOptions => [];
public FrameState NextState(FrameState currentState) => currentState;
@ -47,6 +50,7 @@ public class ComplexFilter : IPipelineStep @@ -47,6 +50,7 @@ public class ComplexFilter : IPipelineStep
var videoLabel = "0:v";
string? watermarkLabel = null;
string? subtitleLabel = null;
string? graphicsEngineLabel = null;
var result = new List<string>();
@ -56,6 +60,8 @@ public class ComplexFilter : IPipelineStep @@ -56,6 +60,8 @@ public class ComplexFilter : IPipelineStep
string watermarkOverlayFilterComplex = string.Empty;
string subtitleFilterComplex = string.Empty;
string subtitleOverlayFilterComplex = string.Empty;
string graphicsEngineFilterComplex = string.Empty;
string graphicsEngineOverlayFilterComplex = string.Empty;
string pixelFormatFilterComplex = string.Empty;
var distinctPaths = new List<string>();
@ -93,6 +99,14 @@ public class ComplexFilter : IPipelineStep @@ -93,6 +99,14 @@ public class ComplexFilter : IPipelineStep
}
}
foreach ((string path, _) in _maybeGraphicsEngineInput)
{
if (!distinctPaths.Contains(path))
{
distinctPaths.Add(path);
}
}
foreach (VideoInputFile videoInputFile in _maybeVideoInputFile)
{
int inputIndex = distinctPaths.IndexOf(videoInputFile.Path);
@ -158,6 +172,26 @@ public class ComplexFilter : IPipelineStep @@ -158,6 +172,26 @@ public class ComplexFilter : IPipelineStep
}
}
foreach (GraphicsEngineInput graphicsEngineInput in _maybeGraphicsEngineInput)
{
int inputIndex = distinctPaths.IndexOf(graphicsEngineInput.Path);
graphicsEngineLabel = $"{inputIndex}:0";
if (FilterChain.GraphicsEngineFilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
graphicsEngineFilterComplex += $"[{inputIndex}:0]";
graphicsEngineFilterComplex += string.Join(
",",
FilterChain.GraphicsEngineFilterSteps.Select(f => f.Filter)
.Filter(s => !string.IsNullOrWhiteSpace(s)));
graphicsEngineLabel = "[ge]";
graphicsEngineFilterComplex += graphicsEngineLabel;
}
else
{
graphicsEngineLabel = $"[{graphicsEngineLabel}]";
}
}
// overlay subtitle
if (!string.IsNullOrWhiteSpace(subtitleLabel) && FilterChain.SubtitleOverlayFilterSteps.Count != 0)
{
@ -182,6 +216,18 @@ public class ComplexFilter : IPipelineStep @@ -182,6 +216,18 @@ public class ComplexFilter : IPipelineStep
watermarkOverlayFilterComplex += videoLabel;
}
// overlay graphics engine
if (!string.IsNullOrWhiteSpace(graphicsEngineLabel) && FilterChain.GraphicsEngineOverlayFilterSteps.Count != 0)
{
graphicsEngineOverlayFilterComplex += $"{ProperLabel(videoLabel)}{ProperLabel(graphicsEngineLabel)}";
graphicsEngineOverlayFilterComplex += string.Join(
",",
FilterChain.GraphicsEngineOverlayFilterSteps.Select(f => f.Filter)
.Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[vge]";
graphicsEngineOverlayFilterComplex += videoLabel;
}
// pixel format
if (FilterChain.PixelFormatFilterSteps.Count != 0)
{
@ -220,17 +266,19 @@ public class ComplexFilter : IPipelineStep @@ -220,17 +266,19 @@ public class ComplexFilter : IPipelineStep
videoFilterComplex,
subtitleFilterComplex,
watermarkFilterComplex,
graphicsEngineFilterComplex,
subtitleOverlayFilterComplex,
watermarkOverlayFilterComplex,
graphicsEngineOverlayFilterComplex,
pixelFormatFilterComplex
}.Where(s => !string.IsNullOrWhiteSpace(s)));
if (!string.IsNullOrWhiteSpace(filterComplex))
{
result.AddRange(new[] { "-filter_complex", filterComplex });
result.AddRange(["-filter_complex", filterComplex]);
}
result.AddRange(new[] { "-map", videoLabel, "-map", audioLabel });
result.AddRange(["-map", videoLabel, "-map", audioLabel]);
foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(s =>
s.Method == SubtitleMethod.Copy ||
@ -245,7 +293,7 @@ public class ComplexFilter : IPipelineStep @@ -245,7 +293,7 @@ public class ComplexFilter : IPipelineStep
foreach ((int index, _, _) in subtitleInputFile.Streams)
{
subtitleLabel = $"{inputIndex}:{index}";
result.AddRange(new[] { "-map", subtitleLabel });
result.AddRange(["-map", subtitleLabel]);
}
}
}

9
ErsatzTV.FFmpeg/Filter/Cuda/OverlayGraphicsEngineCudaFilter.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.FFmpeg.Filter.Cuda;
public class OverlayGraphicsEngineCudaFilter : BaseFilter
{
public override string Filter => "overlay_cuda";
public override FrameState NextState(FrameState currentState) =>
currentState with { FrameDataLocation = FrameDataLocation.Hardware };
}

11
ErsatzTV.FFmpeg/Filter/OverlayGraphicsEngineFilter.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Filter;
public class OverlayGraphicsEngineFilter(IPixelFormat outputPixelFormat) : BaseFilter
{
public override string Filter => $"overlay=format={(outputPixelFormat.BitDepth == 10 ? '1' : '0')}";
public override FrameState NextState(FrameState currentState) =>
currentState with { FrameDataLocation = FrameDataLocation.Software };
}

4
ErsatzTV.FFmpeg/FilterChain.cs

@ -4,9 +4,11 @@ public record FilterChain( @@ -4,9 +4,11 @@ public record FilterChain(
List<IPipelineFilterStep> VideoFilterSteps,
List<IPipelineFilterStep> WatermarkFilterSteps,
List<IPipelineFilterStep> SubtitleFilterSteps,
List<IPipelineFilterStep> GraphicsEngineFilterSteps,
List<IPipelineFilterStep> WatermarkOverlayFilterSteps,
List<IPipelineFilterStep> SubtitleOverlayFilterSteps,
List<IPipelineFilterStep> GraphicsEngineOverlayFilterSteps,
List<IPipelineFilterStep> PixelFormatFilterSteps)
{
public static readonly FilterChain Empty = new([], [], [], [], [], []);
public static readonly FilterChain Empty = new([], [], [], [], [], [], [], []);
}

16
ErsatzTV.FFmpeg/Format/ConcatInputFormat.cs

@ -5,23 +5,25 @@ namespace ErsatzTV.FFmpeg.Format; @@ -5,23 +5,25 @@ namespace ErsatzTV.FFmpeg.Format;
public class ConcatInputFormat : IInputOption
{
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => new[]
{
public string[] InputOptions(InputFile inputFile) =>
[
"-f", "concat",
"-safe", "0",
"-protocol_whitelist", "file,http,tcp,https,tcp,tls",
"-probesize", "32"
};
];
public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions => Array.Empty<string>();
public string[] FilterOptions => [];
public string[] OutputOptions => [];
public FrameState NextState(FrameState currentState) => currentState;
public bool AppliesTo(AudioInputFile audioInputFile) => false;
public bool AppliesTo(VideoInputFile videoInputFile) => false;
public bool AppliesTo(ConcatInputFile concatInputFile) => true;
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false;
}

2
ErsatzTV.FFmpeg/Format/PixelFormat.cs

@ -9,4 +9,6 @@ public static class PixelFormat @@ -9,4 +9,6 @@ public static class PixelFormat
public const string YUVJ420P = "yuvj420p";
public const string YUV444P = "yuv444p";
public const string YUV444P10LE = "yuv444p10le";
public const string BGRA = "bgra";
}

11
ErsatzTV.FFmpeg/InputFile.cs

@ -84,3 +84,14 @@ public record SubtitleInputFile(string Path, IList<MediaStream> SubtitleStreams, @@ -84,3 +84,14 @@ public record SubtitleInputFile(string Path, IList<MediaStream> SubtitleStreams,
public bool IsImageBased => SubtitleStreams.All(s =>
s.Codec is "hdmv_pgs_subtitle" or "dvd_subtitle" or "dvdsub" or "vobsub" or "pgssub" or "pgs");
}
public record GraphicsEngineInput() : InputFile("-", [])
{
public void AddOption(IInputOption option)
{
if (option.AppliesTo(this))
{
InputOptions.Add(option);
}
}
}

2
ErsatzTV.FFmpeg/InputOption/CopyTimestampInputOption.cs

@ -18,4 +18,6 @@ public class CopyTimestampInputOption : IInputOption @@ -18,4 +18,6 @@ public class CopyTimestampInputOption : IInputOption
public bool AppliesTo(VideoInputFile videoInputFile) => true;
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false;
}

12
ErsatzTV.FFmpeg/InputOption/DoNotIgnoreLoopInputOption.cs

@ -4,13 +4,13 @@ namespace ErsatzTV.FFmpeg.InputOption; @@ -4,13 +4,13 @@ namespace ErsatzTV.FFmpeg.InputOption;
public class DoNotIgnoreLoopInputOption : IInputOption
{
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => new[] { "-ignore_loop", "0" };
public string[] InputOptions(InputFile inputFile) => ["-ignore_loop", "0"];
public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions => Array.Empty<string>();
public string[] FilterOptions => [];
public string[] OutputOptions => [];
public FrameState NextState(FrameState currentState) => currentState;
public bool AppliesTo(AudioInputFile audioInputFile) => false;
@ -18,4 +18,6 @@ public class DoNotIgnoreLoopInputOption : IInputOption @@ -18,4 +18,6 @@ public class DoNotIgnoreLoopInputOption : IInputOption
public bool AppliesTo(VideoInputFile videoInputFile) => videoInputFile.VideoStreams.All(s => s.StillImage == false);
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false;
}

1
ErsatzTV.FFmpeg/InputOption/IInputOption.cs

@ -5,4 +5,5 @@ public interface IInputOption : IPipelineStep @@ -5,4 +5,5 @@ public interface IInputOption : IPipelineStep
bool AppliesTo(AudioInputFile audioInputFile);
bool AppliesTo(VideoInputFile videoInputFile);
bool AppliesTo(ConcatInputFile concatInputFile);
bool AppliesTo(GraphicsEngineInput graphicsEngineInput);
}

7
ErsatzTV.FFmpeg/InputOption/InfiniteLoopInputOption.cs

@ -9,8 +9,8 @@ public class InfiniteLoopInputOption : IInputOption @@ -9,8 +9,8 @@ public class InfiniteLoopInputOption : IInputOption
public InfiniteLoopInputOption(HardwareAccelerationMode hardwareAccelerationMode) =>
_hardwareAccelerationMode = hardwareAccelerationMode;
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile)
{
@ -24,7 +24,7 @@ public class InfiniteLoopInputOption : IInputOption @@ -24,7 +24,7 @@ public class InfiniteLoopInputOption : IInputOption
return ["-stream_loop", "-1"];
}
public string[] FilterOptions => Array.Empty<string>();
public string[] FilterOptions => [];
public string[] OutputOptions =>
_hardwareAccelerationMode is HardwareAccelerationMode.Qsv or HardwareAccelerationMode.Vaapi
@ -35,4 +35,5 @@ public class InfiniteLoopInputOption : IInputOption @@ -35,4 +35,5 @@ public class InfiniteLoopInputOption : IInputOption
public bool AppliesTo(AudioInputFile audioInputFile) => true;
public bool AppliesTo(VideoInputFile videoInputFile) => true;
public bool AppliesTo(ConcatInputFile concatInputFile) => true;
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false;
}

12
ErsatzTV.FFmpeg/InputOption/LavfiInputOption.cs

@ -4,13 +4,13 @@ namespace ErsatzTV.FFmpeg.InputOption; @@ -4,13 +4,13 @@ namespace ErsatzTV.FFmpeg.InputOption;
public class LavfiInputOption : IInputOption
{
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => new[] { "-f", "lavfi" };
public string[] InputOptions(InputFile inputFile) => ["-f", "lavfi"];
public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions => Array.Empty<string>();
public string[] FilterOptions => [];
public string[] OutputOptions => [];
public FrameState NextState(FrameState currentState) => currentState;
public bool AppliesTo(AudioInputFile audioInputFile) => true;
@ -18,4 +18,6 @@ public class LavfiInputOption : IInputOption @@ -18,4 +18,6 @@ public class LavfiInputOption : IInputOption
public bool AppliesTo(VideoInputFile videoInputFile) => false;
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false;
}

30
ErsatzTV.FFmpeg/InputOption/RawVideoInputOption.cs

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.InputOption;
public class RawVideoInputOption(string pixelFormat, FrameSize frameSize, int frameRate) : IInputOption
{
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) =>
[
"-f", "rawvideo",
"-vcodec", "rawvideo",
"-pix_fmt", pixelFormat,
"-s", $"{frameSize.Width}x{frameSize.Height}",
"-r", $"{frameRate}"
];
public string[] FilterOptions => [];
public string[] OutputOptions => [];
public FrameState NextState(FrameState currentState) => currentState;
public bool AppliesTo(AudioInputFile audioInputFile) => false;
public bool AppliesTo(VideoInputFile videoInputFile) => false;
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => true;
}

10
ErsatzTV.FFmpeg/InputOption/ReadrateInputOption.cs

@ -21,9 +21,9 @@ public class ReadrateInputOption : IInputOption @@ -21,9 +21,9 @@ public class ReadrateInputOption : IInputOption
_logger = logger;
}
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => Array.Empty<string>();
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile)
{
@ -51,8 +51,8 @@ public class ReadrateInputOption : IInputOption @@ -51,8 +51,8 @@ public class ReadrateInputOption : IInputOption
return result.ToArray();
}
public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions => Array.Empty<string>();
public string[] FilterOptions => [];
public string[] OutputOptions => [];
public FrameState NextState(FrameState currentState) => currentState with { Realtime = true };
public bool AppliesTo(AudioInputFile audioInputFile) => audioInputFile is not NullAudioInputFile;
@ -61,4 +61,6 @@ public class ReadrateInputOption : IInputOption @@ -61,4 +61,6 @@ public class ReadrateInputOption : IInputOption
public bool AppliesTo(VideoInputFile videoInputFile) => videoInputFile.VideoStreams.All(s => !s.StillImage);
public bool AppliesTo(ConcatInputFile concatInputFile) => true;
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false;
}

12
ErsatzTV.FFmpeg/InputOption/StreamSeekInputOption.cs

@ -8,11 +8,11 @@ public class StreamSeekInputOption : IInputOption @@ -8,11 +8,11 @@ public class StreamSeekInputOption : IInputOption
public StreamSeekInputOption(TimeSpan start) => _start = start;
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public string[] InputOptions(InputFile inputFile) => new[] { "-ss", $"{_start:c}" };
public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions => Array.Empty<string>();
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => ["-ss", $"{_start:c}"];
public string[] FilterOptions => [];
public string[] OutputOptions => [];
public FrameState NextState(FrameState currentState) => currentState;
public bool AppliesTo(AudioInputFile audioInputFile) => audioInputFile is not NullAudioInputFile;
@ -22,4 +22,6 @@ public class StreamSeekInputOption : IInputOption @@ -22,4 +22,6 @@ public class StreamSeekInputOption : IInputOption
// never seek when concatenating
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false;
}

2
ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs

@ -23,6 +23,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder @@ -23,6 +23,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -33,6 +34,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder @@ -33,6 +34,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
logger)

1
ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs

@ -9,6 +9,7 @@ public interface IPipelineBuilderFactory @@ -9,6 +9,7 @@ public interface IPipelineBuilderFactory
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
Option<string> vaapiDisplay,
Option<string> vaapiDriver,
Option<string> vaapiDevice,

45
ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs

@ -30,6 +30,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -30,6 +30,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -40,6 +41,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -40,6 +41,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
logger)
@ -138,6 +140,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -138,6 +140,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
VideoStream videoStream,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
PipelineContext context,
Option<IDecoder> maybeDecoder,
FFmpegState ffmpegState,
@ -147,6 +150,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -147,6 +150,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
{
var watermarkOverlayFilterSteps = new List<IPipelineFilterStep>();
var subtitleOverlayFilterSteps = new List<IPipelineFilterStep>();
var graphicsEngineOverlayFilterSteps = new List<IPipelineFilterStep>();
FrameState currentState = desiredState with
{
@ -205,7 +209,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -205,7 +209,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
currentState = SetCrop(videoInputFile, desiredState, currentState);
SetStillImageLoop(videoInputFile, videoStream, ffmpegState, desiredState, pipelineSteps);
if (currentState.BitDepth == 8 && context.HasSubtitleOverlay || context.HasWatermark)
if (currentState.BitDepth == 8 && context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine)
{
Option<IPixelFormat> desiredPixelFormat = Some((IPixelFormat)new PixelFormatYuv420P());
@ -241,7 +245,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -241,7 +245,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
// need to upload for any sort of overlay
if (currentState.FrameDataLocation == FrameDataLocation.Software &&
currentState.BitDepth == 8 && context.HasSubtitleText == false
&& (context.HasSubtitleOverlay || context.HasWatermark))
&& (context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine))
{
var hardwareUpload = new HardwareUploadCudaFilter(currentState);
currentState = hardwareUpload.NextState(currentState);
@ -277,6 +281,8 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -277,6 +281,8 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
currentState,
watermarkOverlayFilterSteps);
currentState = SetGraphicsEngine(graphicsEngineInput, currentState, graphicsEngineOverlayFilterSteps);
// after everything else is done, apply the encoder
if (pipelineSteps.OfType<IEncoder>().All(e => e.Kind != StreamKind.Video))
{
@ -308,10 +314,12 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -308,10 +314,12 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
return new FilterChain(
videoInputFile.FilterSteps,
watermarkInputFile.Map(wm => wm.FilterSteps).IfNone(new List<IPipelineFilterStep>()),
subtitleInputFile.Map(st => st.FilterSteps).IfNone(new List<IPipelineFilterStep>()),
watermarkInputFile.Map(wm => wm.FilterSteps).IfNone([]),
subtitleInputFile.Map(st => st.FilterSteps).IfNone([]),
graphicsEngineInput.Map(ge => ge.FilterSteps).IfNone([]),
watermarkOverlayFilterSteps,
subtitleOverlayFilterSteps,
graphicsEngineOverlayFilterSteps,
pixelFormatFilterSteps);
}
@ -357,11 +365,11 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -357,11 +365,11 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
{
_logger.LogDebug("Using software encoder");
if ((context.HasSubtitleOverlay || context.HasWatermark) &&
if ((context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine) &&
currentState.FrameDataLocation == FrameDataLocation.Hardware)
{
_logger.LogDebug(
"HasSubtitleOverlay || HasWatermark && FrameDataLocation == FrameDataLocation.Hardware");
"HasSubtitleOverlay || HasWatermark || HasGraphicsEngine && FrameDataLocation == FrameDataLocation.Hardware");
var hardwareDownload = new CudaHardwareDownloadFilter(currentState.PixelFormat, None);
currentState = hardwareDownload.NextState(currentState);
@ -559,7 +567,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -559,7 +567,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
currentState = subtitlesFilter.NextState(currentState);
videoInputFile.FilterSteps.Add(subtitlesFilter);
if (context.HasWatermark)
if (context.HasWatermark || context.HasGraphicsEngine)
{
var subtitleHardwareUpload = new HardwareUploadCudaFilter(currentState);
currentState = subtitleHardwareUpload.NextState(currentState);
@ -637,6 +645,26 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -637,6 +645,26 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
return currentState;
}
private static FrameState SetGraphicsEngine(
Option<GraphicsEngineInput> graphicsEngineInput,
FrameState currentState,
List<IPipelineFilterStep> graphicsEngineOverlayFilterSteps)
{
foreach (var graphicsEngine in graphicsEngineInput)
{
graphicsEngine.FilterSteps.Add(new PixelFormatFilter(new PixelFormatYuva420P()));
graphicsEngine.FilterSteps.Add(
new HardwareUploadCudaFilter(currentState with { FrameDataLocation = FrameDataLocation.Software }));
var graphicsEngineFilter = new OverlayGraphicsEngineCudaFilter();
graphicsEngineOverlayFilterSteps.Add(graphicsEngineFilter);
currentState = graphicsEngineFilter.NextState(currentState);
}
return currentState;
}
private static FrameState SetPad(
VideoInputFile videoInputFile,
VideoStream videoStream,
@ -672,7 +700,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -672,7 +700,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
bool decodedToSoftware = ffmpegState.DecoderHardwareAccelerationMode == HardwareAccelerationMode.None;
bool softwareEncoder = ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None;
bool noHardwareFilters = context is
{ HasWatermark: false, HasSubtitleOverlay: false, ShouldDeinterlace: false };
{ HasGraphicsEngine: false, HasWatermark: false, HasSubtitleOverlay: false, ShouldDeinterlace: false };
bool needsToPad = currentState.PaddedSize != desiredState.PaddedSize;
if (decodedToSoftware && (needsToPad || noHardwareFilters && softwareEncoder))
@ -690,6 +718,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -690,6 +718,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
currentState with
{
PixelFormat = context is { IsHdr: false, Is10BitOutput: false } && (context.HasWatermark ||
context.HasGraphicsEngine ||
context.HasSubtitleOverlay ||
context.ShouldDeinterlace ||
desiredState.ScaledSize != desiredState.PaddedSize ||

15
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -19,6 +19,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -19,6 +19,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
{
private readonly Option<AudioInputFile> _audioInputFile;
private readonly Option<ConcatInputFile> _concatInputFile;
private readonly Option<GraphicsEngineInput> _graphicsEngineInput;
private readonly IFFmpegCapabilities _ffmpegCapabilities;
private readonly string _fontsFolder;
private readonly HardwareAccelerationMode _hardwareAccelerationMode;
@ -36,6 +37,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -36,6 +37,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
string reportsFolder,
string fontsFolder,
ILogger logger)
@ -47,6 +49,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -47,6 +49,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
_watermarkInputFile = watermarkInputFile;
_subtitleInputFile = subtitleInputFile;
_concatInputFile = concatInputFile;
_graphicsEngineInput = graphicsEngineInput;
_reportsFolder = reportsFolder;
_fontsFolder = fontsFolder;
_logger = logger;
@ -209,6 +212,12 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -209,6 +212,12 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
concatInputFile.AddOption(new InfiniteLoopInputOption(HardwareAccelerationMode.None));
}
foreach (GraphicsEngineInput graphicsEngineInput in _graphicsEngineInput)
{
graphicsEngineInput.AddOption(
new RawVideoInputOption(PixelFormat.BGRA, desiredState.PaddedSize, desiredState.FrameRate.IfNone(24)));
}
Debug.Assert(_videoInputFile.IsSome, "Pipeline builder requires exactly one video input file");
VideoInputFile videoInputFile = _videoInputFile.Head();
@ -218,6 +227,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -218,6 +227,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
var context = new PipelineContext(
_hardwareAccelerationMode,
_graphicsEngineInput.IsSome,
_watermarkInputFile.IsSome,
_subtitleInputFile.Map(s => s is { IsImageBased: true, Method: SubtitleMethod.Burn }).IfNone(false),
_subtitleInputFile.Map(s => s is { IsImageBased: false, Method: SubtitleMethod.Burn }).IfNone(false),
@ -285,6 +295,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -285,6 +295,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
_audioInputFile,
_watermarkInputFile,
_subtitleInputFile,
_graphicsEngineInput,
context,
filterChain);
@ -553,13 +564,12 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -553,13 +564,12 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
SetVideoBufferSizeOutput(desiredState, pipelineSteps);
}
FilterChain filterChain = SetVideoFilters(
videoInputFile,
videoStream,
_watermarkInputFile,
_subtitleInputFile,
_graphicsEngineInput,
context,
maybeDecoder,
ffmpegState,
@ -622,6 +632,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -622,6 +632,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
VideoStream videoStream,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
PipelineContext context,
Option<IDecoder> maybeDecoder,
FFmpegState ffmpegState,

8
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs

@ -23,6 +23,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -23,6 +23,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
Option<string> vaapiDisplay,
Option<string> vaapiDriver,
Option<string> vaapiDevice,
@ -53,6 +54,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -53,6 +54,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
_logger),
@ -66,6 +68,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -66,6 +68,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
_logger),
@ -79,6 +82,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -79,6 +82,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
_logger),
@ -92,6 +96,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -92,6 +96,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
_logger),
@ -106,6 +111,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -106,6 +111,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
_logger),
@ -119,6 +125,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -119,6 +125,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
_logger),
@ -131,6 +138,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -131,6 +138,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
_logger)

1
ErsatzTV.FFmpeg/Pipeline/PipelineContext.cs

@ -2,6 +2,7 @@ namespace ErsatzTV.FFmpeg.Pipeline; @@ -2,6 +2,7 @@ namespace ErsatzTV.FFmpeg.Pipeline;
public record PipelineContext(
HardwareAccelerationMode HardwareAccelerationMode,
bool HasGraphicsEngine,
bool HasWatermark,
bool HasSubtitleOverlay,
bool HasSubtitleText,

41
ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs

@ -30,6 +30,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -30,6 +30,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -40,6 +41,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -40,6 +41,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
logger)
@ -139,6 +141,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -139,6 +141,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
VideoStream videoStream,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
PipelineContext context,
Option<IDecoder> maybeDecoder,
FFmpegState ffmpegState,
@ -148,6 +151,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -148,6 +151,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
{
var watermarkOverlayFilterSteps = new List<IPipelineFilterStep>();
var subtitleOverlayFilterSteps = new List<IPipelineFilterStep>();
var graphicsEngineOverlayFilterSteps = new List<IPipelineFilterStep>();
FrameState currentState = desiredState with
{
@ -168,7 +172,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -168,7 +172,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
}
// easier to use nv12 for overlay
if (context.HasSubtitleOverlay || context.HasWatermark)
if (context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine)
{
IPixelFormat pixelFormat = desiredState.PixelFormat.IfNone(
context.Is10BitOutput ? new PixelFormatYuv420P10Le() : new PixelFormatYuv420P());
@ -191,7 +195,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -191,7 +195,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
// need to download for any sort of overlay
if (currentState.FrameDataLocation == FrameDataLocation.Hardware &&
(context.HasSubtitleOverlay || context.HasWatermark))
(context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine))
{
var hardwareDownload = new HardwareDownloadFilter(currentState);
currentState = hardwareDownload.NextState(currentState);
@ -217,6 +221,8 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -217,6 +221,8 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
currentState,
watermarkOverlayFilterSteps);
SetGraphicsEngine(graphicsEngineInput, currentState, graphicsEngineOverlayFilterSteps);
// after everything else is done, apply the encoder
if (pipelineSteps.OfType<IEncoder>().All(e => e.Kind != StreamKind.Video))
{
@ -250,10 +256,12 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -250,10 +256,12 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
return new FilterChain(
videoInputFile.FilterSteps,
watermarkInputFile.Map(wm => wm.FilterSteps).IfNone(new List<IPipelineFilterStep>()),
subtitleInputFile.Map(st => st.FilterSteps).IfNone(new List<IPipelineFilterStep>()),
watermarkInputFile.Map(wm => wm.FilterSteps).IfNone([]),
subtitleInputFile.Map(st => st.FilterSteps).IfNone([]),
graphicsEngineInput.Map(ge => ge.FilterSteps).IfNone([]),
watermarkOverlayFilterSteps,
subtitleOverlayFilterSteps,
graphicsEngineOverlayFilterSteps,
pixelFormatFilterSteps);
}
@ -556,6 +564,29 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -556,6 +564,29 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
return currentState;
}
private static void SetGraphicsEngine(
Option<GraphicsEngineInput> graphicsEngineInput,
FrameState desiredState,
List<IPipelineFilterStep> graphicsEngineOverlayFilterSteps)
{
foreach (var _ in graphicsEngineInput)
{
foreach (IPixelFormat desiredPixelFormat in desiredState.PixelFormat)
{
IPixelFormat pf = desiredPixelFormat;
if (desiredPixelFormat is PixelFormatNv12 nv12)
{
foreach (IPixelFormat availablePixelFormat in AvailablePixelFormats.ForPixelFormat(nv12.Name, null))
{
pf = availablePixelFormat;
}
}
graphicsEngineOverlayFilterSteps.Add(new OverlayGraphicsEngineFilter(pf));
}
}
}
private static FrameState SetPad(
VideoInputFile videoInputFile,
VideoStream videoStream,
@ -586,7 +617,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -586,7 +617,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
{
DecoderHardwareAccelerationMode: HardwareAccelerationMode.None,
EncoderHardwareAccelerationMode: HardwareAccelerationMode.None
} && context is { HasWatermark: false, HasSubtitleOverlay: false, ShouldDeinterlace: false };
} && context is { HasGraphicsEngine: false, HasWatermark: false, HasSubtitleOverlay: false, ShouldDeinterlace: false };
// auto_scale filter seems to muck up 10-bit software decode => hardware scale, so use software scale in that case
useSoftwareFilter = useSoftwareFilter ||

33
ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs

@ -22,6 +22,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -22,6 +22,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -32,6 +33,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -32,6 +33,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
logger) =>
@ -76,6 +78,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -76,6 +78,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
VideoStream videoStream,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
PipelineContext context,
Option<IDecoder> maybeDecoder,
FFmpegState ffmpegState,
@ -85,6 +88,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -85,6 +88,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
{
var watermarkOverlayFilterSteps = new List<IPipelineFilterStep>();
var subtitleOverlayFilterSteps = new List<IPipelineFilterStep>();
var graphicsEngineOverlayFilterSteps = new List<IPipelineFilterStep>();
FrameState currentState = desiredState with
{
@ -124,6 +128,10 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -124,6 +128,10 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
desiredState,
currentState,
watermarkOverlayFilterSteps);
SetGraphicsEngine(
graphicsEngineInput,
desiredState,
graphicsEngineOverlayFilterSteps);
}
// after everything else is done, apply the encoder
@ -152,8 +160,10 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -152,8 +160,10 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
videoInputFile.FilterSteps,
watermarkInputFile.Map(wm => wm.FilterSteps).IfNone([]),
subtitleInputFile.Map(st => st.FilterSteps).IfNone([]),
graphicsEngineInput.Map(ge => ge.FilterSteps).IfNone([]),
watermarkOverlayFilterSteps,
subtitleOverlayFilterSteps,
graphicsEngineOverlayFilterSteps,
pixelFormatFilterSteps);
}
@ -300,6 +310,29 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -300,6 +310,29 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
}
}
private static void SetGraphicsEngine(
Option<GraphicsEngineInput> graphicsEngineInput,
FrameState desiredState,
List<IPipelineFilterStep> graphicsEngineOverlayFilterSteps)
{
foreach (var _ in graphicsEngineInput)
{
foreach (IPixelFormat desiredPixelFormat in desiredState.PixelFormat)
{
IPixelFormat pf = desiredPixelFormat;
if (desiredPixelFormat is PixelFormatNv12 nv12)
{
foreach (IPixelFormat availablePixelFormat in AvailablePixelFormats.ForPixelFormat(nv12.Name, null))
{
pf = availablePixelFormat;
}
}
graphicsEngineOverlayFilterSteps.Add(new OverlayGraphicsEngineFilter(pf));
}
}
}
private static FrameState SetPad(
VideoInputFile videoInputFile,
VideoStream videoStream,

39
ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs

@ -30,6 +30,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -30,6 +30,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -40,6 +41,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -40,6 +41,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
logger)
@ -138,6 +140,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -138,6 +140,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
VideoStream videoStream,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
PipelineContext context,
Option<IDecoder> maybeDecoder,
FFmpegState ffmpegState,
@ -147,6 +150,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -147,6 +150,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
{
var watermarkOverlayFilterSteps = new List<IPipelineFilterStep>();
var subtitleOverlayFilterSteps = new List<IPipelineFilterStep>();
var graphicsEngineOverlayFilterSteps = new List<IPipelineFilterStep>();
FrameState currentState = desiredState with
{
@ -162,7 +166,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -162,7 +166,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
}
// easier to use nv12 for overlay
if (context.HasSubtitleOverlay || context.HasWatermark)
if (context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine)
{
IPixelFormat pixelFormat = desiredState.PixelFormat.IfNone(
context.Is10BitOutput ? new PixelFormatYuv420P10Le() : new PixelFormatYuv420P());
@ -200,7 +204,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -200,7 +204,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
}
else if (currentState.FrameDataLocation == FrameDataLocation.Hardware &&
(!context.HasSubtitleOverlay || forceSoftwareOverlay) &&
context.HasWatermark)
(context.HasWatermark || context.HasGraphicsEngine))
{
// download for watermark (or forced software subtitle)
var hardwareDownload = new HardwareDownloadFilter(currentState);
@ -226,6 +230,8 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -226,6 +230,8 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
currentState,
watermarkOverlayFilterSteps);
SetGraphicsEngine(graphicsEngineInput, desiredState, graphicsEngineOverlayFilterSteps);
// after everything else is done, apply the encoder
if (pipelineSteps.OfType<IEncoder>().All(e => e.Kind != StreamKind.Video))
{
@ -262,10 +268,12 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -262,10 +268,12 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
return new FilterChain(
videoInputFile.FilterSteps,
watermarkInputFile.Map(wm => wm.FilterSteps).IfNone(new List<IPipelineFilterStep>()),
subtitleInputFile.Map(st => st.FilterSteps).IfNone(new List<IPipelineFilterStep>()),
watermarkInputFile.Map(wm => wm.FilterSteps).IfNone([]),
subtitleInputFile.Map(st => st.FilterSteps).IfNone([]),
graphicsEngineInput.Map(ge => ge.FilterSteps).IfNone([]),
watermarkOverlayFilterSteps,
subtitleOverlayFilterSteps,
graphicsEngineOverlayFilterSteps,
pixelFormatFilterSteps);
}
@ -528,6 +536,29 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -528,6 +536,29 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
return currentState;
}
private static void SetGraphicsEngine(
Option<GraphicsEngineInput> graphicsEngineInput,
FrameState desiredState,
List<IPipelineFilterStep> graphicsEngineOverlayFilterSteps)
{
foreach (var _ in graphicsEngineInput)
{
foreach (IPixelFormat desiredPixelFormat in desiredState.PixelFormat)
{
IPixelFormat pf = desiredPixelFormat;
if (desiredPixelFormat is PixelFormatNv12 nv12)
{
foreach (IPixelFormat availablePixelFormat in AvailablePixelFormats.ForPixelFormat(nv12.Name, null))
{
pf = availablePixelFormat;
}
}
graphicsEngineOverlayFilterSteps.Add(new OverlayGraphicsEngineFilter(pf));
}
}
}
private static FrameState SetPad(
VideoInputFile videoInputFile,
FFmpegState ffmpegState,

2
ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs

@ -24,6 +24,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder @@ -24,6 +24,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -34,6 +35,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder @@ -34,6 +35,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder
watermarkInputFile,
subtitleInputFile,
concatInputFile,
graphicsEngineInput,
reportsFolder,
fontsFolder,
logger)

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -33,6 +33,7 @@ @@ -33,6 +33,7 @@
<PackageReference Include="Refit.Newtonsoft.Json" Version="8.0.0" />
<PackageReference Include="Refit.Xml" Version="8.0.0" />
<PackageReference Include="Scriban.Signed" Version="6.2.1" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />

73
ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
using System.IO.Pipelines;
using ErsatzTV.Core.Interfaces.Streaming;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace ErsatzTV.Infrastructure.Streaming;
public class GraphicsEngine : IGraphicsEngine
{
public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken)
{
var elements = new List<IGraphicsElement>();
foreach (var element in context.Elements)
{
switch (element)
{
case WatermarkElementContext watermarkElementContext:
var watermark = new WatermarkElement(watermarkElementContext.Options);
if (watermark.IsValid)
{
elements.Add(watermark);
}
break;
}
}
// initialize all elements
await Task.WhenAll(elements.Select(e => e.InitializeAsync(context.FrameSize, context.FrameRate, cancellationToken)));
long frameCount = 0;
var totalFrames = (long)(context.Duration.TotalSeconds * context.FrameRate);
try
{
while (!cancellationToken.IsCancellationRequested && frameCount < totalFrames)
{
var timestamp = TimeSpan.FromSeconds(frameCount / context.FrameRate);
using var outputFrame = new Image<Bgra32>(
context.FrameSize.Width,
context.FrameSize.Height,
Color.Transparent);
// draw each element
outputFrame.Mutate(ctx =>
{
foreach (var element in elements)
{
element.Draw(ctx, timestamp);
}
});
// pipe output
int frameBufferSize = context.FrameSize.Width * context.FrameSize.Height * 4;
Memory<byte> memory = pipeWriter.GetMemory(frameBufferSize);
outputFrame.CopyPixelDataTo(memory.Span);
pipeWriter.Advance(frameBufferSize);
await pipeWriter.FlushAsync(cancellationToken);
frameCount++;
}
}
finally
{
foreach (var element in elements.OfType<IDisposable>())
{
element.Dispose();
}
}
}
}

163
ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs

@ -0,0 +1,163 @@ @@ -0,0 +1,163 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg.State;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Processing;
using Image = SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Streaming;
public class WatermarkElement : IGraphicsElement, IDisposable
{
private readonly string _imagePath;
private readonly ChannelWatermark _watermark;
private readonly List<Image> _scaledFrames = [];
private readonly List<double> _frameDelays = [];
private double _animatedDurationSeconds;
private Image _sourceImage;
private Point _location;
public WatermarkElement(WatermarkOptions watermarkOptions)
{
// TODO: better model coming in here?
foreach (var imagePath in watermarkOptions.ImagePath)
{
_imagePath = imagePath;
}
foreach (var watermark in watermarkOptions.Watermark)
{
_watermark = watermark;
}
}
public bool IsValid => _imagePath != null && _watermark != null;
public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
{
bool isRemoteUri = Uri.TryCreate(_imagePath, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
if (isRemoteUri)
{
using var client = new HttpClient();
await using Stream imageStream = await client.GetStreamAsync(uriResult, cancellationToken);
_sourceImage = await Image.LoadAsync(imageStream, cancellationToken);
}
else
{
_sourceImage = await Image.LoadAsync(_imagePath!, cancellationToken);
}
int scaledWidth = _sourceImage.Width;
int scaledHeight = _sourceImage.Height;
if (_watermark.Size == WatermarkSize.Scaled)
{
scaledWidth = (int)Math.Round(_watermark.WidthPercent / 100.0 * frameSize.Width);
double aspectRatio = (double)_sourceImage.Height / _sourceImage.Width;
scaledHeight = (int)(scaledWidth * aspectRatio);
}
int horizontalMargin = (int)Math.Round(_watermark.HorizontalMarginPercent / 100.0 * frameSize.Width);
int verticalMargin = (int)Math.Round(_watermark.VerticalMarginPercent / 100.0 * frameSize.Height);
_location = CalculatePosition(
_watermark.Location,
frameSize.Width,
frameSize.Height,
scaledWidth,
scaledHeight,
horizontalMargin,
verticalMargin);
float opacity = _watermark.Opacity / 100.0f;
_animatedDurationSeconds = 0;
for (int i = 0; i < _sourceImage.Frames.Count; i++)
{
var frame = _sourceImage.Frames.CloneFrame(i);
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight).Opacity(opacity));
_scaledFrames.Add(frame);
var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0;
_animatedDurationSeconds += frameDelay;
_frameDelays.Add(frameDelay);
}
}
public void Draw(object context, TimeSpan timestamp)
{
if (context is not IImageProcessingContext imageProcessingContext)
{
return;
}
Image frameForTimestamp = GetFrameForTimestamp(timestamp);
// scaled frames already have opacity set
imageProcessingContext.DrawImage(frameForTimestamp, _location, 1f);
}
private Image GetFrameForTimestamp(TimeSpan timestamp)
{
if (_scaledFrames.Count <= 1)
{
return _scaledFrames[0];
}
double currentTime = timestamp.TotalSeconds % _animatedDurationSeconds;
double frameTime = 0;
for (int i = 0; i < _sourceImage.Frames.Count; i++)
{
frameTime += _frameDelays[i];
if (currentTime <= frameTime)
{
return _scaledFrames[i];
}
}
return _scaledFrames.Last();
}
private static Point CalculatePosition(
WatermarkLocation location,
int frameWidth,
int frameHeight,
int scaledWidth,
int scaledHeight,
int horizontalMargin,
int verticalMargin)
{
// TODO: source content margins
return location switch
{
WatermarkLocation.BottomLeft => new Point(horizontalMargin, frameHeight - scaledHeight - verticalMargin),
WatermarkLocation.TopLeft => new Point(horizontalMargin, verticalMargin),
WatermarkLocation.TopRight => new Point(frameWidth - scaledWidth - horizontalMargin, verticalMargin),
WatermarkLocation.TopMiddle => new Point((frameWidth - scaledWidth) / 2, verticalMargin),
WatermarkLocation.RightMiddle => new Point(
frameWidth - scaledWidth - horizontalMargin,
(frameHeight - scaledHeight) / 2),
WatermarkLocation.BottomMiddle => new Point(
(frameWidth - scaledWidth) / 2,
frameHeight - scaledHeight - verticalMargin),
WatermarkLocation.LeftMiddle => new Point(horizontalMargin, (frameHeight - scaledHeight) / 2),
_ => new Point(
frameWidth - scaledWidth - horizontalMargin,
frameHeight - scaledHeight - verticalMargin),
};
}
public void Dispose()
{
GC.SuppressFinalize(this);
_sourceImage?.Dispose();
_scaledFrames?.ForEach(f => f.Dispose());
}
}

8
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -350,7 +350,7 @@ public class TranscodingTests @@ -350,7 +350,7 @@ public class TranscodingTests
DateTimeOffset now = DateTimeOffset.Now;
Command process = await service.ForPlayoutItem(
PlayoutItemResult playoutItemResult = await service.ForPlayoutItem(
ExecutableName("ffmpeg"),
ExecutableName("ffprobe"),
false,
@ -387,7 +387,7 @@ public class TranscodingTests @@ -387,7 +387,7 @@ public class TranscodingTests
// Console.WriteLine($"ffmpeg arguments {process.Arguments}");
await TranscodeAndVerify(
process,
playoutItemResult.Process,
profileResolution,
profileBitDepth,
profileVideoFormat,
@ -614,7 +614,7 @@ public class TranscodingTests @@ -614,7 +614,7 @@ public class TranscodingTests
FFmpegLibraryProcessService service = GetService();
Command process = await service.ForPlayoutItem(
PlayoutItemResult playoutItemResult = await service.ForPlayoutItem(
ExecutableName("ffmpeg"),
ExecutableName("ffprobe"),
false,
@ -665,7 +665,7 @@ public class TranscodingTests @@ -665,7 +665,7 @@ public class TranscodingTests
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
await TranscodeAndVerify(
process,
playoutItemResult.Process,
profileResolution,
profileBitDepth,
profileVideoFormat,

1
ErsatzTV/Startup.cs

@ -713,6 +713,7 @@ public class Startup @@ -713,6 +713,7 @@ public class Startup
services.AddScoped<IMultiEpisodeShuffleCollectionEnumeratorFactory,
MultiEpisodeShuffleCollectionEnumeratorFactory>();
services.AddScoped<IChannelLogoGenerator, ChannelLogoGenerator>();
services.AddScoped<IGraphicsEngine, GraphicsEngine>();
services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>();
services.AddScoped<IPipelineBuilderFactory, PipelineBuilderFactory>();

Loading…
Cancel
Save