feat(text): persist independent text layer settings
Add additive defaults and round-trip tests for per-layer text content and formatting. Model the static text source after OBS text-source conventions: https://obsproject.com/kb/text-sources
This commit is contained in:
@@ -41,6 +41,13 @@ public enum MediaPlaybackState
|
||||
Paused
|
||||
}
|
||||
|
||||
public enum TextLayerAlignment
|
||||
{
|
||||
Left,
|
||||
Center,
|
||||
Right
|
||||
}
|
||||
|
||||
public enum ClipShape
|
||||
{
|
||||
Traditional,
|
||||
@@ -391,6 +398,58 @@ public class Source : SceneElement
|
||||
|
||||
public override bool IsChatBox => Type == SourceType.ChatBox;
|
||||
|
||||
private string _textContent = "Text";
|
||||
public string TextContent
|
||||
{
|
||||
get => _textContent;
|
||||
set => Set(ref _textContent, value ?? string.Empty);
|
||||
}
|
||||
|
||||
private string _textFontFamily = "Segoe UI";
|
||||
public string TextFontFamily
|
||||
{
|
||||
get => _textFontFamily;
|
||||
set => Set(ref _textFontFamily, string.IsNullOrWhiteSpace(value) ? "Segoe UI" : value.Trim());
|
||||
}
|
||||
|
||||
private double _textFontSize = 48;
|
||||
public double TextFontSize
|
||||
{
|
||||
get => _textFontSize;
|
||||
set => Set(ref _textFontSize, double.IsFinite(value) ? Math.Clamp(value, 8, 300) : 48);
|
||||
}
|
||||
|
||||
private string _textColor = "#FFFFFF";
|
||||
public string TextColor
|
||||
{
|
||||
get => _textColor;
|
||||
set => Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF");
|
||||
}
|
||||
|
||||
private bool _textBold;
|
||||
public bool TextBold { get => _textBold; set => Set(ref _textBold, value); }
|
||||
|
||||
private bool _textItalic;
|
||||
public bool TextItalic { get => _textItalic; set => Set(ref _textItalic, value); }
|
||||
|
||||
private bool _textUnderline;
|
||||
public bool TextUnderline { get => _textUnderline; set => Set(ref _textUnderline, value); }
|
||||
|
||||
private TextLayerAlignment _textAlignment;
|
||||
public TextLayerAlignment TextAlignment
|
||||
{
|
||||
get => _textAlignment;
|
||||
set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left);
|
||||
}
|
||||
|
||||
private static bool IsValidTextColor(string? value)
|
||||
{
|
||||
if (value is not { Length: 7 or 9 } || value[0] != '#') return false;
|
||||
for (var i = 1; i < value.Length; i++)
|
||||
if (!Uri.IsHexDigit(value[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool IsCountdown => Type == SourceType.Countdown;
|
||||
|
||||
private bool _chatPreviewEnabled = true;
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
| `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`), **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/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 |
|
||||
| `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) |
|
||||
|
||||
@@ -111,7 +111,9 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -167,6 +169,16 @@ public partial class LayoutStore : IDisposable
|
||||
BlurAmount = reader.IsDBNull(reader.GetOrdinal("BlurAmount")) ? 0.5 : reader.GetDouble(reader.GetOrdinal("BlurAmount")),
|
||||
AlertAudioPath = reader.IsDBNull(reader.GetOrdinal("AlertAudioPath")) ? null : reader.GetString(reader.GetOrdinal("AlertAudioPath")),
|
||||
AlertAudioLoop = reader.GetInt32(reader.GetOrdinal("AlertAudioLoop")) != 0,
|
||||
TextContent = reader.IsDBNull(41) ? "Text" : reader.GetString(41),
|
||||
TextFontFamily = reader.IsDBNull(42) ? "Segoe UI" : reader.GetString(42),
|
||||
TextFontSize = reader.IsDBNull(43) ? 48 : reader.GetDouble(43),
|
||||
TextColor = reader.IsDBNull(44) ? "#FFFFFF" : reader.GetString(44),
|
||||
TextBold = !reader.IsDBNull(45) && reader.GetInt32(45) != 0,
|
||||
TextItalic = !reader.IsDBNull(46) && reader.GetInt32(46) != 0,
|
||||
TextUnderline = !reader.IsDBNull(47) && reader.GetInt32(47) != 0,
|
||||
TextAlignment = reader.IsDBNull(48) || !Enum.IsDefined((TextLayerAlignment)reader.GetInt32(48))
|
||||
? TextLayerAlignment.Left
|
||||
: (TextLayerAlignment)reader.GetInt32(48),
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<(Source, int)>();
|
||||
|
||||
@@ -290,6 +290,56 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 3.17: independent static text-layer content and formatting.
|
||||
if (!columns.Contains("TextContent"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextContent TEXT NOT NULL DEFAULT 'Text';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextFontFamily"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontFamily TEXT NOT NULL DEFAULT 'Segoe UI';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextFontSize"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontSize REAL NOT NULL DEFAULT 48;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextColor TEXT NOT NULL DEFAULT '#FFFFFF';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextBold"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextBold INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextItalic"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextItalic INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextUnderline"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextUnderline INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextAlignment"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextAlignment INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("WebUri"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
|
||||
@@ -92,7 +92,9 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop)
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
@@ -100,7 +102,9 @@ public partial class LayoutStore : IDisposable
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
|
||||
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop)
|
||||
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
|
||||
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
|
||||
$textUnderline, $textAlignment)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -144,6 +148,14 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.Parameters.Add("$blurAmount", SqliteType.Real);
|
||||
var alertAudioPathP = cmd.Parameters.Add("$alertAudioPath", SqliteType.Text);
|
||||
var alertAudioLoopP = cmd.Parameters.Add("$alertAudioLoop", SqliteType.Integer);
|
||||
var textContentP = cmd.Parameters.Add("$textContent", SqliteType.Text);
|
||||
var textFontFamilyP = cmd.Parameters.Add("$textFontFamily", SqliteType.Text);
|
||||
var textFontSizeP = cmd.Parameters.Add("$textFontSize", SqliteType.Real);
|
||||
var textColorP = cmd.Parameters.Add("$textColor", SqliteType.Text);
|
||||
var textBoldP = cmd.Parameters.Add("$textBold", SqliteType.Integer);
|
||||
var textItalicP = cmd.Parameters.Add("$textItalic", SqliteType.Integer);
|
||||
var textUnderlineP = cmd.Parameters.Add("$textUnderline", SqliteType.Integer);
|
||||
var textAlignmentP = cmd.Parameters.Add("$textAlignment", SqliteType.Integer);
|
||||
|
||||
// Source and WebcamSceneConfig share ONE z-order space per scene:
|
||||
// each element gets its index within scene.Elements regardless of
|
||||
@@ -196,6 +208,14 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.Parameters["$blurAmount"].Value = source.BlurAmount;
|
||||
alertAudioPathP.Value = (object?)source.AlertAudioPath ?? DBNull.Value;
|
||||
alertAudioLoopP.Value = source.AlertAudioLoop ? 1 : 0;
|
||||
textContentP.Value = source.TextContent;
|
||||
textFontFamilyP.Value = source.TextFontFamily;
|
||||
textFontSizeP.Value = source.TextFontSize;
|
||||
textColorP.Value = source.TextColor;
|
||||
textBoldP.Value = source.TextBold ? 1 : 0;
|
||||
textItalicP.Value = source.TextItalic ? 1 : 0;
|
||||
textUnderlineP.Value = source.TextUnderline ? 1 : 0;
|
||||
textAlignmentP.Value = (int)source.TextAlignment;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,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) |
|
||||
| `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 |
|
||||
| `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; `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 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) |
|
||||
| `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/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) |
|
||||
|
||||
@@ -15,6 +15,97 @@ namespace ytLive.Tests;
|
||||
/// </summary>
|
||||
public class LayoutStorePersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Text_Layers_RoundTrip_Independently_And_Legacy_Rows_Get_Defaults()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
var scene = new Scene { Name = "Starting" };
|
||||
scene.Elements.Add(new Source
|
||||
{
|
||||
Name = "Headline",
|
||||
Type = SourceType.TextOverlay,
|
||||
TextContent = "First line\nSecond line",
|
||||
TextFontFamily = "Arial",
|
||||
TextFontSize = 72,
|
||||
TextColor = "#12AB34",
|
||||
TextBold = true,
|
||||
TextAlignment = TextLayerAlignment.Center,
|
||||
});
|
||||
scene.Elements.Add(new Source
|
||||
{
|
||||
Name = "Caption",
|
||||
Type = SourceType.TextOverlay,
|
||||
TextContent = "Independent caption",
|
||||
TextFontFamily = "Segoe UI",
|
||||
TextFontSize = 24,
|
||||
TextColor = "#80445566",
|
||||
TextItalic = true,
|
||||
TextUnderline = true,
|
||||
TextAlignment = TextLayerAlignment.Right,
|
||||
});
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
store.Save(new[] { scene }, null, null);
|
||||
|
||||
using (var reopened = new LayoutStore(path))
|
||||
{
|
||||
var sources = Assert.Single(reopened.Load()).Elements.OfType<Source>()
|
||||
.Where(source => source.Type == SourceType.TextOverlay).ToArray();
|
||||
Assert.Equal(2, sources.Length);
|
||||
Assert.Equal("First line\nSecond line", sources[0].TextContent);
|
||||
Assert.Equal("Arial", sources[0].TextFontFamily);
|
||||
Assert.Equal(72, sources[0].TextFontSize);
|
||||
Assert.Equal("#12AB34", sources[0].TextColor);
|
||||
Assert.True(sources[0].TextBold);
|
||||
Assert.False(sources[0].TextItalic);
|
||||
Assert.False(sources[0].TextUnderline);
|
||||
Assert.Equal(TextLayerAlignment.Center, sources[0].TextAlignment);
|
||||
Assert.Equal("Independent caption", sources[1].TextContent);
|
||||
Assert.Equal(24, sources[1].TextFontSize);
|
||||
Assert.Equal("#80445566", sources[1].TextColor);
|
||||
Assert.False(sources[1].TextBold);
|
||||
Assert.True(sources[1].TextItalic);
|
||||
Assert.True(sources[1].TextUnderline);
|
||||
Assert.Equal(TextLayerAlignment.Right, sources[1].TextAlignment);
|
||||
}
|
||||
|
||||
SqliteConnection.ClearAllPools();
|
||||
using (var raw = new SqliteConnection($"Data Source={path}"))
|
||||
{
|
||||
raw.Open();
|
||||
using var cmd = raw.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source DROP COLUMN TextContent; ALTER TABLE Source DROP COLUMN TextFontFamily; ALTER TABLE Source DROP COLUMN TextFontSize; ALTER TABLE Source DROP COLUMN TextColor; ALTER TABLE Source DROP COLUMN TextBold; ALTER TABLE Source DROP COLUMN TextItalic; ALTER TABLE Source DROP COLUMN TextUnderline; ALTER TABLE Source DROP COLUMN TextAlignment;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
using (var legacy = new LayoutStore(path))
|
||||
{
|
||||
var sources = Assert.Single(legacy.Load()).Elements.OfType<Source>()
|
||||
.Where(source => source.Type == SourceType.TextOverlay).ToArray();
|
||||
Assert.Equal(2, sources.Length);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
Assert.Equal("Text", source.TextContent);
|
||||
Assert.Equal("Segoe UI", source.TextFontFamily);
|
||||
Assert.Equal(48, source.TextFontSize);
|
||||
Assert.Equal("#FFFFFF", source.TextColor);
|
||||
Assert.False(source.TextBold);
|
||||
Assert.False(source.TextItalic);
|
||||
Assert.False(source.TextUnderline);
|
||||
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>TASK 47: the alert announcement display method must survive a
|
||||
/// restart. This one is easy to get quietly wrong — the Source row is read back
|
||||
/// by POSITIONAL index, so a column inserted anywhere but the end silently
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
public class SourceTextSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Text_Layer_Settings_Have_Safe_Defaults()
|
||||
{
|
||||
var source = new Source { Type = SourceType.TextOverlay };
|
||||
|
||||
Assert.Equal("Text", source.TextContent);
|
||||
Assert.Equal("Segoe UI", source.TextFontFamily);
|
||||
Assert.Equal(48, source.TextFontSize);
|
||||
Assert.Equal("#FFFFFF", source.TextColor);
|
||||
Assert.False(source.TextBold);
|
||||
Assert.False(source.TextItalic);
|
||||
Assert.False(source.TextUnderline);
|
||||
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Text_Layer_Settings_Normalize_Invalid_Values()
|
||||
{
|
||||
var source = new Source
|
||||
{
|
||||
TextFontFamily = " ",
|
||||
TextFontSize = double.NaN,
|
||||
TextColor = "not-a-color",
|
||||
TextAlignment = (TextLayerAlignment)999,
|
||||
};
|
||||
|
||||
Assert.Equal("Segoe UI", source.TextFontFamily);
|
||||
Assert.Equal(48, source.TextFontSize);
|
||||
Assert.Equal("#FFFFFF", source.TextColor);
|
||||
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
|
||||
|
||||
source.TextFontSize = 1;
|
||||
Assert.Equal(8, source.TextFontSize);
|
||||
source.TextFontSize = 1000;
|
||||
Assert.Equal(300, source.TextFontSize);
|
||||
source.TextColor = "#abcdef";
|
||||
Assert.Equal("#ABCDEF", source.TextColor);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user