Round clip: true circle (Viewbox/1x1 ellipse) + grabbable corner handle (transparent source grid, non-hit-testable selection overlay); resize locks square in round mode

This commit is contained in:
2026-08-06 15:05:21 -07:00
parent b64830ed11
commit 7bc70872d9
4 changed files with 169 additions and 24 deletions
+7 -2
View File
@@ -8,7 +8,7 @@
mc:Ignorable="d" mc:Ignorable="d"
Title="{Binding WindowTitle}" Height="720" Width="1280" Title="{Binding WindowTitle}" Height="720" Width="1280"
MinWidth="1180" MinHeight="600" MinWidth="1180" MinHeight="600"
Icon="/Assets/llama-logo-icon.png" Icon="/ytLive;component/Assets/llama-logo-icon.png"
Background="#1a1a2e" Background="#1a1a2e"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Closing="MainWindow_Closing" Closing="MainWindow_Closing"
@@ -323,6 +323,7 @@
<DataTemplate> <DataTemplate>
<Grid Width="{Binding Width}" Height="{Binding Height}" <Grid Width="{Binding Width}" Height="{Binding Height}"
Opacity="{Binding Opacity}" Opacity="{Binding Opacity}"
Background="Transparent"
RenderTransformOrigin="0.5,0.5"> RenderTransformOrigin="0.5,0.5">
<Grid.RenderTransform> <Grid.RenderTransform>
<ScaleTransform ScaleX="{Binding MirrorScale}"/> <ScaleTransform ScaleX="{Binding MirrorScale}"/>
@@ -348,6 +349,8 @@
</Style> </Style>
</Image.Style> </Image.Style>
</Image> </Image>
<Viewbox Stretch="Uniform">
<Grid Width="1" Height="1">
<Ellipse> <Ellipse>
<Ellipse.Style> <Ellipse.Style>
<Style TargetType="Ellipse"> <Style TargetType="Ellipse">
@@ -368,6 +371,8 @@
</Ellipse.Fill> </Ellipse.Fill>
</Ellipse> </Ellipse>
</Grid> </Grid>
</Viewbox>
</Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
@@ -687,7 +692,7 @@
<!-- About --> <!-- About -->
<StackPanel Grid.Row="1" Margin="0,16,0,0" <StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}"> Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
<Image Source="/Assets/llama-logo.png" Height="140" Stretch="Uniform" <Image Source="/ytLive;component/Assets/llama-logo.png" Height="140" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,12"/> HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="ytLlive" FontSize="22" FontWeight="Bold" Foreground="#e94560" <TextBlock Text="ytLlive" FontSize="22" FontWeight="Bold" Foreground="#e94560"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
+1 -1
View File
@@ -153,7 +153,7 @@ public partial class MainWindow : Window
if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel)) if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel))
{ {
_isResizing = true; _isResizing = true;
_resizeAspect = sel.Width / Math.Max(1, sel.Height); _resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
grid.CaptureMouse(); grid.CaptureMouse();
e.Handled = true; e.Handled = true;
return; return;
+10 -2
View File
@@ -110,8 +110,16 @@ C# / WPF (.NET 8) following MVVM:
device never drowns the render thread. device never drowns the render thread.
- **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored` - **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
(`ScaleX = -1`). Rendered in the preview DataTemplate (Image for Traditional, `ImageBrush` inside an (`ScaleX = -1`). Rendered in the preview DataTemplate (Image for Traditional, `ImageBrush` inside an
`Ellipse` for Round); toggled from the source chip; persisted in the layout DB. Round hit-testing is the `Ellipse` for Round); toggled from the source chip; persisted in the layout DB.
same rectangle as Traditional (selection overlay is rectangular) — acceptable for now. - 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
source rect — and the traditional `Image` keeps `UniformToFill` over the full rect.
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
- **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 source — making the corner handle ungrabbable. The source Grid carries `Background="Transparent"`
(whole rect draggable) and the `SelectionOverlay` (dashed border + corner dot) is
`IsHitTestVisible="False"` so it never intercepts the click.
- **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.
+132
View File
@@ -0,0 +1,132 @@
using System;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
using System.Windows.Threading;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Reproduces the real MainWindow preview to check the round-clip corner
/// handle: whether a click at the corner actually stays inside the preview
/// (doesn't fall through and deselect the source) and whether the round clip
/// renders as a circle rather than an oval.
/// </summary>
public sealed class RoundClipInteractionTests
{
[Fact]
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
Run();
}
catch (Exception ex)
{
failure = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (failure != null)
throw new Xunit.Sdk.XunitException("Round-clip interaction failed: " + failure);
}
private void Run()
{
var app = new App();
app.InitializeComponent();
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
window.UpdateLayout();
var scene = vm.ActiveScene!;
var source = new Source
{
Name = "Webcam",
Type = SourceType.Webcam,
DeviceId = "test-camera",
X = 1408,
Y = 778,
Width = 480,
Height = 270,
};
scene.Sources.Add(source);
vm.SelectedSource = source;
window.UpdateLayout();
var previewGrid = (Grid)window.FindName("PreviewGrid")!;
var canvasGrid = (Grid)window.FindName("CanvasGrid")!;
var toWindow = canvasGrid.TransformToVisual(window);
var corner = new Point(source.X + source.Width, source.Y + source.Height);
foreach (var shape in new[] { ClipShape.Traditional, ClipShape.Round })
{
source.ClipShape = shape;
window.UpdateLayout();
var cornerInWindow = toWindow.Transform(corner);
// A click on the corner must land inside the preview (descendant
// of PreviewGrid); otherwise Window_PreviewMouseLeftButtonDown
// deselects the source and the handle can never be grabbed.
var hit = VisualTreeHelper.HitTest(window, cornerInWindow);
var grabbable = hit != null && IsDescendantOf(hit.VisualHit, previewGrid);
Assert.True(grabbable,
$"{shape}: corner click fell through to '{hit?.VisualHit.GetType().Name ?? "null"}' " +
"so the source gets deselected before the resize handler runs");
}
// The round clip must render as a circle (square bounding box), not an oval.
source.ClipShape = ClipShape.Round;
window.UpdateLayout();
var ellipse = FindRoundEllipse(window);
Assert.NotNull(ellipse);
Assert.Equal(ellipse!.RenderSize.Width, ellipse.RenderSize.Height, 1.0);
}
finally
{
window.Close();
}
}
private static Ellipse? FindRoundEllipse(Window window)
{
return Walk(window, element => element is Ellipse e && e.IsVisible) as Ellipse;
}
private static DependencyObject? Walk(DependencyObject parent, Func<DependencyObject, bool> predicate)
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (predicate(child)) return child;
var found = Walk(child, predicate);
if (found != null) return found;
}
return null;
}
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
{
while (child != null && !ReferenceEquals(child, ancestor))
child = VisualTreeHelper.GetParent(child);
return child != null;
}
}