Multi-scene webcam (schema v3/v4): singleton Webcam + per-scene WebcamSceneConfig, right-click OBS-style border/context menu, persisted round-to-rect restore + legacy-square 16:9 heal, dark MenuItem template, tests (25 passing)

This commit is contained in:
2026-08-07 08:24:18 -07:00
parent f31ce9fdb7
commit e037ba027b
23 changed files with 1405 additions and 287 deletions
+7 -1
View File
@@ -51,8 +51,14 @@ conventions live here and in `ai.md`.
## Build ## Build
From WSL, ALWAYS use the Windows dotnet host — Linux `dotnet` re-downloads the
`windowsdesktop.app.*` packs over the slow 9p bridge and re-restores twice (WPF
`_wpftmp`), and `--no-restore` right after an interrupted restore produces bogus
`NETSDK1064` errors. See `ai.md` → Run for the exact commands and why.
```bash ```bash
dotnet build # Windows-only WPF; builds from WSL via EnableWindowsTargeting "/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
"/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
``` ```
Keep it at **0 warnings**. Running requires Windows. On a silent startup crash, Keep it at **0 warnings**. Running requires Windows. On a silent startup crash,
+14
View File
@@ -0,0 +1,14 @@
using System.Globalization;
using System.Windows.Data;
namespace ytLive.Helpers;
/// <summary>True when the bound value's ToString() equals the ConverterParameter string.</summary>
public class EnumToBoolConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value?.ToString() == parameter?.ToString();
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> Binding.DoNothing;
}
+149 -13
View File
@@ -30,6 +30,8 @@
xmlns:Helpers="clr-namespace:ytLive.Helpers"/> xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis" <Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/> xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:EnumToBoolConverter x:Key="EnumToBool"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<!-- Red dot shown in the taskbar icon while live --> <!-- Red dot shown in the taskbar icon while live -->
<DrawingImage x:Key="LiveOverlay"> <DrawingImage x:Key="LiveOverlay">
@@ -230,7 +232,7 @@
<Button.ContextMenu> <Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}"> <ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Webcam" Command="{Binding AddSourceCommand}" CommandParameter="webcam" <MenuItem Header="Webcam" Command="{Binding AddSourceCommand}" CommandParameter="webcam"
IsEnabled="{Binding CanAddWebcam}" IsEnabled="{Binding CanAddWebcamToActiveScene}"
ToolTip="One webcam at a time — it's already in your stream"/> ToolTip="One webcam at a time — it's already in your stream"/>
<MenuItem Header="Screen" Command="{Binding AddSourceCommand}" CommandParameter="screen"/> <MenuItem Header="Screen" Command="{Binding AddSourceCommand}" CommandParameter="screen"/>
<MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="background"/> <MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="background"/>
@@ -244,8 +246,8 @@
</StackPanel> </StackPanel>
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="3" Background="Transparent" BorderThickness="0" <Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="3" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ActiveScene.Sources}" ItemsSource="{Binding ActiveScene.Elements}"
SelectedItem="{Binding SelectedSource, Mode=TwoWay}" SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown" PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
PreviewMouseMove="List_PreviewMouseMove" PreviewMouseMove="List_PreviewMouseMove"
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp" PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
@@ -301,12 +303,17 @@
PreviewMouseMove="Preview_MouseMove" PreviewMouseMove="Preview_MouseMove"
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp"> PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp">
<Viewbox Stretch="Uniform"> <Viewbox Stretch="Uniform">
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True"> <Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
<Grid.ContextMenu>
<ContextMenu>
<MenuItem Header="Show Webcam" Command="{Binding ShowWebcamCommand}"/>
</ContextMenu>
</Grid.ContextMenu>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill" <Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
IsHitTestVisible="False"/> IsHitTestVisible="False"/>
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/> <Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080"> <Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
<ItemsControl ItemsSource="{Binding ActiveScene.Sources}" <ItemsControl ItemsSource="{Binding ActiveScene.Elements}"
Width="1920" Height="1080"> Width="1920" Height="1080">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
@@ -325,6 +332,16 @@
Opacity="{Binding Opacity}" Opacity="{Binding Opacity}"
Background="Transparent" Background="Transparent"
RenderTransformOrigin="0.5,0.5"> RenderTransformOrigin="0.5,0.5">
<Grid.Style>
<Style TargetType="Grid">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
<Grid.RenderTransform> <Grid.RenderTransform>
<ScaleTransform ScaleX="{Binding MirrorScale}"/> <ScaleTransform ScaleX="{Binding MirrorScale}"/>
</Grid.RenderTransform> </Grid.RenderTransform>
@@ -335,12 +352,12 @@
<Style TargetType="Image"> <Style TargetType="Image">
<Setter Property="Visibility" Value="Collapsed"/> <Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding Type}" Value="Image"> <DataTrigger Binding="{Binding IsImageSource}" Value="True">
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
</DataTrigger> </DataTrigger>
<MultiDataTrigger> <MultiDataTrigger>
<MultiDataTrigger.Conditions> <MultiDataTrigger.Conditions>
<Condition Binding="{Binding Type}" Value="Webcam"/> <Condition Binding="{Binding IsWebcam}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Traditional"/> <Condition Binding="{Binding ClipShape}" Value="Traditional"/>
</MultiDataTrigger.Conditions> </MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
@@ -358,7 +375,7 @@
<Style.Triggers> <Style.Triggers>
<MultiDataTrigger> <MultiDataTrigger>
<MultiDataTrigger.Conditions> <MultiDataTrigger.Conditions>
<Condition Binding="{Binding Type}" Value="Webcam"/> <Condition Binding="{Binding IsWebcam}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Round"/> <Condition Binding="{Binding ClipShape}" Value="Round"/>
</MultiDataTrigger.Conditions> </MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
@@ -372,14 +389,133 @@
</Ellipse> </Ellipse>
</Grid> </Grid>
</Viewbox> </Viewbox>
<!-- Static OBS-style border: stroked rect (Traditional) / centered circle (Round). -->
<Rectangle IsHitTestVisible="False"
Stroke="{Binding BorderBrush}"
StrokeThickness="{Binding BorderWidth}">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding HasBorder}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
<Ellipse IsHitTestVisible="False"
Stroke="{Binding BorderBrush}"
StrokeThickness="{Binding BorderWidth}"
Width="{Binding RoundBorderSize}"
Height="{Binding RoundBorderSize}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding HasBorder}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Round"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Webcam…" Click="WebcamMenu_ChangeWebcam"/>
<Separator/>
<MenuItem Header="Border Effect">
<MenuItem Header="None" Tag="None" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=None}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Pulse" Tag="Pulse" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Pulse}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Chase" Tag="Chase" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Chase}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Rainbow" Tag="Rainbow" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Rainbow}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Shimmer" Tag="Shimmer" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Shimmer}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Marching Ants" Tag="MarchingAnts" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=MarchingAnts}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Glow" Tag="Glow" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Glow}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Electricity" Tag="Electricity" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Electricity}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Sparkles" Tag="Sparkles" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Sparkles}"
Click="WebcamMenu_SetBorderAnimation"/>
</MenuItem>
<MenuItem Header="Border Color">
<MenuItem Header="None" Tag="" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=''}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#ffffff" Tag="#ffffff" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#ffffff}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#000000" Tag="#000000" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#000000}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#e94560" Tag="#e94560" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#e94560}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#22c55e" Tag="#22c55e" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#22c55e}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#eab308" Tag="#eab308" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#eab308}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#38bdf8" Tag="#38bdf8" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#38bdf8}"
Click="WebcamMenu_SetBorderColor"/>
</MenuItem>
<MenuItem Header="Border Opacity">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Opacity" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="1" Margin="8,0,0,0"
Value="{Binding BorderOpacity, Mode=TwoWay}"
VerticalAlignment="Center"/>
</StackPanel>
</MenuItem>
<MenuItem Header="Border Thickness">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Thickness" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="20" Margin="8,0,0,0"
Value="{Binding BorderWidth, Mode=TwoWay}"
VerticalAlignment="Center"/>
</StackPanel>
</MenuItem>
<Separator/>
<MenuItem Header="Hide in this scene" Click="WebcamMenu_HideInScene"/>
<MenuItem Header="Remove" Click="WebcamMenu_Remove"/>
</ContextMenu>
</Grid.ContextMenu>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<Grid x:Name="SelectionOverlay" IsHitTestVisible="False" <Grid x:Name="SelectionOverlay" IsHitTestVisible="False"
Canvas.Left="{Binding SelectedSource.X}" Canvas.Top="{Binding SelectedSource.Y}" Canvas.Left="{Binding SelectedElement.X}" Canvas.Top="{Binding SelectedElement.Y}"
Width="{Binding SelectedSource.Width}" Height="{Binding SelectedSource.Height}"> Width="{Binding SelectedElement.Width}" Height="{Binding SelectedElement.Height}">
<Rectangle Stroke="#e94560" StrokeDashArray="4 3" StrokeThickness="3"/> <Rectangle Stroke="#e94560" StrokeDashArray="4 3" StrokeThickness="3"/>
<Ellipse Width="26" Height="26" Fill="#e94560" Stroke="White" StrokeThickness="2" <Ellipse Width="26" Height="26" Fill="#e94560" Stroke="White" StrokeThickness="2"
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/> HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/>
@@ -475,17 +611,17 @@
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="Opacity" Foreground="#e0e0e0" FontSize="13" VerticalAlignment="Center"/> <TextBlock Text="Opacity" Foreground="#e0e0e0" FontSize="13" VerticalAlignment="Center"/>
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1" Margin="10,0,0,0" <Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1" Margin="10,0,0,0"
Value="{Binding SelectedSource.Opacity, Mode=TwoWay}" Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/> ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#e0e0e0" FontSize="13" <TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#e0e0e0" FontSize="13"
Width="40" Margin="8,0,0,0" VerticalAlignment="Center"/> Width="40" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0" <StackPanel Orientation="Horizontal" Margin="0,10,0,0"
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}"> Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
<Button Content="{Binding SelectedSource.MirrorButtonText}" <Button Content="{Binding SelectedElement.MirrorButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4" Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Click="MirrorButton_Click"/> Click="MirrorButton_Click"/>
<Button Content="{Binding SelectedSource.ShapeButtonText}" <Button Content="{Binding SelectedElement.ShapeButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4" Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Margin="8,0,0,0" Click="ShapeButton_Click"/> Margin="8,0,0,0" Click="ShapeButton_Click"/>
</StackPanel> </StackPanel>
+62 -29
View File
@@ -37,7 +37,7 @@ public partial class MainWindow : Window
{ {
if (e.PropertyName == nameof(MainViewModel.IsLive)) if (e.PropertyName == nameof(MainViewModel.IsLive))
UpdateTaskbarOverlay(); UpdateTaskbarOverlay();
else if (e.PropertyName == nameof(MainViewModel.SelectedSource)) else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
UpdateSelectionOverlay(); UpdateSelectionOverlay();
} }
@@ -103,7 +103,7 @@ public partial class MainWindow : Window
if (IsDescendantOf(original, PreviewGrid)) return; if (IsDescendantOf(original, PreviewGrid)) return;
if (IsDescendantOf(original, SourceList)) return; if (IsDescendantOf(original, SourceList)) return;
if (IsDescendantOf(original, OpacityChip)) return; if (IsDescendantOf(original, OpacityChip)) return;
_viewModel.SelectedSource = null; _viewModel.SelectedElement = null;
} }
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor) private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
@@ -115,27 +115,55 @@ public partial class MainWindow : Window
private void UpdateSelectionOverlay() private void UpdateSelectionOverlay()
{ {
var selected = _viewModel.SelectedSource is { } s && IsDraggableSource(s); var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed; SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed; OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
if (selected) if (selected)
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedSource!.Opacity * 100)}%"; OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
} }
// Static images and the webcam share the move/resize/selection behavior. // Static images and the webcam share the move/resize/selection behavior.
private static bool IsDraggableSource(Source source) private static bool IsDraggableElement(SceneElement element)
=> source.Type is SourceType.Image or SourceType.Webcam; => element is Source { Type: SourceType.Image } or WebcamSceneConfig;
private void MirrorButton_Click(object sender, RoutedEventArgs e) private void MirrorButton_Click(object sender, RoutedEventArgs e)
{ {
if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source) if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
source.IsMirrored = !source.IsMirrored; webcam.IsMirrored = !webcam.IsMirrored;
} }
private void ShapeButton_Click(object sender, RoutedEventArgs e) private void ShapeButton_Click(object sender, RoutedEventArgs e)
{ {
if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source) if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
source.ClipShape = source.ClipShape == ClipShape.Traditional ? ClipShape.Round : ClipShape.Traditional; webcam.ToggleClipShape();
}
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
=> _viewModel.ChangeWebcamCommand.Execute(null);
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string tag }
&& Enum.TryParse<BorderAnimation>(tag, out var animation))
webcam.BorderAnimation = animation;
}
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string color })
webcam.BorderColor = color;
}
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam })
webcam.IsVisible = false;
}
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
_viewModel.RemoveSourceCommand.Execute(element);
} }
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
@@ -148,9 +176,9 @@ public partial class MainWindow : Window
var grid = (Grid)sender; var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse; var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid)); var p = toCanvas.Transform(e.GetPosition(grid));
var selected = _viewModel.SelectedSource; var selected = _viewModel.SelectedElement;
if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel)) if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
{ {
_isResizing = true; _isResizing = true;
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height); _resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
@@ -159,10 +187,10 @@ public partial class MainWindow : Window
return; return;
} }
var hit = HitImage(p); var hit = HitElement(p);
if (hit != null) if (hit != null)
{ {
_viewModel.SelectedSource = hit; _viewModel.SelectedElement = hit;
_isDraggingOverlay = true; _isDraggingOverlay = true;
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y); _grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
grid.CaptureMouse(); grid.CaptureMouse();
@@ -170,7 +198,7 @@ public partial class MainWindow : Window
return; return;
} }
_viewModel.SelectedSource = null; _viewModel.SelectedElement = null;
} }
private void Preview_MouseMove(object sender, MouseEventArgs e) private void Preview_MouseMove(object sender, MouseEventArgs e)
@@ -180,7 +208,7 @@ public partial class MainWindow : Window
var grid = (Grid)sender; var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse; var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid)); var p = toCanvas.Transform(e.GetPosition(grid));
var selected = _viewModel.SelectedSource; var selected = _viewModel.SelectedElement;
if (selected == null) if (selected == null)
{ {
EndPreviewDrag(grid); EndPreviewDrag(grid);
@@ -198,11 +226,14 @@ public partial class MainWindow : Window
} }
else if (_isResizing) else if (_isResizing)
{ {
var newW = Math.Clamp(p.X - selected.X, 32, 1920); var isWebcam = selected is WebcamSceneConfig;
var maxW = isWebcam ? MainViewModel.WebcamMaxWidth : 1920;
var maxH = isWebcam ? MainViewModel.WebcamMaxHeight : 1080;
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
var newH = newW / _resizeAspect; var newH = newW / _resizeAspect;
if (newH > 1080) if (newH > maxH)
{ {
newH = 1080; newH = maxH;
newW = newH * _resizeAspect; newW = newH * _resizeAspect;
} }
selected.Width = newW; selected.Width = newW;
@@ -221,23 +252,25 @@ public partial class MainWindow : Window
_isResizing = false; _isResizing = false;
} }
private bool HitHandle(Point mouseScreen, Source source) private bool HitHandle(Point mouseScreen, SceneElement element)
{ {
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(source.X + source.Width, source.Y + source.Height)); var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20; return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
} }
private Source? HitImage(Point p) private SceneElement? HitElement(Point p)
{ {
var scene = _viewModel.ActiveScene; var scene = _viewModel.ActiveScene;
if (scene == null) return null; if (scene == null) return null;
for (var i = scene.Sources.Count - 1; i >= 0; i--) for (var i = scene.Elements.Count - 1; i >= 0; i--)
{ {
var source = scene.Sources[i]; var element = scene.Elements[i];
if (!IsDraggableSource(source) || !source.IsEnabled) continue; if (!IsDraggableElement(element)) continue;
if (p.X >= source.X && p.X <= source.X + source.Width && if (element is Source { IsEnabled: false }) continue;
p.Y >= source.Y && p.Y <= source.Y + source.Height) if (element is WebcamSceneConfig { IsVisible: false }) continue;
return source; if (p.X >= element.X && p.X <= element.X + element.Width &&
p.Y >= element.Y && p.Y <= element.Y + element.Height)
return element;
} }
return null; return null;
} }
@@ -275,7 +308,7 @@ public partial class MainWindow : Window
if (item == null) if (item == null)
{ {
if (ReferenceEquals(listBox, SourceList)) if (ReferenceEquals(listBox, SourceList))
_viewModel.SelectedSource = null; _viewModel.SelectedElement = null;
_dragIndex = -1; _dragIndex = -1;
return; return;
} }
+9 -1
View File
@@ -30,7 +30,15 @@ public class Scene : INotifyPropertyChanged
set => Set(ref _isHidden, value); set => Set(ref _isHidden, value);
} }
public ObservableCollection<Source> Sources { get; } = new(); /// <summary>
/// The scene's rendered elements in z-order (back to front): multi-instance
/// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any.
/// </summary>
public ObservableCollection<SceneElement> Elements { get; } = new();
/// <summary>This scene's webcam usage, or null if the creator hasn't added the webcam here.</summary>
public WebcamSceneConfig? WebcamConfig => Elements.OfType<WebcamSceneConfig>().FirstOrDefault();
public bool IsChatScene { get; init; } public bool IsChatScene { get; init; }
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
+239
View File
@@ -0,0 +1,239 @@
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace ytLive.Models;
public enum BorderAnimation
{
None,
Pulse,
Chase,
Rainbow,
Shimmer,
MarchingAnts,
Glow,
Electricity,
Sparkles
}
/// <summary>
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
/// or a singleton resource's per-scene config (webcam). Carries the shared
/// layout + clip/mirror + border surface the preview pipeline renders from.
/// </summary>
public abstract class SceneElement : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void Raise([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
Raise(name);
return true;
}
public string Id { get; init; } = Guid.NewGuid().ToString();
private string _name = string.Empty;
public string Name { get => _name; set => Set(ref _name, value); }
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
public virtual bool IsWebcam => false;
/// <summary>True only for image sources — the raw preview renderer's discriminator.</summary>
public virtual bool IsImageSource => false;
private bool _isVisible = true;
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
private ClipShape _clipShape = ClipShape.Traditional;
public ClipShape ClipShape
{
get => _clipShape;
set
{
if (Set(ref _clipShape, value))
Raise(nameof(ShapeButtonText));
}
}
// The rectangular dimensions before a Round toggle, so switching back restores
// the aspect instead of staying locked to the square Round used. Persisted so
// the restore survives a reload of a Round element.
private double? _rectWidth;
private double? _rectHeight;
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
public void ToggleClipShape()
{
if (ClipShape == ClipShape.Traditional)
{
RectWidth = Width;
RectHeight = Height;
ClipShape = ClipShape.Round;
}
else
{
ClipShape = ClipShape.Traditional;
if (RectWidth is { } rw && RectHeight is { } rh)
{
Width = rw;
Height = rh;
RectWidth = null;
RectHeight = null;
}
}
}
private bool _isMirrored;
public bool IsMirrored
{
get => _isMirrored;
set
{
if (Set(ref _isMirrored, value))
{
Raise(nameof(MirrorScale));
Raise(nameof(MirrorButtonText));
}
}
}
public double MirrorScale => IsMirrored ? -1 : 1;
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
private WriteableBitmap? _videoImageSource;
public WriteableBitmap? VideoImageSource
{
get => _videoImageSource;
set
{
if (Set(ref _videoImageSource, value))
Raise(nameof(DisplaySource));
}
}
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
public abstract ImageSource? DisplaySource { get; }
// Position/transform (per-scene usage)
private double _x;
public double X { get => _x; set => Set(ref _x, value); }
private double _y;
public double Y { get => _y; set => Set(ref _y, value); }
private double _width;
public double Width
{
get => _width;
set
{
if (Set(ref _width, value))
Raise(nameof(RoundBorderSize));
}
}
private double _height;
public double Height
{
get => _height;
set
{
if (Set(ref _height, value))
Raise(nameof(RoundBorderSize));
}
}
private double _opacity = 1.0;
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
/// <summary>Round clip/border diameter = the shorter dimension (true circle).</summary>
public double RoundBorderSize => Math.Min(Width, Height);
// Border (OBS-style): #RRGGBB color, alpha opacity, pixel width. Off by default.
private string _borderColor = string.Empty;
public string BorderColor
{
get => _borderColor;
set
{
if (Set(ref _borderColor, value))
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
}
}
}
private double _borderOpacity = 1.0;
public double BorderOpacity
{
get => _borderOpacity;
set
{
if (Set(ref _borderOpacity, value))
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
}
}
}
private int _borderWidth;
public int BorderWidth
{
get => _borderWidth;
set
{
if (Set(ref _borderWidth, value))
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
}
}
}
private BorderAnimation _borderAnimation = BorderAnimation.None;
public BorderAnimation BorderAnimation { get => _borderAnimation; set => Set(ref _borderAnimation, value); }
public bool HasBorder
{
get
{
if (BorderWidth <= 0) return false;
return TryGetBorderColor(out _, out _, out _);
}
}
public Brush? BorderBrush
{
get
{
if (!HasBorder || !TryGetBorderColor(out var r, out var g, out var b)) return null;
var alpha = (byte)Math.Round(Math.Clamp(BorderOpacity, 0, 1) * 255);
return new SolidColorBrush(Color.FromArgb(alpha, r, g, b));
}
}
private bool TryGetBorderColor(out byte r, out byte g, out byte b)
{
r = g = b = 0;
var hex = BorderColor.Trim().TrimStart('#');
if (hex.Length != 6) return false;
return byte.TryParse(hex.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out r)
&& byte.TryParse(hex.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out g)
&& byte.TryParse(hex.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out b);
}
}
+9 -89
View File
@@ -1,7 +1,4 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Helpers; using ytLive.Helpers;
namespace ytLive.Models; namespace ytLive.Models;
@@ -10,7 +7,6 @@ public enum SourceType
{ {
DisplayCapture, DisplayCapture,
WindowCapture, WindowCapture,
Webcam,
Background, Background,
Image, Image,
TextOverlay TextOverlay
@@ -22,26 +18,14 @@ public enum ClipShape
Round Round
} }
public class Source : INotifyPropertyChanged /// <summary>
/// A multi-instance scene object: image/background/text (screen/window later).
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
/// is a <see cref="WebcamSceneConfig"/>. See the singleton-vs-multi-instance
/// discriminator in ai.md.
/// </summary>
public class Source : SceneElement
{ {
public event PropertyChangedEventHandler? PropertyChanged;
private void Raise([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
Raise(name);
return true;
}
public string Id { get; init; } = Guid.NewGuid().ToString();
private string _name = string.Empty;
public string Name { get => _name; set => Set(ref _name, value); }
private SourceType _type; private SourceType _type;
public SourceType Type { get => _type; set => Set(ref _type, value); } public SourceType Type { get => _type; set => Set(ref _type, value); }
@@ -55,58 +39,6 @@ public class Source : INotifyPropertyChanged
private IntPtr? _windowHandle; private IntPtr? _windowHandle;
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); } public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
// Webcam
private string? _deviceId;
public string? DeviceId { get => _deviceId; set => Set(ref _deviceId, value); }
// Webcam live preview: the shared WriteableBitmap owned by CameraManager.
private WriteableBitmap? _videoImageSource;
public WriteableBitmap? VideoImageSource
{
get => _videoImageSource;
set
{
if (Set(ref _videoImageSource, value))
Raise(nameof(DisplaySource));
}
}
private ClipShape _clipShape = ClipShape.Traditional;
public ClipShape ClipShape
{
get => _clipShape;
set
{
if (Set(ref _clipShape, value))
Raise(nameof(ShapeButtonText));
}
}
private bool _isMirrored;
public bool IsMirrored
{
get => _isMirrored;
set
{
if (Set(ref _isMirrored, value))
{
Raise(nameof(MirrorScale));
Raise(nameof(MirrorButtonText));
}
}
}
public double MirrorScale => IsMirrored ? -1 : 1;
/// <summary>Mirror toggle label (mirrored → "Unmirror").</summary>
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
/// <summary>Clip-shape toggle label (round → "Rect").</summary>
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
/// <summary>What the preview shows: static image for image/background, live frames for webcam.</summary>
public ImageSource? DisplaySource => Type == SourceType.Webcam ? _videoImageSource : _imageSource;
// Image (asset stored in the layout database) // Image (asset stored in the layout database)
private string? _assetId; private string? _assetId;
private ImageSource? _imageSource; private ImageSource? _imageSource;
@@ -127,19 +59,7 @@ public class Source : INotifyPropertyChanged
public ImageSource? ImageSource => _imageSource; public ImageSource? ImageSource => _imageSource;
// Position/transform (per-scene usage) public override ImageSource? DisplaySource => _imageSource;
private double _x;
public double X { get => _x; set => Set(ref _x, value); }
private double _y; public override bool IsImageSource => Type == SourceType.Image;
public double Y { get => _y; set => Set(ref _y, value); }
private double _width;
public double Width { get => _width; set => Set(ref _width, value); }
private double _height;
public double Height { get => _height; set => Set(ref _height, value); }
private double _opacity = 1.0;
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
} }
+13
View File
@@ -0,0 +1,13 @@
namespace ytLive.Models;
/// <summary>
/// The app-wide webcam identity — one camera input (the limit is the hardware,
/// not us). Scenes reference it via <see cref="WebcamSceneConfig"/>; the DeviceId
/// lives only here, so the camera is a Windows-controlled singleton.
/// </summary>
public class Webcam
{
public string Id { get; init; } = Guid.NewGuid().ToString();
public string DeviceId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
+16
View File
@@ -0,0 +1,16 @@
using System.Windows.Media;
namespace ytLive.Models;
/// <summary>
/// A scene's usage of the app-wide webcam — conceptually `webcam.{scene}.config`.
/// The identity lives once on the <see cref="Webcam"/> entity; this object is that
/// scene's placement, clip/mirror, border, and visibility for it. Exists only in
/// scenes where the creator added the webcam.
/// </summary>
public class WebcamSceneConfig : SceneElement
{
public string WebcamId { get; init; } = string.Empty;
public override bool IsWebcam => true;
public override ImageSource? DisplaySource => VideoImageSource;
}
+5 -2
View File
@@ -5,8 +5,11 @@ Plain data types. No logic beyond what a property can carry. See
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Sources` collection | | `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Elements` collection (images + webcam config), `WebcamConfig` accessor |
| `Source.cs` | A source: `SourceType` enum (Image/Webcam/Screen/Background/Text), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (webcam live frames), `DisplaySource` (whichever the preview shows), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText` toggle labels, asset identity, webcam `DeviceId` | | `Source.cs` | An image source: `SourceType` enum (Image/Screen/Background/Text**Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource`, `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math | | `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum | | `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) | | `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
+22
View File
@@ -122,6 +122,28 @@ public sealed class CameraManager : IDisposable
toStop.PreviewBitmap = null; toStop.PreviewBitmap = null;
} }
/// <summary>
/// Releases a device unconditionally (every ref), regardless of how many scenes
/// hold it. Used on identity changes (webcam swap, layout reload) where the old
/// device's refcount isn't known after the scenes are replaced.
/// </summary>
public async Task ReleaseAllAsync(string deviceId)
{
CameraSession? toStop = null;
lock (_gate)
{
if (!_sessions.TryGetValue(deviceId, out var session)) return;
session.RefCount = 0;
_sessions.Remove(deviceId);
toStop = session;
}
if (toStop == null) return;
toStop.Source.FrameAvailable -= toStop.FrameHandler;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
public VideoFrame? GetLatestFrame(string deviceId) public VideoFrame? GetLatestFrame(string deviceId)
{ {
lock (_gate) lock (_gate)
+302 -14
View File
@@ -12,6 +12,9 @@ public class LayoutStore : IDisposable
private readonly SqliteConnection _connection; private readonly SqliteConnection _connection;
public string ActivePath { get; } public string ActivePath { get; }
/// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary>
public Webcam? Webcam { get; private set; }
public LayoutStore(string path) public LayoutStore(string path)
{ {
ActivePath = path; ActivePath = path;
@@ -31,7 +34,6 @@ public class LayoutStore : IDisposable
{ {
string[] statements = string[] statements =
{ {
"PRAGMA user_version = 2;",
""" """
CREATE TABLE IF NOT EXISTS Scene ( CREATE TABLE IF NOT EXISTS Scene (
Id TEXT PRIMARY KEY, Id TEXT PRIMARY KEY,
@@ -70,6 +72,35 @@ public class LayoutStore : IDisposable
SortOrder INTEGER NOT NULL DEFAULT 0 SortOrder INTEGER NOT NULL DEFAULT 0
); );
""", """,
"""
CREATE TABLE IF NOT EXISTS Webcam (
Id TEXT PRIMARY KEY,
DeviceId TEXT NOT NULL UNIQUE,
Name TEXT NOT NULL
);
""",
"""
CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
IsVisible INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
RectWidth REAL,
RectHeight REAL,
BorderColor TEXT,
BorderOpacity REAL NOT NULL DEFAULT 1,
BorderWidth INTEGER NOT NULL DEFAULT 0,
BorderAnimation TEXT NOT NULL DEFAULT 'None',
SortOrder INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (SceneId, WebcamId)
);
""",
}; };
foreach (var sql in statements) foreach (var sql in statements)
{ {
@@ -78,6 +109,21 @@ public class LayoutStore : IDisposable
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
MigrateSourceTable(); MigrateSourceTable();
MigrateWebcamConfigTable();
if (GetUserVersion() < 3)
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 4;";
cmd.ExecuteNonQuery();
}
}
private int GetUserVersion()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
return Convert.ToInt32(cmd.ExecuteScalar());
} }
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs). // v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
@@ -109,10 +155,117 @@ public class LayoutStore : IDisposable
} }
} }
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
// so a reloaded Round webcam restores its aspect on toggle-back).
private void MigrateWebcamConfigTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (!columns.Contains("RectWidth"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("RectHeight"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
cmd.ExecuteNonQuery();
}
}
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
// one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
// per scene that had one, then the webcam rows are deleted. No backfill:
// scenes without the webcam stay webcam-free.
private void MigrateToV3()
{
var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add((
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetDouble(3),
reader.GetDouble(4),
reader.GetDouble(5),
reader.GetDouble(6),
reader.GetDouble(7),
reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
reader.GetString(9),
reader.GetInt32(10) != 0));
}
}
if (rows.Count == 0) return;
using var tx = _connection.BeginTransaction();
var webcamId = Guid.NewGuid().ToString();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", webcamId);
cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
cmd.Parameters.AddWithValue("$name", rows[0].Name);
cmd.ExecuteNonQuery();
}
foreach (var row in rows)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
$clip, $mirrored, NULL, 1, 0, 'None', 0);
""";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
cmd.Parameters.AddWithValue("$webcamId", webcamId);
cmd.Parameters.AddWithValue("$x", row.X);
cmd.Parameters.AddWithValue("$y", row.Y);
cmd.Parameters.AddWithValue("$w", row.W);
cmd.Parameters.AddWithValue("$h", row.H);
cmd.Parameters.AddWithValue("$opacity", row.Opacity);
cmd.Parameters.AddWithValue("$clip", row.ClipShape);
cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
tx.Commit();
}
public List<Scene> Load() public List<Scene> Load()
{ {
Webcam = null;
var scenes = new List<Scene>(); var scenes = new List<Scene>();
var sourcesByScene = new Dictionary<string, List<Source>>(); var sourcesByScene = new Dictionary<string, List<Source>>();
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
@@ -130,11 +283,26 @@ public class LayoutStore : IDisposable
} }
} }
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Webcam = new Webcam
{
Id = reader.GetString(0),
DeviceId = reader.GetString(1),
Name = reader.GetString(2),
};
}
}
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = """ cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled, SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored
FROM Source ORDER BY SortOrder FROM Source ORDER BY SortOrder
"""; """;
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
@@ -154,9 +322,8 @@ public class LayoutStore : IDisposable
Height = reader.GetDouble(9), Height = reader.GetDouble(9),
Opacity = reader.GetDouble(10), Opacity = reader.GetDouble(10),
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11), MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12), ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(13), out var clip) ? clip : ClipShape.Traditional, IsMirrored = reader.GetInt32(13) != 0,
IsMirrored = reader.GetInt32(14) != 0,
}; };
if (!sourcesByScene.TryGetValue(sceneId, out var list)) if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>(); sourcesByScene[sceneId] = list = new List<Source>();
@@ -164,26 +331,78 @@ public class LayoutStore : IDisposable
} }
} }
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
RectWidth, RectHeight
FROM WebcamSceneConfig ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var sceneId = reader.GetString(0);
var config = new WebcamSceneConfig
{
WebcamId = reader.GetString(1),
Name = Webcam?.Name ?? "Webcam",
IsVisible = reader.GetInt32(2) != 0,
X = reader.GetDouble(3),
Y = reader.GetDouble(4),
Width = reader.GetDouble(5),
Height = reader.GetDouble(6),
Opacity = reader.GetDouble(7),
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
IsMirrored = reader.GetInt32(9) != 0,
BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
BorderAnimation = Enum.TryParse<BorderAnimation>(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
};
if (!configsByScene.TryGetValue(sceneId, out var list))
configsByScene[sceneId] = list = new List<WebcamSceneConfig>();
list.Add(config);
}
}
foreach (var scene in scenes) foreach (var scene in scenes)
{ {
if (sourcesByScene.TryGetValue(scene.Id, out var list)) if (sourcesByScene.TryGetValue(scene.Id, out var sources))
foreach (var source in list) foreach (var source in sources)
scene.Sources.Add(source); scene.Elements.Add(source);
if (configsByScene.TryGetValue(scene.Id, out var configs))
foreach (var config in configs)
scene.Elements.Add(config);
} }
return scenes; return scenes;
} }
public void Save(IEnumerable<Scene> scenes) public void Save(IEnumerable<Scene> scenes, Webcam? webcam)
{ {
using var tx = _connection.BeginTransaction(); using var tx = _connection.BeginTransaction();
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "DELETE FROM Source;"; cmd.CommandText = "DELETE FROM Source;";
cmd.Transaction = tx; cmd.Transaction = tx;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Webcam;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "DELETE FROM Scene;"; cmd.CommandText = "DELETE FROM Scene;";
cmd.Transaction = tx; cmd.Transaction = tx;
@@ -219,10 +438,10 @@ public class LayoutStore : IDisposable
{ {
cmd.CommandText = """ cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled, INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, SortOrder) ClipShape, IsMirrored, SortOrder)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor, $device, $x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $sort) $clip, $mirrored, $sort)
"""; """;
cmd.Transaction = tx; cmd.Transaction = tx;
@@ -238,7 +457,6 @@ public class LayoutStore : IDisposable
var hP = cmd.Parameters.Add("$h", SqliteType.Real); var hP = cmd.Parameters.Add("$h", SqliteType.Real);
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real); var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer); var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
var deviceP = cmd.Parameters.Add("$device", SqliteType.Text);
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text); var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer); var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer); var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
@@ -246,8 +464,9 @@ public class LayoutStore : IDisposable
foreach (var scene in scenes) foreach (var scene in scenes)
{ {
var sort = 0; var sort = 0;
foreach (var source in scene.Sources) foreach (var element in scene.Elements)
{ {
if (element is not Source source) continue;
idP.Value = source.Id; idP.Value = source.Id;
sceneIdP.Value = scene.Id; sceneIdP.Value = scene.Id;
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value; assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
@@ -260,7 +479,6 @@ public class LayoutStore : IDisposable
hP.Value = source.Height; hP.Value = source.Height;
opacityP.Value = source.Opacity; opacityP.Value = source.Opacity;
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value; monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
clipP.Value = source.ClipShape.ToString(); clipP.Value = source.ClipShape.ToString();
mirroredP.Value = source.IsMirrored ? 1 : 0; mirroredP.Value = source.IsMirrored ? 1 : 0;
sortP.Value = sort++; sortP.Value = sort++;
@@ -269,6 +487,76 @@ public class LayoutStore : IDisposable
} }
} }
if (webcam != null)
{
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", webcam.Id);
cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
cmd.Parameters.AddWithValue("$name", webcam.Name);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
RectWidth, RectHeight, SortOrder)
VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
$clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
$rectWidth, $rectHeight, $sort)
""";
cmd.Transaction = tx;
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
foreach (var scene in scenes)
{
var sort = 0;
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
{
sceneIdP.Value = scene.Id;
webcamIdP.Value = config.WebcamId;
visibleP.Value = config.IsVisible ? 1 : 0;
xP.Value = config.X;
yP.Value = config.Y;
wP.Value = config.Width;
hP.Value = config.Height;
opacityP.Value = config.Opacity;
clipP.Value = config.ClipShape.ToString();
mirroredP.Value = config.IsMirrored ? 1 : 0;
colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
borderOpacityP.Value = config.BorderOpacity;
borderWidthP.Value = config.BorderWidth;
animationP.Value = config.BorderAnimation.ToString();
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
sortP.Value = sort++;
cmd.ExecuteNonQuery();
}
}
}
}
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);"; cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
+2 -2
View File
@@ -8,14 +8,14 @@ External-facing logic: YouTube API, persistence. See
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) | | `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** | | `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` | | `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 2 (`Source.ClipShape`/`IsMirrored` — added by `ALTER TABLE` for pre-v2 DBs) | | `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 4 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore) |
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam | | `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device | | `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) | | `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source | | `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` | | `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`); frames arrive on a worker thread | | `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`); frames arrive on a worker thread |
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events | | `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload) |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md). (no DI container yet). Models in [`Models/index.md`](../Models/index.md).
+5 -3
View File
@@ -159,7 +159,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
- **Scenes list:** drag rows to reorder scenes - **Scenes list:** drag rows to reorder scenes
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented - **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
### Status: 🔶 In progress — milestone 1 (webcam) shipped; scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; screen capture, compositing, and encoding pending ### Status: 🔶 In progress — milestone 1 (webcam) shipped; **schema v3 (Ship Branch A) shipped**: multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OSB-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`); **schema v4**: round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load — 25 tests passing; scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; screen capture, compositing, encoding pending
--- ---
@@ -240,8 +240,10 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
5. **Schema**`Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data, 5. **Schema**`Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data,
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled, PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) — X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) —
`user_version` 2 (v1 → v2 = `ALTER TABLE` adds the two webcam columns). `WindowHandle` stays `user_version` **4** (v1 → v2 = `ALTER TABLE` adds the two webcam columns; v3 = singleton
in-memory (per-session). Save = transactional rewrite; orphaned assets pruned. `Webcam` + per-scene `WebcamSceneConfig`; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the
round-to-rect restore). `WindowHandle` stays in-memory (per-session). Save = transactional
rewrite; orphaned assets pruned.
6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending) 6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending)
only when the DB is empty. only when the DB is empty.
+49 -5
View File
@@ -373,7 +373,9 @@
BorderBrush="{TemplateBinding BorderBrush}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="5" Padding="{TemplateBinding Padding}"> CornerRadius="5" Padding="{TemplateBinding Padding}">
<ItemsPresenter/> <Grid IsSharedSizeScope="True">
<ItemsPresenter/>
</Grid>
</Border> </Border>
</ControlTemplate> </ControlTemplate>
</Setter.Value> </Setter.Value>
@@ -387,18 +389,60 @@
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="MenuItem"> <ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="3" <Grid x:Name="Grid" SnapsToDevicePixels="True">
Padding="{TemplateBinding Padding}" Margin="0,1"> <Grid.ColumnDefinitions>
<ContentPresenter ContentSource="Header" VerticalAlignment="Center" <ColumnDefinition x:Name="Col0" MinWidth="17" Width="Auto"
SharedSizeGroup="MenuItemIconColumnGroup"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="14"/>
</Grid.ColumnDefinitions>
<Border x:Name="Bd" Grid.ColumnSpan="3"
Background="{TemplateBinding Background}"
CornerRadius="3" Margin="0,1"/>
<TextBlock x:Name="CheckGlyph" Grid.Column="0" Text="&#x2713;"
Margin="4,0,6,0" HorizontalAlignment="Center"
VerticalAlignment="Center" Visibility="Collapsed"/>
<ContentPresenter x:Name="Content" Grid.Column="1"
ContentSource="Header"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
HorizontalAlignment="Left"/> HorizontalAlignment="Left"/>
</Border> <TextBlock x:Name="Arrow" Grid.Column="2" Text="&#x203A;"
Margin="6,0,0,0" HorizontalAlignment="Center"
VerticalAlignment="Center" Visibility="Collapsed"/>
<Popup x:Name="PART_Popup" Placement="Right"
HorizontalOffset="-1" VerticalOffset="-1"
IsOpen="{TemplateBinding IsSubmenuOpen}"
AllowsTransparency="True"
PopupAnimation="{DynamicResource {x:Static SystemParameters.MenuPopupAnimationKey}}"
Focusable="False">
<Border Background="#16213e" BorderBrush="#2a3a5e"
BorderThickness="1" CornerRadius="5" Padding="3">
<Grid IsSharedSizeScope="True">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsPresenter/>
</ScrollViewer>
</Grid>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers> <ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True"> <Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#26ffffff"/> <Setter TargetName="Bd" Property="Background" Value="#26ffffff"/>
</Trigger> </Trigger>
<Trigger Property="IsSubmenuOpen" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#26ffffff"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="CheckGlyph" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False"> <Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/> <Setter Property="Opacity" Value="0.45"/>
</Trigger> </Trigger>
<DataTrigger Binding="{Binding HasItems, RelativeSource={RelativeSource TemplatedParent}}"
Value="True">
<Setter TargetName="Arrow" Property="Visibility" Value="Visible"/>
</DataTrigger>
</ControlTemplate.Triggers> </ControlTemplate.Triggers>
</ControlTemplate> </ControlTemplate>
</Setter.Value> </Setter.Value>
+233 -88
View File
@@ -23,7 +23,7 @@ public class MainViewModel : ViewModelBase
private readonly DispatcherTimer _liveTimer; private readonly DispatcherTimer _liveTimer;
private Scene? _activeScene; private Scene? _activeScene;
private Source? _selectedSource; private SceneElement? _selectedElement;
private ImageSource? _activeBackgroundImage; private ImageSource? _activeBackgroundImage;
private bool _isConnected; private bool _isConnected;
private StreamStatus _streamStatus = StreamStatus.Offline; private StreamStatus _streamStatus = StreamStatus.Offline;
@@ -57,11 +57,12 @@ public class MainViewModel : ViewModelBase
private DispatcherTimer? _saveDebounce; private DispatcherTimer? _saveDebounce;
private bool _isLoading; private bool _isLoading;
// Webcam: one camera app-wide. The single Source is tracked here so the // Webcam: one camera input app-wide. The identity (DeviceId) lives on the
// Add menu can be disabled and the live preview bitmap can be forwarded. // Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
// (webcam.{scene}.config). CameraManager still owns the single session.
private readonly ICameraEnumerator _cameraEnumerator; private readonly ICameraEnumerator _cameraEnumerator;
private readonly CameraManager _cameraManager; private readonly CameraManager _cameraManager;
private Source? _webcamSource; private Webcam? _webcam;
// Branding flash (monetization): a full-frame "made with ytLlive!" shown // Branding flash (monetization): a full-frame "made with ytLlive!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets // for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
@@ -87,11 +88,13 @@ public class MainViewModel : ViewModelBase
if (value != null && value.IsHidden) return; if (value != null && value.IsHidden) return;
if (SetProperty(ref _activeScene, value)) if (SetProperty(ref _activeScene, value))
{ {
SelectedSource = null; SelectedElement = null;
OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
UpdateActiveBackground(); UpdateActiveBackground();
} }
} }
@@ -103,21 +106,31 @@ public class MainViewModel : ViewModelBase
private set => SetProperty(ref _activeBackgroundImage, value); private set => SetProperty(ref _activeBackgroundImage, value);
} }
public Source? SelectedSource public SceneElement? SelectedElement
{ {
get => _selectedSource; get => _selectedElement;
set set
{ {
if (SetProperty(ref _selectedSource, value)) if (SetProperty(ref _selectedElement, value))
OnPropertyChanged(nameof(IsWebcamSelected)); OnPropertyChanged(nameof(IsWebcamSelected));
} }
} }
/// <summary>The Add → Webcam menu item. One webcam app-wide — once one exists it's greyed out.</summary> /// <summary>The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.</summary>
public bool CanAddWebcam => _webcamSource == null; public bool CanAddWebcamToActiveScene => ActiveScene?.WebcamConfig == null;
/// <summary>
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
/// webcam config (add one) or hides it (unhide — keeps the config row).
/// </summary>
public bool CanShowWebcamInActiveScene
=> ActiveScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
public bool CanChangeWebcam => _webcam != null;
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary> /// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
public bool IsWebcamSelected => SelectedSource?.Type == SourceType.Webcam; public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
public StreamStatus StreamStatus public StreamStatus StreamStatus
{ {
@@ -152,9 +165,9 @@ public class MainViewModel : ViewModelBase
public bool LiveIndicatorVisible => IsLive; public bool LiveIndicatorVisible => IsLive;
public bool ShowStartStream => IsOffline; public bool ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive; public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0; public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Sources.Count == 0; public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the // Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately. // cadence timer is stopped and any active flash is hidden immediately.
@@ -185,7 +198,7 @@ public class MainViewModel : ViewModelBase
private void UpdateActiveBackground() private void UpdateActiveBackground()
{ {
var background = ActiveScene?.Sources.FirstOrDefault(s => s.Type == SourceType.Background); var background = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId) ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
? ImageCache.Get(background.AssetId) ? ImageCache.Get(background.AssetId)
: null; : null;
@@ -315,6 +328,44 @@ public class MainViewModel : ViewModelBase
private const double MasterFrameWidth = 1920; private const double MasterFrameWidth = 1920;
private const double MasterFrameHeight = 1080; private const double MasterFrameHeight = 1080;
// Webcam size safeguard: no more than half the frame in any dimension
// (960x540 over the 1920x1080 master), and no less than 10% of it
// (192x108). Enforced at resize and on layout load.
public const double WebcamMaxWidth = 960;
public const double WebcamMaxHeight = 540;
public const double WebcamMinWidth = MasterFrameWidth * 0.1;
public const double WebcamMinHeight = MasterFrameHeight * 0.1;
internal static void ClampWebcamToBounds(WebcamSceneConfig config)
{
var scale = Math.Min(WebcamMaxWidth / config.Width, WebcamMaxHeight / config.Height);
if (scale < 1)
{
config.Width = Math.Round(config.Width * scale);
config.Height = Math.Round(config.Height * scale);
return;
}
var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
if (minScale > 1)
{
config.Width = Math.Round(config.Width * minScale);
config.Height = Math.Round(config.Height * minScale);
}
}
// One-time heal for layouts saved before the rect dims were persisted (v3→v4):
// a Traditional webcam that ended up square (Round resize then reload lost the
// pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
// a square bounding box is correct there — and an explicit pre-Round rect wins.
internal static void HealLegacySquareRect(WebcamSceneConfig config)
{
if (config.ClipShape != ClipShape.Traditional) return;
if (config.RectWidth != null || config.RectHeight != null) return;
if (Math.Abs(config.Width - config.Height) >= 1) return;
config.Width = Math.Round(config.Height * 16.0 / 9.0);
}
public QualityOption[] QualityOptions { get; } = public QualityOption[] QualityOptions { get; } =
{ {
new("1080p60", 60, 8.0, 1920, 1080), new("1080p60", 60, 8.0, 1920, 1080),
@@ -419,6 +470,8 @@ public class MainViewModel : ViewModelBase
public ICommand AddSourceCommand { get; } public ICommand AddSourceCommand { get; }
public ICommand AddImageCommand { get; } public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; } public ICommand RemoveSourceCommand { get; }
public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; }
public ICommand StartStreamCommand { get; } public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; } public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; } public ICommand OpenSettingsCommand { get; }
@@ -466,7 +519,9 @@ public class MainViewModel : ViewModelBase
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene)); ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
AddSourceCommand = new RelayCommand(type => AddSource(type as string)); AddSourceCommand = new RelayCommand(type => AddSource(type as string));
AddImageCommand = new RelayCommand(_ => AddImage()); AddImageCommand = new RelayCommand(_ => AddImage());
RemoveSourceCommand = new RelayCommand(source => RemoveSource(source as Source)); RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings")); OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug")); OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request")); OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
@@ -555,6 +610,12 @@ public class MainViewModel : ViewModelBase
AddScene("Chat", isChatScene: true); AddScene("Chat", isChatScene: true);
AddScene("Ending"); AddScene("Ending");
} }
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
{
ClampWebcamToBounds(config);
HealLegacySquareRect(config);
}
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded"); AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
} }
finally finally
@@ -568,29 +629,35 @@ public class MainViewModel : ViewModelBase
AppLog.Write("LoadLayout end"); AppLog.Write("LoadLayout end");
} }
// Finds the persisted webcam source (at most one app-wide) and re-acquires // After a layout load / file open: re-point the webcam identity, stop the
// its camera after a layout load / file open. Releasing the previous session // previous device if it changed, and acquire the current one once per scene
// unconditionally keeps the refcount honest even when the device is unchanged. // that uses it (CameraManager refcounts by DeviceId — one camera session).
private void ReacquireWebcam() private void ReacquireWebcam()
{ {
var webcam = Scenes.SelectMany(s => s.Sources).FirstOrDefault(s => s.Type == SourceType.Webcam); var previousDevice = _webcam?.DeviceId;
if (ReferenceEquals(_webcamSource, webcam)) return; _webcam = _layoutStore.Webcam;
var newDevice = _webcam?.DeviceId;
if (_webcamSource != null && !string.IsNullOrWhiteSpace(_webcamSource.DeviceId)) if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
_ = _cameraManager.ReleaseAsync(_webcamSource.DeviceId); _ = _cameraManager.ReleaseAllAsync(previousDevice);
_webcamSource = webcam; OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
if (webcam != null && !string.IsNullOrWhiteSpace(webcam.DeviceId))
_ = _cameraManager.AcquireAsync(webcam.DeviceId); if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
if (previousDevice == newDevice) return;
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
_ = _cameraManager.AcquireAsync(newDevice);
} }
// CameraManager creates the shared WriteableBitmap on the UI thread at the // CameraManager creates the shared WriteableBitmap on the UI thread at the
// device's frame size; the webcam Source's preview picks it up from here. // device's frame size; every scene's webcam config picks it up from here.
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap) private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
{ {
if (_webcamSource?.DeviceId == deviceId) if (_webcam?.DeviceId != deviceId) return;
_webcamSource.VideoImageSource = bitmap; foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
config.VideoImageSource = bitmap;
} }
public void Shutdown() public void Shutdown()
@@ -606,7 +673,7 @@ public class MainViewModel : ViewModelBase
_saveDebounce?.Stop(); _saveDebounce?.Stop();
try try
{ {
_layoutStore.Save(Scenes); _layoutStore.Save(Scenes, _webcam);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -661,31 +728,36 @@ public class MainViewModel : ViewModelBase
private void WireScene(Scene scene) private void WireScene(Scene scene)
{ {
scene.PropertyChanged += OnScenePropertyChanged; scene.PropertyChanged += OnScenePropertyChanged;
scene.Sources.CollectionChanged += OnSourcesChanged; scene.Elements.CollectionChanged += OnElementsChanged;
} }
private void UnwireScene(Scene scene) private void UnwireScene(Scene scene)
{ {
scene.PropertyChanged -= OnScenePropertyChanged; scene.PropertyChanged -= OnScenePropertyChanged;
scene.Sources.CollectionChanged -= OnSourcesChanged; scene.Elements.CollectionChanged -= OnElementsChanged;
} }
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e) private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
=> ScheduleSave(); => ScheduleSave();
private void OnSourcesChanged(object? sender, NotifyCollectionChangedEventArgs e) private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{ {
if (e.NewItems != null) if (e.NewItems != null)
foreach (Source source in e.NewItems) foreach (SceneElement element in e.NewItems)
source.PropertyChanged += OnSourcePropertyChanged; element.PropertyChanged += OnElementPropertyChanged;
if (e.OldItems != null) if (e.OldItems != null)
foreach (Source source in e.OldItems) foreach (SceneElement element in e.OldItems)
source.PropertyChanged -= OnSourcePropertyChanged; element.PropertyChanged -= OnElementPropertyChanged;
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
ScheduleSave(); ScheduleSave();
} }
private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e) private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
=> ScheduleSave(); {
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
ScheduleSave();
}
private void ScheduleSave() private void ScheduleSave()
{ {
@@ -728,9 +800,14 @@ public class MainViewModel : ViewModelBase
var scene = ActiveScene; var scene = ActiveScene;
if (scene == null) return; if (scene == null) return;
if (string.Equals(type, "webcam", StringComparison.OrdinalIgnoreCase))
{
_ = AddWebcamToActiveSceneAsync();
return;
}
var sourceType = type?.ToLowerInvariant() switch var sourceType = type?.ToLowerInvariant() switch
{ {
"webcam" => SourceType.Webcam,
"screen" => SourceType.DisplayCapture, "screen" => SourceType.DisplayCapture,
"window" => SourceType.WindowCapture, "window" => SourceType.WindowCapture,
"background" => SourceType.Background, "background" => SourceType.Background,
@@ -739,7 +816,6 @@ public class MainViewModel : ViewModelBase
}; };
var baseName = sourceType switch var baseName = sourceType switch
{ {
SourceType.Webcam => "Webcam",
SourceType.DisplayCapture => "Screen", SourceType.DisplayCapture => "Screen",
SourceType.WindowCapture => "Window", SourceType.WindowCapture => "Window",
SourceType.Background => "Background", SourceType.Background => "Background",
@@ -748,12 +824,6 @@ public class MainViewModel : ViewModelBase
_ => "Source", _ => "Source",
}; };
if (sourceType == SourceType.Webcam)
{
_ = AddWebcamSourceAsync();
return;
}
if (sourceType == SourceType.Background) if (sourceType == SourceType.Background)
{ {
var bytes = PickImageBytes("Choose a backdrop image"); var bytes = PickImageBytes("Choose a backdrop image");
@@ -761,7 +831,7 @@ public class MainViewModel : ViewModelBase
var assetId = AddAsset(bytes); var assetId = AddAsset(bytes);
if (assetId == null) return; if (assetId == null) return;
var existing = scene.Sources.FirstOrDefault(s => s.Type == SourceType.Background); var existing = scene.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (existing != null) if (existing != null)
{ {
existing.AssetId = assetId; existing.AssetId = assetId;
@@ -769,26 +839,73 @@ public class MainViewModel : ViewModelBase
return; return;
} }
scene.Sources.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId }); scene.Elements.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground(); UpdateActiveBackground();
return; return;
} }
var count = scene.Sources.Count(s => s.Type == sourceType); var count = scene.Elements.OfType<Source>().Count(s => s.Type == sourceType);
var name = count == 0 ? baseName : $"{baseName} {count + 1}"; var name = count == 0 ? baseName : $"{baseName} {count + 1}";
scene.Sources.Add(new Source { Name = name, Type = sourceType }); scene.Elements.Add(new Source { Name = name, Type = sourceType });
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground(); UpdateActiveBackground();
} }
private async Task AddWebcamSourceAsync() // Adds the webcam to the active scene. The camera is picked once app-wide
// (first add); afterwards "Add Webcam" just places the existing webcam here
// at the default spot — each scene's config is independent (webcam.{scene}.config).
private async Task AddWebcamToActiveSceneAsync()
{ {
var scene = ActiveScene; var scene = ActiveScene;
if (scene == null || _webcamSource != null) return; if (scene == null || scene.WebcamConfig != null) return;
if (_webcam == null)
{
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
var device = dialog.PickedDevice;
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam));
}
var config = new WebcamSceneConfig
{
WebcamId = _webcam.Id,
Name = _webcam.Name,
Width = 480,
Height = 270,
X = MasterFrameWidth - 480 - 32,
Y = MasterFrameHeight - 270 - 32,
};
scene.Elements.Add(config);
SelectedElement = config;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
if (!started)
{
MessageBox.Show(
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
// Swaps the device on the app-wide webcam identity. The old device is stopped
// unconditionally; each scene that uses the webcam re-acquires the new one so
// the per-config refcount stays honest.
private async Task ChangeWebcamAsync()
{
if (_webcam == null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator)) var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{ {
@@ -796,35 +913,49 @@ public class MainViewModel : ViewModelBase
}; };
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return; if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
var device = dialog.PickedDevice; var oldDevice = _webcam.DeviceId;
var source = new Source var newDevice = dialog.PickedDevice.Id;
{ if (oldDevice == newDevice) return;
Name = "Webcam",
Type = SourceType.Webcam,
DeviceId = device.Id,
Width = 480,
Height = 270,
X = 1920 - 480 - 32,
Y = 1080 - 270 - 32,
};
_webcamSource = source; _webcam.DeviceId = newDevice;
OnPropertyChanged(nameof(CanAddWebcam)); _webcam.Name = dialog.PickedDevice.DisplayName;
scene.Sources.Add(source); ScheduleSave();
SelectedSource = source;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
var started = await _cameraManager.AcquireAsync(device.Id); if (!string.IsNullOrWhiteSpace(oldDevice))
if (!started) await _cameraManager.ReleaseAllAsync(oldDevice);
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
{ {
MessageBox.Show( var started = await _cameraManager.AcquireAsync(newDevice);
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.", if (!started)
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning); {
MessageBox.Show(
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
break;
}
} }
} }
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
// existing (hidden) config — the config row survives Hide in this scene — or
// adds the webcam here for the first time (which opens the camera picker).
private void ShowWebcamInActiveScene()
{
var scene = ActiveScene;
if (scene == null) return;
var config = scene.WebcamConfig;
if (config != null)
{
config.IsVisible = true;
SelectedElement = config;
return;
}
_ = AddWebcamToActiveSceneAsync();
}
private void AddImage() private void AddImage()
{ {
var scene = ActiveScene; var scene = ActiveScene;
@@ -859,7 +990,7 @@ public class MainViewModel : ViewModelBase
{ {
var candidates = new List<ReuseImageCandidate>(); var candidates = new List<ReuseImageCandidate>();
foreach (var scene in Scenes) foreach (var scene in Scenes)
foreach (var source in scene.Sources.Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId))) foreach (var source in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
{ {
candidates.Add(new ReuseImageCandidate candidates.Add(new ReuseImageCandidate
{ {
@@ -919,7 +1050,7 @@ public class MainViewModel : ViewModelBase
var scene = ActiveScene; var scene = ActiveScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return; if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
var count = scene.Sources.Count(s => s.Type == SourceType.Image); var count = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.Image);
var name = count == 0 ? "Image" : $"Image {count + 1}"; var name = count == 0 ? "Image" : $"Image {count + 1}";
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId }; var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
@@ -934,25 +1065,39 @@ public class MainViewModel : ViewModelBase
source.Y = (1080 - source.Height) / 2; source.Y = (1080 - source.Height) / 2;
} }
scene.Sources.Add(source); scene.Elements.Add(source);
SelectedSource = source; SelectedElement = source;
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground(); UpdateActiveBackground();
} }
private void RemoveSource(Source? source) // Removes an element from the active scene. For a webcam config this drops the
// scene's usage and releases one camera reference (CameraManager stops the
// session when the last using scene lets go). When the last config anywhere is
// removed, the webcam identity is cleared too — re-adding opens the picker
// again instead of silently resurrecting the old camera.
private void RemoveElement(SceneElement? element)
{ {
var scene = ActiveScene; var scene = ActiveScene;
if (scene == null || source == null) return; if (scene == null || element == null) return;
if (source.Type == SourceType.Webcam && ReferenceEquals(source, _webcamSource)) if (element is WebcamSceneConfig && _webcam != null)
{ {
_webcamSource = null; if (SelectedElement == element)
OnPropertyChanged(nameof(CanAddWebcam)); SelectedElement = null;
if (!string.IsNullOrWhiteSpace(source.DeviceId)) _ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
_ = _cameraManager.ReleaseAsync(source.DeviceId);
} }
scene.Sources.Remove(source); scene.Elements.Remove(element);
if (element is WebcamSceneConfig && _webcam != null
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
{
_webcam = null;
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
}
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground(); UpdateActiveBackground();
+1 -1
View File
@@ -4,7 +4,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamSourceAsync` (picker → default 480×270 bottom-right placement → acquire), one-camera app-wide (`CanAddWebcam` greys the menu), `ReacquireWebcam` after layout load, `OnCameraPreviewBitmapChanged` forwards the shared bitmap into `Source.VideoImageSource` | | `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load, `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds` (50% cap seam) |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests | | `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel | | `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` | | `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
+63 -19
View File
@@ -28,11 +28,18 @@ default response style above stays in effect unless invoked.
## Run ## Run
```bash ```bash
dotnet build # Windows only — WPF requires Windows target # From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project:
dotnet run "/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
"/mnt/c/Program Files/dotnet/dotnet.exe" run
``` ```
Note: `EnableWindowsTargeting=true` is set in `ytLive.csproj`, so the project can be restored/built from WSL, but running requires Windows. `EnableWindowsTargeting=true` in `ytLive.csproj` lets a cold restore work from WSL, but a Linux
`dotnet run`/`build` re-downloads 100M+ of `windowsdesktop.app.*` packs into the Linux NuGet cache
(which lacks them) over the slow 9p `/mnt/c` bridge — twice, because the WPF `_wpftmp` generated
project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the
packs resolve from `C:\Program Files\dotnet\packs`, so the Windows host never re-downloads.
Never use `--no-restore` right after an interrupted restore — the stale `project.assets.json`
produces misleading `NETSDK1064` "package not found" errors. Running requires Windows anyway.
## Tests ## Tests
@@ -46,7 +53,8 @@ dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLi
Good Dog Rule: ONE integration test per branch, ONE test per PR. Current: TokenStore DPAPI Good Dog Rule: ONE integration test per branch, ONE test per PR. Current: TokenStore DPAPI
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount + roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
interaction test, LayoutStore delete roundtrip — 13 passing. interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip —
25 passing.
### Real-MainWindow tests MUST be hermetic (DB pollution bug) ### Real-MainWindow tests MUST be hermetic (DB pollution bug)
@@ -61,8 +69,8 @@ test's fake `test-camera`). Rule: a test that constructs `MainWindow` MUST first
`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`. `ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`.
The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so
save/load round trips are exact: a source removed in the UI (`RemoveSource` save/load round trips are exact: an element removed in the UI (`RemoveElement`
`scene.Sources.Remove``OnSourcesChanged` → debounced `ScheduleSave`, plus `Shutdown` on `scene.Elements.Remove``OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this). close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
## Architecture ## Architecture
@@ -92,7 +100,7 @@ C# / WPF (.NET 8) following MVVM:
### Current limitations / TODOs ### Current limitations / TODOs
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`) - `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
- Scene/source/asset layout persists (SQLite, schema v2); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending) - Scene/source/asset layout persists (SQLite, schema v4); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream - `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- Webcam capture is shipped (milestone 1); **screen capture, scene compositing/encoding, RTMP are next** - Webcam capture is shipped (milestone 1); **screen capture, scene compositing/encoding, RTMP are next**
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead - `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
@@ -118,26 +126,62 @@ C# / WPF (.NET 8) following MVVM:
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference - **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working. camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
- **One camera app-wide:** `CameraManager` refcounts sessions by `DeviceId` (a session is created with - **One webcam, many scenes (schema v3):** a singleton `Webcam` row holds the identity
`RefCount = 1`; repeat acquire bumps it; the last release stops + disposes). The Add menu greys Webcam (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig` (position/size/clip/mirror/
out once a webcam source exists anywhere (`CanAddWebcam`); the "OBS time" story is a one-camera limit. border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam
(`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture
sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last
release stops + disposes). The Add Webcam menu greys out when the **active** scene already has a
config (`CanAddWebcamToActiveScene`); showing a hidden webcam reuses the existing config
(`CanShowWebcamInActiveScene` / empty-canvas right-click "Show Webcam"). **Removing the last webcam
config anywhere clears the identity** (`_webcam = null`), so re-adding opens the picker again
instead of resurrecting the old camera.
- **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()`
snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and
restores them when switching back — otherwise the Round resize lock (square) would leave a square
behind. The rect dims are **persisted** (`WebcamSceneConfig.RectWidth`/`RectHeight`, nullable), so a
reloaded Round webcam still restores its pre-Round aspect instead of staying square. A one-time
`HealLegacySquareRect` (load only) widens a pre-v4 `Traditional` config that ended up square to 16:9
(keeps height; Round and explicit rect dims are untouched).
- **Device swap / layout reload:** `ChangeWebcamAsync` (picker) and `ReacquireWebcam` (after load)
release the old device with `ReleaseAllAsync` — a forced full drop that zeroes the refcount and
stops the source regardless of how many scenes held it (the per-config count isn't known once the
scenes are replaced) — then `AcquireAsync` the new device once per config.
- **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread - **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread
at the device's frame size (first frame), forwarded to the single webcam `Source.VideoImageSource` via at the device's frame size (first frame), forwarded to every `WebcamSceneConfig.VideoImageSource` via
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher `PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher
(at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps (at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
device never drowns the render thread. device never drowns the render thread.
- **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored` - **Clip/mirror/border:** per-element `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
(`ScaleX = -1`). Rendered in the preview DataTemplate (Image for Traditional, `ImageBrush` inside an (`ScaleX = -1`) + the OSB-standard static border (`BorderColor` `#RRGGBB` or `""`=none, `BorderOpacity`
`Ellipse` for Round); toggled from the source chip; persisted in the layout DB. 01, `BorderWidth` 020, `BorderAnimation` `None|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow|
Electricity|Sparkles`). Rendered in the preview DataTemplate; toggled from the element's right-click
context menu (webcam menu: Change Webcam…, Border Effect submenu — all 9 items enabled, values persist,
rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in
this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty
preview canvas has its own Show Webcam entry.
- The Round `Ellipse` is wrapped in a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it renders - The Round `Ellipse` is wrapped in a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it renders
as a true circle (diameter = the shorter source dimension) instead of an oval stretched to the as a true circle (diameter = the shorter element dimension) instead of an oval stretched to the
source rect — and the traditional `Image` keeps `UniformToFill` over the full rect. element rect — and the traditional `Image` keeps `UniformToFill` over the full rect. The Round
border is a centered `Ellipse` at `Width/Height = RoundBorderSize`.
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`. - Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
- **Webcam size clamp:** `ClampWebcamToBounds` (internal — test seam) enforces 50% of the 1920×1080
master per dimension (960×540 max) and no less than 10% (192×108) at resize + load;
`RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp.
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in - **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
the source — making the corner handle ungrabbable. The source Grid carries `Background="Transparent"` the element — making the corner handle ungrabbable. The element Grid carries
(whole rect draggable) and the `SelectionOverlay` (dashed border + corner dot) is `Background="Transparent"` (whole rect draggable; the empty canvas Grid uses the same trick for
`IsHitTestVisible="False"` so it never intercepts the click. right-click Show Webcam) and the `SelectionOverlay` (dashed border + corner dot) is
`IsHitTestVisible="False"` so it never intercepts the click. Two things make the webcam menu work:
(1) the `ContextMenu` pins its own `DataContext` to `PlacementTarget.DataContext` — a `ContextMenu`
isn't in the visual tree, so without it the Click-handler `DataContext:` patterns (and the
IsChecked/slider bindings) silently fail; (2) `Themes/Controls.xaml` ships a full dark `MenuItem`
template — `PART_Popup` (submenu popups), a popup `ItemsPresenter` (the Border Opacity/Thickness
sliders live in Items, so they render in a hover flyout), a `` checkmark column, and a `` arrow
driven by `HasItems`. An earlier bare `Border + Header` template dropped all three: submenus never
opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the
Click handlers were fine.
- **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware - **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
to the encoder task. to the encoder task.
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
dotnet run --project ytLive.csproj
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
cd "$(dirname "$0")"
WINPATH=$(wslpath -w "$PWD")
"/mnt/c/Program Files/dotnet/dotnet.exe" run --project "$WINPATH\\ytLive.csproj"
+54 -12
View File
@@ -9,37 +9,79 @@ namespace ytLive.Tests;
/// <summary> /// <summary>
/// The layout DB is a full rewrite on every save (DELETE all scenes/sources, /// The layout DB is a full rewrite on every save (DELETE all scenes/sources,
/// re-insert from memory). This guards the round trip: sources the user deletes /// re-insert from memory). This guards the round trip: webcam configs the user
/// in the UI must not come back after a save + reload. /// removes in the UI must not come back after a save + reload.
/// </summary> /// </summary>
public class LayoutStorePersistenceTests public class LayoutStorePersistenceTests
{ {
[Fact] [Fact]
public void Deleted_Webcam_Source_Does_Not_Return_After_Save_And_Reload() public void Deleted_Webcam_Config_Does_Not_Return_After_Save_And_Reload()
{ {
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db"); var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try try
{ {
using var store = new LayoutStore(path); using var store = new LayoutStore(path);
var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
var scene = new Scene { Name = "Starting" }; var scene = new Scene { Name = "Starting" };
scene.Sources.Add(new Source scene.Elements.Add(new WebcamSceneConfig
{ {
Name = "Webcam", WebcamId = webcam.Id,
Type = SourceType.Webcam, Name = webcam.Name,
DeviceId = "real-device",
Width = 480, Width = 480,
Height = 270, Height = 270,
}); });
store.Save(new[] { scene }); store.Save(new[] { scene }, webcam);
var reloaded = store.Load(); var reloaded = store.Load();
Assert.Single(reloaded[0].Sources); var config = Assert.Single(reloaded[0].Elements);
Assert.IsType<WebcamSceneConfig>(config);
reloaded[0].Sources.RemoveAt(0); reloaded[0].Elements.RemoveAt(0);
store.Save(reloaded); store.Save(reloaded, store.Webcam);
var afterDelete = store.Load(); var afterDelete = store.Load();
Assert.Empty(afterDelete[0].Sources); Assert.Empty(afterDelete[0].Elements);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// Round-to-rect restore is persisted (schema v4): a Round webcam resized to a
// square saves its pre-Round rect dims, and a reloaded config restores them on
// toggle-back instead of staying square.
[Fact]
public void Pre_Round_Rect_Dims_Survive_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
var scene = new Scene { Name = "Starting" };
scene.Elements.Add(new WebcamSceneConfig
{
WebcamId = webcam.Id,
Name = webcam.Name,
Width = 400,
Height = 400,
ClipShape = ClipShape.Round,
RectWidth = 480,
RectHeight = 270,
});
store.Save(new[] { scene }, webcam);
var reloaded = store.Load();
var config = Assert.IsType<WebcamSceneConfig>(Assert.Single(reloaded[0].Elements));
config.ToggleClipShape();
Assert.Equal(ClipShape.Traditional, config.ClipShape);
Assert.Equal(480, config.Width);
Assert.Equal(270, config.Height);
Assert.Null(config.RectWidth);
Assert.Null(config.RectHeight);
} }
finally finally
{ {
+7 -8
View File
@@ -61,29 +61,28 @@ public sealed class RoundClipInteractionTests
window.UpdateLayout(); window.UpdateLayout();
var scene = vm.ActiveScene!; var scene = vm.ActiveScene!;
var source = new Source var webcam = new WebcamSceneConfig
{ {
WebcamId = "test-camera",
Name = "Webcam", Name = "Webcam",
Type = SourceType.Webcam,
DeviceId = "test-camera",
X = 1408, X = 1408,
Y = 778, Y = 778,
Width = 480, Width = 480,
Height = 270, Height = 270,
}; };
scene.Sources.Add(source); scene.Elements.Add(webcam);
vm.SelectedSource = source; vm.SelectedElement = webcam;
window.UpdateLayout(); window.UpdateLayout();
var previewGrid = (Grid)window.FindName("PreviewGrid")!; var previewGrid = (Grid)window.FindName("PreviewGrid")!;
var canvasGrid = (Grid)window.FindName("CanvasGrid")!; var canvasGrid = (Grid)window.FindName("CanvasGrid")!;
var toWindow = canvasGrid.TransformToVisual(window); var toWindow = canvasGrid.TransformToVisual(window);
var corner = new Point(source.X + source.Width, source.Y + source.Height); var corner = new Point(webcam.X + webcam.Width, webcam.Y + webcam.Height);
foreach (var shape in new[] { ClipShape.Traditional, ClipShape.Round }) foreach (var shape in new[] { ClipShape.Traditional, ClipShape.Round })
{ {
source.ClipShape = shape; webcam.ClipShape = shape;
window.UpdateLayout(); window.UpdateLayout();
var cornerInWindow = toWindow.Transform(corner); var cornerInWindow = toWindow.Transform(corner);
@@ -100,7 +99,7 @@ public sealed class RoundClipInteractionTests
} }
// The round clip must render as a circle (square bounding box), not an oval. // The round clip must render as a circle (square bounding box), not an oval.
source.ClipShape = ClipShape.Round; webcam.ClipShape = ClipShape.Round;
window.UpdateLayout(); window.UpdateLayout();
var ellipse = FindRoundEllipse(window); var ellipse = FindRoundEllipse(window);
Assert.NotNull(ellipse); Assert.NotNull(ellipse);
+137
View File
@@ -0,0 +1,137 @@
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The webcam size safeguard: no scene placement may exceed half the 1920×1080
/// master frame (960×540), nor drop below 10% of it (192×108). Enforced at
/// resize (MainWindow) and defensively again on every layout load.
/// </summary>
public class WebcamSafeguardTests
{
[Fact]
public void Oversize_Webcam_Is_Capped_To_Half_The_Frame()
{
var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 };
MainViewModel.ClampWebcamToBounds(webcam);
Assert.Equal(MainViewModel.WebcamMaxWidth, webcam.Width);
Assert.Equal(MainViewModel.WebcamMaxHeight, webcam.Height);
}
[Fact]
public void Wide_Webcam_Scales_To_Fit_Both_Dimensions()
{
var webcam = new WebcamSceneConfig { Width = 1000, Height = 500 };
MainViewModel.ClampWebcamToBounds(webcam);
Assert.Equal(960, webcam.Width);
Assert.Equal(480, webcam.Height);
}
[Fact]
public void Tiny_Webcam_Is_Brought_Up_To_The_Minimum()
{
var webcam = new WebcamSceneConfig { Width = 10, Height = 10 };
MainViewModel.ClampWebcamToBounds(webcam);
Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Width);
Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Height);
}
[Fact]
public void Short_Wide_Webcam_Meets_Both_Minimums()
{
var webcam = new WebcamSceneConfig { Width = 100, Height = 20 };
MainViewModel.ClampWebcamToBounds(webcam);
Assert.True(webcam.Width >= MainViewModel.WebcamMinWidth);
Assert.Equal(MainViewModel.WebcamMinHeight, webcam.Height);
}
[Fact]
public void Round_Border_Size_Tracks_The_Shorter_Clamped_Dimension()
{
var webcam = new WebcamSceneConfig { Width = 1920, Height = 500 };
MainViewModel.ClampWebcamToBounds(webcam);
Assert.Equal(960, webcam.Width);
Assert.Equal(250, webcam.Height);
Assert.Equal(250, webcam.RoundBorderSize);
}
[Fact]
public void Round_Then_Back_To_Rect_Restores_The_Original_Aspect()
{
var webcam = new WebcamSceneConfig { Width = 480, Height = 270 };
webcam.ToggleClipShape();
Assert.Equal(ClipShape.Round, webcam.ClipShape);
Assert.Equal(480, webcam.Width);
Assert.Equal(270, webcam.Height);
Assert.Equal(480, webcam.RectWidth);
Assert.Equal(270, webcam.RectHeight);
webcam.ToggleClipShape();
Assert.Equal(ClipShape.Traditional, webcam.ClipShape);
Assert.Equal(480, webcam.Width);
Assert.Equal(270, webcam.Height);
Assert.Null(webcam.RectWidth);
Assert.Null(webcam.RectHeight);
}
// The persisted-reload repro: Round + square dims (from a resize while Round),
// no in-memory snapshot — exactly what a relaunch produces. The persisted rect
// dims must restore the 16:9 on toggle-back.
[Fact]
public void Reloaded_Round_Config_Restores_Persisted_Rect_Dims()
{
var webcam = new WebcamSceneConfig
{
Width = 400,
Height = 400,
ClipShape = ClipShape.Round,
RectWidth = 480,
RectHeight = 270,
};
webcam.ToggleClipShape();
Assert.Equal(ClipShape.Traditional, webcam.ClipShape);
Assert.Equal(480, webcam.Width);
Assert.Equal(270, webcam.Height);
Assert.Null(webcam.RectWidth);
Assert.Null(webcam.RectHeight);
}
[Fact]
public void Legacy_Square_Rect_Is_Widened_To_16x9()
{
var webcam = new WebcamSceneConfig { Width = 414.92, Height = 414.92 };
MainViewModel.HealLegacySquareRect(webcam);
Assert.Equal(738, webcam.Width);
Assert.Equal(414.92, webcam.Height);
}
[Fact]
public void Legacy_Square_Heal_Leaves_Non_Square_Untouched()
{
var webcam = new WebcamSceneConfig { Width = 480, Height = 270 };
MainViewModel.HealLegacySquareRect(webcam);
Assert.Equal(480, webcam.Width);
Assert.Equal(270, webcam.Height);
}
[Fact]
public void Legacy_Square_Heal_Leaves_Round_Untouched()
{
var webcam = new WebcamSceneConfig { Width = 400, Height = 400, ClipShape = ClipShape.Round };
MainViewModel.HealLegacySquareRect(webcam);
Assert.Equal(400, webcam.Width);
Assert.Equal(400, webcam.Height);
}
[Fact]
public void Legacy_Square_Heal_Respects_Explicit_Rect_Dims()
{
var webcam = new WebcamSceneConfig { Width = 400, Height = 400, RectWidth = 480, RectHeight = 270 };
MainViewModel.HealLegacySquareRect(webcam);
Assert.Equal(400, webcam.Width);
Assert.Equal(400, webcam.Height);
}
}