The broadcast picture is part of the app, so it stops carrying its own colours. Alert cards, the ticker and the branding flash now paint from OutputPalette, which Mirrors DESIGN.md for the brand tokens and owns the alert box's six-event accent language (gold/violet/pink — content, not chrome; the standard has no purple and should not grow one, but they live in ONE file instead of 57 scattered literals). It is a separate type because the renderers run on the frame-pump thread and must not touch application resources. The duplication is made safe by OutputPaletteTests: it parses Themes/Tokens.xaml and fails if any mirrored token drifts, and fails if a renderer reintroduces a colour literal or "Segoe UI". Ticker and flash glyphs now use the bundled Inter instead of Segoe UI. Neon red is the standard live red — one red in the product, including the credit. Fixed on the way: the palette's font fields were static pack-URI initialisers, which throw TypeInitializationException in any host without a WPF Application (the test process caught it); they are lazy now with a name fallback.
390 lines
17 KiB
C#
390 lines
17 KiB
C#
using System.Globalization;
|
||
using System.Windows;
|
||
using System.Windows.Media;
|
||
using System.Windows.Media.Imaging;
|
||
using ytLive.Models;
|
||
using ytLive.Services.Compositor;
|
||
|
||
namespace ytLive.Services;
|
||
|
||
/// <summary>
|
||
/// Renders one event's celebration animation into a BGRA8 frame for the AlertBox.
|
||
/// Six event kinds, six UNIQUE animations — each a distinct gesture, palette and
|
||
/// layout (the creator's ruling: these are the monetization shout-outs and free
|
||
/// branded product placement; a one-template recoloring loses all interest in two
|
||
/// iterations). Each card carries the bottom-right brand line so every broadcast
|
||
/// of an event is an in-video ad placement.
|
||
///
|
||
/// <paramref name="t01"/> walks 0→1 over the whole animation; each kind splits it
|
||
/// into enter / hold / exit phases with its own easing.
|
||
/// </summary>
|
||
public sealed class AlertRenderer
|
||
{
|
||
private const double EnterEnd = 0.16;
|
||
private const double ExitStart = 0.80;
|
||
private const string Brand = "made with LlamaCasty!";
|
||
|
||
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
|
||
private static readonly Color White = Colors.White;
|
||
private static readonly Color[] ConfettiPalette =
|
||
{
|
||
OutputPalette.Crimson, OutputPalette.Gold,
|
||
OutputPalette.Tertiary, OutputPalette.Violet,
|
||
OutputPalette.Positive,
|
||
};
|
||
|
||
public VideoFrame Render(ChatMessage e, double t01, int width, int height)
|
||
{
|
||
return e.Kind switch
|
||
{
|
||
ChatEventKind.SuperChat => SuperChat(e, t01, width, height),
|
||
ChatEventKind.SuperSticker => SuperSticker(e, t01, width, height),
|
||
ChatEventKind.NewMember => NewMember(e, t01, width, height),
|
||
ChatEventKind.MemberGift => MemberGift(e, t01, width, height),
|
||
ChatEventKind.GiftReceived => GiftReceived(e, t01, width, height),
|
||
ChatEventKind.MemberMilestone => Milestone(e, t01, width, height),
|
||
_ => SuperChat(e, t01, width, height),
|
||
};
|
||
}
|
||
|
||
private static VideoFrame SuperChat(ChatMessage e, double t, int w, int h)
|
||
{
|
||
var dpi = 96.0;
|
||
var visual = new DrawingVisual();
|
||
using (var ctx = visual.RenderOpen())
|
||
{
|
||
var margin = Math.Max(w, h) * 0.03;
|
||
var cardX = margin;
|
||
var cardW = w - margin * 2;
|
||
var cardH = h - margin * 2;
|
||
var cardCX = cardX + cardW / 2;
|
||
|
||
var enter = Clamp01(t / EnterEnd);
|
||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||
var exitEase = EaseInCubic(exit);
|
||
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
|
||
cardY = Lerp(cardY, -cardH, exitEase);
|
||
|
||
DrawCard(ctx, cardX, cardY, cardW, cardH, "#0d3b2b", "#14532d", 14);
|
||
DrawBrand(ctx, cardX, cardY, cardW, cardH);
|
||
|
||
var shownAmount = e.SuperChatDisplayString
|
||
?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : $"${e.SuperChatAmount:0.00}");
|
||
var fontSize = cardH * 0.24;
|
||
DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, OutputPalette.Gold, cardW);
|
||
|
||
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.56, cardH * 0.13, FontWeights.SemiBold, White, cardW);
|
||
DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, OutputPalette.MintText, cardW * 0.92);
|
||
|
||
if (t >= EnterEnd && t <= ExitStart)
|
||
{
|
||
var sweep = (t - EnterEnd) / (ExitStart - EnterEnd);
|
||
var shineX = cardX + (sweep * 2 - 0.5) * cardW;
|
||
ctx.DrawRectangle(
|
||
new LinearGradientBrush(
|
||
Colors.Transparent,
|
||
Color.FromArgb(0x30, 0xff, 0xff, 0xff),
|
||
new Point(0, 0),
|
||
new Point(1, 0)),
|
||
null,
|
||
new Rect(shineX - cardW * 0.25, cardY, cardW * 0.5, cardH));
|
||
}
|
||
}
|
||
return CreateFrame(visual, w, h, dpi);
|
||
}
|
||
|
||
private static VideoFrame SuperSticker(ChatMessage e, double t, int w, int h)
|
||
{
|
||
var dpi = 96.0;
|
||
var visual = new DrawingVisual();
|
||
using (var ctx = visual.RenderOpen())
|
||
{
|
||
var margin = Math.Max(w, h) * 0.04;
|
||
var cardW = w - margin * 2;
|
||
var cardH = h - margin * 2;
|
||
var cardCX = w / 2.0;
|
||
|
||
var enter = Clamp01(t / EnterEnd);
|
||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||
var scale = EaseOutBack(enter) * (0.85 + 0.15 * (1 - exit));
|
||
if (t > EnterEnd && t <= ExitStart)
|
||
scale = 1.0 + 0.02 * Math.Sin(t * 26.0);
|
||
var alpha = 1.0 - exit;
|
||
|
||
ctx.PushOpacity(alpha);
|
||
|
||
var slick = new RotateTransform(-6, w / 2.0, h / 2.0);
|
||
ctx.PushTransform(slick);
|
||
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
|
||
|
||
DrawCard(ctx, margin, margin, cardW, cardH, "#4a044e", "#7e22ce", 20);
|
||
DrawBrand(ctx, margin, margin, cardW, cardH);
|
||
|
||
var desc = string.IsNullOrWhiteSpace(e.SuperStickerDescription) ? "SUPER STICKER" : e.SuperStickerDescription.ToUpperInvariant();
|
||
DrawCentered(ctx, desc, cardCX, h / 2.0 - cardH * 0.12, cardH * 0.17, FontWeights.Bold, White, cardW * 0.9);
|
||
|
||
DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, OutputPalette.Pink, cardW * 0.9);
|
||
var tier = e.SuperChatDisplayString ?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : null);
|
||
if (!string.IsNullOrWhiteSpace(tier))
|
||
DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, OutputPalette.Gold, cardW * 0.9);
|
||
|
||
ctx.Pop();
|
||
ctx.Pop();
|
||
ctx.Pop();
|
||
}
|
||
return CreateFrame(visual, w, h, dpi);
|
||
}
|
||
|
||
private static VideoFrame NewMember(ChatMessage e, double t, int w, int h)
|
||
{
|
||
var dpi = 96.0;
|
||
var visual = new DrawingVisual();
|
||
using (var ctx = visual.RenderOpen())
|
||
{
|
||
var margin = Math.Max(w, h) * 0.035;
|
||
var cardW = w - margin * 2;
|
||
var cardH = h - margin * 2;
|
||
var cardCX = w / 2.0;
|
||
|
||
var enter = Clamp01(t / EnterEnd);
|
||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||
var cardY = Lerp(-cardH, margin, EaseOutCubic(enter));
|
||
cardY = Lerp(cardY, h, EaseInCubic(exit));
|
||
|
||
DrawCard(ctx, margin, cardY, cardW, cardH, "#2e1065", "#5b21b6", 16);
|
||
DrawBrand(ctx, margin, cardY, cardW, cardH);
|
||
|
||
if (t <= ExitStart)
|
||
{
|
||
var bandP = EaseOutCubic(Clamp01(t / 0.22));
|
||
ctx.DrawRectangle(
|
||
new LinearGradientBrush(Color.FromArgb(0, 0xff, 0xff, 0xff), Colors.Transparent, new Point(0, 0), new Point(0, 1)),
|
||
null,
|
||
new Rect(margin, cardY + cardH * 0.30, cardW * bandP, cardH * 0.18));
|
||
}
|
||
|
||
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.36, cardH * 0.22, FontWeights.Bold, White, cardW * 0.9);
|
||
DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, OutputPalette.Lilac, cardW * 0.9);
|
||
|
||
var level = string.IsNullOrWhiteSpace(e.MembershipLevel) ? "MEMBER" : e.MembershipLevel.ToUpperInvariant();
|
||
DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, OutputPalette.Gold, cardW * 0.9);
|
||
}
|
||
return CreateFrame(visual, w, h, dpi);
|
||
}
|
||
|
||
private static VideoFrame MemberGift(ChatMessage e, double t, int w, int h)
|
||
{
|
||
var dpi = 96.0;
|
||
var visual = new DrawingVisual();
|
||
using (var ctx = visual.RenderOpen())
|
||
{
|
||
var margin = Math.Max(w, h) * 0.035;
|
||
var cardW = w - margin * 2;
|
||
var cardH = h - margin * 2;
|
||
var cardCX = w / 2.0;
|
||
|
||
var enter = Clamp01(t / EnterEnd);
|
||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||
var cardX = Lerp(-cardW, margin, EaseOutCubic(enter));
|
||
cardX = Lerp(cardX, -cardW, EaseInCubic(exit));
|
||
|
||
DrawCard(ctx, cardX, margin, cardW, cardH, "#422006", "#a16207", 16);
|
||
DrawBrand(ctx, cardX, margin, cardW, cardH);
|
||
|
||
var count = Math.Max(1, e.GiftNewMembersCount);
|
||
var spread = 1.0 - exit;
|
||
var hero = count > 1 ? $"×{count}" : "MEMBERSHIP";
|
||
DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, OutputPalette.Gold, cardW * 0.8);
|
||
DrawCentered(ctx, $"{e.AuthorName} is gifting", cardCX, margin + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
|
||
|
||
var chipCount = Math.Min(count, 6);
|
||
for (var i = 0; i < chipCount; i++)
|
||
{
|
||
var slot = (chipCount - 1) * 0.5;
|
||
var gap = cardH * 0.13 * EaseOutCubic(Clamp01(t / 0.30)) * spread;
|
||
var chipX = cardCX + (i - slot) * gap;
|
||
var chipY = margin + cardH * 0.70 + Math.Sin((i + t * 6) * 2.4) * cardH * 0.015;
|
||
ctx.DrawRoundedRectangle(
|
||
new SolidColorBrush(OutputPalette.GoldPale),
|
||
null,
|
||
new Rect(chipX - cardH * 0.05, chipY, cardH * 0.10, cardH * 0.10),
|
||
cardH * 0.03, cardH * 0.03);
|
||
}
|
||
}
|
||
return CreateFrame(visual, w, h, dpi);
|
||
}
|
||
|
||
private static VideoFrame GiftReceived(ChatMessage e, double t, int w, int h)
|
||
{
|
||
var dpi = 96.0;
|
||
var visual = new DrawingVisual();
|
||
using (var ctx = visual.RenderOpen())
|
||
{
|
||
var margin = Math.Max(w, h) * 0.04;
|
||
var cardW = w - margin * 2;
|
||
var cardH = h - margin * 2;
|
||
var cardCX = w / 2.0;
|
||
|
||
var enter = Clamp01(t / (EnterEnd * 1.4));
|
||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||
var scale = 0.5 + 0.5 * EaseOutBack(enter);
|
||
var alpha = Math.Min(1.0, enter * 1.6) * (1 - exit);
|
||
|
||
ctx.PushOpacity(alpha);
|
||
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
|
||
|
||
DrawCard(ctx, margin, margin, cardW, cardH, "#14532d", "#4c1d95", 18);
|
||
DrawBrand(ctx, margin, margin, cardW, cardH);
|
||
|
||
// Confetti falling inside the card during the hold (deterministic seed).
|
||
if (t >= EnterEnd && t <= ExitStart)
|
||
{
|
||
var hold = (t - EnterEnd) / (ExitStart - EnterEnd);
|
||
for (var i = 0; i < 14; i++)
|
||
{
|
||
var seed = i * 37 + 11;
|
||
var dotX = margin + cardW * (((seed * 53) % 97) / 97.0);
|
||
var dotY = margin + cardH * (((seed + hold * 0.85) % 1.0));
|
||
var size = cardH * 0.02 + (i % 3) * cardH * 0.006;
|
||
ctx.DrawRectangle(
|
||
new SolidColorBrush(ConfettiPalette[i % ConfettiPalette.Length]),
|
||
null,
|
||
new Rect(dotX, dotY, size, size));
|
||
}
|
||
}
|
||
|
||
DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, OutputPalette.Gold, cardW * 0.9);
|
||
var giver = string.IsNullOrWhiteSpace(e.GifterName) ? e.AuthorName : e.GifterName;
|
||
DrawCentered(ctx, giver, cardCX, margin + cardH * 0.42, cardH * 0.20, FontWeights.Bold, White, cardW * 0.9);
|
||
DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, OutputPalette.MintText, cardW * 0.9);
|
||
|
||
ctx.Pop();
|
||
ctx.Pop();
|
||
}
|
||
return CreateFrame(visual, w, h, dpi);
|
||
}
|
||
|
||
private static VideoFrame Milestone(ChatMessage e, double t, int w, int h)
|
||
{
|
||
var dpi = 96.0;
|
||
var visual = new DrawingVisual();
|
||
using (var ctx = visual.RenderOpen())
|
||
{
|
||
var margin = Math.Max(w, h) * 0.04;
|
||
var cardW = w - margin * 2;
|
||
var cardH = h - margin * 2;
|
||
var cardCX = w / 2.0;
|
||
|
||
var enter = Clamp01(t / EnterEnd);
|
||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
|
||
cardY = Lerp(cardY, -cardH, EaseInCubic(exit));
|
||
|
||
DrawCard(ctx, margin, cardY, cardW, cardH, "#431407", "#c2410c", 14);
|
||
DrawBrand(ctx, margin, cardY, cardW, cardH);
|
||
|
||
var hero = e.MilestoneMonths > 0 ? $"{e.MilestoneMonths}" : "MILESTONE";
|
||
DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, OutputPalette.GoldBright, cardW * 0.8);
|
||
|
||
var sub = e.MilestoneMonths > 0 ? "MONTHS STRONG" : "SUPPORTING THE STREAM";
|
||
DrawCentered(ctx, sub, cardCX, cardY + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
|
||
DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, OutputPalette.WarmCream, cardW * 0.9);
|
||
|
||
// Tenure bar: grows through the first half, retracts on exit.
|
||
var barP = Clamp01((t - 0.10) / 0.55) * (1 - exit);
|
||
var barW = cardW * 0.6 * barP;
|
||
ctx.DrawRoundedRectangle(
|
||
new SolidColorBrush(OutputPalette.Gold),
|
||
null,
|
||
new Rect(cardCX - barW / 2, cardY + cardH * 0.84, barW, cardH * 0.045),
|
||
4, 4);
|
||
}
|
||
return CreateFrame(visual, w, h, dpi);
|
||
}
|
||
|
||
// ─── Shared drawing ───
|
||
|
||
private static void DrawCard(DrawingContext ctx, double x, double y, double w, double h, string fromHex, string toHex, double radius)
|
||
{
|
||
var brush = new LinearGradientBrush(ParseColor(fromHex), ParseColor(toHex), new Point(0, 0), new Point(1, 1));
|
||
ctx.DrawRoundedRectangle(brush, new Pen(new SolidColorBrush(Color.FromArgb(0x66, 0xff, 0xff, 0xff)), Math.Max(1, radius * 0.06)), new Rect(x, y, w, h), radius, radius);
|
||
}
|
||
|
||
private static void DrawBrand(DrawingContext ctx, double x, double y, double w, double h)
|
||
{
|
||
var formatted = new FormattedText(
|
||
Brand, Invariant, FlowDirection.LeftToRight,
|
||
new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
|
||
Math.Max(9, h * 0.055),
|
||
new SolidColorBrush(Color.FromArgb(0x88, 0xff, 0xff, 0xff)),
|
||
96.0);
|
||
ctx.DrawText(formatted, new Point(x + w - formatted.Width - h * 0.08, y + h - formatted.Height - h * 0.05));
|
||
}
|
||
|
||
private static void DrawCentered(DrawingContext ctx, string text, double cx, double cy, double size, FontWeight weight, Color color, double maxWidth)
|
||
{
|
||
var formatted = new FormattedText(
|
||
text, Invariant, FlowDirection.LeftToRight,
|
||
new Typeface(OutputPalette.Sans, FontStyles.Normal, weight, FontStretches.Normal),
|
||
size,
|
||
new SolidColorBrush(color),
|
||
96.0);
|
||
formatted.MaxTextWidth = maxWidth;
|
||
formatted.MaxLineCount = 1;
|
||
formatted.Trimming = TextTrimming.CharacterEllipsis;
|
||
ctx.DrawText(formatted, new Point(cx - formatted.Width / 2, cy - formatted.Height / 2));
|
||
}
|
||
|
||
private static Color ParseColor(string hex)
|
||
{
|
||
hex = hex.Trim().TrimStart('#');
|
||
if (hex.Length == 6)
|
||
{
|
||
var r = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
|
||
var g = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
|
||
var b = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
|
||
return Color.FromRgb(r, g, b);
|
||
}
|
||
if (hex.Length == 8)
|
||
{
|
||
var a = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
|
||
var r = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
|
||
var g = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
|
||
var b = byte.Parse(hex.AsSpan(6, 2), NumberStyles.HexNumber);
|
||
return Color.FromArgb(a, r, g, b);
|
||
}
|
||
return Colors.White;
|
||
}
|
||
|
||
private static VideoFrame CreateFrame(DrawingVisual visual, int width, int height, double dpi)
|
||
{
|
||
var rtb = new RenderTargetBitmap(width, height, dpi, dpi, PixelFormats.Pbgra32);
|
||
rtb.Render(visual);
|
||
|
||
var stride = width * 4;
|
||
var pixels = new byte[stride * height];
|
||
rtb.CopyPixels(pixels, stride, 0);
|
||
|
||
for (var i = 0; i < pixels.Length; i += 4)
|
||
{
|
||
(pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]);
|
||
}
|
||
|
||
return new VideoFrame(width, height, pixels);
|
||
}
|
||
|
||
private static double Clamp01(double v) => Math.Clamp(v, 0.0, 1.0);
|
||
|
||
private static double Lerp(double a, double b, double p) => a + (b - a) * p;
|
||
|
||
private static double EaseInCubic(double p) => p * p * p;
|
||
|
||
private static double EaseOutCubic(double p) => 1 - Math.Pow(1 - p, 3);
|
||
|
||
private static double EaseOutBack(double p)
|
||
{
|
||
const double c1 = 1.70158;
|
||
const double c3 = c1 + 1;
|
||
return 1 + c3 * Math.Pow(p - 1, 3) + c1 * Math.Pow(p - 1, 2);
|
||
}
|
||
} |