diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index ec135a6..4c20cc5 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -2,8 +2,7 @@ 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:media="clr-namespace:System.Windows.Media;assembly=PresentationCore"> + xmlns:models="clr-namespace:ytLive.Models"> diff --git a/Controls/index.md b/Controls/index.md index 9db4917..a559ddc 100644 --- a/Controls/index.md +++ b/Controls/index.md @@ -12,7 +12,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions. | `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; right-click opens App Settings directly. `RefreshAvatar()`, `GearButton_Click` | | `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, 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; TextOverlay properties edit multiline content, font, size, color, emphasis, and alignment with a static-text notice. **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 | +| `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; TextOverlay properties edit multiline content, font type/family, size, color via the shared palette, emphasis, and horizontal/vertical alignment with a static-text notice; sub-options are indented under their headings. **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; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings 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/Models/Source.cs b/Models/Source.cs index 7b45826..8f71ade 100644 --- a/Models/Source.cs +++ b/Models/Source.cs @@ -48,6 +48,13 @@ public enum TextLayerAlignment Right } +public enum TextLayerVerticalAlignment +{ + Top, + Center, + Bottom +} + public enum ClipShape { Traditional, @@ -423,9 +430,22 @@ public class Source : SceneElement public string TextColor { get => _textColor; - set => Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF"); + set + { + if (!Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF")) return; + Raise(nameof(TextColorEntry)); + Raise(nameof(TextColorOptions)); + } } + public NamedColor TextColorEntry + { + get => BorderColorPalette.EntryFor(TextColor); + set => TextColor = value.Hex; + } + + public IReadOnlyList TextColorOptions => BorderColorPalette.OptionsFor(TextColor); + private bool _textBold; public bool TextBold { get => _textBold; set => Set(ref _textBold, value); } @@ -442,6 +462,14 @@ public class Source : SceneElement set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left); } + private TextLayerVerticalAlignment _textVerticalAlignment; + public TextLayerVerticalAlignment TextVerticalAlignment + { + get => _textVerticalAlignment; + set => Set(ref _textVerticalAlignment, + Enum.IsDefined(value) ? value : TextLayerVerticalAlignment.Top); + } + private static bool IsValidTextColor(string? value) { if (value is not { Length: 7 or 9 } || value[0] != '#') return false; diff --git a/Models/index.md b/Models/index.md index a3e147d..68f4efd 100644 --- a/Models/index.md +++ b/Models/index.md @@ -7,7 +7,7 @@ Plain data types. No logic beyond what a property can carry. See |------|---------| | `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor | | `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` | -| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **TextOverlay content + font/size/color/emphasis/alignment settings** (independent per source; static text), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:`/`window:`/`picker:`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row | +| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **TextOverlay content + font/size/color/emphasis/horizontal-alignment/vertical-alignment settings** (independent per source; static text), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:`/`window:`/`picker:`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row | | `Ticker.cs` | Shared per-source ticker settings: `IsEnabled` and `DisplayMethod`; one independent instance for each scene ticker and the AlertBox ticker | | `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them | | `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) | diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs index 68a8135..5298b9b 100644 --- a/Services/LayoutStore.Load.cs +++ b/Services/LayoutStore.Load.cs @@ -113,7 +113,7 @@ public partial class LayoutStore : IDisposable AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder, AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop, TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic, - TextUnderline, TextAlignment + TextUnderline, TextAlignment, TextVerticalAlignment FROM Source ORDER BY SortOrder """; using var reader = cmd.ExecuteReader(); @@ -179,6 +179,10 @@ public partial class LayoutStore : IDisposable TextAlignment = reader.IsDBNull(48) || !Enum.IsDefined((TextLayerAlignment)reader.GetInt32(48)) ? TextLayerAlignment.Left : (TextLayerAlignment)reader.GetInt32(48), + TextVerticalAlignment = reader.IsDBNull(49) + || !Enum.IsDefined((TextLayerVerticalAlignment)reader.GetInt32(49)) + ? TextLayerVerticalAlignment.Top + : (TextLayerVerticalAlignment)reader.GetInt32(49), }; if (!sourcesByScene.TryGetValue(sceneId, out var list)) sourcesByScene[sceneId] = list = new List<(Source, int)>(); diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs index 209680a..bd3fbde 100644 --- a/Services/LayoutStore.Migrations.cs +++ b/Services/LayoutStore.Migrations.cs @@ -339,6 +339,12 @@ public partial class LayoutStore : IDisposable cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextAlignment INTEGER NOT NULL DEFAULT 0;"; cmd.ExecuteNonQuery(); } + if (!columns.Contains("TextVerticalAlignment")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextVerticalAlignment INTEGER NOT NULL DEFAULT 0;"; + cmd.ExecuteNonQuery(); + } if (!columns.Contains("WebUri")) { diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs index 54e2c7d..ea553f9 100644 --- a/Services/LayoutStore.Save.cs +++ b/Services/LayoutStore.Save.cs @@ -94,7 +94,7 @@ public partial class LayoutStore : IDisposable AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop, TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic, - TextUnderline, TextAlignment) + TextUnderline, TextAlignment, TextVerticalAlignment) VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, $x, $y, $w, $h, $opacity, $monitor, $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort, @@ -104,7 +104,7 @@ public partial class LayoutStore : IDisposable $alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume, $alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop, $textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic, - $textUnderline, $textAlignment) + $textUnderline, $textAlignment, $textVerticalAlignment) """; cmd.Transaction = tx; var idP = cmd.Parameters.Add("$id", SqliteType.Text); @@ -156,6 +156,7 @@ public partial class LayoutStore : IDisposable var textItalicP = cmd.Parameters.Add("$textItalic", SqliteType.Integer); var textUnderlineP = cmd.Parameters.Add("$textUnderline", SqliteType.Integer); var textAlignmentP = cmd.Parameters.Add("$textAlignment", SqliteType.Integer); + var textVerticalAlignmentP = cmd.Parameters.Add("$textVerticalAlignment", SqliteType.Integer); // Source and WebcamSceneConfig share ONE z-order space per scene: // each element gets its index within scene.Elements regardless of @@ -216,6 +217,7 @@ public partial class LayoutStore : IDisposable textItalicP.Value = source.TextItalic ? 1 : 0; textUnderlineP.Value = source.TextUnderline ? 1 : 0; textAlignmentP.Value = (int)source.TextAlignment; + textVerticalAlignmentP.Value = (int)source.TextVerticalAlignment; cmd.ExecuteNonQuery(); } } diff --git a/Services/TextOverlayRenderer.cs b/Services/TextOverlayRenderer.cs index 87f7b4f..8bb937e 100644 --- a/Services/TextOverlayRenderer.cs +++ b/Services/TextOverlayRenderer.cs @@ -59,6 +59,7 @@ public sealed class TextOverlayRenderer source.TextItalic, source.TextUnderline, source.TextAlignment, + source.TextVerticalAlignment, RasterDimension(source.Width), RasterDimension(source.Height)); @@ -100,7 +101,13 @@ public sealed class TextOverlayRenderer }; if (config.Underline) formatted.SetTextDecorations(TextDecorations.Underline, 0, config.Content.Length); - drawing.DrawText(formatted, new Point(0, 0)); + var top = config.VerticalAlignment switch + { + TextLayerVerticalAlignment.Center => Math.Max(0, (config.Height - formatted.Height) / 2), + TextLayerVerticalAlignment.Bottom => Math.Max(0, config.Height - formatted.Height), + _ => 0, + }; + drawing.DrawText(formatted, new Point(0, top)); } var bitmap = new RenderTargetBitmap(config.Width, config.Height, 96, 96, PixelFormats.Pbgra32); @@ -145,6 +152,7 @@ public sealed class TextOverlayRenderer bool Italic, bool Underline, TextLayerAlignment Alignment, + TextLayerVerticalAlignment VerticalAlignment, int Width, int Height); } diff --git a/Services/index.md b/Services/index.md index 020f0c9..059ab30 100644 --- a/Services/index.md +++ b/Services/index.md @@ -16,7 +16,7 @@ External-facing logic: YouTube API, persistence. See | `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. **Still the fallback** when no alert clip is playable (TASK 47) | | `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain. The shared ticker renderer serves the AlertBox's active event and scene-local `SourceType.Ticker` layers' current+queued feed (transient; no history) | | `AlertClipDecoder.cs` | **Per-play alert video decoder (TASK 47):** `IAlertClipDecoder` (Start/Stop/Dispose + `FrameAvailable`/`AudioReady`/`Completed`) + ffmpeg child emitting `rawvideo bgra` (`-vf scale=W:H`, read via `RawVideoFrameReader`) and `f32le -ar 48000 -ac 2` on a **carry-buffer** audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake | -| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; TASK 3.17 adds eight additive `Source` text-layer columns (`TextContent`/font family/size/color/bold/italic/underline/alignment), also without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) | +| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; TASK 3.17 adds nine additive `Source` text-layer columns (`TextContent`/font family/size/color/bold/italic/underline/horizontal alignment/vertical alignment), also without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) | | `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam | | `BackgroundEffects/IPersonSegmenter.cs` | **TASK 51 seam:** `Segment(VideoFrame) → SegmentationMask?` (`SegmentationMask(Width, Height, float[] Values)`, 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes | | `BackgroundEffects/OnnxPersonSegmenter.cs` | **TASK 51 default segmenter:** MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) | diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs index 3fb755b..ade76aa 100644 --- a/ViewModels/MainViewModel.Scenes.cs +++ b/ViewModels/MainViewModel.Scenes.cs @@ -354,7 +354,8 @@ public partial class MainViewModel or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored) or nameof(Source.TextContent) or nameof(Source.TextFontFamily) or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold) - or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment); + or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment) + or nameof(Source.TextVerticalAlignment); if (bakedSurface) { var containingScene = Scenes.FirstOrDefault(s => s.Elements.Contains(el)); diff --git a/ViewModels/MainViewModel.TextOverlay.cs b/ViewModels/MainViewModel.TextOverlay.cs index 0acbff6..4e10760 100644 --- a/ViewModels/MainViewModel.TextOverlay.cs +++ b/ViewModels/MainViewModel.TextOverlay.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Windows.Media; using ytLive.Models; using ytLive.Services; @@ -7,9 +8,138 @@ namespace ytLive.ViewModels; public partial class MainViewModel { + private static readonly string[] SystemTextFontFamilies = Fonts.SystemFontFamilies + .Select(font => font.Source) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase) + .ToArray(); + private static readonly double[] CommonTextFontSizes = + { 12, 16, 20, 24, 32, 36, 48, 60, 72, 96, 120, 144, 180, 240, 300 }; + private static readonly TextLayerAlignment[] HorizontalAlignmentOptions = + Enum.GetValues(); + private static readonly TextLayerVerticalAlignment[] VerticalAlignmentOptions = + Enum.GetValues(); + private readonly TextOverlayRenderer _textOverlayRenderer = new(); private readonly HashSet _wiredTextOverlays = new(); + private string _selectedTextFontType = "Sans Serif"; + + public IReadOnlyList TextFontTypeOptions { get; } = + new[] { "Sans Serif", "Serif", "Monospace", "Display", "Other" }; + + public string SelectedTextFontType + { + get => _selectedTextFontType; + set + { + if (SetProperty(ref _selectedTextFontType, value)) + { + OnPropertyChanged(nameof(TextFontFamilies)); + OnPropertyChanged(nameof(SelectedTextFontFamily)); + } + } + } + + public IReadOnlyList TextFontFamilies + { + get + { + var selected = SelectedElement is Source { Type: SourceType.TextOverlay } source + ? source.TextFontFamily + : null; + var options = SystemTextFontFamilies + .Where(font => FontTypeFor(font) == SelectedTextFontType) + .ToList(); + if (selected != null && FontTypeFor(selected) == SelectedTextFontType + && !options.Contains(selected, StringComparer.Ordinal)) + options.Add(selected); + return options; + } + } + + public string? SelectedTextFontFamily + { + get => (SelectedElement as Source)?.TextFontFamily; + set + { + if (SelectedElement is Source { Type: SourceType.TextOverlay } source + && !string.IsNullOrWhiteSpace(value)) + source.TextFontFamily = value; + } + } + + public IReadOnlyList TextFontSizeOptions + { + get + { + var selected = (SelectedElement as Source)?.TextFontSize; + return selected is { } size && !CommonTextFontSizes.Contains(size) + ? CommonTextFontSizes.Append(size).OrderBy(value => value).ToArray() + : CommonTextFontSizes; + } + } + + public IReadOnlyList TextHorizontalAlignmentOptions => + HorizontalAlignmentOptions; + + public IReadOnlyList TextVerticalAlignmentOptions => + VerticalAlignmentOptions; + + private void SyncTextFontCategory() + { + SelectedTextFontType = SelectedElement is Source { Type: SourceType.TextOverlay } source + ? FontTypeFor(source.TextFontFamily) + : "Sans Serif"; + OnPropertyChanged(nameof(SelectedTextFontFamily)); + OnPropertyChanged(nameof(TextFontSizeOptions)); + } + + private static string FontTypeFor(string font) + { + if (font.Contains("Mono", StringComparison.OrdinalIgnoreCase) + || font.Contains("Consolas", StringComparison.OrdinalIgnoreCase) + || font.Contains("Courier", StringComparison.OrdinalIgnoreCase) + || font.Contains("Fixedsys", StringComparison.OrdinalIgnoreCase) + || font.Contains("Terminal", StringComparison.OrdinalIgnoreCase)) + return "Monospace"; + + if (SerifFonts.Contains(font, StringComparer.OrdinalIgnoreCase) + || (font.Contains("Serif", StringComparison.OrdinalIgnoreCase) + && !font.Contains("Sans", StringComparison.OrdinalIgnoreCase))) + return "Serif"; + + if (font.Contains("Display", StringComparison.OrdinalIgnoreCase) + || font.Contains("Script", StringComparison.OrdinalIgnoreCase) + || DisplayFonts.Contains(font, StringComparer.OrdinalIgnoreCase)) + return "Display"; + + if (font.Contains("Sans", StringComparison.OrdinalIgnoreCase) + || SansSerifFonts.Contains(font, StringComparer.OrdinalIgnoreCase)) + return "Sans Serif"; + + return "Other"; + } + + private static readonly string[] SerifFonts = + { + "Cambria", "Constantia", "Georgia", "Garamond", "Palatino Linotype", "Times New Roman", + "Book Antiqua", "Century Schoolbook", "Sitka Text", "Sitka Heading", "Bodoni MT", "Bell MT", + }; + + private static readonly string[] DisplayFonts = + { + "Algerian", "Broadway", "Chiller", "Comic Sans MS", "Cooper Black", "Impact", "Jokerman", + "Magneto", "Papyrus", "Showcard Gothic", "Stencil", + }; + + private static readonly string[] SansSerifFonts = + { + "Arial", "Arial Nova", "Aptos", "Bahnschrift", "Calibri", "Candara", "Century Gothic", + "Corbel", "Franklin Gothic Medium", "Microsoft Sans Serif", "Segoe UI", "Tahoma", + "Trebuchet MS", "Verdana", + }; + private void UpdateConfiguredTextOverlays() { var sources = Scenes.SelectMany(scene => scene.Elements).OfType() @@ -54,6 +184,7 @@ public partial class MainViewModel if (propertyName is nameof(Source.TextContent) or nameof(Source.TextFontFamily) or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold) or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment) + or nameof(Source.TextVerticalAlignment) or nameof(Source.Width) or nameof(Source.Height)) UpdateTextOverlayPreview(source); } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 0233eaa..a04fa47 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -74,6 +74,7 @@ public partial class MainViewModel : ViewModelBase OnPropertyChanged(nameof(IsBlurSelected)); OnPropertyChanged(nameof(IsElementsPanelEmpty)); OnPropertyChanged(nameof(CanToggleClipShape)); + SyncTextFontCategory(); if (_selectedElement != null) _selectedElement.PropertyChanged += OnSelectedElementPropertyChanged; } @@ -113,6 +114,17 @@ public partial class MainViewModel : ViewModelBase // property. Cheap: the bake is recomputed on demand, once. if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene); + if (sender is Source text && text.Type == SourceType.TextOverlay) + { + if (e.PropertyName == nameof(Source.TextFontFamily)) + { + SelectedTextFontType = FontTypeFor(text.TextFontFamily); + OnPropertyChanged(nameof(SelectedTextFontFamily)); + } + if (e.PropertyName == nameof(Source.TextFontSize)) + OnPropertyChanged(nameof(TextFontSizeOptions)); + } + if (e.PropertyName == nameof(SceneElement.UseDefaultBackground)) { // ⛔ Turning the default back ON has to RE-STAMP the shipped art. Browse… writes the diff --git a/ViewModels/index.md b/ViewModels/index.md index a1fba46..0085544 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -30,7 +30,7 @@ areas' docs in their own rows keeps this index small to read. | `SocialsDialogViewModel.cs` | Social Media Site Promotion dialog (six fixed slots, `SocialSlotViewModel`): row 0 = YouTube sign-in gate (sign-in provider fills the channel, delete = sign-out); rows 1-5 validated entries / empty / freemium locks 2-5 (`isPremium` seam); `DetectService` → `ISocialValidator` on confirm (input snaps to text + real logo on success, red do-not + stays editable on failure); `CanSave` blocked while any slot edits/validates/errors; `BarEnabled` switch; commit via `CommittedEntries` + `BuildSocialsConfig()` (YouTube first) + `SaveRequested`/`CancelRequested`. WPF-free — the window owns the dialogs (delete confirm, sign-in gate) and calls `StartEdit`/`ConfirmEdit`/`DeleteSlotAsync`. | | `MainViewModel.Sources.cs` | Source creation/removal; `AddSource` creates the standalone ticker layer with one-per-scene gating and its 1920×48 default geometry; TextOverlay starts as a visible 900×140 draggable text layer. | -| `MainViewModel.TextOverlay.cs` | Raster-on-change TextOverlay preview hookup and settings/dimension invalidation; output resolution reads its immutable cached frame. | +| `MainViewModel.TextOverlay.cs` | Raster-on-change TextOverlay preview hookup and settings/dimension invalidation; exposes font categories and selected-layer font/size options; output resolution reads its immutable cached frame. | | `MainViewModel.AlertTicker.cs` | AlertBox ticker preview routing: selects the preview scene's AlertBox settings and publishes the positioned box-sized frame to PreviewPane. | Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md) diff --git a/ytLive.Tests/SourceTextSettingsTests.cs b/ytLive.Tests/SourceTextSettingsTests.cs index e2c8469..65b7b9c 100644 --- a/ytLive.Tests/SourceTextSettingsTests.cs +++ b/ytLive.Tests/SourceTextSettingsTests.cs @@ -18,6 +18,9 @@ public class SourceTextSettingsTests Assert.False(source.TextItalic); Assert.False(source.TextUnderline); Assert.Equal(TextLayerAlignment.Left, source.TextAlignment); + Assert.Equal(TextLayerVerticalAlignment.Top, source.TextVerticalAlignment); + Assert.Equal("#FFFFFF", source.TextColorEntry.Hex); + Assert.Contains(source.TextColorOptions, color => color.Hex == "#FFFFFF"); } [Fact] @@ -29,12 +32,14 @@ public class SourceTextSettingsTests TextFontSize = double.NaN, TextColor = "not-a-color", TextAlignment = (TextLayerAlignment)999, + TextVerticalAlignment = (TextLayerVerticalAlignment)999, }; Assert.Equal("Segoe UI", source.TextFontFamily); Assert.Equal(48, source.TextFontSize); Assert.Equal("#FFFFFF", source.TextColor); Assert.Equal(TextLayerAlignment.Left, source.TextAlignment); + Assert.Equal(TextLayerVerticalAlignment.Top, source.TextVerticalAlignment); source.TextFontSize = 1; Assert.Equal(8, source.TextFontSize); @@ -42,5 +47,11 @@ public class SourceTextSettingsTests Assert.Equal(300, source.TextFontSize); source.TextColor = "#abcdef"; Assert.Equal("#ABCDEF", source.TextColor); + + source.TextColorEntry = BorderColorPalette.All.Single(color => color.Hex == "#FF0000"); + Assert.Equal("#FF0000", source.TextColor); + Assert.Equal("Red", source.TextColorEntry.Name); + source.TextColor = "#123456"; + Assert.Contains(source.TextColorOptions, color => color.Hex == "#123456"); } } diff --git a/ytLive.Tests/TextLayerDropdownTests.cs b/ytLive.Tests/TextLayerDropdownTests.cs new file mode 100644 index 0000000..98e878c --- /dev/null +++ b/ytLive.Tests/TextLayerDropdownTests.cs @@ -0,0 +1,153 @@ +using System; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Controls; +using ytLive.Models; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +[Collection("RealApp")] +public sealed class TextLayerDropdownTests +{ + private readonly RealAppHost _app; + + public TextLayerDropdownTests(RealAppHost app) => _app = app; + + [Fact] + public void Text_Controls_Show_Defaults_Update_And_Restore_Selected_Values() + { + _app.Run(Run); + } + + private static void Run() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"ytLlive-text-controls-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = dbPath; + MainWindow? window = null; + try + { + window = new MainWindow(); + var vm = (MainViewModel)window.DataContext; + vm.EditingActive = true; + vm.StagedScene = vm.Scenes.Single(scene => SceneCatalog.Is(scene.Name, SceneCatalog.Live)); + vm.AddSourceCommand.Execute(SourceType.TextOverlay); + var source = vm.StagedScene!.Elements.OfType() + .Single(item => item.Type == SourceType.TextOverlay); + vm.SelectedElement = source; + + window.Show(); + window.UpdateLayout(); + + var panel = (LeftPanel)window.FindName("LeftPanel")!; + var fontType = (ComboBox)panel.FindName("TextLayerFontTypePicker")!; + var font = (ComboBox)panel.FindName("TextLayerFontPicker")!; + var size = (ComboBox)panel.FindName("TextLayerSizePicker")!; + var colorField = (TextBox)panel.FindName("TextLayerColorEditor")!; + var color = (ComboBox)panel.FindName("TextLayerColorPicker")!; + var horizontal = (ComboBox)panel.FindName("TextLayerHorizontalAlignmentPicker")!; + var vertical = (ComboBox)panel.FindName("TextLayerVerticalAlignmentPicker")!; + + Assert.Equal("Sans Serif", fontType.SelectedItem); + Assert.Equal("Sans Serif", fontType.Text); + Assert.Equal("Segoe UI", font.SelectedItem); + Assert.Equal("Segoe UI", font.Text); + Assert.Equal(48d, size.SelectedItem); + Assert.Equal("48", size.Text); + Assert.Equal("#FFFFFF", colorField.Text); + Assert.Equal(source.TextColorEntry, color.SelectedItem); + Assert.Equal(TextLayerAlignment.Left, horizontal.SelectedItem); + Assert.Equal("Left", horizontal.Text); + Assert.Equal(TextLayerVerticalAlignment.Top, vertical.SelectedItem); + Assert.Equal("Top", vertical.Text); + + Assert.Same(Application.Current.Resources["YtComboBox"], color.Style); + Assert.Same(panel.Resources["BorderColorTemplates"], color.ItemTemplateSelector); + Assert.Equal(10, ((FrameworkElement)fontType.Parent).Margin.Left); + Assert.Equal(10, ((FrameworkElement)color.Parent).Margin.Left); + Assert.Equal(10, ((FrameworkElement)horizontal.Parent).Margin.Left); + Assert.Equal(10, ((FrameworkElement)vertical.Parent).Margin.Left); + + fontType.SelectedItem = "Serif"; + window.UpdateLayout(); + Assert.Equal("Serif", vm.SelectedTextFontType); + Assert.Equal("Segoe UI", source.TextFontFamily); + Assert.NotEmpty(font.Items); + var serifFont = font.Items.Cast().First(); + font.SelectedItem = serifFont; + size.SelectedItem = 36d; + color.SelectedItem = source.TextColorOptions.Single(item => item.Hex == "#FF0000"); + horizontal.SelectedItem = TextLayerAlignment.Center; + vertical.SelectedItem = TextLayerVerticalAlignment.Bottom; + window.UpdateLayout(); + + Assert.Equal(serifFont, source.TextFontFamily); + Assert.Equal(36, source.TextFontSize); + Assert.Equal("#FF0000", source.TextColor); + Assert.Equal(TextLayerAlignment.Center, source.TextAlignment); + Assert.Equal(TextLayerVerticalAlignment.Bottom, source.TextVerticalAlignment); + var raster = Assert.IsType(vm.ResolveOutputFrame(source)); + Assert.True(FirstInkRow(raster) > 50, + "bottom alignment did not place the text lower inside its box"); + Assert.Equal("Serif", fontType.SelectedItem); + Assert.Equal(serifFont, font.SelectedItem); + Assert.Equal(serifFont, font.Text); + Assert.Equal(36d, size.SelectedItem); + Assert.Equal("36", size.Text); + Assert.Equal("#FF0000", colorField.Text); + Assert.Equal(source.TextColorEntry, color.SelectedItem); + Assert.Equal(TextLayerAlignment.Center, horizontal.SelectedItem); + Assert.Equal("Center", horizontal.Text); + Assert.Equal(TextLayerVerticalAlignment.Bottom, vertical.SelectedItem); + Assert.Equal("Bottom", vertical.Text); + + vm.SaveLayoutNow(); + window.Close(); + window = null; + SqliteConnection.ClearAllPools(); + + window = new MainWindow(); + var reloadedVm = (MainViewModel)window.DataContext; + reloadedVm.EditingActive = true; + reloadedVm.StagedScene = reloadedVm.Scenes.Single(scene => SceneCatalog.Is(scene.Name, SceneCatalog.Live)); + var reloaded = reloadedVm.StagedScene.Elements.OfType() + .Single(item => item.Type == SourceType.TextOverlay); + reloadedVm.SelectedElement = reloaded; + window.Show(); + window.UpdateLayout(); + + var reloadedPanel = (LeftPanel)window.FindName("LeftPanel")!; + Assert.Equal(reloadedVm.SelectedTextFontType, ((ComboBox)reloadedPanel.FindName("TextLayerFontTypePicker")!).SelectedItem); + Assert.Equal(reloaded.TextFontFamily, ((ComboBox)reloadedPanel.FindName("TextLayerFontPicker")!).SelectedItem); + Assert.Equal(reloaded.TextFontFamily, ((ComboBox)reloadedPanel.FindName("TextLayerFontPicker")!).Text); + Assert.Equal(reloaded.TextFontSize, ((ComboBox)reloadedPanel.FindName("TextLayerSizePicker")!).SelectedItem); + Assert.Equal(reloaded.TextFontSize.ToString(), ((ComboBox)reloadedPanel.FindName("TextLayerSizePicker")!).Text); + Assert.Equal(reloaded.TextColor, ((TextBox)reloadedPanel.FindName("TextLayerColorEditor")!).Text); + Assert.Equal(reloaded.TextColorEntry, ((ComboBox)reloadedPanel.FindName("TextLayerColorPicker")!).SelectedItem); + Assert.Equal(reloaded.TextAlignment, ((ComboBox)reloadedPanel.FindName("TextLayerHorizontalAlignmentPicker")!).SelectedItem); + Assert.Equal(reloaded.TextAlignment.ToString(), ((ComboBox)reloadedPanel.FindName("TextLayerHorizontalAlignmentPicker")!).Text); + Assert.Equal(reloaded.TextVerticalAlignment, ((ComboBox)reloadedPanel.FindName("TextLayerVerticalAlignmentPicker")!).SelectedItem); + Assert.Equal(reloaded.TextVerticalAlignment.ToString(), ((ComboBox)reloadedPanel.FindName("TextLayerVerticalAlignmentPicker")!).Text); + } + finally + { + window?.Close(); + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(dbPath); } catch { /* best-effort cleanup */ } + } + } + + private static int FirstInkRow(ytLive.Services.VideoFrame frame) + { + for (var y = 0; y < frame.Height; y++) + for (var x = 0; x < frame.Width; x++) + if (frame.BgraPixels[(y * frame.Width + x) * 4 + 3] > 0) + return y; + return frame.Height; + } +}