feat(alerts): add scene-local recent ticker

Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only.

Ref: https://obsproject.com/kb/obs-studio-overview
This commit is contained in:
2026-11-02 10:47:48 -08:00
parent 6208cb9409
commit 20f059422b
20 changed files with 339 additions and 90 deletions
+29 -3
View File
@@ -82,6 +82,9 @@
<MenuItem Header="Stream Alerts" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.AlertBox}"
IsEnabled="{Binding CanAddAlerts}"
ToolTip="Celebration zone for Super Chat &amp; memberships — one alert layer at a time"/>
<MenuItem Header="Ticker" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Ticker}"
IsEnabled="{Binding CanAddTicker}"
ToolTip="Recent alert feed — one independently configured ticker per scene"/>
<!-- Privacy blur: Live scene only (creator ruling 2026-11-01). Sits
above the desktop and below the brand logo; several allowed. -->
<MenuItem Header="Blur" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Blur}"
@@ -219,6 +222,9 @@
<Path Data="M6,16 V11 A6,6 0 0 1 18,11 V16 L20,18 H4 Z M10,20 H14" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsAlertBox}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
</Path>
<Path Data="M3,6 H21 M3,12 H21 M3,18 H21" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" Stretch="Uniform">
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsTicker}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
</Path>
</Grid>
<TextBlock Grid.Column="1" Text="{Binding Name}" VerticalAlignment="Center"
@@ -817,9 +823,9 @@
<Border Style="{StaticResource PropertyDivider}"/>
<TextBlock Text="Ticker" Style="{StaticResource PropertyHeader}"/>
<DockPanel Margin="10,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Message ticker" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
<TextBlock DockPanel.Dock="Left" Text="Current event" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
<ToggleButton Style="{StaticResource PillToggle}"
IsChecked="{Binding SelectedElement.AlertShowTicker, Mode=TwoWay}"
IsChecked="{Binding SelectedElement.Ticker.IsEnabled, Mode=TwoWay}"
HorizontalAlignment="Left"/>
</DockPanel>
@@ -828,7 +834,7 @@
same shape as the chat Font selector above. -->
<DockPanel Margin="10,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Display" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
<ComboBox SelectedValue="{Binding SelectedElement.Ticker.DisplayMethod, Mode=TwoWay}"
SelectedValuePath="Tag"
Style="{StaticResource YtComboBox}"
Width="140" VerticalAlignment="Center">
@@ -847,6 +853,26 @@
</DockPanel>
</StackPanel>
<!-- Per-scene ticker layer: same Ticker settings/renderer as the
ticker embedded in Stream Alerts, but it reads the recent event feed. -->
<StackPanel Visibility="{Binding SelectedElement.IsTicker, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Recent Alert Ticker" Style="{StaticResource PropertyHeader}"/>
<TextBlock Text="Shows current and queued alert events. This ticker's settings are independent in each scene."
Style="{StaticResource PropertyHint}"/>
<CheckBox Content="Show recent alerts" Style="{StaticResource PropertyPillCheck}" Margin="0,2,0,4"
IsChecked="{Binding SelectedElement.Ticker.IsEnabled, Mode=TwoWay}"/>
<DockPanel Margin="10,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Display" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
<ComboBox SelectedValue="{Binding SelectedElement.Ticker.DisplayMethod, Mode=TwoWay}"
SelectedValuePath="Tag" Style="{StaticResource YtComboBox}"
Width="140" VerticalAlignment="Center">
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
<ComboBoxItem Content="Flash" Tag="Flash"/>
<ComboBoxItem Content="Solid" Tag="Solid"/>
</ComboBox>
</DockPanel>
</StackPanel>
<!-- Background properties -->
<StackPanel Margin="0,8,0,0"
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
+3
View File
@@ -152,6 +152,9 @@
<DataTrigger Binding="{Binding IsAlertBox}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsTicker}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<!-- The scoreboard strip is a frame-source too: without this trigger its
Image stays collapsed and every property change looks inert. -->
<DataTrigger Binding="{Binding IsScoreboard}" Value="True">
+4
View File
@@ -17,6 +17,10 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
The Add Layer menu exposes one standalone recent-alert Ticker per scene. The
properties panel edits its independent `Ticker` settings; Stream Alerts retains
its separate current-event ticker controls.
## Window ↔ control contract
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
+18 -22
View File
@@ -1,31 +1,27 @@
# HANDOFF — 2026-11-02 (Steam ownership check)
# HANDOFF — 2026-11-02 (scene-local alert ticker)
## State
- `main`; local work unit, not pushed.
- Distribution plan: one paid Steam app ($79 announced launch price, then $99) plus its associated
free Demo. Same code; no subscription, DLC purchase, or GitHub binary.
- `IEntitlement.IsAppOwned` feeds the app-wide `MainViewModel.IsAppOwned` flag. Debug uses a fake
owned result; the fake is excluded from Release.
- Release ownership uses `BIsSubscribedApp(paidAppId)` and fails closed when the ID or Steam is
unavailable. **The paid app ID is not configured yet; the Steamworks application is pending.**
- Steam's private-use clause is not, by itself, a proven blocker. EULA wording should expressly
allow commercial/monetized streaming; OBS is the relevant Steam software precedent. See
`MyMistakes.md` → Steam distribution.
- `main`; ticker work is local and not pushed.
- Standalone `SourceType.Ticker` is addable once per scene, has per-source settings, and shows the
in-memory current + queued alert feed. It does not persist event history.
- Stream Alerts keeps one box per layout; its ticker shares `Ticker` settings but shows only the
active event. Existing per-Source columns persist the settings without a schema migration.
- Focused build succeeded; the new integration test and all `AlertTickerPreviewDisplayTests` pass.
## Next action
Day 1 next: system-message strip. The queued Debug-only test-access rule also needs checking. Keep
one integration test per change.
Next queued unit: Day 1 system-message strip; also check the queued Debug-only test-access rule.
Keep one integration test per change. Do not push absent a milestone/checkpoint request.
## Landmines
- The Demo has its own App ID. Ownership must check the paid app ID, never the Demo ID or test ID
`480`.
- No fake ownership code or developer panel in Release.
- No Steam checkout in the app; Steam handles purchase and updates.
- The paid app ID is not available yet; Release stays fail-closed until it is configured.
- `verify.sh` clean app build passes; `EntitlementTests` passes 5/5. Full suite currently has 13–14
UI/content failures; connection to this change is unconfirmed, investigate separately.
- Bundled FFmpeg must stay outside the single-file exe (`ExcludeFromSingleFile="true"`).
- Build with Windows `dotnet.exe`; run tests per class (the whole suite can hang in WASAPI teardown).
- Build/test with Windows `dotnet.exe`; the full suite can hang in WASAPI teardown.
- Focused ticker tests pass; clean app build is 0 warnings. Full suite reports 13–14 UI/content
failures (508/521 or 507/521 pass), matching the prior baseline note; none is a ticker test. Their
relationship to this change is unconfirmed, and `verify.sh` currently only allows two other tests.
- The integration assertion is in `ytLive.Tests/AlertTickerPreviewDisplayTests.cs` and must use
`LayoutPathOverride`/clear it in `finally` if it constructs `MainWindow`.
- Scene tickers are dynamic per-element sources; only the AlertBox ticker uses the pump's global
ticker overlay callback.
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
+25 -10
View File
@@ -21,7 +21,9 @@ public enum SourceType
AlertBox,
/// <summary>A privacy blur: blurs the frame beneath it inside a rect or ellipse. Live scene
/// only, always below the brand logo, several allowed (strongest wins on overlap).</summary>
Blur
Blur,
/// <summary>A scene-local feed of recent alert events.</summary>
Ticker
}
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
@@ -66,6 +68,19 @@ public enum AlertDisplayMethod
/// </summary>
public class Source : SceneElement
{
private readonly Ticker _ticker = new();
public Source()
{
_ticker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Ticker.IsEnabled)) Raise(nameof(AlertShowTicker));
if (e.PropertyName == nameof(Ticker.DisplayMethod)) Raise(nameof(AlertDisplayMethod));
};
}
public Ticker Ticker => _ticker;
private SourceType _type;
public SourceType Type
{
@@ -80,6 +95,7 @@ public class Source : SceneElement
Raise(nameof(IsWebSource));
Raise(nameof(IsTextOverlay));
Raise(nameof(IsScoreboard));
Raise(nameof(IsTicker));
}
}
}
@@ -88,18 +104,19 @@ public class Source : SceneElement
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle
/// (creator ruling 2026-11-01).</summary>
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
or SourceType.AlertBox or SourceType.Blur;
or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker;
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
or SourceType.Blur
or SourceType.Blur or SourceType.Ticker
? ElementKind.Dynamic : ElementKind.Static;
public override bool IsWebSource => Type == SourceType.WebSource;
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
public override bool IsScoreboard => Type == SourceType.Scoreboard;
public bool IsTicker => Type == SourceType.Ticker;
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
@@ -156,7 +173,7 @@ public class Source : SceneElement
/// VideoImageSource, and never displayed (the same shape as the invisible-alert defect).</summary>
private bool IsFrameSource =>
Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
or SourceType.AlertBox or SourceType.Scoreboard;
or SourceType.AlertBox or SourceType.Scoreboard or SourceType.Ticker;
public override ImageSource? DisplaySource => IsLiveCapture
? (VideoImageSource ?? _imageSource)
@@ -259,11 +276,10 @@ public class Source : SceneElement
set => Set(ref _alertUseDefaultVideo, value);
}
private bool _alertShowTicker = true;
public bool AlertShowTicker
{
get => _alertShowTicker;
set => Set(ref _alertShowTicker, value);
get => _ticker.IsEnabled;
set => _ticker.IsEnabled = value;
}
private double _alertVideoVolume = 1.0;
@@ -295,11 +311,10 @@ public class Source : SceneElement
/// <summary>True once a file is chosen — gates the loop switch and the clear button.</summary>
public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
public AlertDisplayMethod AlertDisplayMethod
{
get => _alertDisplayMethod;
set => Set(ref _alertDisplayMethod, value);
get => _ticker.DisplayMethod;
set => _ticker.DisplayMethod = value;
}
public override bool IsAlertBox => Type == SourceType.AlertBox;
+31
View File
@@ -0,0 +1,31 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ytLive.Models;
public sealed class Ticker : INotifyPropertyChanged
{
private bool _isEnabled = true;
private AlertDisplayMethod _displayMethod = AlertDisplayMethod.TickerScroll;
public bool IsEnabled
{
get => _isEnabled;
set => Set(ref _isEnabled, value);
}
public AlertDisplayMethod DisplayMethod
{
get => _displayMethod;
set => Set(ref _displayMethod, value);
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
+2 -1
View File
@@ -7,7 +7,8 @@ Plain data types. No logic beyond what a property can carry. See
|------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row |
| `Ticker.cs` | Shared per-source ticker settings: `IsEnabled` and `DisplayMethod`; one independent instance for each scene ticker and the AlertBox ticker |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
+79 -28
View File
@@ -58,6 +58,8 @@ public sealed class AlertOverlayLayer : IDisposable
private ChatMessage? _current;
private Source? _currentBox;
private sealed record TickerFeed(string Text, double Duration);
private volatile TickerFeed? _tickerFeed;
private IAlertClipDecoder? _clip;
private VideoFrame? _latestClipFrame;
private bool _clipEof;
@@ -66,8 +68,6 @@ public sealed class AlertOverlayLayer : IDisposable
private int _clipHeight;
private bool _clipSawFrame;
private bool _clipAudioHeard;
private string? _tickerText;
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
private double _elapsed;
private IEnumerable<Scene>? _scenes;
@@ -139,6 +139,7 @@ public sealed class AlertOverlayLayer : IDisposable
BeginClip();
}
_contentVersion++;
RefreshTickerFeed();
}
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
@@ -172,6 +173,7 @@ public sealed class AlertOverlayLayer : IDisposable
_current = null;
_elapsed = 0;
AdvanceToNext();
RefreshTickerFeed();
}
_contentVersion++;
return;
@@ -184,6 +186,7 @@ public sealed class AlertOverlayLayer : IDisposable
_elapsed = 0;
BeginClip();
_contentVersion++;
RefreshTickerFeed();
return;
}
@@ -193,6 +196,7 @@ public sealed class AlertOverlayLayer : IDisposable
_current = null;
_elapsed = 0;
AdvanceToNext();
RefreshTickerFeed();
}
_contentVersion++;
}
@@ -249,27 +253,55 @@ public sealed class AlertOverlayLayer : IDisposable
/// <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;
var box = _currentBox;
if (box == null) return null;
public VideoFrame? AlertTickerFrame => AlertBoxTickerFrame(_currentBox);
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)),
};
public VideoFrame? AlertBoxTickerFrame(Source? alertBox)
{
if (_current == null || alertBox is not { Type: SourceType.AlertBox }) return null;
var w = (int)Math.Max(1, Math.Round(alertBox.Width));
var h = (int)Math.Max(1, Math.Min(
Math.Round(alertBox.Height), AlertTickerRenderer.Height));
var frame = AlertTickerRenderer.Render(
alertBox.Ticker, ComposeTicker(_current), _elapsed, AlertSeconds(),
TickerReadsPerAlert, w, h);
return frame == null
? null
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
{
Placement = ((int)Math.Round(alertBox.X), (int)Math.Round(alertBox.Y)),
};
}
public VideoFrame? RenderSceneTicker(Source tickerSource)
{
if (tickerSource.Type != SourceType.Ticker || !tickerSource.Ticker.IsEnabled)
return null;
var feed = _tickerFeed;
if (feed == null) return null;
return AlertTickerRenderer.Render(
tickerSource.Ticker,
feed.Text,
_elapsed,
feed.Duration,
TickerReadsPerAlert,
(int)Math.Max(1, Math.Round(tickerSource.Width)),
(int)Math.Max(1, Math.Round(tickerSource.Height)));
}
private void RefreshTickerFeed()
{
if (_current == null)
{
_tickerFeed = null;
return;
}
var messages = new[] { _current }.Concat(_queue).ToArray();
_tickerFeed = new TickerFeed(
string.Join(" • ", messages.Select(ComposeTicker)),
messages.Sum(DurationOf));
}
/// <summary>How many times the marquee must cross the frame per alert, so the
@@ -354,17 +386,11 @@ public sealed class AlertOverlayLayer : IDisposable
private void BeginClip()
{
StopClip();
_tickerText = null;
if (_current == null) return;
var box = _scenes?.SelectMany(s => s.Elements).OfType<Source>()
.FirstOrDefault(s => s.Type == SourceType.AlertBox);
_currentBox = box;
if (box != null && box.AlertShowTicker)
{
_tickerText = ComposeTicker(_current);
_tickerMethod = box.AlertDisplayMethod;
}
if (box == null) return;
StartSoundByte(box);
@@ -552,6 +578,9 @@ public sealed class AlertOverlayLayer : IDisposable
};
UpdatePreview(alertBox);
}
foreach (var ticker in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.Ticker))
UpdateTickerPreview(ticker);
}
private void RefreshAlertPreviews()
@@ -566,6 +595,27 @@ public sealed class AlertOverlayLayer : IDisposable
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.AlertBox))
UpdatePreview(alertBox);
foreach (var ticker in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.Ticker))
UpdateTickerPreview(ticker);
}
private void UpdateTickerPreview(Source ticker)
{
var frame = RenderSceneTicker(ticker);
if (frame == null)
{
ticker.VideoImageSource = null;
return;
}
var bitmap = ticker.VideoImageSource;
if (bitmap == null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height)
{
bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
ticker.VideoImageSource = bitmap;
}
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
}
public void Dispose()
@@ -574,6 +624,7 @@ public sealed class AlertOverlayLayer : IDisposable
_ticker?.Stop();
_queue.Clear();
_current = null;
_tickerFeed = null;
StopClip();
}
}
}
+10 -1
View File
@@ -34,6 +34,15 @@ public static class AlertTickerRenderer
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
public const double FlashHalfSeconds = 0.5;
public static VideoFrame? Render(Ticker ticker, string? text, double elapsedSeconds,
double alertSeconds, int readsPerAlert, int width, int height)
{
if (ticker == null) throw new ArgumentNullException(nameof(ticker));
return ticker.IsEnabled
? Render(text, elapsedSeconds, ticker.DisplayMethod, alertSeconds, readsPerAlert, width, height)
: null;
}
private static readonly object Gate = new();
private static readonly Dictionary<string, VideoFrame> StripCache = new();
@@ -179,4 +188,4 @@ public static class AlertTickerRenderer
}
return new VideoFrame(pillW, Height, pixels);
}
}
}
+3 -3
View File
@@ -29,7 +29,7 @@ public sealed class FramePump : IDisposable
private readonly Action<string>? _log;
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
private readonly Func<VideoFrame?>? _alertTicker;
private readonly Func<Scene, VideoFrame?>? _alertTicker;
private readonly Func<VideoFrame?>? _brandFlash;
private readonly TransitionService? _transition;
private VideoFrame? _lastOutput;
@@ -138,7 +138,7 @@ public sealed class FramePump : IDisposable
Action<string>? log = null,
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
Func<VideoFrame?>? alertTicker = null,
Func<Scene, VideoFrame?>? alertTicker = null,
Func<VideoFrame?>? brandFlash = null,
TransitionService? transition = null,
SceneGraph? sceneGraph = null)
@@ -486,7 +486,7 @@ public sealed class FramePump : IDisposable
}
var sw = System.Diagnostics.Stopwatch.StartNew();
VideoFrame? tickerFrame = _alertTicker?.Invoke();
VideoFrame? tickerFrame = _alertTicker?.Invoke(scene);
tickerTicks += sw.ElapsedTicks;
sw.Restart();
VideoFrame? flashFrame = _brandFlash?.Invoke();
+3 -3
View File
@@ -13,7 +13,7 @@ External-facing logic: YouTube API, persistence. See
| `ChatBoxRenderer.cs` | Chat overlay rasterizer (WPF FormattedText → BGRA `VideoFrame`), live rows + Super Chat/member styling |
| `ChatOverlayLayer.cs` | **The chat component (Commit G):** owns the message buffer, `ChatBoxRenderer`, fade/mock timers, per-source preview renders, live `RenderFrame`; raster-on-change cache pattern (OBS text-source contract) |
| `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. **Still the fallback** when no alert clip is playable (TASK 47) |
| `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain, and `AlertTickerFrame` composes the scrolling message strip |
| `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain. The shared ticker renderer serves the AlertBox's active event and scene-local `SourceType.Ticker` layers' current+queued feed (transient; no history) |
| `AlertClipDecoder.cs` | **Per-play alert video decoder (TASK 47):** `IAlertClipDecoder` (Start/Stop/Dispose + `FrameAvailable`/`AudioReady`/`Completed`) + ffmpeg child emitting `rawvideo bgra` (`-vf scale=W:H`, read via `RawVideoFrameReader`) and `f32le -ar 48000 -ac 2` on a **carry-buffer** audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) |
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
@@ -49,7 +49,7 @@ External-facing logic: YouTube API, persistence. See
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action<string>` log for tests |
| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread |
| `Compositor/AlertTickerRenderer.cs` | **Alert message ticker strip (TASK 47):** marquee overlay rendered per call by pure byte-math onto a 1920×48 transparent BGRA frame — the text pill ("Author — Kind · amount") is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha) and scrolled at 140px/s with a repeat-gap wrap, so the pump can call `Render(elapsed, text)` from its own thread without locking. **Dynamic overlay — never baked** (unlike the social bar) |
| `Compositor/AlertTickerRenderer.cs` | **Shared ticker renderer (TASK 47):** marquee frame rendered per call by pure byte-math; the text pill is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha). Supports AlertBox active-event tickers and ordinary scene-local ticker sources, with per-source `Ticker` settings. **Dynamic output — never baked** (unlike the social bar) |
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` |
@@ -58,7 +58,7 @@ External-facing logic: YouTube API, persistence. See
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47:** adds an `alertTicker: Func<VideoFrame?>?` ctor seam (parallel to `socialBar`), read per tick and threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`** so the ticker scroll changes the cache key. See [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47 + scene ticker:** the `alertTicker: Func<Scene, VideoFrame?>?` seam selects the active scene's AlertBox ticker, threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`**; standalone tickers are resolved as per-element dynamic sources. See [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
| `INotificationService.cs` | **Toast seam (TASK 24)**: `Show(title, message, AppNotificationSeverity)` + `AppNotificationSeverity { Info, Success, Warning, Error }` + `Info/Success/Warning/Error` extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread |
| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (MainWindow bottom-right above the footer); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" |
+19 -3
View File
@@ -5,7 +5,7 @@
> the creator picks their own clip (referenced from disk, never stored in the DB), the
> clip fades in/out in ~0.3s (alpha envelope on the video + volume on the audio, mixed
> into the live stream at unity — no duck), and an auto-composed **message ticker**
> ("Funder — Super Chat · $10.00") scrolls across the very top of the frame, toggleable.
> ("Funder — Super Chat · $10.00") appears inside the Stream Alerts box, toggleable.
> The six TASK 43 animations remain the intrinsic fallback when no asset is playable.
## Provenance (recorded per the feature-provenance rule)
@@ -25,8 +25,9 @@
read-at-play clip lets the creator re-edit the file without a re-import). Decided
with the creator: "never store the video in the layout." The built-in default is the
one BLOB, stamped into the internal `Asset` table at startup.
- **Creator rulings (asked once, held):** custom video + six-animation fallback
(not either/or); ticker on top of the canvas (very top, full width); **no ducking** —
- **Original creator rulings (asked once, held):** custom video + six-animation fallback
(not either/or); ticker on top of the canvas (the placement was later corrected to
inside Stream Alerts); **no ducking** —
"don't lower my game audio for a tip" (the TASK 43 planner's auto-duck idea was
rejected on the floor); a per-alert **Volume** slider instead.
@@ -309,3 +310,18 @@ Flash is on for half of each second; the panel shows and writes back the chosen
risk by asserting all three alert fields together.
Full suite 339/339.
## Scene-local recent-alert ticker
The standalone `SourceType.Ticker` layer is addable once per scene. It shares the
`Models/Ticker.cs` settings and `AlertTickerRenderer` with the AlertBox ticker, but
each source has an independent `Ticker` object. The existing per-Source ticker DB
columns persist both kinds without an alert-history table or schema migration. The
standalone feed renders the active event followed by queued events; completed events
are discarded. The AlertBox ticker remains limited to the active event and uses the
active scene's AlertBox settings. Standalone tickers are ordinary dynamic sources in
preview and output; the AlertBox ticker remains a positioned compositor overlay.
The integration guard in `AlertTickerPreviewDisplayTests` uses two scene tickers to
prove settings are independent, then checks that the standalone feed differs from
the AlertBox's active-only message while a second event is queued.
+10 -1
View File
@@ -1,3 +1,5 @@
using System.Linq;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.ViewModels;
@@ -44,8 +46,15 @@ public partial class MainViewModel
/// 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)
internal void PublishAlertTickerPreview(VideoFrame? incomingFrame)
{
var frame = incomingFrame;
var previewScene = IsEditMode ? StagedScene : LiveScene ?? StagedScene;
var alertBox = previewScene?.Elements.OfType<Source>()
.FirstOrDefault(source => source.Type == SourceType.AlertBox && source.IsVisible);
if (_alertLayer.IsPlaying)
frame = _alertLayer.AlertBoxTickerFrame(alertBox);
if (frame == null)
{
if (_alertTickerBitmap != null)
+5
View File
@@ -44,6 +44,11 @@ public partial class MainViewModel : ViewModelBase
=> StagedScene != null
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.AlertBox));
/// <summary>A recent-alert ticker is an independent layer, one per scene.</summary>
public bool CanAddTicker
=> StagedScene != null
&& !StagedScene.Elements.OfType<Source>().Any(e => e.Type == SourceType.Ticker);
/// <summary>Privacy blur is a Live-scene tool only (creator ruling 2026-11-01) — every other
/// scene's art is authored, and there is no desktop there to hide.</summary>
public bool CanAddBlur
+3 -1
View File
@@ -55,6 +55,7 @@ public partial class MainViewModel
OnPropertyChanged(nameof(CanChangeBackground));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanAddTicker));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(WebcamAddToolTip));
OnPropertyChanged(nameof(IsCountdownAvailable));
@@ -313,6 +314,7 @@ public partial class MainViewModel
element.PropertyChanged -= OnElementPropertyChanged;
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanAddTicker));
ScheduleSave();
}
@@ -356,4 +358,4 @@ public partial class MainViewModel
Scenes.Add(scene);
StagedScene = scene;
}
}
}
+6 -2
View File
@@ -38,6 +38,7 @@ public partial class MainViewModel
SourceType.WebSource => "Web Resource",
SourceType.ChatBox => "YouTube Chat",
SourceType.AlertBox => "Stream Alerts",
SourceType.Ticker => "Ticker",
SourceType.Scoreboard => "Scoreboard",
_ => "Layer",
};
@@ -45,11 +46,12 @@ public partial class MainViewModel
// The (+) menu only adds regular layers. The Background is a locked
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
// never user-created, never deleted. YouTube Chat and Stream Alerts
// are one-per-layout.
// are one-per-layout; a ticker is one-per-scene.
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
return;
if ((sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
|| (sourceType == SourceType.AlertBox && !CanAddAlerts))
|| (sourceType == SourceType.AlertBox && !CanAddAlerts)
|| (sourceType == SourceType.Ticker && !CanAddTicker))
return;
// Web resources use "Web Resource-N" naming (0-indexed per scene)
@@ -73,11 +75,13 @@ public partial class MainViewModel
: sourceType == SourceType.ChatBox ? 500
: sourceType == SourceType.AlertBox ? 680
: sourceType == SourceType.Scoreboard ? 1920
: sourceType == SourceType.Ticker ? 1920
: sourceType == SourceType.Blur ? 240 : 0,
Height = sourceType == SourceType.WebSource ? 480
: sourceType == SourceType.ChatBox ? 648
: sourceType == SourceType.AlertBox ? 200
: sourceType == SourceType.Scoreboard ? 56
: sourceType == SourceType.Ticker ? 48
: sourceType == SourceType.Blur ? 160 : 0,
};
+3 -1
View File
@@ -405,7 +405,8 @@ public partial class MainViewModel : ViewModelBase
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
log: message => AppLog.Write(message),
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
alertTicker: () => _alertLayer.AlertTickerFrame,
alertTicker: scene => _alertLayer.AlertBoxTickerFrame(
scene.Elements.OfType<Source>().FirstOrDefault(source => source.Type == SourceType.AlertBox && source.IsVisible)),
brandFlash: BrandFlashFrame,
transition: _transition,
sceneGraph: _sceneGraph);
@@ -639,6 +640,7 @@ public partial class MainViewModel : ViewModelBase
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId), EffectKeyFor(webcam)),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
Source { Type: SourceType.Ticker } ticker => _alertLayer.RenderSceneTicker(ticker),
Source { Type: SourceType.Scoreboard } => _scoreboardPresenter?.Frame,
Source { Type: SourceType.AlertBox } alertBox => RenderAlertBox(alertBox),
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
+3
View File
@@ -28,6 +28,9 @@ areas' docs in their own rows keeps this index small to read.
| `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` |
| `SocialsDialogViewModel.cs` | Social Media Site Promotion dialog (six fixed slots, `SocialSlotViewModel`): row 0 = YouTube sign-in gate (sign-in provider fills the channel, delete = sign-out); rows 1-5 validated entries / empty / freemium locks 2-5 (`isPremium` seam); `DetectService` → `ISocialValidator` on confirm (input snaps to text + real logo on success, red do-not + stays editable on failure); `CanSave` blocked while any slot edits/validates/errors; `BarEnabled` switch; commit via `CommittedEntries` + `BuildSocialsConfig()` (YouTube first) + `SaveRequested`/`CancelRequested`. WPF-free — the window owns the dialogs (delete confirm, sign-in gate) and calls `StartEdit`/`ConfirmEdit`/`DeleteSlotAsync`. |
| `MainViewModel.Sources.cs` | Source creation/removal; `AddSource` creates the standalone ticker layer with one-per-scene gating and its 1920×48 default geometry. |
| `MainViewModel.AlertTicker.cs` | AlertBox ticker preview routing: selects the preview scene's AlertBox settings and publishes the positioned box-sized frame to PreviewPane. |
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
+21 -11
View File
@@ -98,16 +98,24 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears
`_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio
forwards to the mixer sink scaled by `volume × fade`.
- **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes
- **Ticker settings and alert feed:** `Models/Ticker.cs` is shared by the
AlertBox and standalone `SourceType.Ticker` layers. Each source owns a distinct
settings instance (`IsEnabled` + `DisplayMethod`); the existing `AlertShowTicker`
and `AlertDisplayMethod` columns persist those settings on their own Source row,
so this is additive without a schema migration. The standalone ticker can be
added once in each scene and shows the in-memory active event plus queued events.
Completed events are not retained as history. The AlertBox ticker uses the same
renderer/settings shape but shows only the active event.
- **AlertBox ticker (announcement strip):** `AlertBoxTickerFrame` composes
"Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` —
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` threaded from
`FramePump._alertTicker` (`Func<VideoFrame?>` ctor seam) through
`FramePump._alertTicker` (`Func<Scene, VideoFrame?>` ctor seam) through
`RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll
changes the cache key. Toggled by `AlertShowTicker`.
changes the cache key. It reads the active scene's AlertBox settings.
- **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
@@ -135,14 +143,16 @@ 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 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
`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.
- **AlertBox preview:** `tickerPreviewSink` publishes from
`RefreshAlertPreviews()` (UI thread only — the pump thread must never touch a
`WriteableBitmap`); the VM writes the box-sized frame into the reused bitmap bound
to `AlertTickerElement` in `PreviewPane.xaml`. The rect is re-raised on every
published frame so a box moved or resized mid-alert tracks live.
- **Standalone ticker preview/output:** it is an ordinary dynamic Source with a
per-element frame. `AlertOverlayLayer` updates each scene ticker's reusable
`VideoImageSource` on its UI timer for preview; the output resolver renders the
same source with that source's Ticker settings. It is not the AlertBox global
ticker callback, so each scene's layer and settings remain independent.
- **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
@@ -436,6 +436,68 @@ public sealed class AlertTickerPreviewDisplayTests
});
}
[Fact]
public void SceneTickersKeepIndependentSettings_AndShowQueueWhileAlertBoxShowsActiveEvent()
{
_app.Run(() =>
{
const int width = 680;
const int height = 48;
var alertBox = new Source
{
Name = "Stream Alerts",
Type = SourceType.AlertBox,
Width = width,
Height = height,
};
var tickerA = new Source
{
Name = "Ticker A",
Type = SourceType.Ticker,
Width = width,
Height = height,
};
var tickerB = new Source
{
Name = "Ticker B",
Type = SourceType.Ticker,
Width = width,
Height = height,
};
tickerA.Ticker.DisplayMethod = AlertDisplayMethod.Solid;
tickerB.Ticker.DisplayMethod = AlertDisplayMethod.Flash;
alertBox.Ticker.DisplayMethod = AlertDisplayMethod.Solid;
var firstScene = new Scene { Name = "Starting", Elements = { alertBox, tickerA } };
var secondScene = new Scene { Name = "BRB", Elements = { tickerB } };
using var layer = new AlertOverlayLayer(new AlertRenderer(), tickerPreviewSink: _ => { });
layer.UpdateConfiguredBoxes(new[] { firstScene, secondScene });
layer.Enqueue(new ChatMessage
{
AuthorName = "Active",
Kind = ChatEventKind.SuperChat,
SuperChatDisplayString = "$10.00",
});
layer.Enqueue(new ChatMessage
{
AuthorName = "Queued",
Kind = ChatEventKind.NewMember,
});
layer.Advance(0.7);
var activeAlert = layer.AlertBoxTickerFrame(alertBox);
var recentFeed = layer.RenderSceneTicker(tickerA);
Assert.NotSame(tickerA.Ticker, tickerB.Ticker);
Assert.Equal(AlertDisplayMethod.Solid, tickerA.Ticker.DisplayMethod);
Assert.Equal(AlertDisplayMethod.Flash, tickerB.Ticker.DisplayMethod);
Assert.NotNull(activeAlert);
Assert.NotNull(recentFeed);
Assert.Null(layer.RenderSceneTicker(tickerB));
Assert.False(activeAlert!.BgraPixels.SequenceEqual(recentFeed!.BgraPixels),
"the standalone feed must include the queued event while Stream Alerts shows only the active event");
});
}
/// <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>