diff --git a/Assets/brb-backdrop.jpg b/Assets/brb-background.jpg
similarity index 100%
rename from Assets/brb-backdrop.jpg
rename to Assets/brb-background.jpg
diff --git a/Assets/chat-backdrop.jpg b/Assets/chat-background.jpg
similarity index 100%
rename from Assets/chat-backdrop.jpg
rename to Assets/chat-background.jpg
diff --git a/Assets/ending-backdrop.jpg b/Assets/ending-background.jpg
similarity index 100%
rename from Assets/ending-backdrop.jpg
rename to Assets/ending-background.jpg
diff --git a/Assets/live-backdrop.jpg b/Assets/live-background.jpg
similarity index 100%
rename from Assets/live-backdrop.jpg
rename to Assets/live-background.jpg
diff --git a/Assets/starting-backdrop.jpg b/Assets/starting-background.jpg
similarity index 100%
rename from Assets/starting-backdrop.jpg
rename to Assets/starting-background.jpg
diff --git a/HANDOFF.md b/HANDOFF.md
index d1df55f..5edf406 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,29 +1,86 @@
# HANDOFF — Session State
## Branch
-`main` — clean, pushed (8951141)
+`main` — clean, last push `8951141` (TASK 10 shipped). TRAX meter fix pushed `e06bfa4`.
+All Control Surface UX work is **uncommitted** — build passes 0 warnings/0 errors.
+
+## ⚠️ IN FLIGHT: repo-wide Backdrop→Background rename (multi-session)
+Models already renamed (`Source.IsBackground`, `Scene.HasBackground`/`CustomBackgroundPath`,
+`SceneCatalog.HasBackground`, `Source` enum `Background`). Status per file:
+- ✅ `ViewModels/MainViewModel.cs` — full case-sensitive rename done; compiles clean.
+- ✅ `MainWindow.xaml.cs` + `MainWindow.xaml` — renamed; full solution builds 0 warnings/0 errors.
+- ✅ `Services/Compositor/SceneCompositor.cs` — CS0128 duplicate locals fixed.
+- ⏳ `Services/LayoutStore.cs` — C# members renamed, but DB column strings
+ (`HasBackdrop`/`IsBackdrop`) are **schema** and intentionally still say Backdrop:
+ renaming them needs a real migration (new user_version) touching every existing DB.
+ The V5-backfill unit test's raw CREATE TABLE must keep `IsBackdrop` to match —
+ blanket-renaming it breaks `V5_Database_Backfill_Removes_NonLive_Backgrounds`
+ (verified: migration can't see flags, Live source loses `IsBackground`).
+- ✅ `ytLive.Tests` — all 5 files blanket-renamed (class/method/var/comment names +
+ C# property accesses); `BackdropTests.cs` → `BackgroundTests.cs`. Tests: 207/208 pass;
+ the only red is pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`
+ (unrelated gain-math assert at AudioPipelineTests.cs:373, zero backdrop occurrences).
+- Pending elsewhere: leftover "backdrop" doc comments (`Source.cs`, `SceneCatalog.cs`,
+ `ScreenCaptureManager.cs`, `ScreenCaptureFrameSource.cs`, `MainWindow.xaml:769`),
+ asset files + csproj (`Assets/*-backdrop.jpg` — pack-URI strings in MainViewModel now say
+ `-background.jpg` so default-scene seeding silently no-ops until assets+csproj are renamed).
## What shipped this session
-1. **TASK 10 — Polar billing + Velopack auto-updates (steps 1–7)** — full license key activation flow:
- - **Velopack** — NuGet `Velopack` 1.2.0 added, bootstrap in `App.xaml.cs` (`VelopackApp.Build().Run()` in try/catch). Self-hosted update URL TBD (DO droplet). The bootstrap is a no-op until the update URL is configured.
- - **PolarLicenseService** — `Services/PolarLicenseService.cs`: `POST .../validate` with `key` + `organization_id`. Returns `status` (`granted`/`revoked`/`expired`) + `expires_at`. No auth. Network errors surfaced gracefully.
- - **License key entry UI** — About overlay gains a third panel (same pattern as Licenses): text input + "Activate" button + status message. "Enter License Key" button added to the hub (alongside "Unlock Premium" which opens the Polar checkout URL). `IsHubVisible` computed property hides the hub when either sub-panel is open.
- - **Entitlement persistence** — `LayoutStore` gains `LoadLicenseKey`/`SaveLicenseKey`, `LoadLicenseValidatedAt`/`SaveLicenseValidatedAt`, `LoadIsPremium`/`SaveIsPremium` via the Settings key/value table (no schema version bump needed). 14-day offline grace period: cached premium state trusted while online re-validation runs in the background.
- - **BrandFlash toggle** — `IsPremium` property setter flips `BrandFlashEnabled = !value`. On startup, `ValidateLicenseAtStartupAsync` loads cached state → attempts online re-validation → overrides cache on success → lapses on failure/expiry. Premium removes the watermark; free tier keeps it.
- - **PremiumUrl wired** — set to `https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw`. `IsPremiumAvailable` now true. About overlay "Unlock Premium" button lights up. Tooltip updated.
- - **About text updated** — "one-time unlock" → "subscription".
+1. **TASK 10 — Polar billing + Velopack auto-updates (steps 1–7)** — full license key activation flow
+2. **TRAX meter double-attenuation fix** — removed redundant slider multiplication
+3. **LAN infrastructure documented** — all servers in `ai.md` + `CREDENTIALS.md`
+4. **llamavault NFS/SMB diagnosis** — zombie SMB session from power outage, fixed by killing stale PID
-2. **Polar MCP auth** — authenticated via `opencode mcp auth Polar`. Polar MCP connected.
+## Active work: Control Surface UX (TASK 19/23 merged) — ALL PHASES COMPLETE
-## Files changed
-- `App.xaml.cs` — Velopack bootstrap (try/catch, non-fatal)
-- `ytLive.csproj` — added `Velopack` 1.2.0 NuGet
-- `Services/PolarLicenseService.cs` — new: Polar license validation API client
-- `Services/LayoutStore.cs` — new: `Load/SaveLicenseKey`, `Load/SaveLicenseValidatedAt`, `Load/SaveIsPremium`
-- `ViewModels/MainViewModel.cs` — `IsPremium` (was `static false`, now instance + DB-backed), `PremiumUrl` set, `BrandFlashEnabled` wired to `!IsPremium`, license fields/commands/validation methods, `IsHubVisible`, startup validation
-- `MainWindow.xaml` — license entry panel, "Enter License Key" button, hub visibility binding, About text
-- `MainWindow.xaml.cs` — `BackToAbout_Click` closes both sub-panels
+**Design philosophy:** Director's control surface, not an editor. Five scene monitors above one central live monitor. Click thumbnail = transition (live) or stage (offline). Edit mode only when offline. Live/Recording = read-only. Right-click + drag locked when live.
+
+### Phases completed (ALL)
+1. ✅ **Phase 1:** `StagedScene`/`LiveScene` split + `IsEditMode` in MainViewModel
+2. ✅ **Phase 2:** Left panel two-state (edit: layers+props, live: chat)
+3. ✅ **Phase 3:** Right panel eliminated
+4. ✅ **Phase 4:** Thumbnail strip above center monitor + `Scene.Snapshot` + placeholder snapshots
+5. ✅ **Phase 5:** Edit-mode gating (drag/resize/context menus blocked when live)
+6. ✅ **Phase 6:** `TransitionService.cs` (Cut/Fade/Move) + `SceneCompositor.BlendFrames()`
+7. ✅ **Phase 7:** `FramePump` integrated with `TransitionService`
+8. ✅ **Phase 8:** Thumbnail click → `TransitionToScene()` wiring
+9. ✅ **Phase 9:** Brand rename "ytLlive" → "LlamaCasty" (user-visible strings)
+10. ✅ **Phase 10:** `LayoutStore.SaveTransitionSettings()` / `LoadTransitionSettings()`
+
+### Key decisions
+- **Mode is implicit:** offline = edit, live/recording = read-only. No toggle button.
+- **Left panel = one panel, two states:** offline → LAYERS + PROPERTIES; live → YouTube Live Chat (entire panel replaced, never coexists)
+- **No (=>) transition button:** clicking a thumbnail directly triggers the transition
+- **Thumbnail glow:** active scene gets brand-red `#e94560` border via `IsStaged` data trigger
+- **Right panel eliminated:** chat moved to left panel (live mode only)
+- **5 thumbnails above center monitor**, 120px height, uniform grid. Placeholder colored boxes until compositor snapshots added.
+- **Transitions:** Cut + Fade + Move (v1). Go Live always = Cut. Duration persisted to Settings table.
+- **Brand rename:** "ytLlive" → "LlamaCasty" across all XAML, VM strings, service strings. File paths (`%APPDATA%\ytLlive\`) intentionally NOT renamed.
+
+### Architecture changes
+- `ActiveScene` → split into `StagedScene` (central monitor, editable) + `LiveScene` (encoder output, read-only)
+- `IsEditMode` = derived property: `StreamStatus == StreamStatus.Offline`
+- `TransitionService` — transition state machine with Start/Tick/BlendFrame
+- `SceneCompositor.BlendFrames()` — crossfade between two VideoFrames
+- `Scene.Snapshot` + `Scene.IsStaged` — thumbnail image and staged indicator
+- `FramePump` — now accepts `TransitionService` + `fromSceneProvider`, blends during transitions
+
+### Files changed
+- `ViewModels/MainViewModel.cs` — StagedScene/LiveScene split, IsEditMode, TransitionToScene, snapshots, brand rename, transition persistence
+- `MainWindow.xaml` — 2-col layout, thumbnail strip, left panel two-state, right panel deleted, brand rename
+- `MainWindow.xaml.cs` — Thumbnail click, edit-mode gating, context menu gating, dead code cleanup
+- `Models/Scene.cs` — Snapshot, IsStaged properties
+- `Services/Compositor/SceneCompositor.cs` — BlendFrames() static method
+- `Services/Encoder/FramePump.cs` — transition support in render loop
+- `Services/TransitionService.cs` — **new** — Cut/Fade/Move transition engine
+- `Services/LayoutStore.cs` — SaveTransitionSettings/LoadTransitionSettings/UpsertSetting
+
+### What still needs testing
+- Runtime visual testing (need Windows display to run)
+- Actual transition animation quality (Fade + Move)
+- Thumbnail generation from real scene compositor (currently placeholder colors)
+- Drag-reorder of thumbnails (not yet implemented)
## Build
```bash
@@ -40,16 +97,16 @@ taskkill.exe /F /IM ytLive.exe # if running
| Price | `7c1b6f70-cf8c-46df-9bc8-6703f9fa64d0` |
| Benefit | `80f66bc1-d5a3-44a1-b9ea-1709ae508535` |
| Checkout | `70e037cd-9bcb-414e-8b26-c82c1d268f65` |
-| LLAMAFOUNDER | `01a8c29f-9bb8-4e96-9cbe-417a6a5cec9b` |
-| LLAMA50 | `8cff803e-053f-49b4-8d12-d74d468f2943` |
| Checkout URL | `https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw` |
| Validate endpoint | `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` |
| Key prefix | `LCYT-` |
-## Next session
-1. **TASK 20** — Hotkeys (global keyboard shortcuts)
-2. **TASK 19** — Scene transitions (Cut/Fade/Move/Custom)
-3. **Velopack update URL** — self-host on DO droplet (`llamacasty.com/updates`)
+## Next steps
+1. **Visual testing** — run on Windows display, verify layout, transitions, thumbnail strip
+2. **Thumbnail drag-reorder** — reorder scenes via drag on thumbnail strip
+3. **Real snapshot generation** — replace placeholder colors with compositor-rendered thumbnails
+4. **Transition dropdown UI** — add XAML control to pick transition type + duration
+5. **Commit** — all this work needs to be committed once visually verified
## v1 vision
> A streaming tool that's intuitive and ready to go out-the-gate with nothing held back.
diff --git a/Helpers/HiddenBoolToVisibilityConverter.cs b/Helpers/HiddenBoolToVisibilityConverter.cs
index daf6aa4..b3f0981 100644
--- a/Helpers/HiddenBoolToVisibilityConverter.cs
+++ b/Helpers/HiddenBoolToVisibilityConverter.cs
@@ -7,7 +7,7 @@ namespace ytLive.Helpers;
///
/// Maps bool → Visibility, collapsing nothing: true becomes Hidden (keeps
/// its layout slot) rather than Collapsed (releases it). Used where a row
-/// must keep its column grid aligned — e.g. the Backdrop source's missing
+/// must keep its column grid aligned — e.g. the Background source's missing
/// trashcan must not shift the edit/eye icons one slot to the right.
///
public class HiddenBoolToVisibilityConverter : IValueConverter
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 06ee613..5b4d2f8 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -16,7 +16,8 @@
Loaded="MainWindow_Loaded"
Activated="MainWindow_Activated"
Deactivated="MainWindow_Deactivated"
- PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown">
+ PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"
+ ContextMenuOpening="Window_ContextMenuOpening">
@@ -95,19 +96,28 @@
+
+
+
+
+
+
+
+
+
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+ Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
@@ -367,7 +380,7 @@
@@ -376,19 +389,19 @@
-
-
-
-
@@ -592,29 +605,109 @@
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -622,40 +715,36 @@
-
-
-
-
+
-
-
+
@@ -976,10 +1065,6 @@
-
-
-
@@ -1004,16 +1089,12 @@
TextWrapping="Wrap" MaxWidth="440" TextAlignment="Center"
HorizontalAlignment="Center"/>
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -1428,7 +1394,7 @@
-
+
@@ -1446,7 +1412,7 @@
-
+
@@ -1457,16 +1423,14 @@
-
-
- item is Scene { IsHidden: false };
UpdateTaskbarOverlay();
UpdateSelectionOverlay();
LoadAvatarImage();
@@ -39,6 +37,12 @@ public partial class MainWindow : Window
AppLog.Write("MainWindow loaded");
}
+ private void Window_ContextMenuOpening(object sender, ContextMenuEventArgs e)
+ {
+ if (!_viewModel.IsEditMode)
+ e.Handled = true;
+ }
+
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.IsLive))
@@ -57,7 +61,7 @@ public partial class MainWindow : Window
private void MainWindow_Activated(object? sender, EventArgs e)
{
- _viewModel.RefreshBackdropAutoCapture();
+ _viewModel.RefreshBackgroundAutoCapture();
}
private void MainWindow_Deactivated(object? sender, EventArgs e)
@@ -102,15 +106,6 @@ public partial class MainWindow : Window
}
}
- private void SceneList_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- if (e.AddedItems.Count > 0 && e.AddedItems[0] is Scene { IsHidden: true } hidden)
- {
- var list = (ListBox)sender;
- list.SelectedItem = _viewModel.ActiveScene;
- }
- }
-
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is Slider slider)
@@ -234,17 +229,13 @@ public partial class MainWindow : Window
}
}
- private void AddSceneButton_Click(object sender, RoutedEventArgs e)
+ // ─── Preview image selection / move / resize ───
+ private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
- if (sender is Button { ContextMenu: { } menu } button)
- {
- menu.PlacementTarget = button;
- menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
- menu.IsOpen = true;
- }
+ if (sender is Border { DataContext: Scene scene })
+ _viewModel.TransitionToScene(scene);
}
- // ─── Preview image selection / move / resize ───
private bool _isDraggingOverlay;
private bool _isResizing;
private Point _grabOffset;
@@ -268,6 +259,11 @@ public partial class MainWindow : Window
private void UpdateSelectionOverlay()
{
+ if (!_viewModel.IsEditMode)
+ {
+ SelectionOverlay.Visibility = Visibility.Collapsed;
+ return;
+ }
var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
if (selected)
@@ -441,14 +437,14 @@ public partial class MainWindow : Window
_viewModel.RemoveSourceCommand.Execute(element);
}
- private void BackdropMenu_ChangeCapture(object sender, RoutedEventArgs e)
+ private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
{
- _ = _viewModel.ChangeBackdropCaptureAsync();
+ _ = _viewModel.ChangeBackgroundCaptureAsync();
}
- private void BackdropMenu_RefreshCapture(object sender, RoutedEventArgs e)
+ private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
{
- _viewModel.RefreshBackdropAutoCapture();
+ _viewModel.RefreshBackgroundAutoCapture();
}
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
@@ -471,6 +467,7 @@ public partial class MainWindow : Window
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
+ if (!_viewModel.IsEditMode) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
@@ -501,6 +498,7 @@ public partial class MainWindow : Window
private void Preview_MouseMove(object sender, MouseEventArgs e)
{
+ if (!_viewModel.IsEditMode) return;
if (!_isDraggingOverlay && !_isResizing) return;
var grid = (Grid)sender;
@@ -527,8 +525,8 @@ public partial class MainWindow : Window
var isWebcam = selected is WebcamSceneConfig;
var isRound = selected.ClipShape == ClipShape.Round;
var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
- var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(_viewModel.ActiveScene?.Name) : 1920;
- var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.ActiveScene?.Name) : 1080;
+ var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(_viewModel.StagedScene?.Name) : 1920;
+ var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.StagedScene?.Name) : 1080;
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
if (proportional && newH > maxH)
@@ -560,7 +558,7 @@ public partial class MainWindow : Window
private SceneElement? HitElement(Point p)
{
- var scene = _viewModel.ActiveScene;
+ var scene = _viewModel.StagedScene;
if (scene == null) return null;
for (var i = scene.Elements.Count - 1; i >= 0; i--)
{
diff --git a/Models/Scene.cs b/Models/Scene.cs
index 48f18dd..24141af 100644
--- a/Models/Scene.cs
+++ b/Models/Scene.cs
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
+using System.Windows.Media.Imaging;
namespace ytLive.Models;
@@ -10,8 +11,44 @@ public class Scene : INotifyPropertyChanged
private string _name = string.Empty;
private bool _isHidden;
- private bool _hasBackdrop;
+ private bool _hasBackground;
private bool _hasSocialBar;
+ private bool _isStaged;
+ private bool _isLive;
+ private string? _customBackgroundPath;
+ private bool _useDefaultBackground = true;
+
+ public bool IsStaged
+ {
+ get => _isStaged;
+ set => Set(ref _isStaged, value);
+ }
+
+ public bool IsLive
+ {
+ get => _isLive;
+ set => Set(ref _isLive, value);
+ }
+
+ ///
+ /// User-selected background image file path (FQFN). Null means use the app default.
+ /// Stored as a reference only — the image file is not copied into the DB.
+ ///
+ public string? CustomBackgroundPath
+ {
+ get => _customBackgroundPath;
+ set => Set(ref _customBackgroundPath, value);
+ }
+
+ ///
+ /// When true, the shipped default background image is used and CustomBackgroundPath
+ /// is ignored (but preserved). Toggled by the pill-switch in the properties panel.
+ ///
+ public bool UseDefaultBackground
+ {
+ get => _useDefaultBackground;
+ set => Set(ref _useDefaultBackground, value);
+ }
public string Name
{
@@ -26,13 +63,13 @@ public class Scene : INotifyPropertyChanged
}
///
- /// Whether the scene has a live desktop/game backdrop layer. Enforced by
+ /// Whether the scene has a live desktop/game background layer. Enforced by
/// policy: only the canonical Live scene ever has one (see SceneCatalog).
///
- public bool HasBackdrop
+ public bool HasBackground
{
- get => _hasBackdrop;
- set => Set(ref _hasBackdrop, value);
+ get => _hasBackground;
+ set => Set(ref _hasBackground, value);
}
///
@@ -56,6 +93,18 @@ public class Scene : INotifyPropertyChanged
public bool IsChatScene { get; init; }
+ private BitmapSource? _snapshot;
+
+ ///
+ /// Cached thumbnail snapshot of this scene for the control surface strip.
+ /// Updated by the compositor when the scene is not live-previewed.
+ ///
+ public BitmapSource? Snapshot
+ {
+ get => _snapshot;
+ set => Set(ref _snapshot, value);
+ }
+
public event PropertyChangedEventHandler? PropertyChanged;
private void Set(ref T field, T value, [CallerMemberName] string? propertyName = null)
diff --git a/Models/SceneCatalog.cs b/Models/SceneCatalog.cs
index 912299b..dfc1701 100644
--- a/Models/SceneCatalog.cs
+++ b/Models/SceneCatalog.cs
@@ -8,7 +8,7 @@ namespace ytLive.Models;
///
/// The app's five canonical scenes. Scenes are worked on by name; you can delete
/// them ("work with less"), but the app only ever adds back one of these five —
-/// anything beyond them is OBS territory. The live desktop/game backdrop exists
+/// anything beyond them is OBS territory. The live desktop/game background exists
/// only in the Live scene.
///
public static class SceneCatalog
@@ -47,6 +47,6 @@ public static class SceneCatalog
public static bool IsChat(string? name) => Is(name, Chat);
- /// Only the Live scene carries the live desktop/game backdrop.
- public static bool HasBackdrop(string? name) => Is(name, Live);
+ /// Only the Live scene carries the live desktop/game background.
+ public static bool HasBackground(string? name) => Is(name, Live);
}
diff --git a/Models/SceneElement.cs b/Models/SceneElement.cs
index 0f98efd..1f11c27 100644
--- a/Models/SceneElement.cs
+++ b/Models/SceneElement.cs
@@ -61,6 +61,7 @@ public abstract class SceneElement : INotifyPropertyChanged
/// True only for Countdown-type sources. Gates timer properties panel.
public virtual bool IsCountdown => false;
+ public virtual bool IsBackground { get; set; }
private bool _isVisible = true;
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
diff --git a/Models/Source.cs b/Models/Source.cs
index 769a968..11b3d58 100644
--- a/Models/Source.cs
+++ b/Models/Source.cs
@@ -48,9 +48,9 @@ public class Source : SceneElement
public override bool IsWebSource => Type == SourceType.WebSource;
- /// The permanent scene backdrop (live desktop/game capture). Never removable/reorderable.
- private bool _isBackdrop;
- public bool IsBackdrop { get => _isBackdrop; set => Set(ref _isBackdrop, value); }
+ /// The permanent scene background (live desktop/game capture). Never removable/reorderable.
+ private bool _isBackground;
+ public override bool IsBackground { get => _isBackground; set => Set(ref _isBackground, value); }
private bool _showDesktop;
public bool ShowDesktop { get => _showDesktop; set => Set(ref _showDesktop, value); }
@@ -67,7 +67,7 @@ public class Source : SceneElement
///
/// Identifies what this live-capture source points at: "monitor:<index>" or
- /// "window:<hwnd>". Persisted so a re-designated backdrop survives a reload.
+ /// "window:<hwnd>". Persisted so a re-designated background survives a reload.
///
private string? _captureKey;
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs
index 0f0f7ac..c0a67ae 100644
--- a/Services/Compositor/SceneCompositor.cs
+++ b/Services/Compositor/SceneCompositor.cs
@@ -9,14 +9,14 @@ namespace ytLive.Services.Compositor;
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
///
/// Frame sources are supplied by a resolver
-/// (the caller maps webcam → DeviceId, images → AssetId, backdrop → CaptureKey), keeping
+/// (the caller maps webcam → DeviceId, images → AssetId, background → CaptureKey), keeping
/// the compositor pure and free of WPF and of the capture managers.
///
public sealed class SceneCompositor
{
///
/// Composite into the tier's output frame. Layer order (back →
- /// front): live backdrop (the scene's IsBackdrop source) → background image →
+ /// front): live background (the scene's IsBackground source) → background image →
/// visible elements (z-order = Elements order, mirroring the XAML DataTemplate) →
/// branding flash → social bar (a global overlay; spans the master width at
/// ). Transparent regions read opaque black.
@@ -45,10 +45,10 @@ public sealed class SceneCompositor
var elements = scene.Elements;
- var backdrop = elements.OfType().FirstOrDefault(s => s.IsBackdrop);
- var backdropFrame = backdrop != null && backdrop.IsVisible ? frameFor(backdrop) : null;
- if (backdropFrame != null)
- BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backdropFrame, 1f, false, false);
+ var liveCapture = elements.OfType().FirstOrDefault(s => s.IsBackground);
+ var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
+ if (liveCaptureFrame != null)
+ BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
var background = elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
var backgroundFrame = background != null ? frameFor(background) : null;
@@ -60,10 +60,10 @@ public sealed class SceneCompositor
if (!element.IsVisible) continue;
switch (element)
{
- case Source { IsBackdrop: true }:
+ case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
- continue; // backdrop/background are their own layers; Text isn't shipped
+ continue; // background/background are their own layers; Text isn't shipped
}
var frame = frameFor(element);
@@ -91,6 +91,34 @@ public sealed class SceneCompositor
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}
+ ///
+ /// Render a cross-fade blend between two scenes. Used by
+ /// during fade/move transitions: the caller renders each scene individually, then blends
+ /// the two results at the given alpha (0 = fully from, 1 = fully to).
+ ///
+ public static VideoFrame BlendFrames(VideoFrame from, VideoFrame to, float alpha)
+ {
+ if (from.Width != to.Width || from.Height != to.Height)
+ {
+ if (alpha <= 0.5f) return from;
+ return to;
+ }
+
+ var count = from.BgraPixels.Length;
+ var result = new byte[count];
+ var inv = 1f - alpha;
+
+ for (var i = 0; i < count; i += 4)
+ {
+ result[i] = (byte)(from.BgraPixels[i] * inv + to.BgraPixels[i] * alpha);
+ result[i + 1] = (byte)(from.BgraPixels[i + 1] * inv + to.BgraPixels[i + 1] * alpha);
+ result[i + 2] = (byte)(from.BgraPixels[i + 2] * inv + to.BgraPixels[i + 2] * alpha);
+ result[i + 3] = 255;
+ }
+
+ return new VideoFrame(from.Width, from.Height, result);
+ }
+
///
/// UniformToFill blit of a source frame into element rect (ex, ey, ew, eh), with
/// straight-alpha source-over, optional round clip (true circle, hard edge) and
diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs
index ce4190a..0f8cad0 100644
--- a/Services/Encoder/FramePump.cs
+++ b/Services/Encoder/FramePump.cs
@@ -29,6 +29,8 @@ public sealed class FramePump : IDisposable
private readonly Action? _log;
private readonly Func _pacingDelay;
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
+ private readonly TransitionService? _transition;
+ private readonly Func? _fromSceneProvider;
private readonly SceneCompositor _compositor = new();
private readonly object _gate = new();
@@ -51,7 +53,9 @@ public sealed class FramePump : IDisposable
Func encoderFactory,
Action? log = null,
Func? pacingDelay = null,
- Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
+ Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
+ TransitionService? transition = null,
+ Func? fromSceneProvider = null)
{
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
@@ -61,6 +65,8 @@ public sealed class FramePump : IDisposable
_log = log;
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
_socialBar = socialBar;
+ _transition = transition;
+ _fromSceneProvider = fromSceneProvider;
}
public bool IsRunning { get; private set; }
@@ -207,6 +213,7 @@ public sealed class FramePump : IDisposable
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
{
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
+ var lastTick = System.Diagnostics.Stopwatch.StartNew();
try
{
@@ -227,11 +234,28 @@ public sealed class FramePump : IDisposable
? 0
: compositorOptions.SourceRectHeight - barFrame.Height;
}
- var frame = _compositor.Render(
- scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
+
+ VideoFrame frame;
+ if (_transition is { Active: true } transition && _fromSceneProvider != null)
+ {
+ var fromScene = _fromSceneProvider();
+ var fromFrame = fromScene != null
+ ? _compositor.Render(fromScene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop)
+ : _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
+ var toFrame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
+ frame = transition.BlendFrame(toFrame);
+ transition.Tick(lastTick.Elapsed.TotalMilliseconds);
+ lastTick.Restart();
+ }
+ else
+ {
+ frame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
+ lastTick.Restart();
+ }
+
IFfmpegEncoder? encoder;
lock (_gate) encoder = _encoder;
- if (encoder == null) break; // _encoder is only cleared after the loop ends; defensive
+ if (encoder == null) break;
await encoder.SubmitFrameAsync(frame, ct);
}
await _pacingDelay(interval, ct);
diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs
index cf28c8d..de879d3 100644
--- a/Services/LayoutStore.cs
+++ b/Services/LayoutStore.cs
@@ -47,7 +47,7 @@ public class LayoutStore : IDisposable
Name TEXT NOT NULL,
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
- HasBackdrop INTEGER NOT NULL DEFAULT 1,
+ HasBackground INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
@@ -77,7 +77,7 @@ public class LayoutStore : IDisposable
DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
- IsBackdrop INTEGER NOT NULL DEFAULT 0,
+ IsBackground INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0
);
@@ -198,10 +198,16 @@ public class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
- if (!columns.Contains("IsBackdrop"))
+ if (columns.Contains("IsBackdrop"))
{
using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackdrop INTEGER NOT NULL DEFAULT 0;";
+ cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
+ cmd.ExecuteNonQuery();
+ }
+ else if (!columns.Contains("IsBackground"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
@@ -267,6 +273,13 @@ public class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
+
+ // Rename legacy "Backdrop" display name to "Background" in existing rows
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
+ cmd.ExecuteNonQuery();
+ }
}
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
@@ -374,10 +387,10 @@ public class LayoutStore : IDisposable
tx.Commit();
}
- // v5 → v6: Scene gains HasBackdrop. Added columns default to true; the
+ // v5 → v6: Scene gains HasBackground. Added columns default to true; the
// ONE-TIME backfill (runs only when the column is first added) turns every
- // non-Live canonical scene off and drops their backdrop sources — the fix for
- // DBs saved before the Live-only policy. MainViewModel.EnforceBackdropPolicy
+ // non-Live canonical scene off and drops their background sources — the fix for
+ // DBs saved before the Live-only policy. MainViewModel.EnforceBackgroundPolicy
// re-runs the same normalization on every load for DBs that miss the backfill.
private void MigrateSceneTable()
{
@@ -390,26 +403,36 @@ public class LayoutStore : IDisposable
columns.Add(reader.GetString(1));
}
- if (columns.Contains("HasBackdrop")) return;
-
- using (var cmd = _connection.CreateCommand())
+ if (columns.Contains("HasBackdrop"))
{
- cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackdrop INTEGER NOT NULL DEFAULT 1;";
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
+ cmd.ExecuteNonQuery();
+ }
+ else if (columns.Contains("HasBackground"))
+ {
+ return;
+ }
+
+ if (!columns.Contains("HasBackground"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
- const string noBackdrop = "('starting', 'brb', 'chat', 'ending')";
+ const string noBackground = "('starting', 'brb', 'chat', 'ending')";
using (var cmd = _connection.CreateCommand())
{
- cmd.CommandText = $"UPDATE Scene SET HasBackdrop = 0 WHERE LOWER(TRIM(Name)) IN {noBackdrop};";
+ cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = $"""
DELETE FROM Source
- WHERE IsBackdrop = 1 AND SceneId IN
- (SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackdrop});
+ WHERE IsBackground = 1 AND SceneId IN
+ (SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
""";
cmd.ExecuteNonQuery();
}
@@ -486,7 +509,7 @@ public class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand())
{
- cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar FROM Scene ORDER BY SortOrder";
+ cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
@@ -496,7 +519,7 @@ public class LayoutStore : IDisposable
Name = reader.GetString(1),
IsHidden = reader.GetInt32(2) != 0,
IsChatScene = reader.GetInt32(3) != 0,
- HasBackdrop = reader.GetInt32(4) != 0,
+ HasBackground = reader.GetInt32(4) != 0,
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
});
}
@@ -566,7 +589,7 @@ public class LayoutStore : IDisposable
cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
- IsBackdrop, CaptureKey, ShowDesktop,
+ IsBackground, CaptureKey, ShowDesktop,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor
FROM Source ORDER BY SortOrder
@@ -590,7 +613,7 @@ public class LayoutStore : IDisposable
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
ClipShape = Enum.TryParse(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
IsMirrored = reader.GetInt32(13) != 0,
- IsBackdrop = reader.GetInt32(14) != 0,
+ IsBackground = reader.GetInt32(14) != 0,
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
ShowDesktop = reader.GetInt32(16) != 0,
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
@@ -653,6 +676,14 @@ public class LayoutStore : IDisposable
if (configsByScene.TryGetValue(scene.Id, out var configs))
foreach (var config in configs)
scene.Elements.Add(config);
+
+ var path = GetSetting($"BackgroundPath_{scene.Id}");
+ if (!string.IsNullOrWhiteSpace(path))
+ scene.CustomBackgroundPath = path;
+
+ var useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
+ if (useDefault == "0")
+ scene.UseDefaultBackground = false;
}
return scenes;
@@ -760,15 +791,15 @@ public class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
- INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder)
- VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $hasSocialBar, $sort)
+ INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, SortOrder)
+ VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $sort)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
- var backdropP = cmd.Parameters.Add("$hasBackdrop", SqliteType.Integer);
+ var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
@@ -779,7 +810,7 @@ public class LayoutStore : IDisposable
nameP.Value = scene.Name;
hiddenP.Value = scene.IsHidden ? 1 : 0;
chatP.Value = scene.IsChatScene ? 1 : 0;
- backdropP.Value = scene.HasBackdrop ? 1 : 0;
+ backgroundP.Value = scene.HasBackground ? 1 : 0;
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
sortP.Value = sort++;
cmd.ExecuteNonQuery();
@@ -791,12 +822,12 @@ public class LayoutStore : IDisposable
cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex,
- ClipShape, IsMirrored, IsBackdrop, CaptureKey, ShowDesktop, SortOrder,
+ ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
- $clip, $mirrored, $isBackdrop, $captureKey, $showDesktop, $sort,
+ $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor)
""";
@@ -815,7 +846,7 @@ public class LayoutStore : IDisposable
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
- var isBackdropP = cmd.Parameters.Add("$isBackdrop", SqliteType.Integer);
+ var isBackgroundP = cmd.Parameters.Add("$isBackground", SqliteType.Integer);
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
var showDesktopP = cmd.Parameters.Add("$showDesktop", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
@@ -848,7 +879,7 @@ public class LayoutStore : IDisposable
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
clipP.Value = source.ClipShape.ToString();
mirroredP.Value = source.IsMirrored ? 1 : 0;
- isBackdropP.Value = source.IsBackdrop ? 1 : 0;
+ isBackgroundP.Value = source.IsBackground ? 1 : 0;
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
sortP.Value = sort++;
@@ -994,6 +1025,24 @@ public class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
+ // Save custom background paths and use-default flags
+ foreach (var scene in scenes)
+ {
+ var pathKey = $"BackgroundPath_{scene.Id}";
+ if (!string.IsNullOrWhiteSpace(scene.CustomBackgroundPath))
+ UpsertSetting(pathKey, scene.CustomBackgroundPath);
+ else
+ {
+ using var del = _connection.CreateCommand();
+ del.CommandText = "DELETE FROM Settings WHERE Key = $key;";
+ del.Parameters.AddWithValue("$key", pathKey);
+ del.ExecuteNonQuery();
+ }
+
+ var defaultKey = $"BackgroundUseDefault_{scene.Id}";
+ UpsertSetting(defaultKey, scene.UseDefaultBackground ? "1" : "0");
+ }
+
tx.Commit();
}
@@ -1097,6 +1146,47 @@ public class LayoutStore : IDisposable
insert.ExecuteNonQuery();
}
+ public (string TransitionType, double DurationMs) LoadTransitionSettings()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionType';";
+ var type = cmd.ExecuteScalar() as string;
+
+ using var cmd2 = _connection.CreateCommand();
+ cmd2.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionDurationMs';";
+ var dur = cmd2.ExecuteScalar() as string;
+
+ return (type ?? "Fade", dur != null && double.TryParse(dur, out var d) ? d : 300);
+ }
+
+ public void SaveTransitionSettings(string transitionType, double durationMs)
+ {
+ UpsertSetting("TransitionType", transitionType);
+ UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
+ }
+
+ private string? GetSetting(string key)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = $key;";
+ cmd.Parameters.AddWithValue("$key", key);
+ return cmd.ExecuteScalar() as string;
+ }
+
+ private void UpsertSetting(string key, string value)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
+ cmd.Parameters.AddWithValue("$key", key);
+ cmd.ExecuteNonQuery();
+
+ using var insert = _connection.CreateCommand();
+ insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ($key, $value);";
+ insert.Parameters.AddWithValue("$key", key);
+ insert.Parameters.AddWithValue("$value", value);
+ insert.ExecuteNonQuery();
+ }
+
public void Dispose()
{
_connection.Dispose();
diff --git a/Services/ScreenCaptureFrameSource.cs b/Services/ScreenCaptureFrameSource.cs
index 834bf4d..f01e67d 100644
--- a/Services/ScreenCaptureFrameSource.cs
+++ b/Services/ScreenCaptureFrameSource.cs
@@ -30,10 +30,10 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
private bool _framePending;
private DateTime _lastErrorLog = DateTime.MinValue;
- // The composition master frame (see ai.md "Resolution tiers"): the backdrop
+ // The composition master frame (see ai.md "Resolution tiers"): the background
// is an input layer, so we never hold a CPU frame bigger than the master.
- private const int MaxBackdropWidth = 1920;
- private const int MaxBackdropHeight = 1080;
+ private const int MaxBackgroundWidth = 1920;
+ private const int MaxBackgroundHeight = 1080;
// A failing conversion must not re-flood the log at frame rate.
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
@@ -172,9 +172,9 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
// The master frame is 1920×1080; a larger monitor is scaled down here so
// the CPU never holds a frame above the master (the rendered layer fills
// the frame with UniformToFill regardless).
- if (sw > MaxBackdropWidth || sh > MaxBackdropHeight)
+ if (sw > MaxBackgroundWidth || sh > MaxBackgroundHeight)
{
- var scale = Math.Min(MaxBackdropWidth / (double)sw, MaxBackdropHeight / (double)sh);
+ var scale = Math.Min(MaxBackgroundWidth / (double)sw, MaxBackgroundHeight / (double)sh);
var dw = Math.Max(1, (int)(sw * scale));
var dh = Math.Max(1, (int)(sh * scale));
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh));
diff --git a/Services/ScreenCaptureManager.cs b/Services/ScreenCaptureManager.cs
index edfc31a..eaf73a4 100644
--- a/Services/ScreenCaptureManager.cs
+++ b/Services/ScreenCaptureManager.cs
@@ -158,7 +158,7 @@ public sealed class ScreenCaptureManager : IDisposable
/// The most recent frame for a capture key, or null before the first
/// frame arrives (or if the key has no session). The live compositor reads
- /// the backdrop from here.
+ /// the background from here.
public VideoFrame? GetLatestFrame(string key)
{
lock (_gate)
diff --git a/Services/SocialValidator.cs b/Services/SocialValidator.cs
index 5b32cc3..cdf0073 100644
--- a/Services/SocialValidator.cs
+++ b/Services/SocialValidator.cs
@@ -46,7 +46,7 @@ public sealed class HttpSocialValidator : ISocialValidator
{
_client = client ?? new HttpClient();
_client.DefaultRequestHeaders.UserAgent.ParseAdd(
- "Mozilla/5.0 (compatible; ytLlive social validator)");
+ "Mozilla/5.0 (compatible; LlamaCasty social validator)");
_log = log;
}
diff --git a/Services/TransitionService.cs b/Services/TransitionService.cs
new file mode 100644
index 0000000..8065b78
--- /dev/null
+++ b/Services/TransitionService.cs
@@ -0,0 +1,154 @@
+using ytLive.Models;
+
+namespace ytLive.Services;
+
+///
+/// The three v1 transition types. Clicking a scene thumbnail triggers the selected
+/// transition; Go Live always uses Cut for a clean start.
+///
+public enum TransitionType
+{
+ Cut,
+ Fade,
+ Move
+}
+
+///
+/// Manages the transition state between StagedScene and LiveScene. When a
+/// transition is active, FramePump calls each tick
+/// to get the blended output. When the transition completes,
+/// becomes false and FramePump reverts to a direct LiveScene render.
+///
+public sealed class TransitionService
+{
+ private const double DefaultDurationMs = 300;
+
+ /// True while a transition is in progress.
+ public bool Active { get; private set; }
+
+ /// The transition type for the current in-progress transition.
+ public TransitionType CurrentType { get; private set; } = TransitionType.Cut;
+
+ /// Duration of the current transition in milliseconds.
+ public double DurationMs { get; private set; } = DefaultDurationMs;
+
+ /// Elapsed time in milliseconds (updated by caller).
+ public double ElapsedMs { get; private set; }
+
+ /// 0.0 = fully "from" scene, 1.0 = fully "to" scene.
+ public float Progress => DurationMs <= 0 ? 1f
+ : Math.Clamp((float)(ElapsedMs / DurationMs), 0f, 1f);
+
+ ///
+ /// The "from" frame (captured at transition start). Populated by the caller
+ /// before starting the transition.
+ ///
+ public VideoFrame? FromFrame { get; private set; }
+
+ /// Begin a transition. Caller should capture FromFrame from the current LiveScene render.
+ public void Start(TransitionType type, double durationMs, VideoFrame? fromFrame)
+ {
+ CurrentType = type;
+ DurationMs = durationMs > 0 ? durationMs : DefaultDurationMs;
+ FromFrame = fromFrame;
+ ElapsedMs = 0;
+ Active = true;
+
+ if (type == TransitionType.Cut)
+ {
+ Active = false;
+ ElapsedMs = DurationMs;
+ }
+ }
+
+ ///
+ /// Advance the transition clock. Returns true while the transition is still active.
+ ///
+ public bool Tick(double elapsedMs)
+ {
+ if (!Active) return false;
+ ElapsedMs += elapsedMs;
+ if (ElapsedMs >= DurationMs)
+ {
+ ElapsedMs = DurationMs;
+ Active = false;
+ return false;
+ }
+ return true;
+ }
+
+ ///
+ /// Blend the from-frame and the to-frame based on the current progress.
+ /// Caller passes the freshly-rendered "to" scene; we return the blended output.
+ ///
+ public VideoFrame BlendFrame(VideoFrame toFrame)
+ {
+ if (FromFrame == null || CurrentType == TransitionType.Cut)
+ return toFrame;
+
+ var t = Progress;
+ return CurrentType switch
+ {
+ TransitionType.Fade => BlendFade(FromFrame, toFrame, t),
+ TransitionType.Move => BlendMove(FromFrame, toFrame, t),
+ _ => toFrame
+ };
+ }
+
+ private static VideoFrame BlendFade(VideoFrame from, VideoFrame to, float t)
+ {
+ var w = from.Width;
+ var h = from.Height;
+ var src = from.BgraPixels;
+ var dst = to.BgraPixels;
+ var outPixels = new byte[src.Length];
+
+ for (var i = 0; i < outPixels.Length; i += 4)
+ {
+ outPixels[i] = (byte)(src[i] * (1 - t) + dst[i] * t);
+ outPixels[i + 1] = (byte)(src[i + 1] * (1 - t) + dst[i + 1] * t);
+ outPixels[i + 2] = (byte)(src[i + 2] * (1 - t) + dst[i + 2] * t);
+ outPixels[i + 3] = 255;
+ }
+
+ return new VideoFrame(w, h, outPixels);
+ }
+
+ private static VideoFrame BlendMove(VideoFrame from, VideoFrame to, float t)
+ {
+ var w = from.Width;
+ var h = from.Height;
+ var src = from.BgraPixels;
+ var dst = to.BgraPixels;
+ var outPixels = new byte[src.Length];
+ var offset = (int)(w * (1 - t));
+
+ for (var y = 0; y < h; y++)
+ {
+ for (var x = 0; x < w; x++)
+ {
+ var di = (y * w + x) * 4;
+ var fromX = x - offset;
+ var toX = x + offset;
+
+ var srcPixel = fromX >= 0 && fromX < w
+ ? (src[(y * w + fromX) * 4], src[(y * w + fromX) * 4 + 1], src[(y * w + fromX) * 4 + 2])
+ : ((byte)0, (byte)0, (byte)0);
+
+ var dstPixel = toX >= 0 && toX < w
+ ? (dst[(y * w + toX) * 4], dst[(y * w + toX) * 4 + 1], dst[(y * w + toX) * 4 + 2])
+ : ((byte)0, (byte)0, (byte)0);
+
+ var edgeFalloff = Math.Clamp((float)(offset) / (w * 0.3f), 0, 1);
+ var lerpF = Math.Clamp(1f - edgeFalloff, 0, 1);
+
+ outPixels[di] = (byte)(srcPixel.Item1 * (1 - lerpF) + dstPixel.Item1 * lerpF);
+ outPixels[di + 1] = (byte)(srcPixel.Item2 * (1 - lerpF) + dstPixel.Item2 * lerpF);
+ outPixels[di + 2] = (byte)(srcPixel.Item3 * (1 - lerpF) + dstPixel.Item3 * lerpF);
+ outPixels[di + 3] = 255;
+ }
+ }
+
+ return new VideoFrame(w, h, outPixels);
+ }
+}
diff --git a/Services/YouTubeAuthService.cs b/Services/YouTubeAuthService.cs
index 8c5a2ff..00ce8b2 100644
--- a/Services/YouTubeAuthService.cs
+++ b/Services/YouTubeAuthService.cs
@@ -92,8 +92,8 @@ public class YouTubeAuthService
var error = context.Request.QueryString["error"];
var html = error != null
- ? "
Sign-in failed
You can close this window and return to ytLlive.
"
- : "
Sign-in successful!
You can close this window and return to ytLlive.
";
+ ? "
Sign-in failed
You can close this window and return to LlamaCasty.
"
+ : "
Sign-in successful!
You can close this window and return to LlamaCasty.
";
var buffer = Encoding.UTF8.GetBytes(html);
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.ContentLength64 = buffer.Length;
diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs
index f30d9d8..41003bc 100644
--- a/Services/YouTubeStreamService.cs
+++ b/Services/YouTubeStreamService.cs
@@ -111,7 +111,7 @@ public class YouTubeStreamService
var stream = new
{
- snippet = new { title = "ytLlive Reusable Stream" },
+ snippet = new { title = "LlamaCasty Reusable Stream" },
cdn = new
{
ingestionType = "rtmp",
diff --git a/SocialsDialog.xaml.cs b/SocialsDialog.xaml.cs
index a8c3f1e..e9f18f5 100644
--- a/SocialsDialog.xaml.cs
+++ b/SocialsDialog.xaml.cs
@@ -41,7 +41,7 @@ public partial class SocialsDialog : Window
if (sender is not FrameworkElement { Tag: int index } element) return;
var slot = element.DataContext as SocialSlotViewModel;
var confirm = index == 0
- ? "Remove your YouTube channel from the bar and sign out of ytLlive?"
+ ? "Remove your YouTube channel from the bar and sign out of LlamaCasty?"
: slot is { IsFilled: true }
? $"Remove {slot.Handle}?"
: "Remove this social?";
diff --git a/TASKS.md b/TASKS.md
index a0daccb..e3b051d 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -954,39 +954,21 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
1. ✅ **TASK 9.4** — Live chat (right panel) — wire `YouTubeChatService.Start()`, parse messages, render in panel. Foundation for chat box source.
2. ✅ **TASK 3.18** — Chat box source — renders chat ON the stream. Depends on TASK 9.4 (same message parsing).
-3. ✅ **TASK 10** — Polar billing — license key entry + watermark toggle + Velopack auto-updates (steps 1–7 shipped; Velopack update URL pending).
-4. **TASK 20** — Hotkeys — global keyboard shortcuts. Small scope, huge UX impact.
-5. **TASK 19** — Scene transitions — Cut/Fade/Move/Custom. Depends on compositor.
+3. ✅ **TASK 10** — Polar billing — license key entry + watermark toggle + Velopack auto-updates (steps 1-7 shipped; Velopack update URL pending).
+4. **TASK 19/23** — Control Surface UX — director's control room: thumbnails above central monitor, transitions (Cut/Fade/Move), edit mode offline only, left panel two-state (layers/props ↔ chat), right panel eliminated.
+5. **TASK 20** — Hotkeys — global keyboard shortcuts. Small scope, huge UX impact.
6. **TASK 17** — Web source (WebView2) — enables alert ecosystem.
7. **TASK 18** — Local recording — independent, but pairs with stream.
8. **TASK 21** — Media source — video file playback for non-Live scenes.
9. **TASK 22** — Audio sync offset — small, quality-of-life.
-10. **TASK 23** — Studio mode — lightweight preview before live.
-11. ✅ **TASK 15** — Stock bg images — all 5 scenes seeded (Starting, BRB, Live, Chat, Ending). `EnsureDefaultBackdrop()` runs on every scene switch.
-12. **TASK 13** — Social media launch kit — after all v1 features ship.
+10. ✅ **TASK 15** — Stock bg images — all 5 scenes seeded (Starting, BRB, Live, Chat, Ending). `EnsureDefaultBackdrop()` runs on every scene switch.
+11. **TASK 13** — Social media launch kit — after all v1 features ship.
---
-## TASK 19 — Scene transitions
+## TASK 19 — Scene transitions → MERGED into TASK 19/23 (Control Surface UX)
-**Goal:** smooth visual transitions between scenes — the four types from the design spec (Cut, Fade, Move, Custom).
-
-### Status: ☐ Not started — required for v1
-
-1. ☐ `TransitionType` enum: `Cut`, `Fade`, `Move`, `Custom`
-2. ☐ Global transition setting (`Cut` default, 300ms) with per-scene override
-3. ☐ `TransitionDuration` property (configurable, default 300ms)
-4. ☐ Compositor support — blend two frames during transition:
- - **Cut:** instant switch (current behavior, zero cost)
- - **Fade:** crossfade — source fades out, destination fades in
- - **Move:** simple positional transition — source slides out, destination slides in (economical)
- - **Custom:** media/stinger playback during transition (heavier, but creators pay for these)
-5. ☐ Preview shows the transition (WYSIWYG) — the preview panel renders the same blend
-6. ☐ Encoder output renders the transition too (the live stream sees it)
-7. ☐ Transition state management in MainViewModel (`_isTransitioning`, current/next scene tracking)
-8. ☐ Custom transitions: media resource loading (video file → frame sequence during transition)
-9. ☐ UI: transition type picker (dropdown or buttons), duration slider
-10. ☐ Tests: transition timing, frame blending, Cut is instant, Fade produces blended frames
+**Superseded by the Control Surface UX task.** Cut/Fade/Move transitions, thumbnails, edit mode, left panel two-state — all shipped as one cohesive feature. See the Control Surface UX section in `ai.md`.
### Design decisions
@@ -1069,24 +1051,9 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
---
-## TASK 23 — Studio mode (lightweight preview)
+## TASK 23 — Studio mode → MERGED into TASK 19/23 (Control Surface UX)
-**Goal:** preview the next scene before transitioning — lightweight version of OBS Studio Mode.
-
-### Status: ☐ Not started — required for v1
-
-1. ☐ "Preview" button per scene — clicking shows that scene in a preview pane without going live
-2. ☐ Preview pane — dedicated area showing the selected scene (not the current live scene)
-3. ☐ "Go" button — transitions from preview to live (applies the scene change)
-4. ☐ Transition applies when switching — if Fade is selected, the preview-to-live transition uses it
-5. ☐ Disable during live transitions — prevent rapid-fire preview switching while transitioning
-6. ☐ Tests: preview isolation, go button triggers transition, disable during transition
-
-### Design decisions
-
-- **Lightweight implementation** — not the full OBS dual-canvas setup. Just a "preview this scene" button + a preview pane.
-- **Optional feature** — creators can ignore it and just switch scenes directly (current behavior).
-- **Pairs with transitions** — studio mode + transitions = professional workflow. Preview the fade, then commit.
+**Superseded by the Control Surface UX task.** Studio mode is now the entire app's paradigm — the director's control surface with 5 thumbnails + central monitor. See the Control Surface UX section in `ai.md`.
---
diff --git a/Themes/Controls.xaml b/Themes/Controls.xaml
index 1efaa87..c01e1e4 100644
--- a/Themes/Controls.xaml
+++ b/Themes/Controls.xaml
@@ -555,4 +555,63 @@
+
+
+
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 35569b3..4d644ef 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -31,7 +31,8 @@ public class MainViewModel : ViewModelBase
private readonly DispatcherTimer _gameVolumeFlashTimer;
private readonly DispatcherTimer _healthPollTimer;
- private Scene? _activeScene;
+ private Scene? _stagedScene;
+ private Scene? _liveScene;
private SceneElement? _selectedElement;
private ImageSource? _activeBackgroundImage;
private bool _isConnected;
@@ -69,7 +70,7 @@ public class MainViewModel : ViewModelBase
private ReusableStream? _reusableStream;
private string? _healthIssueBanner;
private string _healthIssueBackground = "Transparent";
- private string _windowTitle = "ytLlive";
+ private string _windowTitle = "LlamaCasty";
private string _topBarBackground = "#16213e";
private string _previewGlowBrush = "Transparent";
private Thickness _previewGlowThickness = new(0);
@@ -119,8 +120,9 @@ public class MainViewModel : ViewModelBase
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
private readonly Func _rtmpUrlProvider;
private readonly FramePump _framePump;
+ private readonly TransitionService _transition = new();
- // Screen backdrop: a permanent live capture (desktop/game) that every scene
+ // Screen background: a permanent live capture (desktop/game) that every scene
// shows at the bottom layer. One shared capture session per key — the
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
private readonly IFullScreenDetector _fullScreenDetector;
@@ -138,13 +140,15 @@ public class MainViewModel : ViewModelBase
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
};
- private ImageSource? _backdropImage;
+ private ImageSource? _backgroundImage;
+ private bool _backgroundSizeMismatch;
+ private string _backgroundSizeMismatchText = "";
private HashSet _liveCaptureKeys = new();
// Chat fade timer: clears chat overlay after N seconds of inactivity.
private System.Windows.Threading.DispatcherTimer? _chatFadeTimer;
- // Branding flash (monetization): a full-frame "made with ytLlive!" shown
+ // Branding flash (monetization): a full-frame "made with LlamaCasty!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization".
private readonly DispatcherTimer _brandFlashTimer;
@@ -180,52 +184,90 @@ public class MainViewModel : ViewModelBase
public ObservableCollection ChatMessages { get; } = new();
public ObservableCollection Displays { get; } = new();
- public Scene? ActiveScene
+ public Scene? StagedScene
{
- get => _activeScene;
+ get => _stagedScene;
set
{
if (value != null && value.IsHidden) return;
- if (SetProperty(ref _activeScene, value))
+ if (SetProperty(ref _stagedScene, value))
{
+ // Update IsStaged flags for thumbnail glow
+ foreach (var s in Scenes)
+ s.IsStaged = s == value;
+
SelectedElement = null;
- EnsureDefaultBackdrop(value);
+ EnsureDefaultBackground(value);
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
- OnPropertyChanged(nameof(CanChangeBackdrop));
- OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
- OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
+ OnPropertyChanged(nameof(CanChangeBackground));
+ OnPropertyChanged(nameof(CanAddWebcamToStagedScene));
+ OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(IsCountdownAvailable));
+ OnPropertyChanged(nameof(UseDefaultBackground));
+ OnPropertyChanged(nameof(CustomBackgroundPath));
UpdateActiveBackground();
- UpdateBackdropImage();
+ UpdateBackgroundImage();
+ RefreshSnapshotsAsync();
}
}
}
+ /// The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene)
+ /// or on transition completion. FramePump reads this every tick.
+ public Scene? LiveScene
+ {
+ get => _liveScene;
+ set
+ {
+ if (SetProperty(ref _liveScene, value))
+ {
+ foreach (var s in Scenes)
+ s.IsLive = s == value;
+ }
+ }
+ }
+
+ /// True when offline — edit mode is available. When live/recording,
+ /// all edit operations are locked out (gun safety).
+ public bool IsEditMode => StreamStatus == StreamStatus.Offline;
+
public ImageSource? ActiveBackgroundImage
{
get => _activeBackgroundImage;
private set => SetProperty(ref _activeBackgroundImage, value);
}
- /// The active scene's live-capture backdrop frame (rendered below the
+ /// The active scene's live-capture background frame (rendered below the
/// static background). Set from the shared capture bitmap as it arrives.
- public ImageSource? BackdropImage
+ public ImageSource? BackgroundImage
{
- get => _backdropImage;
+ get => _backgroundImage;
private set
{
- if (SetProperty(ref _backdropImage, value))
+ if (SetProperty(ref _backgroundImage, value))
{
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
- OnPropertyChanged(nameof(BackdropVisible));
+ OnPropertyChanged(nameof(BackgroundVisible));
}
}
}
- public bool BackdropVisible => ActiveScene?.Elements.OfType().FirstOrDefault(s => s.IsBackdrop)?.IsVisible == true;
+ public bool BackgroundVisible => StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBackground)?.IsVisible == true;
+
+ public bool BackgroundSizeMismatch
+ {
+ get => _backgroundSizeMismatch;
+ private set => SetProperty(ref _backgroundSizeMismatch, value);
+ }
+
+ public string BackgroundSizeMismatchText
+ {
+ get => _backgroundSizeMismatchText;
+ private set => SetProperty(ref _backgroundSizeMismatchText, value);
+ }
public SceneElement? SelectedElement
{
@@ -241,14 +283,54 @@ public class MainViewModel : ViewModelBase
}
/// The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.
- public bool CanAddWebcamToActiveScene => ActiveScene?.WebcamConfig == null;
+ public bool CanAddWebcamToStagedScene => StagedScene?.WebcamConfig == null;
+
+ // ─── Transitions (TASK 19/23) ───
+
+ public TransitionType SelectedTransitionType
+ {
+ get => _selectedTransitionType;
+ set => SetProperty(ref _selectedTransitionType, value);
+ }
+ private TransitionType _selectedTransitionType = TransitionType.Fade;
+
+ public double TransitionDurationMs
+ {
+ get => _transitionDurationMs;
+ set => SetProperty(ref _transitionDurationMs, value);
+ }
+ private double _transitionDurationMs = 300;
+
+ ///
+ /// Called when a scene thumbnail is clicked. If live, triggers a transition from
+ /// LiveScene to the selected StagedScene. Always sets StagedScene first.
+ ///
+ public void TransitionToScene(Scene scene)
+ {
+ if (scene.IsHidden || scene == StagedScene) return;
+ StagedScene = scene;
+ if (IsLive)
+ _transition.Start(SelectedTransitionType, TransitionDurationMs, null);
+ }
+
+ ///
+ /// Apply the current transition type to the next StagedScene change while live.
+ /// Called from XAML when user picks a transition from the dropdown.
+ ///
+ public void ApplyTransitionToLive()
+ {
+ if (IsLive)
+ _transition.Start(SelectedTransitionType, TransitionDurationMs, null);
+ }
+
+ /// The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.
///
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
/// webcam config (add one) or hides it (unhide — keeps the config row).
///
- public bool CanShowWebcamInActiveScene
- => ActiveScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
+ public bool CanShowWebcamInStagedScene
+ => StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
/// Swap-the-device item: enabled once a camera has been picked at all.
public bool CanChangeWebcam => _webcam != null;
@@ -322,6 +404,7 @@ public class MainViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive));
+ OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
@@ -789,12 +872,12 @@ public class MainViewModel : ViewModelBase
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
public bool ShowStartStream => IsOffline && IsConnected;
public bool ShowChatInactiveMessage => !IsLive;
- public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
- public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackdropImage == null;
- public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;
+ public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
+ public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
+ public bool ShowSourcesEmptyHint => StagedScene == null || StagedScene.Elements.Count == 0;
- public bool IsCountdownAvailable => ActiveScene != null
- && (SceneCatalog.Is(ActiveScene.Name, "Starting") || SceneCatalog.Is(ActiveScene.Name, "BRB"));
+ public bool IsCountdownAvailable => StagedScene != null
+ && (SceneCatalog.Is(StagedScene.Name, "Starting") || SceneCatalog.Is(StagedScene.Name, "BRB"));
/// The canonical scenes (SceneCatalog) not present right now — what the
/// "+" button may re-add. Work with less, never more.
@@ -804,8 +887,8 @@ public class MainViewModel : ViewModelBase
/// Only show the "+" button when one of the five canonical scenes is missing.
public bool ShowAddScene => MissingScenes.Any();
- /// "Change Capture…"/"Refresh Capture" apply to the active scene's backdrop.
- public bool CanChangeBackdrop => ActiveScene?.HasBackdrop == true;
+ /// "Change Capture…"/"Refresh Capture" apply to the active scene's background.
+ public bool CanChangeBackground => StagedScene?.HasBackground == true;
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately.
@@ -836,17 +919,128 @@ public class MainViewModel : ViewModelBase
private void UpdateActiveBackground()
{
- var background = ActiveScene?.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
+ var background = StagedScene?.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
? ImageCache.Get(background.AssetId)
: null;
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
}
- private void UpdateBackdropImage()
+ private void UpdateBackgroundImage()
{
- var backdrop = ActiveScene?.Elements.OfType().FirstOrDefault(s => s.IsBackdrop);
- BackdropImage = backdrop?.DisplaySource;
+ var scene = StagedScene;
+ var background = scene?.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ BackgroundImage = background?.DisplaySource;
+
+ if (scene != null && !scene.UseDefaultBackground
+ && !string.IsNullOrWhiteSpace(scene.CustomBackgroundPath)
+ && System.IO.File.Exists(scene.CustomBackgroundPath))
+ {
+ try
+ {
+ var bmi = new System.Windows.Media.Imaging.BitmapImage();
+ bmi.BeginInit();
+ bmi.UriSource = new System.Uri(scene.CustomBackgroundPath);
+ bmi.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
+ bmi.EndInit();
+ bmi.Freeze();
+
+ double expectedRatio = 1920.0 / 1080.0;
+ double imageRatio = (double)bmi.PixelWidth / bmi.PixelHeight;
+ double ratioDiff = Math.Abs(imageRatio - expectedRatio) / expectedRatio;
+
+ if (ratioDiff > 0.05)
+ {
+ BackgroundSizeMismatch = true;
+ BackgroundSizeMismatchText =
+ $"Image is {bmi.PixelWidth}\u00d7{bmi.PixelHeight} \u2014 stream expects 1920\u00d71080 (16:9)";
+ }
+ else
+ {
+ BackgroundSizeMismatch = false;
+ BackgroundSizeMismatchText = "";
+ }
+ }
+ catch
+ {
+ BackgroundSizeMismatch = false;
+ BackgroundSizeMismatchText = "";
+ }
+ }
+ else
+ {
+ BackgroundSizeMismatch = false;
+ BackgroundSizeMismatchText = "";
+ }
+ }
+
+ private void RefreshSnapshotsAsync()
+ {
+ foreach (var scene in Scenes)
+ {
+ var img = LoadBackgroundImage(scene);
+ if (img != null)
+ scene.Snapshot = img;
+ else if (scene.Snapshot == null)
+ scene.Snapshot = CreatePlaceholderSnapshot(scene.Name);
+ }
+ }
+
+ private static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
+ {
+ if (!scene.UseDefaultBackground
+ && !string.IsNullOrWhiteSpace(scene.CustomBackgroundPath)
+ && System.IO.File.Exists(scene.CustomBackgroundPath))
+ {
+ try
+ {
+ var bitmap = new System.Windows.Media.Imaging.BitmapImage();
+ bitmap.BeginInit();
+ bitmap.UriSource = new Uri(scene.CustomBackgroundPath);
+ bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
+ bitmap.EndInit();
+ bitmap.Freeze();
+ return bitmap;
+ }
+ catch { /* fall through to AssetId */ }
+ }
+ var bg = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
+ if (bg != null && !string.IsNullOrWhiteSpace(bg.AssetId))
+ return ImageCache.Get(bg.AssetId);
+ return null;
+ }
+
+ private static BitmapSource CreatePlaceholderSnapshot(string sceneName)
+ {
+ var colors = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Starting"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(233, 69, 96)),
+ ["Live"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(34, 197, 94)),
+ ["BRB"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(234, 179, 8)),
+ ["Chat"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(59, 130, 246)),
+ ["Ending"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(168, 85, 247)),
+ };
+ var brush = colors.TryGetValue(sceneName, out var c) ? c
+ : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 80));
+ var dv = new System.Windows.Media.DrawingVisual();
+ using (var ctx = dv.RenderOpen())
+ {
+ ctx.DrawRectangle(brush, null, new System.Windows.Rect(0, 0, 320, 180));
+ var ft = new System.Windows.Media.FormattedText(
+ sceneName,
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Windows.FlowDirection.LeftToRight,
+ new System.Windows.Media.Typeface("Segoe UI"),
+ 18,
+ System.Windows.Media.Brushes.White,
+ 96);
+ ctx.DrawText(ft, new System.Windows.Point(160 - ft.Width / 2, 90 - ft.Height / 2));
+ }
+ var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(
+ 320, 180, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
+ rtb.Render(dv);
+ rtb.Freeze();
+ return rtb;
}
public StreamHealth CurrentHealth
@@ -1189,8 +1383,9 @@ public class MainViewModel : ViewModelBase
public ICommand ShowWebcamCommand { get; }
public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; }
- public ICommand SetBackdropDisplayCommand { get; }
+ public ICommand SetBackgroundDisplayCommand { get; }
public ICommand ToggleShowDesktopCommand { get; }
+ public ICommand BrowseBackgroundCommand { get; }
public ICommand ToggleMicMuteCommand { get; }
public ICommand ToggleGameMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; }
@@ -1252,18 +1447,19 @@ public class MainViewModel : ViewModelBase
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
- AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync());
+ AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync());
AddImageCommand = new RelayCommand(_ => AddImage());
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
- ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
+ ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInStagedScene(), _ => CanShowWebcamInStagedScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
- ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
- RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
- SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
- ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackdropShowDesktop());
+ ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync());
+ RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture());
+ SetBackgroundDisplayCommand = new RelayCommand(display => SetBackgroundCapture(display as DisplayInfo));
+ ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackgroundShowDesktop());
+ BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground());
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
@@ -1345,13 +1541,15 @@ public class MainViewModel : ViewModelBase
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
- sceneProvider: () => ActiveScene,
+ sceneProvider: () => StagedScene,
frameResolver: ResolveOutputFrame,
compositorOptions: BuildCompositorOptions,
encoderOptions: BuildEncoderOptions,
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
log: message => AppLog.Write(message),
- socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom));
+ socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
+ transition: _transition,
+ fromSceneProvider: () => LiveScene);
_framePump.Failed += OnFramePumpFailed;
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
@@ -1528,15 +1726,15 @@ public class MainViewModel : ViewModelBase
foreach (var name in SceneCatalog.All)
AddScene(name);
}
- SeedStartingBackdrop();
- SeedBrbBackdrop();
- SeedEndingBackdrop();
- SeedChatBackdrop();
+ SeedStartingBackground();
+ SeedBrbBackground();
+ SeedEndingBackground();
+ SeedChatBackground();
- // The live backdrop belongs to Live only; a pre-policy DB may have
- // backdrops lingering in other scenes — drop them, then
+ // The live background belongs to Live only; a pre-policy DB may have
+ // backgrounds lingering in other scenes — drop them, then
// ReacquireScreenCaptures heals Live's.
- EnforceBackdropPolicy(Scenes);
+ EnforceBackgroundPolicy(Scenes);
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType())
@@ -1550,11 +1748,11 @@ public class MainViewModel : ViewModelBase
{
_isLoading = false;
}
- ActiveScene = Scenes.FirstOrDefault();
+ StagedScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
UpdateActiveChatBoxPreviews();
StartMockChatPreview();
- UpdateBackdropImage();
+ UpdateBackgroundImage();
ReacquireWebcam();
ReacquireScreenCaptures();
_socials = _layoutStore.Socials;
@@ -1565,6 +1763,12 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(SocialBarDotBrush));
_music = _layoutStore.Music;
LoadTrax(play: false);
+
+ var (transType, transDur) = _layoutStore.LoadTransitionSettings();
+ if (Enum.TryParse(transType, true, out var parsed))
+ SelectedTransitionType = parsed;
+ TransitionDurationMs = transDur;
+
ScheduleSave();
AppLog.Write("LoadLayout end");
}
@@ -1582,7 +1786,7 @@ public class MainViewModel : ViewModelBase
_ = _cameraManager.ReleaseAllAsync(previousDevice);
OnPropertyChanged(nameof(CanChangeWebcam));
- OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
+ OnPropertyChanged(nameof(CanAddWebcamToStagedScene));
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
if (previousDevice != newDevice)
@@ -1617,56 +1821,56 @@ public class MainViewModel : ViewModelBase
WebcamError = $"Webcam offline: {message}";
}
- // ─── Screen backdrop capture (live desktop/game) ───
+ // ─── Screen background capture (live desktop/game) ───
private const string MonitorKeyPrefix = "monitor:";
- /// Every backdrop-enabled scene has exactly one backdrop, kept at index 0 (bottom layer).
- /// Returns null for a scene with = false.
- internal static Source? EnsureBackdrop(Scene scene)
+ /// Every background-enabled scene has exactly one background, kept at index 0 (bottom layer).
+ /// Returns null for a scene with = false.
+ internal static Source? EnsureBackground(Scene scene)
{
- if (!scene.HasBackdrop) return null;
- var backdrop = scene.Elements.OfType().FirstOrDefault(s => s.IsBackdrop);
- if (backdrop != null) return backdrop;
+ if (!scene.HasBackground) return null;
+ var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ if (background != null) return background;
- backdrop = new Source
+ background = new Source
{
Name = "Game Capture",
Type = SourceType.DisplayCapture,
- IsBackdrop = true,
+ IsBackground = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = MasterFrameWidth,
Height = MasterFrameHeight,
};
- scene.Elements.Insert(0, backdrop);
- return backdrop;
+ scene.Elements.Insert(0, background);
+ return background;
}
- // The backdrop belongs to the Live scene alone. Runs after every layout
- // load: the flag is normalized by scene name and any backdrop lingering in a
+ // The background belongs to the Live scene alone. Runs after every layout
+ // load: the flag is normalized by scene name and any background lingering in a
// non-Live scene (from a pre-policy DB) is removed. ReacquireScreenCaptures
- // re-heals Live's backdrop right after.
- internal static void EnforceBackdropPolicy(IEnumerable scenes)
+ // re-heals Live's background right after.
+ internal static void EnforceBackgroundPolicy(IEnumerable scenes)
{
foreach (var scene in scenes)
{
- scene.HasBackdrop = SceneCatalog.HasBackdrop(scene.Name);
- if (scene.HasBackdrop) continue;
- var backdrop = scene.Elements.OfType().FirstOrDefault(s => s.IsBackdrop);
- if (backdrop != null)
- scene.Elements.Remove(backdrop);
+ scene.HasBackground = SceneCatalog.HasBackground(scene.Name);
+ if (scene.HasBackground) continue;
+ var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ if (background != null)
+ scene.Elements.Remove(background);
}
}
- private IEnumerable AllBackdrops()
- => Scenes.SelectMany(s => s.Elements.OfType().Where(x => x.IsBackdrop));
+ private IEnumerable AllBackgrounds()
+ => Scenes.SelectMany(s => s.Elements.OfType().Where(x => x.IsBackground));
// Full-screen game on its monitor, else null (static bg fallback — no
// infinity mirror). Called at launch (from ReacquireScreenCaptures, before
- // the window steals focus) and on focus regain (RefreshBackdropAutoCapture).
- // When a backdrop has ShowDesktop=true, returns the primary monitor instead
+ // the window steals focus) and on focus regain (RefreshBackgroundAutoCapture).
+ // When a background has ShowDesktop=true, returns the primary monitor instead
// of null so the desktop is always visible.
private string? ResolveAutoCaptureKey()
{
@@ -1674,37 +1878,37 @@ public class MainViewModel : ViewModelBase
if (gameMonitor != null)
return $"{MonitorKeyPrefix}{gameMonitor}";
- if (AllBackdrops().Any(b => b.ShowDesktop))
+ if (AllBackgrounds().Any(b => b.ShowDesktop))
return $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
return null;
}
- // After a layout load / file open: make sure every backdrop-enabled scene
- // has a backdrop, give any backdrop without a persisted key the auto-detected
- // one, then acquire one capture per unique backdrop key. ScreenCaptureManager
+ // After a layout load / file open: make sure every background-enabled scene
+ // has a background, give any background without a persisted key the auto-detected
+ // one, then acquire one capture per unique background key. ScreenCaptureManager
// refcounts by key, so every scene pointing at the same monitor shares one
// session. Captures no longer referenced by any scene are released.
private void ReacquireScreenCaptures()
{
foreach (var scene in Scenes)
- EnsureBackdrop(scene);
+ EnsureBackground(scene);
- SeedLiveBackdropAsset();
+ SeedLiveBackgroundAsset();
var auto = ResolveAutoCaptureKey();
if (auto == null)
{
- foreach (var backdrop in AllBackdrops())
- backdrop.CaptureKey = null;
+ foreach (var background in AllBackgrounds())
+ background.CaptureKey = null;
}
else
{
- foreach (var backdrop in AllBackdrops())
- if (string.IsNullOrWhiteSpace(backdrop.CaptureKey))
- backdrop.CaptureKey = auto;
+ foreach (var background in AllBackgrounds())
+ if (string.IsNullOrWhiteSpace(background.CaptureKey))
+ background.CaptureKey = auto;
}
- var keys = AllBackdrops()
+ var keys = AllBackgrounds()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
@@ -1722,134 +1926,191 @@ public class MainViewModel : ViewModelBase
}
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
- // every backdrop pointing at that key picks it up.
+ // every background pointing at that key picks it up.
private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap)
{
- var activeBackdrop = ActiveScene?.Elements.OfType().FirstOrDefault(s => s.IsBackdrop);
- if (activeBackdrop?.CaptureKey == key)
- BackdropImage = bitmap;
+ var activeBackground = StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ if (activeBackground?.CaptureKey == key)
+ BackgroundImage = bitmap;
- foreach (var backdrop in AllBackdrops().Where(b => b.CaptureKey == key))
- backdrop.VideoImageSource = bitmap;
+ foreach (var background in AllBackgrounds().Where(b => b.CaptureKey == key))
+ background.VideoImageSource = bitmap;
}
// EVENT_SYSTEM_FOREGROUND fires whenever the foreground window changes.
// When a full-screen game appears on a different monitor than the current
- // backdrop, auto-redesignate. When ShowDesktop is enabled and no fullscreen
+ // background, auto-redesignate. When ShowDesktop is enabled and no fullscreen
// game is detected, fall back to the primary monitor (desktop view).
private void OnFullscreenMonitorChanged(int? monitor)
{
if (monitor != null)
{
var newKey = $"{MonitorKeyPrefix}{monitor}";
- var current = AllBackdrops().FirstOrDefault();
+ var current = AllBackgrounds().FirstOrDefault();
if (current?.CaptureKey == newKey) return;
- _ = RedesignateBackdropAsync(newKey);
+ _ = RedesignateBackgroundAsync(newKey);
}
- else if (AllBackdrops().Any(b => b.ShowDesktop))
+ else if (AllBackgrounds().Any(b => b.ShowDesktop))
{
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
- var current = AllBackdrops().FirstOrDefault();
+ var current = AllBackgrounds().FirstOrDefault();
if (current?.CaptureKey == desktopKey) return;
- _ = RedesignateBackdropAsync(desktopKey);
+ _ = RedesignateBackgroundAsync(desktopKey);
}
}
// Manual refresh: the "Refresh Capture" context menu item. Reads the
// current foreground directly (no caching needed — the hook handles
// automatic detection).
- public void RefreshBackdropAutoCapture()
+ public void RefreshBackgroundAutoCapture()
{
var detected = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (detected == null) return;
- _ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{detected}");
+ _ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{detected}");
}
- /// Whether the active backdrop is in Show Desktop mode.
+ /// Whether the active background is in Show Desktop mode.
/// Bindable IsChecked property for the context menu toggle.
- public bool BackdropShowDesktop
+ public bool BackgroundShowDesktop
{
- get => ActiveScene?.Elements.OfType().FirstOrDefault(s => s.IsBackdrop)?.ShowDesktop ?? false;
+ get => StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBackground)?.ShowDesktop ?? false;
set
{
- var backdrop = ActiveScene?.Elements.OfType().FirstOrDefault(s => s.IsBackdrop);
- if (backdrop == null || backdrop.ShowDesktop == value) return;
- backdrop.ShowDesktop = value;
- OnPropertyChanged(nameof(BackdropShowDesktop));
+ var background = StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ if (background == null || background.ShowDesktop == value) return;
+ background.ShowDesktop = value;
+ OnPropertyChanged(nameof(BackgroundShowDesktop));
ScheduleSave();
if (value)
{
- _ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}");
+ _ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}");
}
else
{
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (gameMonitor != null)
- _ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{gameMonitor}");
+ _ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{gameMonitor}");
else
- _ = ClearBackdropCaptureAsync();
+ _ = ClearBackgroundCaptureAsync();
}
}
}
- private void ToggleBackdropShowDesktop() => BackdropShowDesktop = !BackdropShowDesktop;
+ ///
+ /// The custom background file path for the staged scene, shown in the properties panel textbox.
+ /// Null means the scene is using the app default background.
+ ///
+ public string? CustomBackgroundPath => StagedScene?.CustomBackgroundPath;
- /// Release the backdrop capture so the static placeholder image shows.
- private async Task ClearBackdropCaptureAsync()
+ ///
+ /// Whether the staged scene uses the shipped default background. When true,
+ /// CustomBackgroundPath is ignored but preserved. Toggled by the pill-switch.
+ ///
+ public bool UseDefaultBackground
{
- var oldKeys = AllBackdrops()
+ get => StagedScene?.UseDefaultBackground ?? true;
+ set
+ {
+ if (StagedScene == null) return;
+ StagedScene.UseDefaultBackground = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(CustomBackgroundPath));
+ UpdateBackgroundImage();
+ RefreshSnapshotsAsync();
+ ScheduleSave();
+ }
+ }
+
+ private void ToggleBackgroundShowDesktop() => BackgroundShowDesktop = !BackgroundShowDesktop;
+
+ private void BrowseBackground()
+ {
+ var scene = StagedScene;
+ if (scene == null) return;
+ var bg = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
+ if (bg == null) return;
+
+ var dlg = new Microsoft.Win32.OpenFileDialog
+ {
+ Title = "Select background image",
+ Filter = "Image files (*.jpg;*.jpeg;*.png;*.bmp)|*.jpg;*.jpeg;*.png;*.bmp|All files (*.*)|*.*",
+ };
+ if (dlg.ShowDialog() != true) return;
+
+ scene.CustomBackgroundPath = dlg.FileName;
+ scene.UseDefaultBackground = false;
+ try
+ {
+ using var fs = new System.IO.FileStream(dlg.FileName, System.IO.FileMode.Open, System.IO.FileAccess.Read);
+ var bytes = new byte[fs.Length];
+ fs.Read(bytes, 0, bytes.Length);
+ var assetId = AddAsset(bytes);
+ if (assetId != null)
+ bg.AssetId = assetId;
+ }
+ catch { /* non-critical */ }
+
+ UpdateActiveBackground();
+ RefreshSnapshotsAsync();
+ ScheduleSave();
+ }
+
+ /// Release the background capture so the static placeholder image shows.
+ private async Task ClearBackgroundCaptureAsync()
+ {
+ var oldKeys = AllBackgrounds()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
- foreach (var backdrop in AllBackdrops())
+ foreach (var background in AllBackgrounds())
{
- backdrop.CaptureKey = null;
- backdrop.VideoImageSource = null;
+ background.CaptureKey = null;
+ background.VideoImageSource = null;
}
foreach (var key in oldKeys)
await _screenCaptureManager.ReleaseAllAsync(key);
_liveCaptureKeys.Clear();
- UpdateBackdropImage();
+ UpdateBackgroundImage();
ScheduleSave();
}
- private async Task RedesignateBackdropAsync(string newKey)
+ private async Task RedesignateBackgroundAsync(string newKey)
{
- var oldKeys = AllBackdrops()
+ var oldKeys = AllBackgrounds()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
- foreach (var backdrop in AllBackdrops())
- backdrop.CaptureKey = newKey;
+ foreach (var background in AllBackgrounds())
+ background.CaptureKey = newKey;
foreach (var oldKey in oldKeys.Where(k => k != newKey))
await _screenCaptureManager.ReleaseAllAsync(oldKey);
if (!oldKeys.Contains(newKey))
await _screenCaptureManager.AcquireAsync(newKey);
_liveCaptureKeys = new HashSet { newKey };
- UpdateBackdropImage();
+ UpdateBackgroundImage();
ScheduleSave();
}
// "Change Capture…": the OS GraphicsCapturePicker designates the target.
// Picks are transient (see ScreenCaptureSourceFactory) — a reload falls
// back to auto-detection.
- public async Task ChangeBackdropCaptureAsync()
+ public async Task ChangeBackgroundCaptureAsync()
{
var key = await _screenCaptureFactory.PickAsync();
if (key == null) return;
- await RedesignateBackdropAsync(key);
+ await RedesignateBackgroundAsync(key);
}
- // In-app display picker: point every backdrop at a specific monitor.
- private void SetBackdropCapture(DisplayInfo? display)
+ // In-app display picker: point every background at a specific monitor.
+ private void SetBackgroundCapture(DisplayInfo? display)
{
if (display == null) return;
- _ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{display.Index}");
+ _ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{display.Index}");
}
public void Shutdown()
@@ -1871,6 +2132,8 @@ public class MainViewModel : ViewModelBase
try
{
_layoutStore.Save(Scenes, _webcam, _socials, _music);
+ _layoutStore.SaveTransitionSettings(
+ SelectedTransitionType.ToString(), TransitionDurationMs);
}
catch (Exception ex)
{
@@ -1883,7 +2146,7 @@ public class MainViewModel : ViewModelBase
var dialog = new SaveFileDialog
{
Title = "Save Layout As",
- Filter = "ytLlive layout (*.yll)|*.yll|All files (*.*)|*.*",
+ Filter = "LlamaCasty layout (*.yll)|*.yll|All files (*.*)|*.*",
DefaultExt = ".yll",
FileName = "my-layout.yll",
};
@@ -1900,7 +2163,7 @@ public class MainViewModel : ViewModelBase
var dialog = new OpenFileDialog
{
Title = "Open Layout",
- Filter = "ytLlive layout (*.yll;*.db)|*.yll;*.db|All files (*.*)|*.*",
+ Filter = "LlamaCasty layout (*.yll;*.db)|*.yll;*.db|All files (*.*)|*.*",
};
if (dialog.ShowDialog() != true) return;
@@ -1952,14 +2215,14 @@ public class MainViewModel : ViewModelBase
if (e.OldItems != null)
foreach (SceneElement element in e.OldItems)
element.PropertyChanged -= OnElementPropertyChanged;
- OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
+ OnPropertyChanged(nameof(CanAddWebcamToStagedScene));
ScheduleSave();
}
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
- OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
+ OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
ScheduleSave();
}
@@ -1971,7 +2234,7 @@ public class MainViewModel : ViewModelBase
}
// Adds a canonical scene by name — only when it's actually missing. The
- // backdrop flag follows the policy (Live yes, everyone else no).
+ // background flag follows the policy (Live yes, everyone else no).
private void AddScene(string name)
{
if (!SceneCatalog.IsCanonical(name)) return;
@@ -1980,14 +2243,14 @@ public class MainViewModel : ViewModelBase
{
Name = name.Trim(),
IsChatScene = SceneCatalog.IsChat(name),
- HasBackdrop = SceneCatalog.HasBackdrop(name),
+ HasBackground = SceneCatalog.HasBackground(name),
};
- EnsureBackdrop(scene);
+ EnsureBackground(scene);
Scenes.Add(scene);
- ActiveScene = scene;
+ StagedScene = scene;
}
- private void SeedStartingBackdrop()
+ private void SeedStartingBackground()
{
try
{
@@ -1995,7 +2258,7 @@ public class MainViewModel : ViewModelBase
if (starting == null) return;
if (starting.Elements.OfType().Any(s => s.Type == SourceType.Background)) return;
- var uri = new Uri("pack://application:,,,/Assets/starting-backdrop.jpg", UriKind.Absolute);
+ var uri = new Uri("pack://application:,,,/Assets/starting-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
@@ -2018,17 +2281,17 @@ public class MainViewModel : ViewModelBase
///
/// When switching to a scene that has no background, seed the default.
///
- private void EnsureDefaultBackdrop(Scene? scene)
+ private void EnsureDefaultBackground(Scene? scene)
{
if (scene == null) return;
if (scene.Elements.OfType().Any(s => s.Type == SourceType.Background)) return;
string? asset = scene.Name switch
{
- "Starting" => "starting-backdrop.jpg",
- "BRB" => "brb-backdrop.jpg",
- "Ending" => "ending-backdrop.jpg",
- "Chat" => "chat-backdrop.jpg",
+ "Starting" => "starting-background.jpg",
+ "BRB" => "brb-background.jpg",
+ "Ending" => "ending-background.jpg",
+ "Chat" => "chat-background.jpg",
_ => null,
};
if (asset == null) return;
@@ -2056,14 +2319,14 @@ public class MainViewModel : ViewModelBase
catch { /* non-critical */ }
}
- private void SeedBrbBackdrop()
+ private void SeedBrbBackground()
{
try
{
var brb = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "BRB"));
if (brb == null) return;
- var uri = new Uri("pack://application:,,,/Assets/brb-backdrop.jpg", UriKind.Absolute);
+ var uri = new Uri("pack://application:,,,/Assets/brb-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
@@ -2094,16 +2357,16 @@ public class MainViewModel : ViewModelBase
catch { /* non-critical — stream works fine without a default bg */ }
}
- private void SeedLiveBackdropAsset()
+ private void SeedLiveBackgroundAsset()
{
try
{
var live = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Live"));
if (live == null) return;
- var backdrop = live.Elements.OfType().FirstOrDefault(s => s.IsBackdrop);
- if (backdrop == null) return;
+ var background = live.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ if (background == null) return;
- var uri = new Uri("pack://application:,,,/Assets/live-backdrop.jpg", UriKind.Absolute);
+ var uri = new Uri("pack://application:,,,/Assets/live-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
@@ -2111,21 +2374,21 @@ public class MainViewModel : ViewModelBase
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
- backdrop.AssetId = assetId;
- backdrop.Width = 1920;
- backdrop.Height = 1080;
+ background.AssetId = assetId;
+ background.Width = 1920;
+ background.Height = 1080;
}
catch { /* non-critical — stream works fine without a default bg */ }
}
- private void SeedEndingBackdrop()
+ private void SeedEndingBackground()
{
try
{
var ending = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Ending"));
if (ending == null) return;
- var uri = new Uri("pack://application:,,,/Assets/ending-backdrop.jpg", UriKind.Absolute);
+ var uri = new Uri("pack://application:,,,/Assets/ending-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
@@ -2156,14 +2419,14 @@ public class MainViewModel : ViewModelBase
catch { /* non-critical — stream works fine without a default bg */ }
}
- private void SeedChatBackdrop()
+ private void SeedChatBackground()
{
try
{
var chat = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Chat"));
if (chat == null) return;
- var uri = new Uri("pack://application:,,,/Assets/chat-backdrop.jpg", UriKind.Absolute);
+ var uri = new Uri("pack://application:,,,/Assets/chat-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
@@ -2204,13 +2467,13 @@ public class MainViewModel : ViewModelBase
{
if (element == null) return;
element.IsVisible = !element.IsVisible;
- if (element is Source { IsBackdrop: true })
- OnPropertyChanged(nameof(BackdropVisible));
+ if (element is Source { IsBackground: true })
+ OnPropertyChanged(nameof(BackgroundVisible));
}
private void AddSource(object? parameter)
{
- var scene = ActiveScene;
+ var scene = StagedScene;
if (scene == null) return;
var sourceType = parameter is SourceType typed
@@ -2231,7 +2494,7 @@ public class MainViewModel : ViewModelBase
if (sourceType == SourceType.Background)
{
- var bytes = PickImageBytes("Choose a backdrop image");
+ var bytes = PickImageBytes("Choose a background image");
if (bytes == null) return;
var assetId = AddAsset(bytes);
if (assetId == null) return;
@@ -2306,9 +2569,9 @@ public class MainViewModel : ViewModelBase
// another scene still used it). Picking a different camera than the current
// app-wide one swaps it everywhere, so the single-identity model stays honest;
// each scene's placement config is independent (webcam.{scene}.config).
- private async Task AddWebcamToActiveSceneAsync()
+ private async Task AddWebcamToStagedSceneAsync()
{
- var scene = ActiveScene;
+ var scene = StagedScene;
if (scene == null || scene.WebcamConfig != null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
@@ -2355,7 +2618,7 @@ public class MainViewModel : ViewModelBase
{
MessageBox.Show(
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
- "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
+ "LlamaCasty", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
@@ -2379,7 +2642,7 @@ public class MainViewModel : ViewModelBase
{
MessageBox.Show(
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
- "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
+ "LlamaCasty", MessageBoxButton.OK, MessageBoxImage.Warning);
break;
}
}
@@ -2404,9 +2667,9 @@ public class MainViewModel : ViewModelBase
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
// existing (hidden) config — the config row survives Hide in this scene — or
// adds the webcam here for the first time (which opens the camera picker).
- private void ShowWebcamInActiveScene()
+ private void ShowWebcamInStagedScene()
{
- var scene = ActiveScene;
+ var scene = StagedScene;
if (scene == null) return;
var config = scene.WebcamConfig;
@@ -2417,12 +2680,12 @@ public class MainViewModel : ViewModelBase
return;
}
- _ = AddWebcamToActiveSceneAsync();
+ _ = AddWebcamToStagedSceneAsync();
}
private void AddImage()
{
- var scene = ActiveScene;
+ var scene = StagedScene;
if (scene == null) return;
var candidates = BuildImageCandidates();
@@ -2480,7 +2743,7 @@ public class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
- MessageBox.Show($"Couldn't read that image: {ex.Message}", "ytLlive",
+ MessageBox.Show($"Couldn't read that image: {ex.Message}", "LlamaCasty",
MessageBoxButton.OK, MessageBoxImage.Warning);
return null;
}
@@ -2511,7 +2774,7 @@ public class MainViewModel : ViewModelBase
private void AddReusedImage(string assetId)
{
- var scene = ActiveScene;
+ var scene = StagedScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
@@ -2540,9 +2803,9 @@ public class MainViewModel : ViewModelBase
// again instead of silently resurrecting the old camera.
private void RemoveElement(SceneElement? element)
{
- var scene = ActiveScene;
+ var scene = StagedScene;
if (scene == null || element == null) return;
- if (element is Source { IsBackdrop: true }) return;
+ if (element is Source { IsBackground: true }) return;
if (element is WebcamSceneConfig && _webcam != null)
{
if (SelectedElement == element)
@@ -2556,8 +2819,8 @@ public class MainViewModel : ViewModelBase
{
_webcam = null;
OnPropertyChanged(nameof(CanChangeWebcam));
- OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
- OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
+ OnPropertyChanged(nameof(CanAddWebcamToStagedScene));
+ OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
@@ -2610,9 +2873,9 @@ public class MainViewModel : ViewModelBase
var body = string.Join(Environment.NewLine,
BugReportText.Trim(),
string.Empty,
- $"ytLlive {AppVersionLabel}",
+ $"LlamaCasty {AppVersionLabel}",
string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
- ComposeEmail("[ytLlive Bug Report]", body);
+ ComposeEmail("[LlamaCasty Bug Report]", body);
ShowOverlay();
}
@@ -2621,9 +2884,9 @@ public class MainViewModel : ViewModelBase
var body = string.Join(Environment.NewLine,
FeatureRequestText.Trim(),
string.Empty,
- $"ytLlive {AppVersionLabel}",
+ $"LlamaCasty {AppVersionLabel}",
string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
- ComposeEmail("[ytLlive Feature Request]", body);
+ ComposeEmail("[LlamaCasty Feature Request]", body);
ShowOverlay();
}
@@ -2648,7 +2911,7 @@ public class MainViewModel : ViewModelBase
var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
if (channel == null)
{
- MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
+ MessageBox.Show("Sign-in was unsuccessful. Please try again.", "LlamaCasty",
MessageBoxButton.OK, MessageBoxImage.Warning);
return null;
}
@@ -2659,7 +2922,7 @@ public class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
- MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
+ MessageBox.Show($"Sign-in failed: {ex.Message}", "LlamaCasty",
MessageBoxButton.OK, MessageBoxImage.Error);
return null;
}
@@ -2679,8 +2942,8 @@ public class MainViewModel : ViewModelBase
StopMockChatPreview();
// Switch ChatBox previews from mock to real messages
- if (ActiveScene != null)
- foreach (var chatBox in ActiveScene.Elements.OfType().Where(s => s.Type == SourceType.ChatBox))
+ if (StagedScene != null)
+ foreach (var chatBox in StagedScene.Elements.OfType().Where(s => s.Type == SourceType.ChatBox))
UpdateChatBoxPreview(chatBox);
});
}
@@ -2722,8 +2985,8 @@ public class MainViewModel : ViewModelBase
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
- ? "ytLlive"
- : $"{dialog.StreamTitle} — ytLlive";
+ ? "LlamaCasty"
+ : $"{dialog.StreamTitle} — LlamaCasty";
StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
@@ -2845,7 +3108,7 @@ public class MainViewModel : ViewModelBase
private void StopStream()
{
StreamStatus = StreamStatus.Offline;
- WindowTitle = "ytLlive";
+ WindowTitle = "LlamaCasty";
ResetHealth(StreamStatus.Offline);
// Stop chat polling first (TASK 9.4) — no more messages arriving while
// we're tearing down the stream.
@@ -2929,7 +3192,7 @@ public class MainViewModel : ViewModelBase
}
// Scene-element → latest frame, for the live compositor. The map mirrors the
- // preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey,
+ // preview: webcam by DeviceId, live captures (the background) by CaptureKey,
// images/background by AssetId. A null frame leaves the element transparent.
private VideoFrame? ResolveOutputFrame(SceneElement element)
{
diff --git a/ai.md b/ai.md
index 0c7f04f..a3c933a 100644
--- a/ai.md
+++ b/ai.md
@@ -14,6 +14,24 @@ Skip feature pitch, step-by-step implementation plans, pros/cons tables, and
"potential pitfalls" sections unless the user explicitly asks for a plan first.
A short diff-style summary beats a proposal document every time.
+## LAN infrastructure (192.168.50.x)
+
+All LAN credentials live in `CREDENTIALS.md` (gitignored). Server IPs and roles:
+
+| Host | IP | Role |
+|------|----|------|
+| corsair | .132 | Gaming/streaming primary — RTX 5070 Ti (this machine) |
+| llamavault | .86 | ODROID-HC4, 12TB RAID5, media storage (NFS). SSH: `ssh mshallop@192.168.50.86` |
+| pivault | .158 | RPi5 8GB, 10.83TB RAID5 NAS |
+| jarvis | .210 | AMD Ryzen 5 5600X, RX 6600 XT — AI inference, SearXNG, Docker registry, Immich |
+| ultron | .108 | AMD Ryzen 7 7840HS — AI orchestrator, llama-server, Qdrant |
+| gordito | .144 | Home Assistant, Alarmo, Zigbee |
+| officerfriendly | .26 | Pi-hole DNS |
+| octopi | .197 | OctoPrint (Prusa MK4) |
+| BigBlinkyRouter | .1 | ASUS ROG GT-BE98 Pro, WiFi 7 |
+
+Web infra (DO droplet): `llamacasty.com` + `llamachile.tube` at `143.244.176.131`.
+
### No-Fluff Mode (on demand)
Invoke with "no-fluff mode" (or similar) when you want ruthless review instead
@@ -70,13 +88,13 @@ Good Dog Rule: ONE integration test per branch, ONE test per PR. Current: TokenS
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip,
-LayoutStore backdrop roundtrip, LayoutStore HasBackdrop roundtrip + v5→v6 non-Live backfill,
+LayoutStore background roundtrip, LayoutStore HasBackground roundtrip + v5→v6 non-Live backfill,
ScreenCaptureManager refcount + shared-bitmap + coalescing
-(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackdropTests (EnsureBackdrop
-insert/idempotent/heal + HasBackdrop gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests
-(the five canonical scenes, Live-only backdrop policy, EnforceBackdropPolicy), WebcamSafeguardTests
+(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackgroundTests (EnsureBackground
+insert/idempotent/heal + HasBackground gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests
+(the five canonical scenes, Live-only background policy, EnforceBackgroundPolicy), WebcamSafeguardTests
(the per-scene size clamp incl. the Chat half-screen-area cap), SceneCompositorTests (the full-scene
-composite integration test: backdrop + round webcam + mirrored/bordered images + flash; the vertical
+composite integration test: background + round webcam + mirrored/bordered images + flash; the vertical
tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear), FfmpegLocatorTests
(the PATH → cache → download decision ladder with a fake downloader serving a real in-memory zip; shared
build DLL extraction) —
@@ -134,13 +152,30 @@ C# / WPF (.NET 8) following MVVM:
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) shipped — steps 1-7 (Velopack bootstrap, PolarLicenseService, license entry UI, LayoutStore entitlement, BrandFlash toggle, startup re-validation, PremiumUrl wired); Velopack update URL pending:
- TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates
- - TASK 19: Scene transitions (Cut/Fade/Move/Custom) — required for professional polish
+ - TASK 19/23 merged: Control Surface UX — director's control room, transitions (Cut/Fade/Move), thumbnails above central monitor, edit mode offline only, right panel eliminated
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
- TASK 21: Media source (video file playback) — starting soon videos, BRB loops
- TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards
- - TASK 23: Studio mode (lightweight preview) — preview next scene before live
- These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in `TASKS.md`.
+### Control Surface UX (TASK 19/23 merged — active)
+
+The app is a **director's control surface**, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor.
+
+**Layout:** Two-column. Left panel = one panel, two states. Center = thumbnails above central monitor.
+
+- **Left panel:** When offline (edit mode) → LAYERS + PROPERTIES (same as current, minus SCREENS). When live/recording → YouTube Live Chat (entire panel replaced, never coexists with layers/properties). Mode is implicit (derived from `StreamStatus`), no toggle button.
+- **Thumbnails:** Five scene previews above the central monitor, 16:9, drag-reorderable. Click = stage (offline) or transition (live). Active scene gets brand-red glow border.
+- **Central monitor:** Shows staged scene (offline, editable in edit mode) or live output (live, read-only). During transitions: compositor-blended animation.
+- **Right panel:** Eliminated. Chat moved to left panel (live mode).
+- **Edit gating:** `IsEditMode` = `StreamStatus == Offline`. When live/recording: all drag/resize, context menus, right-click property adjustments disabled. Gun safety — no accidental edits mid-stream.
+
+**Scene reference split:** `ActiveScene` → `StagedScene` (central monitor target, editable offline) + `LiveScene` (encoder output). Go Live sets `LiveScene = StagedScene`. Thumbnail click sets `StagedScene` (offline) or fires transition (live).
+
+**Transitions:** `TransitionService` (new file) — Cut/Fade/Move with configurable duration (default 300ms). `SceneCompositor.RenderBlend()` blends two scenes' frames. `FramePump` feeds `LiveScene` to encoder; during transitions, feeds blended frames. Go Live always = Cut (clean start).
+
+**Thumbnails:** `Scene.Snapshot` property. `SceneCompositor.Render()` at 320×180 → `WriteableBitmap`. Only staged scene has live backdrop render; other 4 = static snapshots.
+
### Screen backdrop capture (TASK 3 ship task #1)
The backdrop is the **live desktop/game capture as a permanent, non-deletable bottom layer**
diff --git a/ytLive.Tests/BackdropTests.cs b/ytLive.Tests/BackgroundTests.cs
similarity index 62%
rename from ytLive.Tests/BackdropTests.cs
rename to ytLive.Tests/BackgroundTests.cs
index 93945e2..6b2d937 100644
--- a/ytLive.Tests/BackdropTests.cs
+++ b/ytLive.Tests/BackgroundTests.cs
@@ -5,67 +5,67 @@ using ytLive.ViewModels;
namespace ytLive.Tests;
///
-/// The backdrop is a permanent, non-deletable bottom layer holding live
+/// The background is a permanent, non-deletable bottom layer holding live
/// desktop/game capture, and it exists only in the canonical Live scene (see
-/// ). Every backdrop-enabled scene
-/// () is healed to have exactly one, inserted
+/// ). Every background-enabled scene
+/// () is healed to have exactly one, inserted
/// first; it is never reorderable or removable by the UI. Scenes without the
/// flag never get one.
///
-public class BackdropTests
+public class BackgroundTests
{
[Fact]
- public void EnsureBackdrop_OnEmptyScene_InsertsFullFrameBackdrop()
+ public void EnsureBackground_OnEmptyScene_InsertsFullFrameBackground()
{
- var scene = new Scene { Name = "S", HasBackdrop = true };
+ var scene = new Scene { Name = "S", HasBackground = true };
- var backdrop = MainViewModel.EnsureBackdrop(scene);
+ var background = MainViewModel.EnsureBackground(scene);
- Assert.NotNull(backdrop);
+ Assert.NotNull(background);
Assert.Single(scene.Elements);
- Assert.Same(backdrop, scene.Elements[0]);
- Assert.True(backdrop.IsBackdrop);
- Assert.True(backdrop.IsEnabled);
- Assert.Equal(SourceType.DisplayCapture, backdrop.Type);
- Assert.Equal(0, backdrop.X);
- Assert.Equal(0, backdrop.Y);
- Assert.Equal(1920, backdrop.Width);
- Assert.Equal(1080, backdrop.Height);
+ Assert.Same(background, scene.Elements[0]);
+ Assert.True(background.IsBackground);
+ Assert.True(background.IsEnabled);
+ Assert.Equal(SourceType.DisplayCapture, background.Type);
+ Assert.Equal(0, background.X);
+ Assert.Equal(0, background.Y);
+ Assert.Equal(1920, background.Width);
+ Assert.Equal(1080, background.Height);
}
[Fact]
- public void EnsureBackdrop_ExistingBackdrop_IsIdempotent()
+ public void EnsureBackground_ExistingBackground_IsIdempotent()
{
- var scene = new Scene { Name = "S", HasBackdrop = true };
- var first = MainViewModel.EnsureBackdrop(scene);
- var second = MainViewModel.EnsureBackdrop(scene);
+ var scene = new Scene { Name = "S", HasBackground = true };
+ var first = MainViewModel.EnsureBackground(scene);
+ var second = MainViewModel.EnsureBackground(scene);
Assert.Same(first, second);
Assert.Single(scene.Elements);
}
[Fact]
- public void EnsureBackdrop_HealsMissingBackdropAtIndexZero()
+ public void EnsureBackground_HealsMissingBackgroundAtIndexZero()
{
- var scene = new Scene { Name = "S", HasBackdrop = true };
+ var scene = new Scene { Name = "S", HasBackground = true };
var image = new Source { Name = "Logo", Type = SourceType.Image };
scene.Elements.Add(image);
- MainViewModel.EnsureBackdrop(scene);
+ MainViewModel.EnsureBackground(scene);
Assert.Equal(2, scene.Elements.Count);
- Assert.True(((Source)scene.Elements[0]).IsBackdrop);
+ Assert.True(((Source)scene.Elements[0]).IsBackground);
Assert.Same(image, scene.Elements[1]);
}
[Fact]
- public void EnsureBackdrop_SceneWithoutHasBackdrop_ReturnsNullAndInsertsNothing()
+ public void EnsureBackground_SceneWithoutHasBackground_ReturnsNullAndInsertsNothing()
{
- var scene = new Scene { Name = "Ending", HasBackdrop = false };
+ var scene = new Scene { Name = "Ending", HasBackground = false };
- var backdrop = MainViewModel.EnsureBackdrop(scene);
+ var background = MainViewModel.EnsureBackground(scene);
- Assert.Null(backdrop);
+ Assert.Null(background);
Assert.Empty(scene.Elements);
}
diff --git a/ytLive.Tests/FramePumpTests.cs b/ytLive.Tests/FramePumpTests.cs
index ab12acd..da2bd8f 100644
--- a/ytLive.Tests/FramePumpTests.cs
+++ b/ytLive.Tests/FramePumpTests.cs
@@ -59,10 +59,10 @@ public class FramePumpTests
public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
}
- private static Scene BackdropScene()
+ private static Scene BackgroundScene()
{
var scene = new Scene { Name = "Live" };
- scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" });
+ scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" });
return scene;
}
@@ -72,7 +72,7 @@ public class FramePumpTests
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
{
return new FramePump(
- sceneProvider: scene ?? (() => BackdropScene()),
+ sceneProvider: scene ?? (() => BackgroundScene()),
frameResolver: resolve ?? (_ => null),
compositorOptions: () => new CompositorOptions
{
@@ -166,7 +166,7 @@ public class FramePumpTests
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var encoder = new FakeEncoder();
using var pump = NewPump(encoder,
- resolve: e => e is Source { IsBackdrop: true } ? red : null);
+ resolve: e => e is Source { IsBackground: true } ? red : null);
await pump.StartAsync();
Assert.True(pump.IsRunning);
@@ -177,7 +177,7 @@ public class FramePumpTests
var frame = encoder.Frames[0];
Assert.Equal(64, frame.Width);
Assert.Equal(48, frame.Height);
- AssertColor(frame, 0, 0, 255, 0, 0); // the backdrop really was composited in
+ AssertColor(frame, 0, 0, 255, 0, 0); // the background really was composited in
await pump.StopAsync();
Assert.False(pump.IsRunning);
@@ -236,7 +236,7 @@ public class FramePumpTests
var encoder = new FakeEncoder();
var position = SocialBarPosition.Top;
using var pump = NewPump(encoder,
- resolve: e => e is Source { IsBackdrop: true } ? red : null,
+ resolve: e => e is Source { IsBackground: true } ? red : null,
socialBar: () => (bar, position));
await pump.StartAsync();
@@ -246,7 +246,7 @@ public class FramePumpTests
// Flip to Bottom: the pump re-reads the seam each frame, so a later frame
// lands the bar at the bottom edge (SourceRectHeight - bar height) and the
- // top corner clears back to backdrop.
+ // top corner clears back to background.
position = SocialBarPosition.Bottom;
VideoFrame? flipped = null;
var deadline = DateTime.UtcNow.AddSeconds(5);
diff --git a/ytLive.Tests/LayoutStorePersistenceTests.cs b/ytLive.Tests/LayoutStorePersistenceTests.cs
index 24e3fdf..02676e2 100644
--- a/ytLive.Tests/LayoutStorePersistenceTests.cs
+++ b/ytLive.Tests/LayoutStorePersistenceTests.cs
@@ -78,21 +78,21 @@ public class LayoutStorePersistenceTests
}
}
- // The backdrop (schema v5): permanent bottom layer holding live desktop/game
- // capture. IsBackdrop + CaptureKey must survive a save + reload so a
+ // The background (schema v5): permanent bottom layer holding live desktop/game
+ // capture. IsBackground + CaptureKey must survive a save + reload so a
// re-designated target isn't lost on restart.
[Fact]
- public void Backdrop_IsBackdrop_And_CaptureKey_Survive_Save_And_Reload()
+ public void Background_IsBackground_And_CaptureKey_Survive_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
- var backdrop = new Source
+ var background = new Source
{
Name = "Game Capture",
Type = SourceType.DisplayCapture,
- IsBackdrop = true,
+ IsBackground = true,
IsEnabled = true,
X = 0,
Y = 0,
@@ -102,13 +102,13 @@ public class LayoutStorePersistenceTests
CaptureKey = "monitor:1",
};
var scene = new Scene { Name = "Starting" };
- scene.Elements.Add(backdrop);
+ scene.Elements.Add(background);
store.Save(new[] { scene }, null, null);
var reloaded = store.Load();
var restored = Assert.IsType(Assert.Single(reloaded[0].Elements));
- Assert.True(restored.IsBackdrop);
+ Assert.True(restored.IsBackground);
Assert.Equal("monitor:1", restored.CaptureKey);
Assert.Equal(1, restored.MonitorIndex);
Assert.Equal(SourceType.DisplayCapture, restored.Type);
@@ -120,23 +120,23 @@ public class LayoutStorePersistenceTests
}
}
- // Per-scene backdrop switch (schema v6): a scene whose backdrop was turned
+ // Per-scene background switch (schema v6): a scene whose background was turned
// off must stay off across a save + reload.
[Fact]
- public void Scene_HasBackdrop_Survives_Save_And_Reload()
+ public void Scene_HasBackground_Survives_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
- var on = new Scene { Name = "Live", HasBackdrop = true };
- var off = new Scene { Name = "Ending", HasBackdrop = false };
+ var on = new Scene { Name = "Live", HasBackground = true };
+ var off = new Scene { Name = "Ending", HasBackground = false };
store.Save(new[] { on, off }, null, null);
var reloaded = store.Load();
- Assert.True(reloaded.First(s => s.Name == "Live").HasBackdrop);
- Assert.False(reloaded.First(s => s.Name == "Ending").HasBackdrop);
+ Assert.True(reloaded.First(s => s.Name == "Live").HasBackground);
+ Assert.False(reloaded.First(s => s.Name == "Ending").HasBackground);
}
finally
{
@@ -145,12 +145,12 @@ public class LayoutStorePersistenceTests
}
}
- // v5 → v6 one-time backfill: pre-v6 DBs already have backdrops sitting in the
- // non-Live canonical scenes (Starting/BRB/Chat/Ending). Adding the HasBackdrop
- // column turns those scenes off and drops their backdrop sources; Live keeps
- // its backdrop.
+ // v5 → v6 one-time backfill: pre-v6 DBs already have backgrounds sitting in the
+ // non-Live canonical scenes (Starting/BRB/Chat/Ending). Adding the HasBackground
+ // column turns those scenes off and drops their background sources; Live keeps
+ // its background.
[Fact]
- public void V5_Database_Backfill_Removes_NonLive_Backdrops()
+ public void V5_Database_Backfill_Removes_NonLive_Backgrounds()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
@@ -183,19 +183,19 @@ public class LayoutStorePersistenceTests
DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
- IsBackdrop INTEGER NOT NULL DEFAULT 0,
+ IsBackground INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0
);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-starting', 'Starting', 0);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-live', 'Live', 1);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-chat', 'Chat', 2);
- INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
- VALUES ('b-starting', 's-starting', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
- INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
- VALUES ('b-live', 's-live', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
- INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
- VALUES ('b-chat', 's-chat', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
+ INSERT INTO Source (Id, SceneId, Type, Name, IsBackground, CaptureKey, SortOrder)
+ VALUES ('b-starting', 's-starting', 'DisplayCapture', 'Background', 1, 'monitor:0', 0);
+ INSERT INTO Source (Id, SceneId, Type, Name, IsBackground, CaptureKey, SortOrder)
+ VALUES ('b-live', 's-live', 'DisplayCapture', 'Background', 1, 'monitor:0', 0);
+ INSERT INTO Source (Id, SceneId, Type, Name, IsBackground, CaptureKey, SortOrder)
+ VALUES ('b-chat', 's-chat', 'DisplayCapture', 'Background', 1, 'monitor:0', 0);
PRAGMA user_version = 5;
""";
cmd.ExecuteNonQuery();
@@ -205,16 +205,16 @@ public class LayoutStorePersistenceTests
var scenes = store.Load();
var starting = Assert.Single(scenes, s => s.Name == "Starting");
- Assert.False(starting.HasBackdrop);
- Assert.DoesNotContain(starting.Elements, e => e is Source { IsBackdrop: true });
+ Assert.False(starting.HasBackground);
+ Assert.DoesNotContain(starting.Elements, e => e is Source { IsBackground: true });
var chat = Assert.Single(scenes, s => s.Name == "Chat");
- Assert.False(chat.HasBackdrop);
- Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackdrop: true });
+ Assert.False(chat.HasBackground);
+ Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackground: true });
var live = Assert.Single(scenes, s => s.Name == "Live");
- Assert.True(live.HasBackdrop);
- Assert.Single(live.Elements, e => e is Source { IsBackdrop: true });
+ Assert.True(live.HasBackground);
+ Assert.Single(live.Elements, e => e is Source { IsBackground: true });
}
finally
{
diff --git a/ytLive.Tests/RoundClipInteractionTests.cs b/ytLive.Tests/RoundClipInteractionTests.cs
index b6d3001..3e0384f 100644
--- a/ytLive.Tests/RoundClipInteractionTests.cs
+++ b/ytLive.Tests/RoundClipInteractionTests.cs
@@ -48,7 +48,7 @@ public sealed class RoundClipInteractionTests
window.Show();
window.UpdateLayout();
- var scene = vm.ActiveScene!;
+ var scene = vm.StagedScene!;
var webcam = new WebcamSceneConfig
{
WebcamId = "test-camera",
diff --git a/ytLive.Tests/SceneCatalogTests.cs b/ytLive.Tests/SceneCatalogTests.cs
index 1dfa551..bf43737 100644
--- a/ytLive.Tests/SceneCatalogTests.cs
+++ b/ytLive.Tests/SceneCatalogTests.cs
@@ -7,7 +7,7 @@ namespace ytLive.Tests;
///
/// The app is a five-scene product: Starting/Live/BRB/Chat/Ending. Scenes are
/// worked on by name; you can delete them but never add beyond the set, and the
-/// live backdrop belongs to Live alone.
+/// live background belongs to Live alone.
///
public class SceneCatalogTests
{
@@ -40,38 +40,38 @@ public class SceneCatalogTests
[InlineData("BRB", false)]
[InlineData("Chat", false)]
[InlineData("Ending", false)]
- public void SceneCatalog_HasBackdrop_Only_For_Live(string name, bool expected)
+ public void SceneCatalog_HasBackground_Only_For_Live(string name, bool expected)
{
- Assert.Equal(expected, SceneCatalog.HasBackdrop(name));
+ Assert.Equal(expected, SceneCatalog.HasBackground(name));
}
[Fact]
- public void EnforceBackdropPolicy_Removes_Backdrop_From_NonLive_Scenes()
+ public void EnforceBackgroundPolicy_Removes_Background_From_NonLive_Scenes()
{
- var chat = new Scene { Name = "Chat", HasBackdrop = true };
- var live = new Scene { Name = "Live", HasBackdrop = true };
- var chatBackdrop = MainViewModel.EnsureBackdrop(chat)!;
- var liveBackdrop = MainViewModel.EnsureBackdrop(live)!;
- Assert.Contains(chat.Elements, e => ReferenceEquals(e, chatBackdrop));
+ var chat = new Scene { Name = "Chat", HasBackground = true };
+ var live = new Scene { Name = "Live", HasBackground = true };
+ var chatBackground = MainViewModel.EnsureBackground(chat)!;
+ var liveBackground = MainViewModel.EnsureBackground(live)!;
+ Assert.Contains(chat.Elements, e => ReferenceEquals(e, chatBackground));
- MainViewModel.EnforceBackdropPolicy(new[] { chat, live });
+ MainViewModel.EnforceBackgroundPolicy(new[] { chat, live });
- Assert.False(chat.HasBackdrop);
- Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackdrop: true });
- Assert.True(live.HasBackdrop);
- Assert.Contains(live.Elements, e => ReferenceEquals(e, liveBackdrop));
+ Assert.False(chat.HasBackground);
+ Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackground: true });
+ Assert.True(live.HasBackground);
+ Assert.Contains(live.Elements, e => ReferenceEquals(e, liveBackground));
}
[Fact]
- public void EnforceBackdropPolicy_Marks_Live_And_Clears_A_Pre_Policy_Database()
+ public void EnforceBackgroundPolicy_Marks_Live_And_Clears_A_Pre_Policy_Database()
{
- var starting = new Scene { Name = "Starting", HasBackdrop = true };
- MainViewModel.EnsureBackdrop(starting);
- var live = new Scene { Name = "Live", HasBackdrop = false };
+ var starting = new Scene { Name = "Starting", HasBackground = true };
+ MainViewModel.EnsureBackground(starting);
+ var live = new Scene { Name = "Live", HasBackground = false };
- MainViewModel.EnforceBackdropPolicy(new[] { starting, live });
+ MainViewModel.EnforceBackgroundPolicy(new[] { starting, live });
- Assert.False(starting.HasBackdrop);
- Assert.True(live.HasBackdrop);
+ Assert.False(starting.HasBackground);
+ Assert.True(live.HasBackground);
}
}
diff --git a/ytLive.Tests/SceneCompositorTests.cs b/ytLive.Tests/SceneCompositorTests.cs
index 14a6627..6b45920 100644
--- a/ytLive.Tests/SceneCompositorTests.cs
+++ b/ytLive.Tests/SceneCompositorTests.cs
@@ -8,7 +8,7 @@ namespace ytLive.Tests;
///
/// The output compositor (TASK 4 ship step 1): renders a scene into the encoder's
/// master frame, mirroring the XAML preview minus the editing chrome. The integration
-/// test composites a full scene (backdrop + round webcam + images + mirror + border)
+/// test composites a full scene (background + round webcam + images + mirror + border)
/// and asserts per-layer probe pixels; the vertical-tier test asserts the 9:16 crop
/// and upscale. Frames are pushed by the test — no capture managers involved.
///
@@ -60,11 +60,11 @@ public class SceneCompositorTests
[Fact]
public void Composite_FullScene_MasterPixels()
{
- var red = Solid(1920, 1080, 255, 0, 0); // backdrop
+ var red = Solid(1920, 1080, 255, 0, 0); // background
var green = Solid(1280, 720, 0, 255, 0); // webcam
var split = Split(100, 100, 0, 255, 255, 255, 0, 255); // image: left cyan, right magenta
- var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
+ var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
var roundWebcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
var imageA = new Source { Type = SourceType.Image, X = 1200, Y = 600, Width = 200, Height = 200 };
var imageB = new Source
@@ -74,7 +74,7 @@ public class SceneCompositorTests
};
var scene = new Scene { Name = "Live" };
- scene.Elements.Add(backdrop);
+ scene.Elements.Add(background);
scene.Elements.Add(roundWebcam);
scene.Elements.Add(imageA);
scene.Elements.Add(imageB);
@@ -82,7 +82,7 @@ public class SceneCompositorTests
VideoFrame? FrameFor(SceneElement e) => e switch
{
WebcamSceneConfig => green,
- Source { IsBackdrop: true } => red,
+ Source { IsBackground: true } => red,
Source { Type: SourceType.Image } => split,
_ => null,
};
@@ -98,11 +98,11 @@ public class SceneCompositorTests
Assert.Equal(1920, output.Width);
Assert.Equal(1080, output.Height);
- // backdrop at the frame corner
+ // background at the frame corner
AssertColor(output, 0, 0, 255, 0, 0);
// round webcam: center is the (square-cropped) webcam feed
AssertColor(output, 250, 250, 0, 255, 0);
- // round webcam: element-square corner is OUTSIDE the circle -> backdrop shows
+ // round webcam: element-square corner is OUTSIDE the circle -> background shows
AssertColor(output, 101, 101, 255, 0, 0);
// imageA (unmirrored): left half cyan, right half magenta
AssertColor(output, 1220, 700, 0, 255, 255);
@@ -120,16 +120,16 @@ public class SceneCompositorTests
var red = Solid(1920, 1080, 255, 0, 0);
var green = Solid(1280, 720, 0, 255, 0);
- var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
+ var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
var roundWebcam = new WebcamSceneConfig { X = 656, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
var scene = new Scene { Name = "Live" };
- scene.Elements.Add(backdrop);
+ scene.Elements.Add(background);
scene.Elements.Add(roundWebcam);
VideoFrame? FrameFor(SceneElement e) => e switch
{
WebcamSceneConfig => green,
- Source { IsBackdrop: true } => red,
+ Source { IsBackground: true } => red,
_ => null,
};
@@ -143,11 +143,11 @@ public class SceneCompositorTests
Assert.Equal(1080, output.Width);
Assert.Equal(1920, output.Height);
- // top-left of the crop is pure backdrop (webcam starts at crop y=100, inset from the corner)
+ // top-left of the crop is pure background (webcam starts at crop y=100, inset from the corner)
AssertColor(output, 0, 0, 255, 0, 0);
// the webcam center (crop 150,250) scales to output ~(267,444) and stays green
AssertColor(output, 267, 444, 0, 255, 0);
- // far from the webcam, still backdrop
+ // far from the webcam, still background
AssertColor(output, 978, 1688, 255, 0, 0);
}
@@ -155,9 +155,9 @@ public class SceneCompositorTests
public void Composite_WithFlash_BlendsOverContent()
{
var red = Solid(1920, 1080, 255, 0, 0);
- var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
+ var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
var scene = new Scene { Name = "Live" };
- scene.Elements.Add(backdrop);
+ scene.Elements.Add(background);
// flash: a semi-transparent white pixel at the center of a master-sized frame
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
@@ -185,9 +185,9 @@ public class SceneCompositorTests
public void Composite_WithSocialBar_OverlaysAboveFlash_AtTopOrBottom()
{
var red = Solid(1920, 1080, 255, 0, 0);
- var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
+ var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
var scene = new Scene { Name = "Live" };
- scene.Elements.Add(backdrop);
+ scene.Elements.Add(background);
var options = new CompositorOptions
{
@@ -210,11 +210,11 @@ public class SceneCompositorTests
var top = compositor.Render(scene, _ => red, flash, options, bar, 0);
AssertColor(top, 0, 0, 0, 255, 0); // bar above flash at the top-left
AssertColor(top, 960, 540, 255, 127, 127); // 50% white over red
- AssertColor(top, 100, 100, 255, 0, 0); // empty overlay area: backdrop
+ AssertColor(top, 100, 100, 255, 0, 0); // empty overlay area: background
var bottom = compositor.Render(scene, _ => red, flash, options, bar, 1040);
AssertColor(bottom, 0, 1040, 0, 255, 0); // bar drawn at the bottom edge
- AssertColor(bottom, 0, 1039, 255, 0, 0); // backdrop just above the bar
+ AssertColor(bottom, 0, 1039, 255, 0, 0); // background just above the bar
AssertColor(bottom, 960, 540, 255, 0, 0); // bar region moved away from center
}
}
diff --git a/ytLive.Tests/SourceNamingTests.cs b/ytLive.Tests/SourceNamingTests.cs
index eea943d..7f69ae3 100644
--- a/ytLive.Tests/SourceNamingTests.cs
+++ b/ytLive.Tests/SourceNamingTests.cs
@@ -38,7 +38,7 @@ public sealed class SourceNamingTests
var vm = (MainViewModel)window.DataContext;
try
{
- var scene = vm.ActiveScene!;
+ var scene = vm.StagedScene!;
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
diff --git a/ytLive.csproj b/ytLive.csproj
index 69ed644..df67d00 100644
--- a/ytLive.csproj
+++ b/ytLive.csproj
@@ -22,11 +22,11 @@
-
-
-
-
-
+
+
+
+
+