Browse Source

refactor watermark selection (#2328)

* move watermark options into watermark selector

* fix graphics engine overlay performance

* more refactoring

* add some tests for existing watermark selector behavior

* remove extra ffprobe call on all watermarks

* remove a bunch of unused code; add failing tests

* implement new watermark selection

* add tests for new selector

* probably sufficient (though verbose) test coverage

* more tests

* remove some unused code

* simplify watermark selection

* remove old selection code and tests

* more tests
pull/2329/head
Jason Dove 5 months ago committed by GitHub
parent
commit
0318e71745
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 165
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  2. 47
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  3. 23
      ErsatzTV.Core.Tests/FFmpeg/WatermarkCalculatorTests.cs
  4. 352
      ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs
  5. 3
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  6. 37
      ErsatzTV.Core/FFmpeg/DecoSelector.cs
  7. 53
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  8. 35
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  9. 278
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  10. 11
      ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs
  11. 79
      ErsatzTV.Core/FFmpeg/WatermarkCalculator.cs
  12. 10
      ErsatzTV.Core/FFmpeg/WatermarkOptions.cs
  13. 298
      ErsatzTV.Core/FFmpeg/WatermarkSelector.cs
  14. 2
      ErsatzTV.Core/Images/ChannelLogoGenerator.cs
  15. 6
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  16. 2
      ErsatzTV.Core/Interfaces/FFmpeg/ISongVideoGenerator.cs
  17. 18
      ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs
  18. 11
      ErsatzTV.Core/Interfaces/FFmpeg/WatermarkResult.cs
  19. 2
      ErsatzTV.FFmpeg/Filter/OverlayGraphicsEngineFilter.cs
  20. 2
      ErsatzTV.FFmpeg/Filter/Vaapi/OverlayGraphicsEngineVaapiFilter.cs
  21. 3
      ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs
  22. 3
      ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs
  23. 2
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs
  24. 12
      ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs
  25. 80
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  26. 1
      ErsatzTV/Startup.cs

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

@ -36,6 +36,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -36,6 +36,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
private readonly IWatermarkSelector _watermarkSelector;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ISongVideoGenerator _songVideoGenerator;
private readonly ITelevisionRepository _televisionRepository;
@ -53,6 +54,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -53,6 +54,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
IArtistRepository artistRepository,
ISongVideoGenerator songVideoGenerator,
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
IWatermarkSelector watermarkSelector,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(dbContextFactory)
{
@ -67,6 +69,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -67,6 +69,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
_artistRepository = artistRepository;
_songVideoGenerator = songVideoGenerator;
_musicVideoCreditsGenerator = musicVideoCreditsGenerator;
_watermarkSelector = watermarkSelector;
_logger = logger;
}
@ -250,32 +253,17 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -250,32 +253,17 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.BindT(watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
List<ChannelWatermark> playoutItemWatermarks = [];
playoutItemWatermarks.AddRange(playoutItemWithPath.PlayoutItem.Watermarks);
bool disableWatermarks = playoutItemWithPath.PlayoutItem.DisableWatermarks;
WatermarkResult watermarkResult = GetPlayoutItemWatermark(playoutItemWithPath.PlayoutItem, now);
switch (watermarkResult)
{
case InheritWatermark:
// do nothing, other code will fall back to channel/global
break;
case DisableWatermark:
disableWatermarks = true;
break;
case CustomWatermarks watermarks:
playoutItemWatermarks.Clear();
playoutItemWatermarks.AddRange(watermarks.Watermarks);
break;
}
List<WatermarkOptions> watermarks = _watermarkSelector.SelectWatermarks(
maybeGlobalWatermark,
channel,
playoutItemWithPath.PlayoutItem,
now);
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
{
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
playoutItemWatermarks.HeadOrNone(),
maybeGlobalWatermark,
ffmpegPath,
ffprobePath,
cancellationToken);
@ -288,21 +276,26 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -288,21 +276,26 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
bool is43 = Math.Abs(ratio - 4.0 / 3.0) < 0.01;
string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png";
disableWatermarks = false;
playoutItemWatermarks.Clear();
playoutItemWatermarks.Add(
new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
Size = WatermarkSize.Scaled,
WidthPercent = 100,
HorizontalMarginPercent = 0,
VerticalMarginPercent = 0,
Opacity = 100,
Location = WatermarkLocation.TopLeft,
ImageSource = ChannelWatermarkImageSource.Resource,
Image = image
});
var progressWatermark = new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
Size = WatermarkSize.Scaled,
WidthPercent = 100,
HorizontalMarginPercent = 0,
VerticalMarginPercent = 0,
Opacity = 100,
Location = WatermarkLocation.TopLeft,
ImageSource = ChannelWatermarkImageSource.Resource,
Image = image
};
var progressWatermarkOption = new WatermarkOptions(
progressWatermark,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, progressWatermark.Image),
Option<int>.None);
watermarks.Clear();
watermarks.Add(progressWatermarkOption);
}
}
@ -348,8 +341,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -348,8 +341,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
start,
finish,
effectiveNow,
playoutItemWatermarks,
maybeGlobalWatermark,
watermarks,
playoutItemWithPath.PlayoutItem.PlayoutItemGraphicsElements,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
@ -365,7 +357,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -365,7 +357,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
request.ChannelStartTime,
request.PtsOffset,
request.TargetFramerate,
disableWatermarks,
Option<string>.None,
_ => { });
@ -754,68 +745,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -754,68 +745,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
};
}
private WatermarkResult GetPlayoutItemWatermark(PlayoutItem playoutItem, DateTimeOffset now)
{
if (playoutItem.DisableWatermarks)
{
_logger.LogDebug("Watermark is disabled by playout item");
return new DisableWatermark();
}
DecoEntries decoEntries = GetDecoEntries(playoutItem.Playout, now);
// first, check deco template / active deco
foreach (Deco templateDeco in decoEntries.TemplateDeco)
{
switch (templateDeco.WatermarkMode)
{
case DecoMode.Override:
if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller)
{
_logger.LogDebug("Watermark will come from template deco (override)");
return new CustomWatermarks(templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark).ToList());
}
_logger.LogDebug("Watermark is disabled by template deco during filler");
return new DisableWatermark();
case DecoMode.Disable:
_logger.LogDebug("Watermark is disabled by template deco");
return new DisableWatermark();
case DecoMode.Inherit:
_logger.LogDebug("Watermark will inherit from playout deco");
break;
}
}
// second, check playout deco
foreach (Deco playoutDeco in decoEntries.PlayoutDeco)
{
switch (playoutDeco.WatermarkMode)
{
case DecoMode.Override:
if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller)
{
_logger.LogDebug("Watermark will come from playout deco (override)");
return new CustomWatermarks(playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark).ToList());
}
_logger.LogDebug("Watermark is disabled by playout deco during filler");
return new DisableWatermark();
case DecoMode.Disable:
_logger.LogDebug("Watermark is disabled by playout deco");
return new DisableWatermark();
case DecoMode.Inherit:
_logger.LogDebug("Watermark will inherit from channel and/or global setting");
break;
}
}
return new InheritWatermark();
}
private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now)
{
DecoEntries decoEntries = GetDecoEntries(playout, now);
DecoEntries decoEntries = DecoSelector.GetDecoEntries(playout, now);
// first, check deco template / active deco
foreach (Deco templateDeco in decoEntries.TemplateDeco)
@ -864,43 +796,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -864,43 +796,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return new InheritDeadAirFallback();
}
private static DecoEntries GetDecoEntries(Playout playout, DateTimeOffset now)
{
if (playout is null)
{
return new DecoEntries(Option<Deco>.None, Option<Deco>.None);
}
Option<Deco> maybePlayoutDeco = Optional(playout.Deco);
Option<Deco> maybeTemplateDeco = Option<Deco>.None;
Option<PlayoutTemplate> maybeActiveTemplate =
PlayoutTemplateSelector.GetPlayoutTemplateFor(playout.Templates, now);
foreach (PlayoutTemplate activeTemplate in maybeActiveTemplate)
{
Option<DecoTemplateItem> maybeItem = Optional(activeTemplate.DecoTemplate)
.SelectMany(dt => dt.Items)
.Find(i => i.StartTime <= now.TimeOfDay && i.EndTime == TimeSpan.Zero || i.EndTime > now.TimeOfDay);
foreach (DecoTemplateItem item in maybeItem)
{
maybeTemplateDeco = Optional(item.Deco);
}
}
return new DecoEntries(maybeTemplateDeco, maybePlayoutDeco);
}
private sealed record DecoEntries(Option<Deco> TemplateDeco, Option<Deco> PlayoutDeco);
private abstract record WatermarkResult;
private sealed record InheritWatermark : WatermarkResult;
private sealed record DisableWatermark : WatermarkResult;
private sealed record CustomWatermarks(List<ChannelWatermark> Watermarks) : WatermarkResult;
private abstract record DeadAirFallbackResult;
private sealed record InheritDeadAirFallback : DeadAirFallbackResult;

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

