TASK 47 take four: alert ticker visible in the preview + 3 display methods

The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).

- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
  is always the UI thread; MainViewModel.AlertTicker writes it into one reused
  WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
  Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
  showed the text once, entering from the right and never crossing. Paced in reads per
  alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
  from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
  alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
  Duration: choose how long your alert stays on your stream" and "Text Delay", never a
  scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
  Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
  table_info migration, appended LAST in the SELECT because the Source reader is
  positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.

Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.

Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
This commit is contained in:
2026-09-26 15:10:26 -07:00
parent e2ecc248e0
commit 7f14ffb11e
17 changed files with 781 additions and 37 deletions
+15
View File
@@ -484,6 +484,21 @@
HorizontalAlignment="Left"/> HorizontalAlignment="Left"/>
</DockPanel> </DockPanel>
<!-- Announcement display: scroll (marquee), flash (pulse) or
solid (still). Inline items, not a bound ItemsSource —
same shape as the chat Font selector above. -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Display" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
SelectedValuePath="Tag"
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
Width="140" VerticalAlignment="Center">
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
<ComboBoxItem Content="Flash" Tag="Flash"/>
<ComboBoxItem Content="Solid" Tag="Solid"/>
</ComboBox>
</DockPanel>
<DockPanel Margin="0,0,0,4"> <DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Volume" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/> <TextBlock DockPanel.Dock="Left" Text="Volume" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<Slider Width="120" Minimum="0" Maximum="1" <Slider Width="120" Minimum="0" Maximum="1"
+10
View File
@@ -358,6 +358,16 @@
</Viewbox> </Viewbox>
</Grid> </Grid>
<!-- Alert announcement strip (TASK 47): the same global
1920px overlay the output compositor blits along the
top edge. Global resource — never a Source. Without it
the strip reached the stream but never the preview. -->
<Grid x:Name="AlertTickerElement" Width="1920" Canvas.Top="0"
Background="Transparent" IsHitTestVisible="False"
Visibility="{Binding AlertTickerVisible, Converter={StaticResource BoolToVis}}">
<Image Source="{Binding AlertTickerImageSource}"
Stretch="Fill" RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
<!-- Social bar: one centered horizontal row at the top or bottom of <!-- Social bar: one centered horizontal row at the top or bottom of
the frame, green glow while enabled. Global resource — never a the frame, green glow while enabled. Global resource — never a
Source, no Sources entry. Click toggles top ⇄ bottom. --> Source, no Sources entry. Click toggles top ⇄ bottom. -->
+42 -5
View File
@@ -1,4 +1,4 @@
# HANDOFF — 2026-09-26, end of session # HANDOFF — 2026-09-26, end of session (take four: ticker display methods)
## Where we are ## Where we are
`main`, **5 local commits ahead of `origin/main` (= `ecb329e`), NOTHING PUSHED.** `main`, **5 local commits ahead of `origin/main` (= `ecb329e`), NOTHING PUSHED.**
@@ -10,7 +10,23 @@ Last pushed commit is still `ecb329e`. Do not push without the creator saying so
| `7b940b6` | poller-thread marshal crash fix | | `7b940b6` | poller-thread marshal crash fix |
| `80038ff` | silent-decoder fallback + diagnostics | | `80038ff` | silent-decoder fallback + diagnostics |
| `aea0670` | video pacing via `FfmpegFrameRateProbe` | | `aea0670` | video pacing via `FfmpegFrameRateProbe` |
| (last, unpushed) | **the real alert fix: `PreviewPane.xaml` `IsAlertBox` trigger** + 2 Good Dog tests + resolver/preview diagnostics | | `e2ecc24` | **the real alert fix: `PreviewPane.xaml` `IsAlertBox` trigger** + 2 Good Dog tests + resolver/preview diagnostics |
| (last, unpushed) | **alert announcement strip: visible in the preview + 3 display methods** (Scroll / Flash / Solid), paced in reads-per-alert |
## THE ANSWER, take two (do not re-derive this)
The **ticker was output-only**. `AlertTickerFrame` existed solely as a frame-pump
callback (blitted at 0,0 into the OUTPUT). `PreviewPane.xaml` had no element for it
because the strip is master-width and GLOBAL, not a `Source`, so it cannot ride a
per-element `Image`. Nothing was broken in the renderer — there was simply no consumer
in the preview. Fixed with a `tickerPreviewSink` on `AlertOverlayLayer` (published from
`RefreshAlertPreviews()`, UI thread only) + `MainViewModel.AlertTickerImageSource` +
a global `AlertTickerElement` in `PreviewPane.xaml` mirroring `SocialBarElement`.
The marquee was also **unreadably paced**: a fixed `140px/s` needed ~17s per pass, so a
10s alert showed the message barely once, entering from the right and never crossing. It
is now paced in **reads per alert** (`TickerReadsPerAlert = 3` inside the alert's own
length), which is the incumbent's own unit (Streamlabs: "Alert Duration" + "Text Delay",
never a scroll-speed slider).
## THE ANSWER (do not re-derive this) ## THE ANSWER (do not re-derive this)
The alert video was **never drawn**, never mis-decoded. Build 90's diagnostics showed The alert video was **never drawn**, never mis-decoded. Build 90's diagnostics showed
@@ -36,7 +52,7 @@ MSB3027 — the creator must close ytLive first. That is normal, not a broken bu
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" --no-restore "/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" --no-restore
"/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll" "/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
``` ```
Last full suite: **324/324 pass**. Run it with ytLive CLOSED or the RealMouseDrag env Last full suite: **339/339 pass** (15 new facts for the ticker feature). Run it with ytLive CLOSED or the RealMouseDrag env
flake fires. flake fires.
## Queued next (creator's words, 2026-09-26) — the Good Dog queue ## Queued next (creator's words, 2026-09-26) — the Good Dog queue
@@ -46,11 +62,32 @@ flake fires.
2. **Test pull-out chat input: 20px right padding** — the text box is clipped at the right 2. **Test pull-out chat input: 20px right padding** — the text box is clipped at the right
edge of the div; needs 20px padding against the div's right side. edge of the div; needs 20px padding against the div's right side.
3. **Test pull-out chat input: Enter inserts a newline**, it must not send. 3. **Test pull-out chat input: Enter inserts a newline**, it must not send.
4. Verify the alert video live on the next build (the fix is in; nobody has SEEN it play 4. ~~Verify the alert video live~~ — **DONE, the creator confirmed it plays.**
yet). This is the confirmation step for the commit above. 5. **Verify the three ticker display methods live** (Scroll / Flash / Solid) and pick a
default. Flash is 0.5s on / 0.5s off; Solid is centred; Scroll does 3 passes per 10s
alert. Nothing here has been SEEN on screen yet — the tests prove pixels and bindings,
not the look.
6. **Decide whether the once-per-second alert diagnostics stay.** `ViewModels/MainViewModel.cs`
(`ResolveOutputFrame`) and `Services/AlertOverlayLayer.cs` (`UpdatePreview`) still log
`Alert preview:` / `Output resolver:` once a second while an alert plays. They earned
their keep; they are noise in normal use. Creator's call.
## Landmines / facts worth keeping ## Landmines / facts worth keeping
- The pinned BtbN ffmpeg has **no libx264** — generate test clips with `-c:v mpeg4`. - The pinned BtbN ffmpeg has **no libx264** — generate test clips with `-c:v mpeg4`.
- **A bound `ItemsSource` ComboBox in `LeftPanel.xaml` breaks
`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`** (it
injects PHYSICAL mouse input, so a load-time re-measure moves the rows out from under
the cursor). Use inline `<ComboBoxItem>`s like the chat Font selector does. If a UI-only
change breaks a real-input test, suspect the control's binding STYLE first, and bisect
with `git checkout <the one xaml file>`.
- The `Source` row is read back by **POSITIONAL** index in `LayoutStore.Load.cs`
(`GetInt32(32..34)`). Append new Source columns to the END of the SELECT, or a
mid-list insert silently shifts a neighbour instead of failing to load. New columns use
the idempotent `PRAGMA table_info` guard in `LayoutStore.Migrations.cs` (no
`user_version` bump needed).
- **A seamless marquee never goes blank** (the wrapped copy enters from the right as the
pill clears the left), so an "empty frame" cannot count a pass — count the pill's
leading edge resetting to the right edge.
- `SceneCompositor.Render` fills its base with **opaque black**, so "any non-zero byte" - `SceneCompositor.Render` fills its base with **opaque black**, so "any non-zero byte"
is not proof a layer painted. Compare against an idle render, or count COLOURFUL px. is not proof a layer painted. Compare against an idle render, or count COLOURFUL px.
- `sqlite3` on WSL: `/home/gramps/android-sdk/platform-tools/sqlite3`. Extract a BLOB with - `sqlite3` on WSL: `/home/gramps/android-sdk/platform-tools/sqlite3`. Extract a BLOB with
+20
View File
@@ -40,6 +40,19 @@ public enum ClipShape
Round Round
} }
/// <summary>How an alert box presents its announcement strip (TASK 47). The strip is
/// the full-width pill naming the event ("Viewer — Super Chat · $12.34").</summary>
public enum AlertDisplayMethod
{
/// <summary>Marquee: the pill scrolls across the top edge, paced so the text is
/// read <see cref="AlertOverlayLayer.TickerReadsPerAlert"/> times per alert.</summary>
TickerScroll,
/// <summary>Pulse: the static pill blinks on and off for the whole alert.</summary>
Flash,
/// <summary>Static: the pill sits centred and still for the whole alert.</summary>
Solid
}
/// <summary> /// <summary>
/// A multi-instance scene object: image/background/text (screen/window later). /// A multi-instance scene object: image/background/text (screen/window later).
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage /// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
@@ -218,6 +231,13 @@ public class Source : SceneElement
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0)); set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
} }
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
public AlertDisplayMethod AlertDisplayMethod
{
get => _alertDisplayMethod;
set => Set(ref _alertDisplayMethod, value);
}
public bool IsAlertBox => Type == SourceType.AlertBox; public bool IsAlertBox => Type == SourceType.AlertBox;
// Chat box config (TASK 3.18) // Chat box config (TASK 3.18)
+46
View File
@@ -943,3 +943,49 @@ and the compositor resolver. The box was blank because `PreviewPane.xaml`'s per-
the honest test asserts on the CONSUMER (is the Image Visible / are the canvas pixels the honest test asserts on the CONSUMER (is the Image Visible / are the canvas pixels
different), never on the producer's own counters. different), never on the producer's own counters.
- Corollary: never trust "the data arrived" as a root cause. Ask what draws it. - Corollary: never trust "the data arrived" as a root cause. Ask what draws it.
**RULE (6) — a per-element `Image` is the wrong home for a GLOBAL overlay, and an
output-only producer is invisible by construction.** Same hunt, one layer up
(2026-09-26, after the `IsAlertBox` fix): the creator confirmed the clip plays, then
asked "where is the scrolling text? I do not see it." The ticker was healthy the whole
time — `AlertTickerRenderer` produced a 1920x48 frame and `FramePump` blitted it at
(0,0) — but it existed ONLY as a frame-pump callback (`alertTicker: () =>
_alertLayer.AlertTickerFrame`). `PreviewPane.xaml` had no element for it, because the
strip is master-width, not a `Source`, so it can't ride a per-element `Image`. Two
lessons:
- **When a feature's output is global (full frame width, a fixed overlay slot), it needs
its own root element + its own VM-published `WriteableBitmap`, exactly like
`SocialBarElement` — not a `Source`.** A global overlay that only a background thread
consumes will reach the stream and never the creator's preview, and no amount of
producer-side logging will show it: there is no consumer to be unhealthy.
- **Ask "who displays this?" while designing, not after the report.** RULE (5) says
audit the consumer when a picture is missing. RULE (6) is the earlier lesson: the
consumer may not exist yet, and that is invisible from the producer side.
**RULE (7) — pace a marquee in READS PER ALERT, never in pixels per second.** The ticker
shipped at a fixed `PixelsPerSecond = 140`, which sounds reasonable and was wrong: one
full pass took ~17s, so the 10s default clip showed the announcement barely once,
entering from the right and never crossing. The number that matches the creator's intent
("readable three times") is **passes per alert**, divided into the alert's own length —
the same convention the incumbent uses (Streamlabs exposes "Alert Duration: choose how
long your alert stays on your stream" and "Text Delay", never a scroll-speed slider;
https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
So: `readsPerAlert / alertSeconds` => a pass length => a speed derived from it. Corollary
that cost a test rewrite: **a seamless marquee can never go blank**, so "the frame went
empty" is NOT a valid way to count passes — count the pill's leading edge resetting to
the right edge instead. And phase-start the run half a frame in, or the first published
frame is blank (the strip sits exactly off the right edge) and the preview opens on
nothing.
**RULE (8) — a bound `ItemsSource` ComboBox can break an unrelated real-mouse test.**
`LeftPanel.xaml` gained a display selector written as
`ItemsSource="{Binding ...}" + DisplayMemberPath + SelectedValuePath`; it compiled, my
feature's tests passed, and it broke `LayerReorderPersistenceTests.RealMouseDrag_OnThe
LayerList_PersistsTheReorder` — a test that injects PHYSICAL mouse input
(`SetCursorPos` + `mouse_event`). A data-bound selection resolves during load and
re-measures the panel under the test's feet, so the real cursor lands on the wrong row.
Rewriting it as inline `<ComboBoxItem>`s (the shape the chat Font selector already uses
in the same panel) fixed it with no other change. LESSON: **when a UI-only change breaks a
real-input test, the control's data-binding style is the suspect, not the feature** — and
bisect by reverting ONE file (`git checkout Controls/LeftPanel.xaml`) before theorising.
Prefer the idiom already in the file you are editing.
+32 -6
View File
@@ -52,6 +52,7 @@ public sealed class AlertOverlayLayer : IDisposable
private readonly Func<string, int, int, IAlertClipDecoder>? _clipDecoderFactory; private readonly Func<string, int, int, IAlertClipDecoder>? _clipDecoderFactory;
private readonly Func<Source, string?>? _clipPathResolver; private readonly Func<Source, string?>? _clipPathResolver;
private readonly Action<AudioSample>? _audioSink; private readonly Action<AudioSample>? _audioSink;
private readonly Action<VideoFrame?>? _tickerPreviewSink;
private readonly Queue<ChatMessage> _queue = new(); private readonly Queue<ChatMessage> _queue = new();
private readonly DispatcherTimer? _ticker; private readonly DispatcherTimer? _ticker;
@@ -66,6 +67,7 @@ public sealed class AlertOverlayLayer : IDisposable
private bool _clipSawFrame; private bool _clipSawFrame;
private bool _clipAudioHeard; private bool _clipAudioHeard;
private string? _tickerText; private string? _tickerText;
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
private double _elapsed; private double _elapsed;
private IEnumerable<Scene>? _scenes; private IEnumerable<Scene>? _scenes;
@@ -79,12 +81,14 @@ public sealed class AlertOverlayLayer : IDisposable
AlertRenderer renderer, AlertRenderer renderer,
Func<string, int, int, IAlertClipDecoder>? clipDecoderFactory = null, Func<string, int, int, IAlertClipDecoder>? clipDecoderFactory = null,
Func<Source, string?>? clipPathResolver = null, Func<Source, string?>? clipPathResolver = null,
Action<AudioSample>? audioSink = null) Action<AudioSample>? audioSink = null,
Action<VideoFrame?>? tickerPreviewSink = null)
{ {
_renderer = renderer; _renderer = renderer;
_clipDecoderFactory = clipDecoderFactory; _clipDecoderFactory = clipDecoderFactory;
_clipPathResolver = clipPathResolver; _clipPathResolver = clipPathResolver;
_audioSink = audioSink; _audioSink = audioSink;
_tickerPreviewSink = tickerPreviewSink;
if (Application.Current != null) if (Application.Current != null)
{ {
_ticker = new DispatcherTimer { Interval = TickInterval }; _ticker = new DispatcherTimer { Interval = TickInterval };
@@ -234,19 +238,32 @@ public sealed class AlertOverlayLayer : IDisposable
} }
/// <summary>The global ticker strip for the stream output (top edge, full /// <summary>The global ticker strip for the stream output (top edge, full
/// master width): a marquee of "viewer — event" while an alert is playing /// master width) and the preview pane: a marquee, a pulse or a static pill
/// and the config has the ticker on, else null. Pure byte-math per call so /// naming the event while an alert plays, else null. Pure byte-math per call so
/// the frame pump can read it on any thread (the text strip is rasterized /// the frame pump can read it on any thread (the text strip is rasterized once
/// once on the UI thread and cached).</summary> /// on the UI thread and cached).</summary>
public VideoFrame? AlertTickerFrame public VideoFrame? AlertTickerFrame
{ {
get get
{ {
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null; if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
return AlertTickerRenderer.Render(_tickerText!, _elapsed); return AlertTickerRenderer.Render(_tickerText!, _elapsed, _tickerMethod, AlertSeconds());
} }
} }
/// <summary>How many times the marquee must cross the frame per alert, so the
/// announcement is actually readable inside a 10s clip (2026-09-26: the old
/// fixed 140px/s took ~17s per pass, so a 10s alert showed it barely once).</summary>
internal const int TickerReadsPerAlert = 3;
/// <summary>Assumed alert length for the marquee rhythm. The clip's real length
/// isn't known until it EOFs, and the shipped default clip is 10s — the number
/// the creator's "three reads" target is stated against.</summary>
internal const double AssumedAlertSeconds = 10.0;
private double AlertSeconds() => _clip != null ? AssumedAlertSeconds : DurationOf(_current!);
private VideoFrame? RenderClipFrame(Source alertBox) private VideoFrame? RenderClipFrame(Source alertBox)
{ {
var frame = _latestClipFrame; var frame = _latestClipFrame;
@@ -289,7 +306,10 @@ public sealed class AlertOverlayLayer : IDisposable
.FirstOrDefault(s => s.Type == SourceType.AlertBox); .FirstOrDefault(s => s.Type == SourceType.AlertBox);
_currentBox = box; _currentBox = box;
if (box != null && box.AlertShowTicker) if (box != null && box.AlertShowTicker)
{
_tickerText = ComposeTicker(_current); _tickerText = ComposeTicker(_current);
_tickerMethod = box.AlertDisplayMethod;
}
if (box == null || _clipDecoderFactory == null) return; if (box == null || _clipDecoderFactory == null) return;
var path = _clipPathResolver?.Invoke(box); var path = _clipPathResolver?.Invoke(box);
@@ -478,6 +498,12 @@ public sealed class AlertOverlayLayer : IDisposable
private void RefreshAlertPreviews() private void RefreshAlertPreviews()
{ {
// The announcement strip is a GLOBAL 1920px overlay, not a per-box frame, so
// it gets its own sink: until this existed the strip went only to the frame
// pump and was therefore invisible in the preview pane the creator watches
// (2026-09-26 — "I do not see the scrolling text").
_tickerPreviewSink?.Invoke(AlertTickerFrame);
if (_scenes == null) return; if (_scenes == null) return;
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>() foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.AlertBox)) .Where(s => s.Type == SourceType.AlertBox))
+41 -10
View File
@@ -4,6 +4,7 @@ using System.Windows;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using System.Windows.Threading; using System.Windows.Threading;
using ytLive.Models;
namespace ytLive.Services.Compositor; namespace ytLive.Services.Compositor;
@@ -26,34 +27,64 @@ public static class AlertTickerRenderer
/// <summary>Strip height (pill + vertical padding).</summary> /// <summary>Strip height (pill + vertical padding).</summary>
public const int Height = 48; public const int Height = 48;
private const int PixelsPerSecond = 140;
private const int RepeatGap = 120; private const int RepeatGap = 120;
private const int CacheLimit = 48; 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 object Gate = new();
private static readonly Dictionary<string, VideoFrame> StripCache = new(); private static readonly Dictionary<string, VideoFrame> StripCache = new();
/// <summary>A marquee frame of <paramref name="text"/> scrolled /// <summary>A frame of <paramref name="text"/> presented per
/// <paramref name="elapsedSeconds"/> into its loop, or null for empty text. /// <paramref name="method"/> at <paramref name="elapsedSeconds"/> into the alert,
/// Thread-safe: reads the cached raster and does row copies only.</summary> /// or null when there is nothing to show (no text, or a Flash in its off half).
public static VideoFrame? Render(string? text, double elapsedSeconds) /// 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></summary>
public static VideoFrame? Render(string? text, double elapsedSeconds,
AlertDisplayMethod method = AlertDisplayMethod.TickerScroll,
double alertSeconds = 0.0, int readsPerAlert = 3)
{ {
if (string.IsNullOrWhiteSpace(text)) return null; if (string.IsNullOrWhiteSpace(text)) return null;
var strip = GetStrip(text!); var strip = GetStrip(text!);
if (strip == null) return null; if (strip == null) return null;
var sw = strip.Width; if (method == AlertDisplayMethod.Flash
var cycle = Width + sw + RepeatGap; && elapsedSeconds % (FlashHalfSeconds * 2) >= FlashHalfSeconds)
var pos = (int)(elapsedSeconds * PixelsPerSecond) % cycle; return null; // off half of the pulse
var x = Width - pos;
var x = method == AlertDisplayMethod.TickerScroll
? ScrollX(strip.Width, elapsedSeconds, alertSeconds, readsPerAlert)
: (Width - strip.Width) / 2; // Solid + Flash: centred, still
var pixels = new byte[Width * Height * 4]; var pixels = new byte[Width * Height * 4];
CopyStrip(pixels, strip, x); CopyStrip(pixels, strip, x);
if (x < 0) if (method == AlertDisplayMethod.TickerScroll && x + strip.Width <= 0)
CopyStrip(pixels, strip, x + Width + RepeatGap); // wrapped tail enters from the right CopyStrip(pixels, strip, x + Width + RepeatGap); // wrapped tail enters from the right
return new VideoFrame(Width, Height, pixels); 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, 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, VideoFrame strip, int dstX) private static void CopyStrip(byte[] dst, VideoFrame strip, int dstX)
{ {
if (dstX >= Width || dstX + strip.Width <= 0) return; if (dstX >= Width || dstX + strip.Width <= 0) return;
+3 -1
View File
@@ -100,7 +100,8 @@ public partial class LayoutStore : IDisposable
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor, ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState, WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
AlertDisplayMethod
FROM Source ORDER BY SortOrder FROM Source ORDER BY SortOrder
"""; """;
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
@@ -151,6 +152,7 @@ public partial class LayoutStore : IDisposable
AlertUseDefaultVideo = reader.IsDBNull(32) ? true : reader.GetInt32(32) != 0, AlertUseDefaultVideo = reader.IsDBNull(32) ? true : reader.GetInt32(32) != 0,
AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0, AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0,
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34), AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
}; };
if (!sourcesByScene.TryGetValue(sceneId, out var list)) if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>(); sourcesByScene[sceneId] = list = new List<(Source, int)>();
+7
View File
@@ -169,6 +169,13 @@ public partial class LayoutStore : IDisposable
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
if (!columns.Contains("AlertDisplayMethod"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertDisplayMethod INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("IsMirrored")) if (!columns.Contains("IsMirrored"))
{ {
using var cmd = _connection.CreateCommand(); using var cmd = _connection.CreateCommand();
+6 -2
View File
@@ -89,14 +89,16 @@ public partial class LayoutStore : IDisposable
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor, ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState, WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume) AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
AlertDisplayMethod)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor, $x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort, $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs, $chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor, $chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState, $webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume) $alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
$alertDisplayMethod)
"""; """;
cmd.Transaction = tx; cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text); var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -135,6 +137,7 @@ public partial class LayoutStore : IDisposable
var alertUseDefaultVideoP = cmd.Parameters.Add("$alertUseDefaultVideo", SqliteType.Integer); var alertUseDefaultVideoP = cmd.Parameters.Add("$alertUseDefaultVideo", SqliteType.Integer);
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer); var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real); var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
// Source and WebcamSceneConfig share ONE z-order space per scene: // Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of // each element gets its index within scene.Elements regardless of
@@ -182,6 +185,7 @@ public partial class LayoutStore : IDisposable
alertUseDefaultVideoP.Value = source.AlertUseDefaultVideo ? 1 : 0; alertUseDefaultVideoP.Value = source.AlertUseDefaultVideo ? 1 : 0;
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0; alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
alertVideoVolumeP.Value = source.AlertVideoVolume; alertVideoVolumeP.Value = source.AlertVideoVolume;
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
} }
+8 -1
View File
@@ -62,7 +62,7 @@
| 44 | TEST-tab chat fix: resolve liveChatId from snippet (not contentDetails) + poll until the broadcast is live | ✅ Done (2026-09-25); full suite 318/318 | [`TASKS/task-44-test-chat-fix.md`](TASKS/task-44-test-chat-fix.md) | | 44 | TEST-tab chat fix: resolve liveChatId from snippet (not contentDetails) + poll until the broadcast is live | ✅ Done (2026-09-25); full suite 318/318 | [`TASKS/task-44-test-chat-fix.md`](TASKS/task-44-test-chat-fix.md) |
| 45 | TEST-tab chat fix #2: insert body must declare `snippet.type` (400 MISSING_REQUIRED_FIELD) | ✅ Done (2026-09-25) | [`TASKS/task-45-chat-insert-type.md`](TASKS/task-45-chat-insert-type.md) | | 45 | TEST-tab chat fix #2: insert body must declare `snippet.type` (400 MISSING_REQUIRED_FIELD) | ✅ Done (2026-09-25) | [`TASKS/task-45-chat-insert-type.md`](TASKS/task-45-chat-insert-type.md) |
| 46 | Drawers: click outside the rail closes whichever is open — TEST added to the existing Stream Settings + YPP dismiss behavior | ✅ Done (2026-09-25) | [`TASKS/task-46-drawer-click-outside-close.md`](TASKS/task-46-drawer-click-outside-close.md) | | 46 | Drawers: click outside the rail closes whichever is open — TEST added to the existing Stream Settings + YPP dismiss behavior | ✅ Done (2026-09-25) | [`TASKS/task-46-drawer-click-outside-close.md`](TASKS/task-46-drawer-click-outside-close.md) |
| 47 | Alert box video: built-in/custom alert clip (+ six-animation fallback) with read-time fade in/out + ticker + alert volume in the live mix | ✅ Done (2026-09-26) | [`TASKS/task-47-alert-videos.md`](TASKS/task-47-alert-videos.md) | | 47 | Alert box video: built-in/custom alert clip (+ six-animation fallback) with read-time fade in/out + ticker (now in the preview too, 3 display methods) + alert volume in the live mix | ✅ Done (2026-09-26) | [`TASKS/task-47-alert-videos.md`](TASKS/task-47-alert-videos.md) |
--- ---
@@ -121,6 +121,13 @@
enableAutoStart; YouTube only populates the id on live broadcasts — official GetLiveChatId.java enableAutoStart; YouTube only populates the id on live broadcasts — official GetLiveChatId.java
sample lists broadcastStatus=active). Fixed by reading `snippet.liveChatId` and polling with a sample lists broadcastStatus=active). Fixed by reading `snippet.liveChatId` and polling with a
bounded retry after the frame pump starts; chat stays non-fatal. bounded retry after the frame pump starts; chat stays non-fatal.
- **TASK 47 take four (2026-09-26)** — the announcement strip was **output-only** (a
frame-pump callback with no preview consumer, since a master-width global overlay cannot
ride a per-element `Image`) and the marquee was paced at a fixed `140px/s` (~17s per
pass, so a 10s alert showed the text once). Now: a `tickerPreviewSink` publishes it to a
global `AlertTickerElement` in `PreviewPane.xaml` (mirroring `SocialBarElement`), and the
creator picks **Scroll / Flash / Solid** in the panel, with Scroll paced in *reads per
alert* (3 inside a 10s alert) rather than px/s. 15 new facts; suite 339/339.
- **TASK 47 — Alert box video** — **DONE 2026-09-26** (`TASKS/task-47-alert-videos.md`): - **TASK 47 — Alert box video** — **DONE 2026-09-26** (`TASKS/task-47-alert-videos.md`):
the TASK 43 alert box now plays a **video** on every alert — a shipped built-in mp4 the TASK 43 alert box now plays a **video** on every alert — a shipped built-in mp4
(`Assets/alert-default.mp4`, stamped into the `Asset` table at startup) that the creator can (`Assets/alert-default.mp4`, stamped into the `Asset` table at startup) that the creator can
+59 -5
View File
@@ -63,11 +63,21 @@
same `FadeDurationSeconds = 0.30` envelope → drain (StopClip + dispose, idle null same `FadeDurationSeconds = 0.30` envelope → drain (StopClip + dispose, idle null
again). Ticker: `AlertTickerFrame` composes "Author — Kind · amount" from the live again). Ticker: `AlertTickerFrame` composes "Author — Kind · amount" from the live
message and scrolls it. message and scrolls it.
- **`Services/Compositor/AlertTickerRenderer.cs`** — marquee strip: 1920×48 transparent - **`Services/Compositor/AlertTickerRenderer.cs`** — announcement strip: 1920×48 transparent
BGRA, text-pill rasterized once per text on the UI thread (cached, unpremultiplied), BGRA, text-pill rasterized once per text on the UI thread (cached, unpremultiplied),
then pure byte-math per call (scroll `pos = elapsed × 140px/s % cycle`, pill drawn then pure byte-math per call, so the pump can call it from its own thread every tick
straight-alpha over transparent with a repeat-gap copy), so the pump can call it from with no locking. Three display methods (`Source.AlertDisplayMethod`, creator-set in the
its own thread every tick with no locking. panel):
- `TickerScroll` — marquee. Paced in **reads per alert**, not px/s: one pass takes
`alertSeconds / TickerReadsPerAlert(3)`, and the speed is derived from that, so the
10s default clip carries the announcement across three times. The old fixed
`140px/s` needed ~17s per pass, i.e. barely one sighting in a 10s alert.
- `Flash` — the static pill blinks `0.5s` on / `0.5s` off (1Hz) for the whole alert;
the off half returns null so the preview collapses too.
- `Solid` — the static pill centred and still for the whole alert.
The run is phase-started half a frame in so the first published frame isn't blank,
and the wrap copies in from the right the instant the pill clears the left (a
seamless loop — the frame never goes empty).
- **Ticker threading** — dynamic overlay like the social bar, but **never baked** - **Ticker threading** — dynamic overlay like the social bar, but **never baked**
(the social bar IS baked into `BakeStaticBase`; the ticker is a per-frame marquee): (the social bar IS baked into `BakeStaticBase`; the ticker is a per-frame marquee):
`SceneCompositor.Render` + `CompositeLayers` take a trailing `tickerFrame` param `SceneCompositor.Render` + `CompositeLayers` take a trailing `tickerFrame` param
@@ -81,7 +91,11 @@
it. The layer applies `volume × fade` per sample before forwarding. it. The layer applies `volume × fade` per sample before forwarding.
- **UI — Stream Alerts section** (LeftPanel, visible only on an `AlertBox`): - **UI — Stream Alerts section** (LeftPanel, visible only on an `AlertBox`):
"Built-in video" pill; custom path box + Browse…; "Reset to built-in"; "Message "Built-in video" pill; custom path box + Browse…; "Reset to built-in"; "Message
ticker" pill; "Volume" slider with % readout. Wiring: `BrowseAlertVideoCommand` + ticker" pill; **"Display" selector (Scroll / Flash / Solid)**; "Volume" slider with %
readout. The selector is inline `<ComboBoxItem>`s bound by `SelectedValuePath="Tag"`,
the same shape as the chat Font selector in this panel — NOT a bound `ItemsSource`
(see RULE (8) in `MyMistakes.md`: a data-bound selection re-measures the panel and
breaks the real-mouse layer-drag test). Wiring: `BrowseAlertVideoCommand` +
`ResetAlertVideoCommand` (CanExecute gates on `Source { Type: AlertBox }`); browse = `ResetAlertVideoCommand` (CanExecute gates on `Source { Type: AlertBox }`); browse =
OpenFileDialog (mp4/mov/webm), result writes `AlertVideoPath` + switches the pill off. OpenFileDialog (mp4/mov/webm), result writes `AlertVideoPath` + switches the pill off.
@@ -255,3 +269,43 @@ Two Good Dog tests, because the fakes had been hiding this:
the fix, green after. the fix, green after.
Full suite 324/324. Full suite 324/324.
## Take four (2026-09-26) — the ticker was output-only, and unreadably paced
The creator confirmed the clip fix ("the video plays now"), then asked two things:
**"where is the scrolling text? I do not see it"** and whether ~3 reads inside the
10-second default alert would be readable. Answer: yes, three is right — and the text
was invisible for a structural reason, not a rendering fault.
**Bug 1 — the strip had no consumer in the preview.** `AlertTickerFrame` existed only as
a frame-pump callback, blitted into the OUTPUT at (0,0). `PreviewPane.xaml` had no
element for it because the strip is master-width and global, not a `Source`, so it
cannot ride a per-element `Image` (the trap RULE (5) records, one layer up — RULE (6)).
Fix: `tickerPreviewSink` on the ctor + `AlertOverlayLayer` (UI-thread `DispatcherTimer`
only, never the pump thread), the layer publishes from `RefreshAlertPreviews()`, and
`MainViewModel` writes it into one reused `WriteableBitmap`
(`AlertTickerImageSource` / `AlertTickerVisible`) bound to a new global
`AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`.
**Bug 2 — a fixed px/s cannot express "three reads".** See the renderer note above.
Incumbent convention agrees: Streamlabs exposes *Alert Duration* ("choose how long your
alert stays on your stream") and *Text Delay*, never a scroll-speed slider
(https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts),
so the unit is repeats-per-alert, not pixels-per-second.
**Persistence** — `Source.AlertDisplayMethod INTEGER NOT NULL DEFAULT 0` via the
idempotent `PRAGMA table_info` migration (no `user_version` bump: the guard is the
column's presence). The column is appended **last** in the `SELECT` on purpose — the
reader is POSITIONAL (`GetInt32(32..34)`), so inserting anywhere but the end would
silently shift a neighbour rather than fail to load.
**Tests** (`ytLive.Tests/AlertTickerPreviewDisplayTests.cs`, RealApp STA host):
the pane draws the strip and collapses when the alert ends; the layer publishes a real
1920x48 frame for all three methods (and nothing at all when the ticker is off); three
passes are counted in 10s by the pill's leading edge resetting (a seamless marquee never
blanks, so "blank frame" cannot count a pass); Solid is byte-identical at every moment;
Flash is on for half of each second; the panel shows and writes back the chosen method.
`LayoutStorePersistenceTests.AlertDisplayMethod_RoundTrips` guards the positional-reader
risk by asserting all three alert fields together.
Full suite 339/339.
+61
View File
@@ -0,0 +1,61 @@
using ytLive.Services;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
/// <summary>The alert announcement strip as the preview pane shows it: a
/// full-master-width bitmap, or null when no alert is playing (or a Flash is in
/// its off half). Mirrors the social bar's global-overlay pattern — the strip is
/// NOT a per-source frame, so it can't ride the alert box's
/// <c>VideoImageSource</c>.</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>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;
}
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));
}
}
+2 -1
View File
@@ -316,7 +316,8 @@ public partial class MainViewModel : ViewModelBase
new AlertRenderer(), new AlertRenderer(),
clipDecoderFactory: AlertClipDecoderFor, clipDecoderFactory: AlertClipDecoderFor,
clipPathResolver: ResolveAlertClipPath, clipPathResolver: ResolveAlertClipPath,
audioSink: _audioMixer.EnqueueAlertAudio); audioSink: _audioMixer.EnqueueAlertAudio,
tickerPreviewSink: PublishAlertTickerPreview);
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL _rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump( _framePump = new FramePump(
+24 -6
View File
@@ -1630,16 +1630,34 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears
`_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio `_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio
forwards to the mixer sink scaled by `volume × fade`. forwards to the mixer sink scaled by `volume × fade`.
- **Alert ticker:** `AlertTickerFrame` (in the layer) composes "Author — Kind · amount" - **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes
and hands it to `Services/Compositor/AlertTickerRenderer.cs` — a 1920×48 marquee strip "Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` —
rasterized **on the UI thread once per text** (cached, unpremultiplied straight-alpha a 1920×48 strip rasterized **on the UI thread once per text** (cached, unpremultiplied
pill) then scrolled by pure byte-math per call (`140px/s`, repeat-gap wrap), so the straight-alpha pill) then positioned by pure byte-math per call, so the pump calls it
pump calls it from its own thread with no locking. **It is a dynamic overlay, NEVER from its own thread with no locking. **It is a dynamic overlay, NEVER baked** (the
baked** (the social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` + social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` +
`CompositeLayers` take a trailing `tickerFrame` (blitted last at 0,0) threaded from `CompositeLayers` take a trailing `tickerFrame` (blitted last at 0,0) threaded from
`FramePump._alertTicker` (`Func<VideoFrame?>` ctor seam) through `FramePump._alertTicker` (`Func<VideoFrame?>` ctor seam) through
`RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll `RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll
changes the cache key. Toggled by `AlertShowTicker`. changes the cache key. Toggled by `AlertShowTicker`.
- **Three display methods** (`Source.AlertDisplayMethod`, DB column, panel "Display"
selector): `TickerScroll` (marquee), `Flash` (0.5s on / 0.5s off), `Solid` (centred,
still). The marquee is paced in **reads per alert** — `TickerReadsPerAlert = 3`
passes inside the alert's own length, speed derived from that — **never px/s**; the
old fixed `140px/s` took ~17s per pass, so a 10s alert showed the message once.
Same convention as the incumbent (Streamlabs: "Alert Duration" + "Text Delay", not a
scroll-speed slider). Flash's off half returns null.
- **A global overlay needs its own preview element, not a `Source`.** The strip is
master-width, so it can't ride a per-element `Image`; a frame-pump-only producer
reaches the stream and never the creator's preview. `AlertOverlayLayer` takes a
`tickerPreviewSink` and publishes from `RefreshAlertPreviews()` (UI thread only —
the pump thread must never touch a `WriteableBitmap`); the VM writes it into one
reused `WriteableBitmap` (`AlertTickerImageSource`/`AlertTickerVisible`) bound to a
global `AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`.
- **Two measurement traps here:** a seamless marquee never goes blank (a wrapped copy
enters as the pill clears), so count a pass by the pill's leading edge resetting, not
by an empty frame; and the run is phase-started half a frame in, or the first
published frame is blank (the pill sits exactly off the right edge).
- **Alert audio under the master limiter:** `AudioMixer.EnqueueAlertAudio` writes a - **Alert audio under the master limiter:** `AudioMixer.EnqueueAlertAudio` writes a
dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` before the dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` before the
limiter and added at **unity — never ducked** (creator ruling: "don't lower my game limiter and added at **unity — never ducked** (creator ruling: "don't lower my game
@@ -0,0 +1,356 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 47 Good Dog (take four, 2026-09-26): the alert announcement strip must be
/// DRAWN by the preview pane, in all three display methods, at a pace that is
/// actually readable.
/// <para>The bug: the strip is a global 1920x48 overlay that existed only as a frame-
/// pump callback (<c>alertTicker: () => _alertLayer.AlertTickerFrame</c>), so it
/// reached the stream and never the pane the creator watches — the same class of
/// defect as the missing IsAlertBox trigger, one layer up: something is produced and
/// consumed, but nothing ever displays it. The fix is a preview sink on the layer
/// plus a global <c>AlertTickerElement</c> in PreviewPane.xaml.</para>
/// </summary>
[Collection("RealApp")]
public sealed class AlertTickerPreviewDisplayTests
{
private readonly RealAppHost _app;
public AlertTickerPreviewDisplayTests(RealAppHost app) => _app = app;
private const string TickerText = "Funder — Super Chat$10.00";
private const double ClipSeconds = 10.0;
// ---------- the pane actually draws the strip ----------
[Fact]
public void PreviewPane_DrawsTheTickerStrip_AndCollapsesWhenTheAlertEnds()
{
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
window.UpdateLayout();
var frame = AlertTickerRenderer.Render(
TickerText, 0.2, AlertDisplayMethod.TickerScroll, ClipSeconds);
Assert.NotNull(frame);
vm.PublishAlertTickerPreview(frame);
window.UpdateLayout();
var pane = (UserControl)window.FindName("PreviewPane")!;
var holder = Find(pane, e => e is FrameworkElement f
&& f.Name == "AlertTickerElement") as FrameworkElement;
Assert.True(holder != null,
"PreviewPane.xaml has no AlertTickerElement — the strip is global, so it needs its own root, not a Source's Image");
Assert.Equal(Visibility.Visible, holder!.Visibility);
var image = Find(holder, e => e is Image) as Image;
Assert.True(image != null && image.Source != null,
"the alert ticker element never bound a bitmap, so the strip is never rendered");
Assert.IsType<System.Windows.Media.Imaging.WriteableBitmap>(image!.Source);
// The alert ends: the strip must go away again, not sit on top of
// the next scene forever.
vm.PublishAlertTickerPreview(null);
window.UpdateLayout();
Assert.Equal(Visibility.Collapsed, holder.Visibility);
}
finally
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
});
}
// ---------- the layer actually publishes to that sink ----------
[Fact]
public void AlertLayer_PublishesARealTickerFrameToThePreviewSink()
{
_app.Run(() =>
{
foreach (var method in new[]
{
AlertDisplayMethod.TickerScroll,
AlertDisplayMethod.Flash,
AlertDisplayMethod.Solid,
})
{
var published = new List<VideoFrame?>();
var layer = new AlertOverlayLayer(new AlertRenderer(),
tickerPreviewSink: f => published.Add(f));
try
{
var box = new Source
{
Name = "Stream Alerts",
Type = SourceType.AlertBox,
AlertShowTicker = true,
AlertDisplayMethod = method,
};
var scene = new Scene { Name = "Live" };
scene.Elements.Add(box);
// The production entry from the chat feed.
layer.OnMessageReceived(new ChatMessage
{
AuthorName = "Funder",
Kind = ChatEventKind.SuperChat,
SuperChatDisplayString = "$10.00",
}, new[] { scene });
var frame = published.LastOrDefault();
Assert.True(frame != null, $"{method}: the layer published no ticker frame to the preview");
Assert.Equal(1920, frame!.Width);
Assert.Equal(48, frame.Height);
}
finally
{
layer.Dispose();
}
}
});
}
[Fact]
public void AlertLayer_TickerOff_StaysOffThePreviewSink()
{
_app.Run(() =>
{
var published = new List<VideoFrame?>();
var layer = new AlertOverlayLayer(new AlertRenderer(),
tickerPreviewSink: f => published.Add(f));
try
{
var box = new Source
{
Name = "Stream Alerts",
Type = SourceType.AlertBox,
AlertShowTicker = false, // creator's existing "Message ticker" switch
AlertDisplayMethod = AlertDisplayMethod.Solid,
};
var scene = new Scene { Name = "Live" };
scene.Elements.Add(box);
layer.OnMessageReceived(new ChatMessage
{
AuthorName = "Funder",
Kind = ChatEventKind.SuperChat,
SuperChatDisplayString = "$10.00",
}, new[] { scene });
Assert.NotEmpty(published);
Assert.All(published, f => Assert.Null(f));
}
finally
{
layer.Dispose();
}
});
}
// ---------- the three methods behave as specified ----------
[Fact]
public void TickerScroll_CompletesThreeCrossingsInTheTenSecondAlert()
{
// The old constant (140px/s) needed ~17s per pass, so a 10s alert showed the
// message barely once, entering from the right and never crossing. This is
// the creator's "readable three times" requirement.
//
// The marquee is SEAMLESS — a new copy enters from the right the instant the
// old one clears the left — so the frame is never blank and "empty frame"
// can't count a pass. Count the pill's leading edge instead: it sweeps left
// across one pass, then snaps back to the right edge to start the next.
var resets = 0;
var previousEdge = -1;
for (var t = 0.0; t < ClipSeconds; t += 1.0 / 60.0)
{
var frame = AlertTickerRenderer.Render(
TickerText, t, AlertDisplayMethod.TickerScroll, ClipSeconds);
Assert.NotNull(frame);
var edge = LeftmostVisibleColumn(frame!);
Assert.True(edge >= 0, $"the marquee drew nothing at t={t:0.00}s");
if (previousEdge >= 0 && edge > previousEdge) resets++;
previousEdge = edge;
}
// Resets are the pass boundaries. The old fixed 140px/s produced zero of them
// inside a 10s alert (one pass took ~17s), which is the bug this pins.
Assert.True(resets >= 3,
$"expected at least three readable passes in {ClipSeconds}s, counted {resets}");
}
private static int LeftmostVisibleColumn(VideoFrame frame)
{
for (var x = 0; x < frame.Width; x++)
for (var y = 0; y < frame.Height; y++)
if (frame.BgraPixels[(y * frame.Stride) + (x * 4) + 3] != 0)
return x;
return -1;
}
[Fact]
public void TickerScroll_MidPassFrame_CarriesVisiblePixels()
{
// The empty frame at t=0 is the strip still sitting exactly on the right
// edge; a pass must actually put the announcement on screen.
var frame = AlertTickerRenderer.Render(
TickerText, 1.0, AlertDisplayMethod.TickerScroll, ClipSeconds);
Assert.NotNull(frame);
Assert.True(frame!.BgraPixels.Any(b => b != 0), "the marquee never drew anything");
}
[Fact]
public void Solid_IsIdenticalAtEveryMoment()
{
var first = AlertTickerRenderer.Render(TickerText, 0.0, AlertDisplayMethod.Solid, ClipSeconds);
var last = AlertTickerRenderer.Render(TickerText, 9.9, AlertDisplayMethod.Solid, ClipSeconds);
Assert.NotNull(first);
Assert.Equal(first!.BgraPixels, last!.BgraPixels);
}
[Fact]
public void Flash_IsOnForHalfOfEachSecond()
{
Assert.NotNull(AlertTickerRenderer.Render(TickerText, 0.2, AlertDisplayMethod.Flash, ClipSeconds));
Assert.Null(AlertTickerRenderer.Render(TickerText, 0.7, AlertDisplayMethod.Flash, ClipSeconds));
Assert.NotNull(AlertTickerRenderer.Render(TickerText, 1.2, AlertDisplayMethod.Flash, ClipSeconds));
}
[Fact]
public void Render_IgnoresEmptyTextAndDefaultsToScroll()
{
Assert.Null(AlertTickerRenderer.Render(null, 1.0));
Assert.Null(AlertTickerRenderer.Render(" ", 1.0));
}
// ---------- the properties panel offers the three methods ----------
[Theory]
[InlineData(AlertDisplayMethod.TickerScroll, "Scroll")]
[InlineData(AlertDisplayMethod.Flash, "Flash")]
[InlineData(AlertDisplayMethod.Solid, "Solid")]
public void PropertiesPanel_ShowsTheChosenDisplayMethod(AlertDisplayMethod method, string label)
{
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
var box = new Source
{
Name = "Stream Alerts",
Type = SourceType.AlertBox,
AlertDisplayMethod = method,
};
vm.StagedScene!.Elements.Add(box);
vm.SelectedElement = box;
window.UpdateLayout();
var panel = (UserControl)window.FindName("LeftPanel")!;
var combo = FindDisplayComboBox(panel);
Assert.True(combo != null,
"the alert properties panel has no display-method selector");
Assert.Equal(label, ((ComboBoxItem)combo!.SelectedItem!).Content);
}
finally
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
});
}
[Fact]
public void PropertiesPanel_WritesTheChosenDisplayMethodBackToTheModel()
{
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
var box = new Source
{
Name = "Stream Alerts",
Type = SourceType.AlertBox,
AlertDisplayMethod = AlertDisplayMethod.TickerScroll,
};
vm.StagedScene!.Elements.Add(box);
vm.SelectedElement = box;
window.UpdateLayout();
var panel = (UserControl)window.FindName("LeftPanel")!;
var combo = FindDisplayComboBox(panel)!;
var solid = combo.Items.OfType<ComboBoxItem>().Single(i => (string)i.Content == "Solid");
combo.SelectedItem = solid;
window.UpdateLayout();
// The selector is bound by string ("Solid" -> the enum), so prove the
// round trip rather than trusting the XAML.
Assert.Equal(AlertDisplayMethod.Solid, box.AlertDisplayMethod);
}
finally
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effore cleanup */ }
}
});
}
/// <summary>The alert display selector is the ComboBox offering exactly
/// Scroll / Flash / Solid (the chat Font selector offers font names instead).</summary>
private static ComboBox? FindDisplayComboBox(DependencyObject parent)
=> Find(parent, e => e is ComboBox c
&& c.Items.OfType<ComboBoxItem>().Any(i => (string)i.Content == "Scroll")
&& c.Items.OfType<ComboBoxItem>().Any(i => (string)i.Content == "Solid")) as ComboBox;
private static DependencyObject? Find(DependencyObject parent, Func<DependencyObject, bool> predicate)
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (predicate(child)) return child;
var found = Find(child, predicate);
if (found != null) return found;
}
return null;
}
}
@@ -15,6 +15,55 @@ namespace ytLive.Tests;
/// </summary> /// </summary>
public class LayoutStorePersistenceTests public class LayoutStorePersistenceTests
{ {
/// <summary>TASK 47: the alert announcement display method must survive a
/// restart. This one is easy to get quietly wrong — the Source row is read back
/// by POSITIONAL index, so a column inserted anywhere but the end silently
/// shifts a neighbour (e.g. AlertVideoVolume) rather than failing to load.
/// <see cref="AlertBoxPreviewVisibilityTests"/>'s neighbour settings are the
/// canary: all three alert fields are asserted together.</summary>
[Theory]
[InlineData(AlertDisplayMethod.TickerScroll)]
[InlineData(AlertDisplayMethod.Flash)]
[InlineData(AlertDisplayMethod.Solid)]
public void AlertDisplayMethod_RoundTrips(AlertDisplayMethod method)
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using (var store = new LayoutStore(path))
store.Save(new[]
{
new Scene
{
Name = SceneCatalog.Live,
Elements =
{
new Source
{
Name = "Stream Alerts",
Type = SourceType.AlertBox,
AlertShowTicker = true,
AlertVideoVolume = 0.25,
AlertDisplayMethod = method,
},
},
},
}, null, null);
using var reopened = new LayoutStore(path);
var box = reopened.Load().SelectMany(s => s.Elements).OfType<Source>()
.Single(s => s.Type == SourceType.AlertBox);
Assert.Equal(method, box.AlertDisplayMethod);
Assert.True(box.AlertShowTicker);
Assert.Equal(0.25, box.AlertVideoVolume, 3);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// The reusable stream cache (TASK 5): the RTMP URL must survive restarts so // The reusable stream cache (TASK 5): the RTMP URL must survive restarts so
// the frame pump has it the moment go-live starts, without a YouTube round-trip. // the frame pump has it the moment go-live starts, without a YouTube round-trip.
[Fact] [Fact]