From 075f3fcac7a6bbb56300520b7c51c2f2da57dde9 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sat, 9 Aug 2025 01:29:20 +0000 Subject: [PATCH] pass music video variables to text element (#2285) * pass music video variables to text element * remove unused file --- CHANGELOG.md | 3 +- ...layoutItemProcessByChannelNumberHandler.cs | 2 +- .../FFmpeg/FFmpegLibraryProcessService.cs | 1 + .../Repositories/ITemplateDataRepository.cs | 11 ++++ .../Streaming/GraphicsEngineContext.cs | 1 + .../Repositories/TemplateDataRepository.cs | 61 +++++++++++++++++++ .../FFmpeg/MusicVideoCreditsGenerator.cs | 28 +++------ .../Streaming/GraphicsEngine.cs | 28 ++++++++- .../Streaming/TextElement.cs | 9 ++- ErsatzTV/Startup.cs | 1 + 10 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 ErsatzTV.Core/Interfaces/Repositories/ITemplateDataRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b34309d8a..67d75dbc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add *experimental* `Graphics Element` template system - Graphics elements are defined in YAML files inside ETV config folder / templates / graphics-elements subfolder - Add `Text` graphics element type - - Supported in playback troubleshooting + - Supported in playback troubleshooting and YAML playouts - Displays multi-line text in a specified font, color, location, z-index - Supports constant opacity and opacity expression + - Supports variable replacement for music videos - YAML playout: add `graphics_on` and `graphics_off` instructions to control graphics elements - `graphics_on` requires the name of a graphics element template, e.g. `text/cool_element.yml` - The `variables` property can be used to dynamically replace text from the template diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 74233f6c4..fc58d1ca5 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -523,7 +523,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< subtitles.AddRange( await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles) - .IfNoneAsync(new List())); + .IfNoneAsync([])); break; } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 77b6a5486..8431b5c79 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -414,6 +414,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService graphicsEngineInput = new GraphicsEngineInput(); graphicsEngineContext = new GraphicsEngineContext( + audioVersion.MediaItem, graphicsElementContexts, channel.FFmpegProfile.Resolution, await playbackSettings.FrameRate.IfNoneAsync(24), diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITemplateDataRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITemplateDataRepository.cs new file mode 100644 index 000000000..57322df63 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/ITemplateDataRepository.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Repositories; + +public interface ITemplateDataRepository +{ + public Task>> GetMusicVideoTemplateData( + Resolution resolution, + TimeSpan streamSeek, + int musicVideoId); +} \ No newline at end of file diff --git a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs index 75ed7fff8..843a5489f 100644 --- a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs +++ b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs @@ -5,6 +5,7 @@ using ErsatzTV.Core.Graphics; namespace ErsatzTV.Core.Interfaces.Streaming; public record GraphicsEngineContext( + MediaItem MediaItem, List Elements, Resolution FrameSize, int FrameRate, diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs new file mode 100644 index 000000000..126b85e41 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs @@ -0,0 +1,61 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories; + +public class TemplateDataRepository(IDbContextFactory dbContextFactory) : ITemplateDataRepository +{ + public async Task>> GetMusicVideoTemplateData( + Resolution resolution, + TimeSpan streamSeek, + int musicVideoId) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); + + Option maybeMusicVideo = await dbContext.MusicVideos + .AsNoTracking() + .Include(mv => mv.MediaVersions) + .Include(mv => mv.Artist) + .ThenInclude(a => a.ArtistMetadata) + .Include(mv => mv.MusicVideoMetadata) + .ThenInclude(mvm => mvm.Artists) + .Include(mv => mv.MusicVideoMetadata) + .ThenInclude(mvm => mvm.Studios) + .Include(mv => mv.MusicVideoMetadata) + .ThenInclude(mvm => mvm.Directors) + .SelectOneAsync(mv => mv.Id, mv => mv.Id == musicVideoId); + + foreach (var musicVideo in maybeMusicVideo) + { + foreach (var metadata in musicVideo.MusicVideoMetadata.HeadOrNone()) + { + string artist = string.Empty; + foreach (ArtistMetadata artistMetadata in Optional(musicVideo.Artist?.ArtistMetadata).Flatten()) + { + artist = artistMetadata.Title; + } + + return new Dictionary + { + ["Resolution"] = resolution, + ["Title"] = metadata.Title, + ["Track"] = metadata.Track, + ["Album"] = metadata.Album, + ["Plot"] = metadata.Plot, + ["ReleaseDate"] = metadata.ReleaseDate, + ["Artists"] = (metadata.Artists ?? []).Map(a => a.Name).ToList(), + ["Artist"] = artist, + ["Studios"] = (metadata.Studios ?? []).Map(s => s.Name).ToList(), + ["Directors"] = (metadata.Directors ?? []).Map(s => s.Name).ToList(), + ["Duration"] = musicVideo.GetHeadVersion().Duration, + ["StreamSeek"] = streamSeek + }; + } + } + + return Option>.None; + } +} \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs b/ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs index eb6f6b6c5..50b5b7873 100644 --- a/ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs +++ b/ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs @@ -9,31 +9,23 @@ using Scriban; namespace ErsatzTV.Infrastructure.FFmpeg; -public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator +public class MusicVideoCreditsGenerator(ITempFilePool tempFilePool, ILogger logger) + : IMusicVideoCreditsGenerator { - private readonly ILogger _logger; - private readonly ITempFilePool _tempFilePool; - - public MusicVideoCreditsGenerator(ITempFilePool tempFilePool, ILogger logger) - { - _tempFilePool = tempFilePool; - _logger = logger; - } - public async Task> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile) { - const int HORIZONTAL_MARGIN_PERCENT = 3; - const int VERTICAL_MARGIN_PERCENT = 5; + const int horizontalMarginPercent = 3; + const int verticalMarginPercent = 5; var fontSize = (int)Math.Round(ffmpegProfile.Resolution.Height / 20.0); - int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT; - int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT; + int leftMarginPercent = horizontalMarginPercent; + int rightMarginPercent = horizontalMarginPercent; var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * ffmpegProfile.Resolution.Width); var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * ffmpegProfile.Resolution.Width); var verticalMargin = - (int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * ffmpegProfile.Resolution.Height); + (int)Math.Round(verticalMarginPercent / 100.0 * ffmpegProfile.Resolution.Height); foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata) { @@ -60,7 +52,7 @@ public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator sb.Append(CultureInfo.InvariantCulture, $"\\N{metadata.Album}"); } - string subtitles = await new SubtitleBuilder(_tempFilePool) + string subtitles = await new SubtitleBuilder(tempFilePool) .WithResolution(ffmpegProfile.Resolution) .WithFontName("OPTIKabel-Heavy") .WithFontSize(fontSize) @@ -127,7 +119,7 @@ public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator StreamSeek = await settings.StreamSeek.IfNoneAsync(TimeSpan.Zero) }); - string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle); + string fileName = tempFilePool.GetNextTempFile(TempFileCategory.Subtitle); await File.WriteAllTextAsync(fileName, result); return new Subtitle { @@ -143,7 +135,7 @@ public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator } catch (Exception ex) { - _logger.LogError(ex, "Error generating music video credits from template {Template}", templateFileName); + logger.LogError(ex, "Error generating music video credits from template {Template}", templateFileName); } return None; diff --git a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs index c1f041c9d..9a1c93962 100644 --- a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs @@ -1,5 +1,7 @@ using System.IO.Pipelines; using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Streaming; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; @@ -8,7 +10,7 @@ using SixLabors.ImageSharp.Processing; namespace ErsatzTV.Infrastructure.Streaming; -public class GraphicsEngine(ILogger logger) : IGraphicsEngine +public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILogger logger) : IGraphicsEngine { public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken) { @@ -28,7 +30,29 @@ public class GraphicsEngine(ILogger logger) : IGraphicsEngine break; case TextElementContext textElementContext: - elements.Add(new TextElement(textElementContext.TextElement, textElementContext.Variables, logger)); + var variables = new Dictionary(); + foreach (var variable in textElementContext.Variables) + { + variables.Add(variable.Key, variable.Value); + } + + if (context.MediaItem is MusicVideo musicVideo) + { + var maybeTemplateData = await templateDataRepository.GetMusicVideoTemplateData( + context.FrameSize, + context.Seek, + musicVideo.Id); + + foreach (var templateData in maybeTemplateData) + { + foreach (var variable in templateData) + { + variables.Add(variable.Key, variable.Value); + } + } + + } + elements.Add(new TextElement(textElementContext.TextElement, variables, logger)); break; } } diff --git a/ErsatzTV.Infrastructure/Streaming/TextElement.cs b/ErsatzTV.Infrastructure/Streaming/TextElement.cs index 65596e1f7..9a4acb2ea 100644 --- a/ErsatzTV.Infrastructure/Streaming/TextElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/TextElement.cs @@ -12,7 +12,8 @@ using Image=SixLabors.ImageSharp.Image; namespace ErsatzTV.Infrastructure.Streaming; -public class TextElement(TextGraphicsElement textElement, Dictionary variables, ILogger logger) : IGraphicsElement, IDisposable +public class TextElement(TextGraphicsElement textElement, Dictionary variables, ILogger logger) + : IGraphicsElement, IDisposable { private Option _maybeOpacityExpression; private float _opacity; @@ -42,7 +43,8 @@ public class TextElement(TextGraphicsElement textElement, Dictionary((int)Math.Ceiling(textBounds.Width), (int)Math.Ceiling(textBounds.Height)); _image.Mutate(ctx => ctx.DrawText(textOptions, textToRender, fontColor)); - int horizontalMargin = (int)Math.Round((textElement.HorizontalMarginPercent ?? 0) / 100.0 * frameSize.Width); + int horizontalMargin = + (int)Math.Round((textElement.HorizontalMarginPercent ?? 0) / 100.0 * frameSize.Width); int verticalMargin = (int)Math.Round((textElement.VerticalMarginPercent ?? 0) / 100.0 * frameSize.Height); _location = WatermarkElement.CalculatePosition( diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 814812c62..223c247a7 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -720,6 +720,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();