From 8cbc3b083a0a6ae8996102e963a809c202cb4742 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:02:16 +0000 Subject: [PATCH] fix placing watermarks within source content (#2297) * fix placing watermarks within source content * formatting --- .../FFmpeg/FFmpegLibraryProcessService.cs | 145 +++++++++--------- .../Streaming/GraphicsEngineContext.cs | 1 + .../Streaming/GraphicsEngine.cs | 3 +- .../Streaming/IGraphicsElement.cs | 6 +- .../Streaming/ImageElement.cs | 6 +- .../Streaming/TextElement.cs | 6 +- .../Streaming/WatermarkElement.cs | 34 +++- 7 files changed, 120 insertions(+), 81 deletions(-) diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index b6e2810b..98fdc758 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -375,6 +375,78 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService graphicsElementContexts.AddRange(watermarks.Values); } + HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind); + + string videoFormat = GetVideoFormat(playbackSettings); + Option maybeVideoProfile = GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile); + Option maybeVideoPreset = GetVideoPreset(hwAccel, videoFormat, channel.FFmpegProfile.VideoPreset); + + Option hlsPlaylistPath = outputFormat == OutputFormatKind.Hls + ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8") + : Option.None; + + Option hlsSegmentTemplate = outputFormat == OutputFormatKind.Hls + ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts") + : Option.None; + + FrameSize scaledSize = ffmpegVideoStream.SquarePixelFrameSize( + new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)); + + var paddedSize = new FrameSize( + channel.FFmpegProfile.Resolution.Width, + channel.FFmpegProfile.Resolution.Height); + + Option cropSize = Option.None; + + if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Stretch) + { + scaledSize = paddedSize; + } + + if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Crop) + { + bool isTooSmallToCrop = videoVersion.Height < channel.FFmpegProfile.Resolution.Height || + videoVersion.Width < channel.FFmpegProfile.Resolution.Width; + + // if any dimension is smaller than the crop, scale beyond the crop (beyond the target resolution) + if (isTooSmallToCrop) + { + foreach (IDisplaySize size in playbackSettings.ScaledSize) + { + scaledSize = new FrameSize(size.Width, size.Height); + } + + paddedSize = scaledSize; + } + else + { + paddedSize = ffmpegVideoStream.SquarePixelFrameSizeForCrop( + new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)); + } + + cropSize = new FrameSize( + channel.FFmpegProfile.Resolution.Width, + channel.FFmpegProfile.Resolution.Height); + } + + var desiredState = new FrameState( + playbackSettings.RealtimeOutput, + fillerKind == FillerKind.Fallback, + videoFormat, + maybeVideoProfile, + maybeVideoPreset, + channel.FFmpegProfile.AllowBFrames, + Optional(playbackSettings.PixelFormat), + scaledSize, + paddedSize, + cropSize, + false, + playbackSettings.FrameRate, + playbackSettings.VideoBitrate, + playbackSettings.VideoBufferSize, + playbackSettings.VideoTrackTimeScale, + playbackSettings.Deinterlace); + foreach (var playoutItemGraphicsElement in graphicsElements) { switch (playoutItemGraphicsElement.GraphicsElement.Kind) @@ -445,6 +517,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService graphicsEngineContext = new GraphicsEngineContext( audioVersion.MediaItem, graphicsElementContexts, + new Resolution { Width = desiredState.ScaledSize.Width, Height = desiredState.ScaledSize.Height }, channel.FFmpegProfile.Resolution, await playbackSettings.FrameRate.IfNoneAsync(24), ChannelStartTime: channelStartTime, @@ -453,78 +526,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService finish - now); } - HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind); - - string videoFormat = GetVideoFormat(playbackSettings); - Option maybeVideoProfile = GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile); - Option maybeVideoPreset = GetVideoPreset(hwAccel, videoFormat, channel.FFmpegProfile.VideoPreset); - - Option hlsPlaylistPath = outputFormat == OutputFormatKind.Hls - ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8") - : Option.None; - - Option hlsSegmentTemplate = outputFormat == OutputFormatKind.Hls - ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts") - : Option.None; - - FrameSize scaledSize = ffmpegVideoStream.SquarePixelFrameSize( - new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)); - - var paddedSize = new FrameSize( - channel.FFmpegProfile.Resolution.Width, - channel.FFmpegProfile.Resolution.Height); - - Option cropSize = Option.None; - - if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Stretch) - { - scaledSize = paddedSize; - } - - if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Crop) - { - bool isTooSmallToCrop = videoVersion.Height < channel.FFmpegProfile.Resolution.Height || - videoVersion.Width < channel.FFmpegProfile.Resolution.Width; - - // if any dimension is smaller than the crop, scale beyond the crop (beyond the target resolution) - if (isTooSmallToCrop) - { - foreach (IDisplaySize size in playbackSettings.ScaledSize) - { - scaledSize = new FrameSize(size.Width, size.Height); - } - - paddedSize = scaledSize; - } - else - { - paddedSize = ffmpegVideoStream.SquarePixelFrameSizeForCrop( - new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)); - } - - cropSize = new FrameSize( - channel.FFmpegProfile.Resolution.Width, - channel.FFmpegProfile.Resolution.Height); - } - - var desiredState = new FrameState( - playbackSettings.RealtimeOutput, - fillerKind == FillerKind.Fallback, - videoFormat, - maybeVideoProfile, - maybeVideoPreset, - channel.FFmpegProfile.AllowBFrames, - Optional(playbackSettings.PixelFormat), - scaledSize, - paddedSize, - cropSize, - false, - playbackSettings.FrameRate, - playbackSettings.VideoBitrate, - playbackSettings.VideoBufferSize, - playbackSettings.VideoTrackTimeScale, - playbackSettings.Deinterlace); - var ffmpegState = new FFmpegState( saveReports, hwAccel, diff --git a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs index 55a53366..b4aa9b1a 100644 --- a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs +++ b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs @@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Streaming; public record GraphicsEngineContext( MediaItem MediaItem, List Elements, + Resolution SquarePixelFrameSize, Resolution FrameSize, int FrameRate, DateTimeOffset ChannelStartTime, diff --git a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs index 201e5efa..ffc7b594 100644 --- a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs @@ -61,7 +61,8 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog } // initialize all elements - await Task.WhenAll(elements.Select(e => e.InitializeAsync(context.FrameSize, context.FrameRate, cancellationToken))); + await Task.WhenAll(elements.Select(e => + e.InitializeAsync(context.SquarePixelFrameSize, context.FrameSize, context.FrameRate, cancellationToken))); long frameCount = 0; var totalFrames = (long)(context.Duration.TotalSeconds * context.FrameRate); diff --git a/ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs b/ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs index d9e80d4b..68ef5bda 100644 --- a/ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs @@ -8,7 +8,11 @@ public interface IGraphicsElement bool IsFailed { get; set; } - Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken); + Task InitializeAsync( + Resolution squarePixelFrameSize, + Resolution frameSize, + int frameRate, + CancellationToken cancellationToken); ValueTask> PrepareImage( TimeSpan timeOfDay, diff --git a/ErsatzTV.Infrastructure/Streaming/ImageElement.cs b/ErsatzTV.Infrastructure/Streaming/ImageElement.cs index 714b585b..02a1be73 100644 --- a/ErsatzTV.Infrastructure/Streaming/ImageElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/ImageElement.cs @@ -24,7 +24,11 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log public bool IsFailed { get; set; } - public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken) + public async Task InitializeAsync( + Resolution squarePixelFrameSize, + Resolution frameSize, + int frameRate, + CancellationToken cancellationToken) { try { diff --git a/ErsatzTV.Infrastructure/Streaming/TextElement.cs b/ErsatzTV.Infrastructure/Streaming/TextElement.cs index 9a4acb2e..8b6a1b2f 100644 --- a/ErsatzTV.Infrastructure/Streaming/TextElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/TextElement.cs @@ -24,7 +24,11 @@ public class TextElement(TextGraphicsElement textElement, Dictionary new Point(horizontalMargin, frameHeight - imageHeight - verticalMargin), @@ -213,6 +212,31 @@ public class WatermarkElement : IGraphicsElement, IDisposable }; } + private WatermarkMargins NormalMargins(Resolution frameSize) + { + double horizontalMargin = Math.Round(_watermark.HorizontalMarginPercent / 100.0 * frameSize.Width); + double verticalMargin = Math.Round(_watermark.VerticalMarginPercent / 100.0 * frameSize.Height); + + return new WatermarkMargins((int)Math.Round(horizontalMargin), (int)Math.Round(verticalMargin)); + } + + private WatermarkMargins SourceContentMargins(Resolution squarePixelFrameSize, Resolution frameSize) + { + int horizontalPadding = frameSize.Width - squarePixelFrameSize.Width; + int verticalPadding = frameSize.Height - squarePixelFrameSize.Height; + + double horizontalMargin = Math.Round( + _watermark.HorizontalMarginPercent / 100.0 * squarePixelFrameSize.Width + + horizontalPadding / 2.0); + double verticalMargin = Math.Round( + _watermark.VerticalMarginPercent / 100.0 * squarePixelFrameSize.Height + + verticalPadding / 2.0); + + return new WatermarkMargins((int)Math.Round(horizontalMargin), (int)Math.Round(verticalMargin)); + } + + private sealed record WatermarkMargins(int HorizontalMargin, int VerticalMargin); + public void Dispose() { GC.SuppressFinalize(this);