eb4c379b91
Take 10 (59a02a5b, slice 6) finally produced a self-contradicting stat: render 22.4ms + submit 2.5 against a 16.7ms deadline, yet avg wait 10ms — a rebasing pacer CANNOT sleep after a blown deadline. The wait was queue time: StartAsync fires from a UI command handler, and async continuations re-capture the current SynchronizationContext — the 'WPF-free, hermetic' frame pump had been rendering ON THE DISPATCHER behind the live preview the entire starvation saga. OBS keeps obs_graphics_thread/video_thread off-UI for exactly this reason (dedicated threads; see docs.obsproject.com/backend-design 'Libobs Threads'). - FramePump: _pumpTask = Task.Run(() => PumpAsync(...)) — null context inside, every continuation stays on the pool. - Audited, not ignored, what that exposes: StaticPixelCache.Get now locks (pool miss-decodes raced UI callers); ChatOverlayLayer.RenderFrame checks its cache off-thread but marshals the rare raster MISS to the dispatcher (DrawingVisual + RenderTargetBitmap are UI-thread objects) and re-validates there; pump events already marshal in the VM. - GCLatencyMode.SustainedLowLatency for the pump's life (restored in finally). - Stats gained 'worst render Xms' — bimodal averages hid per-tick spikes. - Webcam routes through the paste cache (the IsOpaque bypass re-sampled ~156k px every tick even between identical device frames). ONE integration test: Pump_Produces_OffTheStartingContext — an inline-pumping SynchronizationContext makes the old construction run the resolver on the starting thread by capture; the loop must never. 70/70 per-class green, clean build 0 warnings. Docs same commit (ai.md slice 7, TASKS take-11 gate, MyMistakes #6, HANDOFF). take 11: ~300/300 + honest wait -> saga closed, Unit B (two-line top bar spec, fully captured) starts.
250 lines
11 KiB
C#
250 lines
11 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Threading;
|
|
using ytLive.Helpers;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.Services;
|
|
|
|
/// <summary>Owns the YouTube-chat overlay behavior: the message buffer, the
|
|
/// renderer, and the fade/mock-preview timers. The MainViewModel keeps only the
|
|
/// thin binding surface (the shared <see cref="Messages"/> collection + two
|
|
/// computed gate props) and hands the scene list in at each call — this class
|
|
/// owns all chat logic so an AI can read one self-contained unit.</summary>
|
|
public sealed class ChatOverlayLayer
|
|
{
|
|
private readonly ChatBoxRenderer _renderer;
|
|
|
|
// Mock chat messages for the ChatBox preview — branded placeholders so the
|
|
// creator sees where the overlay lands before going live.
|
|
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
|
|
{
|
|
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
|
|
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
|
|
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
|
|
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
|
|
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
|
|
};
|
|
|
|
// Chat fade timer: clears chat overlay after N seconds of inactivity.
|
|
private DispatcherTimer? _fadeTimer;
|
|
private CancellationTokenSource? _mockCts;
|
|
|
|
/// <summary>The live chat message buffer. Shared with the ViewModel (which
|
|
/// binds it to the panel) via <c>ChatMessages => _chatLayer.Messages</c>.</summary>
|
|
public ObservableCollection<ChatMessage> Messages { get; } = new();
|
|
|
|
// Output-frame cache (take-5 starvation fix): the pump called RenderFrame
|
|
// every tick and the renderer did a FULL WPF raster (FormattedText +
|
|
// RenderTargetBitmap + CopyPixels + channel swap) each time — 15-25ms of a
|
|
// 25.5ms render. Established answer (OBS text sources): raster on change,
|
|
// blit the cache every tick. _contentVersion bumps on every buffer mutation
|
|
// (CollectionChanged — covers adds, the 500-cap removal, and the fade
|
|
// Clear from any caller); the config key covers the box geometry/appearance.
|
|
// The cached VideoFrame is treated as immutable: the compositor only reads.
|
|
private int _contentVersion;
|
|
private int _frameVersion = -1;
|
|
private string? _frameKey;
|
|
private VideoFrame? _cachedFrame;
|
|
|
|
public ChatOverlayLayer(ChatBoxRenderer renderer)
|
|
{
|
|
_renderer = renderer;
|
|
Messages.CollectionChanged += (_, _) => _contentVersion++;
|
|
}
|
|
|
|
/// <summary>Ingest a message from the poller. UI-thread marshalled: appended
|
|
/// to the buffer (capped at 500), mock preview stopped, swipe to real
|
|
/// messages, and the fade timer reset. Scenes are handed in so the layer can
|
|
/// refresh every ChatBox preview without owning the scene graph.</summary>
|
|
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
|
|
{
|
|
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
|
{
|
|
Messages.Add(message);
|
|
if (Messages.Count > 500)
|
|
Messages.RemoveAt(0);
|
|
|
|
// Reset chat fade timer on every message
|
|
ResetChatFadeTimer(scenes);
|
|
|
|
StopMockPreview();
|
|
|
|
// Switch ChatBox previews from mock to real messages
|
|
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Where(s => s.Type == SourceType.ChatBox))
|
|
UpdatePreview(chatBox);
|
|
});
|
|
}
|
|
|
|
private void ResetChatFadeTimer(IEnumerable<Scene> scenes)
|
|
{
|
|
_fadeTimer?.Stop();
|
|
var chatBoxes = scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
|
|
if (chatBoxes.Count == 0) return;
|
|
|
|
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
|
|
_fadeTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(delay) };
|
|
_fadeTimer.Tick += (_, _) =>
|
|
{
|
|
_fadeTimer?.Stop();
|
|
_fadeTimer = null;
|
|
Messages.Clear();
|
|
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Where(s => s.Type == SourceType.ChatBox))
|
|
UpdatePreview(chatBox);
|
|
StartMockPreview(scenes);
|
|
};
|
|
_fadeTimer.Start();
|
|
}
|
|
|
|
/// <summary>Renders the live-output frame for one ChatBox element (the
|
|
/// compositor path), or null when no messages are buffered yet. Mirrors the
|
|
/// preview render but without writing onto the source's VideoImageSource.</summary>
|
|
public VideoFrame? RenderFrame(Source chatBox)
|
|
{
|
|
var width = (int)Math.Max(1, chatBox.Width);
|
|
var height = (int)Math.Max(1, chatBox.Height);
|
|
var key =
|
|
$"{width}x{height}|{chatBox.ChatFontSize}|{chatBox.ChatFontFamily}|{chatBox.ChatFontColor}"
|
|
+ $"|{chatBox.ChatBackgroundColor}|{chatBox.ChatMaxMessages}|{chatBox.ChatShowTimestamps}"
|
|
+ $"|{chatBox.ChatShowBadges}|{chatBox.ChatMemberColor}|{chatBox.ChatSuperChatColor}";
|
|
|
|
if (_frameKey == key && _frameVersion == _contentVersion)
|
|
return _cachedFrame; // same content + same config — the cached raster stands
|
|
|
|
// The raster is WPF (DrawingVisual + RenderTargetBitmap = UI-thread objects)
|
|
// and the frame pump now renders from its own thread (take-8 off-UI fix) —
|
|
// so only the cache CHECK runs off-thread; the rare miss marshals to the
|
|
// dispatcher and re-validates there (a second message landing mid-hop must
|
|
// not be answered by a snapshot taken before it).
|
|
var app = System.Windows.Application.Current;
|
|
if (app != null && !app.Dispatcher.CheckAccess())
|
|
{
|
|
var capturedKey = key;
|
|
var capturedVersion = _contentVersion;
|
|
var frame = app.Dispatcher.Invoke(() =>
|
|
{
|
|
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
|
|
return _cachedFrame; // someone already re-rasterized on the UI thread
|
|
return RenderCore(chatBox, width, height, capturedKey, capturedVersion);
|
|
});
|
|
return frame;
|
|
}
|
|
|
|
return RenderCore(chatBox, width, height, key, _contentVersion);
|
|
}
|
|
|
|
private VideoFrame? RenderCore(Source chatBox, int width, int height, string key, int version)
|
|
{
|
|
_frameKey = key;
|
|
_frameVersion = version;
|
|
if (Messages.Count == 0) return _cachedFrame = null;
|
|
|
|
return _cachedFrame = _renderer.Render(
|
|
Messages,
|
|
width,
|
|
height,
|
|
chatBox.ChatFontSize,
|
|
chatBox.ChatFontFamily,
|
|
chatBox.ChatFontColor,
|
|
chatBox.ChatBackgroundColor,
|
|
chatBox.ChatMaxMessages,
|
|
chatBox.ChatShowTimestamps,
|
|
chatBox.ChatShowBadges,
|
|
chatBox.ChatMemberColor,
|
|
chatBox.ChatSuperChatColor);
|
|
}
|
|
|
|
/// <summary>Renders one ChatBox preview: real messages when connected, mock
|
|
/// when not. Writes the resulting bitmap onto the source's
|
|
/// <c>VideoImageSource</c>.</summary>
|
|
public void UpdatePreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
|
|
{
|
|
try
|
|
{
|
|
messages ??= Messages.Count > 0 ? (IReadOnlyList<ChatMessage>)Messages : MockChatMessages;
|
|
var width = (int)Math.Max(32, chatBox.Width);
|
|
var height = (int)Math.Max(32, chatBox.Height);
|
|
|
|
var frame = _renderer.Render(
|
|
messages,
|
|
width,
|
|
height,
|
|
chatBox.ChatFontSize,
|
|
chatBox.ChatFontFamily,
|
|
chatBox.ChatFontColor,
|
|
chatBox.ChatBackgroundColor,
|
|
chatBox.ChatMaxMessages,
|
|
chatBox.ChatShowTimestamps,
|
|
chatBox.ChatShowBadges,
|
|
chatBox.ChatMemberColor,
|
|
chatBox.ChatSuperChatColor);
|
|
|
|
var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
|
|
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
|
|
chatBox.VideoImageSource = bitmap;
|
|
}
|
|
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
|
|
}
|
|
|
|
private async Task RunMockPreviewAsync(Source chatBox, CancellationToken ct)
|
|
{
|
|
var n = 0;
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
UpdatePreview(chatBox, MockChatMessages.Take(n + 1).ToList());
|
|
await Task.Delay(1000, ct);
|
|
n++;
|
|
if (n == MockChatMessages.Count)
|
|
{
|
|
await Task.Delay(3000, ct);
|
|
n = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void StartMockPreview(IEnumerable<Scene> scenes)
|
|
{
|
|
StopMockPreview();
|
|
_mockCts = new CancellationTokenSource();
|
|
var ct = _mockCts.Token;
|
|
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
|
|
_ = RunMockPreviewAsync(chatBox, ct);
|
|
}
|
|
|
|
public void StopMockPreview()
|
|
{
|
|
_mockCts?.Cancel();
|
|
_mockCts?.Dispose();
|
|
_mockCts = null;
|
|
}
|
|
|
|
/// <summary>Render previews + wire property-change subscriptions for all
|
|
/// ChatBox sources across every scene. Called after layout load so existing
|
|
/// ChatBox sources show mock content and respond to config changes.</summary>
|
|
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
|
|
{
|
|
foreach (var scene in scenes)
|
|
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
|
|
{
|
|
chatBox.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
|
|
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
|
|
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
|
|
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
|
|
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
|
|
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
|
|
UpdatePreview(chatBox);
|
|
};
|
|
UpdatePreview(chatBox);
|
|
}
|
|
}
|
|
}
|