feat(732): On Now / Next gets a background box, and is on by default (#843)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
This commit was merged in pull request #843.
This commit is contained in:
2026-08-26 19:28:25 +00:00
committed by timothy
parent f2551b778e
commit ba6a4b08aa
22 changed files with 2501 additions and 27 deletions
@@ -4,12 +4,16 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public static class GraphicsElementSeeder
{
private const string OnNowNextYaml =
// The pre-#732 default, kept verbatim so an installation still carrying it byte-for-byte can
// be recognised as unmodified and upgraded. Never edit an entry here -- it is a fingerprint of
// what we shipped, not a template. Add a new entry when the current default changes again.
private const string OnNowNextYamlV1 =
"""
name: On Now / Next
epg_entries: 2
@@ -51,18 +55,88 @@ public static class GraphicsElementSeeder
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
""";
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
{
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
if (alreadySeeded)
{
return;
}
private const string OnNowNextYaml =
"""
name: On Now / Next
epg_entries: 2
location: BottomLeft
horizontal_margin_percent: 4
vertical_margin_percent: 8
width_percent: 42
text_fit: Wrap
text_align: Left
z_index: 100
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
# #732: a translucent box carries legibility over both bright and dark content. The halo is
# cut from 2 to 1 rather than dropped -- the box is translucent, so bright content still
# shows through behind the glyphs, but 2px of halo ON TOP of a box over-darkens the text.
background_color: "#000000"
background_opacity_percent: 65
background_padding: 14
background_corner_radius: 8
# A translucent black box vanishes into dark content, so the box needs an edge of its own.
# Low-alpha white reads as a hairline on dark frames without becoming a hard line on bright ones.
border_color: "#59FFFFFF"
border_width: 1
base_style: now
styles:
- name: now
font_family: "Noto Sans"
font_size: 30
font_weight: 700
text_color: "#FFFFFF"
halo_color: "#000000"
halo_width: 1
- name: sub
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 1
- name: next
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 1
text: |
[now]NOW {{ Epg[0].Title }}[/now]
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
""";
// Every default we have ever shipped, most recent first. A file matching one of these was
// written by us and never touched, so replacing it is an upgrade rather than a clobber.
private static readonly string[] SupersededDefaults = [OnNowNextYamlV1];
public static async Task SeedOnNowNext(
TvContext context,
IFileSystem fileSystem,
ILogger logger,
CancellationToken cancellationToken)
{
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
if (alreadySeeded)
{
// Already-seeded installations never revisit the file, so a change to the default would
// otherwise reach new databases only. Upgrade the ones still carrying an untouched
// earlier default; anything an operator edited no longer matches and is left alone.
//
// Deliberately no CreateDirectory on this branch: before #732 it touched the filesystem
// not at all, so an already-seeded install stays bootable on a read-only /config.
await UpgradeUnmodifiedTemplate(fileSystem, target, logger, cancellationToken);
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
return;
}
if (!fileSystem.Directory.Exists(folder))
{
fileSystem.Directory.CreateDirectory(folder);
@@ -78,5 +152,218 @@ public static class GraphicsElementSeeder
new ConfigElement { Key = seededKey, Value = "true" },
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
}
/// <summary>
/// `RefreshGraphicsElements` is what normally turns a template file into a `GraphicsElement` row,
/// but it runs on the scheduler/stream-start path -- long after startup. Creating the row here
/// removes that ordering dependency, so `AttachOnNowNextByDefault` below can never mark itself
/// done against an element that simply had not been discovered yet.
/// </summary>
private static async Task EnsureBuiltInElementRow(
TvContext context,
IFileSystem fileSystem,
string target,
CancellationToken cancellationToken)
{
if (!fileSystem.File.Exists(target))
{
return;
}
bool exists = await context.GraphicsElements.AnyAsync(e => e.Path == target, cancellationToken);
if (exists)
{
return;
}
// Name is display-only (identity is the filename), but leaving it null sorts the built-in
// element into the unnamed bucket at the bottom of the SPA list until the first refresh.
await context.GraphicsElements.AddAsync(
new Core.Domain.GraphicsElement
{
Path = target,
Kind = GraphicsElementKind.Text,
Name = GraphicsElementDefaults.OnNowNextName
},
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// #732: the On Now / Next overlay is a default, not an opt-in. Existing channels predate that
/// decision, so attach the built-in element to them once.
/// </summary>
/// <remarks>
/// The marker is written only once the built-in element RESOLVES, so an install whose row does
/// not exist yet is retried on the next startup rather than stranded permanently. Once written,
/// no channel is ever re-attached. While still armed the backfill cannot tell a deliberately
/// cleared channel from an untouched one -- a single global flag cannot express both
/// properties; see <c>graphics.on-now-next-on-by-default</c> for why that trade is made this
/// way. Every channel created after the marker gets the element from
/// <c>ChannelGraphicsDefaults.Attach</c> instead, which BOTH create paths call.
/// </remarks>
public static async Task AttachOnNowNextByDefault(TvContext context, CancellationToken cancellationToken)
{
string key = ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key;
if (await context.ConfigElements.AnyAsync(c => c.Key == key, cancellationToken))
{
return;
}
Option<int> maybeElementId = await GetBuiltInElementId(context, cancellationToken);
if (maybeElementId.IsNone)
{
// Nothing to attach TO. Writing the marker here would strand every channel permanently
// on the one population this exists for, so stay armed and try again next startup.
return;
}
foreach (int elementId in maybeElementId)
{
// HLS Direct has no frame pipeline to draw into, so an attachment there would be inert
// while still reading as "on" in the editor.
List<int> channelIds = await context.Channels
.Where(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
.Where(c => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != elementId))
.Select(c => c.Id)
.ToListAsync(cancellationToken);
foreach (int channelId in channelIds)
{
await context.AddAsync(
new ChannelGraphicsElement { ChannelId = channelId, GraphicsElementId = elementId },
cancellationToken);
}
}
await context.ConfigElements.AddAsync(
new ConfigElement { Key = key, Value = "true" },
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Identity is the filename, never the user-editable Name (the #67 lesson carried into #74).
/// The Kind is part of it: the five template folders are separate namespaces, so an unrelated
/// image/motion/subtitle/script element may legitimately be named `on-now-next.yml` too, and
/// filename alone would hand back whichever row the unordered query happened to return first.
/// </summary>
public static async Task<Option<int>> GetBuiltInElementId(
TvContext context,
CancellationToken cancellationToken)
{
List<(int Id, string Path)> candidates = await context.GraphicsElements
.Where(e => e.Kind == GraphicsElementKind.Text)
.Select(e => new { e.Id, e.Path })
.ToListAsync(cancellationToken)
.Map(rows => rows.Select(r => (r.Id, r.Path)).ToList());
var matches = candidates
.Where(c => System.IO.Path.GetFileName(c.Path) == GraphicsElementDefaults.OnNowNextFileName)
.OrderBy(c => c.Id)
.ToList();
// Lowest id wins if two text templates somehow share the filename, so the choice is stable
// across restarts rather than dependent on query order.
return matches.Count == 0 ? Option<int>.None : matches[0].Id;
}
private static async Task UpgradeUnmodifiedTemplate(
IFileSystem fileSystem,
string target,
ILogger logger,
CancellationToken cancellationToken)
{
// This runs inside the blocking database-startup path, ahead of DatabaseIsReady(). Before
// #732 the already-seeded branch never touched the filesystem at all, so an unreadable or
// read-only template is a state that used to boot fine -- it must not become a failure to
// start. Cosmetic upgrade, best effort.
try
{
if (!fileSystem.File.Exists(target))
{
return;
}
string existing = await fileSystem.File.ReadAllTextAsync(target, cancellationToken);
if (!SupersededDefaults.Any(d => IsSameTemplate(existing, d)))
{
return;
}
// Write-then-move, never write in place. WriteAllTextAsync truncates first, so an
// interrupted write (disk full, IO fault, cancellation) would leave a partial file that
// matches no fingerprint and is therefore never repaired on a later boot -- the overlay
// would just be gone, permanently, on every channel carrying it.
//
// The temp name is random per call. A fixed one is shared by two containers on the same
// config volume, where one can truncate it while the other is mid-write and then rename
// the partial file over the live template. The process id is NOT good enough here: the
// image uses an exec-form ENTRYPOINT, so every container's PID namespace makes this
// process 1 and every container computes the same name. A random name also means a temp
// left by a crashed earlier boot is never reused. Only ever delete the path this call
// created.
string temp = $"{target}.{fileSystem.Path.GetRandomFileName()}.upgrade.tmp";
try
{
await fileSystem.File.WriteAllTextAsync(temp, OnNowNextYaml, cancellationToken);
// Deliberately NO in-place fallback when this throws. rename(2) onto a mountpoint
// is EBUSY, so a single-file bind mount of this template will not be upgraded --
// accepted, because reaching that case needs a pinned file that is ALSO byte-identical
// to a shipped default, and the alternative is reintroducing the truncation this
// whole dance exists to prevent, on every IO fault rather than just that one.
fileSystem.File.Move(temp, target, true);
}
finally
{
// Cleanup must never REPLACE the exception that brought us here. Without this inner
// catch, a delete that throws while unwinding a cancellation swaps the
// OperationCanceledException for an IOException, which the outer filter then
// swallows -- so a real shutdown would be silently downgraded to a warning.
try
{
if (fileSystem.File.Exists(temp))
{
fileSystem.File.Delete(temp);
}
}
catch (Exception cleanupEx)
{
logger.LogDebug(cleanupEx, "Could not remove the temporary upgrade file {Path}", temp);
}
}
}
// Recoverable filesystem faults only. Catching everything would swallow genuinely fatal
// runtime failures (OutOfMemory and friends) and continue booting a compromised process;
// letting IO escape would turn a file permission into a restart loop. Cancellation
// propagates so shutdown is not swallowed.
// OperationCanceledException is deliberately absent from this list so a real shutdown
// propagates -- but only a real one: an OCE raised while the token is NOT cancelled is just
// another faulty read, and letting it escape is the restart loop this catch exists to stop.
catch (Exception ex) when ((ex is IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException)
|| (ex is OperationCanceledException
&& !cancellationToken.IsCancellationRequested))
{
logger.LogWarning(
ex,
"Could not upgrade the built-in graphics template at {Path}; leaving it as-is",
target);
}
}
// Compare on content, ignoring the line endings and trailing whitespace an editor or a volume
// mount may rewrite. This is a fingerprint check, not a parse: anything that is not one of our
// own shipped defaults must fall through untouched.
private static bool IsSameTemplate(string left, string right) =>
string.Equals(Normalize(left), Normalize(right), StringComparison.Ordinal);
private static string Normalize(string value) =>
value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
}
@@ -14,6 +14,10 @@ public partial class TextElement(
ILogger logger)
: GraphicsElement, IDisposable
{
// Far larger than any sane overlay on an 8K frame, and small enough that every downstream
// int cast stays well inside range.
private const float MaxBoxDimension = 10_000f;
private static readonly Regex StylePattern = StyleRegex();
private SKBitmap _image;
private SKPointI _location;
@@ -62,30 +66,96 @@ public partial class TextElement(
}
}
BackgroundBox box = BuildBackgroundBox();
RichTextKit.TextBlock textBlock = BuildTextBlock(textElement.Text);
if (textElement.WidthPercent.HasValue)
// Padding and border sit OUTSIDE the laid-out text on every side, so they shrink the
// space the text may occupy and grow the bitmap that holds it. Zero when there is no
// box, which reproduces the pre-#732 geometry exactly.
//
// Round ONCE, here, and use the same integer on both sides: the bitmap grows by
// 2 * insetPixels, so subtracting the unrounded inset from the wrap budget would let a
// fractional padding push the finished box a pixel past width_percent.
var insetPixels = (int)Math.Ceiling(box?.Inset ?? 0f);
// Bound the inset against the FRAME even when there is no width_percent. Sanitize caps
// each field individually, but padding and border add up, and without a budget nothing
// else clamps them -- a two-field fat-finger would otherwise allocate a bitmap far
// larger than the frame it is drawn onto. Pre-#732 no config value could inflate the
// bitmap independently of the measured text.
int frameInsetCap = Math.Max(0, Math.Min(context.FrameSize.Width, context.FrameSize.Height) / 2);
if (insetPixels > frameInsetCap)
{
logger.LogWarning(
"Background padding/border of {Inset}px exceeds the frame; clamping to {Clamped}px",
insetPixels,
frameInsetCap);
insetPixels = frameInsetCap;
box = box?.ClampedTo(frameInsetCap);
}
// A width_percent of 1e300 makes maxWidth Infinity, and every int cast below it is then
// unspecified. Treat a non-finite budget as "no budget", which is what an absent
// width_percent already means.
if (textElement.WidthPercent.HasValue
&& float.IsFinite((float)(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width)))
{
var maxWidth = (float)Math.Round(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width);
// A padding wider than the budget itself cannot be honoured AND stay inside it.
// Clamp the inset rather than squeezing the text to 1px: an unclamped floor turns a
// fat-fingered background_padding into a box several times the requested width.
int maxInset = Math.Max(0, (int)Math.Floor((maxWidth - 1) / 2));
if (insetPixels > maxInset)
{
logger.LogWarning(
"Background padding/border of {Inset}px does not fit within width_percent "
+ "({MaxWidth}px); clamping to {Clamped}px",
insetPixels,
maxWidth,
maxInset);
// Clamp the BOX, not just the bitmap's inset. Shrinking insetPixels alone leaves
// DrawBackgroundBox stroking at the original border width, which is centred on a
// rect that no longer has room for it -- the stroke then floods the element.
insetPixels = maxInset;
box = box?.ClampedTo(maxInset);
}
// width_percent bounds the ELEMENT, so the text gets what is left after the insets.
// With no box the budget is passed through untouched -- not through Math.Max -- so a
// width_percent that rounds to 0 keeps its exact pre-#732 behavior.
float textMaxWidth = insetPixels == 0
? maxWidth
: Math.Max(1f, maxWidth - (2 * insetPixels));
switch (textElement.Fit)
{
case TextFit.Wrap:
textBlock.MaxWidth = maxWidth;
textBlock.MaxWidth = textMaxWidth;
break;
case TextFit.Scale:
FitTextBlock(textBlock, maxWidth);
FitTextBlock(textBlock, textMaxWidth);
break;
}
}
_image = new SKBitmap(
(int)Math.Ceiling(textBlock.MeasuredWidth),
(int)Math.Ceiling(textBlock.MeasuredHeight));
(int)Math.Ceiling(textBlock.MeasuredWidth) + (2 * insetPixels),
(int)Math.Ceiling(textBlock.MeasuredHeight) + (2 * insetPixels));
using (var canvas = new SKCanvas(_image))
{
canvas.Clear(SKColors.Transparent);
textBlock.Paint(canvas, new SKPoint(0, 0));
if (box is not null)
{
DrawBackgroundBox(canvas, box, _image.Width, _image.Height);
}
textBlock.Paint(canvas, new SKPoint(insetPixels, insetPixels));
}
var horizontalMargin =
@@ -134,6 +204,158 @@ public partial class TextElement(
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, ZIndex, false));
}
// A background box is drawn only when a colour actually parses. An unparseable colour is
// warned about and skipped rather than substituted, so a typo never silently changes the
// look into something that appears deliberate.
private BackgroundBox BuildBackgroundBox()
{
SKColor? fill = ParseOptionalColor(textElement.BackgroundColor, "background_color");
if (fill.HasValue)
{
fill = ApplyOpacityPercent(fill.Value, textElement.BackgroundOpacityPercent);
}
SKColor? border = ParseOptionalColor(textElement.BorderColor, "border_color");
// A border colour with no explicit width means a hairline border, not an invisible one:
// "border_color set, nothing drawn" is the more confusing of the two readings.
float borderWidth = Sanitize(textElement.BorderWidth ?? 1, "border_width");
if (!border.HasValue)
{
borderWidth = 0;
}
if (!fill.HasValue && borderWidth <= 0)
{
return null;
}
return new BackgroundBox(
fill,
border,
borderWidth,
Sanitize(textElement.BackgroundCornerRadius, "background_corner_radius"),
Sanitize(textElement.BackgroundPadding, "background_padding"));
}
// YAML happily yields 1e100 or NaN. Cast to float those become Infinity/NaN, and
// (int)Math.Ceiling(Infinity) is an unspecified value -- in practice int.MinValue, which sails
// straight past every `> maxInset` clamp and can wrap 2 * inset back to zero. Sanitize at the
// boundary so no downstream arithmetic ever sees a non-finite value.
private float Sanitize(double? value, string fieldName)
{
if (value is not { } raw)
{
return 0f;
}
if (double.IsNaN(raw) || raw < 0)
{
logger.LogWarning("Ignoring out-of-range {Field} value {Value}", fieldName, raw);
return 0f;
}
if (raw > MaxBoxDimension)
{
logger.LogWarning(
"Clamping {Field} value {Value} to {Max}",
fieldName,
raw,
MaxBoxDimension);
return MaxBoxDimension;
}
return (float)raw;
}
private SKColor? ParseOptionalColor(string value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (SKColor.TryParse(value, out SKColor parsed))
{
return parsed;
}
logger.LogWarning(
"Unable to parse {Field} value {Value}; that part of the background box will not be drawn",
fieldName,
value);
return null;
}
private static SKColor ApplyOpacityPercent(SKColor color, int? opacityPercent)
{
if (opacityPercent is not { } percent)
{
return color;
}
int clamped = Math.Clamp(percent, 0, 100);
return color.WithAlpha((byte)Math.Round(color.Alpha * clamped / 100.0));
}
private static void DrawBackgroundBox(SKCanvas canvas, BackgroundBox box, int width, int height)
{
// Skia strokes centred on the path, so half the border would fall outside the bitmap and
// be clipped. Inset the rect by half the width to keep the whole border visible.
float half = box.BorderWidth / 2f;
var rect = new SKRect(half, half, width - half, height - half);
// A radius larger than half the shorter side is not expressible as a rounded rect.
float radius = Math.Min(box.CornerRadius, Math.Min(rect.Width, rect.Height) / 2f);
radius = Math.Max(0, radius);
if (box.Fill is { } fill)
{
using var fillPaint = new SKPaint
{
Color = fill,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRoundRect(rect, radius, radius, fillPaint);
}
if (box.Border is { } border && box.BorderWidth > 0)
{
using var borderPaint = new SKPaint
{
Color = border,
Style = SKPaintStyle.Stroke,
StrokeWidth = box.BorderWidth,
IsAntialias = true
};
canvas.DrawRoundRect(rect, radius, radius, borderPaint);
}
}
private sealed record BackgroundBox(
SKColor? Fill,
SKColor? Border,
float BorderWidth,
float CornerRadius,
float Padding)
{
public float Inset => Padding + BorderWidth;
// Border first, then whatever is left goes to padding: a border that cannot be drawn inside
// the bitmap is worse than a thin one, and padding degrades gracefully to zero.
public BackgroundBox ClampedTo(float maxInset)
{
float borderWidth = Math.Min(BorderWidth, maxInset);
float padding = Math.Max(0, maxInset - borderWidth);
return this with { BorderWidth = borderWidth, Padding = padding };
}
}
private RichTextKit.TextBlock BuildTextBlock(string textToRender)
{
var textBlock = new RichTextKit.TextBlock
@@ -211,6 +433,17 @@ public partial class TextElement(
finalStyle.TextColor = parsedColor;
}
// Halo is per-style in the schema and was being dropped here, so a non-base style's
// halo_* silently inherited the base style's. The seeded template only looked correct
// because all three of its styles declare the same halo.
finalStyle.HaloWidth = s.HaloWidth ?? finalStyle.HaloWidth;
finalStyle.HaloBlur = s.HaloBlur ?? finalStyle.HaloBlur;
if (s.HaloColor != null && SKColor.TryParse(s.HaloColor, out SKColor parsedHalo))
{
finalStyle.HaloColor = parsedHalo;
}
styles[s.Name] = finalStyle;
}
@@ -286,6 +519,11 @@ public partial class TextElement(
foreach ((string text, RichTextKit.IStyle style) in originalContent)
{
// Carry across every property the YAML schema can set, not just the ones the scale
// needs (the rest are RichTextKit defaults we never touch). Halo and
// line height were being dropped here, which only mattered once #732 gave the box
// insets that can push a previously-fitting element into the Scale path: adding a
// background would then silently remove the halo it sits behind.
var newStyle = new RichTextKit.Style
{
FontFamily = style.FontFamily,
@@ -294,7 +532,11 @@ public partial class TextElement(
FontWidth = style.FontWidth,
FontWeight = style.FontWeight,
LetterSpacing = style.LetterSpacing,
TextColor = style.TextColor
LineHeight = style.LineHeight,
TextColor = style.TextColor,
HaloColor = style.HaloColor,
HaloWidth = style.HaloWidth,
HaloBlur = style.HaloBlur
};
float newSize = newStyle.FontSize * scale;