Files
LlamaCasty/ViewModels/MainViewModel.Chat.cs
gramps aea0670723 fix(alerts): wire the frame-rate probe so alert-clip VIDEO paces to real time
The 12:35 live session proved the decoder healthy (frames=240 audioChunks=156
failed=False both clips, alert audio in the mix at peakMix 0.277→0.733) yet the
creator still saw 'no video plays / you lost the video'. Root cause: pacing,
not decode. AlertClipDecoderFor built the decoder with no frameRateProbe, so
RunAsync computed frameDuration = TimeSpan.Zero and RunVideoAsync's pace step
was dead code — all 240 frames of the 10s clip dumped through the pipe in the
first ~1-2s (130MB as fast as ffmpeg read), then the box froze on the LAST
frame while audio chunk-paced its real-time ~7.8s. Reads exactly like a dead
decoder on screen. The media path already wired this seam
(MainViewModel.cs:308); the alert factory never supplied one.

Derivative fix (media/decoder plumbing, cited in broad consensus of players):
pass FfmpegFrameRateProbe into the per-play alert decoder exactly like
MediaVideoSource does. Good Dog:
AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate —
real AlertClipDecoder via the fake-process/fake-probe/delay-recorder seam;
red on the old factory (zero pacing delays), green with the probe (one ~10ms
delay per frame at 100fps). Full suite 321/322, one known-env flake
(RealMouseDrag reorder). Docs in this commit: task-47 addendum,
MyMistakes.md (a decoder that drops data faster than wall-clock looks
identical to a dead one), HANDOFF rewrite.
2026-09-26 12:45:40 -07:00

180 lines
6.8 KiB
C#

using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Windows;
using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private readonly ChatOverlayLayer _chatLayer;
private readonly AlertOverlayLayer _alertLayer;
/// <summary>The live chat message buffer — the same collection the
/// <see cref="ChatOverlayLayer"/> owns; here so XAML can bind it and the
/// left panel can subscribe to its changes.</summary>
public ObservableCollection<ChatMessage> ChatMessages => _chatLayer.Messages;
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
/// anywhere — one chat overlay per layout.</summary>
public bool CanAddYouTubeChat
=> StagedScene != null
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
/// <summary>The Add → Stream Alerts menu item: one alert zone per layout
/// (multiple zones would double-play every event).</summary>
public bool CanAddAlerts
=> StagedScene != null
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.AlertBox));
public bool ShowChatInactiveMessage => !IsLive;
private void OnChatMessageReceived(ChatMessage message)
{
_chatLayer.OnMessageReceived(message, Scenes);
_alertLayer.OnMessageReceived(message, Scenes);
}
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
=> _chatLayer.UpdatePreview(chatBox, messages);
private VideoFrame? RenderChatBox(Source chatBox)
=> _chatLayer.RenderFrame(chatBox);
private void UpdateAlertBoxPreview(Source alertBox)
=> _alertLayer.UpdatePreview(alertBox);
private VideoFrame? RenderAlertBox(Source alertBox)
=> _alertLayer.RenderFrame(alertBox);
private void StartMockChatPreview()
=> _chatLayer.StartMockPreview(Scenes);
private void StopMockChatPreview()
=> _chatLayer.StopMockPreview();
private void UpdateActiveChatBoxPreviews()
=> _chatLayer.UpdateConfiguredBoxes(Scenes);
private void UpdateActiveAlertBoxPreviews()
=> _alertLayer.UpdateConfiguredBoxes(Scenes);
// ─── TASK 47: the alert clip (video) ───
private string? _alertDefaultVideoId;
private string? _defaultAlertClipScratch;
/// <summary>Runtime clip resolution for one alert box: the Creator's OWN video
/// is a plain path on disk (never stored — the 2026-09-26 decision); the shipped
/// llama placeholder lives as a BLOB in the internal Asset table and is
/// materialized to a temp file once. Null → the six celebratory animations.
/// Called from the output pump's alert frame path.</summary>
private string? ResolveAlertClipPath(Source box)
{
if (!box.AlertUseDefaultVideo
&& !string.IsNullOrWhiteSpace(box.AlertVideoPath)
&& File.Exists(box.AlertVideoPath))
return box.AlertVideoPath;
return MaterializeDefaultAlertClip();
}
private string? MaterializeDefaultAlertClip()
{
if (_defaultAlertClipScratch != null && File.Exists(_defaultAlertClipScratch))
return _defaultAlertClipScratch;
_alertDefaultVideoId ??= _layoutStore.GetAlertDefaultVideoId();
if (_alertDefaultVideoId == null) return null;
var bytes = _layoutStore.GetAssetBytes(_alertDefaultVideoId);
if (bytes == null) return null;
var scratch = Path.Combine(Path.GetTempPath(), $"ytLive-alert-{_alertDefaultVideoId}.mp4");
try
{
File.WriteAllBytes(scratch, bytes);
_defaultAlertClipScratch = scratch;
return scratch;
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert default materialization failed");
return null;
}
}
/// <summary>One decoder per play (the refcounted MediaVideoSourceManager sessions
/// can't restart per replay). The clip is full-box in video + 48k stereo audio;
/// both steams pace themselves to real time inside the decoder — the frame-rate
/// probe is what makes the VIDEO pace (without it every frame dumps in ~1-2s and
/// the box freezes on the last frame for the rest of the audio).</summary>
private IAlertClipDecoder AlertClipDecoderFor(string path, int width, int height) =>
new AlertClipDecoder(
path, width, height,
new FfmpegLocator(),
() => new FfmpegDecodeProcess(),
frameRateProbe: new FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess()));
/// <summary>Stamp the shipped llama placeholder into the Asset table at startup
/// (idempotent — hash-keyed UpsertAsset dedupes). Best-effort: if it fails the
/// alert box simply falls back to the six animations.</summary>
private void StampDefaultAlertVideo()
{
try
{
_alertDefaultVideoId = _layoutStore.GetAlertDefaultVideoId();
if (!string.IsNullOrWhiteSpace(_alertDefaultVideoId)) return;
var bytes = ReadAlertDefaultVideoResource();
if (bytes is null or { Length: 0 }) return;
_alertDefaultVideoId = _layoutStore.EnsureAlertDefaultVideo(bytes);
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert default video stamp failed");
}
}
private static byte[]? ReadAlertDefaultVideoResource()
{
try
{
var uri = new Uri("pack://application:,,,/Assets/alert-default.mp4", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return null;
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert default video resource missing");
return null;
}
}
private void BrowseAlertVideo(Source? box)
{
if (box == null || box.Type != SourceType.AlertBox) return;
var dialog = new OpenFileDialog
{
Title = "Choose the alert clip (mp4)",
Filter = "Video files (*.mp4;*.mov;*.webm;*.mkv)|*.mp4;*.mov;*.webm;*.mkv|All files (*.*)|*.*"
};
if (dialog.ShowDialog() != true) return;
box.AlertVideoPath = dialog.FileName;
box.AlertUseDefaultVideo = false;
}
private void ResetAlertVideo(Source? box)
{
if (box == null || box.Type != SourceType.AlertBox) return;
box.AlertVideoPath = null;
box.AlertShowTicker = true;
box.AlertUseDefaultVideo = true;
}
}