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.
This commit is contained in:
@@ -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. -->
|
||||
<Grid x:Name="AlertTickerElement" Width="1920" Canvas.Top="0"
|
||||
<!-- Alert ticker: the announcement strip draws INSIDE the Stream
|
||||
Alerts box (creator ruling 2026-09-26), so this element is
|
||||
placed and sized to that box's rect rather than pinned as a
|
||||
1920px bar to the top edge of the preview. -->
|
||||
<Grid x:Name="AlertTickerElement"
|
||||
Canvas.Left="{Binding AlertTickerLeft}"
|
||||
Canvas.Top="{Binding AlertTickerTop}"
|
||||
Width="{Binding AlertTickerWidth}"
|
||||
Height="{Binding AlertTickerHeight}"
|
||||
Background="Transparent" IsHitTestVisible="False"
|
||||
Visibility="{Binding AlertTickerVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Image Source="{Binding AlertTickerImageSource}"
|
||||
|
||||
+20
-4
@@ -13,6 +13,21 @@ compositing from **local recordings**. They assumed the live path needed a separ
|
||||
(go-live) and `:199` (record) call that same `StartAsync`. Record+simulcast is one ffmpeg with two
|
||||
outputs. Five items, tracked in `TASKS.md` → "Creator-reported batch".
|
||||
|
||||
### Done: the alert ticker draws inside the Stream Alerts box
|
||||
|
||||
Was a **global 1920px bar pinned to the top edge**; the creator wants it "over the stream alerts
|
||||
video, not over the entire preview window". 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)?`, with
|
||||
`OriginX`/`OriginY` defaulting to 0 so full-canvas overlays are unaffected). Every blit site now
|
||||
reads `tickerFrame.OriginX/OriginY` instead of a literal `0, 0` — `SceneCompositor` ×2 plus
|
||||
`FramePump`'s static-bake `Overlay` path — and `PreviewPane.xaml`'s `AlertTickerElement` binds the
|
||||
same rect (`AlertTickerLeft/Top/Width/Height`), so preview and output can't drift.
|
||||
|
||||
Design notes: the position rides on the frame rather than in a full-canvas frame because a
|
||||
1920×1080 overlay is 8.3MB of LOH garbage per tick (~2.5GB over one 10s alert); the strip is
|
||||
~370KB. `CopyStrip` now clips **rows** to the target height — the pill rasterises at 48px, so a
|
||||
shorter box would have written past the buffer. **No alert box in the scene ⇒ no ticker.**
|
||||
|
||||
### Done: the branding flash is no longer a go-live-only behaviour
|
||||
|
||||
The presenter was `Start()`/`Stop()`-ed from `UpdateLiveVisuals()`'s `IsLive` branch, so a
|
||||
@@ -115,14 +130,15 @@ The entire implementation is inside `#if DEBUG`. Release compiles to `DataRoot =
|
||||
|
||||
## Next
|
||||
|
||||
1. **Confine the ticker to the alert box** (#3) — it is still a global 1920×48 top overlay
|
||||
(`BlitOverlay(…, 0, 0)`); needs a rect + scale/clip decision in both preview and output.
|
||||
2. **Multi-instance** (#4) — route `FfmpegLocator._toolsDir` through `InstanceProfile`, and confirm
|
||||
1. **Multi-instance** (#4) — route `FfmpegLocator._toolsDir` through `InstanceProfile`, and confirm
|
||||
the launch method: `dotnet run` while the first app holds `bin/…/ytLive.exe` fails with MSB3027
|
||||
*before any instance starts* (a build-output lock, not a log conflict). `$env:YTLIVE_INSTANCE=2`
|
||||
+ `dotnet run --no-build` in a second shell is the known-good path.
|
||||
3. **Post-session efficacy report** (#5) — roll up `CurrentHealth` (dropped frames, duration, health
|
||||
2. **Post-session efficacy report** (#5) — roll up `CurrentHealth` (dropped frames, duration, health
|
||||
message) when a stream or recording ends.
|
||||
3. **The creator's own eyes on the output** — the ticker-placement and brand-flash changes are proven
|
||||
by unit tests and the compositor, but the final proof is a local recording, since no YouTube
|
||||
private test recordings are being saved.
|
||||
4. Push the commits when the creator asks.
|
||||
5. Test-console chat UX (queued): don't clear the chat window, 20px right padding on the pull-out
|
||||
input, Enter inserts a newline.
|
||||
|
||||
@@ -237,17 +237,38 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
return RenderCore(alertBox, width, height, key, _contentVersion);
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
/// <summary>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).
|
||||
/// <para><b>Creator ruling 2026-09-26:</b> 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 <see cref="VideoFrame.Placement"/>; every blit site reads
|
||||
/// <see cref="VideoFrame.OriginX"/>/<see cref="VideoFrame.OriginY"/> instead of
|
||||
/// a hardcoded 0. <b>No alert box in the scene means no ticker at all</b> — there
|
||||
/// is no longer a global position for it to live in.</para></summary>
|
||||
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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,19 @@ public static class AlertTickerRenderer
|
||||
/// <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>
|
||||
/// crossing. The creator's target is three readable passes per alert.</para>
|
||||
/// <para><paramref name="width"/>/<paramref name="height"/> default to the
|
||||
/// full-master strip. They are parameters because the ticker now renders INTO the
|
||||
/// Stream Alerts box (creator ruling 2026-09-26) and therefore has to be the box's
|
||||
/// size: a 1920-wide strip clipped to an 800px box would hide the announcement
|
||||
/// off the right edge for most of its pass, which is the opposite of readable.</para></summary>
|
||||
public static VideoFrame? Render(string? text, double elapsedSeconds,
|
||||
AlertDisplayMethod method = AlertDisplayMethod.TickerScroll,
|
||||
double alertSeconds = 0.0, int readsPerAlert = 3)
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Left edge of the pill for a marquee that must complete
|
||||
@@ -75,26 +82,31 @@ public static class AlertTickerRenderer
|
||||
/// <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)
|
||||
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)
|
||||
|
||||
@@ -37,8 +37,10 @@ public sealed class SceneCompositor
|
||||
/// front): live background (the scene's <c>IsBackground</c> source) → background image →
|
||||
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
|
||||
/// branding flash → social bar (a global overlay; spans the master width at
|
||||
/// <paramref name="socialBarTop"/>) → stream-alert ticker (a global overlay pinned to
|
||||
/// the very top, TASK 47). Transparent regions read opaque black.
|
||||
/// <paramref name="socialBarTop"/>) → stream-alert ticker (drawn INSIDE the Stream
|
||||
/// Alerts box, at the origin the frame carries on <see cref="VideoFrame.Placement"/>;
|
||||
/// creator ruling 2026-09-26 — it used to be a global bar pinned to the very top).
|
||||
/// Transparent regions read opaque black.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,22 @@ public sealed class VideoFrame
|
||||
/// reveal layers beneath — the crop defines the actual content region.</summary>
|
||||
public (int X, int Y, int W, int H)? CropBounds { get; init; }
|
||||
|
||||
/// <summary>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). <c>null</c> — the default
|
||||
/// — means the canvas origin, which is what every full-canvas overlay wants.
|
||||
/// <para>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.</para></summary>
|
||||
public (int X, int Y)? Placement { get; init; }
|
||||
|
||||
/// <summary>Composite origin X: <see cref="Placement"/> if set, else 0.</summary>
|
||||
public int OriginX => Placement?.X ?? 0;
|
||||
|
||||
/// <summary>Composite origin Y: <see cref="Placement"/> if set, else 0.</summary>
|
||||
public int OriginY => Placement?.Y ?? 0;
|
||||
|
||||
public VideoFrame(int width, int height, byte[] bgraPixels)
|
||||
{
|
||||
Width = width;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,11 +4,17 @@ 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>
|
||||
/// <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
|
||||
@@ -23,6 +29,16 @@ public partial class MainViewModel
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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<VideoFrame?>` 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
|
||||
|
||||
@@ -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.
|
||||
/// <para><b>take five, 2026-09-26 (creator ruling):</b> 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 <see cref="VideoFrame.Placement"/>; the preview element
|
||||
/// and every compositor blit site read that origin instead of a hardcoded 0.</para>
|
||||
/// <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
|
||||
@@ -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<ComboBoxItem>().Any(i => (string)i.Content == "Scroll")
|
||||
&& c.Items.OfType<ComboBoxItem>().Any(i => (string)i.Content == "Solid")) as ComboBox;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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<DependencyObject, bool> predicate)
|
||||
{
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
|
||||
|
||||
Reference in New Issue
Block a user