Files
LlamaCasty/Services/TextOverlayRenderer.cs
gramps 5f7bcc2425 fix(text): show selected formatting and style controls
Use category-filtered font and selected-item dropdowns, separate horizontal and vertical alignment, and reuse the border-color palette templates for text color. Persist vertical alignment additively. Follow the established static text-source pattern: https://obsproject.com/kb/text-sources
2026-11-04 17:05:06 -08:00

159 lines
5.7 KiB
C#

using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Models;
namespace ytLive.Services;
public sealed class TextOverlayRenderer
{
private const int MaxRasterDimension = 4096;
private readonly ConcurrentDictionary<Source, CacheEntry> _cache = new();
public VideoFrame? RenderFrame(Source source)
=> _cache.TryGetValue(source, out var entry) ? entry.Frame : null;
public void Remove(Source source) => _cache.TryRemove(source, out _);
public void Retain(IEnumerable<Source> sources)
{
var active = new HashSet<Source>(sources);
foreach (var source in _cache.Keys)
if (!active.Contains(source))
_cache.TryRemove(source, out _);
}
public void UpdatePreview(Source source)
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
{
dispatcher.Invoke(() => UpdatePreview(source));
return;
}
var config = Capture(source);
if (_cache.TryGetValue(source, out var cached) && cached.Config == config)
{
if (!ReferenceEquals(source.VideoImageSource, cached.Preview))
source.VideoImageSource = cached.Preview;
return;
}
var frame = Rasterize(config);
var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
_cache[source] = new CacheEntry(config, frame, bitmap);
source.VideoImageSource = bitmap;
}
private static RenderConfig Capture(Source source) => new(
source.TextContent,
source.TextFontFamily,
source.TextFontSize,
source.TextColor,
source.TextBold,
source.TextItalic,
source.TextUnderline,
source.TextAlignment,
source.TextVerticalAlignment,
RasterDimension(source.Width),
RasterDimension(source.Height));
private static int RasterDimension(double value) =>
double.IsFinite(value) ? (int)Math.Clamp(Math.Ceiling(value), 1, MaxRasterDimension) : 1;
private static VideoFrame Rasterize(RenderConfig config)
{
var visual = new DrawingVisual();
if (!string.IsNullOrEmpty(config.Content))
{
using var drawing = visual.RenderOpen();
var family = new FontFamily(config.FontFamily);
var typeface = new Typeface(
family,
config.Italic ? FontStyles.Italic : FontStyles.Normal,
config.Bold ? FontWeights.Bold : FontWeights.Normal,
FontStretches.Normal);
var brush = new SolidColorBrush(ParseColor(config.Color));
brush.Freeze();
var formatted = new FormattedText(
config.Content,
CultureInfo.CurrentUICulture,
FlowDirection.LeftToRight,
typeface,
config.FontSize,
brush,
1.0)
{
MaxTextWidth = config.Width,
MaxTextHeight = config.Height,
TextAlignment = config.Alignment switch
{
TextLayerAlignment.Center => System.Windows.TextAlignment.Center,
TextLayerAlignment.Right => System.Windows.TextAlignment.Right,
_ => System.Windows.TextAlignment.Left,
},
Trimming = TextTrimming.CharacterEllipsis,
};
if (config.Underline)
formatted.SetTextDecorations(TextDecorations.Underline, 0, config.Content.Length);
var top = config.VerticalAlignment switch
{
TextLayerVerticalAlignment.Center => Math.Max(0, (config.Height - formatted.Height) / 2),
TextLayerVerticalAlignment.Bottom => Math.Max(0, config.Height - formatted.Height),
_ => 0,
};
drawing.DrawText(formatted, new Point(0, top));
}
var bitmap = new RenderTargetBitmap(config.Width, config.Height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var pixels = new byte[config.Width * config.Height * 4];
bitmap.CopyPixels(pixels, config.Width * 4, 0);
UnpremultiplyAlpha(pixels);
return new VideoFrame(config.Width, config.Height, pixels);
}
private static Color ParseColor(string hex) =>
(Color)ColorConverter.ConvertFromString(hex)!;
private static void UnpremultiplyAlpha(byte[] pixels)
{
for (var i = 0; i < pixels.Length; i += 4)
{
var alpha = pixels[i + 3];
if (alpha == 0)
{
pixels[i] = pixels[i + 1] = pixels[i + 2] = 0;
continue;
}
if (alpha == 255) continue;
pixels[i] = Unpremultiply(pixels[i], alpha);
pixels[i + 1] = Unpremultiply(pixels[i + 1], alpha);
pixels[i + 2] = Unpremultiply(pixels[i + 2], alpha);
}
}
private static byte Unpremultiply(byte value, byte alpha) =>
(byte)Math.Min(255, (value * 255 + alpha / 2) / alpha);
private sealed record CacheEntry(RenderConfig Config, VideoFrame Frame, WriteableBitmap Preview);
private readonly record struct RenderConfig(
string Content,
string FontFamily,
double FontSize,
string Color,
bool Bold,
bool Italic,
bool Underline,
TextLayerAlignment Alignment,
TextLayerVerticalAlignment VerticalAlignment,
int Width,
int Height);
}