@ -29,6 +29,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -29,6 +29,7 @@ public class PrepareTroubleshootingPlaybackHandler(
IFFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem,
ISongVideoGenerator songVideoGenerator,
IWatermarkSelector watermarkSelector,
IEntityLocker entityLocker,
IMediator mediator,
ILogger<PrepareTroubleshootingPlaybackHandler> logger)
@ -95,16 +96,22 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -95,16 +96,22 @@ public class PrepareTroubleshootingPlaybackHandler(
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting,
SubtitleMode = SUBTITLE_MODE
//SongVideoMode = ChannelSongVideoMode.WithProgress
};
List<ChannelWatermark> watermarks = [];
List<WatermarkOptions> watermarks = [];
if (request.WatermarkIds.Count > 0)
{
List<ChannelWatermark> channelWatermarks = await dbContext.ChannelWatermarks
.AsNoTracking()
.Where(w => request.WatermarkIds.Contains(w.Id))
.ToListAsync();
watermarks.AddRange(channelWatermarks);
foreach (var watermark in channelWatermarks)
{
watermarks.AddRange(
watermarkSelector.GetWatermarkOptions(channel, watermark, Option<ChannelWatermark>.None));
}
}
string videoPath = mediaPath;
@ -115,8 +122,6 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -115,8 +122,6 @@ public class PrepareTroubleshootingPlaybackHandler(
(videoPath, videoVersion) = await songVideoGenerator.GenerateSongVideo(
song,
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None,
ffmpegPath,
ffprobePath,
CancellationToken.None);
@ -129,20 +134,26 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -129,20 +134,26 @@ public class PrepareTroubleshootingPlaybackHandler(
bool is43 = Math.Abs(ratio - 4.0 / 3.0) < 0.01;
string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png";
var progressWatermark = new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
Size = WatermarkSize.Scaled,
WidthPercent = 100,
HorizontalMarginPercent = 0,
VerticalMarginPercent = 0,
Opacity = 100,
Location = WatermarkLocation.TopLeft,
ImageSource = ChannelWatermarkImageSource.Resource,
Image = image
};
var progressWatermarkOption = new WatermarkOptions(
progressWatermark,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, progressWatermark.Image),
Option<int>.None);
watermarks.Clear();
watermarks.Add(
new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
Size = WatermarkSize.Scaled,
WidthPercent = 100,
HorizontalMarginPercent = 0,
VerticalMarginPercent = 0,
Opacity = 100,
Location = WatermarkLocation.TopLeft,
ImageSource = ChannelWatermarkImageSource.Resource,
Image = image
});
watermarks.Add(progressWatermarkOption);
}
}
@ -200,7 +211,6 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -200,7 +211,6 @@ public class PrepareTroubleshootingPlaybackHandler(
now + duration,
now,
watermarks,
Option<ChannelWatermark>.None,
graphicsElements.Map(ge => new PlayoutItemGraphicsElement { GraphicsElement = ge }).ToList(),
ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver,
@ -214,7 +224,6 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -214,7 +224,6 @@ public class PrepareTroubleshootingPlaybackHandler(
channelStartTime: DateTimeOffset.Now,
0,
None,
false,
FileSystemLayout.TranscodeTroubleshootingFolder,
_ => { });

23
ErsatzTV.Core.Tests/FFmpeg/WatermarkCalculatorTests.cs

@ -1,23 +0,0 @@ @@ -1,23 +0,0 @@
using ErsatzTV.Core.FFmpeg;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class WatermarkCalculatorTests
{
[Test]
public void EntireVideoBetweenWatermarks_ShouldReturn_EmptyFadePointList()
{
List<FadePoint> actual = WatermarkCalculator.CalculateFadePoints(
new DateTimeOffset(2022, 01, 31, 13, 34, 00, TimeSpan.FromHours(-5)),
TimeSpan.Zero,
TimeSpan.FromMinutes(5),
None,
15,
10);
actual.Count.ShouldBe(0);
}
}

352
ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs

@ -0,0 +1,352 @@ @@ -0,0 +1,352 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Serilog;
using Shouldly;
namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class WatermarkSelectorTests
{
private static readonly WatermarkSelector WatermarkSelector;
private static readonly Option<ChannelWatermark> WatermarkNone;
private static readonly ChannelWatermark WatermarkGlobal;
private static readonly ChannelWatermark WatermarkChannel;
private static readonly ChannelWatermark WatermarkPlayoutItem;
private static readonly ChannelWatermark WatermarkTemplateDeco;
private static readonly ChannelWatermark WatermarkDefaultDeco;
private static readonly Channel ChannelWithWatermark;
private static readonly Channel ChannelNoWatermark;
private static readonly DateTimeOffset Now = new(2025, 08, 17, 12, 0, 0, TimeSpan.FromHours(-5));
private static readonly PlayoutItem PlayoutItemDisableWatermarks =
new() { Watermarks = [], DisableWatermarks = true };
private static readonly PlayoutItem PlayoutItemWithNoWatermarks =
new() { Watermarks = [], DisableWatermarks = false };
private static readonly PlayoutItem PlayoutItemWithWatermark;
private static readonly PlayoutItem PlayoutItemWithDisabledWatermark;
private static readonly PlayoutItem TemplateDecoInherit;
private static readonly PlayoutItem TemplateDecoDisable;
private static readonly PlayoutItem TemplateDecoInheritWithWatermark;
private static readonly PlayoutItem TemplateDecoDisableWithWatermark;
private static readonly PlayoutItem TemplateDecoOverrideWithWatermark;
private static readonly PlayoutItem TemplateDecoInheritDefaultDecoOverrideWithWatermark;
private static readonly PlayoutItem DefaultDecoInherit;
private static readonly PlayoutItem DefaultDecoDisable;
private static readonly PlayoutItem DefaultDecoInheritWithWatermark;
private static readonly PlayoutItem DefaultDecoDisableWithWatermark;
private static readonly PlayoutItem DefaultDecoOverrideWithWatermark;
private static readonly List<ChannelWatermark> WatermarkResultEmpty = [];
static WatermarkSelectorTests()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();
var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
WatermarkSelector = new WatermarkSelector(
Substitute.For<IImageCache>(),
loggerFactory.CreateLogger<WatermarkSelector>());
WatermarkNone = Option<ChannelWatermark>.None;
WatermarkGlobal = new ChannelWatermark { Id = 0, Name = "Global", Image = "GlobalImage" };
WatermarkChannel = new ChannelWatermark { Id = 1, Name = "Channel", Image = "ChannelImage" };
WatermarkPlayoutItem = new ChannelWatermark { Id = 2, Name = "PlayoutItem", Image = "PlayoutItemImage" };
WatermarkTemplateDeco = new ChannelWatermark { Id = 3, Name = "TemplateDeco", Image = "TemplateDecoImage" };
WatermarkDefaultDeco = new ChannelWatermark { Id = 4, Name = "DefaultDeco", Image = "DefaultDecoImage" };
ChannelWithWatermark = new Channel(Guid.Empty)
{ Id = 0, Watermark = WatermarkChannel, WatermarkId = WatermarkChannel.Id };
ChannelNoWatermark = new Channel(Guid.Empty) { Id = 0, Watermark = null, WatermarkId = null };
PlayoutItemWithWatermark = new PlayoutItem { Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false };
PlayoutItemWithDisabledWatermark = new PlayoutItem
{ Watermarks = [WatermarkPlayoutItem], DisableWatermarks = true };
var decoWithInherit = new DecoTemplate
{
Items = [new DecoTemplateItem { Deco = new Deco { WatermarkMode = DecoMode.Inherit } }]
};
var decoWithDisable = new DecoTemplate
{
Items = [new DecoTemplateItem { Deco = new Deco { WatermarkMode = DecoMode.Disable } }]
};
var decoWithOverride = new DecoTemplate
{
Items =
[
new DecoTemplateItem
{
Deco = new Deco
{
WatermarkMode = DecoMode.Override,
DecoWatermarks = [new DecoWatermark { Watermark = WatermarkTemplateDeco }]
}
}
]
};
var playoutWithTemplateDecoInherit = new Playout
{
Templates =
[
new PlayoutTemplate
{
DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(),
DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(),
MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(),
DecoTemplate = decoWithInherit
}
]
};
var playoutWithTemplateDecoDisable = new Playout
{
Templates =
[
new PlayoutTemplate
{
DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(),
DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(),
MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(),
DecoTemplate = decoWithDisable
}
]
};
var playoutWithTemplateDecoOverride = new Playout
{
Templates =
[
new PlayoutTemplate
{
DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(),
DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(),
MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(),
DecoTemplate = decoWithOverride
}
]
};
TemplateDecoInherit = new PlayoutItem { Playout = playoutWithTemplateDecoInherit, Watermarks = [] };
TemplateDecoDisable = new PlayoutItem
{ Watermarks = [], DisableWatermarks = false, Playout = playoutWithTemplateDecoDisable };
TemplateDecoInheritWithWatermark = new PlayoutItem
{
Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithTemplateDecoInherit
};
TemplateDecoDisableWithWatermark = new PlayoutItem
{
Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithTemplateDecoDisable
};
TemplateDecoOverrideWithWatermark = new PlayoutItem
{
Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithTemplateDecoOverride
};
var playoutWithDecoInherit = new Playout
{
Deco = new Deco { WatermarkMode = DecoMode.Inherit },
Templates = []
};
var playoutWithDecoDisable = new Playout
{
Deco = new Deco { WatermarkMode = DecoMode.Disable },
Templates = []
};
var playoutWithDecoOverride = new Playout
{
Deco = new Deco
{
WatermarkMode = DecoMode.Override,
DecoWatermarks = [new DecoWatermark { Watermark = WatermarkDefaultDeco }]
},
Templates = []
};
DefaultDecoInherit = new PlayoutItem { Playout = playoutWithDecoInherit, Watermarks = [] };
DefaultDecoDisable = new PlayoutItem
{ Watermarks = [], DisableWatermarks = false, Playout = playoutWithDecoDisable };
DefaultDecoInheritWithWatermark = new PlayoutItem
{
Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithDecoInherit
};
DefaultDecoDisableWithWatermark = new PlayoutItem
{
Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithDecoDisable
};
DefaultDecoOverrideWithWatermark = new PlayoutItem
{
Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithDecoOverride
};
var playoutWithTemplateDecoInheritDefaultDecoOverride = new Playout
{
Templates =
[
new PlayoutTemplate
{
DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(),
DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(),
MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(),
DecoTemplate = decoWithInherit
}
],
Deco = new Deco
{
WatermarkMode = DecoMode.Override,
DecoWatermarks = [new DecoWatermark { Watermark = WatermarkDefaultDeco }]
},
};
TemplateDecoInheritDefaultDecoOverrideWithWatermark = new PlayoutItem
{
Watermarks = [WatermarkPlayoutItem],
DisableWatermarks = false,
Playout = playoutWithTemplateDecoInheritDefaultDecoOverride
};
}
private static IEnumerable<(Option<ChannelWatermark>, Channel, PlayoutItem, List<ChannelWatermark>)>
SelectWatermarksTestCases()
{
// STANDARD --------------------------------------------
// no watermark when none are configured
yield return (WatermarkNone, ChannelNoWatermark, PlayoutItemWithNoWatermarks, WatermarkResultEmpty);
// no watermark when global configured but disabled playout item
yield return (WatermarkGlobal, ChannelNoWatermark, PlayoutItemDisableWatermarks, WatermarkResultEmpty);
// global watermark when global configured
yield return (WatermarkGlobal, ChannelNoWatermark, PlayoutItemWithNoWatermarks, [WatermarkGlobal]);
// channel watermark when global and channel configured
yield return (WatermarkGlobal, ChannelWithWatermark, PlayoutItemWithNoWatermarks, [WatermarkChannel]);
// channel watermark when channel configured
yield return (WatermarkNone, ChannelWithWatermark, PlayoutItemWithNoWatermarks, [WatermarkChannel]);
// playout item when global, channel and playout item configured
yield return (WatermarkGlobal, ChannelWithWatermark, PlayoutItemWithWatermark, [WatermarkPlayoutItem]);
// playout item when channel and playout item configured
yield return (WatermarkNone, ChannelWithWatermark, PlayoutItemWithWatermark, [WatermarkPlayoutItem]);
// playout item when playout item configured
yield return (WatermarkNone, ChannelNoWatermark, PlayoutItemWithWatermark, [WatermarkPlayoutItem]);
// no watermark when channel configured with playout item disabled
yield return (WatermarkNone, ChannelWithWatermark, PlayoutItemDisableWatermarks, WatermarkResultEmpty);
// no watermark when global and channel configured with playout item disabled
yield return (WatermarkGlobal, ChannelWithWatermark, PlayoutItemDisableWatermarks, WatermarkResultEmpty);
// no watermark when global, channel and playout item configured with playout item disabled
yield return (WatermarkGlobal, ChannelWithWatermark, PlayoutItemWithDisabledWatermark, WatermarkResultEmpty);
// PLAYOUT TEMPLATE DECO -------------------------------
// no watermark when global, channel and playout item configured with template deco disabled
yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoDisableWithWatermark, WatermarkResultEmpty);
// no watermark when global, channel configured with template deco disabled
yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoDisable, WatermarkResultEmpty);
// no watermark when global configured with template deco disabled
yield return (WatermarkGlobal, ChannelNoWatermark, TemplateDecoDisable, WatermarkResultEmpty);
// playout item when global, channel and playout item configured with template deco inherit
yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoInheritWithWatermark, [WatermarkPlayoutItem]);
// channel when global, channel configured with template deco inherit
yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoInherit, [WatermarkChannel]);
// global when global configured with template deco inherit
yield return (WatermarkGlobal, ChannelNoWatermark, TemplateDecoInherit, [WatermarkGlobal]);
// no watermark when none configured with template deco inherit
yield return (WatermarkNone, ChannelNoWatermark, TemplateDecoInherit, WatermarkResultEmpty);
// PLAYOUT DEFAULT DECO --------------------------------
// no watermark when global, channel and playout item configured with default deco disabled
yield return (WatermarkGlobal, ChannelWithWatermark, DefaultDecoDisableWithWatermark, WatermarkResultEmpty);
// no watermark when global, channel configured with default deco disabled
yield return (WatermarkGlobal, ChannelWithWatermark, DefaultDecoDisable, WatermarkResultEmpty);
// no watermark when global configured with default deco disabled
yield return (WatermarkGlobal, ChannelNoWatermark, DefaultDecoDisable, WatermarkResultEmpty);
// playout item when global, channel and playout item configured with default deco inherit
yield return (WatermarkGlobal, ChannelWithWatermark, DefaultDecoInheritWithWatermark, [WatermarkPlayoutItem]);
// channel when global, channel configured with default deco inherit
yield return (WatermarkGlobal, ChannelWithWatermark, DefaultDecoInherit, [WatermarkChannel]);
// global when global configured with default deco inherit
yield return (WatermarkGlobal, ChannelNoWatermark, DefaultDecoInherit, [WatermarkGlobal]);
// no watermark when none configured with default deco inherit
yield return (WatermarkNone, ChannelNoWatermark, DefaultDecoInherit, WatermarkResultEmpty);
// PLAYOUT TEMPLATE AND DEFAULT DECO -------------------
// default deco when global, channel and playout item configured with default deco override, template deco inherit
yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoInheritDefaultDecoOverrideWithWatermark, [WatermarkDefaultDeco]);
}
[TestCaseSource(nameof(SelectWatermarksTestCases))]
public void Should_Select_Appropriate_Watermark(
(Option<ChannelWatermark> globalWatermark,
Channel channel,
PlayoutItem playoutItem,
List<ChannelWatermark> expectedWatermarks) td)
{
List<WatermarkOptions> watermarks = WatermarkSelector.SelectWatermarks(
td.globalWatermark,
td.channel,
td.playoutItem,
Now);
watermarks.Count.ShouldBe(td.expectedWatermarks.Count);
for (var i = 0; i < td.expectedWatermarks.Count; i++)
{
watermarks[i].Watermark.ShouldBe(td.expectedWatermarks[i]);
}
}
// TODO: also decos?
}

