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
|
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 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="⭐" 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 "made with ytLlive!" flash. A subscription removes it and adds alerts."
|
||||
<TextBlock Text="Free forever — live streams carry a brief "made with LlamaCasty!" 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"
|
||||
|
||||
@@ -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--)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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:<index>" or
|
||||
/// "window:<hwnd>". Persisted so a re-designated backdrop survives a reload.
|
||||
/// "window:<hwnd>". Persisted so a re-designated background survives a reload.
|
||||
/// </summary>
|
||||
private string? _captureKey;
|
||||
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?";
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||