diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index fd3d3f4..cbe8135 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -344,7 +344,15 @@ 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. --> - + The global ticker strip for the stream output (top edge, full - /// master width) and the preview pane: a marquee, a pulse or a static pill - /// 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 once - /// on the UI thread and cached). + /// The ticker strip as the alert box shows it: a marquee, a pulse or a + /// static pill 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 once on the UI thread and cached). + /// Creator ruling 2026-09-26: the strip is composited INSIDE the + /// Stream Alerts box, not as a full-width bar at the top edge of the frame — "the + /// ticker should appear over the stream alerts video, not over the entire preview + /// window". So the frame is rendered at the box's own size and carries the box's + /// origin on ; every blit site reads + /// / instead of + /// a hardcoded 0. No alert box in the scene means no ticker at all — there + /// is no longer a global position for it to live in. public VideoFrame? AlertTickerFrame { get { if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null; - return AlertTickerRenderer.Render(_tickerText!, _elapsed, _tickerMethod, AlertSeconds()); + var box = _currentBox; + if (box == null) return null; + + var w = (int)Math.Max(1, Math.Round(box.Width)); + var h = (int)Math.Max(1, Math.Min( + Math.Round(box.Height), AlertTickerRenderer.Height)); + var frame = AlertTickerRenderer.Render( + _tickerText!, _elapsed, _tickerMethod, AlertSeconds(), + TickerReadsPerAlert, w, h); + return frame == null + ? null + : new VideoFrame(frame.Width, frame.Height, frame.BgraPixels) + { + Placement = ((int)Math.Round(box.X), (int)Math.Round(box.Y)), + }; } } diff --git a/Services/Compositor/AlertTickerRenderer.cs b/Services/Compositor/AlertTickerRenderer.cs index 28bf907..f8d183d 100644 --- a/Services/Compositor/AlertTickerRenderer.cs +++ b/Services/Compositor/AlertTickerRenderer.cs @@ -45,12 +45,19 @@ public static class AlertTickerRenderer /// over 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. + /// crossing. The creator's target is three readable passes per alert. + /// / 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. public static VideoFrame? Render(string? text, double elapsedSeconds, AlertDisplayMethod method = AlertDisplayMethod.TickerScroll, - double alertSeconds = 0.0, int readsPerAlert = 3) + 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; @@ -59,14 +66,14 @@ public static class AlertTickerRenderer return null; // off half of the pulse var x = method == AlertDisplayMethod.TickerScroll - ? ScrollX(strip.Width, elapsedSeconds, alertSeconds, readsPerAlert) - : (Width - strip.Width) / 2; // Solid + Flash: centred, still + ? ScrollX(strip.Width, width, elapsedSeconds, alertSeconds, readsPerAlert) + : (width - strip.Width) / 2; // Solid + Flash: centred, still - var pixels = new byte[Width * Height * 4]; - CopyStrip(pixels, strip, x); + var pixels = new byte[width * height * 4]; + CopyStrip(pixels, width, height, strip, x); if (method == AlertDisplayMethod.TickerScroll && x + strip.Width <= 0) - CopyStrip(pixels, strip, x + Width + RepeatGap); // wrapped tail enters from the right - return new VideoFrame(Width, Height, pixels); + CopyStrip(pixels, width, height, strip, x + width + RepeatGap); // wrapped tail enters + return new VideoFrame(width, height, pixels); } /// Left edge of the pill for a marquee that must complete @@ -75,26 +82,31 @@ public static class AlertTickerRenderer /// 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. - private static int ScrollX(int stripWidth, double elapsedSeconds, double alertSeconds, int readsPerAlert) + private static int ScrollX(int stripWidth, int width, double elapsedSeconds, + double alertSeconds, int readsPerAlert) { - var cycle = Width + stripWidth + RepeatGap; + 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; + 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, int dstW, int dstH, VideoFrame strip, int dstX) { - if (dstX >= Width || dstX + strip.Width <= 0) return; + if (dstX >= dstW || dstX + strip.Width <= 0) return; var x0 = Math.Max(0, dstX); - var x1 = Math.Min(Width, dstX + strip.Width); + var x1 = Math.Min(dstW, dstX + strip.Width); var sx0 = x0 - dstX; var copyBytes = (x1 - x0) * 4; var sw = strip.Width; - for (var y = 0; y < strip.Height; y++) - Buffer.BlockCopy(strip.BgraPixels, (y * sw + sx0) * 4, dst, (y * Width + x0) * 4, copyBytes); + // 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) diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs index 78d7312..a94264e 100644 --- a/Services/Compositor/SceneCompositor.cs +++ b/Services/Compositor/SceneCompositor.cs @@ -37,8 +37,10 @@ public sealed class SceneCompositor /// front): live background (the scene's IsBackground source) → background image → /// visible elements (z-order = Elements order, mirroring the XAML DataTemplate) → /// branding flash → social bar (a global overlay; spans the master width at - /// ) → stream-alert ticker (a global overlay pinned to - /// the very top, TASK 47). Transparent regions read opaque black. + /// ) → stream-alert ticker (drawn INSIDE the Stream + /// Alerts box, at the origin the frame carries on ; + /// creator ruling 2026-09-26 — it used to be a global bar pinned to the very top). + /// Transparent regions read opaque black. /// public VideoFrame Render( Scene scene, @@ -140,7 +142,7 @@ public sealed class SceneCompositor BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop); if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0) - BlitOverlay(buffer, cropW, cropH, options, tickerFrame, 0, 0); + BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY); return StretchMath.BilinearScale( new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight); @@ -258,7 +260,7 @@ public sealed class SceneCompositor } if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0) - BlitOverlay(buffer, cropW, cropH, options, tickerFrame, 0, 0); + BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY); return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight); } diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs index d763ac0..6bc3573 100644 --- a/Services/Encoder/FramePump.cs +++ b/Services/Encoder/FramePump.cs @@ -601,7 +601,7 @@ public sealed class FramePump : IDisposable { var withOverlays = baked; if (flashFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, flashFrame, 0, 0); - if (tickerFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, tickerFrame, 0, 0); + if (tickerFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY); if (socialBarFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, socialBarFrame, socialBarTop, 0); baked = withOverlays; } diff --git a/Services/VideoFrame.cs b/Services/VideoFrame.cs index 3b5f328..d2e485b 100644 --- a/Services/VideoFrame.cs +++ b/Services/VideoFrame.cs @@ -34,6 +34,22 @@ public sealed class VideoFrame /// reveal layers beneath — the crop defines the actual content region. public (int X, int Y, int W, int H)? CropBounds { get; init; } + /// Where a SMALLER-than-canvas overlay composites, in master pixels + /// (creator ruling 2026-09-26: the alert ticker draws INSIDE the Stream Alerts video + /// box, not as a full-width strip pinned to the top edge). null — the default + /// — means the canvas origin, which is what every full-canvas overlay wants. + /// This is deliberately NOT a full-canvas frame: at 30–60fps a 1920×1080 + /// overlay is 8.3MB of large-object-heap garbage per tick, and a 10s alert would + /// churn ~2.5GB. A box-sized strip is ~370KB, so the position travels on the frame + /// instead of in a 1920-wide transparent margin. + public (int X, int Y)? Placement { get; init; } + + /// Composite origin X: if set, else 0. + public int OriginX => Placement?.X ?? 0; + + /// Composite origin Y: if set, else 0. + public int OriginY => Placement?.Y ?? 0; + public VideoFrame(int width, int height, byte[] bgraPixels) { Width = width; diff --git a/TASKS.md b/TASKS.md index c1a41eb..29ace28 100644 --- a/TASKS.md +++ b/TASKS.md @@ -86,10 +86,14 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw premium-edge/restart trap that app-lifetime exposed. Tests: `BrandFlashOutputTests` (12 facts) incl. `Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt` and `ADowngradeMidSession_RestartsTheCadenceTimer`. -3. ☐ **Ticker confined to the alert box.** The announcement strip is a **global 1920×48 top overlay** - (`AlertOverlayLayer` comment + `BlitOverlay(…, 0, 0)`), but the creator wants it over the *Stream - Alerts video* — i.e. inside the `SourceType.AlertBox` rect, in preview AND output. Needs a rect - + scale/clip decision (see `ai.md`). +3. ✅ **Ticker confined to the alert box** — was a global 1920×48 bar at the top edge; the + creator wants it over the *Stream Alerts video*. The strip now renders at the alert box's own + size and carries the box's origin on a new `VideoFrame.Placement`; every blit site reads + `OriginX/OriginY` instead of a literal `0, 0`, and the preview element is bound to the same + rect so preview and output cannot drift. No alert box ⇒ no ticker. Height clamped to the 48px + strip; `CopyStrip` clips rows so a short box can't overrun the buffer. Tests: 3 new facts + incl. an output-side `SceneCompositor.Render` check that the pixels land in the box and NOT at + the top-left corner. 4. ☐ **Two instances still refuse to run.** `InstanceProfile` isolates layout/auth/log/WebView, but `FfmpegLocator._toolsDir` is still the hardcoded shared `%APPDATA%\ytLlive\tools` and both instances can `Directory.CreateDirectory` + `ExtractBinaries` into it. Also the launch method is diff --git a/ViewModels/MainViewModel.AlertTicker.cs b/ViewModels/MainViewModel.AlertTicker.cs index 7026818..0b07a9f 100644 --- a/ViewModels/MainViewModel.AlertTicker.cs +++ b/ViewModels/MainViewModel.AlertTicker.cs @@ -4,11 +4,17 @@ namespace ytLive.ViewModels; public partial class MainViewModel { - /// 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 - /// VideoImageSource. + /// 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). + /// Creator ruling 2026-09-26: the strip draws INSIDE the alert box, so + /// the pane positions and sizes this element to the box's own rect + /// (// + /// /) rather than + /// pinning a 1920px bar to the top edge of the preview. The frame carries its + /// origin on , which is the same number the + /// encoder composites at — so the preview cannot drift from the broadcast, exactly + /// like the branding credit. private System.Windows.Media.Imaging.WriteableBitmap? _alertTickerBitmap; public System.Windows.Media.Imaging.WriteableBitmap? AlertTickerImageSource @@ -23,6 +29,16 @@ public partial class MainViewModel public bool AlertTickerVisible => _alertTickerBitmap != null; + /// 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. + public double AlertTickerLeft { get; private set; } + + public double AlertTickerTop { get; private set; } + + public double AlertTickerWidth { get; private set; } + + public double AlertTickerHeight { get; private set; } + /// 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 and @@ -40,6 +56,13 @@ public partial class MainViewModel 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) diff --git a/ai.md b/ai.md index 00269b3..e81a3ba 100644 --- a/ai.md +++ b/ai.md @@ -1724,14 +1724,34 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`): forwards to the mixer sink scaled by `volume × fade`. - **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes "Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` — - a 1920×48 strip rasterized **on the UI thread once per text** (cached, unpremultiplied + a strip rasterized **on the UI thread once per text** (cached, unpremultiplied straight-alpha pill) then positioned by pure byte-math per call, so the pump calls it from its own thread with no locking. **It is a dynamic overlay, NEVER baked** (the 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` threaded from `FramePump._alertTicker` (`Func` ctor seam) through `RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll changes the cache key. Toggled by `AlertShowTicker`. + - **It draws INSIDE the Stream Alerts box (creator ruling 2026-09-26).** Was a global + 1920px bar pinned to the top edge: *"the ticker should appear over the stream alerts + video, not over the entire preview window"*. The strip is now rendered at the + **alert box's own size** (`AlertTickerRenderer.Render(..., width, height)`, defaults + still 1920×48) and the box's origin rides on **`VideoFrame.Placement`** + (`(int X, int Y)?`, with `OriginX`/`OriginY` = 0 when unset). Every blit site reads + `tickerFrame.OriginX/OriginY` instead of a literal `0, 0` — `SceneCompositor` ×2 and + `FramePump`'s static-bake `Overlay` path. The preview binds the same numbers + (`AlertTickerLeft/Top/Width/Height` → `AlertTickerElement`'s `Canvas.Left/Top` + + `Width`/`Height`), so preview and output cannot drift — the same guarantee as the + branding credit. Product default box is **680×200 at (620, 430)** + (`MainViewModel.Sources.cs`); height is clamped to the natural 48px strip. + **No alert box in the scene ⇒ no ticker at all** — there is no global position left. + - **Why the position travels on the frame, not in a full-canvas frame:** a 1920×1080 + 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 it **recycles** one 8MB buffer and bumps `Epoch`, so it never + allocates per frame; the ticker allocates per call, so it must stay small. + - `CopyStrip` clips **rows** to the target height: the pill rasterises at its natural + 48px, so a box shorter than 48px would otherwise write past the end of the buffer. - **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` @@ -1739,13 +1759,14 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`): 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 + - **A ticker still needs its own preview element, not a `Source`.** It is a frame-pump + producer, so it never rides a per-element `Image`; `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`. + reused `WriteableBitmap` (`AlertTickerImageSource`/`AlertTickerVisible`) bound to + `AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`. The rect + is re-raised on **every** published frame, not just on a bitmap resize, so a box + moved or resized mid-alert tracks live. - **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 diff --git a/ytLive.Tests/AlertTickerPreviewDisplayTests.cs b/ytLive.Tests/AlertTickerPreviewDisplayTests.cs index 389f144..f9b959c 100644 --- a/ytLive.Tests/AlertTickerPreviewDisplayTests.cs +++ b/ytLive.Tests/AlertTickerPreviewDisplayTests.cs @@ -18,6 +18,11 @@ namespace ytLive.Tests; /// 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. +/// take five, 2026-09-26 (creator ruling): the strip is no longer a global +/// 1920px bar at the top of the frame — it draws INSIDE the Stream Alerts box, "over the +/// stream alerts video, not over the entire preview window". The frame is box-sized and +/// carries the box's origin on ; the preview element +/// and every compositor blit site read that origin instead of a hardcoded 0. /// The bug: the strip is a global 1920x48 overlay that existed only as a frame- /// pump callback (alertTicker: () => _alertLayer.AlertTickerFrame), so it /// reached the stream and never the pane the creator watches — the same class of @@ -62,7 +67,7 @@ public sealed class AlertTickerPreviewDisplayTests 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"); + "PreviewPane.xaml has no AlertTickerElement — the strip 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; @@ -111,6 +116,14 @@ public sealed class AlertTickerPreviewDisplayTests Type = SourceType.AlertBox, AlertShowTicker = true, AlertDisplayMethod = method, + // The product's own default for a new alert box + // (MainViewModel.Sources.cs:54-57) — 680x200 at (620, 430). + // A box with no geometry defaulted to 1px, which is what made + // this assertion read 1920 before the strip became box-sized. + X = 620, + Y = 430, + Width = 680, + Height = 200, }; var scene = new Scene { Name = "Live" }; scene.Elements.Add(box); @@ -125,8 +138,15 @@ public sealed class AlertTickerPreviewDisplayTests var frame = published.LastOrDefault(); Assert.True(frame != null, $"{method}: the layer published no ticker frame to the preview"); - Assert.Equal(1920, frame!.Width); + // CREATOR RULING 2026-09-26: the strip draws INSIDE the Stream + // Alerts box — "over the stream alerts video, not over the entire + // preview window". So the frame is the box's width and carries the + // box's origin; the height stays the natural 48px strip, pinned to + // the top of the (200px tall) box. + Assert.Equal(680, frame!.Width); Assert.Equal(48, frame.Height); + Assert.Equal(620, frame.OriginX); + Assert.Equal(430, frame.OriginY); } finally { @@ -342,6 +362,105 @@ public sealed class AlertTickerPreviewDisplayTests && c.Items.OfType().Any(i => (string)i.Content == "Scroll") && c.Items.OfType().Any(i => (string)i.Content == "Solid")) as ComboBox; + /// CREATOR RULING 2026-09-26, the OUTPUT side: "the ticker should appear + /// over the stream alerts video, not over the entire preview window". The creator is + /// judging compositing from local recordings, so this renders through the real + /// compositor and asserts where the pixels actually land: inside the alert box at + /// (620, 430), and NOT at the top-left corner where the old global strip sat. + [Fact] + public void ComposedOutput_PutsTheTickerInsideTheAlertBox_NotAtTheTopEdge() + { + const int boxX = 620, boxY = 430, boxW = 680, boxH = 48; + + // A solid opaque slab, so "did it composite here?" is a one-pixel question. + var slab = new byte[boxW * boxH * 4]; + for (var i = 0; i < slab.Length; i += 4) + { + slab[i] = 0; slab[i + 1] = 255; slab[i + 2] = 0; slab[i + 3] = 255; // green + } + var ticker = new VideoFrame(boxW, boxH, slab) { Placement = (boxX, boxY) }; + + var options = new CompositorOptions + { + SourceRectX = 0, + SourceRectY = 0, + SourceRectWidth = 1920, + SourceRectHeight = 1080, + OutputWidth = 1920, + OutputHeight = 1080, + }; + var outFrame = new SceneCompositor().Render( + new Scene { Name = "Live" }, + _ => null, + flashFrame: null, + options, + tickerFrame: ticker); + + Assert.True(IsGreen(outFrame, boxX, boxY), + "the ticker is not in the alert box — the strip is no longer drawn over the " + + "Stream Alerts video"); + Assert.True(IsGreen(outFrame, boxX + boxW - 1, boxY + boxH - 1), + "the ticker's far corner is missing from the alert box"); + Assert.False(IsGreen(outFrame, 0, 0), + "the ticker is still a full-width bar at the top-left of the frame — it must " + + "sit inside the Stream Alerts box, not over the whole canvas"); + } + + private static bool IsGreen(VideoFrame frame, int x, int y) + { + if (x < 0 || y < 0 || x >= frame.Width || y >= frame.Height) return false; + var i = (y * frame.Width + x) * 4; + return frame.BgraPixels[i + 1] > 200 && frame.BgraPixels[i + 3] > 200; + } + + /// With the strip living inside the alert box, a scene with NO alert box + /// has nowhere to put it — so there must be no ticker at all. This is the guard + /// against the old global strip quietly coming back. + [Fact] + public void ASceneWithNoAlertBox_PublishesNoTicker() + { + _app.Run(() => + { + var layer = new AlertOverlayLayer(new AlertRenderer(), tickerPreviewSink: _ => { }); + var scene = new Scene { Name = "Live" }; + scene.Elements.Add(new Source { Name = "Bg", Type = SourceType.Background }); + + layer.OnMessageReceived(new ChatMessage + { + AuthorName = "Funder", + Kind = ChatEventKind.SuperChat, + SuperChatDisplayString = "$10.00", + }, new[] { scene }); + + Assert.Null(layer.AlertTickerFrame); + }); + } + + /// A short alert box must clip the strip's rows, not overrun the target + /// buffer. The strip rasterises at its natural 48px, so a 20px-tall box used to be + /// a heap-corrupting write once the strip was composited at the box's size. + [Fact] + public void ABoxShorterThanTheStrip_ClipsRowsInsteadOfOverrunning() + { + var box = new Source + { + Name = "Stream Alerts", + Type = SourceType.AlertBox, + AlertShowTicker = true, + X = 620, + Y = 430, + Width = 680, + Height = 20, + }; + var frame = AlertTickerRenderer.Render( + TickerText, 0.2, AlertDisplayMethod.Solid, ClipSeconds, + readsPerAlert: 3, width: 680, height: 20); + + Assert.NotNull(frame); + Assert.Equal(680, frame!.Width); + Assert.Equal(20 * 680 * 4, frame.BgraPixels.Length); + } + private static DependencyObject? Find(DependencyObject parent, Func predicate) { for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)