3
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -28,6 +28,9 @@ public class ChannelWatermark @@ -28,6 +28,9 @@ public class ChannelWatermark
public List<Deco> Decos { get; set; }
public List<DecoWatermark> DecoWatermarks { get; set; }
public int ZIndex { get; set; }
// for unit testing
public override string ToString() => Name;
}
public enum ChannelWatermarkMode

37
ErsatzTV.Core/FFmpeg/DecoSelector.cs

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.FFmpeg;
public static class DecoSelector
{
public static DecoEntries GetDecoEntries(Playout playout, DateTimeOffset now)
{
if (playout is null)
{
return new DecoEntries(Option<Deco>.None, Option<Deco>.None);
}
Option<Deco> maybePlayoutDeco = Optional(playout.Deco);
Option<Deco> maybeTemplateDeco = Option<Deco>.None;
Option<PlayoutTemplate> maybeActiveTemplate =
PlayoutTemplateSelector.GetPlayoutTemplateFor(playout.Templates, now);
foreach (PlayoutTemplate activeTemplate in maybeActiveTemplate)
{
Option<DecoTemplateItem> maybeItem = Optional(activeTemplate.DecoTemplate)
.SelectMany(dt => dt.Items)
.Find(i => i.StartTime <= now.TimeOfDay && i.EndTime == TimeSpan.Zero || i.EndTime > now.TimeOfDay);
foreach (DecoTemplateItem item in maybeItem)
{
maybeTemplateDeco = Optional(item.Deco);
}
}
return new DecoEntries(maybeTemplateDeco, maybePlayoutDeco);
}
}
public sealed record DecoEntries(Option<Deco> TemplateDeco, Option<Deco> PlayoutDeco);

