process graphics element yaml files with scriban (#2410)
* add content rating to media item template * process graphics element yaml files with scriban
This commit is contained in:
@@ -30,6 +30,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Add playout detail row coloring by @peterdey
|
||||
- Filler has unique row colors
|
||||
- Unscheduled gaps are now displayed and have a unique row color
|
||||
- Process entire graphics element YAML files using scriban
|
||||
- This allows things like different images based on `MediaItem_ContentRating` (movie) or `MediaItem_ShowContentRating` (episode)
|
||||
|
||||
### Fixed
|
||||
- Fix transcoding content with bt709/pc color metadata
|
||||
|
||||
@@ -63,6 +63,13 @@ public class FakeLocalFileSystem : ILocalFileSystem
|
||||
.Select(f => f.Contents)
|
||||
.IfNoneAsync(string.Empty);
|
||||
|
||||
public async Task<string[]> ReadAllLines(string path) => await _files
|
||||
.Filter(f => f.Path == path)
|
||||
.HeadOrNone()
|
||||
.Select(f => f.Contents)
|
||||
.IfNoneAsync(string.Empty)
|
||||
.Map(s => s.Split(Environment.NewLine));
|
||||
|
||||
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
|
||||
|
||||
private static List<DirectoryInfo> Split(DirectoryInfo path)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
@@ -14,7 +13,6 @@ using ErsatzTV.FFmpeg.Pipeline;
|
||||
using ErsatzTV.FFmpeg.Preset;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
@@ -405,88 +403,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.Deinterlace);
|
||||
|
||||
foreach (PlayoutItemGraphicsElement playoutItemGraphicsElement in graphicsElements)
|
||||
{
|
||||
switch (playoutItemGraphicsElement.GraphicsElement.Kind)
|
||||
{
|
||||
case GraphicsElementKind.Text:
|
||||
{
|
||||
Option<TextGraphicsElement> maybeElement =
|
||||
await TextGraphicsElement.FromFile(playoutItemGraphicsElement.GraphicsElement.Path);
|
||||
if (maybeElement.IsNone)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to load text graphics element from file {Path}; ignoring",
|
||||
playoutItemGraphicsElement.GraphicsElement.Path);
|
||||
}
|
||||
|
||||
foreach (TextGraphicsElement element in maybeElement)
|
||||
{
|
||||
var variables = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrWhiteSpace(playoutItemGraphicsElement.Variables))
|
||||
{
|
||||
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(
|
||||
playoutItemGraphicsElement.Variables);
|
||||
}
|
||||
|
||||
graphicsElementContexts.Add(new TextElementDataContext(element, variables));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case GraphicsElementKind.Image:
|
||||
{
|
||||
Option<ImageGraphicsElement> maybeElement =
|
||||
await ImageGraphicsElement.FromFile(playoutItemGraphicsElement.GraphicsElement.Path);
|
||||
if (maybeElement.IsNone)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to load image graphics element from file {Path}; ignoring",
|
||||
playoutItemGraphicsElement.GraphicsElement.Path);
|
||||
}
|
||||
|
||||
foreach (ImageGraphicsElement element in maybeElement)
|
||||
{
|
||||
graphicsElementContexts.Add(new ImageElementContext(element));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case GraphicsElementKind.Subtitle:
|
||||
{
|
||||
Option<SubtitlesGraphicsElement> maybeElement =
|
||||
await SubtitlesGraphicsElement.FromFile(playoutItemGraphicsElement.GraphicsElement.Path);
|
||||
if (maybeElement.IsNone)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to load subtitle graphics element from file {Path}; ignoring",
|
||||
playoutItemGraphicsElement.GraphicsElement.Path);
|
||||
}
|
||||
|
||||
foreach (SubtitlesGraphicsElement element in maybeElement)
|
||||
{
|
||||
var variables = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrWhiteSpace(playoutItemGraphicsElement.Variables))
|
||||
{
|
||||
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(
|
||||
playoutItemGraphicsElement.Variables);
|
||||
}
|
||||
|
||||
graphicsElementContexts.Add(new SubtitleElementDataContext(element, variables));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
_logger.LogInformation(
|
||||
"Ignoring unsupported graphics element kind {Kind}",
|
||||
nameof(playoutItemGraphicsElement.GraphicsElement.Kind));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// only use graphics engine when we have elements
|
||||
if (graphicsElementContexts.Count > 0)
|
||||
if (graphicsElementContexts.Count > 0 || graphicsElements.Count > 0)
|
||||
{
|
||||
graphicsEngineInput = new GraphicsEngineInput();
|
||||
|
||||
@@ -496,6 +414,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
channel.Number,
|
||||
audioVersion.MediaItem,
|
||||
graphicsElementContexts,
|
||||
graphicsElements,
|
||||
new Resolution { Width = targetSize.Width, Height = targetSize.Height },
|
||||
channel.FFmpegProfile.Resolution,
|
||||
await playbackSettings.FrameRate.IfNoneAsync(24),
|
||||
|
||||
@@ -36,12 +36,10 @@ public class ImageGraphicsElement
|
||||
[YamlMember(Alias = "scale_width_percent", ApplyNamingConventions = false)]
|
||||
public double? ScaleWidthPercent { get; set; }
|
||||
|
||||
public static async Task<Option<ImageGraphicsElement>> FromFile(string fileName)
|
||||
public static Option<ImageGraphicsElement> FromYaml(string yaml)
|
||||
{
|
||||
try
|
||||
{
|
||||
string yaml = await File.ReadAllTextAsync(fileName);
|
||||
|
||||
// TODO: validate schema
|
||||
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
|
||||
// {
|
||||
|
||||
@@ -13,12 +13,10 @@ public class SubtitlesGraphicsElement
|
||||
|
||||
public string Template { get; set; }
|
||||
|
||||
public static async Task<Option<SubtitlesGraphicsElement>> FromFile(string fileName)
|
||||
public static Option<SubtitlesGraphicsElement> FromYaml(string yaml)
|
||||
{
|
||||
try
|
||||
{
|
||||
string yaml = await File.ReadAllTextAsync(fileName);
|
||||
|
||||
// TODO: validate schema
|
||||
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
|
||||
// {
|
||||
|
||||
@@ -45,12 +45,10 @@ public class TextGraphicsElement
|
||||
|
||||
public string Text { get; set; }
|
||||
|
||||
public static async Task<Option<TextGraphicsElement>> FromFile(string fileName)
|
||||
public static Option<TextGraphicsElement> FromYaml(string yaml)
|
||||
{
|
||||
try
|
||||
{
|
||||
string yaml = await File.ReadAllTextAsync(fileName);
|
||||
|
||||
// TODO: validate schema
|
||||
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
|
||||
// {
|
||||
|
||||
@@ -15,4 +15,5 @@ public interface ILocalFileSystem
|
||||
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
|
||||
Unit EmptyFolder(string folder);
|
||||
Task<string> ReadAllText(string path);
|
||||
Task<string[]> ReadAllLines(string path);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public record GraphicsEngineContext(
|
||||
string ChannelNumber,
|
||||
MediaItem MediaItem,
|
||||
List<GraphicsElementContext> Elements,
|
||||
List<PlayoutItemGraphicsElement> ElementReferences,
|
||||
Resolution SquarePixelFrameSize,
|
||||
Resolution FrameSize,
|
||||
int FrameRate,
|
||||
|
||||
@@ -157,4 +157,5 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
|
||||
}
|
||||
|
||||
public Task<string> ReadAllText(string path) => File.ReadAllTextAsync(path);
|
||||
public Task<string[]> ReadAllLines(string path) => File.ReadAllLinesAsync(path);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ public static class MediaItemTemplateDataKey
|
||||
public static readonly string Directors = "MediaItem_Directors";
|
||||
public static readonly string Genres = "MediaItem_Genres";
|
||||
|
||||
// movie
|
||||
public static readonly string ContentRating = "MediaItem_ContentRating";
|
||||
|
||||
// episode/show
|
||||
public static readonly string ShowTitle = "MediaItem_ShowTitle";
|
||||
public static readonly string ShowYear = "MediaItem_ShowYear";
|
||||
|
||||
@@ -118,7 +118,8 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext
|
||||
[MediaItemTemplateDataKey.Directors] =
|
||||
(metadata.Directors ?? []).Map(d => d.Name).OrderBy(identity),
|
||||
[MediaItemTemplateDataKey.Genres] = (metadata.Genres ?? []).Map(g => g.Name).OrderBy(identity),
|
||||
[MediaItemTemplateDataKey.Duration] = movie.GetHeadVersion().Duration
|
||||
[MediaItemTemplateDataKey.Duration] = movie.GetHeadVersion().Duration,
|
||||
[MediaItemTemplateDataKey.ContentRating] = metadata.ContentRating
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
using System.IO.Pipelines;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Scriban;
|
||||
using Scriban.Runtime;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public class GraphicsEngine(
|
||||
public partial class GraphicsEngine(
|
||||
TemplateFunctions templateFunctions,
|
||||
GraphicsEngineFonts graphicsEngineFonts,
|
||||
ITempFilePool tempFilePool,
|
||||
ITemplateDataRepository templateDataRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<GraphicsEngine> logger)
|
||||
: IGraphicsEngine
|
||||
{
|
||||
@@ -21,37 +29,104 @@ public class GraphicsEngine(
|
||||
{
|
||||
graphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder);
|
||||
|
||||
var templateVariables = new Dictionary<string, object>();
|
||||
|
||||
// init template element variables once
|
||||
if (context.Elements.OfType<ITemplateDataContext>().Any())
|
||||
// get max epg entries
|
||||
int epgEntries = 0;
|
||||
foreach (var reference in context.ElementReferences)
|
||||
{
|
||||
// common variables
|
||||
templateVariables[MediaItemTemplateDataKey.Resolution] = context.FrameSize;
|
||||
templateVariables[MediaItemTemplateDataKey.StreamSeek] = context.Seek;
|
||||
|
||||
// media item variables
|
||||
Option<Dictionary<string, object>> maybeTemplateData =
|
||||
await templateDataRepository.GetMediaItemTemplateData(context.MediaItem, cancellationToken);
|
||||
foreach (Dictionary<string, object> templateData in maybeTemplateData)
|
||||
if (reference.GraphicsElement.Kind is GraphicsElementKind.Text or GraphicsElementKind.Subtitle)
|
||||
{
|
||||
foreach (KeyValuePair<string, object> variable in templateData)
|
||||
foreach (string line in await localFileSystem.ReadAllLines(reference.GraphicsElement.Path))
|
||||
{
|
||||
templateVariables.Add(variable.Key, variable.Value);
|
||||
Match match = EpgEntriesRegex().Match(line);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out int value))
|
||||
{
|
||||
epgEntries = Math.Max(epgEntries, value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// epg variables
|
||||
int maxEpg = context.Elements.OfType<ITemplateDataContext>().Max(c => c.EpgEntries);
|
||||
DateTimeOffset startTime = context.ContentStartTime + context.Seek;
|
||||
Option<Dictionary<string, object>> maybeEpgData =
|
||||
await templateDataRepository.GetEpgTemplateData(context.ChannelNumber, startTime, maxEpg);
|
||||
foreach (Dictionary<string, object> templateData in maybeEpgData)
|
||||
// init template element variables once
|
||||
Dictionary<string, object> templateVariables =
|
||||
await InitTemplateVariables(context, epgEntries, cancellationToken);
|
||||
|
||||
// fully process references (using template variables)
|
||||
foreach (var reference in context.ElementReferences)
|
||||
{
|
||||
switch (reference.GraphicsElement.Kind)
|
||||
{
|
||||
foreach (KeyValuePair<string, object> variable in templateData)
|
||||
case GraphicsElementKind.Text:
|
||||
{
|
||||
templateVariables.Add(variable.Key, variable.Value);
|
||||
Option<TextGraphicsElement> maybeElement = TextGraphicsElement.FromYaml(
|
||||
await GetTemplatedYaml(reference.GraphicsElement.Path, templateVariables));
|
||||
if (maybeElement.IsNone)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Failed to load text graphics element from file {Path}; ignoring",
|
||||
reference.GraphicsElement.Path);
|
||||
}
|
||||
|
||||
foreach (TextGraphicsElement element in maybeElement)
|
||||
{
|
||||
var variables = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrWhiteSpace(reference.Variables))
|
||||
{
|
||||
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(reference.Variables);
|
||||
}
|
||||
|
||||
context.Elements.Add(new TextElementDataContext(element, variables));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case GraphicsElementKind.Image:
|
||||
{
|
||||
Option<ImageGraphicsElement> maybeElement = ImageGraphicsElement.FromYaml(
|
||||
await GetTemplatedYaml(reference.GraphicsElement.Path, templateVariables));
|
||||
if (maybeElement.IsNone)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Failed to load image graphics element from file {Path}; ignoring",
|
||||
reference.GraphicsElement.Path);
|
||||
}
|
||||
|
||||
foreach (ImageGraphicsElement element in maybeElement)
|
||||
{
|
||||
context.Elements.Add(new ImageElementContext(element));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case GraphicsElementKind.Subtitle:
|
||||
{
|
||||
Option<SubtitlesGraphicsElement> maybeElement = SubtitlesGraphicsElement.FromYaml(
|
||||
await GetTemplatedYaml(reference.GraphicsElement.Path, templateVariables));
|
||||
if (maybeElement.IsNone)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Failed to load subtitle graphics element from file {Path}; ignoring",
|
||||
reference.GraphicsElement.Path);
|
||||
}
|
||||
|
||||
foreach (SubtitlesGraphicsElement element in maybeElement)
|
||||
{
|
||||
var variables = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrWhiteSpace(reference.Variables))
|
||||
{
|
||||
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(reference.Variables);
|
||||
}
|
||||
|
||||
context.Elements.Add(new SubtitleElementDataContext(element, variables));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.LogInformation(
|
||||
"Ignoring unsupported graphics element kind {Kind}",
|
||||
nameof(reference.GraphicsElement.Kind));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,4 +305,65 @@ public class GraphicsEngine(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object>> InitTemplateVariables(
|
||||
GraphicsEngineContext context,
|
||||
int epgEntries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// common variables
|
||||
var result = new Dictionary<string, object>
|
||||
{
|
||||
[MediaItemTemplateDataKey.Resolution] = context.FrameSize,
|
||||
[MediaItemTemplateDataKey.StreamSeek] = context.Seek
|
||||
};
|
||||
|
||||
// media item variables
|
||||
Option<Dictionary<string, object>> maybeTemplateData =
|
||||
await templateDataRepository.GetMediaItemTemplateData(context.MediaItem, cancellationToken);
|
||||
foreach (Dictionary<string, object> templateData in maybeTemplateData)
|
||||
{
|
||||
foreach (KeyValuePair<string, object> variable in templateData)
|
||||
{
|
||||
result.Add(variable.Key, variable.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// epg variables
|
||||
DateTimeOffset startTime = context.ContentStartTime + context.Seek;
|
||||
Option<Dictionary<string, object>> maybeEpgData =
|
||||
await templateDataRepository.GetEpgTemplateData(context.ChannelNumber, startTime, epgEntries);
|
||||
foreach (Dictionary<string, object> templateData in maybeEpgData)
|
||||
{
|
||||
foreach (KeyValuePair<string, object> variable in templateData)
|
||||
{
|
||||
result.Add(variable.Key, variable.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<string> GetTemplatedYaml(string fileName, Dictionary<string, object> variables)
|
||||
{
|
||||
string yaml = await localFileSystem.ReadAllText(fileName);
|
||||
try
|
||||
{
|
||||
var scriptObject = new ScriptObject();
|
||||
scriptObject.Import(variables, renamer: member => member.Name);
|
||||
scriptObject.Import("convert_timezone", templateFunctions.ConvertTimeZone);
|
||||
scriptObject.Import("format_datetime", templateFunctions.FormatDateTime);
|
||||
|
||||
var context = new TemplateContext { MemberRenamer = member => member.Name };
|
||||
context.PushGlobal(scriptObject);
|
||||
return await Template.Parse(yaml).RenderAsync(context);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return yaml;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"epg_entries:\s*(\d+)")]
|
||||
private static partial Regex EpgEntriesRegex();
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ public class FakeLocalFileSystem : ILocalFileSystem
|
||||
|
||||
public Unit EmptyFolder(string folder) => Unit.Default;
|
||||
public Task<string> ReadAllText(string path) => throw new NotImplementedException();
|
||||
public Task<string[]> ReadAllLines(string path) => throw new NotImplementedException();
|
||||
|
||||
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
|
||||
|
||||
private static List<DirectoryInfo> Split(DirectoryInfo path)
|
||||
|
||||
Reference in New Issue
Block a user