938c5b3de4
Creator 2026-09-26: "the ticker should appear over the stream alerts video, not over
the entire preview window". It was a GLOBAL 1920x48 bar blitted last at a hardcoded
(0, 0) -- it covered the whole preview width, not the alert video.
The strip is now rendered at the alert box's own size and the box's origin travels on
the frame in a new VideoFrame.Placement ((int X, int Y)?). OriginX/OriginY default to
0 when Placement is null, so every full-canvas overlay (the branding flash) is
unaffected. Every ticker blit site now reads tickerFrame.OriginX/OriginY instead of a
literal 0, 0 -- SceneCompositor x2 and FramePump's static-bake Overlay path -- and
PreviewPane.xaml's AlertTickerElement binds the same rect (AlertTickerLeft/Top/
Width/Height), so the preview cannot drift from the recording the creator is judging.
Why the position rides on the frame rather than in a full-canvas frame: a 1920x1080
overlay is 8.3MB of large-object-heap garbage per tick, ~2.5GB churned over one 10s
alert at 30fps. A box-sized strip is ~370KB. The branding flash does render
full-canvas but RECYCLES one 8MB buffer and bumps Epoch, so it never allocates per
frame; the ticker allocates per call, so it has to stay small.
Also fixed: CopyStrip now clips ROWS to the target height. The pill rasterises at its
natural 48px, so an alert box shorter than that would have written past the end of the
target buffer once the strip became box-sized.
No alert box in the scene now means no ticker at all -- there is no global position
left for it, and this is the guard against the old bar quietly coming back.
Tests (3 new facts, 27 in the file):
ComposedOutput_PutsTheTickerInsideTheAlertBox_NotAtTheTopEdge -- renders through
the real SceneCompositor and asserts the pixels land at (620, 430) and NOT at the
top-left corner. The creator is judging compositing from local recordings, so the
OUTPUT side is the side that needed proving.
ASceneWithNoAlertBox_PublishesNoTicker
ABoxShorterThanTheStrip_ClipsRowsInsteadOfOverrunning
Updated AlertLayer_PublishesARealTickerFrameToThePreviewSink, which asserted the old
1920 width from a box with no geometry (defaulted to 1px); it now uses the product's
own default box (680x200 at 620,430, MainViewModel.Sources.cs:54-57) and pins the
frame size and origin.
Full suite 367/367.
182 lines
8.3 KiB
C#
182 lines
8.3 KiB
C#
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Threading;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.Services.Compositor;
|
|
|
|
/// <summary>
|
|
/// Rasterizes the stream-alert ticker into a transparent BGRA strip that the
|
|
/// output compositor overlays along the very top edge of the master frame
|
|
/// (TASK 47). The strip is text ("Viewer — Super Chat · $12.34") inside a dark
|
|
/// pill with the LlamaCasty accent bar, rendered ONCE per unique text on the UI
|
|
/// thread (WPF <c>RenderTargetBitmap</c>, Pbgra32→straight-alpha like
|
|
/// <see cref="SocialBarRenderer"/>), then cached. Each <see cref="Render"/> call
|
|
/// is pure byte-math on that cached raster producing a scrolling marquee frame,
|
|
/// so the frame pump can call it from any thread for every output frame at
|
|
/// 30fps without a Dispatcher round-trip.
|
|
/// </summary>
|
|
public static class AlertTickerRenderer
|
|
{
|
|
/// <summary>Master-frame width — the ticker spans the full 1920px output.</summary>
|
|
public const int Width = 1920;
|
|
|
|
/// <summary>Strip height (pill + vertical padding).</summary>
|
|
public const int Height = 48;
|
|
|
|
private const int RepeatGap = 120;
|
|
private const int CacheLimit = 48;
|
|
|
|
/// <summary>How long each blink half lasts in <see cref="AlertDisplayMethod.Flash"/>
|
|
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
|
|
public const double FlashHalfSeconds = 0.5;
|
|
|
|
private static readonly object Gate = new();
|
|
private static readonly Dictionary<string, VideoFrame> StripCache = new();
|
|
|
|
/// <summary>A frame of <paramref name="text"/> presented per
|
|
/// <paramref name="method"/> at <paramref name="elapsedSeconds"/> into the alert,
|
|
/// or null when there is nothing to show (no text, or a Flash in its off half).
|
|
/// Thread-safe: reads the cached raster and does row copies only.
|
|
/// <para><see cref="AlertDisplayMethod.TickerScroll"/> paces itself from
|
|
/// <paramref name="readsPerAlert"/> over <paramref name="alertSeconds"/> rather
|
|
/// than a fixed pixels-per-second: the old constant (140px/s ≈ a 17s pass) meant
|
|
/// a 10s alert showed the text barely once, entering from the right and never
|
|
/// crossing. The creator's target is three readable passes per alert.</para>
|
|
/// <para><paramref name="width"/>/<paramref name="height"/> default to the
|
|
/// full-master strip. They are parameters because the ticker now renders INTO the
|
|
/// Stream Alerts box (creator ruling 2026-09-26) and therefore has to be the box's
|
|
/// size: a 1920-wide strip clipped to an 800px box would hide the announcement
|
|
/// off the right edge for most of its pass, which is the opposite of readable.</para></summary>
|
|
public static VideoFrame? Render(string? text, double elapsedSeconds,
|
|
AlertDisplayMethod method = AlertDisplayMethod.TickerScroll,
|
|
double alertSeconds = 0.0, int readsPerAlert = 3,
|
|
int width = Width, int height = Height)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
|
if (width <= 0 || height <= 0) return null;
|
|
var strip = GetStrip(text!);
|
|
if (strip == null) return null;
|
|
|
|
if (method == AlertDisplayMethod.Flash
|
|
&& elapsedSeconds % (FlashHalfSeconds * 2) >= FlashHalfSeconds)
|
|
return null; // off half of the pulse
|
|
|
|
var x = method == AlertDisplayMethod.TickerScroll
|
|
? ScrollX(strip.Width, width, elapsedSeconds, alertSeconds, readsPerAlert)
|
|
: (width - strip.Width) / 2; // Solid + Flash: centred, still
|
|
|
|
var pixels = new byte[width * height * 4];
|
|
CopyStrip(pixels, width, height, strip, x);
|
|
if (method == AlertDisplayMethod.TickerScroll && x + strip.Width <= 0)
|
|
CopyStrip(pixels, width, height, strip, x + width + RepeatGap); // wrapped tail enters
|
|
return new VideoFrame(width, height, pixels);
|
|
}
|
|
|
|
/// <summary>Left edge of the pill for a marquee that must complete
|
|
/// <paramref name="readsPerAlert"/> passes in <paramref name="alertSeconds"/>:
|
|
/// one pass (off the right edge, across, out of sight) takes that long.
|
|
/// <para>The run is phase-started half a frame in, so the very first frame of an
|
|
/// alert already shows the announcement instead of a blank strip sitting exactly
|
|
/// off the right edge.</para></summary>
|
|
private static int ScrollX(int stripWidth, int width, double elapsedSeconds,
|
|
double alertSeconds, int readsPerAlert)
|
|
{
|
|
var cycle = width + stripWidth + RepeatGap;
|
|
var pass = Math.Max(0.5, alertSeconds) / Math.Max(1, readsPerAlert);
|
|
var speed = cycle / pass;
|
|
var pos = (width / 2 + (int)(elapsedSeconds * speed)) % cycle;
|
|
return width - pos;
|
|
}
|
|
|
|
|
|
private static void CopyStrip(byte[] dst, int dstW, int dstH, VideoFrame strip, int dstX)
|
|
{
|
|
if (dstX >= dstW || dstX + strip.Width <= 0) return;
|
|
var x0 = Math.Max(0, dstX);
|
|
var x1 = Math.Min(dstW, dstX + strip.Width);
|
|
var sx0 = x0 - dstX;
|
|
var copyBytes = (x1 - x0) * 4;
|
|
var sw = strip.Width;
|
|
// The strip is rasterised at its natural 48px; the target box can be shorter,
|
|
// and copying strip.Height rows into a shorter dst would run off the end of the
|
|
// array. Clip the rows, not the buffer.
|
|
var rows = Math.Min(strip.Height, dstH);
|
|
for (var y = 0; y < rows; y++)
|
|
Buffer.BlockCopy(strip.BgraPixels, (y * sw + sx0) * 4, dst, (y * dstW + x0) * 4, copyBytes);
|
|
}
|
|
|
|
private static VideoFrame? GetStrip(string text)
|
|
{
|
|
lock (Gate)
|
|
{
|
|
if (StripCache.TryGetValue(text, out var cached)) return cached;
|
|
}
|
|
var strip = RenderStrip(text);
|
|
if (strip == null) return null;
|
|
lock (Gate)
|
|
{
|
|
if (StripCache.Count >= CacheLimit) StripCache.Clear();
|
|
if (!StripCache.TryGetValue(text, out var existing))
|
|
StripCache[text] = strip;
|
|
return StripCache[text];
|
|
}
|
|
}
|
|
|
|
private static VideoFrame? RenderStrip(string text)
|
|
{
|
|
// WPF rendering is single-threaded; the pump thread resolves to the UI
|
|
// thread at most once per unique text (then hits the cache).
|
|
var app = Application.Current;
|
|
if (app != null && !app.Dispatcher.CheckAccess())
|
|
{
|
|
VideoFrame? result = null;
|
|
app.Dispatcher.Invoke(() => result = RenderStrip(text));
|
|
return result;
|
|
}
|
|
|
|
const double pillHeight = 34;
|
|
const double accentW = 6;
|
|
const double padX = 16;
|
|
const double gap = 8;
|
|
|
|
var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
|
|
var ft = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
|
|
font, 22, Brushes.White, 1.0);
|
|
|
|
var pillW = (int)Math.Ceiling(padX + accentW + gap + ft.Width + padX);
|
|
var top = (Height - pillHeight) / 2.0;
|
|
var textTop = top + (pillHeight - ft.Height) / 2.0;
|
|
|
|
var visual = new DrawingVisual();
|
|
using (var dc = visual.RenderOpen())
|
|
{
|
|
var pill = new Rect(0, top, pillW, pillHeight);
|
|
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(0xE6, 0x10, 0x13, 0x24)), null, pill, pillHeight / 2, pillHeight / 2);
|
|
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromRgb(0xe9, 0x45, 0x60)), null,
|
|
new Rect(padX, top + (pillHeight - 12) / 2.0, accentW, 12), 2, 2);
|
|
dc.DrawText(ft, new Point(padX + accentW + gap, textTop));
|
|
}
|
|
|
|
var bitmap = new RenderTargetBitmap(pillW, Height, 96, 96, PixelFormats.Pbgra32);
|
|
bitmap.Render(visual);
|
|
|
|
var pixels = new byte[pillW * Height * 4];
|
|
bitmap.CopyPixels(pixels, pillW * 4, 0);
|
|
|
|
// Pbgra32 is premultiplied — unpremultiply so the compositor's
|
|
// straight-alpha source-over doesn't halo the text edges.
|
|
for (var i = 0; i < pixels.Length; i += 4)
|
|
{
|
|
var a = pixels[i + 3];
|
|
if (a == 0 || a == 255) continue;
|
|
pixels[i] = (byte)(pixels[i] * 255 / a);
|
|
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
|
|
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
|
|
}
|
|
return new VideoFrame(pillW, Height, pixels);
|
|
}
|
|
} |