From 769c616706ab8086032e87401bb537d60e5d64f7 Mon Sep 17 00:00:00 2001 From: gramps Date: Wed, 28 Oct 2026 13:09:15 -0700 Subject: [PATCH] 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 --- Controls/PreviewPane.xaml | 2 +- Controls/PreviewPane.xaml.cs | 44 +++++-- Controls/index.md | 12 +- HANDOFF.md | 66 +++++++++- MyMistakes.md | 16 +++ TASKS.md | 8 +- ai.md | 43 +++---- ytLive.Tests/BorderColorPickerTests.cs | 26 ++-- ytLive.Tests/LayerReorderPersistenceTests.cs | 17 ++- ytLive.Tests/SceneThumbnailTests.cs | 36 ++++-- .../SelectedLayerPreviewHitTestingTests.cs | 118 ++++++++++++++++++ ytLive.Tests/SourceNamingTests.cs | 6 +- ytLive.Tests/ZoomBoxInteractionTests.cs | 11 +- 13 files changed, 326 insertions(+), 79 deletions(-) create mode 100644 ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index bd15ede..90d0558 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -74,7 +74,7 @@ - diff --git a/Controls/PreviewPane.xaml.cs b/Controls/PreviewPane.xaml.cs index 27c6044..ddc59fc 100644 --- a/Controls/PreviewPane.xaml.cs +++ b/Controls/PreviewPane.xaml.cs @@ -31,6 +31,7 @@ public partial class PreviewPane : UserControl { InitializeComponent(); DataContextChanged += PreviewPane_DataContextChanged; + OverlayElements.ItemContainerGenerator.StatusChanged += (_, _) => UpdateSelectedLayerZOrder(); } private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -45,6 +46,7 @@ public partial class PreviewPane : UserControl newVm.PropertyChanged += ViewModel_PropertyChanged; newVm.PreviewTransition += PlayPreviewTransition; UpdateZoomBox(); + UpdateSelectedLayerZOrder(); } } @@ -92,12 +94,27 @@ public partial class PreviewPane : UserControl 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. @@ -439,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; @@ -593,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; @@ -645,4 +673,4 @@ public partial class PreviewPane : UserControl ZoomVisualBrush.Visual = null; } } -} \ No newline at end of file +} diff --git a/Controls/index.md b/Controls/index.md index 7d0e099..615034d 100644 --- a/Controls/index.md +++ b/Controls/index.md @@ -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 | diff --git a/HANDOFF.md b/HANDOFF.md index 1720d2d..04b0fc6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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, diff --git a/MyMistakes.md b/MyMistakes.md index bbbd9d3..2e80880 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -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: . + +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, diff --git a/TASKS.md b/TASKS.md index b43d098..8c654a4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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). @@ -452,11 +453,12 @@ These were explicitly ruled out: - **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 (queued 2026-10-27) +## 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 diff --git a/ai.md b/ai.md index 5add97b..4effada 100644 --- a/ai.md +++ b/ai.md @@ -265,15 +265,15 @@ C# / WPF (.NET 8) following MVVM: | `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** | | `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition), and now also over `Services/AlertOverlayLayer.cs` (TASK 43 — the native alert box, same true-component pattern)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** | | `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see [`docs/ai/frame-pipeline.md`](docs/ai/frame-pipeline.md) → "Scene compositor")**, **alerts: `ChatOverlayLayer` + `AlertOverlayLayer`/`AlertRenderer` (the native celebration-zone overlay, TASK 43 — see "Native events & alerts")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `ITraxPlayer` (TRAX toggle seam — FakePlayer in tests; the dot is green/red/gray and the error scrolls terse in the desktop-audio bar; MusicVolume fixed 0.5) + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see [`docs/ai/output.md`](docs/ai/output.md) → "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see [`docs/ai/output.md`](docs/ai/output.md) → "Live encoder" + [`docs/ai/frame-pipeline.md`](docs/ai/frame-pipeline.md) → "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** | -| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, **BuildStamp (wordmark release counter `#N`, +1 per commit, generated by `GenerateBuildStamp` in ytLive.csproj via git rev-list; the per-build GUID now logs to startup.log only — 2026-09-04)**, visibility converters | +| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, **BuildStamp (wordmark release counter `#N`, incremented by `GenerateBuildStamp` from the local `BuildCounter.txt`; per-build GUID/time are logged to startup.log)**, visibility converters | | `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) | -| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) | +| `MainWindow.xaml` | Dark theme; top bar + health banner; work area has a fixed 360px left column and preview column, top-left readouts/resolution and scene strip above preview, lower left panel + preview; bottom bar is an empty spacer | ### Key patterns - `ViewModelBase.SetProperty()` for property change notifications - `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows) -- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the Socials+TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC / ON-AIR pills + signs (TASK 18, 2026-08-29):** the top-center area is now two **sliding pill toggles** (intent) next to two **status signs** (reality). REC pill (`RecordPillOn`) is the local-recording intent — works signed-out; ON-AIR pill (`OnAirPillOn`) is the streaming intent — greyed/disabled until `IsConnected`. The **REC sign** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`) is dark-gray + dim offline and **green-tinged, pulsing only while actually recording** (`IsRecording`), not merely pill-on. The **ON-AIR sign** (`OnAirBrush` = `#555` offline, `#22c55e` live) is green while actually streaming (`IsLive`); the `PRIVATE` badge (`IsLivePrivate`) still shows when the broadcast is private. Both sign labels share the `StatusSignText` style (`Themes/Controls.xaml`) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows while `IsLive || IsRecording`. The connected account's avatar/name shows in the top bar next to the primary button (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live. +- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row; Socials is on the preview rail. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC / ON-AIR pills + signs (TASK 18, 2026-08-29):** the top-center area is now two **sliding pill toggles** (intent) next to two **status signs** (reality). REC pill (`RecordPillOn`) is the local-recording intent — works signed-out; ON-AIR pill (`OnAirPillOn`) is the streaming intent — greyed/disabled until `IsConnected`. The **REC sign** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`) is dark-gray + dim offline and **green-tinged, pulsing only while actually recording** (`IsRecording`), not merely pill-on. The **ON-AIR sign** (`OnAirBrush` = `#555` offline, `#22c55e` live) is green while actually streaming (`IsLive`); the `PRIVATE` badge (`IsLivePrivate`) still shows when the broadcast is private. Both sign labels share the `StatusSignText` style (`Themes/Controls.xaml`) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows while `IsLive || IsRecording`. The connected account's avatar/name shows in the top bar next to the primary button (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live. - ViewModels are constructed in XAML (`` as DataContext) - **True decomposition beats partial-shuffling (2026-08-31, Commit G):** `MainViewModel` is a ~4100-line god-object split into 17 partials — but partials are a *myth of decomposition*: every partial shares the same class, same `SetProperty` state, same collaborators via `Scenes`/`StagedScene`/`IsLive`/`Source.VideoImageSource`, so an AI fetching one partial still reconstructs the whole class. The only thing that actually improves retrieval is a boundary where a feature **owns its own state + collaborators**. `Services/ChatOverlayLayer.cs` is the model (extracted from `MainViewModel.Chat.cs` 194→44): the layer owns the message buffer, `ChatBoxRenderer`, fade/mock timers, preview renders, and the live `RenderFrame`; the VM keeps only the binding surface (`ChatMessages` delegates to `_chatLayer.Messages` so XAML + LeftPanel `CollectionChanged` hold; the two computed gate props stay on the VM because their `OnPropertyChanged` is raised from VM setters). **`Services/AlertOverlayLayer.cs` (TASK 43, 2026-09-24) is a second true component built fresh on the same contract** — it owns the event queue, the 33ms playback ticker, the per-source preview renders and the live `RenderFrame`, with `Advance(double)` exposed as the deterministic test clock; the VM again keeps only the facade (`RenderAlertBox`, `UpdateAlertBoxPreview`, `CanAddAlerts`). Two COMPONENT layers, one facade partial. **Diagnostic — glue vs. component:** before extracting, count `OnPropertyChanged`/`SetProperty` touches (glue) and bound-property reads per partial. Zero/low-glue + a cohesive state blob (renderer/timers/buffer) = extract (chat). High glue over an already-extracted service (Audio, Webcam, Background, Scenes — `AudioMixer`/`CameraManager`/`ScreenCaptureManager`/`ChatBoxRenderer` already exist) = leave as VM glue; forcing it adds coupling, doesn't remove it. Don't manufacture seams to hit a line count — the line count is a guideline for context, not a design goal. - **SceneGraph component + baked-crust compositor (TASK 31):** `Services/SceneGraph.cs` owns the scene collection (`Scenes` — the ViewModel's `Scenes` property delegates to it) and the element mutation surface (`AddElement`/`InsertElement`/`RemoveElement`/`MoveElement`, each invalidating the bake cache) plus queries that were scattered LINQ (`GetBackground`/`GetWebcam`/`GetChatBoxes`/`GetSplitPoint`/`IsStatic`). Elements expose `ElementKind Kind` (`Static` = images/background art, `Dynamic` = webcam/live capture/chat/web). The **split point** is the index of the first dynamic element; `SceneGraph.GetBakedBase` bakes/caches all static layers below it (keyed by scene id + static element identities), and `SceneCompositor.BakeStaticBase`/`CompositeLayers`/the `Render(.., staticBase, split)` overload composite the dynamic/above-split layers per frame. `FramePump.RenderScene` uses the optimized path when a `SceneGraph` is wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (via `InvalidateBake` from the VM's element-property and background-heal paths). **Defensive deviation from the TASK 31 spec:** `ChatOverlayLayer` keeps taking `IEnumerable` instead of depending on `GetChatBoxes()` — it is deliberately decoupled from the graph (its doc comment says "without owning the scene graph"); and the static background helpers (`EnsureBackground`/`NormalizeBackgrounds`) stay on the ViewModel because `BackgroundTests.cs` unit-tests `MainViewModel.EnsureBackground` directly. Queries + invalidation moved; helpers stayed. @@ -329,7 +329,7 @@ C# / WPF (.NET 8) following MVVM: geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's `FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard: `ytLive.Tests/GeometryFieldWidthTests.cs` -- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. Bottom bar: gear icon far left, stream stats (bitrate/FPS/dropped/duration) centered under preview, resolution dropdown far right. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height +- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p30@8 and 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height - Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug). **The log is SIZE-CAPPED (TASK 55, 2026-10-22):** at 5 MB `startup.log` rolls to `startup.1.log` (one rolled copy kept) — the policy lives in `Helpers/LogRotation.cs` as a pure function over a path, so `AppLog`'s 156-call-site surface never changes and the rule is testable on its own. Guard: `ytLive.Tests/AppLogRotationTests.cs` ### ⚠️ Known issue: screens/layers settings audit — RETIRED INTO THE GOLD PASS (2026-08-22, dispositioned 2026-09-01) @@ -346,32 +346,28 @@ The AI hallucinated through multiple commits that night on background/scene prop - `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead - **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) shipped — steps 1-7 (Velopack bootstrap, PolarLicenseService, license entry UI, LayoutStore entitlement, BrandFlash toggle, startup re-validation, PremiumUrl wired); Velopack update URL pending: - TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates - - TASK 19/23 merged: Control Surface UX — director's control room, transitions (Cut/Fade/Move), thumbnails above central monitor, edit mode offline only, right panel eliminated + - TASK 19/23 merged: Control Surface UX — director's control room with an explicit EDIT toggle, scene strip above the preview, readouts in the top-left cell, and preview-rail drawers; current behavior is detailed in “Control Surface UX” below - TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap - Broadcast metadata pull-out + launch geometry — shipped 2026-08-24 ("Text" tab on the Live screen, see its section below) - TASK 21: Media source (video file playback) — starting soon videos, BRB loops - TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards - These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in `TASKS.md`. -### Control Surface UX (TASK 19/23 — shipped 2026-08-24, verified by code survey) +### Control Surface UX (TASK 19/23 foundation; surface redesign started 2026-10-26) The app is a **director's control surface**, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor. -**Layout:** Two-column. Left panel = one panel, two states. Center = thumbnails above central monitor. +**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer. -- **Left panel:** When offline (edit mode) → LAYERS + PROPERTIES (same as current, minus SCREENS). When live/recording → YouTube Live Chat (entire panel replaced, never coexists with layers/properties). Mode is implicit (derived from `StreamStatus`), no toggle button. -- **Thumbnails:** Five scene previews above the central monitor, 16:9, drag-reorderable. Click = stage (offline) or transition (live). Active scene gets brand-red glow border. -- **Central monitor:** Shows staged scene (offline, editable in edit mode) or live output (live, read-only). During transitions: compositor-blended animation. -- **Right panel:** Eliminated. Chat moved to left panel (live mode). -- **Edit gating:** `IsEditMode` = `StreamStatus == Offline && !IsRecording`. When live/recording: all drag/resize, context menus, right-click property adjustments disabled. Gun safety — no accidental edits mid-stream. +- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output. +- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand/gear and account surfaces remain at the sides. The gear's left-click menu remains; direct right-click-to-settings is in the current uncommitted UX work (see HANDOFF). +- **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition. +- **Preview rail:** stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls. +- **Central monitor:** shows the staged scene while editing and the program scene when editing is off. Preview transitions snapshot and animate the outgoing picture in every mode; output transitions run while streaming or recording. Go Live itself remains a Cut. +- **Edit safety:** edit availability follows `EditingActive`; it is no longer a hard lock derived from "offline and not recording." Keep the explicit toggle and output/edit behavior distinct. +- **Selected-layer preview access:** a draggable layer selected from the list temporarily gets the highest preview `Panel.ZIndex` and first priority in the preview's custom hit test, so it can be grabbed even underneath a higher scene layer. Clearing/replacing selection restores the preview stack; `Scene.Elements` order is untouched, so output and saved z-order do not change. Guard: `ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs`. -**Scene reference split:** `ActiveScene` → `StagedScene` (central monitor target, editable offline) + `LiveScene` (encoder output). Go Live sets `LiveScene = StagedScene`. Thumbnail click sets `StagedScene` (offline) or fires transition (live). - -**Transitions:** `TransitionService` (new file) — Cut/Fade/Move with configurable duration (default 300ms). `SceneCompositor.RenderBlend()` blends two scenes' frames. `FramePump` feeds `LiveScene` to encoder; during transitions, feeds blended frames. Go Live always = Cut (clean start). - -**Thumbnails:** `Scene.Snapshot` property. `SceneCompositor.Render()` at 320×180 → `WriteableBitmap`. Only staged scene has live backdrop render; other 4 = static snapshots. - -**Thumbnails:** `Scene.Snapshot` property. Fill = per-scene tile artwork first — each canonical scene's `ThumbnailAssetId` (an Asset-table image, the creator's CRT-styled renders seeded at heal into the internal DB), else background art; **minis never render live captures**. The scene currently staged shows the shared **on-air image** (`OnAirImageAssetId` setting → Asset row, seed path `MainViewModel.OnAirSeedPath`) in its tile and it follows the scene swap — "the on-air screen jumps around the top bar" (user ruling 2026-10-10). The Live screen's tile only shows the green placeholder when it is staged and no on-air art is stamped. The strip labels are FontSize 20 (user ruling 2026-10-10); border = `#e94560` when staged, `#22c55e` when live. The preview's own live signal is the outer `PreviewGlow` red border — NOT the 6px green `#22c55e` rectangle that previously sat in the BackdropLayer (removed 2026-10-10). +**Scene thumbnails:** `Scene.Snapshot` is a 320×180 compositor render. The strip uses per-scene `ThumbnailAssetId`, then background art, then a placeholder; existing saved `OnAirImageAssetId` can still replace the staged tile. The old hardcoded CRT-thumbnail and on-air seed paths are empty, so a fresh layout no longer imports that artwork. Minis never render live captures. `DESIGN.md` records exploratory Stitch color/type tokens only; it is not a layout spec and has not been reconciled into `Themes/Controls.xaml`. ### Global hotkeys (TASK 20 — step 1 shipped 2026-08-24) @@ -1294,8 +1290,13 @@ persisted chat or reward payloads." `CaptureRequested`; the frame pump copies its last output at the top of its next tick and calls `SupplyFromFrame`. Before that fix the blend had no starting frame and a scene swap was instant. Guard: `ytLive.Tests/TransitionFadeTests.cs`. -- **Planned next (see TASKS.md):** transition picker in App Settings, Wipe, Move (persistent layers), - brand logo layer, GPU cube (capability-gated). +- **Type and duration** are selected in App Settings and persisted with the layout. Cut, Fade, Move, + and Wipe are offered; Wipe is a CPU left-to-right reveal. Guard: `ytLive.Tests/TransitionPickerTests.cs` + and `ytLive.Tests/WipeTransitionTests.cs`. +- **A scene change animates the outgoing preview in every mode.** Fade and Move fade it out; Wipe + shrinks it away; Cut is immediate. Program output transitions when streaming or recording, while + offline scene changes affect only the preview. Guard: `ytLive.Tests/PreviewTransitionTests.cs`. +- **Still planned (see TASKS.md):** persistent layers for Move and a capability-gated GPU Cube. ## Brand logo (2026-10-27) diff --git a/ytLive.Tests/BorderColorPickerTests.cs b/ytLive.Tests/BorderColorPickerTests.cs index 881a476..dd6d75a 100644 --- a/ytLive.Tests/BorderColorPickerTests.cs +++ b/ytLive.Tests/BorderColorPickerTests.cs @@ -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(panel) - .Single(c => c.ItemsSource is IEnumerable opts && opts.Any(o => o.Hex == "#e94560")); + .Single(c => c.IsVisible + && c.ItemsSource is IEnumerable opts + && opts.Any(o => o.Hex == "#e94560")); realCombo.IsDropDownOpen = true; realCombo.UpdateLayout(); Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded); diff --git a/ytLive.Tests/LayerReorderPersistenceTests.cs b/ytLive.Tests/LayerReorderPersistenceTests.cs index f35c84f..e6c7950 100644 --- a/ytLive.Tests/LayerReorderPersistenceTests.cs +++ b/ytLive.Tests/LayerReorderPersistenceTests.cs @@ -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().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().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().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 } } } -} \ No newline at end of file +} diff --git a/ytLive.Tests/SceneThumbnailTests.cs b/ytLive.Tests/SceneThumbnailTests.cs index 2105ca8..c8699e2 100644 --- a/ytLive.Tests/SceneThumbnailTests.cs +++ b/ytLive.Tests/SceneThumbnailTests.cs @@ -15,9 +15,8 @@ using ytLive.ViewModels; namespace ytLive.Tests; /// -/// 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. /// [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"); -} \ No newline at end of file +} diff --git a/ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs b/ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs new file mode 100644 index 0000000..d3117fe --- /dev/null +++ b/ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs @@ -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().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; + } +} diff --git a/ytLive.Tests/SourceNamingTests.cs b/ytLive.Tests/SourceNamingTests.cs index fbcbd5d..aff50a5 100644 --- a/ytLive.Tests/SourceNamingTests.cs +++ b/ytLive.Tests/SourceNamingTests.cs @@ -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().Where(s => !s.IsBackground) + // Persistent scene layers are not part of the user-created Text sequence. + var names = scene.Elements.OfType().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().Where(s => !s.IsBackground) + var survivors = scene.Elements.OfType().Where(s => s.Type == SourceType.TextOverlay) .Select(s => s.Name).ToList(); Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors); } diff --git a/ytLive.Tests/ZoomBoxInteractionTests.cs b/ytLive.Tests/ZoomBoxInteractionTests.cs index 2304937..64bc2e2 100644 --- a/ytLive.Tests/ZoomBoxInteractionTests.cs +++ b/ytLive.Tests/ZoomBoxInteractionTests.cs @@ -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().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(vm.StagedScene.Elements[1]); + var zoom = vm.StagedScene.Elements.OfType().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(vm.StagedScene!.Elements[1]); + var zoom = vm.StagedScene!.Elements.OfType().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); } -} \ No newline at end of file +}