Raster static text on UI-thread changes and reuse the same transparent BGRA frame in preview and all compositor paths. Cache text frame identity in the frame-pump signature and invalidate static bakes on edits. Follow OBS text-source conventions: https://obsproject.com/kb/text-sources
151 lines
5.3 KiB
C#
151 lines
5.3 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,
|
|
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);
|
|
drawing.DrawText(formatted, new Point(0, 0));
|
|
}
|
|
|
|
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,
|
|
int Width,
|
|
int Height);
|
|
}
|