TASK 64 slice F — the two things left after the feature worked by hand. - App Settings → **Video clips → Auto Green/Blue Screen** (off by default). When on, a high-confidence detected screen is keyed as soon as the clip is added; the panel says "Removed automatically" so an automatic change is never silent. Medium confidence still asks, low says nothing — a wrong automatic key eats the picture. - **A clip is a clip:** the alert box's own replacement clip keys through the same settings block (one alert box per layout, so its settings are the ones the next alert uses), and choosing an alert clip runs the same detector. The Green/Blue Screen panel now appears for a video layer *or* an alert box with a custom clip (`Source.ShowsChromaKey`). - Spill ships as the creator-facing **Fringe** switch; nothing further to tune behind it. Test: `ChromaKeyApplyTests` gains the auto-apply case (no click, still explicit in the panel) and an alert-clip case (its pipe asks ffmpeg for colorkey+despill with the blue screen type). Docs: TASK 64 complete in TASKS.md, known limits recorded in the task file, HANDOFF rewritten. verify.sh: 546 passed, 0 failed, 0 warnings.
312 lines
13 KiB
C#
312 lines
13 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)
|
|
{
|
|
_mediaManager.SetLooping(key, AnyMediaReferenceLoops(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;
|
|
|
|
// 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>
|
|
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];
|
|
}
|