diff --git a/Controls/OverlayHost.xaml b/Controls/OverlayHost.xaml
index 50ec09d..cf6df90 100644
--- a/Controls/OverlayHost.xaml
+++ b/Controls/OverlayHost.xaml
@@ -56,6 +56,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/Models/Source.cs b/Models/Source.cs
index b178583..817be1d 100644
--- a/Models/Source.cs
+++ b/Models/Source.cs
@@ -88,6 +88,10 @@ public class Source : SceneElement
public override bool IsWebSource => Type == SourceType.WebSource;
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
+ /// The permanent brand logo layer (one per scene, directly above the background).
+ /// Locked against removal; its image comes from the App Settings brand logo.
+ public bool IsBrandLogo { get; set; }
+
/// The permanent scene background (live desktop/game capture). Never removable/reorderable.
private bool _isBackground;
public override bool IsBackground { get => _isBackground; set => Set(ref _isBackground, value); }
diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs
index dfd9261..21de2c5 100644
--- a/Services/LayoutStore.Load.cs
+++ b/Services/LayoutStore.Load.cs
@@ -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)>();
diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs
index 6dd5e9b..22b91c0 100644
--- a/Services/LayoutStore.Migrations.cs
+++ b/Services/LayoutStore.Migrations.cs
@@ -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();
diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs
index da4555d..2cb5ed0 100644
--- a/Services/LayoutStore.Save.cs
+++ b/Services/LayoutStore.Save.cs
@@ -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();
}
}
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index 98d1583..2def9cb 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -348,4 +348,20 @@ public partial class LayoutStore : IDisposable
/// The stored on-air image's Asset id, or null when never stamped.
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);
+
+ /// Stores the brand logo image as an asset and records its file name for display.
+ public string SetBrandLogo(byte[] data, string fileName)
+ {
+ var id = UpsertAsset(data, 0, 0);
+ UpsertSetting(BrandLogoAssetKey, id);
+ UpsertSetting(BrandLogoNameKey, fileName);
+ return id;
+ }
}
diff --git a/TASKS.md b/TASKS.md
index eb74c5c..b43d098 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -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.
diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs
index 50bce24..1ad9ab1 100644
--- a/ViewModels/MainViewModel.Background.cs
+++ b/ViewModels/MainViewModel.Background.cs
@@ -438,6 +438,7 @@ public partial class MainViewModel
StampDefaultBackgroundAsset(background, scene.Name);
}
SeedOnAirImage();
+ EnsureBrandLogos();
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(LayerCount));
}
diff --git a/ViewModels/MainViewModel.BrandLogo.cs b/ViewModels/MainViewModel.BrandLogo.cs
new file mode 100644
index 0000000..b20a2fe
--- /dev/null
+++ b/ViewModels/MainViewModel.BrandLogo.cs
@@ -0,0 +1,77 @@
+using System.IO;
+using System.Windows.Input;
+using ytLive.Helpers;
+using ytLive.Models;
+
+namespace ytLive.ViewModels;
+
+///
+/// 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).
+///
+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,
+ };
+
+ /// Exactly one brand logo per scene, placed directly above the background, carrying
+ /// the current App Settings image.
+ internal void EnsureBrandLogos()
+ {
+ var assetId = _layoutStore.GetBrandLogoAssetId();
+ foreach (var scene in Scenes)
+ {
+ var logos = scene.Elements.OfType().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().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);
+ }
+ }
+}
diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs
index a4f2ad5..59fc6c4 100644
--- a/ViewModels/MainViewModel.Sources.cs
+++ b/ViewModels/MainViewModel.Sources.cs
@@ -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)
diff --git a/ai.md b/ai.md
index 23a5f5b..5add97b 100644
--- a/ai.md
+++ b/ai.md
@@ -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.
diff --git a/ytLive.Tests/BrandLogoTests.cs b/ytLive.Tests/BrandLogoTests.cs
new file mode 100644
index 0000000..6755b1e
--- /dev/null
+++ b/ytLive.Tests/BrandLogoTests.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Linq;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// The brand logo is one locked image layer per scene, directly above the background.
+///
+[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().Where(s => s.IsBrandLogo).ToList();
+ Assert.Single(logos);
+ var index = scene.Elements.IndexOf(logos[0]);
+ var background = scene.Elements.OfType().First(s => s.IsBackground);
+ Assert.Equal(scene.Elements.IndexOf(background) + 1, index);
+ }
+
+ var staged = vm.StagedScene!;
+ var logo = staged.Elements.OfType().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 { }
+ }
+ }
+}