Brand logo: one locked image layer per scene above the background, image from App Settings

- Source.IsBrandLogo persisted (additive migration); layer healed on load, removal blocked
- Image stored as an asset; settings keep the asset id and file name
- App Settings: Brand Logo file field with Browse
- Default placement top-left; per-scene eye uses the existing element visibility
Test: BrandLogoTests; layout/background tests pass (34). Not pushed.
This commit is contained in:
2026-10-27 09:25:49 -07:00
parent b86b6bb601
commit 841c905c06
12 changed files with 194 additions and 7 deletions
+11
View File
@@ -56,6 +56,17 @@
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static Models:ZoomShape.VerticalRectangle}"/>
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static Models:ZoomShape.HorizontalRectangle}"/>
</ComboBox>
<TextBlock Text="Brand Logo" Style="{StaticResource YtLabel}" Margin="0,12,0,4"/>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Style="{StaticResource YtTextBox}" Text="{Binding BrandLogoFileDisplay, Mode=OneWay}"
IsReadOnly="True" Foreground="#a0a0b0"/>
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBrandLogoCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="8,0,0,0"/>
</Grid>
<TextBlock Text="Scene Transition" Style="{StaticResource YtLabel}" Margin="0,12,0,4"/>
<ComboBox Style="{StaticResource YtComboBox}" SelectedValue="{Binding SelectedTransitionType}" SelectedValuePath="Tag">
<ComboBoxItem Content="Cut" Tag="{x:Static services:TransitionType.Cut}"/>
+4
View File
@@ -88,6 +88,10 @@ public class Source : SceneElement
public override bool IsWebSource => Type == SourceType.WebSource;
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
public bool IsBrandLogo { get; set; }
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
private bool _isBackground;
public override bool IsBackground { get => _isBackground; set => Set(ref _isBackground, value); }
+2 -1
View File
@@ -102,7 +102,7 @@ public partial class LayoutStore : IDisposable
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
AlertDisplayMethod
AlertDisplayMethod, IsBrandLogo
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -154,6 +154,7 @@ public partial class LayoutStore : IDisposable
AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0,
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
IsBrandLogo = reader.GetInt32(reader.GetOrdinal("IsBrandLogo")) != 0,
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>();
+7
View File
@@ -199,6 +199,13 @@ public partial class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
if (!columns.Contains("IsBrandLogo"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBrandLogo INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("CaptureKey"))
{
using var cmd = _connection.CreateCommand();
+4 -2
View File
@@ -92,7 +92,7 @@ public partial class LayoutStore : IDisposable
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
AlertDisplayMethod)
AlertDisplayMethod, IsBrandLogo)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
@@ -100,7 +100,7 @@ public partial class LayoutStore : IDisposable
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
$alertDisplayMethod)
$alertDisplayMethod, $isBrandLogo)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -140,6 +140,7 @@ public partial class LayoutStore : IDisposable
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
var isBrandLogoP = cmd.Parameters.Add("$isBrandLogo", SqliteType.Integer);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
@@ -188,6 +189,7 @@ public partial class LayoutStore : IDisposable
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
alertVideoVolumeP.Value = source.AlertVideoVolume;
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
isBrandLogoP.Value = source.IsBrandLogo ? 1 : 0;
cmd.ExecuteNonQuery();
}
}
+16
View File
@@ -348,4 +348,20 @@ public partial class LayoutStore : IDisposable
/// <summary>The stored on-air image's Asset id, or null when never stamped.</summary>
public string? GetOnAirImageId() => GetSetting(OnAirImageKey);
private const string BrandLogoAssetKey = "BrandLogoAssetId";
private const string BrandLogoNameKey = "BrandLogoFileName";
public string? GetBrandLogoAssetId() => GetSetting(BrandLogoAssetKey);
public string? GetBrandLogoFileName() => GetSetting(BrandLogoNameKey);
/// <summary>Stores the brand logo image as an asset and records its file name for display.</summary>
public string SetBrandLogo(byte[] data, string fileName)
{
var id = UpsertAsset(data, 0, 0);
UpsertSetting(BrandLogoAssetKey, id);
UpsertSetting(BrandLogoNameKey, fileName);
return id;
}
}
+3 -2
View File
@@ -458,7 +458,8 @@ These were explicitly ruled out:
- ✅ **Transition picker** in App Settings (type + duration), saved with the layout (`TransitionPickerTests`).
- ✅ **Wipe** — CPU blend, left-to-right reveal (`WipeTransitionTests`). Direction setting not yet added.
- ☐ **Move** — persistent layers (per-layer "carry across scenes" flag), per-scene placement, fixed z band.
- ☐ **Brand logo** — permanent "Brand logo" layer above background in every scene; file in App Settings
(imported to the asset table); per-scene eye toggle; top-left default.
- ✅ **Brand logo** — one locked "Brand logo" image layer per scene, directly above the background; file in
App Settings (imported to the asset table, the setting keeps the asset id); per-scene eye toggle; top-left
default. Tests: `BrandLogoTests`. On-screen check pending.
- ☐ **Cube** — GPU path gated on capability; CPU path only if it holds 60fps.
- Gear: right-click opens App Settings (built, on-screen check pending). Left-click menu stays for now.
+1
View File
@@ -438,6 +438,7 @@ public partial class MainViewModel
StampDefaultBackgroundAsset(background, scene.Name);
}
SeedOnAirImage();
EnsureBrandLogos();
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(LayerCount));
}
+77
View File
@@ -0,0 +1,77 @@
using System.IO;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
/// <summary>
/// The permanent brand logo: one image layer in every scene, directly above the background.
/// Its image comes from App Settings; each scene keeps its own visibility (the eye toggle).
/// </summary>
public partial class MainViewModel
{
internal const double BrandLogoX = 32;
internal const double BrandLogoY = 32;
internal const double BrandLogoWidth = 240;
internal const double BrandLogoHeight = 120;
private ICommand? _browseBrandLogo;
public ICommand BrowseBrandLogoCommand => _browseBrandLogo ??= new RelayCommand(_ => BrowseBrandLogo());
public string BrandLogoFileDisplay => _layoutStore.GetBrandLogoFileName() ?? "None";
private void BrowseBrandLogo()
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "Choose brand logo",
Filter = "Images|*.png;*.jpg;*.jpeg;*.bmp;*.gif",
};
if (dialog.ShowDialog() != true) return;
_layoutStore.SetBrandLogo(File.ReadAllBytes(dialog.FileName), Path.GetFileName(dialog.FileName));
EnsureBrandLogos();
OnPropertyChanged(nameof(BrandLogoFileDisplay));
ScheduleSave();
}
internal static Source CreateBrandLogo(string? assetId) => new()
{
Name = "Brand logo",
Type = SourceType.Image,
IsBrandLogo = true,
IsEnabled = true,
AssetId = assetId,
X = BrandLogoX,
Y = BrandLogoY,
Width = BrandLogoWidth,
Height = BrandLogoHeight,
};
/// <summary>Exactly one brand logo per scene, placed directly above the background, carrying
/// the current App Settings image.</summary>
internal void EnsureBrandLogos()
{
var assetId = _layoutStore.GetBrandLogoAssetId();
foreach (var scene in Scenes)
{
var logos = scene.Elements.OfType<Source>().Where(s => s.IsBrandLogo).ToList();
foreach (var extra in logos.Skip(1))
scene.Elements.Remove(extra);
var logo = logos.FirstOrDefault();
if (logo == null)
{
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
var index = background == null ? 0 : scene.Elements.IndexOf(background) + 1;
logo = CreateBrandLogo(assetId);
scene.Elements.Insert(index, logo);
}
else
{
logo.AssetId = assetId;
}
_sceneGraph.InvalidateBake(scene);
}
}
}
+2 -2
View File
@@ -237,11 +237,11 @@ public partial class MainViewModel
// session when the last using scene lets go). The webcam identity survives —
// it's the app-level default, so removing a scene's placement re-enables Add
// for the same camera instead of dropping it.
private void RemoveElement(SceneElement? element)
internal void RemoveElement(SceneElement? element)
{
var scene = StagedScene;
if (scene == null || element == null) return;
if (element is Source { IsBackground: true }) return;
if (element is Source { IsBackground: true } or Source { IsBrandLogo: true }) return;
if (element is WebcamSceneConfig && _webcam != null)
{
if (SelectedElement == element)
+9
View File
@@ -1296,3 +1296,12 @@ persisted chat or reward payloads."
Guard: `ytLive.Tests/TransitionFadeTests.cs`.
- **Planned next (see TASKS.md):** transition picker in App Settings, Wipe, Move (persistent layers),
brand logo layer, GPU cube (capability-gated).
## Brand logo (2026-10-27)
- One locked image layer per scene, `Source.IsBrandLogo` (persisted column, additive migration), named
"Brand logo", directly above the background. Healed on load by `EnsureBrandLogos`, which also copies the
App Settings image into every scene's layer. Removal is blocked like the background.
- The image lives in the asset table; the settings table keeps its asset id and file name
(`BrandLogoAssetId` / `BrandLogoFileName`). Eye visibility is the layer's ordinary per-scene `IsVisible`.
- Default placement: top-left (32,32), 240x120 master px.
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The brand logo is one locked image layer per scene, directly above the background.
/// </summary>
[Collection("RealApp")]
public sealed class BrandLogoTests
{
private readonly RealAppHost _app;
public BrandLogoTests(RealAppHost app) => _app = app;
[Fact]
public void Every_Scene_Gets_One_Brand_Logo_Directly_Above_The_Background()
{
_app.Run(Run);
}
private static void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-brand-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var schema = new LayoutStore(tempDb)) { }
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
foreach (var scene in vm.Scenes)
{
var logos = scene.Elements.OfType<Source>().Where(s => s.IsBrandLogo).ToList();
Assert.Single(logos);
var index = scene.Elements.IndexOf(logos[0]);
var background = scene.Elements.OfType<Source>().First(s => s.IsBackground);
Assert.Equal(scene.Elements.IndexOf(background) + 1, index);
}
var staged = vm.StagedScene!;
var logo = staged.Elements.OfType<Source>().First(s => s.IsBrandLogo);
vm.RemoveElement(logo);
Assert.Contains(logo, staged.Elements);
}
finally
{
MainViewModel.LayoutPathOverride = null;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { }
}
}
}