Compare commits

...
6 Commits
Author SHA1 Message Date
gramps 769c616706 Preview: promote selected layer for hit testing
Temporarily raise the selected preview item and prioritize it in the custom hit test without changing scene order. Refresh integration-test expectations for Brand logo layers and explicit thumbnail assets.

WPF references: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.panel.zindex?view=windowsdesktop-10.0 https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer
2026-10-28 13:09:15 -07:00
gramps 7da287244b Transitions in edit mode and on output: program transitions run while recording or streaming; the preview animates the outgoing picture (fade/move fade, wipe shrinks) in every mode
Test: PreviewTransitionTests (with the transition, wipe, and brand logo tests).
2026-10-27 10:20:53 -07:00
gramps 841c905c06 Brand logo: one locked image layer per scene above the background, image from App Settings
- Source.IsBrandLogo persisted (additive migration); layer healed on load, removal blocked
- Image stored as an asset; settings keep the asset id and file name
- App Settings: Brand Logo file field with Browse
- Default placement top-left; per-scene eye uses the existing element visibility
Test: BrandLogoTests; layout/background tests pass (34). Not pushed.
2026-10-27 09:25:49 -07:00
gramps b86b6bb601 Transitions: Wipe (left-to-right reveal), added to the App Settings picker
Test: WipeTransitionTests. Not pushed.
2026-10-27 09:12:59 -07:00
gramps f67a263ccd App Settings: transition type and length picker
Cut/Fade/Move and a 100-2000 ms length, bound to the view model; changes save with the layout.
Test: TransitionPickerTests. Not pushed.
2026-10-27 09:12:37 -07:00
gramps 90cd039d98 Transitions: fade starts from the outgoing picture (was an instant cut)
The transition started with no from-frame, so BlendFrame returned the new scene at once.
Start now requests a capture and the frame pump supplies its last output on the next tick.
Test: TransitionFadeTests. Docs: ai.md, TASKS.md.

