From e04a834edf896e150e74c73d13211c7a1f9dfa8b Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sat, 2 May 2026 11:12:54 -0500 Subject: [PATCH] feat: overlay single permanent watermarks using next engine (#2878) --- .../Channels/Commands/UpdateChannelHandler.cs | 7 + .../Commands/SyncNextPlayoutHandler.cs | 104 +++++++ ...layoutItemProcessByChannelNumberHandler.cs | 3 +- .../PrepareTroubleshootingPlaybackHandler.cs | 2 +- .../FFmpeg/DecoSelectorTests.cs | 5 +- .../FFmpeg/GraphicsElementSelectorTests.cs | 2 +- .../FFmpeg/WatermarkSelectorTests.cs | 5 +- ErsatzTV.Core/FFmpeg/DecoSelector.cs | 7 +- ErsatzTV.Core/FFmpeg/WatermarkSelector.cs | 98 ++++--- .../Interfaces/FFmpeg/IWatermarkSelector.cs | 6 +- ErsatzTV.Core/Next/Playout.cs | 262 ++++++++++++++++++ .../Core/FFmpeg/TranscodingTests.cs | 8 +- 12 files changed, 450 insertions(+), 59 deletions(-) diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index 445b1d4bd..9a9cab6e3 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -46,6 +46,10 @@ public class UpdateChannelHandler( } bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg; + bool hasPlayoutChange = hasEpgChange || c.WatermarkId != update.WatermarkId || + c.PreferredAudioLanguageCode != update.PreferredAudioLanguageCode || + c.PreferredAudioTitle != update.PreferredAudioTitle || + c.PreferredSubtitleLanguageCode != update.PreferredSubtitleLanguageCode; c.Name = update.Name; c.Number = update.Number; @@ -162,6 +166,9 @@ public class UpdateChannelHandler( if (hasEpgChange) { await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken); + } + if (hasPlayoutChange) + { await workerChannel.WriteAsync(new SyncNextPlayout(c.Number), cancellationToken); } diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs index c459723e9..ff7a18e37 100644 --- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs @@ -13,11 +13,13 @@ using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.FFmpeg.State; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem; +using WatermarkLocation = ErsatzTV.FFmpeg.State.WatermarkLocation; namespace ErsatzTV.Application.Playouts; @@ -29,6 +31,7 @@ public partial class SyncNextPlayoutHandler( IEmbyPathReplacementService embyPathReplacementService, ICustomStreamSelector customStreamSelector, IFFmpegStreamSelector ffmpegStreamSelector, + IWatermarkSelector watermarkSelector, IDbContextFactory dbContextFactory, ILogger logger) : IRequestHandler @@ -126,6 +129,28 @@ public partial class SyncNextPlayoutHandler( List playoutItems = await dbContext.PlayoutItems .AsNoTracking() .Where(i => i.Playout.Channel.Number == (mirrorChannelNumber ?? channelNumber)) + + // get playout deco + .Include(i => i.Playout) + .ThenInclude(p => p.Deco) + .ThenInclude(d => d.DecoWatermarks) + .ThenInclude(d => d.Watermark) + .Include(i => i.Playout) + .ThenInclude(p => p.Deco) + .ThenInclude(d => d.DecoGraphicsElements) + .ThenInclude(d => d.GraphicsElement) + + .Include(i => i.Watermarks) + + // get playout templates (and deco templates/decos) + .Include(i => i.Playout) + .ThenInclude(p => p.Templates) + .ThenInclude(t => t.DecoTemplate) + .ThenInclude(t => t.Items) + .ThenInclude(i => i.Deco) + .ThenInclude(d => d.DecoWatermarks) + .ThenInclude(d => d.Watermark) + .Include(i => i.MediaItem) .ThenInclude(mi => mi.LibraryPath) .ThenInclude(lp => lp.Library) @@ -167,6 +192,11 @@ public partial class SyncNextPlayoutHandler( logger.LogDebug("Located {Count} local playout items", playoutItems.Count); + Option maybeGlobalWatermark = await dbContext.ConfigElements + .GetValue(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken) + .BindT(watermarkId => dbContext.ChannelWatermarks + .SelectOneAsync(w => w.Id, w => w.Id == watermarkId, cancellationToken)); + foreach (IGrouping group in playoutItems.GroupBy(pi => pi.StartOffset.Date) .Where(g => g.Any())) { @@ -239,6 +269,7 @@ public partial class SyncNextPlayoutHandler( maybeChannel = await dbContext.Channels .AsNoTracking() + .Include(c => c.Watermark) .SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken); foreach (Channel channel in maybeChannel) { @@ -252,6 +283,11 @@ public partial class SyncNextPlayoutHandler( playoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode, playoutItem.SubtitleMode ?? channel.SubtitleMode, cancellationToken); + await SelectWatermark( + maybeGlobalWatermark, + channel, + playoutItem, + nextPlayoutItem); } playout.Items.Add(nextPlayoutItem); @@ -346,6 +382,74 @@ public partial class SyncNextPlayoutHandler( } } + private async Task SelectWatermark( + Option maybeGlobalWatermark, + Channel channel, + PlayoutItem playoutItem, + Core.Next.PlayoutItem nextPlayoutItem) + { + List watermarks = watermarkSelector.SelectWatermarks( + maybeGlobalWatermark, + channel, + playoutItem, + playoutItem.StartOffset, + shouldLogMessages: false); + + // single, permanent watermarks are supported + if (watermarks.Count == 1 && watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent)) + { + foreach (WatermarkOptions watermarkOptions in watermarks) + { + if (nextPlayoutItem.Watermark is null) + { + Core.Next.WatermarkLocation location = watermarkOptions.Watermark.Location switch + { + WatermarkLocation.TopMiddle => Core.Next.WatermarkLocation.TopCenter, + WatermarkLocation.TopRight => Core.Next.WatermarkLocation.TopRight, + WatermarkLocation.LeftMiddle => Core.Next.WatermarkLocation.CenterLeft, + WatermarkLocation.MiddleCenter => Core.Next.WatermarkLocation.Center, + WatermarkLocation.RightMiddle => Core.Next.WatermarkLocation.CenterRight, + WatermarkLocation.BottomLeft => Core.Next.WatermarkLocation.BottomLeft, + WatermarkLocation.BottomMiddle => Core.Next.WatermarkLocation.BottomCenter, + WatermarkLocation.BottomRight => Core.Next.WatermarkLocation.BottomRight, + _ => Core.Next.WatermarkLocation.TopLeft, + }; + + nextPlayoutItem.Watermark = new Core.Next.Watermark + { + Location = location, + HorizontalMarginPercent = watermarkOptions.Watermark.HorizontalMarginPercent, + VerticalMarginPercent = watermarkOptions.Watermark.VerticalMarginPercent, + OpacityPercent = watermarkOptions.Watermark.Opacity, + StreamIndex = await watermarkOptions.ImageStreamIndex.IfNoneAsync(0), + }; + + if (watermarkOptions.Watermark.Size is WatermarkSize.Scaled) + { + nextPlayoutItem.Watermark.WidthPercent = watermarkOptions.Watermark.WidthPercent; + } + + if (watermarkOptions.ImagePath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource + { + SourceType = Core.Next.SourceType.Http, + Uri = watermarkOptions.ImagePath, + }; + } + else + { + nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource + { + SourceType = Core.Next.SourceType.Local, + Path = watermarkOptions.ImagePath, + }; + } + } + } + } + } + private async Task> SourceForItem( PlayoutItem playoutItem, CancellationToken cancellationToken) diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index b6184f4f1..d5e2a4238 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -369,7 +369,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< maybeGlobalWatermark, channel, playoutItemWithPath.PlayoutItem, - now); + now, + shouldLogMessages: true); if (playoutItemWithPath.PlayoutItem.MediaItem is Song song) { diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 09c470884..812a82145 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -210,7 +210,7 @@ public class PrepareTroubleshootingPlaybackHandler( foreach (var watermark in channelWatermarks) { watermarks.AddRange( - watermarkSelector.GetWatermarkOptions(channel, watermark, Option.None)); + watermarkSelector.GetWatermarkOptions(channel, watermark, Option.None, shouldLogMessages: true)); } } diff --git a/ErsatzTV.Core.Tests/FFmpeg/DecoSelectorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/DecoSelectorTests.cs index d769e720c..949c3a0e7 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/DecoSelectorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/DecoSelectorTests.cs @@ -2,7 +2,6 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Scheduling; -using Microsoft.Extensions.Logging; using NUnit.Framework; using Serilog; using Shouldly; @@ -21,9 +20,7 @@ public class DecoSelectorTests .WriteTo.Console() .CreateLogger(); - var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger); - - DecoSelector = new DecoSelector(loggerFactory.CreateLogger()); + DecoSelector = new DecoSelector(); } [Test] diff --git a/ErsatzTV.Core.Tests/FFmpeg/GraphicsElementSelectorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/GraphicsElementSelectorTests.cs index 62e358581..48981a092 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/GraphicsElementSelectorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/GraphicsElementSelectorTests.cs @@ -53,7 +53,7 @@ public class GraphicsElementSelectorTests var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger); GraphicsElementSelector = new GraphicsElementSelector( - new DecoSelector(loggerFactory.CreateLogger()), + new DecoSelector(), loggerFactory.CreateLogger()); GraphicsElementTemplateDeco = new GraphicsElement { Id = 1, Path = "Template Deco GE" }; diff --git a/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs index 685c9192e..7458e5e2e 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs @@ -78,7 +78,7 @@ public class WatermarkSelectorTests WatermarkSelector = new WatermarkSelector( mockFileSystem, fakeImageCache, - new DecoSelector(loggerFactory.CreateLogger()), + new DecoSelector(), loggerFactory.CreateLogger()); WatermarkNone = Option.None; @@ -568,7 +568,8 @@ public class WatermarkSelectorTests td.globalWatermark, td.channel, td.playoutItem, - Now); + Now, + shouldLogMessages: true); watermarks.Count.ShouldBe(td.expectedWatermarks.Count); for (var i = 0; i < td.expectedWatermarks.Count; i++) diff --git a/ErsatzTV.Core/FFmpeg/DecoSelector.cs b/ErsatzTV.Core/FFmpeg/DecoSelector.cs index 8cde0dfec..147523a74 100644 --- a/ErsatzTV.Core/FFmpeg/DecoSelector.cs +++ b/ErsatzTV.Core/FFmpeg/DecoSelector.cs @@ -2,15 +2,14 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Scheduling; -using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.FFmpeg; -public class DecoSelector(ILogger logger) : IDecoSelector +public class DecoSelector : IDecoSelector { public DecoEntries GetDecoEntries(Playout playout, DateTimeOffset now) { - logger.LogDebug("Checking for deco at {Now}", now); + //logger.LogDebug("Checking for deco at {Now}", now); if (playout is null) { @@ -30,7 +29,7 @@ public class DecoSelector(ILogger logger) : IDecoSelector .Find(i => i.StartTime <= now.TimeOfDay && (i.EndTime == TimeSpan.Zero || i.EndTime > now.TimeOfDay)); foreach (DecoTemplateItem item in maybeItem) { - logger.LogDebug("Selecting deco between {Start} and {End}", item.StartTime, item.EndTime); + //logger.LogDebug("Selecting deco between {Start} and {End}", item.StartTime, item.EndTime); maybeTemplateDeco = Optional(item.Deco); } } diff --git a/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs b/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs index 6519860c6..c777cf325 100644 --- a/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs +++ b/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs @@ -6,6 +6,7 @@ using ErsatzTV.Core.Images; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace ErsatzTV.Core.FFmpeg; @@ -20,9 +21,12 @@ public class WatermarkSelector( Option globalWatermark, Channel channel, PlayoutItem playoutItem, - DateTimeOffset now) + DateTimeOffset now, + bool shouldLogMessages) { - logger.LogDebug("Checking for watermark at {Now}", now); + ILogger log = shouldLogMessages ? logger : NullLogger.Instance; + + log.LogDebug("Checking for watermark at {Now}", now); var result = new List(); @@ -33,7 +37,7 @@ public class WatermarkSelector( if (playoutItem.DisableWatermarks) { - logger.LogDebug("Watermark is disabled by playout item"); + log.LogDebug("Watermark is disabled by playout item"); return result; } @@ -49,36 +53,36 @@ public class WatermarkSelector( case DecoMode.Merge: if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller) { - logger.LogDebug("Watermark will come from template deco (merge)"); + log.LogDebug("Watermark will come from template deco (merge)"); result.AddRange( - OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); + OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark), log)); break; } - logger.LogDebug("Watermark is disabled by template deco during filler"); + log.LogDebug("Watermark is disabled by template deco during filler"); result.Clear(); done = true; break; case DecoMode.Override: if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller) { - logger.LogDebug("Watermark will come from template deco (replace)"); + log.LogDebug("Watermark will come from template deco (replace)"); result.AddRange( - OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); + OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark), log)); done = true; break; } - logger.LogDebug("Watermark is disabled by template deco during filler"); + log.LogDebug("Watermark is disabled by template deco during filler"); result.Clear(); done = true; break; case DecoMode.Disable: - logger.LogDebug("Watermark is disabled by template deco"); + log.LogDebug("Watermark is disabled by template deco"); done = true; break; case DecoMode.Inherit: - logger.LogDebug("Watermark will inherit from playout deco"); + log.LogDebug("Watermark will inherit from playout deco"); break; } @@ -98,36 +102,36 @@ public class WatermarkSelector( case DecoMode.Merge: if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller) { - logger.LogDebug("Watermark will come from playout deco (merge)"); + log.LogDebug("Watermark will come from playout deco (merge)"); result.AddRange( - OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); + OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark), log)); break; } - logger.LogDebug("Watermark is disabled by playout deco during filler"); + log.LogDebug("Watermark is disabled by playout deco during filler"); result.Clear(); done = true; break; case DecoMode.Override: if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller) { - logger.LogDebug("Watermark will come from playout deco (replace)"); + log.LogDebug("Watermark will come from playout deco (replace)"); result.AddRange( - OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); + OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark), log)); done = true; break; } - logger.LogDebug("Watermark is disabled by playout deco during filler"); + log.LogDebug("Watermark is disabled by playout deco during filler"); result.Clear(); done = true; break; case DecoMode.Disable: - logger.LogDebug("Watermark is disabled by playout deco"); + log.LogDebug("Watermark is disabled by playout deco"); done = true; break; case DecoMode.Inherit: - logger.LogDebug("Watermark will inherit from channel and/or global setting"); + log.LogDebug("Watermark will inherit from channel and/or global setting"); break; } @@ -144,7 +148,8 @@ public class WatermarkSelector( Option options = GetWatermarkOptions( channel, watermark, - Option.None); + Option.None, + shouldLogMessages); result.AddRange(options); } @@ -152,7 +157,11 @@ public class WatermarkSelector( } - var finalOptions = GetWatermarkOptions(channel, Option.None, globalWatermark); + Option finalOptions = GetWatermarkOptions( + channel, + Option.None, + globalWatermark, + shouldLogMessages); result.AddRange(finalOptions); return result; @@ -161,8 +170,11 @@ public class WatermarkSelector( public Option GetWatermarkOptions( Channel channel, Option playoutItemWatermark, - Option globalWatermark) + Option globalWatermark, + bool shouldLogMessages) { + ILogger log = shouldLogMessages ? logger : NullLogger.Instance; + if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect) { return Option.None; @@ -183,7 +195,7 @@ public class WatermarkSelector( return new WatermarkOptions(watermark, resourcePath, Option.None); } - logger.LogWarning( + log.LogWarning( "Watermark resource no longer exists at {Path} and will be ignored", resourcePath); return None; @@ -191,13 +203,13 @@ public class WatermarkSelector( // bad form validation makes this possible if (string.IsNullOrWhiteSpace(watermark.Image)) { - logger.LogWarning( + log.LogWarning( "Watermark {Name} has custom image configured with no image; ignoring", watermark.Name); break; } - logger.LogDebug("Watermark will come from playout item (custom)"); + log.LogDebug("Watermark will come from playout item (custom)"); string customPath = imageCache.GetPathForImage( watermark.Image, @@ -209,12 +221,12 @@ public class WatermarkSelector( return new WatermarkOptions(watermark, customPath, None); } - logger.LogWarning( + log.LogWarning( "Custom watermark no longer exists at {Path} and will be ignored", customPath); return None; case ChannelWatermarkImageSource.ChannelLogo: - logger.LogDebug("Watermark will come from playout item (channel logo)"); + log.LogDebug("Watermark will come from playout item (channel logo)"); string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); Option maybeLogoArtwork = @@ -231,7 +243,7 @@ public class WatermarkSelector( return new WatermarkOptions(watermark, channelPath, None); } - logger.LogWarning( + log.LogWarning( "Channel logo no longer exists at {Path} and will be ignored", channelPath); return None; @@ -246,7 +258,7 @@ public class WatermarkSelector( switch (channel.Watermark.ImageSource) { case ChannelWatermarkImageSource.Custom: - logger.LogDebug("Watermark will come from channel (custom)"); + log.LogDebug("Watermark will come from channel (custom)"); string customPath = imageCache.GetPathForImage( channel.Watermark.Image, @@ -258,12 +270,12 @@ public class WatermarkSelector( return new WatermarkOptions(channel.Watermark, customPath, None); } - logger.LogWarning( + log.LogWarning( "Custom watermark no longer exists at {Path} and will be ignored", customPath); return None; case ChannelWatermarkImageSource.ChannelLogo: - logger.LogDebug("Watermark will come from channel (channel logo)"); + log.LogDebug("Watermark will come from channel (channel logo)"); string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); Option maybeLogoArtwork = @@ -280,7 +292,7 @@ public class WatermarkSelector( return new WatermarkOptions(channel.Watermark, channelPath, None); } - logger.LogWarning( + log.LogWarning( "Channel logo no longer exists at {Path} and will be ignored", channelPath); return None; @@ -295,7 +307,7 @@ public class WatermarkSelector( switch (watermark.ImageSource) { case ChannelWatermarkImageSource.Custom: - logger.LogDebug("Watermark will come from global (custom)"); + log.LogDebug("Watermark will come from global (custom)"); string customPath = imageCache.GetPathForImage( watermark.Image, @@ -307,12 +319,12 @@ public class WatermarkSelector( return new WatermarkOptions(watermark, customPath, None); } - logger.LogWarning( + log.LogWarning( "Custom watermark no longer exists at {Path} and will be ignored", customPath); return None; case ChannelWatermarkImageSource.ChannelLogo: - logger.LogDebug("Watermark will come from global (channel logo)"); + log.LogDebug("Watermark will come from global (channel logo)"); string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); Option maybeLogoArtwork = @@ -329,7 +341,7 @@ public class WatermarkSelector( return new WatermarkOptions(watermark, channelPath, None); } - logger.LogWarning( + log.LogWarning( "Channel logo no longer exists at {Path} and will be ignored", channelPath); return None; @@ -341,19 +353,25 @@ public class WatermarkSelector( return Option.None; } - private List OptionsForWatermarks(Channel channel, IEnumerable watermarks) + private List OptionsForWatermarks( + Channel channel, + IEnumerable watermarks, + ILogger log) { var result = new List(); foreach (var watermark in watermarks) { - result.AddRange(GetWatermarkOptions(channel, watermark)); + result.AddRange(GetWatermarkOptions(channel, watermark, log)); } return result; } - private Option GetWatermarkOptions(Channel channel, ChannelWatermark watermark) + private Option GetWatermarkOptions( + Channel channel, + ChannelWatermark watermark, + ILogger log) { switch (watermark.ImageSource) { @@ -367,7 +385,7 @@ public class WatermarkSelector( // bad form validation makes this possible if (string.IsNullOrWhiteSpace(watermark.Image)) { - logger.LogWarning( + log.LogWarning( "Watermark {Name} has custom image configured with no image; ignoring", watermark.Name); break; diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs index c2c9ae1f0..c5ca86cbd 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs @@ -9,10 +9,12 @@ public interface IWatermarkSelector Option globalWatermark, Channel channel, PlayoutItem playoutItem, - DateTimeOffset now); + DateTimeOffset now, + bool shouldLogMessages); Option GetWatermarkOptions( Channel channel, Option playoutItemWatermark, - Option globalWatermark); + Option globalWatermark, + bool shouldLogMessages); } diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs index 69793bd12..85067e233 100644 --- a/ErsatzTV.Core/Next/Playout.cs +++ b/ErsatzTV.Core/Next/Playout.cs @@ -84,6 +84,13 @@ namespace ErsatzTV.Core.Next /// [JsonProperty("tracks")] public PlayoutItemTracks Tracks { get; set; } + + /// + /// Watermark (image/video overlay) to composite on top of the primary content for the + /// duration of this item. Omit for no watermark. + /// + [JsonProperty("watermark")] + public Watermark Watermark { get; set; } } /// @@ -209,8 +216,152 @@ namespace ErsatzTV.Core.Next public long? StreamIndex { get; set; } } + /// + /// An image or video overlay composited on top of the primary content. Sized and positioned + /// relative to the primary content's frame. + /// + public partial class Watermark + { + /// + /// Horizontal offset from the anchor `location`, as a percent of primary content width + /// (0–100). Omit for 0. + /// + [JsonProperty("horizontal_margin_percent")] + [JsonConverter(typeof(MinMaxValueCheckConverter))] + public double? HorizontalMarginPercent { get; set; } + + /// + /// Anchor position within the primary content frame. + /// + [JsonProperty("location")] + public WatermarkLocation Location { get; set; } + + /// + /// Opacity as a percent (0–100). Omit for fully opaque (100). + /// + [JsonProperty("opacity_percent")] + [JsonConverter(typeof(MinMaxValueCheckConverter))] + public double? OpacityPercent { get; set; } + + /// + /// The source providing the watermark media (typically an image, but any `PlayoutItemSource` + /// is accepted). + /// + [JsonProperty("source")] + public PlayoutItemSource Source { get; set; } + + /// + /// Zero-based stream index within the source. If omitted, the server picks the first video + /// stream. + /// + [JsonProperty("stream_index")] + public long? StreamIndex { get; set; } + + /// + /// Vertical offset from the anchor `location`, as a percent of primary content height + /// (0–100). Omit for 0. + /// + [JsonProperty("vertical_margin_percent")] + [JsonConverter(typeof(MinMaxValueCheckConverter))] + public double? VerticalMarginPercent { get; set; } + + /// + /// Scale the watermark to this percent of the primary content width (0–100). Omit to use the + /// watermark's actual size. + /// + [JsonProperty("width_percent")] + [JsonConverter(typeof(MinMaxValueCheckConverter))] + public double? WidthPercent { get; set; } + } + + /// + /// A media source. Exactly one variant, distinguished by `source_type`. + /// + /// The source providing the watermark media (typically an image, but any `PlayoutItemSource` + /// is accepted). + /// + /// A file on the local filesystem reachable by the server. + /// + /// A synthetic source produced by an ffmpeg lavfi filter graph. + /// + /// A remote source fetched over HTTP(S). + /// + public partial class PlayoutItemSource + { + /// + /// Optional start offset into the source, in milliseconds. + /// + [JsonProperty("in_point_ms")] + public long? InPointMs { get; set; } + + /// + /// Optional end offset into the source, in milliseconds. + /// + [JsonProperty("out_point_ms")] + public long? OutPointMs { get; set; } + + /// + /// Absolute path to the media file. + /// + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + public string Path { get; set; } + + [JsonProperty("source_type")] + public SourceType SourceType { get; set; } + + /// + /// The lavfi filter graph parameters, passed verbatim to ffmpeg's `-f lavfi -i`. + /// + [JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)] + public string Params { get; set; } + + /// + /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. + /// + [JsonProperty("headers")] + public List Headers { get; set; } + + /// + /// Enable reconnect on failure. Default: true. + /// + [JsonProperty("reconnect")] + public bool? Reconnect { get; set; } + + /// + /// Maximum reconnect delay in seconds. Maps to ffmpeg's `reconnect_delay_max`. + /// + [JsonProperty("reconnect_delay_max")] + public long? ReconnectDelayMax { get; set; } + + /// + /// Socket timeout in microseconds. + /// + [JsonProperty("timeout_us")] + public long? TimeoutUs { get; set; } + + /// + /// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}". + /// + [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] + public string Uri { get; set; } + + /// + /// Custom User-Agent string. + /// + [JsonProperty("user_agent")] + public string UserAgent { get; set; } + } + public enum SourceType { Http, Lavfi, Local }; + /// + /// Anchor position within the primary content frame. + /// + /// Nine-position anchor within the primary content frame. Read like a 3×3 grid: rows + /// top/center/bottom, columns left/center/right; the dead center is `center`. + /// + public enum WatermarkLocation { BottomCenter, BottomLeft, BottomRight, Center, CenterLeft, CenterRight, TopCenter, TopLeft, TopRight }; + public partial class Playout { public static Playout FromJson(string json) => JsonConvert.DeserializeObject(json, ErsatzTV.Core.Next.Converter.Settings); @@ -231,6 +382,7 @@ namespace ErsatzTV.Core.Next Converters = { SourceTypeConverter.Singleton, + WatermarkLocationConverter.Singleton, new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } }, }; @@ -281,4 +433,114 @@ namespace ErsatzTV.Core.Next public static readonly SourceTypeConverter Singleton = new SourceTypeConverter(); } + + internal class MinMaxValueCheckConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(double) || t == typeof(double?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + if (value >= 0 && value <= 100) + { + return value; + } + throw new Exception("Cannot unmarshal type double"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (double)untypedValue; + if (value >= 0 && value <= 100) + { + serializer.Serialize(writer, value); + return; + } + throw new Exception("Cannot marshal type double"); + } + + public static readonly MinMaxValueCheckConverter Singleton = new MinMaxValueCheckConverter(); + } + + internal class WatermarkLocationConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(WatermarkLocation) || t == typeof(WatermarkLocation?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + switch (value) + { + case "bottom_center": + return WatermarkLocation.BottomCenter; + case "bottom_left": + return WatermarkLocation.BottomLeft; + case "bottom_right": + return WatermarkLocation.BottomRight; + case "center": + return WatermarkLocation.Center; + case "center_left": + return WatermarkLocation.CenterLeft; + case "center_right": + return WatermarkLocation.CenterRight; + case "top_center": + return WatermarkLocation.TopCenter; + case "top_left": + return WatermarkLocation.TopLeft; + case "top_right": + return WatermarkLocation.TopRight; + } + throw new Exception("Cannot unmarshal type WatermarkLocation"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (WatermarkLocation)untypedValue; + switch (value) + { + case WatermarkLocation.BottomCenter: + serializer.Serialize(writer, "bottom_center"); + return; + case WatermarkLocation.BottomLeft: + serializer.Serialize(writer, "bottom_left"); + return; + case WatermarkLocation.BottomRight: + serializer.Serialize(writer, "bottom_right"); + return; + case WatermarkLocation.Center: + serializer.Serialize(writer, "center"); + return; + case WatermarkLocation.CenterLeft: + serializer.Serialize(writer, "center_left"); + return; + case WatermarkLocation.CenterRight: + serializer.Serialize(writer, "center_right"); + return; + case WatermarkLocation.TopCenter: + serializer.Serialize(writer, "top_center"); + return; + case WatermarkLocation.TopLeft: + serializer.Serialize(writer, "top_left"); + return; + case WatermarkLocation.TopRight: + serializer.Serialize(writer, "top_right"); + return; + } + throw new Exception("Cannot marshal type WatermarkLocation"); + } + + public static readonly WatermarkLocationConverter Singleton = new WatermarkLocationConverter(); + } } diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index 0e084921e..179a37dfd 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -375,13 +375,13 @@ public class TranscodingTests WatermarkSelector watermarkSelector = new WatermarkSelector( new MockFileSystem(), mockImageCache, - new DecoSelector(LoggerFactory.CreateLogger()), + new DecoSelector(), LoggerFactory.CreateLogger()); List watermarks = []; foreach (var wm in GetWatermark(watermark)) { - watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option.None)); + watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option.None, shouldLogMessages: true)); } PlayoutItemResult playoutItemResult = await service.ForPlayoutItem( @@ -707,13 +707,13 @@ public class TranscodingTests WatermarkSelector watermarkSelector = new WatermarkSelector( new RealFileSystem(), mockImageCache, - new DecoSelector(LoggerFactory.CreateLogger()), + new DecoSelector(), LoggerFactory.CreateLogger()); List watermarks = []; foreach (var wm in channelWatermark) { - watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option.None)); + watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option.None, shouldLogMessages: true)); } var mediaItem = new OtherVideo { MediaVersions = [v] };