fix(text): show selected formatting and style controls

Use category-filtered font and selected-item dropdowns, separate horizontal and vertical alignment, and reuse the border-color palette templates for text color. Persist vertical alignment additively. Follow the established static text-source pattern: https://obsproject.com/kb/text-sources
This commit is contained in:
2026-11-04 17:05:06 -08:00
parent 5c4acf141b
commit 5f7bcc2425
15 changed files with 417 additions and 52 deletions
+51 -42
View File
@@ -2,8 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers" xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:models="clr-namespace:ytLive.Models" xmlns:models="clr-namespace:ytLive.Models">
xmlns:media="clr-namespace:System.Windows.Media;assembly=PresentationCore">
<UserControl.Resources> <UserControl.Resources>
<!-- TASK 63: the border-colour drop-down is the app's own YtComboBox, so the <!-- TASK 63: the border-colour drop-down is the app's own YtComboBox, so the
colour entries are drawn by TEMPLATES, in the theme's colours and with the colour entries are drawn by TEMPLATES, in the theme's colours and with the
@@ -321,47 +320,46 @@
<StackPanel Visibility="{Binding SelectedElement.IsTextOverlay, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8"> <StackPanel Visibility="{Binding SelectedElement.IsTextOverlay, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Text" Style="{StaticResource PropertyHeader}"/> <TextBlock Text="Text" Style="{StaticResource PropertyHeader}"/>
<TextBlock Text="Static text — it will not animate automatically. Move and resize it in the preview." <TextBlock Text="Static text — it will not animate automatically. Move and resize it in the preview."
Style="{StaticResource PropertyHint}" Margin="0,0,0,6" TextWrapping="Wrap"/> Style="{StaticResource PropertyHint}" Margin="10,0,0,6" TextWrapping="Wrap"/>
<TextBox x:Name="TextLayerEditor" Text="{Binding SelectedElement.TextContent, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" <TextBox x:Name="TextLayerEditor" Text="{Binding SelectedElement.TextContent, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource YtTextBoxCompact}" AcceptsReturn="True" TextWrapping="Wrap" Style="{StaticResource YtTextBoxCompact}" AcceptsReturn="True" TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" MinHeight="72" MaxHeight="120" VerticalScrollBarVisibility="Auto" MinHeight="72" MaxHeight="120" Margin="10,0,0,8"
AutomationProperties.Name="Text layer content"/> AutomationProperties.Name="Text layer content"/>
<TextBlock Text="Font" Style="{StaticResource PropertyHeader}"/> <TextBlock Text="Typography" Style="{StaticResource PropertyHeader}"/>
<ComboBox IsEditable="True" ItemsSource="{x:Static media:Fonts.SystemFontFamilies}" <StackPanel Margin="10,0,0,6">
DisplayMemberPath="Source" <TextBlock Text="Font Type" Style="{StaticResource PropertyFieldLabel}" Margin="0,0,0,3"/>
Text="{Binding SelectedElement.TextFontFamily, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" <ComboBox x:Name="TextLayerFontTypePicker"
Style="{StaticResource YtComboBox}" Height="26" Margin="0,0,0,6" ItemsSource="{Binding TextFontTypeOptions}"
AutomationProperties.Name="Text layer font"/> SelectedItem="{Binding SelectedTextFontType, Mode=TwoWay}"
<DockPanel Margin="0,0,0,6" LastChildFill="False"> Style="{StaticResource YtComboBox}" Height="30" Margin="0,0,0,6"/>
<TextBlock DockPanel.Dock="Left" Text="Size" Style="{StaticResource PropertyFieldLabel}" <TextBlock Text="Font" Style="{StaticResource PropertyFieldLabel}" Margin="0,0,0,3"/>
Width="42" VerticalAlignment="Center"/> <ComboBox x:Name="TextLayerFontPicker"
<ComboBox IsEditable="True" ItemsSource="{Binding TextFontFamilies}"
Text="{Binding SelectedElement.TextFontSize, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" SelectedItem="{Binding SelectedTextFontFamily, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="26" Width="76" Style="{StaticResource YtComboBox}" Height="30" Margin="0,0,0,6"
AutomationProperties.Name="Text layer size"> AutomationProperties.Name="Text layer font"/>
<ComboBoxItem Content="12"/><ComboBoxItem Content="16"/> <TextBlock Text="Size" Style="{StaticResource PropertyFieldLabel}" Margin="0,0,0,3"/>
<ComboBoxItem Content="20"/><ComboBoxItem Content="24"/> <ComboBox x:Name="TextLayerSizePicker"
<ComboBoxItem Content="32"/><ComboBoxItem Content="36"/> ItemsSource="{Binding TextFontSizeOptions}"
<ComboBoxItem Content="48"/><ComboBoxItem Content="60"/> SelectedItem="{Binding SelectedElement.TextFontSize, Mode=TwoWay}"
<ComboBoxItem Content="72"/><ComboBoxItem Content="96"/> Style="{StaticResource YtComboBox}" Height="30" Width="92"
<ComboBoxItem Content="120"/><ComboBoxItem Content="144"/> HorizontalAlignment="Left" AutomationProperties.Name="Text layer size"/>
<ComboBoxItem Content="180"/><ComboBoxItem Content="240"/> </StackPanel>
<ComboBoxItem Content="300"/> <TextBlock Text="Color" Style="{StaticResource PropertyHeader}"/>
</ComboBox> <DockPanel Margin="10,0,0,6" LastChildFill="False">
</DockPanel>
<DockPanel Margin="0,0,0,6" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="Color" Style="{StaticResource PropertyFieldLabel}"
Width="42" VerticalAlignment="Center"/>
<TextBox x:Name="TextLayerColorEditor" <TextBox x:Name="TextLayerColorEditor"
Text="{Binding SelectedElement.TextColor, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" Text="{Binding SelectedElement.TextColor, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
Style="{StaticResource YtTextBoxCompact}" MaxLength="9" Width="82" Style="{StaticResource YtTextBoxCompact}" MaxLength="9" Width="82"
ToolTip="#RRGGBB or #AARRGGBB" AutomationProperties.Name="Text layer color"/> ToolTip="#RRGGBB or #AARRGGBB" AutomationProperties.Name="Text layer color"/>
<Border Width="18" Height="18" Margin="8,0,0,0" CornerRadius="2" <ComboBox x:Name="TextLayerColorPicker"
Background="{Binding SelectedElement.TextColor, Converter={StaticResource HexToBrush}}" ItemsSource="{Binding SelectedElement.TextColorOptions}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1" SelectedItem="{Binding SelectedElement.TextColorEntry, Mode=TwoWay}"
VerticalAlignment="Center"/> ItemTemplateSelector="{StaticResource BorderColorTemplates}"
Style="{StaticResource YtComboBox}" MinWidth="112" Margin="8,0,0,0"
ToolTip="Choose a text color" AutomationProperties.Name="Text layer color palette"/>
</DockPanel> </DockPanel>
<WrapPanel Margin="0,0,0,4"> <TextBlock Text="Emphasis" Style="{StaticResource PropertyHeader}"/>
<WrapPanel Margin="10,0,0,4">
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Bold" Margin="0,0,8,4" <CheckBox Style="{StaticResource PropertyPillCheck}" Content="Bold" Margin="0,0,8,4"
IsChecked="{Binding SelectedElement.TextBold, Mode=TwoWay}"/> IsChecked="{Binding SelectedElement.TextBold, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Italic" Margin="0,0,8,4" <CheckBox Style="{StaticResource PropertyPillCheck}" Content="Italic" Margin="0,0,8,4"
@@ -370,13 +368,24 @@
IsChecked="{Binding SelectedElement.TextUnderline, Mode=TwoWay}"/> IsChecked="{Binding SelectedElement.TextUnderline, Mode=TwoWay}"/>
</WrapPanel> </WrapPanel>
<TextBlock Text="Alignment" Style="{StaticResource PropertyHeader}"/> <TextBlock Text="Alignment" Style="{StaticResource PropertyHeader}"/>
<ComboBox SelectedValuePath="Tag" <DockPanel Margin="10,0,0,4">
SelectedValue="{Binding SelectedElement.TextAlignment, Mode=TwoWay}" <TextBlock DockPanel.Dock="Left" Text="Horizontal" Style="{StaticResource PropertyFieldLabel}"
Style="{StaticResource YtComboBox}" Height="26"> Width="72" VerticalAlignment="Center"/>
<ComboBoxItem Content="Left" Tag="{x:Static models:TextLayerAlignment.Left}"/> <ComboBox x:Name="TextLayerHorizontalAlignmentPicker"
<ComboBoxItem Content="Center" Tag="{x:Static models:TextLayerAlignment.Center}"/> ItemsSource="{Binding TextHorizontalAlignmentOptions}"
<ComboBoxItem Content="Right" Tag="{x:Static models:TextLayerAlignment.Right}"/> SelectedItem="{Binding SelectedElement.TextAlignment, Mode=TwoWay}"
</ComboBox> Style="{StaticResource YtComboBox}" Height="30"
AutomationProperties.Name="Horizontal text alignment"/>
</DockPanel>
<DockPanel Margin="10,0,0,0">
<TextBlock DockPanel.Dock="Left" Text="Vertical" Style="{StaticResource PropertyFieldLabel}"
Width="72" VerticalAlignment="Center"/>
<ComboBox x:Name="TextLayerVerticalAlignmentPicker"
ItemsSource="{Binding TextVerticalAlignmentOptions}"
SelectedItem="{Binding SelectedElement.TextVerticalAlignment, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="30"
AutomationProperties.Name="Vertical text alignment"/>
</DockPanel>
</StackPanel> </StackPanel>
<!-- Opacity + mirror/shape (draggable elements: image, webcam) --> <!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
+1 -1
View File
@@ -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` | | `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. | | `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 | | `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 | | `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 | | `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above | | `LeftPanel.xaml.cs` | Code-behind for the panel above |
+29 -1
View File
@@ -48,6 +48,13 @@ public enum TextLayerAlignment
Right Right
} }
public enum TextLayerVerticalAlignment
{
Top,
Center,
Bottom
}
public enum ClipShape public enum ClipShape
{ {
Traditional, Traditional,
@@ -423,9 +430,22 @@ public class Source : SceneElement
public string TextColor public string TextColor
{ {
get => _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<NamedColor> TextColorOptions => BorderColorPalette.OptionsFor(TextColor);
private bool _textBold; private bool _textBold;
public bool TextBold { get => _textBold; set => Set(ref _textBold, value); } 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); 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) private static bool IsValidTextColor(string? value)
{ {
if (value is not { Length: 7 or 9 } || value[0] != '#') return false; if (value is not { Length: 7 or 9 } || value[0] != '#') return false;
+1 -1
View File
@@ -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 | | `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` | | `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:<n>`/`window:<hwnd>`/`picker:<name>`), `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:<n>`/`window:<hwnd>`/`picker:<name>`), `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 | | `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 | | `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) | | `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) |
+5 -1
View File
@@ -113,7 +113,7 @@ public partial class LayoutStore : IDisposable
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder, AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop, AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic, TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
TextUnderline, TextAlignment TextUnderline, TextAlignment, TextVerticalAlignment
FROM Source ORDER BY SortOrder FROM Source ORDER BY SortOrder
"""; """;
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
@@ -179,6 +179,10 @@ public partial class LayoutStore : IDisposable
TextAlignment = reader.IsDBNull(48) || !Enum.IsDefined((TextLayerAlignment)reader.GetInt32(48)) TextAlignment = reader.IsDBNull(48) || !Enum.IsDefined((TextLayerAlignment)reader.GetInt32(48))
? TextLayerAlignment.Left ? TextLayerAlignment.Left
: (TextLayerAlignment)reader.GetInt32(48), : (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)) if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>(); sourcesByScene[sceneId] = list = new List<(Source, int)>();
+6
View File
@@ -339,6 +339,12 @@ public partial class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextAlignment INTEGER NOT NULL DEFAULT 0;"; cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextAlignment INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery(); 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")) if (!columns.Contains("WebUri"))
{ {
+4 -2
View File
@@ -94,7 +94,7 @@ public partial class LayoutStore : IDisposable
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop, AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic, TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
TextUnderline, TextAlignment) TextUnderline, TextAlignment, TextVerticalAlignment)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor, $x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort, $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
@@ -104,7 +104,7 @@ public partial class LayoutStore : IDisposable
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume, $alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop, $alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic, $textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
$textUnderline, $textAlignment) $textUnderline, $textAlignment, $textVerticalAlignment)
"""; """;
cmd.Transaction = tx; cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text); 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 textItalicP = cmd.Parameters.Add("$textItalic", SqliteType.Integer);
var textUnderlineP = cmd.Parameters.Add("$textUnderline", SqliteType.Integer); var textUnderlineP = cmd.Parameters.Add("$textUnderline", SqliteType.Integer);
var textAlignmentP = cmd.Parameters.Add("$textAlignment", 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: // Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of // 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; textItalicP.Value = source.TextItalic ? 1 : 0;
textUnderlineP.Value = source.TextUnderline ? 1 : 0; textUnderlineP.Value = source.TextUnderline ? 1 : 0;
textAlignmentP.Value = (int)source.TextAlignment; textAlignmentP.Value = (int)source.TextAlignment;
textVerticalAlignmentP.Value = (int)source.TextVerticalAlignment;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
} }
+9 -1
View File
@@ -59,6 +59,7 @@ public sealed class TextOverlayRenderer
source.TextItalic, source.TextItalic,
source.TextUnderline, source.TextUnderline,
source.TextAlignment, source.TextAlignment,
source.TextVerticalAlignment,
RasterDimension(source.Width), RasterDimension(source.Width),
RasterDimension(source.Height)); RasterDimension(source.Height));
@@ -100,7 +101,13 @@ public sealed class TextOverlayRenderer
}; };
if (config.Underline) if (config.Underline)
formatted.SetTextDecorations(TextDecorations.Underline, 0, config.Content.Length); 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); var bitmap = new RenderTargetBitmap(config.Width, config.Height, 96, 96, PixelFormats.Pbgra32);
@@ -145,6 +152,7 @@ public sealed class TextOverlayRenderer
bool Italic, bool Italic,
bool Underline, bool Underline,
TextLayerAlignment Alignment, TextLayerAlignment Alignment,
TextLayerVerticalAlignment VerticalAlignment,
int Width, int Width,
int Height); int Height);
} }
+1 -1
View File
@@ -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) | | `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) | | `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 | | `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 | | `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/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) | | `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) |
+2 -1
View File
@@ -354,7 +354,8 @@ public partial class MainViewModel
or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored) or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored)
or nameof(Source.TextContent) or nameof(Source.TextFontFamily) or nameof(Source.TextContent) or nameof(Source.TextFontFamily)
or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold) 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) if (bakedSurface)
{ {
var containingScene = Scenes.FirstOrDefault(s => s.Elements.Contains(el)); var containingScene = Scenes.FirstOrDefault(s => s.Elements.Contains(el));
+131
View File
@@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Windows.Media;
using ytLive.Models; using ytLive.Models;
using ytLive.Services; using ytLive.Services;
@@ -7,9 +8,138 @@ namespace ytLive.ViewModels;
public partial class MainViewModel 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<TextLayerAlignment>();
private static readonly TextLayerVerticalAlignment[] VerticalAlignmentOptions =
Enum.GetValues<TextLayerVerticalAlignment>();
private readonly TextOverlayRenderer _textOverlayRenderer = new(); private readonly TextOverlayRenderer _textOverlayRenderer = new();
private readonly HashSet<Source> _wiredTextOverlays = new(); private readonly HashSet<Source> _wiredTextOverlays = new();
private string _selectedTextFontType = "Sans Serif";
public IReadOnlyList<string> 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<string> 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<double> 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<TextLayerAlignment> TextHorizontalAlignmentOptions =>
HorizontalAlignmentOptions;
public IReadOnlyList<TextLayerVerticalAlignment> 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() private void UpdateConfiguredTextOverlays()
{ {
var sources = Scenes.SelectMany(scene => scene.Elements).OfType<Source>() var sources = Scenes.SelectMany(scene => scene.Elements).OfType<Source>()
@@ -54,6 +184,7 @@ public partial class MainViewModel
if (propertyName is nameof(Source.TextContent) or nameof(Source.TextFontFamily) 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.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)
or nameof(Source.Width) or nameof(Source.Height)) or nameof(Source.Width) or nameof(Source.Height))
UpdateTextOverlayPreview(source); UpdateTextOverlayPreview(source);
} }
+12
View File
@@ -74,6 +74,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(IsBlurSelected)); OnPropertyChanged(nameof(IsBlurSelected));
OnPropertyChanged(nameof(IsElementsPanelEmpty)); OnPropertyChanged(nameof(IsElementsPanelEmpty));
OnPropertyChanged(nameof(CanToggleClipShape)); OnPropertyChanged(nameof(CanToggleClipShape));
SyncTextFontCategory();
if (_selectedElement != null) if (_selectedElement != null)
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged; _selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
} }
@@ -113,6 +114,17 @@ public partial class MainViewModel : ViewModelBase
// property. Cheap: the bake is recomputed on demand, once. // property. Cheap: the bake is recomputed on demand, once.
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene); 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)) if (e.PropertyName == nameof(SceneElement.UseDefaultBackground))
{ {
// ⛔ Turning the default back ON has to RE-STAMP the shipped art. Browse… writes the // ⛔ Turning the default back ON has to RE-STAMP the shipped art. Browse… writes the
+1 -1
View File
@@ -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`. | | `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.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. | | `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) Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
+11
View File
@@ -18,6 +18,9 @@ public class SourceTextSettingsTests
Assert.False(source.TextItalic); Assert.False(source.TextItalic);
Assert.False(source.TextUnderline); Assert.False(source.TextUnderline);
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment); 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] [Fact]
@@ -29,12 +32,14 @@ public class SourceTextSettingsTests
TextFontSize = double.NaN, TextFontSize = double.NaN,
TextColor = "not-a-color", TextColor = "not-a-color",
TextAlignment = (TextLayerAlignment)999, TextAlignment = (TextLayerAlignment)999,
TextVerticalAlignment = (TextLayerVerticalAlignment)999,
}; };
Assert.Equal("Segoe UI", source.TextFontFamily); Assert.Equal("Segoe UI", source.TextFontFamily);
Assert.Equal(48, source.TextFontSize); Assert.Equal(48, source.TextFontSize);
Assert.Equal("#FFFFFF", source.TextColor); Assert.Equal("#FFFFFF", source.TextColor);
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment); Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
Assert.Equal(TextLayerVerticalAlignment.Top, source.TextVerticalAlignment);
source.TextFontSize = 1; source.TextFontSize = 1;
Assert.Equal(8, source.TextFontSize); Assert.Equal(8, source.TextFontSize);
@@ -42,5 +47,11 @@ public class SourceTextSettingsTests
Assert.Equal(300, source.TextFontSize); Assert.Equal(300, source.TextFontSize);
source.TextColor = "#abcdef"; source.TextColor = "#abcdef";
Assert.Equal("#ABCDEF", source.TextColor); 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");
} }
} }
+153
View File
@@ -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<Source>()
.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<string>().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<ytLive.Services.VideoFrame>(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<Source>()
.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;
}
}