Files
LlamaCasty/ViewModels/MainViewModel.Media.cs
T
gramps ea24405ba2 feat(chroma): offer to remove a green or blue screen, and key it in the decode
TASK 64 slices D and E: the feature is usable end to end.

- Add a video file → one frame is probed (new `IChromaProbe`/`FfmpegChromaProbe`, one ffmpeg
  pass at 160x90, off the UI thread) and the layer gets an offer: "Looks like a green screen —
  Remove it", with the measured colour as a swatch.
- Accepting it turns the offer into settings (Amount / Softness / Fringe) that reach ffmpeg as
  `colorkey` + `despill` + `format=bgra`, so the key is computed in the pipe and both consumers
  (the shared preview bitmap and the compositor's frame) carry the same alpha.
- Media sessions are now keyed by **path + look** (`MainViewModel.MediaSessionKey`), so two
  layers with the same settings still share one decoder, and changing the look releases the old
  session and starts the new one. Removal, load reconciliation, loop and audio settings all move
  to the same key.
- Settings persist on the `Source` row (six ALTER TABLE guards + SELECT/INSERT), so a keyed layer
  comes back keyed.
- Panel section is plain creator words: Green/Blue Screen, Amount, Softness, Fringe.

Test: `ChromaKeyApplyTests` — the pipe asks ffmpeg to key (and does not when off); a green-screen
clip is offered, accepting it starts a keyed session, the layer's resolver then reads that session
rather than the old one; the setting survives a save.

Docs: TASK 64 D/E ticked with slice F (auto-apply setting + alert clips) left open; frame-pipeline
records the decode-option design; HANDOFF rewritten. verify.sh: 544 passed, 0 failed, 0 warnings.
2026-11-05 08:15:32 -08:00

278 lines
12 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);
// 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.</summary>
private async System.Threading.Tasks.Task SuggestChromaKeyAsync(Source source)
{
if (!source.IsMediaSource || source.MediaPath is not { } path) return;
if (_chromaProbe == null) return;
var frame = await _chromaProbe.ProbeAsync(path).ConfigureAwait(true);
if (frame == null) return;
if (!string.Equals(source.MediaPath, path, StringComparison.OrdinalIgnoreCase)) return;
source.ChromaSuggestion = ChromaKeyDetector.Detect(frame);
}
/// <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.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];
}