53
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -64,8 +64,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -64,8 +64,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark,
List<WatermarkOptions> watermarks,
List<PlayoutItemGraphicsElement> graphicsElements,
string vaapiDisplay,
VaapiDriver vaapiDriver,
@ -79,7 +78,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -79,7 +78,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset channelStartTime,
long ptsOffset,
Option<int> targetFramerate,
bool disableWatermarks,
Option<string> customReportsFolder,
Action<FFmpegPipeline> pipelineAction)
{
@ -330,50 +328,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -330,50 +328,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
List<GraphicsElementContext> graphicsElementContexts = [];
// use graphics engine for all watermarks
if (!disableWatermarks)
{
var watermarks = new Dictionary<int, WatermarkElementContext>();
// still need channel and global watermarks
if (playoutItemWatermarks.Count == 0)
{
WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
Option<ChannelWatermark>.None,
globalWatermark,
videoVersion,
None,
None);
foreach (ChannelWatermark watermark in options.Watermark)
{
// don't allow duplicates
watermarks.TryAdd(watermark.Id, new WatermarkElementContext(options));
}
}
// load all playout item watermarks
foreach (ChannelWatermark playoutItemWatermark in playoutItemWatermarks)
{
WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
None,
None);
foreach (ChannelWatermark watermark in options.Watermark)
{
// don't allow duplicates
watermarks.TryAdd(watermark.Id, new WatermarkElementContext(options));
}
}
graphicsElementContexts.AddRange(watermarks.Values);
}
graphicsElementContexts.AddRange(watermarks.Map(wm => new WatermarkElementContext(wm)));
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind);
@ -1047,8 +1002,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1047,8 +1002,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
string ffprobePath,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath,
bool boxBlur,
@ -1063,8 +1016,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1063,8 +1016,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
ffprobePath,
subtitleFile,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
videoPath,
boxBlur,

