feat(text): composite shared text rasters in output
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
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user