Control Surface UX: pill toggle, background rename, per-scene properties

- Sliding pill toggle (green ON / red OFF) replaces checkbox
- Geometry mismatch error for custom backgrounds
- Backdrop -> Background: C# rename, SQL column rename with migration
- Source.IsBackground: new -> override (fixes WPF binding)
- Legacy 'Backdrop' display name migrated to 'Background'
- Per-scene UseDefaultBackground + CustomBackgroundPath
- Asset files renamed backdrop -> background
- StagedScene/LiveScene split, thumbnail strip, edit gating
- TransitionService (Cut/Fade/Move)
- Context menu: Capture Desktop, Refresh Desktop
- Resolution badge removed, ON-AIR status light added
This commit is contained in:
2026-08-22 18:10:59 -07:00
parent e06bfa42a8
commit 4b9a8e515f
35 changed files with 1406 additions and 717 deletions

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

+81 -24
View File
@@ -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.
+1 -1
View File
@@ -7,7 +7,7 @@ namespace ytLive.Helpers;
/// <summary>
/// Maps bool → Visibility, collapsing nothing: true becomes <c>Hidden</c> (keeps
/// its layout slot) rather than <c>Collapsed</c> (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.
/// </summary>
public class HiddenBoolToVisibilityConverter : IValueConverter
+215 -251
View File
@@ -16,7 +16,8 @@
Loaded="MainWindow_Loaded"
Activated="MainWindow_Activated"
Deactivated="MainWindow_Deactivated"
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown">
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"
ContextMenuOpening="Window_ContextMenuOpening">
<Window.DataContext>
<vm:MainViewModel/>
@@ -95,19 +96,28 @@
<!-- Logo / Brand — clicking opens the in-app About overlay -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About ytLlive"
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About LlamaCasty"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="ytLlive" Foreground="#e94560" FontSize="22" FontWeight="Bold"/>
<TextBlock FontSize="22" FontWeight="Bold">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/>
</TextBlock>
</Button>
<!-- REC sign (center): dark offline, red live, darker red when private -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- ON-AIR indicator -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,16,0"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}">
<Ellipse Width="12" Height="12" Fill="#e94560" VerticalAlignment="Center"/>
<TextBlock Text="ON-AIR" FontSize="16" FontWeight="Bold"
Foreground="#e94560" VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
<TextBlock Text="REC" FontSize="16" FontWeight="Bold"
@@ -174,18 +184,79 @@
</Border>
<Grid Grid.Row="2" Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" MinWidth="220" MaxWidth="360"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="1*" MinWidth="300" MaxWidth="400"/>
</Grid.ColumnDefinitions>
<!-- LEFT PANEL: Scenes & Sources -->
<Border Grid.Column="0" Background="#16213e" CornerRadius="6" Padding="12">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" MinWidth="220" MaxWidth="360"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<!-- ═══ THUMBNAIL STRIP ═══ -->
<Border Grid.Row="0" Grid.ColumnSpan="2" Background="#0d1117" CornerRadius="4" Margin="0,0,0,4"
Padding="4">
<ItemsControl ItemsSource="{Binding Scenes}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" Columns="5"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border CornerRadius="4" Margin="2" Padding="2"
BorderThickness="2"
BorderBrush="Transparent"
Cursor="Hand"
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderBrush" Value="#333344"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsStaged}" Value="True">
<Setter Property="BorderBrush" Value="#e94560"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="BorderBrush" Value="#22c55e"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" CornerRadius="3"
Background="#0a0a1a" ClipToBounds="True">
<Image Source="{Binding Snapshot}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="LowQuality"/>
</Border>
<TextBlock Grid.Row="1" Text="{Binding Name}"
Foreground="#a0a0b0" FontSize="10"
HorizontalAlignment="Center"
Margin="0,2,0,0"
TextTrimming="CharacterEllipsis"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- LEFT PANEL: one panel, two states.
Offline (edit mode): LAYERS + PROPERTIES
Live/Recording: YouTube Live Chat -->
<Border Grid.Row="1" Grid.Column="0" Background="#16213e" CornerRadius="6" Padding="12">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Edit mode (offline): layers + properties -->
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
@@ -194,73 +265,15 @@
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Scenes: the app has five canonical scenes. Work with less,
never more — the "+" only offers the missing ones, and is
hidden entirely once all five are back. -->
<StackPanel Grid.Row="0" Orientation="Horizontal">
<TextBlock Text="SCREENS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
<Button ToolTip="Add Missing Screen" Click="AddSceneButton_Click"
Style="{StaticResource IconButton}" Margin="4,-4,0,0"
Visibility="{Binding ShowAddScene, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}"
ItemsSource="{Binding MissingScenes}">
<ContextMenu.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding}"/>
<Setter Property="Command"
Value="{Binding DataContext.AddSceneCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</ContextMenu.ItemContainerStyle>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
</StackPanel>
<Helpers:FocusPreservingListBox x:Name="SceneList" Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding Scenes}"
SelectedItem="{Binding ActiveScene}"
SelectionChanged="SceneList_SelectionChanged"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
PreviewMouseMove="List_PreviewMouseMove"
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
Foreground="#e0e0e0" FontSize="13">
<Helpers:FocusPreservingListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsHidden}" Value="True">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
<TextBlock Text="{Binding Name, Converter={x:Static models:SceneCatalog.DisplayNameConverter}}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</Helpers:FocusPreservingListBox>
<!-- Visual separation between the two listboxes: a hairline
with breathing room on either side (the creator's fix). -->
<Rectangle Grid.Row="2" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"/>
<!-- Sources (for active scene) -->
<StackPanel Grid.Row="3" Orientation="Horizontal" Margin="0,8,0,0">
<!-- LAYERS header + Add button -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="LAYERS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
<Button ToolTip="Add Layer" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
Click="AddSourceButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
IsEnabled="{Binding CanAddWebcamToActiveScene}"
IsEnabled="{Binding CanAddWebcamToStagedScene}"
ToolTip="One webcam at a time — it's already in your stream"/>
<MenuItem Header="Screen" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.DisplayCapture}"/>
<MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Background}"/>
@@ -280,8 +293,8 @@
</Button>
</StackPanel>
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="4" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ActiveScene.Elements}"
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding StagedScene.Elements}"
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
PreviewMouseMove="List_PreviewMouseMove"
@@ -309,13 +322,13 @@
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Capture…" Click="BackdropMenu_ChangeCapture"
Visibility="{Binding IsBackdrop, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Refresh Capture" Click="BackdropMenu_RefreshCapture"
Visibility="{Binding IsBackdrop, Converter={StaticResource BoolToVis}}"/>
<Separator Visibility="{Binding IsBackdrop, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture"
Visibility="{Binding IsBackground, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture"
Visibility="{Binding IsBackground, Converter={StaticResource BoolToVis}}"/>
<Separator Visibility="{Binding IsBackground, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Remove Layer" Click="SourceMenu_Remove"
Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}"/>
Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
</ContextMenu>
</Grid.ContextMenu>
@@ -367,7 +380,7 @@
<Button Grid.Column="3" ToolTip="Remove Layer"
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
Visibility="{Binding IsBackdrop, Converter={StaticResource HiddenBoolToVis}}">
Visibility="{Binding IsBackground, Converter={StaticResource HiddenBoolToVis}}">
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
@@ -376,19 +389,19 @@
</ListBox.ItemTemplate>
</Helpers:FocusPreservingListBox>
<TextBlock Grid.Row="4" Text="No layers yet" Foreground="#555" FontSize="12"
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="#555" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
<!-- ELEMENTS panel: per-element config beneath Sources.
Collapses entirely when no element is selected. -->
<Rectangle Grid.Row="5" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"
<Rectangle Grid.Row="3" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}"/>
<StackPanel Grid.Row="6" Orientation="Horizontal" Margin="0,8,0,0"
<StackPanel Grid.Row="4" Orientation="Horizontal" Margin="0,8,0,0"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
</StackPanel>
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="7" VerticalScrollBarVisibility="Auto" Background="Transparent"
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="5" VerticalScrollBarVisibility="Auto" Background="Transparent"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<StackPanel x:Name="ElementsPanel" Margin="0,4,0,0">
<!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
@@ -592,29 +605,109 @@
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
</StackPanel>
<!-- Placeholder hint when no config available -->
<TextBlock Text="No configurable properties" Foreground="#555" FontSize="11"
HorizontalAlignment="Center" Margin="0,8,0,0"
Visibility="{Binding IsElementsPanelEmpty, Converter={StaticResource BoolToVis}}"/>
<!-- Background properties -->
<StackPanel Margin="0,8,0,0"
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
<TextBlock Text="BACKGROUND" Style="{StaticResource SectionHeader}"/>
<ToggleButton Style="{StaticResource PillToggle}"
IsChecked="{Binding UseDefaultBackground, Mode=TwoWay}"
Margin="0,0,0,8"/>
<Grid Margin="0,0,0,4"
Visibility="{Binding UseDefaultBackground, Converter={StaticResource InverseBoolToVis}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding CustomBackgroundPath, Mode=OneWay}"
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
FontSize="11" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
</Grid>
<TextBlock Text="{Binding BackgroundSizeMismatchText}"
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap"
Margin="0,4,0,0"
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
<!-- CENTER: Preview Area -->
<Border Grid.Column="1" Background="#0a0a1a" CornerRadius="6" Margin="8,0"
BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}">
<Grid>
<!-- Live mode: YouTube Live Chat (entire left panel replaced) -->
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding ActiveScene.Name}"
Foreground="#e0e0e0" FontSize="15" FontWeight="SemiBold"
Margin="14,12,14,0"/>
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
<Grid x:Name="PreviewGrid" Grid.Row="1"
<!-- Inactive overlay (shown when NOT live) -->
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Chat is inactive" Foreground="#a0a0b0" FontSize="13"
HorizontalAlignment="Center" Margin="0,0,0,4"/>
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
Foreground="#666" FontSize="11" TextWrapping="Wrap"
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
</StackPanel>
<!-- Message feed (shown when live) -->
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ChatMessages}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
x:Name="ChatListBox">
<ListBox.ItemTemplate><DataTemplate>
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
<Setter Property="Background" Value="#2a1a0a"/>
<Setter Property="BorderBrush" Value="#eab308"/>
<Setter Property="BorderThickness" Value="1,0,1,0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<TextBlock Text="&#x2B50;" FontSize="10" Margin="0,0,4,0"
VerticalAlignment="Center"
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding AuthorName}" Foreground="#e94560"
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
Foreground="#eab308" FontWeight="Bold" FontSize="12"
Margin="8,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="#eab308"
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<TextBlock Text="{Binding Message}" Foreground="#e0e0e0"
TextWrapping="Wrap" Margin="0,2,0,0"/>
</StackPanel>
</Border>
</DataTemplate></ListBox.ItemTemplate>
</ListBox>
</Grid>
</Grid>
</Border>
<!-- CENTER: Preview Area -->
<Border Grid.Row="1" Grid.Column="1" Background="#0a0a1a" CornerRadius="6" Margin="8,0"
BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Preview monitor -->
<Grid x:Name="PreviewGrid" Grid.Row="0"
PreviewMouseLeftButtonDown="Preview_MouseLeftButtonDown"
PreviewMouseMove="Preview_MouseMove"
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp">
@@ -622,40 +715,36 @@
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Show Webcam" Command="{Binding ShowWebcamCommand}"/>
<MenuItem Header="Show Desktop" IsCheckable="True"
IsChecked="{Binding BackdropShowDesktop}"
Visibility="{Binding CanChangeBackdrop, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding BackgroundShowDesktop}"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
ToolTip="Show desktop when no fullscreen game is detected"/>
<MenuItem Header="Capture Display"
Visibility="{Binding CanChangeBackdrop, Converter={StaticResource BoolToVis}}"
<MenuItem Header="Capture Desktop"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
ItemsSource="{Binding Displays}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command"
Value="{Binding DataContext.SetBackdropDisplayCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
Value="{Binding DataContext.SetBackgroundDisplayCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<MenuItem Header="Change Capture…" Command="{Binding ChangeCaptureCommand}"
Visibility="{Binding CanChangeBackdrop, Converter={StaticResource BoolToVis}}"
ToolTip="Pick any window with the OS picker"/>
<MenuItem Header="Refresh Capture" Command="{Binding RefreshCaptureCommand}"
Visibility="{Binding CanChangeBackdrop, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Refresh Desktop" Command="{Binding RefreshCaptureCommand}"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
</ContextMenu>
</Grid.ContextMenu>
<!-- Live desktop/game backdrop (bottom layer; the static
background renders above it). -->
<Image Source="{Binding BackdropImage}" Stretch="UniformToFill"
Visibility="{Binding BackdropVisible, Converter={StaticResource BoolToVis}}"
<!-- Live desktop/game background (bottom layer; the static
image renders above it). -->
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"
IsHitTestVisible="False"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
IsHitTestVisible="False"/>
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
<ItemsControl ItemsSource="{Binding ActiveScene.Elements}"
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
Width="1920" Height="1080">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -681,10 +770,10 @@
<DataTrigger Binding="{Binding IsVisible}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<!-- The backdrop is a fixed full-frame layer (rendered separately,
<!-- The background is a fixed full-frame layer (rendered separately,
below the static background): never hit-testable, so it can't be
selected, dragged, or bring up the webcam context menu. -->
<DataTrigger Binding="{Binding IsBackdrop}" Value="True">
<DataTrigger Binding="{Binding IsBackground}" Value="True">
<Setter Property="IsHitTestVisible" Value="False"/>
</DataTrigger>
</Style.Triggers>
@@ -935,7 +1024,7 @@
</Grid.Style>
<Viewbox Stretch="Uniform" MaxWidth="1600" MaxHeight="800"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="made with ytLlive!" FontWeight="Bold" Foreground="White"
<TextBlock Text="made with LlamaCasty!" FontWeight="Bold" Foreground="White"
FontSize="180" TextAlignment="Center"/>
</Viewbox>
</Grid>
@@ -976,10 +1065,6 @@
</Canvas>
</Grid>
</Viewbox>
<Border HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,8,8,0"
Background="#CC16213e" CornerRadius="4" Padding="8,3" IsHitTestVisible="False">
<TextBlock Text="{Binding ResolutionBadgeText}" Foreground="#e0e0e0" FontSize="12"/>
</Border>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
Background="#CCe94560" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"
Visibility="{Binding WebcamError, Converter={StaticResource NotNullToVis}}">
@@ -1004,16 +1089,12 @@
TextWrapping="Wrap" MaxWidth="440" TextAlignment="Center"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
<!-- Desktop/game audio bar: overlaid at the bottom of the
preview, always visible. Meter + mute + volume control
live here; desktop audio itself is automatic WASAPI
loopback. Volume + mute are stream-honest AND scale the
TRAX music in the headphones, so the creator hears the
control work. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
<!-- ═══ TRAX METER (below preview, never overlaps) ═══ -->
<Border Grid.Row="2" HorizontalAlignment="Center" VerticalAlignment="Center"
Background="#D9000000" CornerRadius="6" Padding="12,8"
Margin="0,0,0,12">
Margin="0,4">
<StackPanel Orientation="Horizontal">
<!-- TRAX sits left of the "Desktop Audio" label
(the creator's pick): it rides the desktop
@@ -1083,121 +1164,6 @@
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>
<!-- RIGHT PANEL: Chat -->
<Border Grid.Column="2" Background="#16213e" CornerRadius="6" Padding="12">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
<StackPanel Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Chat is inactive" Foreground="#e0e0e0" FontSize="18"
FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Chat becomes active once ytLlive is connected and streaming on YouTube."
Foreground="#a0a0b0" FontSize="13" Margin="0,8,0,0"
TextWrapping="Wrap" MaxWidth="260" TextAlignment="Center"
HorizontalAlignment="Center"/>
</StackPanel>
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ChatMessages}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
Foreground="#e0e0e0" FontSize="12"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
x:Name="ChatListBox">
<ListBox.ItemTemplate>
<DataTemplate>
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4" Margin="0,1">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
<Setter Property="Background" Value="#2a1a0a"/>
<Setter Property="BorderBrush" Value="#eab308"/>
<Setter Property="BorderThickness" Value="1,0,1,0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<StackPanel Orientation="Horizontal">
<!-- Member badge -->
<TextBlock Text="⭐" FontSize="10" Margin="0,0,3,0"
VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsMember}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<!-- Author name -->
<TextBlock FontWeight="Bold" Foreground="#e94560"
Text="{Binding AuthorName, Mode=OneWay}"
VerticalAlignment="Center"/>
<!-- Super Chat amount -->
<TextBlock Foreground="#eab308" FontWeight="Bold"
FontSize="11" Margin="6,0,0,0"
VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
<Run Text="💰 $"/>
<Run Text="{Binding SuperChatAmount, Mode=OneWay, StringFormat=F2}"/>
<Run Text="{Binding SuperChatCurrency, Mode=OneWay}"/>
</TextBlock>
<!-- Timestamp -->
<TextBlock Foreground="#555" FontSize="10"
Margin="6,0,0,0" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Timestamp}" Value="{x:Null}">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
<TextBlock.Text>
<Binding Path="Timestamp" StringFormat="{}{0:HH:mm}"/>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<!-- Message -->
<TextBlock Text="{Binding Message, Mode=OneWay}"
Foreground="#e0e0e0" TextWrapping="Wrap"
Margin="0,2,0,0"/>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Grid>
@@ -1428,7 +1394,7 @@
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding BugReportEmail, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Opens a pre-filled email to the ytLlive team." Style="{StaticResource YtLabel}"/>
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
<Button Content="Send Report" Command="{Binding SubmitBugCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
</StackPanel>
@@ -1446,7 +1412,7 @@
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding FeatureRequestEmail, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Opens a pre-filled email to the ytLlive team." Style="{StaticResource YtLabel}"/>
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
<Button Content="Send Request" Command="{Binding SubmitFeatureCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
</StackPanel>
@@ -1457,16 +1423,14 @@
<!-- Hub view -->
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="100" Stretch="Uniform"
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="ytLlive" FontSize="22" FontWeight="Bold" Foreground="#e94560"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,16,0,0"/>
<TextBlock Text="Free forever — live streams carry a brief &quot;made with ytLlive!&quot; flash. A subscription removes it and adds alerts."
<TextBlock Text="Free forever — live streams carry a brief &quot;made with LlamaCasty!&quot; flash. A subscription removes it and adds alerts."
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,12,0,0"/>
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
+25 -27
View File
@@ -26,8 +26,6 @@ public partial class MainWindow : Window
_viewModel = (MainViewModel)DataContext;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
// When a focused scene is deleted, never land focus on a hidden scene.
SceneList.IsSelectable = item => 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--)
{
+54 -5
View File
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
public string? CustomBackgroundPath
{
get => _customBackgroundPath;
set => Set(ref _customBackgroundPath, value);
}
/// <summary>
/// When true, the shipped default background image is used and CustomBackgroundPath
/// is ignored (but preserved). Toggled by the pill-switch in the properties panel.
/// </summary>
public bool UseDefaultBackground
{
get => _useDefaultBackground;
set => Set(ref _useDefaultBackground, value);
}
public string Name
{
@@ -26,13 +63,13 @@ public class Scene : INotifyPropertyChanged
}
/// <summary>
/// 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).
/// </summary>
public bool HasBackdrop
public bool HasBackground
{
get => _hasBackdrop;
set => Set(ref _hasBackdrop, value);
get => _hasBackground;
set => Set(ref _hasBackground, value);
}
/// <summary>
@@ -56,6 +93,18 @@ public class Scene : INotifyPropertyChanged
public bool IsChatScene { get; init; }
private BitmapSource? _snapshot;
/// <summary>
/// Cached thumbnail snapshot of this scene for the control surface strip.
/// Updated by the compositor when the scene is not live-previewed.
/// </summary>
public BitmapSource? Snapshot
{
get => _snapshot;
set => Set(ref _snapshot, value);
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
+3 -3
View File
@@ -8,7 +8,7 @@ namespace ytLive.Models;
/// <summary>
/// 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.
/// </summary>
public static class SceneCatalog
@@ -47,6 +47,6 @@ public static class SceneCatalog
public static bool IsChat(string? name) => Is(name, Chat);
/// <summary>Only the Live scene carries the live desktop/game backdrop.</summary>
public static bool HasBackdrop(string? name) => Is(name, Live);
/// <summary>Only the Live scene carries the live desktop/game background.</summary>
public static bool HasBackground(string? name) => Is(name, Live);
}
+1
View File
@@ -61,6 +61,7 @@ public abstract class SceneElement : INotifyPropertyChanged
/// <summary>True only for Countdown-type sources. Gates timer properties panel.</summary>
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); }
+4 -4
View File
@@ -48,9 +48,9 @@ public class Source : SceneElement
public override bool IsWebSource => Type == SourceType.WebSource;
/// <summary>The permanent scene backdrop (live desktop/game capture). Never removable/reorderable.</summary>
private bool _isBackdrop;
public bool IsBackdrop { get => _isBackdrop; set => Set(ref _isBackdrop, value); }
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
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
/// <summary>
/// Identifies what this live-capture source points at: "monitor:&lt;index&gt;" or
/// "window:&lt;hwnd&gt;". Persisted so a re-designated backdrop survives a reload.
/// "window:&lt;hwnd&gt;". Persisted so a re-designated background survives a reload.
/// </summary>
private string? _captureKey;
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
+36 -8
View File
@@ -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 <see cref="Func{SceneElement, VideoFrame}"/> 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.
/// </summary>
public sealed class SceneCompositor
{
/// <summary>
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
/// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
/// front): live background (the scene's <c>IsBackground</c> source) → background image →
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
/// branding flash → social bar (a global overlay; spans the master width at
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
@@ -45,10 +45,10 @@ public sealed class SceneCompositor
var elements = scene.Elements;
var backdrop = elements.OfType<Source>().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<Source>().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<Source>().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);
}
/// <summary>
/// Render a cross-fade blend between two scenes. Used by <see cref="TransitionService"/>
/// 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).
/// </summary>
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);
}
/// <summary>
/// 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
+28 -4
View File
@@ -29,6 +29,8 @@ public sealed class FramePump : IDisposable
private readonly Action<string>? _log;
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
private readonly TransitionService? _transition;
private readonly Func<Scene?>? _fromSceneProvider;
private readonly SceneCompositor _compositor = new();
private readonly object _gate = new();
@@ -51,7 +53,9 @@ public sealed class FramePump : IDisposable
Func<IFfmpegEncoder> encoderFactory,
Action<string>? log = null,
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
TransitionService? transition = null,
Func<Scene?>? 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);
+117 -27
View File
@@ -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<ClipShape>(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();
+5 -5
View File
@@ -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));
+1 -1
View File
@@ -158,7 +158,7 @@ public sealed class ScreenCaptureManager : IDisposable
/// <summary>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.</summary>
/// the background from here.</summary>
public VideoFrame? GetLatestFrame(string key)
{
lock (_gate)
+1 -1
View File
@@ -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;
}
+154
View File
@@ -0,0 +1,154 @@
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// The three v1 transition types. Clicking a scene thumbnail triggers the selected
/// transition; Go Live always uses Cut for a clean start.
/// </summary>
public enum TransitionType
{
Cut,
Fade,
Move
}
/// <summary>
/// Manages the transition state between StagedScene and LiveScene. When a
/// transition is active, FramePump calls <see cref="BlendFrame"/> each tick
/// to get the blended output. When the transition completes, <see cref="Active"/>
/// becomes false and FramePump reverts to a direct LiveScene render.
/// </summary>
public sealed class TransitionService
{
private const double DefaultDurationMs = 300;
/// <summary>True while a transition is in progress.</summary>
public bool Active { get; private set; }
/// <summary>The transition type for the current in-progress transition.</summary>
public TransitionType CurrentType { get; private set; } = TransitionType.Cut;
/// <summary>Duration of the current transition in milliseconds.</summary>
public double DurationMs { get; private set; } = DefaultDurationMs;
/// <summary>Elapsed time in milliseconds (updated by caller).</summary>
public double ElapsedMs { get; private set; }
/// <summary>0.0 = fully "from" scene, 1.0 = fully "to" scene.</summary>
public float Progress => DurationMs <= 0 ? 1f
: Math.Clamp((float)(ElapsedMs / DurationMs), 0f, 1f);
/// <summary>
/// The "from" frame (captured at transition start). Populated by the caller
/// before starting the transition.
/// </summary>
public VideoFrame? FromFrame { get; private set; }
/// <summary>Begin a transition. Caller should capture FromFrame from the current LiveScene render.</summary>
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;
}
}
/// <summary>
/// Advance the transition clock. Returns true while the transition is still active.
/// </summary>
public bool Tick(double elapsedMs)
{
if (!Active) return false;
ElapsedMs += elapsedMs;
if (ElapsedMs >= DurationMs)
{
ElapsedMs = DurationMs;
Active = false;
return false;
}
return true;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
+2 -2
View File
@@ -92,8 +92,8 @@ public class YouTubeAuthService
var error = context.Request.QueryString["error"];
var html = error != null
? "<html><body style=\"font-family:sans-serif\"><h2>Sign-in failed</h2><p>You can close this window and return to ytLlive.</p></body></html>"
: "<html><body style=\"font-family:sans-serif\"><h2>Sign-in successful!</h2><p>You can close this window and return to ytLlive.</p></body></html>";
? "<html><body style=\"font-family:sans-serif\"><h2>Sign-in failed</h2><p>You can close this window and return to LlamaCasty.</p></body></html>"
: "<html><body style=\"font-family:sans-serif\"><h2>Sign-in successful!</h2><p>You can close this window and return to LlamaCasty.</p></body></html>";
var buffer = Encoding.UTF8.GetBytes(html);
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.ContentLength64 = buffer.Length;
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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?";
+9 -42
View File
@@ -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`.
---
+59
View File
@@ -555,4 +555,63 @@
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
<!-- Sliding pill toggle: ON = green, OFF = red (#ef4444) -->
<Style x:Key="PillToggle" TargetType="ToggleButton">
<Setter Property="Width" Value="48"/>
<Setter Property="Height" Value="24"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Background" Value="#ef4444"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Grid>
<Border x:Name="Track" Background="{TemplateBinding Background}"
CornerRadius="12" SnapsToDevicePixels="True">
<Ellipse x:Name="Thumb" Width="18" Height="18" Fill="White"
HorizontalAlignment="Left" Margin="3,0,0,0">
<Ellipse.Effect>
<DropShadowEffect BlurRadius="3" ShadowDepth="1" Opacity="0.35"/>
</Ellipse.Effect>
<Ellipse.RenderTransform>
<TranslateTransform x:Name="ThumbTransform" X="0"/>
</Ellipse.RenderTransform>
</Ellipse>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Track" Property="Background" Value="#22c55e"/>
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="ThumbTransform"
Storyboard.TargetProperty="X"
To="24" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="ThumbTransform"
Storyboard.TargetProperty="X"
To="0" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
File diff suppressed because it is too large Load Diff
+42 -7
View File
@@ -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**
@@ -5,67 +5,67 @@ using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// 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
/// <see cref="SceneCatalog"/>). Every backdrop-enabled scene
/// (<see cref="Scene.HasBackdrop"/>) is healed to have exactly one, inserted
/// <see cref="SceneCatalog"/>). Every background-enabled scene
/// (<see cref="Scene.HasBackground"/>) is healed to have exactly one, inserted
/// first; it is never reorderable or removable by the UI. Scenes without the
/// flag never get one.
/// </summary>
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);
}
+7 -7
View File
@@ -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);
+31 -31
View File
@@ -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<Source>(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
{
+1 -1
View File
@@ -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",
+21 -21
View File
@@ -7,7 +7,7 @@ namespace ytLive.Tests;
/// <summary>
/// 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.
/// </summary>
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);
}
}
+18 -18
View File
@@ -8,7 +8,7 @@ namespace ytLive.Tests;
/// <summary>
/// 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.
/// </summary>
@@ -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
}
}
+1 -1
View File
@@ -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);
+5 -5
View File
@@ -22,11 +22,11 @@
<ItemGroup>
<Resource Include="Assets\llamacasty-icon.png" />
<Resource Include="Assets\llamacasty-logo.jpg" />
<Resource Include="Assets\starting-backdrop.jpg" />
<Resource Include="Assets\brb-backdrop.jpg" />
<Resource Include="Assets\live-backdrop.jpg" />
<Resource Include="Assets\ending-backdrop.jpg" />
<Resource Include="Assets\chat-backdrop.jpg" />
<Resource Include="Assets\starting-background.jpg" />
<Resource Include="Assets\brb-background.jpg" />
<Resource Include="Assets\live-background.jpg" />
<Resource Include="Assets\ending-background.jpg" />
<Resource Include="Assets\chat-background.jpg" />
</ItemGroup>
<ItemGroup>