Files
LlamaCasty/ViewModels/MainViewModel.AlertTicker.cs
gramps 938c5b3de4 alert ticker: draw it inside the Stream Alerts box, not as a top-edge bar
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.
2026-09-27 09:43:26 -07:00

85 lines
3.9 KiB
C#

using ytLive.Services;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
/// <summary>The alert announcement strip as the preview pane shows it: a bitmap the
/// size of the Stream Alerts box, or null when no alert is playing (or a Flash is
/// in its off half).
/// <para><b>Creator ruling 2026-09-26:</b> the strip draws INSIDE the alert box, so
/// the pane positions and sizes this element to the box's own rect
/// (<see cref="AlertTickerLeft"/>/<see cref="AlertTickerTop"/>/
/// <see cref="AlertTickerWidth"/>/<see cref="AlertTickerHeight"/>) rather than
/// pinning a 1920px bar to the top edge of the preview. The frame carries its
/// origin on <see cref="VideoFrame.Placement"/>, which is the same number the
/// encoder composites at — so the preview cannot drift from the broadcast, exactly
/// like the branding credit.</para></summary>
private System.Windows.Media.Imaging.WriteableBitmap? _alertTickerBitmap;
public System.Windows.Media.Imaging.WriteableBitmap? AlertTickerImageSource
{
get => _alertTickerBitmap;
private set
{
_alertTickerBitmap = value;
OnPropertyChanged(nameof(AlertTickerImageSource));
}
}
public bool AlertTickerVisible => _alertTickerBitmap != null;
/// <summary>Alert-box rect (master pixels) the ticker element is placed at in the
/// preview canvas. Set from the published frame, so preview and output agree.</summary>
public double AlertTickerLeft { get; private set; }
public double AlertTickerTop { get; private set; }
public double AlertTickerWidth { get; private set; }
public double AlertTickerHeight { get; private set; }
/// <summary>Publishes one ticker frame to the preview pane. Called on the UI
/// thread from the alert layer's own tick, so the WPF bitmap write is legal.
/// Reuses one <see cref="System.Windows.Media.Imaging.WriteableBitmap"/> and
/// overwrites its back buffer — a fresh bitmap every tick would churn the
/// render cache for no reason.</summary>
internal void PublishAlertTickerPreview(VideoFrame? frame)
{
if (frame == null)
{
if (_alertTickerBitmap != null)
{
AlertTickerImageSource = null;
OnPropertyChanged(nameof(AlertTickerVisible));
}
return;
}
// The box can be moved or resized mid-alert, and the element has to track it —
// raise the rect unconditionally rather than only on a bitmap resize.
if (AlertTickerLeft != frame.OriginX) { AlertTickerLeft = frame.OriginX; OnPropertyChanged(nameof(AlertTickerLeft)); }
if (AlertTickerTop != frame.OriginY) { AlertTickerTop = frame.OriginY; OnPropertyChanged(nameof(AlertTickerTop)); }
if (AlertTickerWidth != frame.Width) { AlertTickerWidth = frame.Width; OnPropertyChanged(nameof(AlertTickerWidth)); }
if (AlertTickerHeight != frame.Height) { AlertTickerHeight = frame.Height; OnPropertyChanged(nameof(AlertTickerHeight)); }
if (_alertTickerBitmap == null
|| _alertTickerBitmap.PixelWidth != frame.Width
|| _alertTickerBitmap.PixelHeight != frame.Height)
{
AlertTickerImageSource = new System.Windows.Media.Imaging.WriteableBitmap(
frame.Width, frame.Height, 96, 96,
System.Windows.Media.PixelFormats.Bgra32, null);
OnPropertyChanged(nameof(AlertTickerVisible));
}
var target = _alertTickerBitmap!;
target.WritePixels(
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
frame.BgraPixels, frame.Stride, 0);
// The bitmap instance is unchanged, so the pane's Source binding still
// points at it — nudge it so WPF re-reads the back buffer this tick.
OnPropertyChanged(nameof(AlertTickerImageSource));
}
}