diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index 30af66c..fd054b8 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -761,6 +761,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HANDOFF.md b/HANDOFF.md
index c2c8a2f..521d6e8 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,21 +1,24 @@
-# HANDOFF — 2026-11-05 (TASK 21 — video file layer + its audio)
+# HANDOFF — 2026-11-05 (TASK 64 — green/blue screen for clips)
## State
-- `main`; TASK 21 is complete. A video file added from the (+) menu decodes, composites into preview and
- stream, loops on toggle, and **plays its own soundtrack** (Mute + Level per layer, mixed into the
- desktop/game group so the desktop fader and the ducker apply). Removing it stops the decoder.
-- Decoding is now ONE pipe: `Services/VideoClipPipe.cs` behind both `MediaVideoSource` (TASK 21) and
- `AlertClipDecoder` (TASK 47) — "a clip is a clip". Alert behaviour is unchanged and its tests stay green.
-- Tests: `MediaSourceAcquisitionTests` (add → decoder starts, frames, loop, remove) and
- `MediaSourceAudioTests` (chunks, level scaling, mute, sound reaching the desktop meter). App build 0 warnings.
+- `main`. TASK 21 complete (video file layer + its soundtrack). TASK 64 shipped through slice E:
+ a clip with a green or blue backdrop is **offered** a key on add ("Looks like a green screen —
+ Remove it"), accepting it keys the background **inside the decode pipe** (bundled ffmpeg
+ `colorkey`/`despill`), with Amount/Softness/Fringe in the panel and the settings persisted.
+ Sessions are keyed by **path + look**, so identical looks share one decoder.
+- Detection stays silent on lawns/pitches (confidence = coverage × hue tightness) and ignores
+ letterbox bars. It never acts on its own.
+- Tests: `ChromaKeyDetectorTests` (pure) + `ChromaKeyApplyTests` (pipe asks ffmpeg to key; offer →
+ accept → keyed session feeds the resolver; survives save). App build 0 warnings; full suite green
+ apart from the two documented pre-existing failures.
## Next action
-- Next queued unit: TASK 49 (chat profanity filter). Day 3's TASK 32/33 resilience slices are still
- outstanding — 32·4 (fragmented MP4) is the smallest.
-- Agreed next feature work (designed, not started): green/blue screen detection + keying through the clip
- pipe — suggest-only by default, an opt-in "Auto Green/Blue Screen" setting, alert clips included.
+- TASK 64 slice F: the **"Auto Green/Blue Screen"** opt-in setting (App Settings, high confidence
+ only) and wiring alert clips to the same settings block.
+- Then TASK 49 (chat profanity filter). Day 3's TASK 32/33 resilience slices remain outstanding —
+ 32·4 (fragmented MP4) is the smallest.
- Do not push absent a checkpoint request.
## Landmines
@@ -23,7 +26,6 @@
- `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical
mouse input. Do not run it while the desktop is in use.
- Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`.
-- Media sessions are keyed per **path**, not per layer: acquire through `EnsureMediaSession`, release
- through `ReleaseMediaIfUnused`, never `AcquireAsync` twice for one path. Level/mute are therefore
- per session too.
+- Media sessions are keyed by **path + look** (`MainViewModel.MediaSessionKey`); never acquire by raw
+ path, and release through `ReleaseMediaIfUnused`.
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
diff --git a/Models/Source.cs b/Models/Source.cs
index a08ca4a..4636c2b 100644
--- a/Models/Source.cs
+++ b/Models/Source.cs
@@ -1,5 +1,6 @@
using System.Windows.Media;
using ytLive.Helpers;
+using ytLive.Services;
namespace ytLive.Models;
@@ -282,6 +283,114 @@ public class Source : SceneElement
public override bool IsMediaSource => Type == SourceType.MediaSource;
+ // ── Green/Blue Screen keying (TASK 64) ────────────────────────────────────────────
+ // Settings live on the LAYER. The decoder is keyed by (path + these settings), so two
+ // layers with the same look share one decode and two different looks get two decoders.
+
+ private bool _chromaKeyEnabled;
+ public bool ChromaKeyEnabled
+ {
+ get => _chromaKeyEnabled;
+ set
+ {
+ if (Set(ref _chromaKeyEnabled, value))
+ {
+ Raise(nameof(ChromaKey));
+ Raise(nameof(HasChromaSuggestion));
+ }
+ }
+ }
+
+ private string _chromaKeyColor = "#00B140";
+ /// The screen colour as it was shot (the detector's median), not a canonical green.
+ public string ChromaKeyColor
+ {
+ get => _chromaKeyColor;
+ set
+ {
+ if (Set(ref _chromaKeyColor, value)) Raise(nameof(ChromaKey));
+ }
+ }
+
+ private double _chromaKeySimilarity = 0.12;
+ /// Creator-facing "Amount": how far from the key colour still counts as screen.
+ public double ChromaKeySimilarity
+ {
+ get => _chromaKeySimilarity;
+ set
+ {
+ if (Set(ref _chromaKeySimilarity, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
+ }
+ }
+
+ private double _chromaKeySoftness = 0.08;
+ /// Creator-facing "Softness": the width of the fade at the keyed edge.
+ public double ChromaKeySoftness
+ {
+ get => _chromaKeySoftness;
+ set
+ {
+ if (Set(ref _chromaKeySoftness, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
+ }
+ }
+
+ private bool _chromaKeySpill = true;
+ /// Creator-facing "Fringe": clean the coloured rim the screen leaves on hair and edges.
+ public bool ChromaKeySpill
+ {
+ get => _chromaKeySpill;
+ set
+ {
+ if (Set(ref _chromaKeySpill, value)) Raise(nameof(ChromaKey));
+ }
+ }
+
+ private ChromaScreen _chromaScreen = ChromaScreen.Green;
+ public ChromaScreen ChromaScreen
+ {
+ get => _chromaScreen;
+ set
+ {
+ if (Set(ref _chromaScreen, value)) Raise(nameof(ChromaKey));
+ }
+ }
+
+ /// The look this layer asks its decoder for — the one place the settings become an
+ /// ffmpeg chain and a session signature.
+ public ChromaKeyOptions ChromaKey
+ => new(ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness, ChromaKeySpill, ChromaScreen);
+
+ private ChromaKeySuggestion _chromaSuggestion;
+ /// The detector's offer for this clip. Transient by design — never persisted, because
+ /// it is a hint about a file, not a setting the creator made.
+ public ChromaKeySuggestion ChromaSuggestion
+ {
+ get => _chromaSuggestion;
+ set
+ {
+ _chromaSuggestion = value;
+ Raise(nameof(ChromaSuggestion));
+ Raise(nameof(HasChromaSuggestion));
+ Raise(nameof(ChromaSuggestionText));
+ Raise(nameof(HasChromaSuggestionColor));
+ }
+ }
+
+ /// True while there is an offer the creator has not accepted.
+ public bool HasChromaSuggestion => !ChromaKeyEnabled && ChromaSuggestion.Screen != ChromaScreen.None;
+
+ public string ChromaSuggestionText => ChromaSuggestion.Screen switch
+ {
+ ChromaScreen.Green => "Looks like a green screen.",
+ ChromaScreen.Blue => "Looks like a blue screen.",
+ _ => string.Empty,
+ };
+
+ /// The detected colour, for the swatch next to the offer.
+ public string ChromaSuggestionColor => ChromaSuggestion.ColorHex ?? "#00B140";
+
+ public bool HasChromaSuggestionColor => ChromaSuggestion.ColorHex != null;
+
// Alert video config (TASK 47): the shipped default clip lives in the Asset
// table (seeded like image assets); the creator's replacement is a FILE PATH
// reference only — never into the DB (creator ruling, 2026-09-26). Runtime
diff --git a/Services/ChromaProbe.cs b/Services/ChromaProbe.cs
new file mode 100644
index 0000000..082b40f
--- /dev/null
+++ b/Services/ChromaProbe.cs
@@ -0,0 +1,74 @@
+using System.Diagnostics;
+using System.IO;
+using ytLive.Services.Encoder;
+
+namespace ytLive.Services;
+
+///
+/// TASK 64: reads ONE frame out of a video file so the app can look at its background before
+/// the creator says anything. Kept behind an interface so tests never spawn ffmpeg, and separate
+/// from the decode pipe because this is a read, not a playback session.
+///
+public interface IChromaProbe
+{
+ /// The file's first usable frame at a small size, or null when it cannot be read
+ /// (bad path, unsupported file, ffmpeg gave nothing). Null is a normal answer: the layer
+ /// simply gets no suggestion.
+ Task ProbeAsync(string path, CancellationToken cancellationToken = default);
+}
+
+/// Default : one ffmpeg pass, scaled to 160×90 so the look is
+/// cheap and the detector sees plenty of the backdrop.
+public sealed class FfmpegChromaProbe : IChromaProbe
+{
+ private const int ProbeWidth = 160;
+ private const int ProbeHeight = 90;
+
+ private readonly IFfmpegLocator _locator;
+ private readonly Func _processFactory;
+
+ public FfmpegChromaProbe(IFfmpegLocator locator, Func processFactory)
+ {
+ _locator = locator;
+ _processFactory = processFactory;
+ }
+
+ public async Task ProbeAsync(string path, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return null;
+ try
+ {
+ var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
+ var args =
+ $"-hide_banner -loglevel error -i \"{path}\" -frames:v 1 -an " +
+ $"-f rawvideo -pix_fmt bgra -vf scale={ProbeWidth}:{ProbeHeight} -";
+
+ using var process = _processFactory();
+ process.Start(new ProcessStartInfo
+ {
+ FileName = ffmpegPath,
+ Arguments = args,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ });
+
+ var frames = await Task.Run(() =>
+ {
+ using var buffer = new MemoryStream();
+ process.StandardOutput.CopyTo(buffer);
+ var reader = new RawVideoFrameReader(ProbeWidth, ProbeHeight);
+ var fed = reader.Feed(buffer.ToArray());
+ return fed;
+ }, cancellationToken).ConfigureAwait(false);
+
+ return frames.Count > 0 ? frames[0] : null;
+ }
+ catch (Exception ex)
+ {
+ Helpers.AppLog.Write($"ChromaProbe: '{Path.GetFileName(path)}' unreadable: {ex.Message}");
+ return null;
+ }
+ }
+}
diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs
index 4c7871e..c0035b3 100644
--- a/Services/LayoutStore.Load.cs
+++ b/Services/LayoutStore.Load.cs
@@ -113,7 +113,9 @@ public partial class LayoutStore : IDisposable
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
- TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted
+ TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted,
+ ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness,
+ ChromaKeySpill, ChromaScreen
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -184,6 +186,16 @@ public partial class LayoutStore : IDisposable
? TextLayerVerticalAlignment.Top
: (TextLayerVerticalAlignment)reader.GetInt32(49),
MediaMuted = reader.GetInt32(reader.GetOrdinal("MediaMuted")) != 0,
+ ChromaKeyEnabled = reader.GetInt32(reader.GetOrdinal("ChromaKeyEnabled")) != 0,
+ ChromaKeyColor = reader.IsDBNull(reader.GetOrdinal("ChromaKeyColor"))
+ ? "#00B140"
+ : reader.GetString(reader.GetOrdinal("ChromaKeyColor")),
+ ChromaKeySimilarity = reader.GetDouble(reader.GetOrdinal("ChromaKeySimilarity")),
+ ChromaKeySoftness = reader.GetDouble(reader.GetOrdinal("ChromaKeySoftness")),
+ ChromaKeySpill = reader.GetInt32(reader.GetOrdinal("ChromaKeySpill")) != 0,
+ ChromaScreen = reader.IsDBNull(reader.GetOrdinal("ChromaScreen"))
+ ? ChromaScreen.Green
+ : (ChromaScreen)reader.GetInt32(reader.GetOrdinal("ChromaScreen")),
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>();
diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs
index ec05b85..61a9e62 100644
--- a/Services/LayoutStore.Migrations.cs
+++ b/Services/LayoutStore.Migrations.cs
@@ -385,6 +385,44 @@ public partial class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
+ // TASK 64: green/blue screen settings per layer (applied in the decode pipe).
+ if (!columns.Contains("ChromaKeyEnabled"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeyEnabled INTEGER NOT NULL DEFAULT 0;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChromaKeyColor"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeyColor TEXT NOT NULL DEFAULT '#00B140';";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChromaKeySimilarity"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySimilarity REAL NOT NULL DEFAULT 0.12;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChromaKeySoftness"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySoftness REAL NOT NULL DEFAULT 0.08;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChromaKeySpill"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySpill INTEGER NOT NULL DEFAULT 1;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChromaScreen"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaScreen INTEGER NOT NULL DEFAULT 0;";
+ cmd.ExecuteNonQuery();
+ }
+
// TASK 47: alert video config. The default clip asset id + the creator's
// replacement path (a FILE reference, never a stored BLOB) + the pill,
// ticker toggle and volume live on the row.
diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs
index 1933e81..fe5849a 100644
--- a/Services/LayoutStore.Save.cs
+++ b/Services/LayoutStore.Save.cs
@@ -94,7 +94,9 @@ public partial class LayoutStore : IDisposable
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
- TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted)
+ TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted,
+ ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity,
+ ChromaKeySoftness, ChromaKeySpill, ChromaScreen)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
@@ -104,7 +106,9 @@ public partial class LayoutStore : IDisposable
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
- $textUnderline, $textAlignment, $textVerticalAlignment, $mediaMuted)
+ $textUnderline, $textAlignment, $textVerticalAlignment, $mediaMuted,
+ $chromaEnabled, $chromaColor, $chromaSimilarity, $chromaSoftness,
+ $chromaSpill, $chromaScreen)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -138,6 +142,12 @@ public partial class LayoutStore : IDisposable
var mediaIsLoopingP = cmd.Parameters.Add("$mediaIsLooping", SqliteType.Integer);
var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
var mediaMutedP = cmd.Parameters.Add("$mediaMuted", SqliteType.Integer);
+ var chromaEnabledP = cmd.Parameters.Add("$chromaEnabled", SqliteType.Integer);
+ var chromaColorP = cmd.Parameters.Add("$chromaColor", SqliteType.Text);
+ var chromaSimilarityP = cmd.Parameters.Add("$chromaSimilarity", SqliteType.Real);
+ var chromaSoftnessP = cmd.Parameters.Add("$chromaSoftness", SqliteType.Real);
+ var chromaSpillP = cmd.Parameters.Add("$chromaSpill", SqliteType.Integer);
+ var chromaScreenP = cmd.Parameters.Add("$chromaScreen", SqliteType.Integer);
var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
var alertVideoAssetIdP = cmd.Parameters.Add("$alertVideoAssetId", SqliteType.Text);
var alertVideoPathP = cmd.Parameters.Add("$alertVideoPath", SqliteType.Text);
@@ -200,6 +210,12 @@ public partial class LayoutStore : IDisposable
mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
mediaVolumeP.Value = source.MediaVolume;
mediaMutedP.Value = source.MediaMuted ? 1 : 0;
+ chromaEnabledP.Value = source.ChromaKeyEnabled ? 1 : 0;
+ chromaColorP.Value = source.ChromaKeyColor;
+ chromaSimilarityP.Value = source.ChromaKeySimilarity;
+ chromaSoftnessP.Value = source.ChromaKeySoftness;
+ chromaSpillP.Value = source.ChromaKeySpill ? 1 : 0;
+ chromaScreenP.Value = (int)source.ChromaScreen;
mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString();
alertVideoAssetIdP.Value = (object?)source.AlertVideoAssetId ?? DBNull.Value;
alertVideoPathP.Value = (object?)source.AlertVideoPath ?? DBNull.Value;
diff --git a/Services/MediaVideoSource.cs b/Services/MediaVideoSource.cs
index 24110d2..ebcd00a 100644
--- a/Services/MediaVideoSource.cs
+++ b/Services/MediaVideoSource.cs
@@ -10,6 +10,9 @@ namespace ytLive.Services;
/// (TASK 47), so the two features cannot drift apart again in how they decode.
/// Video AND the file's own soundtrack are decoded. The manager scales the audio by
/// the layer's level/mute and hands it to the mixer's desktop group.
+/// Keying (TASK 64) is a decode option: pass and the
+/// green/blue screen is removed inside ffmpeg's pipe, so the preview bitmap and the composited
+/// frame both carry the keyed alpha. Two looks = two sources; identical looks share one.
///
public sealed class MediaVideoSource : IMediaFrameSource
{
@@ -35,11 +38,13 @@ public sealed class MediaVideoSource : IMediaFrameSource
IFfmpegLocator locator,
Func processFactory,
IFrameRateProbe? frameRateProbe = null,
- Func? delay = null)
+ Func? delay = null,
+ ChromaKeyOptions? chromaKey = null)
{
_path = path;
_clip = new VideoClipPipe(
- path, width, height, locator, processFactory, frameRateProbe, delay, audio: true);
+ path, width, height, locator, processFactory, frameRateProbe, delay,
+ audio: true, chromaKey: chromaKey);
_clip.FrameAvailable += frame => FrameAvailable?.Invoke(frame);
_clip.AudioAvailable += sample => AudioAvailable?.Invoke(sample);
_clip.Completed += () => Completed?.Invoke();
diff --git a/Services/MediaVideoSourceManager.cs b/Services/MediaVideoSourceManager.cs
index e3d0de1..05176a4 100644
--- a/Services/MediaVideoSourceManager.cs
+++ b/Services/MediaVideoSourceManager.cs
@@ -41,7 +41,8 @@ public sealed class MediaVideoSourceManager : IDisposable
}
}
- private readonly Func _sourceFactory;
+ // (file path, the layer's look) → decoder. The SESSION key is separate: path + look.
+ private readonly Func _sourceFactory;
private readonly Dispatcher? _uiDispatcher;
private readonly Func? _loopProvider;
private readonly Dictionary _sessions = new();
@@ -58,7 +59,7 @@ public sealed class MediaVideoSourceManager : IDisposable
public event Action? AudioAvailable;
public MediaVideoSourceManager(
- Func sourceFactory,
+ Func sourceFactory,
Dispatcher? uiDispatcher = null,
Func? loopProvider = null)
{
@@ -67,8 +68,14 @@ public sealed class MediaVideoSourceManager : IDisposable
_loopProvider = loopProvider;
}
- /// Increments the refcount for a file, starting the decoder the first time.
- public async Task AcquireAsync(string key)
+ /// Increments the refcount for a file, starting the decoder the first time.
+ /// is the layer's look (TASK 64): the caller keys sessions by
+ /// path + look, so two looks on one file are two decoders and identical looks share one.
+ public async Task AcquireAsync(string key, ChromaKeyOptions? keyOptions = null)
+ => await AcquireAsync(key, key, keyOptions).ConfigureAwait(false);
+
+ /// Acquires by session key, decoding with the given look.
+ public async Task AcquireAsync(string key, string path, ChromaKeyOptions? keyOptions = null)
{
if (string.IsNullOrWhiteSpace(key)) return false;
@@ -87,7 +94,7 @@ public sealed class MediaVideoSourceManager : IDisposable
IMediaFrameSource? source;
try
{
- source = _sourceFactory(key);
+ source = _sourceFactory(path, keyOptions);
}
catch (Exception ex)
{
diff --git a/Services/VideoClipPipe.cs b/Services/VideoClipPipe.cs
index bdfa4ca..1934d05 100644
--- a/Services/VideoClipPipe.cs
+++ b/Services/VideoClipPipe.cs
@@ -44,6 +44,7 @@ public sealed class VideoClipPipe : IDisposable
private readonly IFrameRateProbe? _probe;
private readonly Func _delay;
private readonly bool _audio;
+ private readonly ChromaKeyOptions? _key;
private readonly CancellationTokenSource _cts = new();
private IDisposable? _run;
private IDecodeProcess? _activeVideo;
@@ -57,7 +58,8 @@ public sealed class VideoClipPipe : IDisposable
Func processFactory,
IFrameRateProbe? frameRateProbe = null,
Func? delay = null,
- bool audio = true)
+ bool audio = true,
+ ChromaKeyOptions? chromaKey = null)
{
_path = path;
_width = width;
@@ -67,6 +69,7 @@ public sealed class VideoClipPipe : IDisposable
_probe = frameRateProbe;
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
_audio = audio;
+ _key = chromaKey;
}
public Task StartAsync(CancellationToken cancellationToken = default)
@@ -147,7 +150,7 @@ public sealed class VideoClipPipe : IDisposable
{
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
- $"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
+ $"-f rawvideo -pix_fmt bgra -vf {VideoFilterChain.BuildVideo(_width, _height, _key)} -an -";
using var process = _processFactory();
_activeVideo = process;
process.Start(new ProcessStartInfo
diff --git a/TASKS.md b/TASKS.md
index 573d143..723e978 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -43,7 +43,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block).
| — | ✅ | Output entitlement code: free at 30 fps / paid at 60 fps regardless of resolution; Steam App ID is still pending | [`commercial`](docs/ai/commercial.md) |
| 3·17 | ✅ | **Text layer** — content, font family/size/weight/style, colour, alignment, drag/resize, persisted (done 2026-11-04) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
| 21 | ✅ | Media source: video file added from the (+) menu decodes, composites, plays its soundtrack, loops, and stops with its last reference (done 2026-11-05) | [`task-21`](TASKS/task-21-media-source.md) |
-| 64 | 🔶 **NEXT** | **Green/Blue Screen** — detect a green/blue backdrop in a clip and key it out through the decode pipe; suggest-only, opt-in auto-apply, alert clips included | [`task-64`](TASKS/task-64-green-blue-screen.md) |
+| 64 | 🔶 | **Green/Blue Screen** — detect + key shipped 2026-11-05 (offer on add, Amount/Softness/Fringe, keyed in the decode pipe, persisted); **remaining: opt-in auto-apply setting + alert clips** | [`task-64`](TASKS/task-64-green-blue-screen.md) |
| — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
| 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
diff --git a/TASKS/task-64-green-blue-screen.md b/TASKS/task-64-green-blue-screen.md
index d91b444..fba5576 100644
--- a/TASKS/task-64-green-blue-screen.md
+++ b/TASKS/task-64-green-blue-screen.md
@@ -46,9 +46,9 @@ lighting to blame. The reversal is recorded there.
| A | ✅ media audio + shared clip pipe (TASK 21) | shipped `2670ac0` |
| B | ✅ Pure `Services/ChromaKeyDetector.cs`: sample a `VideoFrame` → green / blue / none + suggested colour + confidence | synthetic frames: green, blue, gradient, letterboxed, lawn (must say none) |
| C | ✅ Pure `Services/VideoFilterChain.cs`: options → `-vf` string (`scale → colorkey → despill → format=bgra`) | exact string assertions (encoder `FfmpegArgs` style) |
-| D | Detect on add: one `-frames:v 1` probe (mirror `IFrameRateProbe`) → suggestion in the panel; no rendering change | add a green-screen file → suggestion appears with its colour |
-| E | Apply: session key `path|key-settings`, filter chain in the pipe, level/mute untouched | keyed layer over art shows the art through keyed pixels **and** the preview bitmap carries alpha |
-| F | Alert clips (falls out of E), tuning sliders, "Auto Green/Blue Screen" setting (App Settings), spill | slider reaches the chain and the restart preserves loop state |
+| D | ✅ Detect on add: one `-frames:v 1` probe (mirror `IFrameRateProbe`) → suggestion in the panel; no rendering change | add a green-screen file → suggestion appears with its colour |
+| E | ✅ Apply: session key `path|key-settings`, filter chain in the pipe, level/mute untouched | keyed layer over art shows the art through keyed pixels **and** the preview bitmap carries alpha |
+| F | ☐ **Remaining:** "Auto Green/Blue Screen" opt-in in App Settings (high confidence only), alert clips wired to the same settings block, spill quality pass | slider reaches the chain and the restart preserves loop state |
## 3. Landmines for whoever builds this
diff --git a/ViewModels/MainViewModel.Media.cs b/ViewModels/MainViewModel.Media.cs
index 1515856..59b3489 100644
--- a/ViewModels/MainViewModel.Media.cs
+++ b/ViewModels/MainViewModel.Media.cs
@@ -15,6 +15,10 @@ namespace ytLive.ViewModels;
// 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
@@ -27,23 +31,37 @@ public partial class MainViewModel
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 _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 _mediaHandlers = new();
- // MediaVideoSourceManager publishes one shared WriteableBitmap per file; every
- // media Source pointing at that path picks it up for the preview/canvas.
- private void OnMediaPreviewBitmapChanged(string path, WriteableBitmap bitmap)
+ /// 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.
+ 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())
- .Where(s => s.IsMediaSource && s.MediaPath == path))
- source.VideoImageSource = bitmap;
+ foreach (var source in Scenes.SelectMany(s => s.Elements).OfType())
+ 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(path));
+ _notifications.Warning("Couldn't play that video", System.IO.Path.GetFileName(SessionPath(path)));
}
// ---------- add / change the file ----------
@@ -77,45 +95,93 @@ public partial class MainViewModel
if (source is not { IsMediaSource: true }) return;
if (PickVideoFile("Replace the video") is not { } path) return;
PushPreviewUndo();
+ source.ChromaSuggestion = default;
source.MediaPath = path;
}
- // ---------- session lifecycle (one session per distinct path) ----------
+ // ---------- green / blue screen detection (TASK 64) ----------
+
+ /// 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.
+ 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);
+ }
+
+ /// Accepts the detector's offer: the layer keys the colour the camera actually saw.
+ 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) ----------
/// Starts decoding the file if nothing else already is, and subscribes to the
- /// layer so a file swap or a loop flip reaches the session.
+ /// layer so a file swap, a loop flip or a look change reaches the right session.
private void RegisterMediaSource(Source source)
{
if (_mediaHandlers.ContainsKey(source.Id)) return;
- string? lastPath = source.MediaPath;
+ var lastKey = MediaSessionKey(source);
void OnSourceChanged(object? _, PropertyChangedEventArgs e)
{
- if (e.PropertyName == nameof(Source.MediaPath))
+ 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 previous = lastPath;
- lastPath = source.MediaPath;
- if (string.Equals(previous, source.MediaPath, StringComparison.OrdinalIgnoreCase)) return;
- // The layer now points elsewhere, so the old file may be unreferenced —
- // release it before acquiring the replacement.
+ 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(source.MediaPath);
+ EnsureMediaSession(next);
+ return;
}
- else if (e.PropertyName == nameof(Source.MediaIsLooping) && source.MediaPath is { } path)
+
+ if (e.PropertyName == nameof(Source.MediaIsLooping) && MediaSessionKey(source) is { Length: > 0 } key)
{
- _mediaManager.SetLooping(path, AnyMediaReferenceLoops(path));
+ _mediaManager.SetLooping(key, AnyMediaReferenceLoops(key));
}
else if (e.PropertyName is nameof(Source.MediaVolume) or nameof(Source.MediaMuted)
- && source.MediaPath is { } audioPath)
+ && MediaSessionKey(source) is { Length: > 0 } audioKey)
{
- ApplyMediaAudioSettings(audioPath);
+ ApplyMediaAudioSettings(audioKey);
}
}
_mediaHandlers[source.Id] = OnSourceChanged;
source.PropertyChanged += OnSourceChanged;
- EnsureMediaSession(source.MediaPath);
+
+ 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)
@@ -124,64 +190,68 @@ public partial class MainViewModel
source.PropertyChanged -= handler;
}
- private void EnsureMediaSession(string? path)
+ private void EnsureMediaSession(string? sessionKey)
{
- if (string.IsNullOrWhiteSpace(path)) return;
- if (_mediaManager.HasSession(path))
+ if (string.IsNullOrWhiteSpace(sessionKey)) return;
+ if (_mediaManager.HasSession(sessionKey))
{
- ApplyMediaAudioSettings(path);
+ ApplyMediaAudioSettings(sessionKey);
return;
}
- _ = AcquireMediaAsync(path);
+ _ = AcquireMediaSessionAsync(sessionKey);
}
- private async System.Threading.Tasks.Task AcquireMediaAsync(string path)
- {
- if (!await _mediaManager.AcquireAsync(path)) return;
-
- // A session can be running before this layer existed (shared path), in which
- // case the bitmap is already published and will not be raised again for us.
- if (_mediaManager.GetPreviewBitmap(path) is { } bitmap)
- OnMediaPreviewBitmapChanged(path, bitmap);
-
- ApplyMediaAudioSettings(path);
- }
-
- /// The file's sound controls: one decoder is shared per path, so the level and
- /// mute are per session — the layer that was last touched is the one that wins.
- private void ApplyMediaAudioSettings(string path)
+ private async System.Threading.Tasks.Task AcquireMediaSessionAsync(string sessionKey)
{
var source = Scenes.SelectMany(s => s.Elements).OfType()
- .FirstOrDefault(s => s.IsMediaSource && s.MediaPath == path);
+ .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);
+ }
+
+ /// 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.
+ private void ApplyMediaAudioSettings(string sessionKey)
+ {
+ var source = Scenes.SelectMany(s => s.Elements).OfType()
+ .FirstOrDefault(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey);
if (source == null) return;
- _mediaManager.SetAudioSettings(path, source.MediaVolume, source.MediaMuted);
+ _mediaManager.SetAudioSettings(sessionKey, source.MediaVolume, source.MediaMuted);
}
/// 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).
- private void OnMediaAudioAvailable(string path, AudioSample sample)
+ private void OnMediaAudioAvailable(string sessionKey, AudioSample sample)
=> _audioMixer.EnqueueDesktopMediaAudio(sample);
- /// Shared sessions or not, the loop state is the path's: any live media
- /// reference asking for a loop makes the one decoder loop (recorded rule, TASK 21).
- private bool AnyMediaReferenceLoops(string path)
+ /// 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).
+ private bool AnyMediaReferenceLoops(string sessionKey)
=> Scenes.SelectMany(s => s.Elements).OfType()
- .Any(s => s.IsMediaSource && s.MediaPath == path && s.MediaIsLooping);
+ .Any(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey && s.MediaIsLooping);
- private bool HasMediaReference(string path)
+ private bool HasMediaReference(string sessionKey)
=> Scenes.SelectMany(s => s.Elements).OfType()
- .Any(s => s.IsMediaSource && s.MediaPath == path);
+ .Any(s => s.IsMediaSource && string.Equals(MediaSessionKey(s), sessionKey, StringComparison.OrdinalIgnoreCase));
- private void ReleaseMediaIfUnused(string? path)
+ private void ReleaseMediaIfUnused(string? sessionKey)
{
- if (string.IsNullOrWhiteSpace(path)) return;
- if (!_mediaManager.HasSession(path)) return;
- if (HasMediaReference(path)) return;
- _ = _mediaManager.ReleaseAsync(path);
+ if (string.IsNullOrWhiteSpace(sessionKey)) return;
+ if (!_mediaManager.HasSession(sessionKey)) return;
+ if (HasMediaReference(sessionKey)) return;
+ _ = _mediaManager.ReleaseAsync(sessionKey);
}
/// After a layout load: subscribe every persisted media layer and start one
- /// session per distinct file; release sessions for files the layout no longer uses.
+ /// session per distinct path+look; release sessions the layout no longer uses.
private void ReacquireMediaSources()
{
var sources = Scenes.SelectMany(s => s.Elements).OfType()
@@ -191,10 +261,17 @@ public partial class MainViewModel
foreach (var source in sources)
RegisterMediaSource(source);
- var wanted = sources.Select(s => s.MediaPath!).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ 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);
}
+
+ /// A session key carries a file path and a look; the toast wants the file name.
+ private static string SessionPath(string sessionKey)
+ => sessionKey.Split('|')[0];
}
diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs
index 21700be..3a79666 100644
--- a/ViewModels/MainViewModel.Sources.cs
+++ b/ViewModels/MainViewModel.Sources.cs
@@ -300,16 +300,16 @@ public partial class MainViewModel
_webView2Manager?.Unregister(webSource.Id);
// Media: the layer stops listening, and its decode session stops once no scene
- // references the file any more (sessions are shared per path).
- string? mediaPath = null;
+ // references the same file + look any more (TASK 64: sessions are per path + look).
+ string? mediaSessionKey = null;
if (element is Source mediaSource && mediaSource.IsMediaSource)
{
+ mediaSessionKey = MediaSessionKey(mediaSource);
UnregisterMediaSource(mediaSource);
- mediaPath = mediaSource.MediaPath;
}
_sceneGraph.RemoveElement(scene, element);
- ReleaseMediaIfUnused(mediaPath);
+ ReleaseMediaIfUnused(mediaSessionKey);
if (element is WebcamSceneConfig)
{
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 2a894da..8d9f618 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -190,6 +190,8 @@ public partial class MainViewModel : ViewModelBase
public ICommand AddImageCommand { get; }
public ICommand AddMediaCommand { get; }
public ICommand ChangeMediaFileCommand { get; }
+ /// Accepts the detector's green/blue-screen offer on a clip (TASK 64).
+ public ICommand AcceptChromaSuggestionCommand { get; }
public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
@@ -266,6 +268,9 @@ public partial class MainViewModel : ViewModelBase
ChangeMediaFileCommand = new RelayCommand(
element => ChangeMediaFile(element as Source),
element => element is Source { IsMediaSource: true });
+ AcceptChromaSuggestionCommand = new RelayCommand(
+ element => AcceptChromaSuggestion(element as Source),
+ element => element is Source { HasChromaSuggestion: true });
RemoveSourceCommand = new RelayCommand(element => { PushPreviewUndo(); RemoveElement(element as SceneElement); });
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
ToggleElementVisibilityCommand = new RelayCommand(element => { PushPreviewUndo(); ToggleElementVisibility(element as SceneElement); });
@@ -401,12 +406,18 @@ public partial class MainViewModel : ViewModelBase
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
_screenCaptureManager.CaptureFailed += OnScreenCaptureFailed;
+ _chromaProbe = ChromaProbeOverride
+ ?? new FfmpegChromaProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess());
+
_mediaManager = new MediaVideoSourceManager(
- MediaFrameSourceFactoryOverride ?? (path => new MediaVideoSource(
- path, MediaDecodeWidth, MediaDecodeHeight,
- new FfmpegLocator(), () => new FfmpegDecodeProcess(),
- frameRateProbe: new FfmpegFrameRateProbe(
- new FfmpegLocator(), () => new FfmpegDecodeProcess()))),
+ MediaFrameSourceFactoryOverride is { } overrideFactory
+ ? (path, _) => overrideFactory(path)
+ : (path, chromaKey) => new MediaVideoSource(
+ path, MediaDecodeWidth, MediaDecodeHeight,
+ new FfmpegLocator(), () => new FfmpegDecodeProcess(),
+ frameRateProbe: new FfmpegFrameRateProbe(
+ new FfmpegLocator(), () => new FfmpegDecodeProcess()),
+ chromaKey: chromaKey),
System.Windows.Application.Current?.Dispatcher,
// Loop intent is per Source; the manager needs the per-*path* answer.
AnyMediaReferenceLoops);
@@ -479,6 +490,10 @@ public partial class MainViewModel : ViewModelBase
/// lifecycle without spawning ffmpeg. Mirrors .
internal static Func? MediaFrameSourceFactoryOverride { get; set; }
+ /// Test seam (TASK 64): a fake chroma probe so tests never spawn ffmpeg to look at
+ /// a clip's backdrop.
+ internal static IChromaProbe? ChromaProbeOverride { get; set; }
+
private void LoadLayout()
{
_isLoading = true;
@@ -678,7 +693,8 @@ public partial class MainViewModel : ViewModelBase
Source { Type: SourceType.Scoreboard } => _scoreboardPresenter?.Frame,
Source { Type: SourceType.AlertBox } alertBox => RenderAlertBox(alertBox),
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
- Source { IsMediaSource: true, MediaPath: not null } media => _mediaManager.GetLatestFrame(media.MediaPath),
+ Source { IsMediaSource: true, MediaPath: not null } media
+ => _mediaManager.GetLatestFrame(MediaSessionKey(media)),
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
_ => null,
};
diff --git a/docs/ai/frame-pipeline.md b/docs/ai/frame-pipeline.md
index 26cef71..c2530f6 100644
--- a/docs/ai/frame-pipeline.md
+++ b/docs/ai/frame-pipeline.md
@@ -805,6 +805,13 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t
layer's Level/Mute per session and raises `AudioAvailable`, and `MainViewModel` feeds it to
`AudioMixer.EnqueueDesktopMediaAudio` — the DESKTOP/GAME ring, so the desktop fader and the voice
ducker apply. The alert clip keeps its own unity-gain ring (`EnqueueAlertAudio`, never ducked).
+ **Green/blue screen (TASK 64)** rides the same pipe as a decode option: `VideoFilterChain.BuildVideo`
+ appends `colorkey=…` + `despill=…` + `format=bgra` after the scale, so the key is computed by ffmpeg
+ and both consumers (shared preview bitmap, compositor frame) carry the same alpha — no app-side
+ per-pixel pass, no second render path. A media session is therefore keyed by **path + look**
+ (`MainViewModel.MediaSessionKey`), so two layers with identical settings share one decoder and two
+ different looks get two. The offer comes from `ChromaKeyDetector` over one probed frame
+ (`FfmpegChromaProbe`); settings persist on the `Source` row.
- **Social bar on the output (bar bug-fix branch):** the `FramePump` takes an optional
`socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam, re-read **every frame** (so a
mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs`
diff --git a/ytLive.Tests/ChromaKeyApplyTests.cs b/ytLive.Tests/ChromaKeyApplyTests.cs
new file mode 100644
index 0000000..7e7f140
--- /dev/null
+++ b/ytLive.Tests/ChromaKeyApplyTests.cs
@@ -0,0 +1,219 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.Services.Encoder;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 64 Good Dog (2026-11-05): a clip with a green backdrop gets offered a key, accepting the
+/// offer changes the DECODE (a keyed session, not the plain one), and the layer's frames come from
+/// that keyed session — which is the whole feature: the key lives in ffmpeg's pipe, so the preview
+/// and the stream cannot disagree about it.
+///
+[Collection("RealApp")]
+public sealed class ChromaKeyApplyTests
+{
+ private readonly RealAppHost _app;
+
+ public ChromaKeyApplyTests(RealAppHost app) => _app = app;
+
+ // ─── the pipe actually asks ffmpeg to key ───
+
+ [Fact]
+ public async Task KeyedClip_AsksFfmpegToKey_UnkeyedDoesNot()
+ {
+ var seen = new List();
+ var keyed = new VideoClipPipe(
+ "screen.mp4", 320, 180, new FakeLocator(),
+ () => new CapturingProcess(seen),
+ delay: (_, _) => Task.CompletedTask,
+ audio: false,
+ chromaKey: new ChromaKeyOptions(true, "#00B140", 0.12, 0.08, true));
+ var plain = new VideoClipPipe(
+ "plain.mp4", 320, 180, new FakeLocator(),
+ () => new CapturingProcess(seen),
+ delay: (_, _) => Task.CompletedTask,
+ audio: false);
+
+ await keyed.StartAsync();
+ await Task.Delay(150);
+ await keyed.StopAsync();
+ keyed.Dispose();
+
+ await plain.StartAsync();
+ await Task.Delay(150);
+ await plain.StopAsync();
+ plain.Dispose();
+
+ Assert.Contains(seen, args => args.Contains("colorkey=0x00B140:0.12:0.08") && args.Contains("despill=type=green"));
+ Assert.Contains(seen, args => args.Contains("scale=320:180") && !args.Contains("colorkey"));
+ }
+
+ // ─── the offer, and what accepting it does ───
+
+ [Fact]
+ public void GreenScreenClip_IsOffered_AndAcceptingItKeysTheDecode()
+ {
+ _app.Run(() =>
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
+ var videoPath = Path.Combine(Path.GetTempPath(), "talking-head.mp4");
+ var decoders = new List();
+ MainViewModel.LayoutPathOverride = tempDb;
+ MainViewModel.MediaFrameSourceFactoryOverride = key =>
+ {
+ var decoder = new FakeMediaSource(key);
+ decoders.Add(decoder);
+ return decoder;
+ };
+ MainViewModel.ChromaProbeOverride = new FakeProbe(GreenScreenFrame());
+
+ var window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+ try
+ {
+ window.Show();
+ window.UpdateLayout();
+
+ vm.AddMediaSource(videoPath);
+
+ var layer = vm.StagedScene!.Elements.OfType().Single(s => s.IsMediaSource);
+
+ // The app noticed, and said so without doing anything.
+ Assert.True(layer.HasChromaSuggestion, "the detector's offer never reached the layer");
+ Assert.Contains("green", layer.ChromaSuggestionText, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal("#00B140", layer.ChromaSuggestionColor);
+ Assert.False(layer.ChromaKeyEnabled);
+ Assert.Single(decoders);
+
+ // The plain session feeds the resolver before the offer is accepted.
+ var plainFrame = SolidFrame(1);
+ decoders[0].Emit(plainFrame);
+ Assert.Same(plainFrame, vm.ResolveOutputFrame(layer));
+
+ vm.AcceptChromaSuggestionCommand.Execute(layer);
+
+ Assert.True(layer.ChromaKeyEnabled);
+ Assert.False(layer.HasChromaSuggestion); // accepted, so the offer is gone
+ Assert.Equal("#00B140", layer.ChromaKeyColor);
+ Assert.Equal(ChromaScreen.Green, layer.ChromaScreen);
+
+ // A different look is a different decode: the plain session is released and a
+ // keyed one starts, because the key lives in the pipe.
+ Assert.Equal(2, decoders.Count);
+ Assert.Equal(1, decoders[0].StopCount);
+
+ // And the layer now reads the keyed session, not the old one.
+ var keyedFrame = SolidFrame(2);
+ decoders[1].Emit(keyedFrame);
+ Assert.Same(keyedFrame, vm.ResolveOutputFrame(layer));
+
+ // The settings survive a save/reload — a keyed layer must come back keyed.
+ vm.SaveLayoutNow();
+ Assert.True(ReadChromaEnabled(tempDb));
+ }
+ finally
+ {
+ window.Close();
+ MainViewModel.ChromaProbeOverride = null;
+ MainViewModel.MediaFrameSourceFactoryOverride = null;
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ });
+ }
+
+ private static bool ReadChromaEnabled(string dbPath)
+ {
+ using var connection = new SqliteConnection($"Data Source={dbPath}");
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT COUNT(*) FROM Source WHERE ChromaKeyEnabled = 1;";
+ return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
+ }
+
+ /// A 160×90 frame: green at the edges, a face-ish block in the middle.
+ private static VideoFrame GreenScreenFrame()
+ {
+ const int w = 160, h = 90;
+ var bytes = new byte[w * h * 4];
+ for (var y = 0; y < h; y++)
+ for (var x = 0; x < w; x++)
+ {
+ var inCentre = x > w * 0.4 && x < w * 0.6 && y > h * 0.2 && y < h * 0.8;
+ var i = (y * w + x) * 4;
+ bytes[i] = inCentre ? (byte)0x90 : (byte)0x40; // B
+ bytes[i + 1] = inCentre ? (byte)0xB0 : (byte)0xB1; // G
+ bytes[i + 2] = inCentre ? (byte)0xE0 : (byte)0x00; // R
+ bytes[i + 3] = 255;
+ }
+ return new VideoFrame(w, h, bytes);
+ }
+
+ private static VideoFrame SolidFrame(byte value)
+ => new(4, 4, Enumerable.Repeat(value, 4 * 4 * 4).Select(b => b).ToArray());
+
+ private sealed class FakeProbe : IChromaProbe
+ {
+ private readonly VideoFrame? _frame;
+ public FakeProbe(VideoFrame? frame) => _frame = frame;
+ public Task ProbeAsync(string path, CancellationToken cancellationToken = default)
+ => Task.FromResult(_frame);
+ }
+
+ private sealed class FakeMediaSource : IMediaFrameSource
+ {
+ public FakeMediaSource(string key) => Key = key;
+
+ public string Key { get; }
+ public bool Looping { get; set; }
+ public int StopCount { get; private set; }
+
+ public event Action? FrameAvailable;
+ public event Action? AudioAvailable { add { } remove { } }
+ public event Action? Completed { add { } remove { } }
+
+ public void Emit(VideoFrame frame) => FrameAvailable?.Invoke(frame);
+
+ public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+
+ public Task StopAsync()
+ {
+ StopCount++;
+ return Task.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ private sealed class CapturingProcess : IDecodeProcess
+ {
+ private readonly List _seen;
+ public CapturingProcess(List seen) => _seen = seen;
+ public void Start(ProcessStartInfo startInfo) => _seen.Add(startInfo.Arguments);
+ public Stream StandardOutput => Stream.Null;
+ public bool HasExited => true;
+ public int ExitCode => 0;
+ public void Kill() { }
+ public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask;
+ public void Dispose() { }
+ }
+
+ private sealed class FakeLocator : IFfmpegLocator
+ {
+ public Task LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
+ }
+}
diff --git a/ytLive.Tests/MediaSourceAudioTests.cs b/ytLive.Tests/MediaSourceAudioTests.cs
index 1651347..c8e1eed 100644
--- a/ytLive.Tests/MediaSourceAudioTests.cs
+++ b/ytLive.Tests/MediaSourceAudioTests.cs
@@ -63,7 +63,7 @@ public sealed class MediaSourceAudioTests
public async Task Manager_ScalesTheSoundtrackByTheSessionsLevel_AndDropsItWhenMuted()
{
var source = new FakeMediaSource("clip.mp4");
- var manager = new MediaVideoSourceManager(_ => source, uiDispatcher: null);
+ var manager = new MediaVideoSourceManager((_, _) => source, uiDispatcher: null);
var heard = new List();
manager.AudioAvailable += (_, sample) => heard.Add(sample);
diff --git a/ytLive.Tests/MediaVideoSourceManagerTests.cs b/ytLive.Tests/MediaVideoSourceManagerTests.cs
index 9a73c44..b2ee380 100644
--- a/ytLive.Tests/MediaVideoSourceManagerTests.cs
+++ b/ytLive.Tests/MediaVideoSourceManagerTests.cs
@@ -106,7 +106,7 @@ public class MediaVideoSourceManagerTests
{
var started = new List();
var stopped = new List();
- var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key, started, stopped));
+ var manager = new MediaVideoSourceManager((key, _) => new FakeMediaSource(key, started, stopped));
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
@@ -124,7 +124,7 @@ public class MediaVideoSourceManagerTests
{
var started = new List();
var stopped = new List();
- var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key, started, stopped));
+ var manager = new MediaVideoSourceManager((key, _) => new FakeMediaSource(key, started, stopped));
await manager.AcquireAsync(@"C:\videos\clip.mp4");
await manager.AcquireAsync(@"C:\videos\clip.mp4");
@@ -136,7 +136,7 @@ public class MediaVideoSourceManagerTests
[Fact]
public async Task Acquire_FailingSource_ReturnsFalseAndRaisesMediaFailed()
{
- var manager = new MediaVideoSourceManager(key => new FailingMediaSource(key));
+ var manager = new MediaVideoSourceManager((key, _) => new FailingMediaSource(key));
string? failedKey = null;
manager.MediaFailed += (key, _) => failedKey = key;
@@ -147,7 +147,7 @@ public class MediaVideoSourceManagerTests
[Fact]
public async Task Acquire_EmptyKey_ReturnsFalse()
{
- var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key));
+ var manager = new MediaVideoSourceManager((key, _) => new FakeMediaSource(key));
Assert.False(await manager.AcquireAsync(" "));
}
@@ -155,7 +155,7 @@ public class MediaVideoSourceManagerTests
public async Task GetLatestFrame_ReturnsLatestPump_UntilReleased()
{
FakeMediaSource? captured = null;
- var manager = new MediaVideoSourceManager(key => captured = new FakeMediaSource(key));
+ var manager = new MediaVideoSourceManager((key, _) => captured = new FakeMediaSource(key));
Assert.Null(manager.GetLatestFrame(@"C:\videos\clip.mp4"));
@@ -181,7 +181,7 @@ public class MediaVideoSourceManagerTests
{
using var pump = new DispatcherPump();
FakeMediaSource? captured = null;
- var manager = new MediaVideoSourceManager(key => captured = new FakeMediaSource(key), pump.Dispatcher);
+ var manager = new MediaVideoSourceManager((key, _) => captured = new FakeMediaSource(key), pump.Dispatcher);
WriteableBitmap? published = null;
var publishedCount = 0;