Audit + refactor: guard image decode/surface, clamp scrim & font size, Lazy typeface, dedupe backdrop lookup, drop unwired special-views scaffold
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using SkiaSharp;
|
||||
|
||||
@@ -6,62 +7,45 @@ namespace Jellyfin.Plugin.Placard;
|
||||
/// <summary>Composites a library name onto a backdrop, matching Jellyfin's default cover style.</summary>
|
||||
public static class CardRenderer
|
||||
{
|
||||
private static SKTypeface? _typeface;
|
||||
private const float FitWidthFraction = 0.90f; // label spans at most this much of the width
|
||||
private const float ShadowBlurDivisor = 20f; // blur radius = fontSize / this
|
||||
private const float ShadowOffsetDivisor = 28f; // shadow offset = fontSize / this
|
||||
private const int MinFontSize = 12;
|
||||
private const int JpegQuality = 92;
|
||||
|
||||
private static SKTypeface Typeface
|
||||
private static readonly Lazy<SKTypeface> LazyTypeface = new(LoadTypeface);
|
||||
|
||||
private static SKTypeface Typeface => LazyTypeface.Value;
|
||||
|
||||
private static SKTypeface LoadTypeface()
|
||||
{
|
||||
get
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
using var stream = asm.GetManifestResourceStream("Jellyfin.Plugin.Placard.Fonts.NotoSans-Bold.ttf");
|
||||
if (stream is null)
|
||||
{
|
||||
if (_typeface != null)
|
||||
{
|
||||
return _typeface;
|
||||
}
|
||||
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
using var s = asm.GetManifestResourceStream("Jellyfin.Plugin.Placard.Fonts.NotoSans-Bold.ttf");
|
||||
_typeface = s != null
|
||||
? SKTypeface.FromStream(s)
|
||||
: SKTypeface.FromFamilyName("sans-serif", SKFontStyle.Bold);
|
||||
return _typeface;
|
||||
return SKTypeface.FromFamilyName("sans-serif", SKFontStyle.Bold);
|
||||
}
|
||||
|
||||
// Copy into SKData so the typeface does not depend on the (disposed) resource stream.
|
||||
using var data = SKData.Create(stream);
|
||||
return SKTypeface.FromData(data) ?? SKTypeface.FromFamilyName("sans-serif", SKFontStyle.Bold);
|
||||
}
|
||||
|
||||
/// <summary>Render <paramref name="label"/> centered on the image at <paramref name="backdropPath"/>.</summary>
|
||||
/// <param name="scrim">Darkening overlay opacity, 0-255.</param>
|
||||
/// <param name="fontHeightPct">Label height as a percent of image height.</param>
|
||||
public static byte[] Render(string backdropPath, string label, int scrim, int fontHeightPct)
|
||||
{
|
||||
using var input = SKBitmap.Decode(backdropPath);
|
||||
return RenderBitmap(input, label, scrim, fontHeightPct);
|
||||
}
|
||||
using var input = SKBitmap.Decode(backdropPath)
|
||||
?? throw new InvalidOperationException($"Placard could not decode backdrop image: {backdropPath}");
|
||||
|
||||
/// <summary>Generate SMPTE color bars and label them (for Live TV, which has no media backdrop).</summary>
|
||||
public static byte[] RenderColorBars(string label, int scrim)
|
||||
{
|
||||
const int w = 1920;
|
||||
const int h = 1080;
|
||||
using var bmp = new SKBitmap(w, h);
|
||||
using (var canvas = new SKCanvas(bmp))
|
||||
{
|
||||
var cols = new[]
|
||||
{
|
||||
new SKColor(192, 192, 192), new SKColor(192, 192, 0), new SKColor(0, 192, 192),
|
||||
new SKColor(0, 192, 0), new SKColor(192, 0, 192), new SKColor(192, 0, 0), new SKColor(0, 0, 192)
|
||||
};
|
||||
float bw = (float)w / cols.Length;
|
||||
for (int i = 0; i < cols.Length; i++)
|
||||
{
|
||||
using var p = new SKPaint { Color = cols[i] };
|
||||
canvas.DrawRect(i * bw, 0, bw + 1, h, p);
|
||||
}
|
||||
}
|
||||
|
||||
return RenderBitmap(bmp, label, scrim, 20);
|
||||
}
|
||||
|
||||
private static byte[] RenderBitmap(SKBitmap input, string label, int scrim, int fontHeightPct)
|
||||
{
|
||||
int w = input.Width;
|
||||
int h = input.Height;
|
||||
using var surface = SKSurface.Create(new SKImageInfo(w, h));
|
||||
scrim = Math.Clamp(scrim, 0, 255);
|
||||
fontHeightPct = Math.Clamp(fontHeightPct, 5, 40);
|
||||
|
||||
using var surface = SKSurface.Create(new SKImageInfo(w, h))
|
||||
?? throw new InvalidOperationException($"Placard could not create a {w}x{h} drawing surface");
|
||||
var canvas = surface.Canvas;
|
||||
canvas.DrawBitmap(input, 0, 0);
|
||||
|
||||
@@ -70,20 +54,22 @@ public static class CardRenderer
|
||||
canvas.DrawRect(0, 0, w, h, scrimPaint);
|
||||
}
|
||||
|
||||
using var text = new SKPaint { Typeface = Typeface, IsAntialias = true, Color = SKColors.White };
|
||||
|
||||
// Shrink until the label fits within FitWidthFraction of the width.
|
||||
float size = h * fontHeightPct / 100f;
|
||||
using var measure = new SKPaint { Typeface = Typeface, IsAntialias = true };
|
||||
for (; size > 12; size -= 4)
|
||||
for (; size > MinFontSize; size -= 4)
|
||||
{
|
||||
measure.TextSize = size;
|
||||
if (measure.MeasureText(label) <= w * 0.90f)
|
||||
text.TextSize = size;
|
||||
if (text.MeasureText(label) <= w * FitWidthFraction)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
measure.TextSize = size;
|
||||
text.TextSize = size;
|
||||
SKRect bounds = default;
|
||||
measure.MeasureText(label, ref bounds);
|
||||
text.MeasureText(label, ref bounds);
|
||||
float x = ((w - bounds.Width) / 2f) - bounds.Left;
|
||||
float y = ((h - bounds.Height) / 2f) - bounds.Top;
|
||||
|
||||
@@ -93,25 +79,16 @@ public static class CardRenderer
|
||||
TextSize = size,
|
||||
IsAntialias = true,
|
||||
Color = new SKColor(0, 0, 0, 205),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, size / 20f)
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, size / ShadowBlurDivisor)
|
||||
})
|
||||
{
|
||||
canvas.DrawText(label, x, y + (size / 28f), shadow);
|
||||
canvas.DrawText(label, x, y + (size / ShadowOffsetDivisor), shadow);
|
||||
}
|
||||
|
||||
using (var text = new SKPaint
|
||||
{
|
||||
Typeface = Typeface,
|
||||
TextSize = size,
|
||||
IsAntialias = true,
|
||||
Color = SKColors.White
|
||||
})
|
||||
{
|
||||
canvas.DrawText(label, x, y, text);
|
||||
}
|
||||
canvas.DrawText(label, x, y, text);
|
||||
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Jpeg, 92);
|
||||
using var data = image.Encode(SKEncodedImageFormat.Jpeg, JpegQuality);
|
||||
return data.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,6 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// <summary>Label height as a percent of image height.</summary>
|
||||
public int FontHeightPercent { get; set; } = 20;
|
||||
|
||||
/// <summary>Also label Live TV (SMPTE color bars) and Playlists.</summary>
|
||||
public bool IncludeSpecialViews { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Per-library pinned source titles, one per line as "Library Name=Item Title".
|
||||
/// A pinned library uses that item's backdrop instead of the <see cref="Source"/> rule.
|
||||
|
||||
@@ -45,16 +45,9 @@
|
||||
<input is="emby-input" type="number" id="FontHeightPercent" min="8" max="40" />
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input is="emby-checkbox" type="checkbox" id="IncludeSpecialViews" />
|
||||
<span>Include Live TV (color bars) and Playlists</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="PinnedSources">Pinned sources</label>
|
||||
<textarea is="emby-textarea" id="PinnedSources" rows="6"
|
||||
<textarea id="PinnedSources" rows="6" class="emby-input"
|
||||
placeholder="Movies=The Dark Knight Anime=Death Note"></textarea>
|
||||
<div class="fieldDescription">
|
||||
One per line as <code>Library Name=Item Title</code>. Pinned libraries use that title's
|
||||
@@ -80,7 +73,6 @@
|
||||
document.querySelector('#Source').value = config.Source;
|
||||
document.querySelector('#ScrimOpacity').value = config.ScrimOpacity;
|
||||
document.querySelector('#FontHeightPercent').value = config.FontHeightPercent;
|
||||
document.querySelector('#IncludeSpecialViews').checked = config.IncludeSpecialViews;
|
||||
document.querySelector('#PinnedSources').value = config.PinnedSources || '';
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
@@ -94,7 +86,6 @@
|
||||
config.Source = parseInt(document.querySelector('#Source').value, 10);
|
||||
config.ScrimOpacity = parseInt(document.querySelector('#ScrimOpacity').value, 10);
|
||||
config.FontHeightPercent = parseInt(document.querySelector('#FontHeightPercent').value, 10);
|
||||
config.IncludeSpecialViews = document.querySelector('#IncludeSpecialViews').checked;
|
||||
config.PinnedSources = document.querySelector('#PinnedSources').value;
|
||||
ApiClient.updatePluginConfiguration(PlacardPluginId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
|
||||
@@ -19,6 +19,12 @@ namespace Jellyfin.Plugin.Placard;
|
||||
/// <summary>Scheduled task that (re)generates a labeled Primary image for each library.</summary>
|
||||
public class PlacardTask : IScheduledTask
|
||||
{
|
||||
private static readonly BaseItemKind[] SourceTypes =
|
||||
{
|
||||
BaseItemKind.Movie, BaseItemKind.Series,
|
||||
BaseItemKind.MusicArtist, BaseItemKind.BoxSet, BaseItemKind.MusicAlbum
|
||||
};
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly ILogger<PlacardTask> _logger;
|
||||
@@ -62,6 +68,7 @@ public class PlacardTask : IScheduledTask
|
||||
|
||||
var pins = ParsePins(config.PinnedSources);
|
||||
_logger.LogInformation("Placard: processing {Count} libraries ({Pins} pinned)", folders.Count, pins.Count);
|
||||
|
||||
int done = 0;
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
@@ -79,11 +86,8 @@ public class PlacardTask : IScheduledTask
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly BaseItemKind[] SourceTypes =
|
||||
{
|
||||
BaseItemKind.Movie, BaseItemKind.Series,
|
||||
BaseItemKind.MusicArtist, BaseItemKind.BoxSet, BaseItemKind.MusicAlbum
|
||||
};
|
||||
private static ItemImageInfo? Backdrop(BaseItem item)
|
||||
=> item.GetImages(ImageType.Backdrop).FirstOrDefault();
|
||||
|
||||
private static Dictionary<string, string> ParsePins(string raw)
|
||||
{
|
||||
@@ -106,7 +110,7 @@ public class PlacardTask : IScheduledTask
|
||||
return pins;
|
||||
}
|
||||
|
||||
private (ItemSortBy, SortOrder)[] OrderFor(SourceRule rule) => rule switch
|
||||
private static (ItemSortBy, SortOrder)[] OrderFor(SourceRule rule) => rule switch
|
||||
{
|
||||
SourceRule.Newest => new[] { (ItemSortBy.DateCreated, SortOrder.Descending) },
|
||||
SourceRule.Random => new[] { (ItemSortBy.Random, SortOrder.Ascending) },
|
||||
@@ -115,20 +119,20 @@ public class PlacardTask : IScheduledTask
|
||||
|
||||
private BaseItem? PickSource(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary<string, string> pins)
|
||||
{
|
||||
// Pinned title wins.
|
||||
// A pinned title wins, when it exists and has a backdrop.
|
||||
if (pins.TryGetValue(folder.Name, out var pinned) && !string.IsNullOrWhiteSpace(pinned))
|
||||
{
|
||||
var byName = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
var candidates = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
Parent = folder,
|
||||
Recursive = true,
|
||||
SearchTerm = pinned,
|
||||
IncludeItemTypes = SourceTypes,
|
||||
Limit = 15
|
||||
});
|
||||
var match = byName.FirstOrDefault(i =>
|
||||
string.Equals(i.Name, pinned, StringComparison.OrdinalIgnoreCase) && i.GetImages(ImageType.Backdrop).Any())
|
||||
?? byName.FirstOrDefault(i => i.GetImages(ImageType.Backdrop).Any());
|
||||
}).Where(i => Backdrop(i) is not null).ToList();
|
||||
|
||||
var match = candidates.FirstOrDefault(i => string.Equals(i.Name, pinned, StringComparison.OrdinalIgnoreCase))
|
||||
?? candidates.FirstOrDefault();
|
||||
if (match is not null)
|
||||
{
|
||||
return match;
|
||||
@@ -144,23 +148,16 @@ public class PlacardTask : IScheduledTask
|
||||
IncludeItemTypes = SourceTypes,
|
||||
OrderBy = OrderFor(config.Source),
|
||||
Limit = 60
|
||||
}).FirstOrDefault(i => i.GetImages(ImageType.Backdrop).Any());
|
||||
}).FirstOrDefault(i => Backdrop(i) is not null);
|
||||
}
|
||||
|
||||
private async Task ProcessFolderAsync(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary<string, string> pins, CancellationToken cancellationToken)
|
||||
{
|
||||
var source = PickSource(folder, config, pins);
|
||||
|
||||
if (source is null)
|
||||
var backdropPath = source is null ? null : Backdrop(source)?.Path;
|
||||
if (source is null || string.IsNullOrEmpty(backdropPath) || !File.Exists(backdropPath))
|
||||
{
|
||||
_logger.LogWarning("Placard: no backdrop candidate found for {Name}", folder.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var backdropPath = source.GetImages(ImageType.Backdrop).First().Path;
|
||||
if (string.IsNullOrEmpty(backdropPath) || !File.Exists(backdropPath))
|
||||
{
|
||||
_logger.LogWarning("Placard: backdrop path missing for {Name}", folder.Name);
|
||||
_logger.LogWarning("Placard: no usable backdrop found for {Name}", folder.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user