diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index f6a5b91..76fec90 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -82,6 +82,9 @@ + + + + - + @@ -828,7 +834,7 @@ same shape as the chat Font selector above. --> - @@ -847,6 +853,26 @@ + + + + + + + + + + + + + + + diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index 705f97a..f3e0597 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -152,6 +152,9 @@ + + + diff --git a/Controls/index.md b/Controls/index.md index 63777b4..70c12d3 100644 --- a/Controls/index.md +++ b/Controls/index.md @@ -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 diff --git a/HANDOFF.md b/HANDOFF.md index b7e20af..412e06d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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"`). diff --git a/Models/Source.cs b/Models/Source.cs index fbbfeb2..24c870a 100644 --- a/Models/Source.cs +++ b/Models/Source.cs @@ -21,7 +21,9 @@ public enum SourceType AlertBox, /// 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). - Blur + Blur, + /// A scene-local feed of recent alert events. + Ticker } /// Distinguishes how a media source presents its content (TASK 21). @@ -66,6 +68,19 @@ public enum AlertDisplayMethod /// 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). 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; /// Live captures, chat, web sources and media are dynamic; static art is fixed. 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; /// The permanent brand logo layer (one per scene, directly above the background). /// Locked against removal; its image comes from the App Settings brand logo. @@ -156,7 +173,7 @@ public class Source : SceneElement /// VideoImageSource, and never displayed (the same shape as the invisible-alert defect). 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 /// True once a file is chosen — gates the loop switch and the clear button. 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; diff --git a/Models/Ticker.cs b/Models/Ticker.cs new file mode 100644 index 0000000..6b944c1 --- /dev/null +++ b/Models/Ticker.cs @@ -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(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/Models/index.md b/Models/index.md index 352de35..8468901 100644 --- a/Models/index.md +++ b/Models/index.md @@ -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:`/`window:`/`picker:`), `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:`/`window:`/`picker:`), `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`. | diff --git a/Services/AlertOverlayLayer.cs b/Services/AlertOverlayLayer.cs index 6c51dd6..916a594 100644 --- a/Services/AlertOverlayLayer.cs +++ b/Services/AlertOverlayLayer.cs @@ -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? _scenes; @@ -139,6 +139,7 @@ public sealed class AlertOverlayLayer : IDisposable BeginClip(); } _contentVersion++; + RefreshTickerFeed(); } /// 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 /// / 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; - 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)); } /// 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() .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() + .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() .Where(s => s.Type == SourceType.AlertBox)) UpdatePreview(alertBox); + foreach (var ticker in _scenes.SelectMany(s => s.Elements).OfType() + .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(); } -} \ No newline at end of file +} diff --git a/Services/Compositor/AlertTickerRenderer.cs b/Services/Compositor/AlertTickerRenderer.cs index 04df117..3af2cc2 100644 --- a/Services/Compositor/AlertTickerRenderer.cs +++ b/Services/Compositor/AlertTickerRenderer.cs @@ -34,6 +34,15 @@ public static class AlertTickerRenderer /// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert). 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 StripCache = new(); @@ -179,4 +188,4 @@ public static class AlertTickerRenderer } return new VideoFrame(pillW, Height, pixels); } -} \ No newline at end of file +} diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs index bda8227..af58824 100644 --- a/Services/Encoder/FramePump.cs +++ b/Services/Encoder/FramePump.cs @@ -29,7 +29,7 @@ public sealed class FramePump : IDisposable private readonly Action? _log; private readonly Func _pacingDelay; private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar; - private readonly Func? _alertTicker; + private readonly Func? _alertTicker; private readonly Func? _brandFlash; private readonly TransitionService? _transition; private VideoFrame? _lastOutput; @@ -138,7 +138,7 @@ public sealed class FramePump : IDisposable Action? log = null, Func? pacingDelay = null, Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null, - Func? alertTicker = null, + Func? alertTicker = null, Func? 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(); diff --git a/Services/index.md b/Services/index.md index 7d4f558..ac56886 100644 --- a/Services/index.md +++ b/Services/index.md @@ -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` 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` 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?` 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` 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?` 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" | diff --git a/TASKS/task-47-alert-videos.md b/TASKS/task-47-alert-videos.md index d5bbf0e..e8d44c3 100644 --- a/TASKS/task-47-alert-videos.md +++ b/TASKS/task-47-alert-videos.md @@ -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. diff --git a/ViewModels/MainViewModel.AlertTicker.cs b/ViewModels/MainViewModel.AlertTicker.cs index 0b07a9f..af7c12c 100644 --- a/ViewModels/MainViewModel.AlertTicker.cs +++ b/ViewModels/MainViewModel.AlertTicker.cs @@ -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 and /// overwrites its back buffer — a fresh bitmap every tick would churn the /// render cache for no reason. - internal void PublishAlertTickerPreview(VideoFrame? frame) + internal void PublishAlertTickerPreview(VideoFrame? incomingFrame) { + var frame = incomingFrame; + var previewScene = IsEditMode ? StagedScene : LiveScene ?? StagedScene; + var alertBox = previewScene?.Elements.OfType() + .FirstOrDefault(source => source.Type == SourceType.AlertBox && source.IsVisible); + if (_alertLayer.IsPlaying) + frame = _alertLayer.AlertBoxTickerFrame(alertBox); + if (frame == null) { if (_alertTickerBitmap != null) diff --git a/ViewModels/MainViewModel.Chat.cs b/ViewModels/MainViewModel.Chat.cs index 21e3217..f327118 100644 --- a/ViewModels/MainViewModel.Chat.cs +++ b/ViewModels/MainViewModel.Chat.cs @@ -44,6 +44,11 @@ public partial class MainViewModel : ViewModelBase => StagedScene != null && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.AlertBox)); + /// A recent-alert ticker is an independent layer, one per scene. + public bool CanAddTicker + => StagedScene != null + && !StagedScene.Elements.OfType().Any(e => e.Type == SourceType.Ticker); + /// 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. public bool CanAddBlur diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs index 2fcde58..84deeb1 100644 --- a/ViewModels/MainViewModel.Scenes.cs +++ b/ViewModels/MainViewModel.Scenes.cs @@ -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; } -} \ No newline at end of file +} diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs index f860c10..eba64cf 100644 --- a/ViewModels/MainViewModel.Sources.cs +++ b/ViewModels/MainViewModel.Sources.cs @@ -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, }; diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index d93ea4e..b576baa 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -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().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), diff --git a/ViewModels/index.md b/ViewModels/index.md index 304cd7c..8ba42f3 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -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). diff --git a/docs/ai/overlays.md b/docs/ai/overlays.md index 69c7abd..bdef7a3 100644 --- a/docs/ai/overlays.md +++ b/docs/ai/overlays.md @@ -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` ctor seam) through + `FramePump._alertTicker` (`Func` 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 diff --git a/ytLive.Tests/AlertTickerPreviewDisplayTests.cs b/ytLive.Tests/AlertTickerPreviewDisplayTests.cs index f9b959c..bee0b18 100644 --- a/ytLive.Tests/AlertTickerPreviewDisplayTests.cs +++ b/ytLive.Tests/AlertTickerPreviewDisplayTests.cs @@ -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"); + }); + } + /// 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.