The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).
Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this
scene has no vertical placement yet"). The 16:9 layout stays in the old columns
and is parked in the transient Landscape* fields while the vertical tier is
live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
X/Y/W/H overrides clamp every write into the band - outside it the camera is
invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
constrains every writer (drag, resize handle, typed fields) in ONE choke point
instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
Live scene gets its small tile automatically on the first vertical switch (the
vertical stream would otherwise have no camera at all); the other scenes keep
their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
the vertical tier instead of free-floating over the master.
Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
(X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE
placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam -
the TASK 51 test finds that panel by binding text and would match twice).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
live the save writes the parked values there and the live ones into Vertical*.
Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.
Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.
Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
257 lines
14 KiB
C#
257 lines
14 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.Services;
|
|
|
|
public partial class LayoutStore : IDisposable
|
|
{
|
|
public List<Scene> Load()
|
|
{
|
|
Webcam = null;
|
|
Socials = null;
|
|
Music = null;
|
|
var scenes = new List<Scene>();
|
|
var sourcesByScene = new Dictionary<string, List<(Source Source, int Sort)>>();
|
|
var configsByScene = new Dictionary<string, List<(WebcamSceneConfig Config, int Sort)>>();
|
|
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId FROM Scene ORDER BY SortOrder";
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
scenes.Add(new Scene
|
|
{
|
|
Id = reader.GetString(0),
|
|
Name = reader.GetString(1),
|
|
IsHidden = reader.GetInt32(2) != 0,
|
|
IsChatScene = reader.GetInt32(3) != 0,
|
|
HasBackground = reader.GetInt32(4) != 0,
|
|
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
|
|
ThumbnailAssetId = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetString(6) : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
|
|
using var reader = cmd.ExecuteReader();
|
|
if (reader.Read())
|
|
{
|
|
Webcam = new Webcam
|
|
{
|
|
Id = reader.GetString(0),
|
|
DeviceId = reader.GetString(1),
|
|
Name = reader.GetString(2),
|
|
};
|
|
}
|
|
}
|
|
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
|
|
using var reader = cmd.ExecuteReader();
|
|
if (reader.Read())
|
|
{
|
|
Socials = new SocialsConfig
|
|
{
|
|
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
|
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
|
|
};
|
|
var socialsId = reader.GetString(0);
|
|
|
|
using var entryCmd = _connection.CreateCommand();
|
|
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
|
|
entryCmd.Parameters.AddWithValue("$id", socialsId);
|
|
using var entryReader = entryCmd.ExecuteReader();
|
|
while (entryReader.Read())
|
|
{
|
|
Socials.Entries.Add(new SocialEntry
|
|
{
|
|
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
|
|
Handle = entryReader.GetString(1),
|
|
ProfileUrl = entryReader.GetString(2),
|
|
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
|
|
using var reader = cmd.ExecuteReader();
|
|
if (reader.Read())
|
|
{
|
|
Music = new Music
|
|
{
|
|
TrackPath = reader.GetString(0),
|
|
IsEnabled = reader.GetInt32(1) != 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = """
|
|
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
|
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
|
|
IsBackground, CaptureKey, ShowDesktop,
|
|
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
|
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
|
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
|
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
|
AlertDisplayMethod
|
|
FROM Source ORDER BY SortOrder
|
|
""";
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
var sceneId = reader.GetString(1);
|
|
var type = Enum.Parse<SourceType>(reader.GetString(3));
|
|
var source = new Source
|
|
{
|
|
Id = reader.GetString(0),
|
|
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
|
Type = type,
|
|
Name = reader.GetString(4),
|
|
IsEnabled = reader.GetInt32(5) != 0,
|
|
X = reader.GetDouble(6),
|
|
Y = reader.GetDouble(7),
|
|
Width = reader.GetDouble(8),
|
|
Height = reader.GetDouble(9),
|
|
Opacity = reader.GetDouble(10),
|
|
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
|
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
|
|
IsMirrored = reader.GetInt32(13) != 0,
|
|
// Rows saved before the Type setter derived the flag carry
|
|
// IsBackground=0; derive from Type, keeping the persisted bit
|
|
// for legacy DisplayCapture backdrops.
|
|
IsBackground = type == SourceType.Background || reader.GetInt32(14) != 0,
|
|
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
|
|
ShowDesktop = reader.GetInt32(16) != 0,
|
|
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
|
|
ChatFontColor = reader.IsDBNull(18) ? "#FFFFFF" : reader.GetString(18),
|
|
ChatBackgroundColor = reader.IsDBNull(19) ? "#80000000" : reader.GetString(19),
|
|
ChatMaxMessages = reader.IsDBNull(20) ? 5 : reader.GetInt32(20),
|
|
ChatShowTimestamps = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
|
|
ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
|
|
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
|
|
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
|
|
WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
|
|
MediaPath = reader.IsDBNull(26) ? null : reader.GetString(26),
|
|
MediaIsLooping = reader.IsDBNull(27) ? false : reader.GetInt32(27) != 0,
|
|
MediaVolume = reader.IsDBNull(28) ? 1.0 : reader.GetDouble(28),
|
|
MediaPlaybackState = reader.IsDBNull(29)
|
|
? MediaPlaybackState.Stopped
|
|
: Enum.TryParse<MediaPlaybackState>(reader.GetString(29), out var ps)
|
|
? ps
|
|
: MediaPlaybackState.Stopped,
|
|
AlertVideoAssetId = reader.IsDBNull(30) ? null : reader.GetString(30),
|
|
AlertVideoPath = reader.IsDBNull(31) ? null : reader.GetString(31),
|
|
AlertUseDefaultVideo = reader.IsDBNull(32) ? true : reader.GetInt32(32) != 0,
|
|
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),
|
|
};
|
|
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
|
sourcesByScene[sceneId] = list = new List<(Source, int)>();
|
|
list.Add((source, reader.GetInt32(35)));
|
|
}
|
|
}
|
|
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = """
|
|
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
|
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
|
RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength,
|
|
VerticalX, VerticalY, VerticalWidth, VerticalHeight, VerticalClipShape,
|
|
VerticalRectWidth, VerticalRectHeight
|
|
FROM WebcamSceneConfig ORDER BY SortOrder
|
|
""";
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
var sceneId = reader.GetString(0);
|
|
var config = new WebcamSceneConfig
|
|
{
|
|
WebcamId = reader.GetString(1),
|
|
Name = Webcam?.Name ?? "Webcam",
|
|
IsVisible = reader.GetInt32(2) != 0,
|
|
X = reader.GetDouble(3),
|
|
Y = reader.GetDouble(4),
|
|
Width = reader.GetDouble(5),
|
|
Height = reader.GetDouble(6),
|
|
Opacity = reader.GetDouble(7),
|
|
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
|
|
IsMirrored = reader.GetInt32(9) != 0,
|
|
BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
|
BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
|
|
BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
|
|
BorderAnimation = Enum.TryParse<BorderAnimation>(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
|
|
RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
|
|
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
|
|
BackgroundEffect = Enum.TryParse<BackgroundEffect>(reader.GetString(17), out var effect) ? effect : BackgroundEffect.None,
|
|
BackgroundStrength = reader.GetDouble(18),
|
|
// TASK 57: the vertical tier's own placement (null = this
|
|
// scene keeps its 16:9 placement under the vertical tier).
|
|
VerticalX = reader.IsDBNull(19) ? null : reader.GetDouble(19),
|
|
VerticalY = reader.IsDBNull(20) ? null : reader.GetDouble(20),
|
|
VerticalWidth = reader.IsDBNull(21) ? null : reader.GetDouble(21),
|
|
VerticalHeight = reader.IsDBNull(22) ? null : reader.GetDouble(22),
|
|
VerticalClipShape = reader.IsDBNull(23)
|
|
? null
|
|
: Enum.TryParse<ClipShape>(reader.GetString(23), out var vClip) ? vClip : null,
|
|
VerticalRectWidth = reader.IsDBNull(24) ? null : reader.GetDouble(24),
|
|
VerticalRectHeight = reader.IsDBNull(25) ? null : reader.GetDouble(25),
|
|
};
|
|
if (!configsByScene.TryGetValue(sceneId, out var list))
|
|
configsByScene[sceneId] = list = new List<(WebcamSceneConfig, int)>();
|
|
list.Add((config, reader.GetInt32(16)));
|
|
}
|
|
}
|
|
|
|
foreach (var scene in scenes)
|
|
{
|
|
// Source and WebcamSceneConfig rows share ONE z-order space per scene
|
|
// (both SortOrders = the element's index within scene.Elements). Merge
|
|
// the two tables' rows by that shared z rather than appending all
|
|
// sources before all configs — a webcam slotted between sources must
|
|
// reload in place. Legacy rows (written before the unified space) may
|
|
// tie at 0: break ties sources-first, so old data reads back stacked
|
|
// the same way it did pre-unification.
|
|
var ordered = new List<(int Sort, SceneElement Element)>();
|
|
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
|
|
foreach (var (source, sort) in sources)
|
|
ordered.Add((sort, source));
|
|
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
|
foreach (var (config, sort) in configs)
|
|
ordered.Add((sort, config));
|
|
|
|
foreach (var (_, element) in ordered.OrderBy(e => e.Sort).ThenBy(e => e.Element is Source ? 0 : 1))
|
|
scene.Elements.Add(element);
|
|
|
|
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
|
if (bg != null)
|
|
{
|
|
// Load from source-keyed settings (new) or scene-keyed (legacy migration)
|
|
var path = GetSetting($"BackgroundPath_{bg.Id}");
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
path = GetSetting($"BackgroundPath_{scene.Id}");
|
|
if (!string.IsNullOrWhiteSpace(path))
|
|
bg.CustomBackgroundPath = path;
|
|
|
|
var useDefault = GetSetting($"BackgroundUseDefault_{bg.Id}");
|
|
if (string.IsNullOrWhiteSpace(useDefault))
|
|
useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
|
|
if (useDefault == "0")
|
|
bg.UseDefaultBackground = false;
|
|
}
|
|
}
|
|
|
|
return scenes;
|
|
}
|
|
}
|