diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index fd054b8..43da574 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -762,8 +762,11 @@ - + + + + @@ -787,6 +790,9 @@ + + + + + + + + + + + diff --git a/HANDOFF.md b/HANDOFF.md index 521d6e8..b26fe7e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -9,15 +9,15 @@ 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. +- Slice F shipped too: **App Settings → Video clips → "Auto Green/Blue Screen"** (off by default, + high confidence only, and the panel states when a key was automatic) and **alert clips** key through + the same settings block (one alert box per layout). +- Tests: `ChromaKeyDetectorTests` + `ChromaKeyApplyTests` (pipe asks ffmpeg to key; offer → accept → + keyed session feeds the resolver; survives save; auto-apply; alert clip keyed). App build 0 warnings. ## Next action -- 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 — +- TASK 64 is complete. Next queued: 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. diff --git a/Models/Source.cs b/Models/Source.cs index 4636c2b..d6bf940 100644 --- a/Models/Source.cs +++ b/Models/Source.cs @@ -111,6 +111,7 @@ public class Source : SceneElement Raise(nameof(IsTextOverlay)); Raise(nameof(IsScoreboard)); Raise(nameof(IsTicker)); + Raise(nameof(ShowsChromaKey)); } } } @@ -379,6 +380,25 @@ public class Source : SceneElement /// True while there is an offer the creator has not accepted. public bool HasChromaSuggestion => !ChromaKeyEnabled && ChromaSuggestion.Screen != ChromaScreen.None; + private bool _chromaKeyAutoApplied; + /// True when the app applied the key on its own (the "Auto Green/Blue Screen" + /// setting). The panel says so, so an automatic change is never silent. + public bool ChromaKeyAutoApplied + { + get => _chromaKeyAutoApplied; + set + { + if (Set(ref _chromaKeyAutoApplied, value)) Raise(nameof(HasAutoChromaKey)); + } + } + + public bool HasAutoChromaKey => ChromaKeyAutoApplied && ChromaKeyEnabled; + + /// Which layers get the Green/Blue Screen block: a video layer, or the alert box's + /// own clip (TASK 64 — a clip is a clip). + public bool ShowsChromaKey => IsMediaSource + || (Type == SourceType.AlertBox && !AlertUseDefaultVideo && !string.IsNullOrWhiteSpace(AlertVideoPath)); + public string ChromaSuggestionText => ChromaSuggestion.Screen switch { ChromaScreen.Green => "Looks like a green screen.", @@ -406,14 +426,20 @@ public class Source : SceneElement public string? AlertVideoPath { get => _alertVideoPath; - set => Set(ref _alertVideoPath, value); + set + { + if (Set(ref _alertVideoPath, value)) Raise(nameof(ShowsChromaKey)); + } } private bool _alertUseDefaultVideo = true; public bool AlertUseDefaultVideo { get => _alertUseDefaultVideo; - set => Set(ref _alertUseDefaultVideo, value); + set + { + if (Set(ref _alertUseDefaultVideo, value)) Raise(nameof(ShowsChromaKey)); + } } public bool AlertShowTicker diff --git a/Services/AlertClipDecoder.cs b/Services/AlertClipDecoder.cs index 62af420..5ee7937 100644 --- a/Services/AlertClipDecoder.cs +++ b/Services/AlertClipDecoder.cs @@ -50,10 +50,12 @@ public sealed class AlertClipDecoder : IAlertClipDecoder IFfmpegLocator locator, Func processFactory, IFrameRateProbe? frameRateProbe = null, - Func? delay = null) + Func? delay = null, + ChromaKeyOptions? chromaKey = null) { _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 => AudioReady?.Invoke(sample); _clip.Completed += () => Completed?.Invoke(); diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index a1d302d..65652e0 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -48,6 +48,12 @@ public partial class LayoutStore : IDisposable /// Zoom box follow mode (creator ruling 2026-11-01): false = ANCHOR (the capture /// stays on the point that was clicked, the default), true = FOLLOW (the capture re-anchors /// to the box centre while it is dragged). + /// TASK 64: key a detected green/blue screen without asking. Off by default — the + /// app offers, the creator accepts; this switch is for the creator who is tired of clicking. + public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1"; + + public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0"); + public bool LoadZoomFollowBox() => GetSetting("Zoom.FollowBox") == "1"; public void SaveZoomFollowBox(bool follow) => UpsertSetting("Zoom.FollowBox", follow ? "1" : "0"); diff --git a/TASKS.md b/TASKS.md index 723e978..8ccae71 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 | 🔶 | **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) | +| 64 | ✅ | **Green/Blue Screen** — detect + key from the decode pipe, offer on add, Amount/Softness/Fringe, opt-in auto-apply, alert clips included, persisted (done 2026-11-05) | [`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 fba5576..fdd6b92 100644 --- a/TASKS/task-64-green-blue-screen.md +++ b/TASKS/task-64-green-blue-screen.md @@ -48,7 +48,7 @@ lighting to blame. The reversal is recorded there. | 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 | ☐ **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 | +| F | ✅ "Auto Green/Blue Screen" opt-in (App Settings; high confidence only, and the panel says it was automatic), alert clips keyed through the same settings block, spill shipped as the **Fringe** switch | slider reaches the chain and the restart preserves loop state | ## 3. Landmines for whoever builds this @@ -61,3 +61,12 @@ lighting to blame. The reversal is recorded there. - **Detection is a second reader of the file** — one frame, async, never on the UI thread; a failed probe means "no suggestion", never a broken layer. - **Letterboxing** (black bars) breaks naive border sampling — detect and exclude them first. + +## 4. Known limits (honest) + +- The offer is made **when a clip is added or changed** — not at every launch. The settings persist, + so a keyed layer comes back keyed; it just does not re-offer the probe. +- Auto-apply fires only at high confidence. Medium confidence always asks, low says nothing. +- The key is the layer's, but the decode is shared per **path + look**: two layers on one file with + different Amount/Softness values are two decoders (and therefore two audio streams, muted upstream + unless enabled) — rare, and the alternative is silently sharing the wrong picture. diff --git a/ViewModels/MainViewModel.Chat.cs b/ViewModels/MainViewModel.Chat.cs index f327118..d3582df 100644 --- a/ViewModels/MainViewModel.Chat.cs +++ b/ViewModels/MainViewModel.Chat.cs @@ -140,7 +140,10 @@ public partial class MainViewModel : ViewModelBase path, width, height, new FfmpegLocator(), () => new FfmpegDecodeProcess(), - frameRateProbe: new FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess())); + frameRateProbe: new FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess()), + // TASK 64: an alert clip is a clip — it keys through the same settings block. The + // layout has one alert box, so its settings are the ones the next alert uses. + chromaKey: AlertBoxChromaKey()); /// Stamp the shipped llama placeholder into the Asset table at startup /// (idempotent — hash-keyed UpsertAsset dedupes). Best-effort: if it fails the @@ -179,6 +182,13 @@ public partial class MainViewModel : ViewModelBase } } + /// The alert zone's own green/blue screen settings (one alert box per layout). + private ChromaKeyOptions? AlertBoxChromaKey() + => Scenes.SelectMany(scene => scene.Elements).OfType() + .FirstOrDefault(source => source.IsAlertBox)?.ChromaKey is { Enabled: true } key + ? key + : null; + private void BrowseAlertVideo(Source? box) { if (box == null || box.Type != SourceType.AlertBox) return; @@ -190,6 +200,8 @@ public partial class MainViewModel : ViewModelBase if (dialog.ShowDialog() != true) return; box.AlertVideoPath = dialog.FileName; box.AlertUseDefaultVideo = false; + // Offer the key for the chosen clip (same detector as a media layer). + _ = SuggestChromaKeyAsync(box); } private void BrowseAlertAudio(Source? box) diff --git a/ViewModels/MainViewModel.Media.cs b/ViewModels/MainViewModel.Media.cs index 59b3489..b3e853f 100644 --- a/ViewModels/MainViewModel.Media.cs +++ b/ViewModels/MainViewModel.Media.cs @@ -37,6 +37,21 @@ public partial class MainViewModel // One probe per file per launch — the answer belongs to the file, not to the layer. private readonly HashSet _chromaProbedPaths = new(StringComparer.OrdinalIgnoreCase); + private bool _autoChromaScreen; + + /// "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. + 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 _mediaHandlers = new(); @@ -102,19 +117,37 @@ public partial class MainViewModel // ---------- 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. + /// 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. private async System.Threading.Tasks.Task SuggestChromaKeyAsync(Source source) { - if (!source.IsMediaSource || source.MediaPath is not { } path) return; + 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(source.MediaPath, path, StringComparison.OrdinalIgnoreCase)) return; + if (!string.Equals(ClipPathFor(source), path, StringComparison.OrdinalIgnoreCase)) return; - source.ChromaSuggestion = ChromaKeyDetector.Detect(frame); + var suggestion = ChromaKeyDetector.Detect(frame); + source.ChromaSuggestion = suggestion; + + if (AutoChromaScreen && suggestion.IsHighConfidence) + { + AcceptChromaSuggestion(source); + source.ChromaKeyAutoApplied = true; + } } + /// 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. + private static string? ClipPathFor(Source source) + => source.IsMediaSource + ? source.MediaPath + : source.Type == SourceType.AlertBox && !source.AlertUseDefaultVideo + ? source.AlertVideoPath + : null; + /// Accepts the detector's offer: the layer keys the colour the camera actually saw. private void AcceptChromaSuggestion(Source? source) { @@ -126,6 +159,7 @@ public partial class MainViewModel source.ChromaKeySoftness = key.Softness; source.ChromaKeySpill = key.Spill; source.ChromaScreen = key.Screen; + source.ChromaKeyAutoApplied = false; source.ChromaKeyEnabled = true; } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 8d9f618..59570a3 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -344,6 +344,7 @@ public partial class MainViewModel : ViewModelBase MicSourceName = _layoutStore.LoadMicSourceName(); AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs(); _zoomFollowsBox = _layoutStore.LoadZoomFollowBox(); + _autoChromaScreen = _layoutStore.LoadAutoChromaScreen(); MonoOutput = _layoutStore.LoadMonoOutput(); // applied to the mixer by the setter _musicPlayer.UserGain = (float)Helpers.GainDb.FromDb(TraxDefaultDb); // background by default InitScoreboard(); diff --git a/ytLive.Tests/ChromaKeyApplyTests.cs b/ytLive.Tests/ChromaKeyApplyTests.cs index 7e7f140..2fd6550 100644 --- a/ytLive.Tests/ChromaKeyApplyTests.cs +++ b/ytLive.Tests/ChromaKeyApplyTests.cs @@ -134,6 +134,71 @@ public sealed class ChromaKeyApplyTests }); } + // ─── the opt-in setting ─── + + [Fact] + public void AutoGreenBlueScreen_KeysAHighConfidenceClipWithoutAsking() + { + _app.Run(() => + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db"); + var videoPath = Path.Combine(Path.GetTempPath(), "auto-keyed.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(); + vm.AutoChromaScreen = true; + + vm.AddMediaSource(videoPath); + + var layer = vm.StagedScene!.Elements.OfType().Single(s => s.IsMediaSource); + Assert.True(layer.ChromaKeyEnabled, "the auto setting did not key a high-confidence screen"); + Assert.True(layer.ChromaKeyAutoApplied, "an automatic key must say that it was automatic"); + Assert.True(layer.HasAutoChromaKey); + Assert.Equal(2, decoders.Count); // plain session released, keyed one started + } + finally + { + window.Close(); + MainViewModel.ChromaProbeOverride = null; + MainViewModel.MediaFrameSourceFactoryOverride = null; + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + }); + } + + // ─── an alert clip is a clip ─── + + [Fact] + public async Task AlertClip_KeyedThroughTheSameSettingsBlock() + { + var seen = new List(); + using var decoder = new AlertClipDecoder( + "alert-clip.mp4", 320, 180, new FakeLocator(), + () => new CapturingProcess(seen), + delay: (_, _) => Task.CompletedTask, + chromaKey: new ChromaKeyOptions(true, "#0047BB", 0.1, 0.05, true, ChromaScreen.Blue)); + + decoder.Start(); + await Task.Delay(150); + decoder.Stop(); + + Assert.Contains(seen, args => args.Contains("colorkey=0x0047BB") && args.Contains("despill=type=blue")); + } + private static bool ReadChromaEnabled(string dbPath) { using var connection = new SqliteConnection($"Data Source={dbPath}");