Not pushed.
2026-10-27 08:39:49 -07:00
30 changed files with 818 additions and 86 deletions
+25 -1
View File
@@ -2,7 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:Models="clr-namespace:ytLive.Models">
xmlns:Models="clr-namespace:ytLive.Models"
xmlns:services="clr-namespace:ytLive.Services">
<Grid Grid.RowSpan="3"
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
@@ -55,6 +56,29 @@
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static Models:ZoomShape.VerticalRectangle}"/>
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static Models:ZoomShape.HorizontalRectangle}"/>
</ComboBox>
<TextBlock Text="Brand Logo" Style="{StaticResource YtLabel}" Margin="0,12,0,4"/>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Style="{StaticResource YtTextBox}" Text="{Binding BrandLogoFileDisplay, Mode=OneWay}"
IsReadOnly="True" Foreground="#a0a0b0"/>
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBrandLogoCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="8,0,0,0"/>
</Grid>
<TextBlock Text="Scene Transition" Style="{StaticResource YtLabel}" Margin="0,12,0,4"/>
<ComboBox Style="{StaticResource YtComboBox}" SelectedValue="{Binding SelectedTransitionType}" SelectedValuePath="Tag">
<ComboBoxItem Content="Cut" Tag="{x:Static services:TransitionType.Cut}"/>
<ComboBoxItem Content="Fade" Tag="{x:Static services:TransitionType.Fade}"/>
<ComboBoxItem Content="Move" Tag="{x:Static services:TransitionType.Move}"/>
<ComboBoxItem Content="Wipe" Tag="{x:Static services:TransitionType.Wipe}"/>
</ComboBox>
<TextBlock Style="{StaticResource YtLabel}" Margin="0,8,0,4">
<Run Text="Transition length: "/><Run Text="{Binding TransitionDurationMs, StringFormat={}{0:0} ms}"/>
</TextBlock>
<Slider Minimum="100" Maximum="2000" TickFrequency="50" IsSnapToTickEnabled="True"
Value="{Binding TransitionDurationMs, Mode=TwoWay}"/>
</StackPanel>
<!-- Report Bug -->
+3 -1
View File
@@ -71,8 +71,10 @@
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/>
</Grid>
<Image x:Name="TransitionOverlay" Width="1920" Height="1080" Stretch="Fill"
IsHitTestVisible="False" Visibility="Collapsed"/>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
<ItemsControl x:Name="OverlayElements" ItemsSource="{Binding StagedScene.Elements}"
Width="1920" Height="1080">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
+82 -8
View File
@@ -31,27 +31,90 @@ public partial class PreviewPane : UserControl
{
InitializeComponent();
DataContextChanged += PreviewPane_DataContextChanged;
OverlayElements.ItemContainerGenerator.StatusChanged += (_, _) => UpdateSelectedLayerZOrder();
}
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is MainViewModel oldVm)
{
oldVm.PropertyChanged -= ViewModel_PropertyChanged;
oldVm.PreviewTransition -= PlayPreviewTransition;
}
if (e.NewValue is MainViewModel newVm)
{
newVm.PropertyChanged += ViewModel_PropertyChanged;
newVm.PreviewTransition += PlayPreviewTransition;
UpdateZoomBox();
UpdateSelectedLayerZOrder();
}
}
/// <summary>Animates the outgoing picture away over the new scene: a fade for Fade and Move,
/// a right-to-left shrink for Wipe. Cut plays nothing.</summary>
private void PlayPreviewTransition(ytLive.Services.TransitionType type, double milliseconds)
{
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, null);
TransitionOverlay.Clip = null;
if (type == ytLive.Services.TransitionType.Cut || milliseconds <= 0)
{
TransitionOverlay.Visibility = Visibility.Collapsed;
return;
}
var snapshot = new System.Windows.Media.Imaging.RenderTargetBitmap(1920, 1080, 96, 96,
System.Windows.Media.PixelFormats.Pbgra32);
snapshot.Render(PreviewCanvas);
TransitionOverlay.Source = snapshot;
TransitionOverlay.Opacity = 1;
TransitionOverlay.Visibility = Visibility.Visible;
var duration = TimeSpan.FromMilliseconds(milliseconds);
if (type == ytLive.Services.TransitionType.Wipe)
{
var clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 1920, 1080));
TransitionOverlay.Clip = clip;
var reveal = new System.Windows.Media.Animation.RectAnimation(new Rect(0, 0, 0, 1080), duration)
{
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
};
reveal.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
clip.BeginAnimation(System.Windows.Media.RectangleGeometry.RectProperty, reveal);
}
else
{
var fade = new System.Windows.Media.Animation.DoubleAnimation(1, 0, duration)
{
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
};
fade.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, fade);
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.SelectedElement))
UpdateSelectedLayerZOrder();
if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape))
{
UpdateZoomBox();
}
}
private void UpdateSelectedLayerZOrder()
{
var selected = (DataContext as MainViewModel)?.SelectedElement;
if (selected != null && !IsDraggableElement(selected))
selected = null;
for (var i = 0; i < OverlayElements.Items.Count; i++)
{
if (OverlayElements.ItemContainerGenerator.ContainerFromIndex(i) is UIElement container)
Panel.SetZIndex(container, ReferenceEquals(OverlayElements.Items[i], selected) ? 1 : 0);
}
}
// Static images and the webcam share the move/resize/selection behavior.
@@ -393,7 +456,7 @@ public partial class PreviewPane : UserControl
return;
}
var hit = HitElement(vm, p);
var hit = HitElementAt(vm, p);
if (hit != null)
{
vm.SelectedElement = hit;
@@ -547,23 +610,34 @@ public partial class PreviewPane : UserControl
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
}
private SceneElement? HitElement(MainViewModel vm, Point p)
internal SceneElement? HitElementAt(MainViewModel vm, Point p)
{
var scene = vm.StagedScene;
if (scene == null) return null;
// A layer selected from the list temporarily wins both preview stacking and hit-testing;
// the scene's persistent element order remains unchanged.
if (vm.SelectedElement is { } selected && IsHitTestable(selected) && Contains(selected, p))
return selected;
for (var i = scene.Elements.Count - 1; i >= 0; i--)
{
var element = scene.Elements[i];
if (!IsDraggableElement(element)) continue;
if (element is Source { IsEnabled: false }) continue;
if (element is WebcamSceneConfig { IsVisible: false }) continue;
if (p.X >= element.X && p.X <= element.X + element.Width &&
p.Y >= element.Y && p.Y <= element.Y + element.Height)
if (IsHitTestable(element) && Contains(element, p))
return element;
}
return null;
}
private static bool IsHitTestable(SceneElement element)
=> IsDraggableElement(element)
&& element is not Source { IsEnabled: false }
&& element is not WebcamSceneConfig { IsVisible: false };
private static bool Contains(SceneElement element, Point point)
=> point.X >= element.X && point.X <= element.X + element.Width
&& point.Y >= element.Y && point.Y <= element.Y + element.Height;
private void UpdateZoomBox()
{
if (DataContext is not MainViewModel vm) return;
@@ -599,4 +673,4 @@ public partial class PreviewPane : UserControl
ZoomVisualBrush.Visual = null;
}
}
}
}
+6 -6
View File
@@ -7,13 +7,13 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
| `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
| `TopBar.xaml(.cs)` | Row 0 session surface: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; direct right-click-to-settings is active uncommitted work. `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; App Settings now groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
+62 -4
View File
@@ -1,6 +1,64 @@
# HANDOFF — 2026-10-26 (ai.md memory split, 3 units landed; see below — everything after this block is the prior 2026-10-22 state, still accurate)
# HANDOFF — 2026-10-28 (selected-layer preview stacking; suite green)
## At a glance (2026-10-26 docs session)
## Current state
- **Branch `main`; `origin/main` remains at `7ae30d4`.** The five
feature commits since that point are `90cd039` (fade starts from outgoing picture), `f67a263`
(transition picker), `b86b6bb` (Wipe), `841c905` (brand logo layer), and `7da2872` (preview and
program transitions). They are not pushed; no push was requested.
- **Four UI-redesign commits already on `origin/main` were missing from the older handoff:**
`24aca7d` moved the scene strip above the preview and removed the default CRT/on-air image seeds;
`3239808` added the Scene Layers header/count and layer icons plus scene-card names, hotkeys, and
PROGRAM badge; `a8814b4` added the explicit EDIT toggle and fixed the scene-card/left-column
sizing; `7ae30d4` moved readouts/resolution into the top-left cell, added the Socials rail drawer,
and restored webcam/chat border controls and vertical 1080p30. `b7ab169` added [`DESIGN.md`](DESIGN.md)
with exploratory color/type tokens; it explicitly is not a layout spec or a theme implementation.
`ai.md` and `Controls/index.md` now describe the landed surface. No new task was added to the queue.
- **Shipped in those commits:** type/duration picker persisted with the layout; CPU left-to-right
Wipe; one locked Brand logo image layer per scene; preview transition animation in every mode;
program transitions while recording or streaming. The 7da2872 commit omitted a memory update;
`TASKS.md` and `ai.md` now reflect it.
- **Current work unit:** when a draggable element is selected from the layer list, `PreviewPane`
temporarily raises its item container and gives it priority in the custom preview hit test. On
deselection, normal preview stacking returns; the scene collection order and saved/program z-order
never change. `SelectedLayerPreviewHitTestingTests` verifies the overlapping Brand logo/webcam
visual hit, custom hit priority, and deselection restore.
- **The six prior suite failures are resolved in their tests, not by reverting current behavior:**
LayerReorder now accounts for the permanent Brand logo after Background; BorderColorPicker validates
every palette picker and scopes its rendered-widget lookup to the visible webcam control; both
thumbnail cases persist explicit tile assets instead of relying on removed default seed files;
SourceNaming only considers user-created Text overlays; ZoomBox locates its placeholder by type,
not a list index changed by Brand logo.
- **The worktree already had three uncommitted user edits before this docs sync; they are preserved:**
`Controls/OverlayHost.xaml` groups App Settings into Recordings, Brand logo, Preview, and Scene
transition sections with compact controls; `Controls/TopBar.xaml` wires right-click on the gear;
`Controls/TopBar.xaml.cs` opens App Settings directly on right-click while left-click keeps the
menu. These are active UX-redesign work, not part of the selected-layer fix. On-screen verification is
still pending.
- **This work unit is committed locally, not pushed.** The three pre-existing user edits above
remain uncommitted and untouched.
- **Next:** continue the creator's UX redesign and verify the current App Settings/gear changes,
transitions, and Brand logo on-screen. The earlier vertical-webcam hands-on check is also still
pending unless the creator confirms it was completed. Move's persistent-layer behavior and Cube
remain queued; distribution remains deferred until near v1.
- **Verification state:** `verify.sh` passed: clean build with 0 warnings, **444/444 tests passed**,
and scope check passed. The new selected-layer integration test also passes by itself.
- **Follow-up, not fixed here:** `ytLive.csproj`'s `GenerateBuildStamp` comment still describes a
commit-count-based `+1 per commit` counter, but the target now reads/writes local `BuildCounter.txt`
and increments it per build. `ai.md` records the code's current behavior; correct the stale source
comment in a separately scoped change. `ViewModels/MainViewModel.Socials.cs` also still calls the
Socials surface a modal dialog in its method comment; the committed UI is now a preview-rail drawer.
Correct that source comment separately if the file is next in scope. The new deterministic WPF
hit-test recipe is in `MyMistakes.md`; the final commit cites its Microsoft reference.
- **WPF stacking reference:** [`Panel.ZIndex`](https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.panel.zindex?view=windowsdesktop-10.0)
governs preview container order. The code also prioritizes the selected element in `HitElementAt`,
because the custom hit test does not consult WPF's visual z-order. The deterministic test recipe and
hit-testing reference are recorded in `MyMistakes.md`; cite that Microsoft reference in the commit.
---
## Historical notes (retained reference; current checkout state is above)
### At a glance (2026-10-26 docs session)
- **`ai.md` is being split into `docs/ai/*.md`** (user-approved: verbatim move, staged units, "ai.md
only"). Three units landed as separate commits, **docs only, not pushed**:
`804b1d2` (frame pipeline: cadence/timing/budget/compositor/pump),
@@ -20,7 +78,7 @@
---
## Prior state (2026-10-22) — the creator's properties-panel asks shipped, incl. TASK 63, and pushed; the vv webcam Units 1+2 state below
### Earlier detailed state (2026-10-22) — properties-panel asks shipped, incl. TASK 63, and pushed; vv webcam Units 1+2 state below
## At a glance
- **Branch** main, tracking `origin/main`. **The six-commit properties-panel batch is PUSHED**
@@ -264,7 +322,7 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
*Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — in Chat under the vertical tier, enlarge sets
the full top area and the circle option is not offered. **PASSES.**
## Next step
## Historical next step (2026-10-22; superseded by Current state above)
- **✅ TASK 63 + popup-click fix verified by creator (2026-10-22)** — clicking a colour in the
Border Color `YtComboBox` dropdown now stays inside `LeftPanel` (`IsDescendantOf` crosses
`PopupRoot` → `PART_Popup` via `LogicalTreeHelper.GetParent`), updates the webcam border colour,
+4
View File
@@ -88,6 +88,10 @@ public class Source : SceneElement
public override bool IsWebSource => Type == SourceType.WebSource;
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
public bool IsBrandLogo { get; set; }
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
private bool _isBackground;
public override bool IsBackground { get => _isBackground; set => Set(ref _isBackground, value); }
+16
View File
@@ -775,6 +775,22 @@ with `#Name`) + panel properties (`IsHitTestVisible/Opacity/children/actual size
`camera 'test-camera' failed` are test noise, not DB state — to check pollution, query
the DB directly (`python3 sqlite3`, `SELECT DeviceId FROM Webcam`), not the log.
## Deterministic test for a selected layer above an overlap (2026-10-28)
When preview rendering uses WPF z-order but dragging uses an additional custom hit-test, test both
contracts without injecting a physical cursor: in a real `MainWindow` on the `RealApp` STA, overlap
two scene elements, select the lower one through the layer list, assert `Panel.GetZIndex` raises its
container, assert `window.InputHitTest(point)` resolves the selected visual, and call the same
production hit-test method used by the drag handler to assert it chooses that selected model. Clear
selection and assert both hit paths return the original top layer and the scene's element order is
unchanged. WPF documents that input hit testing returns the input element at a point and that visual
hit-test results follow z-order: <https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer>.
Do not use `SetCursorPos`/`mouse_event` for this specific assertion: the test passed alone but was
intermittent in the full suite, with a cursor move landing at the canvas edge (`X=1900`) rather than
the intended offset. Physical injection remains appropriate only when the behavior under test is the
actual OS mouse-routing/drag path (as in the layer-list reorder test).
## A "known failure" label without a recorded cause = a bug on life support
(2026-09-01, the audio triple-take) One line — `_delayedMix` (nullable, added by TASK 22,
+5
View File
@@ -32,6 +32,7 @@ public sealed class FramePump : IDisposable
private readonly Func<VideoFrame?>? _alertTicker;
private readonly Func<VideoFrame?>? _brandFlash;
private readonly TransitionService? _transition;
private VideoFrame? _lastOutput;
private readonly SceneGraph? _sceneGraph;
private readonly SceneCompositor _compositor = new();
@@ -497,6 +498,9 @@ public sealed class FramePump : IDisposable
socialBarTicks += sw.ElapsedTicks;
VideoFrame frame;
if (_transition is { CaptureRequested: true } capture && _lastOutput != null)
capture.SupplyFromFrame(new VideoFrame(_lastOutput.Width, _lastOutput.Height,
(byte[])_lastOutput.BgraPixels.Clone()));
var scratchSize = compositorOptions.SourceRectWidth
* compositorOptions.SourceRectHeight * 4;
renderSw.Restart();
@@ -567,6 +571,7 @@ public sealed class FramePump : IDisposable
// tick's buffers are recyclable once the enqueue snapshot them.
// The free-list Contains guard keeps the Cut path (BlendFrame
// returns toFrame itself, aliasing scratch) safe.
_lastOutput = frame;
ReleaseScratch(frame.BgraPixels);
ReleaseScratch(scratch);
+2 -1
View File
@@ -102,7 +102,7 @@ public partial class LayoutStore : IDisposable
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
AlertDisplayMethod
AlertDisplayMethod, IsBrandLogo
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -154,6 +154,7 @@ public partial class LayoutStore : IDisposable
AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0,
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
IsBrandLogo = reader.GetInt32(reader.GetOrdinal("IsBrandLogo")) != 0,
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>();
+7
View File
@@ -199,6 +199,13 @@ public partial class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
if (!columns.Contains("IsBrandLogo"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBrandLogo INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("CaptureKey"))
{
using var cmd = _connection.CreateCommand();
+4 -2
View File
@@ -92,7 +92,7 @@ public partial class LayoutStore : IDisposable
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
AlertDisplayMethod)
AlertDisplayMethod, IsBrandLogo)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
@@ -100,7 +100,7 @@ public partial class LayoutStore : IDisposable
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
$alertDisplayMethod)
$alertDisplayMethod, $isBrandLogo)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -140,6 +140,7 @@ public partial class LayoutStore : IDisposable
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
var isBrandLogoP = cmd.Parameters.Add("$isBrandLogo", SqliteType.Integer);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
@@ -188,6 +189,7 @@ public partial class LayoutStore : IDisposable
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
alertVideoVolumeP.Value = source.AlertVideoVolume;
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
isBrandLogoP.Value = source.IsBrandLogo ? 1 : 0;
cmd.ExecuteNonQuery();
}
}
+16
View File
@@ -348,4 +348,20 @@ public partial class LayoutStore : IDisposable
/// <summary>The stored on-air image's Asset id, or null when never stamped.</summary>
public string? GetOnAirImageId() => GetSetting(OnAirImageKey);
private const string BrandLogoAssetKey = "BrandLogoAssetId";
private const string BrandLogoNameKey = "BrandLogoFileName";
public string? GetBrandLogoAssetId() => GetSetting(BrandLogoAssetKey);
public string? GetBrandLogoFileName() => GetSetting(BrandLogoNameKey);
/// <summary>Stores the brand logo image as an asset and records its file name for display.</summary>
public string SetBrandLogo(byte[] data, string fileName)
{
var id = UpsertAsset(data, 0, 0);
UpsertSetting(BrandLogoAssetKey, id);
UpsertSetting(BrandLogoNameKey, fileName);
return id;
}
}
+36 -1
View File
@@ -10,7 +10,8 @@ public enum TransitionType
{
Cut,
Fade,
Move
Move,
Wipe
}
/// <summary>
@@ -45,12 +46,23 @@ public sealed class TransitionService
/// </summary>
public VideoFrame? FromFrame { get; private set; }
/// <summary>True when a transition started without its "from" picture; the frame pump
/// supplies the outgoing program frame on its next tick via <see cref="SupplyFromFrame"/>.</summary>
public bool CaptureRequested { get; private set; }
public void SupplyFromFrame(VideoFrame frame)
{
FromFrame = frame;
CaptureRequested = false;
}
/// <summary>Begin a transition. Caller should capture FromFrame from the current LiveScene render.</summary>
public void Start(TransitionType type, double durationMs, VideoFrame? fromFrame)
{
CurrentType = type;
DurationMs = durationMs > 0 ? durationMs : DefaultDurationMs;
FromFrame = fromFrame;
CaptureRequested = fromFrame == null && type != TransitionType.Cut;
ElapsedMs = 0;
Active = true;
@@ -91,10 +103,33 @@ public sealed class TransitionService
{
TransitionType.Fade => BlendFade(FromFrame, toFrame, t),
TransitionType.Move => BlendMove(FromFrame, toFrame, t),
TransitionType.Wipe => BlendWipe(FromFrame, toFrame, t),
_ => toFrame
};
}
/// <summary>Left-to-right reveal: the new scene sweeps in across the frame.</summary>
private static VideoFrame BlendWipe(VideoFrame from, VideoFrame to, float t)
{
var w = from.Width;
var h = from.Height;
var output = new byte[w * h * 4];
var edge = (int)Math.Round(Math.Clamp(t, 0f, 1f) * w);
for (var y = 0; y < h; y++)
{
for (var x = 0; x < w; x++)
{
var src = x < edge ? to.BgraPixels : from.BgraPixels;
var i = (y * w + x) * 4;
output[i] = src[i];
output[i + 1] = src[i + 1];
output[i + 2] = src[i + 2];
output[i + 3] = src[i + 3];
}
}
return new VideoFrame(w, h, output);
}
private static VideoFrame BlendFade(VideoFrame from, VideoFrame to, float t)
{
var w = from.Width;
+16 -2
View File
@@ -321,8 +321,9 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
- **⏸ TASK 48 / ALL distribution & packaging work — DEFERRED 2026-10-26 (creator ruling). Do not
start, plan, or re-litigate any of it until we are close to v1.** Verbatim reason: *"we're not
anywhere close to release just considering the remaining programming tasks"* and *"I am getting
ready to do a complete UX redesign"* — packaging, signing, installer and updater decisions would
be chasing UI and scope that are about to change, so the urgency is withdrawn, not the task.
ready to do a complete UX redesign"* — that redesign began 2026-10-26 (see [`HANDOFF.md`](HANDOFF.md)
for landed UI slices and current worktree). Packaging, signing, installer and updater decisions
would chase UI and scope that are still changing, so the urgency is withdrawn, not the task.
What is frozen:
- **TASK 48 items 6-8** (recording invariant comment, disqualifier re-check, pre-submission gates)
and the creator's items 4/5 (Certification Kit, Partner Center listing).
@@ -451,3 +452,16 @@ These were explicitly ruled out:
- **OBS-style source expansion** (game capture, browser source, media playlist, VLC, color-key, MIDI — the minimal source set is deliberate; creators who need more have graduated to OBS)
- **Multi-layout** (auto-save, single layout; multi-layout = OBS territory)
- **Cross-process stream resume** (API makes it impossible; creator ruling 2026-09-01)
## Transitions & brand logo (status updated 2026-10-28)
- ✅ **Fade fix** — swaps fade from the outgoing picture (`TransitionFadeTests`). On-screen check pending.
- ✅ **Transition picker** in App Settings (type + duration), saved with the layout (`TransitionPickerTests`).
- ✅ **Wipe** — CPU blend, left-to-right reveal (`WipeTransitionTests`). Direction setting not yet added.
- ✅ **Transitions in edit mode and on output** — preview animates the outgoing picture for scene changes in every mode; program output transitions while recording or streaming. Fade/Move fade out, Wipe shrinks the outgoing picture, Cut is immediate. Guard: `PreviewTransitionTests`.
- ☐ **Move** — persistent layers (per-layer "carry across scenes" flag), per-scene placement, fixed z band.
- ✅ **Brand logo** — one locked "Brand logo" image layer per scene, directly above the background; file in
App Settings (imported to the asset table, the setting keeps the asset id); per-scene eye toggle; top-left
default. Tests: `BrandLogoTests`. On-screen check pending.
- ☐ **Cube** — GPU path gated on capability; CPU path only if it holds 60fps.
- Gear: right-click opens App Settings (built, on-screen check pending). Left-click menu stays for now.
+1
View File
@@ -438,6 +438,7 @@ public partial class MainViewModel
StampDefaultBackgroundAsset(background, scene.Name);
}
SeedOnAirImage();
EnsureBrandLogos();
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(LayerCount));
}
+77
View File
@@ -0,0 +1,77 @@
using System.IO;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
/// <summary>
/// The permanent brand logo: one image layer in every scene, directly above the background.
/// Its image comes from App Settings; each scene keeps its own visibility (the eye toggle).
/// </summary>
public partial class MainViewModel
{
internal const double BrandLogoX = 32;
internal const double BrandLogoY = 32;
internal const double BrandLogoWidth = 240;
internal const double BrandLogoHeight = 120;
private ICommand? _browseBrandLogo;
public ICommand BrowseBrandLogoCommand => _browseBrandLogo ??= new RelayCommand(_ => BrowseBrandLogo());
public string BrandLogoFileDisplay => _layoutStore.GetBrandLogoFileName() ?? "None";
private void BrowseBrandLogo()
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "Choose brand logo",
Filter = "Images|*.png;*.jpg;*.jpeg;*.bmp;*.gif",
};
if (dialog.ShowDialog() != true) return;
_layoutStore.SetBrandLogo(File.ReadAllBytes(dialog.FileName), Path.GetFileName(dialog.FileName));
EnsureBrandLogos();
OnPropertyChanged(nameof(BrandLogoFileDisplay));
ScheduleSave();
}
internal static Source CreateBrandLogo(string? assetId) => new()
{
Name = "Brand logo",
Type = SourceType.Image,
IsBrandLogo = true,
IsEnabled = true,
AssetId = assetId,
X = BrandLogoX,
Y = BrandLogoY,
Width = BrandLogoWidth,
Height = BrandLogoHeight,
};
/// <summary>Exactly one brand logo per scene, placed directly above the background, carrying
/// the current App Settings image.</summary>
internal void EnsureBrandLogos()
{
var assetId = _layoutStore.GetBrandLogoAssetId();
foreach (var scene in Scenes)
{
var logos = scene.Elements.OfType<Source>().Where(s => s.IsBrandLogo).ToList();
foreach (var extra in logos.Skip(1))
scene.Elements.Remove(extra);
var logo = logos.FirstOrDefault();
if (logo == null)
{
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
var index = background == null ? 0 : scene.Elements.IndexOf(background) + 1;
logo = CreateBrandLogo(assetId);
scene.Elements.Insert(index, logo);
}
else
{
logo.AssetId = assetId;
}
_sceneGraph.InvalidateBake(scene);
}
}
}
+9 -3
View File
@@ -97,14 +97,14 @@ public partial class MainViewModel
public TransitionType SelectedTransitionType
{
get => _selectedTransitionType;
set => SetProperty(ref _selectedTransitionType, value);
set { if (SetProperty(ref _selectedTransitionType, value)) ScheduleSave(); }
}
private TransitionType _selectedTransitionType = TransitionType.Fade;
public double TransitionDurationMs
{
get => _transitionDurationMs;
set => SetProperty(ref _transitionDurationMs, value);
set { if (SetProperty(ref _transitionDurationMs, value)) ScheduleSave(); }
}
private double _transitionDurationMs = 300;
@@ -115,14 +115,20 @@ public partial class MainViewModel
public void TransitionToScene(Scene scene)
{
if (scene.IsHidden || scene == StagedScene) return;
// The preview animates the outgoing picture before the switch, in every mode.
PreviewTransition?.Invoke(SelectedTransitionType, TransitionDurationMs);
StagedScene = scene;
if (IsLive)
// The program output transitions whenever it is running: recording or streaming.
if (IsLive || IsRecording)
{
LiveScene = scene;
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
}
}
/// <summary>Raised before a scene switch so the preview can animate the old picture away.</summary>
public event Action<TransitionType, double>? PreviewTransition;
/// <summary>
/// Apply the current transition type to the next StagedScene change while live.
/// Called from XAML when user picks a transition from the dropdown.
+2 -2
View File
@@ -237,11 +237,11 @@ public partial class MainViewModel
// session when the last using scene lets go). The webcam identity survives —
// it's the app-level default, so removing a scene's placement re-enables Add
// for the same camera instead of dropping it.
private void RemoveElement(SceneElement? element)
internal void RemoveElement(SceneElement? element)
{
var scene = StagedScene;
if (scene == null || element == null) return;
if (element is Source { IsBackground: true }) return;
if (element is Source { IsBackground: true } or Source { IsBrandLogo: true }) return;
if (element is WebcamSceneConfig && _webcam != null)
{
if (SelectedElement == element)
+38 -19
View File
File diff suppressed because one or more lines are too long
+16 -10
View File
@@ -69,16 +69,20 @@ public sealed class BorderColorPickerTests
// ── 2. the panel: the app's drop-down, no hand-rolled menu ───────────────
var xaml = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
var picker = xaml.Descendants()
var pickers = xaml.Descendants()
.Where(e => e.Name.LocalName == "ComboBox")
.SingleOrDefault(c => (string?)c.Attribute("ItemsSource")
== "{Binding SelectedElement.BorderColorOptions}");
Assert.True(picker != null, "the border colour is not a drop-down over the palette");
Assert.True(((string?)picker!.Attribute("Style") ?? string.Empty)
.Contains("YtComboBox", StringComparison.Ordinal),
"the border-colour drop-down is not styled with YtComboBox");
Assert.Equal("{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}",
(string?)picker.Attribute("SelectedItem"));
.Where(c => (string?)c.Attribute("ItemsSource")
== "{Binding SelectedElement.BorderColorOptions}")
.ToArray();
Assert.NotEmpty(pickers);
foreach (var picker in pickers)
{
Assert.True(((string?)picker.Attribute("Style") ?? string.Empty)
.Contains("YtComboBox", StringComparison.Ordinal),
"a border-colour drop-down is not styled with YtComboBox");
Assert.Equal("{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}",
(string?)picker.Attribute("SelectedItem"));
}
var hexField = xaml.Descendants()
.Where(e => e.Name.LocalName == "TextBox")
@@ -146,7 +150,9 @@ public sealed class BorderColorPickerTests
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var realCombo = FindVisualChildren<ComboBox>(panel)
.Single(c => c.ItemsSource is IEnumerable<NamedColor> opts && opts.Any(o => o.Hex == "#e94560"));
.Single(c => c.IsVisible
&& c.ItemsSource is IEnumerable<NamedColor> opts
&& opts.Any(o => o.Hex == "#e94560"));
realCombo.IsDropDownOpen = true;
realCombo.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The brand logo is one locked image layer per scene, directly above the background.
/// </summary>
[Collection("RealApp")]
public sealed class BrandLogoTests
{
private readonly RealAppHost _app;
public BrandLogoTests(RealAppHost app) => _app = app;
[Fact]
public void Every_Scene_Gets_One_Brand_Logo_Directly_Above_The_Background()
{
_app.Run(Run);
}
private static void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-brand-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var schema = new LayoutStore(tempDb)) { }
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
foreach (var scene in vm.Scenes)
{
var logos = scene.Elements.OfType<Source>().Where(s => s.IsBrandLogo).ToList();
Assert.Single(logos);
var index = scene.Elements.IndexOf(logos[0]);
var background = scene.Elements.OfType<Source>().First(s => s.IsBackground);
Assert.Equal(scene.Elements.IndexOf(background) + 1, index);
}
var staged = vm.StagedScene!;
var logo = staged.Elements.OfType<Source>().First(s => s.IsBrandLogo);
vm.RemoveElement(logo);
Assert.Contains(logo, staged.Elements);
}
finally
{
MainViewModel.LayoutPathOverride = null;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { }
}
}
}
+8 -9
View File
@@ -83,7 +83,7 @@ public sealed class LayerReorderPersistenceTests
var vm = (MainViewModel)window.DataContext;
vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
Assert.True(vm.StagedScene!.Elements.OfType<Source>().Count() >= 4,
"3 seeded layers + the healed Background must load");
"3 seeded layers + the healed Background + Brand logo must load");
window.Show();
window.Activate();
@@ -94,12 +94,9 @@ public sealed class LayerReorderPersistenceTests
var list = (FocusPreservingListBox)panel.FindName("SourceList")!;
list.UpdateLayout();
// Elements = [Background, Zoom(placeholder, collapsed), ImgA, ImgB,
// ImgC]. Physically drag ImgC up onto ImgA: the handlers reorder the
// live elements to [Background, Zoom, ImgC, ImgA, ImgB] and EndListDrag
// must persist that. Rows are resolved BY ITEM — the collapsed zoom row
// shifts container indices, but ItemContainerGenerator maps items to
// their (collapsed = zero-height) containers transparently.
// Brand logo is a permanent layer between Background and user layers.
// Physically drag ImgC above ImgA; the background and logo keep their
// relative positions while EndListDrag persists the user-layer reorder.
var sources = vm.StagedScene.Elements.OfType<Source>().ToArray();
var from = RowScreenPoint(list, sources.First(s => s.Name == "ImgC"));
var to = RowScreenPoint(list, sources.First(s => s.Name == "ImgA"));
@@ -115,7 +112,9 @@ public sealed class LayerReorderPersistenceTests
var expected = vm.StagedScene.Elements.OfType<Source>().Select(s => s.Name).ToArray();
Assert.Equal("Background", expected[0]);
Assert.Equal("ImgC", expected[1]);
Assert.Equal("Brand logo", expected[1]);
Assert.Equal("ImgC", expected[2]);
Assert.True(Array.IndexOf(expected, "ImgC") < Array.IndexOf(expected, "ImgA"));
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
TimeSpan.FromSeconds(10)),
@@ -466,4 +465,4 @@ public sealed class LayerReorderPersistenceTests
}
}
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using System;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// A scene switch in edit mode raises the preview transition with the chosen type and
/// length, before the staged scene changes, so the preview can animate the old picture.
/// </summary>
[Collection("RealApp")]
public sealed class PreviewTransitionTests
{
private readonly RealAppHost _app;
public PreviewTransitionTests(RealAppHost app) => _app = app;
[Fact]
public void Scene_Switch_In_Edit_Mode_Raises_The_Preview_Transition_Before_The_Switch()
{
_app.Run(Run);
}
private static void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-preview-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var schema = new LayoutStore(tempDb)) { }
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
var from = vm.StagedScene!;
var to = vm.Scenes.First(s => s != from && !s.IsHidden);
vm.SelectedTransitionType = TransitionType.Fade;
vm.TransitionDurationMs = 800;
Scene? stagedWhenRaised = null;
TransitionType? typeRaised = null;
double? msRaised = null;
vm.PreviewTransition += (type, ms) =>
{
stagedWhenRaised = vm.StagedScene;
typeRaised = type;
msRaised = ms;
};
vm.TransitionToScene(to);
Assert.Same(from, stagedWhenRaised);
Assert.Equal(TransitionType.Fade, typeRaised);
Assert.Equal(800, msRaised);
Assert.Same(to, vm.StagedScene);
}
finally
{
MainViewModel.LayoutPathOverride = null;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { }
}
}
}
+27 -9
View File
@@ -15,9 +15,8 @@ using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Scene strip tile artwork (creator ruling 2026-10-10). Each canonical scene's
/// top-strip tile is its own image, stored as an Asset row and seeded from the
/// creator's CRT-styled renders. The preview pane keeps rendering each scene's
/// Scene strip tile artwork (creator ruling 2026-10-10). A persisted scene tile
/// is stored as an Asset row; the preview pane keeps rendering each scene's
/// ORIGINAL background — the tiles never appear as preview backgrounds.
/// </summary>
[Collection("RealApp")]
@@ -28,7 +27,7 @@ public sealed class SceneThumbnailTests
public SceneThumbnailTests(RealAppHost app) => _app = app;
[Fact]
public void Strip_Shows_The_Seeded_Tile_And_The_Preview_Keeps_The_Original_Background()
public void Strip_Shows_The_Persisted_Tile_And_The_Preview_Keeps_The_Original_Background()
{
_app.Run(() =>
{
@@ -36,10 +35,19 @@ public sealed class SceneThumbnailTests
try
{
env = ArrangedDb(out var file);
env.SeedPaths[SceneCatalog.Starting] = file.TilePath;
env.SeedPaths[SceneCatalog.Live] = MissingPath();
env.Apply();
var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180);
using (var connection = new SqliteConnection($"Data Source={env.DbPath}"))
{
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;";
cmd.Parameters.AddWithValue("$asset", tileAssetId);
cmd.Parameters.AddWithValue("$scene", SceneCatalog.Starting);
Assert.Equal(1, cmd.ExecuteNonQuery());
}
var vm = new MainViewModel();
var starting = SceneByName(vm, SceneCatalog.Starting);
@@ -91,7 +99,7 @@ public sealed class SceneThumbnailTests
}
[Fact]
public void Live_Staged_Mini_Shows_Its_Tile_Not_The_Capture_Placeholder()
public void Live_Staged_Mini_Shows_Its_Persisted_Tile_Not_The_Capture_Placeholder()
{
_app.Run(() =>
{
@@ -99,9 +107,19 @@ public sealed class SceneThumbnailTests
try
{
env = ArrangedDb(out var file);
env.SeedPaths[SceneCatalog.Live] = file.TilePath;
env.Apply();
var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180);
using (var connection = new SqliteConnection($"Data Source={env.DbPath}"))
{
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;";
cmd.Parameters.AddWithValue("$asset", tileAssetId);
cmd.Parameters.AddWithValue("$scene", SceneCatalog.Live);
Assert.Equal(1, cmd.ExecuteNonQuery());
}
var vm = new MainViewModel();
var live = SceneByName(vm, SceneCatalog.Live);
Assert.False(string.IsNullOrWhiteSpace(live.ThumbnailAssetId));
@@ -355,4 +373,4 @@ public sealed class SceneThumbnailTests
private static string MissingPath()
=> Path.Combine(Path.GetTempPath(), $"ytLive-no-such-{Guid.NewGuid():N}.png");
}
}
@@ -0,0 +1,118 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Controls;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
[Collection("RealApp")]
public sealed class SelectedLayerPreviewHitTestingTests
{
private readonly RealAppHost _app;
public SelectedLayerPreviewHitTestingTests(RealAppHost app) => _app = app;
[Fact]
public void ListSelectedBrandLogo_WinsPreviewHitTesting_UntilDeselected()
{
_app.Run(Run);
}
private static void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-selected-layer-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
MainWindow? window = null;
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
var scene = vm.StagedScene!;
var logo = scene.Elements.OfType<Source>().Single(source => source.IsBrandLogo);
logo.X = 420;
logo.Y = 300;
logo.Width = 300;
logo.Height = 180;
var webcam = new WebcamSceneConfig
{
WebcamId = "test-camera",
Name = "Webcam",
X = logo.X,
Y = logo.Y,
Width = logo.Width,
Height = logo.Height,
};
scene.Elements.Add(webcam);
var originalOrder = scene.Elements.ToArray();
window.Show();
window.Activate();
window.Topmost = true;
window.UpdateLayout();
var leftPanel = (LeftPanel)window.FindName("LeftPanel")!;
var sourceList = (ListBox)leftPanel.FindName("SourceList")!;
var preview = (PreviewPane)window.FindName("PreviewPane")!;
var canvasGrid = (FrameworkElement)preview.FindName("CanvasGrid")!;
var overlayElements = (ItemsControl)preview.FindName("OverlayElements")!;
var logoContainer = (UIElement)overlayElements.ItemContainerGenerator.ContainerFromItem(logo)!;
var webcamContainer = (UIElement)overlayElements.ItemContainerGenerator.ContainerFromItem(webcam)!;
var pointInCanvas = new Point(logo.X + 100, logo.Y + 60);
var pointInWindow = canvasGrid.TransformToVisual(window).Transform(pointInCanvas);
Assert.True(ReferenceEquals(webcam, ElementAt(window.InputHitTest(pointInWindow))));
Assert.Same(webcam, preview.HitElementAt(vm, pointInCanvas));
Assert.Equal(0, Panel.GetZIndex(logoContainer));
sourceList.SelectedItem = logo;
window.UpdateLayout();
Assert.True(ReferenceEquals(logo, vm.SelectedElement), "Selecting the layer-list row must select the brand logo.");
Assert.True(Panel.GetZIndex(logoContainer) > Panel.GetZIndex(webcamContainer));
Assert.Same(logo, ElementAt(window.InputHitTest(pointInWindow)));
Assert.Same(logo, preview.HitElementAt(vm, pointInCanvas));
Assert.Equal(originalOrder, scene.Elements);
sourceList.SelectedItem = null;
window.UpdateLayout();
Assert.Null(vm.SelectedElement);
Assert.Equal(0, Panel.GetZIndex(logoContainer));
Assert.Same(webcam, ElementAt(window.InputHitTest(pointInWindow)));
Assert.Same(webcam, preview.HitElementAt(vm, pointInCanvas));
Assert.Equal(originalOrder, scene.Elements);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { }
}
}
private static SceneElement? ElementAt(System.Windows.IInputElement? hit)
{
DependencyObject? current = hit as DependencyObject;
while (current != null)
{
if (current is FrameworkElement { DataContext: SceneElement element })
return element;
current = current is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(current)
: LogicalTreeHelper.GetParent(current);
}
return null;
}
}
+3 -3
View File
@@ -44,8 +44,8 @@ public sealed class SourceNamingTests
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
// The locked Background layer is always present but never numbered.
var names = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
// Persistent scene layers are not part of the user-created Text sequence.
var names = scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.TextOverlay)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
@@ -53,7 +53,7 @@ public sealed class SourceNamingTests
scene.Elements.Remove(middle);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var survivors = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
var survivors = scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.TextOverlay)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
}
+26
View File
@@ -0,0 +1,26 @@
using ytLive.Services;
using Xunit;
namespace ytLive.Tests;
public class TransitionFadeTests
{
[Fact]
public void Fade_Without_A_From_Frame_Requests_Capture_And_Blends_Once_Supplied()
{
var transition = new TransitionService();
var to = new VideoFrame(2, 2, Enumerable.Repeat((byte)255, 16).ToArray());
var from = new VideoFrame(2, 2, new byte[16]);
transition.Start(TransitionType.Fade, 300, null);
Assert.True(transition.CaptureRequested);
transition.SupplyFromFrame(from);
Assert.False(transition.CaptureRequested);
transition.Tick(150);
var blended = transition.BlendFrame(to);
Assert.NotSame(to, blended);
Assert.NotEqual((byte)255, blended.BgraPixels[0]);
}
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.ComponentModel;
using Xunit;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The App Settings transition picker drives the view model: type and length changes
/// notify the bound controls.
/// </summary>
[Collection("RealApp")]
public sealed class TransitionPickerTests
{
private readonly RealAppHost _app;
public TransitionPickerTests(RealAppHost app) => _app = app;
[Fact]
public void Transition_Type_And_Length_Notify_The_Settings_Picker()
{
_app.Run(Run);
}
private static void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-transition-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var schema = new LayoutStore(tempDb)) { }
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
var raised = new System.Collections.Generic.List<string>();
((INotifyPropertyChanged)vm).PropertyChanged += (_, e) => raised.Add(e.PropertyName ?? "");
vm.SelectedTransitionType = TransitionType.Move;
vm.TransitionDurationMs = 750;
Assert.Equal(TransitionType.Move, vm.SelectedTransitionType);
Assert.Equal(750, vm.TransitionDurationMs);
Assert.Contains(nameof(MainViewModel.SelectedTransitionType), raised);
Assert.Contains(nameof(MainViewModel.TransitionDurationMs), raised);
}
finally
{
MainViewModel.LayoutPathOverride = null;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { }
}
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.Linq;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
public class WipeTransitionTests
{
[Fact]
public void Wipe_At_Half_Reveals_The_New_Scene_On_The_Left_Half()
{
var transition = new TransitionService();
var to = new VideoFrame(4, 1, Enumerable.Repeat((byte)255, 16).ToArray());
var from = new VideoFrame(4, 1, new byte[16]);
transition.Start(TransitionType.Wipe, 300, from);
transition.Tick(150);
var blended = transition.BlendFrame(to);
Assert.Equal((byte)255, blended.BgraPixels[0]);
Assert.Equal((byte)255, blended.BgraPixels[4]);
Assert.Equal((byte)0, blended.BgraPixels[8]);
Assert.Equal((byte)0, blended.BgraPixels[12]);
}
}
+6 -5
View File
@@ -49,8 +49,9 @@ public sealed class ZoomBoxInteractionTests
window.Show();
window.UpdateLayout();
Assert.NotNull(vm.StagedScene);
// The healed zoom placeholder sits at z-index 1, inert.
var placeholder = (ZoomSceneElement)vm.StagedScene.Elements[1];
// The healed zoom placeholder is present and inert; persistent scene
// layers may precede it, so locate it by type rather than list index.
var placeholder = vm.StagedScene.Elements.OfType<ZoomSceneElement>().Single();
Assert.False(placeholder.IsZoomActive);
Assert.Equal(ElementKind.Static, placeholder.Kind);
@@ -137,7 +138,7 @@ public sealed class ZoomBoxInteractionTests
Assert.True(Math.Abs(vm.ZoomState.Y - boxY) >= 100, "the box must have moved");
Assert.Equal(captureX, vm.ZoomState.CaptureX);
Assert.Equal(captureY, vm.ZoomState.CaptureY);
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene.Elements[1]);
var zoom = vm.StagedScene.Elements.OfType<ZoomSceneElement>().Single();
Assert.Equal(vm.ZoomState.X, zoom.X, 3);
Assert.Equal(vm.ZoomState.Y, zoom.Y, 3);
Assert.Equal(captureX, zoom.CaptureX, 3);
@@ -179,7 +180,7 @@ public sealed class ZoomBoxInteractionTests
private static void AssertTrueActiveLayer(MainViewModel vm)
{
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene!.Elements[1]);
var zoom = vm.StagedScene!.Elements.OfType<ZoomSceneElement>().Single();
Assert.True(zoom.IsZoomActive);
Assert.True(zoom.IsVisible);
Assert.Equal(ElementKind.Dynamic, zoom.Kind);
@@ -191,4 +192,4 @@ public sealed class ZoomBoxInteractionTests
Assert.Equal(vm.ZoomState.CaptureY, zoom.CaptureY, 3);
Assert.Equal(vm.ZoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional, zoom.ClipShape);
}
}
}