35
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -48,8 +48,7 @@ internal class FFmpegProcessBuilder @@ -48,8 +48,7 @@ internal class FFmpegProcessBuilder
Option<List<FadePoint>> maybeFadePoints,
IDisplaySize resolution)
{
ChannelWatermarkMode maybeWatermarkMode = watermarkOptions.Map(wmo => wmo.Watermark.Map(wm => wm.Mode))
.Flatten()
ChannelWatermarkMode maybeWatermarkMode = watermarkOptions.Map(wmo => wmo.Watermark.Mode)
.IfNone(ChannelWatermarkMode.None);
// skip watermark if intermittent and no fade points
@ -59,30 +58,14 @@ internal class FFmpegProcessBuilder @@ -59,30 +58,14 @@ internal class FFmpegProcessBuilder
{
foreach (WatermarkOptions options in watermarkOptions)
{
foreach (string path in options.ImagePath)
{
if (options.IsAnimated)
{
_arguments.Add("-ignore_loop");
_arguments.Add("0");
}
// when we have fade points, we need to loop the static watermark image
else if (maybeFadePoints.Map(fp => fp.Count).IfNone(0) > 0)
{
_arguments.Add("-stream_loop");
_arguments.Add("-1");
}
_arguments.Add("-i");
_arguments.Add(path);
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(
options.Watermark,
maybeFadePoints,
resolution,
options.ImageStreamIndex);
}
_arguments.Add("-i");
_arguments.Add(options.ImagePath);
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(
options.Watermark,
maybeFadePoints,
resolution,
options.ImageStreamIndex);
}
}

278
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -1,15 +1,10 @@ @@ -1,15 +1,10 @@
using System.Diagnostics;
using System.Text;
using Bugsnag;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
@ -19,24 +14,18 @@ public class FFmpegProcessService @@ -19,24 +14,18 @@ public class FFmpegProcessService
{
private readonly IClient _client;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache;
private readonly ILogger<FFmpegProcessService> _logger;
private readonly IMemoryCache _memoryCache;
private readonly ITempFilePool _tempFilePool;
public FFmpegProcessService(
IFFmpegStreamSelector ffmpegStreamSelector,
IImageCache imageCache,
ITempFilePool tempFilePool,
IClient client,
IMemoryCache memoryCache,
ILogger<FFmpegProcessService> logger)
{
_ffmpegStreamSelector = ffmpegStreamSelector;
_imageCache = imageCache;
_tempFilePool = tempFilePool;
_client = client;
_memoryCache = memoryCache;
_logger = logger;
}
@ -45,8 +34,6 @@ public class FFmpegProcessService @@ -45,8 +34,6 @@ public class FFmpegProcessService
string ffprobePath,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath,
bool boxBlur,
@ -63,29 +50,25 @@ public class FFmpegProcessService @@ -63,29 +50,25 @@ public class FFmpegProcessService
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion);
Option<ChannelWatermark> watermarkOverride =
videoVersion is FallbackMediaVersion or CoverArtMediaVersion
? new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
HorizontalMarginPercent = horizontalMarginPercent,
VerticalMarginPercent = verticalMarginPercent,
Location = watermarkLocation,
Size = WatermarkSize.Scaled,
WidthPercent = watermarkWidthPercent,
Opacity = 100
}
: None;
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
watermarkOverride,
watermarkPath);
Option<WatermarkOptions> watermarkOptions = Option<WatermarkOptions>.None;
if (videoVersion is FallbackMediaVersion or CoverArtMediaVersion)
{
var songWatermark = new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
HorizontalMarginPercent = horizontalMarginPercent,
VerticalMarginPercent = verticalMarginPercent,
Location = watermarkLocation,
Size = WatermarkSize.Scaled,
WidthPercent = watermarkWidthPercent,
Opacity = 100
};
watermarkOptions = new WatermarkOptions(
songWatermark,
await watermarkPath.IfNoneAsync(videoVersion.MediaFiles.Head().Path),
0);
}
FFmpegPlaybackSettings playbackSettings =
FFmpegPlaybackSettingsCalculator.CalculateErrorSettings(
@ -156,227 +139,4 @@ public class FFmpegProcessService @@ -156,227 +139,4 @@ public class FFmpegProcessService
private static bool NeedToPad(Resolution target, IDisplaySize displaySize) =>
displaySize.Width != target.Width || displaySize.Height != target.Height;
internal async Task<WatermarkOptions> GetWatermarkOptions(
string ffprobePath,
Channel channel,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
Option<ChannelWatermark> watermarkOverride,
Option<string> watermarkPath)
{
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
{
if (videoVersion is CoverArtMediaVersion)
{
return new WatermarkOptions(
watermarkOverride,
await watermarkPath.IfNoneAsync(videoVersion.MediaFiles.Head().Path),
0,
false);
}
// check for playout item watermark
foreach (ChannelWatermark watermark in playoutItemWatermark)
{
switch (watermark.ImageSource)
{
// used for song progress overlay
case ChannelWatermarkImageSource.Resource:
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image),
Option<int>.None,
false);
case ChannelWatermarkImageSource.Custom:
// bad form validation makes this possible
if (string.IsNullOrWhiteSpace(watermark.Image))
{
_logger.LogWarning(
"Watermark {Name} has custom image configured with no image; ignoring",
watermark.Name);
break;
}
_logger.LogDebug("Watermark will come from playout item (custom)");
string customPath = _imageCache.GetPathForImage(
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
customPath,
None,
await IsAnimated(ffprobePath, customPath));
case ChannelWatermarkImageSource.ChannelLogo:
_logger.LogDebug("Watermark will come from playout item (channel logo)");
Option<string> maybeChannelPath = channel.Artwork.Count == 0
?
//We have to generate the logo on the fly and save it to a local temp path
ChannelLogoGenerator.GenerateChannelLogoUrl(channel)
:
//We have an artwork attached to the channel, let's use it :)
channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => Artwork.IsExternalUrl(a.Path)
? a.Path
: _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => IsAnimated(ffprobePath, p),
() => Task.FromResult(false)));
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
// check for channel watermark
if (channel.Watermark != null)
{
switch (channel.Watermark.ImageSource)
{
case ChannelWatermarkImageSource.Custom:
_logger.LogDebug("Watermark will come from channel (custom)");
string customPath = _imageCache.GetPathForImage(
channel.Watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(channel.Watermark),
customPath,
None,
await IsAnimated(ffprobePath, customPath));
case ChannelWatermarkImageSource.ChannelLogo:
_logger.LogDebug("Watermark will come from channel (channel logo)");
Option<string> maybeChannelPath = channel.Artwork.Count == 0
?
//We have to generate the logo on the fly and save it to a local temp path
ChannelLogoGenerator.GenerateChannelLogoUrl(channel)
:
//We have an artwork attached to the channel, let's use it :)
channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => Artwork.IsExternalUrl(a.Path)
? a.Path
: _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(channel.Watermark),
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => IsAnimated(ffprobePath, p),
() => Task.FromResult(false)));
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
// check for global watermark
foreach (ChannelWatermark watermark in globalWatermark)
{
switch (watermark.ImageSource)
{
case ChannelWatermarkImageSource.Custom:
_logger.LogDebug("Watermark will come from global (custom)");
string customPath = _imageCache.GetPathForImage(
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
customPath,
None,
await IsAnimated(ffprobePath, customPath));
case ChannelWatermarkImageSource.ChannelLogo:
_logger.LogDebug("Watermark will come from global (channel logo)");
Option<string> maybeChannelPath = channel.Artwork.Count == 0
?
//We have to generate the logo on the fly and save it to a local temp path
ChannelLogoGenerator.GenerateChannelLogoUrl(channel)
:
//We have an artwork attached to the channel, let's use it :)
channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => Artwork.IsExternalUrl(a.Path)
? a.Path
: _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => IsAnimated(ffprobePath, p),
() => Task.FromResult(false)));
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
}
return new WatermarkOptions(None, None, None, false);
}
private async Task<bool> IsAnimated(string ffprobePath, string path)
{
try
{
var cacheKey = $"image.animated.{Path.GetFileName(path)}";
if (_memoryCache.TryGetValue(cacheKey, out bool animated))
{
return animated;
}
BufferedCommandResult result = await Cli.Wrap(ffprobePath)
.WithArguments(
[
"-loglevel", "error",
"-select_streams", "v:0",
"-count_frames",
"-show_entries", "stream=nb_read_frames",
"-print_format", "csv",
path
])
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8);
if (result.ExitCode == 0)
{
string output = result.StandardOutput;
output = output.Replace("stream,", string.Empty);
if (int.TryParse(output, out int frameCount))
{
bool isAnimated = frameCount > 1;
_memoryCache.Set(cacheKey, isAnimated, TimeSpan.FromDays(1));
return isAnimated;
}
}
else
{
_logger.LogWarning(
"Error checking frame count for file {File}l exit code {ExitCode}",
path,
result.ExitCode);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error checking frame count for file {File}", path);
}
return false;
}
}

