aea0670723
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.
180 lines
6.8 KiB
C#
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;
|
|
}
|
|
}
|