Reported 2026-11-05: "when I click Loop, the video doesn't play in the preview pane". Two causes, both real: - `VideoClipPipe.StartAsync` bailed out once it had run once (`_run != null`), so a clip that reached EOF could never be started again — it held its last frame forever. - Loop is only consulted at end-of-file by a LIVE decoder, so flipping it on a finished clip did nothing at all. Now: the pipe reports running state and is restartable after a natural end; the manager marks a session finished (keeping its last frame) and exposes `IsRunning`/`RestartAsync`; the layer shows `IsMediaRunning`/`IsMediaFinished`; flipping Loop on a finished clip restarts it. UI: `▶ Play` sits to the right of the **Video File** label and is enabled only while Loop is off (creator ruling: with Loop on the clip already runs continuously, so there is nothing to start). The panel says "Clip finished — holding the last frame." so a frozen picture explains itself. Also: verify.sh no longer runs physical-input tests by default. That test moves the real cursor (SetCursorPos + mouse_event); running it mid-gate hijacks the machine someone is using, which is exactly how it felt. Opt in with PHYSICAL_TESTS=1 when the desktop is idle; the header documents the scoped-run command for working hours. Test: `MediaPlaybackRestartTests` (pipe restart, finished state, Play again, Loop-on-finished). 8 scoped tests green. FULL SUITE NOT RUN — the machine was in use; run scripts/verify.sh in a quiet window before the next milestone.
355 lines
15 KiB
C#
355 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.Linq;
|
|
using System.Windows.Media.Imaging;
|
|
using Microsoft.Win32;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Audio;
|
|
|
|
namespace ytLive.ViewModels;
|
|
|
|
// Media source (TASK 21): decode sessions are owned app-wide by
|
|
// MediaVideoSourceManager, refcounted by MediaPath. Modeled on the Webcam/Background
|
|
// partials: the readonly manager field is assigned in the core constructor, the
|
|
// shared WriteableBitmap published per file is adopted by every media Source whose
|
|
// MediaPath matches, and ResolveOutputFrame feeds the live compositor.
|
|
//
|
|
// TASK 64 changed what a "session" is: it is now keyed by file path + the layer's LOOK
|
|
// (green/blue screen settings), because the key is applied inside the decode pipe. Two
|
|
// layers with the same look share one decoder; two different looks get two.
|
|
public partial class MainViewModel
|
|
{
|
|
// Decode resolution: the 1920x1080 master. Media frames are scaled to the
|
|
// element rect by the compositor (UniformToFill), so this is the capture size.
|
|
private const int MediaDecodeWidth = 1920;
|
|
private const int MediaDecodeHeight = 1080;
|
|
|
|
private const string VideoFileFilter =
|
|
"Video files (*.mp4;*.mov;*.avi;*.mkv;*.webm)|*.mp4;*.mov;*.avi;*.mkv;*.webm|All files (*.*)|*.*";
|
|
|
|
private readonly MediaVideoSourceManager _mediaManager;
|
|
|
|
// Reads one frame of a clip so the layer can offer "looks like a green screen" (TASK 64).
|
|
private readonly IChromaProbe? _chromaProbe;
|
|
|
|
// One probe per file per launch — the answer belongs to the file, not to the layer.
|
|
private readonly HashSet<string> _chromaProbedPaths = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private bool _autoChromaScreen;
|
|
|
|
/// <summary>"Auto Green/Blue Screen": apply a detected screen without asking. Off by default —
|
|
/// the app offers, and only a creator who turns this on gets it done for them. Fires only on
|
|
/// high-confidence detections, because a wrong automatic key eats the picture.</summary>
|
|
public bool AutoChromaScreen
|
|
{
|
|
get => _autoChromaScreen;
|
|
set
|
|
{
|
|
if (!SetProperty(ref _autoChromaScreen, value)) return;
|
|
_layoutStore.SaveAutoChromaScreen(value);
|
|
}
|
|
}
|
|
|
|
// One subscription per media Source, so removing a layer stops the listener as
|
|
// well as the decoder (a scene element outlives its layer list row).
|
|
private readonly Dictionary<string, PropertyChangedEventHandler> _mediaHandlers = new();
|
|
|
|
/// <summary>What a decode session is: the file plus the look applied in the pipe (TASK 64).
|
|
/// A path cannot contain '|', so the key is unambiguous and human-readable in logs.</summary>
|
|
internal static string MediaSessionKey(string path, ChromaKeyOptions key)
|
|
=> $"{path}|{key.Signature}";
|
|
|
|
internal static string MediaSessionKey(Source source)
|
|
=> source.MediaPath is { } path ? MediaSessionKey(path, source.ChromaKey) : string.Empty;
|
|
|
|
// MediaVideoSourceManager publishes one shared WriteableBitmap per session; every
|
|
// media Source pointing at that path+look picks it up for the preview/canvas.
|
|
private void OnMediaPreviewBitmapChanged(string sessionKey, WriteableBitmap bitmap)
|
|
{
|
|
foreach (var source in Scenes.SelectMany(s => s.Elements).OfType<Source>())
|
|
if (source.IsMediaSource && MediaSessionKey(source) == sessionKey)
|
|
source.VideoImageSource = bitmap;
|
|
}
|
|
|
|
private void OnMediaFailed(string path, string message)
|
|
{
|
|
Helpers.AppLog.Write($"MediaSource: '{path}' failed: {message}");
|
|
_notifications.Warning("Couldn't play that video", System.IO.Path.GetFileName(SessionPath(path)));
|
|
}
|
|
|
|
// ---------- add / change the file ----------
|
|
|
|
private void AddMedia()
|
|
{
|
|
if (PickVideoFile() is { } path)
|
|
AddMediaSource(path);
|
|
}
|
|
|
|
private string? PickVideoFile(string title = "Choose a video file")
|
|
{
|
|
var dialog = new OpenFileDialog
|
|
{
|
|
Title = title,
|
|
Filter = VideoFileFilter,
|
|
};
|
|
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
|
}
|
|
|
|
/// <summary>Adds a MediaSource layer for <paramref name="path"/> to the staged scene.
|
|
/// Internal so the Good Dog test drives the real add path without a file dialog.</summary>
|
|
internal void AddMediaSource(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) return;
|
|
AddSource(SourceType.MediaSource, path);
|
|
}
|
|
|
|
private void ChangeMediaFile(Source? source)
|
|
{
|
|
if (source is not { IsMediaSource: true }) return;
|
|
if (PickVideoFile("Replace the video") is not { } path) return;
|
|
PushPreviewUndo();
|
|
source.ChromaSuggestion = default;
|
|
source.MediaPath = path;
|
|
}
|
|
|
|
// ---------- green / blue screen detection (TASK 64) ----------
|
|
|
|
/// <summary>Looks at one frame of the clip and stores the offer on the layer. Runs off the
|
|
/// UI thread through the probe seam; a file that cannot be read simply gets no offer.
|
|
/// Works for a video layer and for the alert box's own clip — a clip is a clip.</summary>
|
|
private async System.Threading.Tasks.Task SuggestChromaKeyAsync(Source source)
|
|
{
|
|
if (source.ChromaKeyEnabled) return;
|
|
if (ClipPathFor(source) is not { } path) return;
|
|
if (_chromaProbe == null) return;
|
|
|
|
var frame = await _chromaProbe.ProbeAsync(path).ConfigureAwait(true);
|
|
if (frame == null) return;
|
|
if (!string.Equals(ClipPathFor(source), path, StringComparison.OrdinalIgnoreCase)) return;
|
|
|
|
var suggestion = ChromaKeyDetector.Detect(frame);
|
|
source.ChromaSuggestion = suggestion;
|
|
|
|
if (AutoChromaScreen && suggestion.IsHighConfidence)
|
|
{
|
|
AcceptChromaSuggestion(source);
|
|
source.ChromaKeyAutoApplied = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>The file a layer plays: a video layer's clip, or the alert box's replacement clip.
|
|
/// The shipped alert default is not probed — it is ours, and it is not a screen shoot.</summary>
|
|
private static string? ClipPathFor(Source source)
|
|
=> source.IsMediaSource
|
|
? source.MediaPath
|
|
: source.Type == SourceType.AlertBox && !source.AlertUseDefaultVideo
|
|
? source.AlertVideoPath
|
|
: null;
|
|
|
|
/// <summary>Accepts the detector's offer: the layer keys the colour the camera actually saw.</summary>
|
|
private void AcceptChromaSuggestion(Source? source)
|
|
{
|
|
if (source is not { IsMediaSource: true } || source.ChromaSuggestion.Screen == ChromaScreen.None) return;
|
|
PushPreviewUndo();
|
|
var key = ChromaKeyOptions.For(source.ChromaSuggestion);
|
|
source.ChromaKeyColor = key.Color;
|
|
source.ChromaKeySimilarity = key.Similarity;
|
|
source.ChromaKeySoftness = key.Softness;
|
|
source.ChromaKeySpill = key.Spill;
|
|
source.ChromaScreen = key.Screen;
|
|
source.ChromaKeyAutoApplied = false;
|
|
source.ChromaKeyEnabled = true;
|
|
}
|
|
|
|
// ---------- session lifecycle (one session per path + look) ----------
|
|
|
|
/// <summary>Starts decoding the file if nothing else already is, and subscribes to the
|
|
/// layer so a file swap, a loop flip or a look change reaches the right session.</summary>
|
|
private void RegisterMediaSource(Source source)
|
|
{
|
|
if (_mediaHandlers.ContainsKey(source.Id)) return;
|
|
|
|
var lastKey = MediaSessionKey(source);
|
|
|
|
void OnSourceChanged(object? _, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName is nameof(Source.MediaPath) or nameof(Source.ChromaKeyEnabled)
|
|
or nameof(Source.ChromaKeyColor) or nameof(Source.ChromaKeySimilarity)
|
|
or nameof(Source.ChromaKeySoftness) or nameof(Source.ChromaKeySpill)
|
|
or nameof(Source.ChromaScreen))
|
|
{
|
|
var next = MediaSessionKey(source);
|
|
if (string.Equals(next, lastKey, StringComparison.OrdinalIgnoreCase)) return;
|
|
var previous = lastKey;
|
|
lastKey = next;
|
|
// A look change is a different decode: let the old session go (if nothing
|
|
// else uses it) and start the new one.
|
|
ReleaseMediaIfUnused(previous);
|
|
EnsureMediaSession(next);
|
|
return;
|
|
}
|
|
|
|
if (e.PropertyName == nameof(Source.MediaIsLooping) && MediaSessionKey(source) is { Length: > 0 } key)
|
|
{
|
|
var looping = AnyMediaReferenceLoops(key);
|
|
_mediaManager.SetLooping(key, looping);
|
|
// Loop is only consulted at end-of-file by a LIVE decoder, so flipping it on a clip
|
|
// that already finished used to do nothing at all (reported 2026-11-05). Start it.
|
|
if (looping && !_mediaManager.IsRunning(key))
|
|
_ = RestartMediaAsync(source, key);
|
|
}
|
|
else if (e.PropertyName is nameof(Source.MediaVolume) or nameof(Source.MediaMuted)
|
|
&& MediaSessionKey(source) is { Length: > 0 } audioKey)
|
|
{
|
|
ApplyMediaAudioSettings(audioKey);
|
|
}
|
|
}
|
|
|
|
_mediaHandlers[source.Id] = OnSourceChanged;
|
|
source.PropertyChanged += OnSourceChanged;
|
|
|
|
if (source.MediaPath is { Length: > 0 } path)
|
|
{
|
|
EnsureMediaSession(MediaSessionKey(path, source.ChromaKey));
|
|
|
|
// One probe per file per launch: the answer depends on the file, not the layer.
|
|
if (source.ChromaSuggestion.Screen == ChromaScreen.None && !_chromaProbedPaths.Contains(path))
|
|
{
|
|
_chromaProbedPaths.Add(path);
|
|
_ = SuggestChromaKeyAsync(source);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UnregisterMediaSource(Source source)
|
|
{
|
|
if (_mediaHandlers.Remove(source.Id, out var handler))
|
|
source.PropertyChanged -= handler;
|
|
}
|
|
|
|
private void EnsureMediaSession(string? sessionKey)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sessionKey)) return;
|
|
if (_mediaManager.HasSession(sessionKey))
|
|
{
|
|
ApplyMediaAudioSettings(sessionKey);
|
|
return;
|
|
}
|
|
_ = AcquireMediaSessionAsync(sessionKey);
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task AcquireMediaSessionAsync(string sessionKey)
|
|
{
|
|
var source = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.FirstOrDefault(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey);
|
|
if (source?.MediaPath is not { } path) return;
|
|
|
|
if (!await _mediaManager.AcquireAsync(sessionKey, path, source.ChromaKey)) return;
|
|
source.IsMediaRunning = true;
|
|
source.MediaPlaybackState = MediaPlaybackState.Playing;
|
|
|
|
// A session can be running before this layer existed (shared look), in which
|
|
// case the bitmap is already published and will not be raised again for us.
|
|
if (_mediaManager.GetPreviewBitmap(sessionKey) is { } bitmap)
|
|
OnMediaPreviewBitmapChanged(sessionKey, bitmap);
|
|
|
|
ApplyMediaAudioSettings(sessionKey);
|
|
}
|
|
|
|
/// <summary>The session's sound controls: one decoder is shared per path+look, so the level
|
|
/// and mute are per session — the layer that was last touched is the one that wins.</summary>
|
|
private void ApplyMediaAudioSettings(string sessionKey)
|
|
{
|
|
var source = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.FirstOrDefault(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey);
|
|
if (source == null) return;
|
|
_mediaManager.SetAudioSettings(sessionKey, source.MediaVolume, source.MediaMuted);
|
|
}
|
|
|
|
/// <summary>A clip's own soundtrack joins the desktop/game group, so the desktop fader and
|
|
/// the voice ducker treat it exactly like game audio (TASK 21 audio slice).</summary>
|
|
/// <summary>A clip played out: say so on the layer, so the panel stops pretending it is playing.</summary>
|
|
private void OnMediaSessionCompleted(string sessionKey)
|
|
{
|
|
foreach (var source in Scenes.SelectMany(s => s.Elements).OfType<Source>())
|
|
if (source.IsMediaSource && MediaSessionKey(source) == sessionKey)
|
|
{
|
|
source.IsMediaRunning = false;
|
|
source.MediaPlaybackState = MediaPlaybackState.Stopped;
|
|
}
|
|
}
|
|
|
|
/// <summary>Starts a finished clip again from the top (the panel's "Play again").</summary>
|
|
private void ReplayMedia(Source? source)
|
|
{
|
|
if (source is not { IsMediaSource: true }) return;
|
|
var key = MediaSessionKey(source);
|
|
if (key.Length == 0) return;
|
|
|
|
_ = RestartMediaAsync(source, key);
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task RestartMediaAsync(Source source, string sessionKey)
|
|
{
|
|
if (!_mediaManager.HasSession(sessionKey))
|
|
{
|
|
EnsureMediaSession(sessionKey);
|
|
return;
|
|
}
|
|
|
|
if (await _mediaManager.RestartAsync(sessionKey))
|
|
{
|
|
source.IsMediaRunning = true;
|
|
source.MediaPlaybackState = MediaPlaybackState.Playing;
|
|
}
|
|
}
|
|
|
|
private void OnMediaAudioAvailable(string sessionKey, AudioSample sample)
|
|
=> _audioMixer.EnqueueDesktopMediaAudio(sample);
|
|
|
|
/// <summary>Shared sessions or not, the loop state is the session's: any live reference
|
|
/// asking for a loop makes that one decoder loop (recorded rule, TASK 21).</summary>
|
|
private bool AnyMediaReferenceLoops(string sessionKey)
|
|
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Any(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey && s.MediaIsLooping);
|
|
|
|
private bool HasMediaReference(string sessionKey)
|
|
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Any(s => s.IsMediaSource && string.Equals(MediaSessionKey(s), sessionKey, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private void ReleaseMediaIfUnused(string? sessionKey)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sessionKey)) return;
|
|
if (!_mediaManager.HasSession(sessionKey)) return;
|
|
if (HasMediaReference(sessionKey)) return;
|
|
_ = _mediaManager.ReleaseAsync(sessionKey);
|
|
}
|
|
|
|
/// <summary>After a layout load: subscribe every persisted media layer and start one
|
|
/// session per distinct path+look; release sessions the layout no longer uses.</summary>
|
|
private void ReacquireMediaSources()
|
|
{
|
|
var sources = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Where(s => s.IsMediaSource && !string.IsNullOrWhiteSpace(s.MediaPath))
|
|
.ToList();
|
|
|
|
foreach (var source in sources)
|
|
RegisterMediaSource(source);
|
|
|
|
var wanted = sources.Select(MediaSessionKey)
|
|
.Where(key => key.Length > 0)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
foreach (var stale in _mediaManager.GetActiveKeys()
|
|
.Where(key => !wanted.Contains(key, StringComparer.OrdinalIgnoreCase))
|
|
.ToList())
|
|
_ = _mediaManager.ReleaseAllAsync(stale);
|
|
}
|
|
|
|
/// <summary>A session key carries a file path and a look; the toast wants the file name.</summary>
|
|
private static string SessionPath(string sessionKey)
|
|
=> sessionKey.Split('|')[0];
|
|
}
|