11
ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs

@ -10,7 +10,7 @@ namespace ErsatzTV.Core.FFmpeg; @@ -10,7 +10,7 @@ namespace ErsatzTV.Core.FFmpeg;
public class SongVideoGenerator : ISongVideoGenerator
{
private static readonly Random Random = new();
private static readonly object RandomLock = new();
private static readonly Lock RandomLock = new();
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IImageCache _imageCache;
@ -29,8 +29,6 @@ public class SongVideoGenerator : ISongVideoGenerator @@ -29,8 +29,6 @@ public class SongVideoGenerator : ISongVideoGenerator
public async Task<Tuple<string, MediaVersion>> GenerateSongVideo(
Song song,
Channel channel,
Option<ChannelWatermark> maybePlayoutItemWatermark,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
@ -219,18 +217,13 @@ public class SongVideoGenerator : ISongVideoGenerator @@ -219,18 +217,13 @@ public class SongVideoGenerator : ISongVideoGenerator
string videoPath = backgroundPath;
videoVersion.MediaFiles = new List<MediaFile>
{
new() { Path = videoPath }
};
videoVersion.MediaFiles = [new MediaFile { Path = videoPath }];
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
ffprobePath,
subtitleFile,
channel,
maybePlayoutItemWatermark,
maybeGlobalWatermark,
videoVersion,
videoPath,
boxBlur,

79
ErsatzTV.Core/FFmpeg/WatermarkCalculator.cs

@ -1,79 +0,0 @@ @@ -1,79 +0,0 @@
namespace ErsatzTV.Core.FFmpeg;
public static class WatermarkCalculator
{
public static List<FadePoint> CalculateFadePoints(
DateTimeOffset itemStartTime,
TimeSpan inPoint,
TimeSpan outPoint,
Option<TimeSpan> streamSeek,
int frequencyMinutes,
int durationSeconds)
{
var result = new List<FadePoint>();
TimeSpan duration = outPoint - inPoint;
DateTimeOffset itemFinishTime = itemStartTime + duration;
DateTimeOffset start = itemStartTime.AddMinutes(-16);
// find the next whole minute
if (start.Second > 0 || start.Millisecond > 0)
{
start = start.AddMinutes(1);
start = start.AddSeconds(-start.Second);
start = start.AddMilliseconds(-start.Millisecond);
}
DateTimeOffset finish = itemFinishTime;
// find the previous whole minute
if (finish.Second > 0 || finish.Millisecond > 0)
{
finish = finish.AddSeconds(-finish.Second);
finish = finish.AddMilliseconds(-finish.Millisecond);
}
DateTimeOffset current = start;
while (current <= finish)
{
current = current.AddMinutes(1);
if (current.Minute % frequencyMinutes == 0)
{
TimeSpan fadeInTime = inPoint + (current - itemStartTime);
result.Add(new FadeInPoint(fadeInTime));
result.Add(new FadeOutPoint(fadeInTime.Add(TimeSpan.FromSeconds(durationSeconds))));
}
}
// if we're seeking, subtract the seek from each item and return that
foreach (TimeSpan ss in streamSeek)
{
result = result.Map(fp => fp with { Time = fp.Time - ss }).ToList();
}
// trim points that have already passed
result.RemoveAll(fp => fp.Time < TimeSpan.Zero);
// trim points that are past the end
result.RemoveAll(fp => fp.Time >= outPoint);
if (result.Count != 0)
{
for (var i = 0; i < result.Count; i++)
{
result[i].EnableStart = i == 0 ? TimeSpan.Zero : result[i - 1].Time.Add(TimeSpan.FromSeconds(1));
}
for (var i = 0; i < result.Count; i++)
{
result[i].EnableFinish = i == result.Count - 1
? outPoint
: result[i + 1].Time.Subtract(TimeSpan.FromSeconds(1));
}
}
return result;
}
}

10
ErsatzTV.Core/FFmpeg/WatermarkOptions.cs

@ -3,7 +3,9 @@ @@ -3,7 +3,9 @@
namespace ErsatzTV.Core.FFmpeg;
public record WatermarkOptions(
Option<ChannelWatermark> Watermark,
Option<string> ImagePath,
Option<int> ImageStreamIndex,
bool IsAnimated);
ChannelWatermark Watermark,
string ImagePath,
Option<int> ImageStreamIndex)
{
public static WatermarkOptions NoWatermark => new(null, null, None);
}

298
ErsatzTV.Core/FFmpeg/WatermarkSelector.cs

@ -0,0 +1,298 @@ @@ -0,0 +1,298 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg;
public class WatermarkSelector(IImageCache imageCache, ILogger<WatermarkSelector> logger)
: IWatermarkSelector
{
public List<WatermarkOptions> SelectWatermarks(
Option<ChannelWatermark> globalWatermark,
Channel channel,
PlayoutItem playoutItem,
DateTimeOffset now)
{
var result = new List<WatermarkOptions>();
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect)
{
return result;
}
if (playoutItem.DisableWatermarks)
{
logger.LogDebug("Watermark is disabled by playout item");
return result;
}
DecoEntries decoEntries = DecoSelector.GetDecoEntries(playoutItem.Playout, now);
// first, check deco template / active deco
foreach (Deco templateDeco in decoEntries.TemplateDeco)
{
switch (templateDeco.WatermarkMode)
{
case DecoMode.Override:
if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller)
{
logger.LogDebug("Watermark will come from template deco (override)");
result.AddRange(
OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark)));
return result;
}
logger.LogDebug("Watermark is disabled by template deco during filler");
return result;
case DecoMode.Disable:
logger.LogDebug("Watermark is disabled by template deco");
return result;
case DecoMode.Inherit:
logger.LogDebug("Watermark will inherit from playout deco");
break;
}
}
// second, check playout deco
foreach (Deco playoutDeco in decoEntries.PlayoutDeco)
{
switch (playoutDeco.WatermarkMode)
{
case DecoMode.Override:
if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller)
{
logger.LogDebug("Watermark will come from playout deco (override)");
result.AddRange(
OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark)));
return result;
}
logger.LogDebug("Watermark is disabled by playout deco during filler");
return result;
case DecoMode.Disable:
logger.LogDebug("Watermark is disabled by playout deco");
return result;
case DecoMode.Inherit:
logger.LogDebug("Watermark will inherit from channel and/or global setting");
break;
}
}
if (playoutItem.Watermarks.Count > 0)
{
foreach (var watermark in playoutItem.Watermarks)
{
var options = GetWatermarkOptions(channel, watermark, Option<ChannelWatermark>.None);
result.AddRange(options);
}
return result;
}
var finalOptions = GetWatermarkOptions(channel, Option<ChannelWatermark>.None, globalWatermark);
result.AddRange(finalOptions);
return result;
}
public Option<WatermarkOptions> GetWatermarkOptions(
Channel channel,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark)
{
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect)
{
return Option<WatermarkOptions>.None;
}
// check for playout item watermark
foreach (ChannelWatermark watermark in playoutItemWatermark)
{
switch (watermark.ImageSource)
{
// used for song progress overlay
case ChannelWatermarkImageSource.Resource:
return new WatermarkOptions(
watermark,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image),
Option<int>.None);
case ChannelWatermarkImageSource.Custom:
// bad form validation makes this possible
if (string.IsNullOrWhiteSpace(watermark.Image))
{
logger.LogWarning(
"Watermark {Name} has custom image configured with no image; ignoring",
watermark.Name);
break;
}
logger.LogDebug("Watermark will come from playout item (custom)");
string customPath = imageCache.GetPathForImage(
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
watermark,
customPath,
None);
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from playout item (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork =
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
foreach (var logoArtwork in maybeLogoArtwork)
{
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
? logoArtwork.Path
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(watermark, channelPath, None);
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
// check for channel watermark
if (channel.Watermark != null)
{
switch (channel.Watermark.ImageSource)
{
case ChannelWatermarkImageSource.Custom:
logger.LogDebug("Watermark will come from channel (custom)");
string customPath = imageCache.GetPathForImage(
channel.Watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
channel.Watermark,
customPath,
None);
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from channel (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork =
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
foreach (var logoArtwork in maybeLogoArtwork)
{
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
? logoArtwork.Path
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(channel.Watermark, channelPath, None);
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
// check for global watermark
foreach (ChannelWatermark watermark in globalWatermark)
{
switch (watermark.ImageSource)
{
case ChannelWatermarkImageSource.Custom:
logger.LogDebug("Watermark will come from global (custom)");
string customPath = imageCache.GetPathForImage(
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
watermark,
customPath,
None);
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from global (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork =
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
foreach (var logoArtwork in maybeLogoArtwork)
{
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
? logoArtwork.Path
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(watermark, channelPath, None);
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
return Option<WatermarkOptions>.None;
}
private List<WatermarkOptions> OptionsForWatermarks(Channel channel, IEnumerable<ChannelWatermark> watermarks)
{
var result = new List<WatermarkOptions>();
foreach (var watermark in watermarks)
{
result.AddRange(GetWatermarkOptions(channel, watermark));
}
return result;
}
private Option<WatermarkOptions> GetWatermarkOptions(Channel channel, ChannelWatermark watermark)
{
switch (watermark.ImageSource)
{
// used for song progress overlay
case ChannelWatermarkImageSource.Resource:
return new WatermarkOptions(
watermark,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image),
Option<int>.None);
case ChannelWatermarkImageSource.Custom:
// bad form validation makes this possible
if (string.IsNullOrWhiteSpace(watermark.Image))
{
logger.LogWarning(
"Watermark {Name} has custom image configured with no image; ignoring",
watermark.Name);
break;
}
logger.LogDebug("Watermark will come from playout item (custom)");
string customPath = imageCache.GetPathForImage(
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
watermark,
customPath,
None);
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from playout item (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork =
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
foreach (var logoArtwork in maybeLogoArtwork)
{
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
? logoArtwork.Path
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
return new WatermarkOptions(watermark, channelPath, None);
default:
throw new NotSupportedException("Unsupported watermark image source");
}
return Option<WatermarkOptions>.None;
}
}

2
ErsatzTV.Core/Images/ChannelLogoGenerator.cs

@ -78,6 +78,6 @@ public class ChannelLogoGenerator : IChannelLogoGenerator @@ -78,6 +78,6 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
}
}
public static Option<string> GenerateChannelLogoUrl(Channel channel) =>
public static string GenerateChannelLogoUrl(Channel channel) =>
$"http://localhost:{Settings.StreamingPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}";
}

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

@ -26,8 +26,7 @@ public interface IFFmpegProcessService @@ -26,8 +26,7 @@ public interface IFFmpegProcessService
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark,
List<WatermarkOptions> watermarks,
List<PlayoutItemGraphicsElement> graphicsElements,
string vaapiDisplay,
VaapiDriver vaapiDriver,
@ -41,7 +40,6 @@ public interface IFFmpegProcessService @@ -41,7 +40,6 @@ public interface IFFmpegProcessService
DateTimeOffset channelStartTime,
long ptsOffset,
Option<int> targetFramerate,
bool disableWatermarks,
Option<string> customReportsFolder,
Action<FFmpegPipeline> pipelineAction);
@ -81,8 +79,6 @@ public interface IFFmpegProcessService @@ -81,8 +79,6 @@ public interface IFFmpegProcessService
string ffprobePath,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath,
bool boxBlur,

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

@ -7,8 +7,6 @@ public interface ISongVideoGenerator @@ -7,8 +7,6 @@ public interface ISongVideoGenerator
Task<Tuple<string, MediaVersion>> GenerateSongVideo(
Song song,
Channel channel,
Option<ChannelWatermark> maybePlayoutItemWatermark,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken);

18
ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
public interface IWatermarkSelector
{
List<WatermarkOptions> SelectWatermarks(
Option<ChannelWatermark> globalWatermark,
Channel channel,
PlayoutItem playoutItem,
DateTimeOffset now);
Option<WatermarkOptions> GetWatermarkOptions(
Channel channel,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark);
}

11
ErsatzTV.Core/Interfaces/FFmpeg/WatermarkResult.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
public abstract record WatermarkResult;
public sealed record InheritWatermark : WatermarkResult;
public sealed record DisableWatermark : WatermarkResult;
public sealed record CustomWatermarks(List<ChannelWatermark> Watermarks) : WatermarkResult;

2
ErsatzTV.FFmpeg/Filter/OverlayGraphicsEngineFilter.cs

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

2
ErsatzTV.FFmpeg/Filter/Vaapi/OverlayGraphicsEngineVaapiFilter.cs

@ -7,7 +7,7 @@ public class OverlayGraphicsEngineVaapiFilter(FrameState currentState, IPixelFor @@ -7,7 +7,7 @@ public class OverlayGraphicsEngineVaapiFilter(FrameState currentState, IPixelFor
public override string Filter =>
currentState.FrameDataLocation is FrameDataLocation.Hardware
? "overlay_vaapi"
: $"overlay=format={(outputPixelFormat.BitDepth == 10 ? '1' : '0')}:alpha=premultiplied";
: $"overlay=format={(outputPixelFormat.BitDepth == 10 ? '1' : '0')}";
public override FrameState NextState(FrameState currentState) => currentState;
}

3
ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs

@ -655,9 +655,6 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -655,9 +655,6 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
{
graphicsEngine.FilterSteps.Add(new PixelFormatFilter(new PixelFormatYuva420P()));
// overlay_cuda expects straight alpha
graphicsEngine.FilterSteps.Add(new UnpremultiplyFilter());
graphicsEngine.FilterSteps.Add(
new HardwareUploadCudaFilter(currentState with { FrameDataLocation = FrameDataLocation.Software }));

3
ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs

@ -557,9 +557,6 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -557,9 +557,6 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
if (currentState.FrameDataLocation is FrameDataLocation.Hardware)
{
// overlay_vaapi expects straight alpha
graphicsEngine.FilterSteps.Add(new UnpremultiplyFilter());
graphicsEngine.FilterSteps.Add(new HardwareUploadVaapiFilter(false));
}

2
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs

@ -129,7 +129,7 @@ public class GraphicsEngine( @@ -129,7 +129,7 @@ public class GraphicsEngine(
context.FrameSize.Width,
context.FrameSize.Height,
SKColorType.Bgra8888,
SKAlphaType.Premul);
SKAlphaType.Unpremul);
try
{

12
ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs

@ -20,16 +20,10 @@ public class WatermarkElement : ImageElementBase @@ -20,16 +20,10 @@ public class WatermarkElement : ImageElementBase
{
_logger = logger;
// TODO: better model coming in here?
foreach (string imagePath in watermarkOptions.ImagePath)
{
_imagePath = imagePath;
}
foreach (ChannelWatermark watermark in watermarkOptions.Watermark)
{
_watermark = watermark;
ZIndex = watermark.ZIndex;
}
_imagePath = watermarkOptions.ImagePath;
_watermark = watermarkOptions.Watermark;
ZIndex = watermarkOptions.Watermark.ZIndex;
}
public bool IsValid => _imagePath != null && _watermark != null;

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

@ -254,10 +254,8 @@ public class TranscodingTests @@ -254,10 +254,8 @@ public class TranscodingTests
var oldService = new FFmpegProcessService(
new FakeStreamSelector(),
mockImageCache,
tempFilePool,
Substitute.For<IClient>(),
MemoryCache,
LoggerFactory.CreateLogger<FFmpegProcessService>());
var service = new FFmpegLibraryProcessService(
@ -317,8 +315,6 @@ public class TranscodingTests @@ -317,8 +315,6 @@ public class TranscodingTests
(string videoPath, MediaVersion videoVersion) = await songVideoGenerator.GenerateSongVideo(
song,
channel,
None, // playout item watermark
None, // global watermark
ExecutableName("ffmpeg"),
ExecutableName("ffprobe"),
CancellationToken.None);
@ -350,6 +346,16 @@ public class TranscodingTests @@ -350,6 +346,16 @@ public class TranscodingTests
DateTimeOffset now = DateTimeOffset.Now;
WatermarkSelector watermarkSelector = new WatermarkSelector(
mockImageCache,
LoggerFactory.CreateLogger<WatermarkSelector>());
List<WatermarkOptions> watermarks = [];
foreach (var wm in GetWatermark(watermark))
{
watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option<ChannelWatermark>.None));
}
PlayoutItemResult playoutItemResult = await service.ForPlayoutItem(
ExecutableName("ffmpeg"),
ExecutableName("ffprobe"),
@ -367,8 +373,7 @@ public class TranscodingTests @@ -367,8 +373,7 @@ public class TranscodingTests
now,
now + TimeSpan.FromSeconds(3),
now,
[],
GetWatermark(watermark),
watermarks,
[],
"drm",
VaapiDriver.RadeonSI,
@ -382,7 +387,6 @@ public class TranscodingTests @@ -382,7 +387,6 @@ public class TranscodingTests
DateTimeOffset.Now,
0,
None,
false,
Option<string>.None,
_ => { });
@ -616,25 +620,51 @@ public class TranscodingTests @@ -616,25 +620,51 @@ public class TranscodingTests
FFmpegLibraryProcessService service = GetService();
var channel = new Channel(Guid.NewGuid())
{
Number = "1",
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
{
HardwareAcceleration = profileAcceleration,
VideoFormat = profileVideoFormat,
AudioFormat = FFmpegProfileAudioFormat.Aac,
DeinterlaceVideo = true,
BitDepth = profileBitDepth,
ScalingBehavior = scalingBehavior
},
StreamingMode = streamingMode,
SubtitleMode = subtitleMode
};
var localFileSystem = new LocalFileSystem(
Substitute.For<IClient>(),
LoggerFactory.CreateLogger<LocalFileSystem>());
var tempFilePool = new TempFilePool();
ImageCache mockImageCache = Substitute.For<ImageCache>(localFileSystem, tempFilePool);
// always return the static watermark resource
mockImageCache.GetPathForImage(
Arg.Any<string>(),
Arg.Is<ArtworkKind>(x => x == ArtworkKind.Watermark),
Arg.Any<Option<int>>())
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png"));
WatermarkSelector watermarkSelector = new WatermarkSelector(
mockImageCache,
LoggerFactory.CreateLogger<WatermarkSelector>());
List<WatermarkOptions> watermarks = [];
foreach (var wm in channelWatermark)
{
watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option<ChannelWatermark>.None));
}
PlayoutItemResult playoutItemResult = await service.ForPlayoutItem(
ExecutableName("ffmpeg"),
ExecutableName("ffprobe"),
false,
new Channel(Guid.NewGuid())
{
Number = "1",
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
{
HardwareAcceleration = profileAcceleration,
VideoFormat = profileVideoFormat,
AudioFormat = FFmpegProfileAudioFormat.Aac,
DeinterlaceVideo = true,
BitDepth = profileBitDepth,
ScalingBehavior = scalingBehavior
},
StreamingMode = streamingMode,
SubtitleMode = subtitleMode
},
channel,
v,
new MediaItemAudioVersion(null, v),
file,
@ -647,8 +677,7 @@ public class TranscodingTests @@ -647,8 +677,7 @@ public class TranscodingTests
now,
now + TimeSpan.FromSeconds(3),
now,
[],
channelWatermark,
watermarks,
[],
"drm",
VaapiDriver.RadeonSI,
@ -662,7 +691,6 @@ public class TranscodingTests @@ -662,7 +691,6 @@ public class TranscodingTests
DateTimeOffset.Now,
0,
None,
false,
Option<string>.None,
PipelineAction);
@ -898,10 +926,8 @@ public class TranscodingTests @@ -898,10 +926,8 @@ public class TranscodingTests
var oldService = new FFmpegProcessService(
new FakeStreamSelector(),
imageCache,
Substitute.For<ITempFilePool>(),
Substitute.For<IClient>(),
MemoryCache,
LoggerFactory.CreateLogger<FFmpegProcessService>());
var service = new FFmpegLibraryProcessService(

1
ErsatzTV/Startup.cs

@ -727,6 +727,7 @@ public class Startup @@ -727,6 +727,7 @@ public class Startup
services.AddScoped<IGraphicsElementRepository, GraphicsElementRepository>();
services.AddScoped<ITemplateDataRepository, TemplateDataRepository>();
services.AddScoped<TemplateFunctions>();
services.AddScoped<IWatermarkSelector, WatermarkSelector>();
services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>();
services.AddScoped<IPipelineBuilderFactory, PipelineBuilderFactory>();

Loading…
Cancel
Save