diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs
index ab598aa..0bfe92b 100644
--- a/Services/LayoutStore.Migrations.cs
+++ b/Services/LayoutStore.Migrations.cs
@@ -147,6 +147,7 @@ public partial class LayoutStore : IDisposable
MigrateSocialEntryTable();
if (GetUserVersion() < 3)
MigrateToV3();
+ EnsureLayoutSnapshotSchema();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 10;";
diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs
index 8d60d8e..e114d5f 100644
--- a/Services/LayoutStore.Save.cs
+++ b/Services/LayoutStore.Save.cs
@@ -434,10 +434,16 @@ public partial class LayoutStore : IDisposable
DELETE FROM Asset WHERE Id NOT IN (
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
UNION
+ SELECT AssetId FROM DefaultSource WHERE AssetId IS NOT NULL
+ UNION
SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL
UNION
+ SELECT ThumbnailAssetId FROM DefaultScene WHERE ThumbnailAssetId IS NOT NULL
+ UNION
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
UNION
+ SELECT AlertVideoAssetId FROM DefaultSource WHERE AlertVideoAssetId IS NOT NULL
+ UNION
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
UNION
SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId'
diff --git a/Services/LayoutStore.Snapshots.cs b/Services/LayoutStore.Snapshots.cs
new file mode 100644
index 0000000..3846174
--- /dev/null
+++ b/Services/LayoutStore.Snapshots.cs
@@ -0,0 +1,168 @@
+using Microsoft.Data.Sqlite;
+
+namespace ytLive.Services;
+
+public partial class LayoutStore : IDisposable
+{
+ private static readonly string[] SnapshotTables =
+ {
+ "Scene", "Source", "Webcam", "WebcamSceneConfig", "Socials", "SocialEntry", "Music",
+ };
+
+ private const string DefaultsInitializedKey = "DefaultsInitialized";
+ private const string CurrentBuildIdKey = "CurrentBuildId";
+
+ private void EnsureLayoutSnapshotSchema()
+ {
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ CREATE TABLE IF NOT EXISTS LayoutSnapshotState (
+ Key TEXT PRIMARY KEY,
+ Value TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS DefaultLayoutSetting (
+ Key TEXT PRIMARY KEY,
+ Value TEXT NOT NULL
+ );
+ """;
+ cmd.ExecuteNonQuery();
+ }
+
+ foreach (var table in SnapshotTables)
+ {
+ var snapshotTable = "Default" + table;
+ using (var create = _connection.CreateCommand())
+ {
+ create.CommandText = $"CREATE TABLE IF NOT EXISTS {snapshotTable} AS SELECT * FROM {table} WHERE 0;";
+ create.ExecuteNonQuery();
+ }
+
+ var snapshotColumns = ReadColumnNames(snapshotTable);
+ foreach (var (name, type, defaultValue) in ReadColumnDefinitions(table))
+ {
+ if (snapshotColumns.Contains(name, StringComparer.OrdinalIgnoreCase)) continue;
+ var definition = string.IsNullOrWhiteSpace(type) ? "BLOB" : type;
+ if (defaultValue != null) definition += " DEFAULT " + defaultValue;
+ using var add = _connection.CreateCommand();
+ add.CommandText = $"ALTER TABLE {snapshotTable} ADD COLUMN {QuoteIdentifier(name)} {definition};";
+ add.ExecuteNonQuery();
+ }
+ }
+ }
+
+ /// Captures the installed layout once as the immutable branded default. Existing databases
+ /// are snapshotted before MainViewModel loads them; a fresh database calls
+ /// after its factory scenes have been seeded.
+ public void PrepareLayoutSnapshots(string buildId)
+ {
+ if (GetSnapshotState(DefaultsInitializedKey) != "1" && HasStoredScenes())
+ CaptureDefaults();
+
+ if (string.IsNullOrWhiteSpace(GetSnapshotState(CurrentBuildIdKey)))
+ SetSnapshotState(CurrentBuildIdKey, buildId);
+ }
+
+ /// Seeds the default snapshot after the first-run factory layout has been persisted.
+ public void CaptureDefaultsIfMissing()
+ {
+ if (GetSnapshotState(DefaultsInitializedKey) != "1")
+ CaptureDefaults();
+ }
+
+ private void CaptureDefaults()
+ {
+ var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames);
+ using var tx = _connection.BeginTransaction();
+ foreach (var table in SnapshotTables.Reverse())
+ {
+ using var delete = _connection.CreateCommand();
+ delete.Transaction = tx;
+ delete.CommandText = $"DELETE FROM Default{table};";
+ delete.ExecuteNonQuery();
+ }
+
+ foreach (var table in SnapshotTables)
+ {
+ var columns = string.Join(", ", columnsByTable[table].Select(QuoteIdentifier));
+ using var copy = _connection.CreateCommand();
+ copy.Transaction = tx;
+ copy.CommandText = $"INSERT INTO Default{table} ({columns}) SELECT {columns} FROM {table};";
+ copy.ExecuteNonQuery();
+ }
+
+ using (var clearSettings = _connection.CreateCommand())
+ {
+ clearSettings.Transaction = tx;
+ clearSettings.CommandText = "DELETE FROM DefaultLayoutSetting;";
+ clearSettings.ExecuteNonQuery();
+ }
+ using (var copySettings = _connection.CreateCommand())
+ {
+ copySettings.Transaction = tx;
+ copySettings.CommandText = """
+ INSERT INTO DefaultLayoutSetting (Key, Value)
+ SELECT Key, Value FROM Settings
+ WHERE Key LIKE 'BackgroundPath_%' OR Key LIKE 'BackgroundUseDefault_%';
+ """;
+ copySettings.ExecuteNonQuery();
+ }
+
+ using (var state = _connection.CreateCommand())
+ {
+ state.Transaction = tx;
+ state.CommandText = """
+ INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, '1')
+ ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;
+ """;
+ state.Parameters.AddWithValue("$key", DefaultsInitializedKey);
+ state.ExecuteNonQuery();
+ }
+ tx.Commit();
+ }
+
+ private bool HasStoredScenes()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT EXISTS(SELECT 1 FROM Scene LIMIT 1);";
+ return Convert.ToInt32(cmd.ExecuteScalar()) != 0;
+ }
+
+ private string? GetSnapshotState(string key)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM LayoutSnapshotState WHERE Key = $key;";
+ cmd.Parameters.AddWithValue("$key", key);
+ return cmd.ExecuteScalar() as string;
+ }
+
+ private void SetSnapshotState(string key, string value)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, $value)
+ ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;
+ """;
+ cmd.Parameters.AddWithValue("$key", key);
+ cmd.Parameters.AddWithValue("$value", value);
+ cmd.ExecuteNonQuery();
+ }
+
+ private List ReadColumnNames(string table)
+ => ReadColumnDefinitions(table).Select(column => column.Name).ToList();
+
+ private List<(string Name, string Type, string? DefaultValue)> ReadColumnDefinitions(string table)
+ {
+ var columns = new List<(string, string, string?)>();
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = $"PRAGMA table_info({table});";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ columns.Add((reader.GetString(1), reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
+ reader.IsDBNull(4) ? null : reader.GetString(4)));
+ return columns;
+ }
+
+ private static string QuoteIdentifier(string identifier)
+ => "\"" + identifier.Replace("\"", "\"\"") + "\"";
+}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index ec1cbe6..b5c31f6 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -514,9 +514,11 @@ public partial class MainViewModel : ViewModelBase
private void LoadLayout()
{
_isLoading = true;
+ var seededFactoryLayout = false;
try
{
AppLog.Write("LoadLayout begin");
+ _layoutStore.PrepareLayoutSnapshots(Helpers.BuildStamp.Id);
var scenes = _layoutStore.Load();
Scenes.Clear();
foreach (var scene in scenes)
@@ -526,6 +528,7 @@ public partial class MainViewModel : ViewModelBase
{
foreach (var name in SceneCatalog.All)
AddScene(name);
+ seededFactoryLayout = true;
}
ReorderScenesToCanonical();
HealBackgrounds();
@@ -582,6 +585,12 @@ public partial class MainViewModel : ViewModelBase
LoadClipSettings();
BroadcastForm.Load();
+ if (seededFactoryLayout)
+ {
+ SaveLayoutNow();
+ _layoutStore.CaptureDefaultsIfMissing();
+ }
+
ScheduleSave();
AppLog.Write("LoadLayout end");
}
diff --git a/ytLive.Tests/LayoutStoreSnapshotTests.cs b/ytLive.Tests/LayoutStoreSnapshotTests.cs
new file mode 100644
index 0000000..dc17c4a
--- /dev/null
+++ b/ytLive.Tests/LayoutStoreSnapshotTests.cs
@@ -0,0 +1,64 @@
+using System;
+using System.IO;
+using System.Linq;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+
+namespace ytLive.Tests;
+
+public sealed class LayoutStoreSnapshotTests
+{
+ [Fact]
+ public void SameBuild_OpensCurrentLayout_WithoutChangingDefaultSnapshot()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-snapshot-{Guid.NewGuid():N}.db");
+ const string buildId = "build-a";
+ try
+ {
+ using (var store = new LayoutStore(path))
+ {
+ var factory = MakeScene("Factory title");
+ store.Save(new[] { factory }, null, null);
+ store.PrepareLayoutSnapshots(buildId);
+
+ store.Save(new[] { MakeScene("Creator edit") }, null, null);
+ }
+
+ using (var reopened = new LayoutStore(path))
+ {
+ reopened.PrepareLayoutSnapshots(buildId);
+ var current = Assert.Single(reopened.Load());
+ Assert.Equal("Creator edit", Assert.Single(current.Elements.OfType()).Name);
+
+ using var connection = GetConnection(path);
+ using var command = connection.CreateCommand();
+ command.CommandText = "SELECT Value FROM LayoutSnapshotState WHERE Key = 'CurrentBuildId';";
+ Assert.Equal(buildId, command.ExecuteScalar());
+
+ command.CommandText = "SELECT Name FROM DefaultSource;";
+ Assert.Equal("Factory title", command.ExecuteScalar());
+ }
+ }
+ finally
+ {
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(path); } catch { /* best-effort cleanup */ }
+ }
+ }
+
+ private static Scene MakeScene(string sourceName)
+ => new()
+ {
+ Name = SceneCatalog.Starting,
+ Elements = { new Source { Name = sourceName, Type = SourceType.TextOverlay } },
+ };
+
+ private static SqliteConnection GetConnection(string path)
+ {
+ var connection = new SqliteConnection($"Data Source={path}");
+ connection.Open();
+ return connection;
+ }
+}