feat(text): composite shared text rasters in output

Raster static text on UI-thread changes and reuse the same transparent BGRA frame in preview and all compositor paths. Cache text frame identity in the frame-pump signature and invalidate static bakes on edits. Follow OBS text-source conventions: https://obsproject.com/kb/text-sources
This commit is contained in:
2026-11-04 15:01:47 -08:00
parent 58eb640c4e
commit 5c4acf141b
19 changed files with 622 additions and 81 deletions
+3 -3
View File
@@ -329,14 +329,14 @@
<TextBlock Text="Font" Style="{StaticResource PropertyHeader}"/>
<ComboBox IsEditable="True" ItemsSource="{x:Static media:Fonts.SystemFontFamilies}"
DisplayMemberPath="Source"
Text="{Binding SelectedElement.TextFontFamily, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Text="{Binding SelectedElement.TextFontFamily, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
Style="{StaticResource YtComboBox}" Height="26" Margin="0,0,0,6"
AutomationProperties.Name="Text layer font"/>
<DockPanel Margin="0,0,0,6" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="Size" Style="{StaticResource PropertyFieldLabel}"
Width="42" VerticalAlignment="Center"/>
<ComboBox IsEditable="True"
Text="{Binding SelectedElement.TextFontSize, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Text="{Binding SelectedElement.TextFontSize, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
Style="{StaticResource YtComboBox}" Height="26" Width="76"
AutomationProperties.Name="Text layer size">
<ComboBoxItem Content="12"/><ComboBoxItem Content="16"/>
@@ -353,7 +353,7 @@
<TextBlock DockPanel.Dock="Left" Text="Color" Style="{StaticResource PropertyFieldLabel}"
Width="42" VerticalAlignment="Center"/>
<TextBox x:Name="TextLayerColorEditor"
Text="{Binding SelectedElement.TextColor, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Text="{Binding SelectedElement.TextColor, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
Style="{StaticResource YtTextBoxCompact}" MaxLength="9" Width="82"
ToolTip="#RRGGBB or #AARRGGBB" AutomationProperties.Name="Text layer color"/>
<Border Width="18" Height="18" Margin="8,0,0,0" CornerRadius="2"
+4 -37
View File
@@ -2,8 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:local="clr-namespace:ytLive" xmlns:controls="clr-namespace:ytLive.Controls"
xmlns:models="clr-namespace:ytLive.Models">
xmlns:local="clr-namespace:ytLive" xmlns:controls="clr-namespace:ytLive.Controls">
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="6" Margin="8,0"
BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}">
<Grid>
@@ -161,6 +160,9 @@
<DataTrigger Binding="{Binding IsScoreboard}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsTextOverlay}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsWebSource}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
<Setter Property="Stretch" Value="Fill"/>
@@ -176,41 +178,6 @@
</Style>
</Image.Style>
</Image>
<TextBlock Tag="TextLayerPreview" Text="{Binding TextContent}"
FontFamily="{Binding TextFontFamily}"
FontSize="{Binding TextFontSize}"
Foreground="{Binding TextColor, Converter={StaticResource HexToBrush}}"
TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
IsHitTestVisible="False">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Setter Property="TextAlignment" Value="Left"/>
<Setter Property="FontWeight" Value="Normal"/>
<Setter Property="FontStyle" Value="Normal"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsTextOverlay}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextBold}" Value="True">
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextItalic}" Value="True">
<Setter Property="FontStyle" Value="Italic"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextUnderline}" Value="True">
<Setter Property="TextDecorations" Value="{x:Static TextDecorations.Underline}"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextAlignment}" Value="{x:Static models:TextLayerAlignment.Center}">
<Setter Property="TextAlignment" Value="Center"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextAlignment}" Value="{x:Static models:TextLayerAlignment.Right}">
<Setter Property="TextAlignment" Value="Right"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Viewbox Stretch="Uniform">
<Grid Width="1" Height="1">
<!-- Round webcam: a plain Image (the same efficient WriteableBitmap
+1 -1
View File
@@ -10,7 +10,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 session surface: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; right-click opens App Settings directly. `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay `TextBlock` preview, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font, size, color, emphasis, and alignment with a static-text notice. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
+9 -12
View File
@@ -1,21 +1,18 @@
# HANDOFF — 2026-11-04 (Day 2 and YouTube live-state color)
# HANDOFF — 2026-11-04 (TASK 3·17 — static text layers)
## State
- `main`; relevant commits: vertical scaling through FFmpeg `6310f50`, Day 2 reconciliation
`f921c01`, Steam-frame-rate entitlement `7216c95`, Task 53 visual acceptance `1993e7c`.
- The top menu background follows YouTube's broadcast status: normal ON-AIR starts red and turns
green only at `lifeCycleStatus=live`; TEST stays gold; stopping restores the idle surface color.
- TASK 53 is complete: six output recordings and the vertical YouTube player view were accepted by
the creator. Platform-specific safe zones are explicitly out of scope.
- Output entitlement code now gates by frame rate; the paid Steam App ID still needs Steamworks metadata.
- The focused top-bar test passes. The last full verification had a clean build and 522/523 tests;
the only failure was the known `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`.
- Four untracked `Assets/llamacasty-*` files remain untouched.
- `main`; TASK 53 is complete and TASK 3·17 is complete in three commits: `60c4a00`, `58eb640`,
and the final output/compositing phase.
- Text layers persist independently, edit as static multiline text, and share one cached raster between
preview and output. The frame pump only reads the raster cache; no WPF render occurs on its thread.
- The app clean build has 0 warnings. 524 tests passed with the documented physical mouse-injection test
and known audio test excluded; the new text renderer, preview/output, and pump-cache tests pass.
- The four untracked `Assets/llamacasty-*` files remain untouched.
## Next action
- Next queued coding task: TASK 3·17, the Text layer.
- Next action: select the next queued task from `TASKS.md`.
- Do not push absent a checkpoint request.
## Landmines
+1 -1
View File
@@ -179,7 +179,7 @@ public class Source : SceneElement
/// scoreboard joined this list 2026-10-30 — without it the strip was rendered, published to
/// VideoImageSource, and never displayed (the same shape as the invisible-alert defect).</summary>
private bool IsFrameSource =>
Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
Type is SourceType.TextOverlay or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
or SourceType.AlertBox or SourceType.Scoreboard or SourceType.Ticker;
public override ImageSource? DisplaySource => IsLiveCapture
-4
View File
@@ -164,7 +164,6 @@ public sealed class SceneCompositor
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement { IsZoomActive: true }:
continue;
}
@@ -187,7 +186,6 @@ public sealed class SceneCompositor
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
// ⛔ The brand logo is drawn AFTER the blur group (creator ruling 2026-11-01):
// the blur is universal and must never hide the brand.
case Source { IsBrandLogo: true }:
@@ -311,7 +309,6 @@ public sealed class SceneCompositor
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement:
continue;
}
@@ -370,7 +367,6 @@ public sealed class SceneCompositor
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
// ⛔ The brand logo is drawn AFTER the blur group (creator ruling 2026-11-01):
// the blur is universal and must never hide the brand.
case Source { IsBrandLogo: true }:
+2 -1
View File
@@ -856,7 +856,8 @@ public sealed class FramePump : IDisposable
}
else if (element is Source { Type: SourceType.TextOverlay })
{
h = Mix(h, 2UL); // skipped kind — never resolved by the compositor
h = Mix(h, 2UL); // static text is resolved from its raster-on-change frame cache
if (element.IsVisible) frame = resolver(element);
}
else if (element is Source { Type: SourceType.Background })
{
+150
View File
@@ -0,0 +1,150 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Models;
namespace ytLive.Services;
public sealed class TextOverlayRenderer
{
private const int MaxRasterDimension = 4096;
private readonly ConcurrentDictionary<Source, CacheEntry> _cache = new();
public VideoFrame? RenderFrame(Source source)
=> _cache.TryGetValue(source, out var entry) ? entry.Frame : null;
public void Remove(Source source) => _cache.TryRemove(source, out _);
public void Retain(IEnumerable<Source> sources)
{
var active = new HashSet<Source>(sources);
foreach (var source in _cache.Keys)
if (!active.Contains(source))
_cache.TryRemove(source, out _);
}
public void UpdatePreview(Source source)
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
{
dispatcher.Invoke(() => UpdatePreview(source));
return;
}
var config = Capture(source);
if (_cache.TryGetValue(source, out var cached) && cached.Config == config)
{
if (!ReferenceEquals(source.VideoImageSource, cached.Preview))
source.VideoImageSource = cached.Preview;
return;
}
var frame = Rasterize(config);
var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
_cache[source] = new CacheEntry(config, frame, bitmap);
source.VideoImageSource = bitmap;
}
private static RenderConfig Capture(Source source) => new(
source.TextContent,
source.TextFontFamily,
source.TextFontSize,
source.TextColor,
source.TextBold,
source.TextItalic,
source.TextUnderline,
source.TextAlignment,
RasterDimension(source.Width),
RasterDimension(source.Height));
private static int RasterDimension(double value) =>
double.IsFinite(value) ? (int)Math.Clamp(Math.Ceiling(value), 1, MaxRasterDimension) : 1;
private static VideoFrame Rasterize(RenderConfig config)
{
var visual = new DrawingVisual();
if (!string.IsNullOrEmpty(config.Content))
{
using var drawing = visual.RenderOpen();
var family = new FontFamily(config.FontFamily);
var typeface = new Typeface(
family,
config.Italic ? FontStyles.Italic : FontStyles.Normal,
config.Bold ? FontWeights.Bold : FontWeights.Normal,
FontStretches.Normal);
var brush = new SolidColorBrush(ParseColor(config.Color));
brush.Freeze();
var formatted = new FormattedText(
config.Content,
CultureInfo.CurrentUICulture,
FlowDirection.LeftToRight,
typeface,
config.FontSize,
brush,
1.0)
{
MaxTextWidth = config.Width,
MaxTextHeight = config.Height,
TextAlignment = config.Alignment switch
{
TextLayerAlignment.Center => System.Windows.TextAlignment.Center,
TextLayerAlignment.Right => System.Windows.TextAlignment.Right,
_ => System.Windows.TextAlignment.Left,
},
Trimming = TextTrimming.CharacterEllipsis,
};
if (config.Underline)
formatted.SetTextDecorations(TextDecorations.Underline, 0, config.Content.Length);
drawing.DrawText(formatted, new Point(0, 0));
}
var bitmap = new RenderTargetBitmap(config.Width, config.Height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var pixels = new byte[config.Width * config.Height * 4];
bitmap.CopyPixels(pixels, config.Width * 4, 0);
UnpremultiplyAlpha(pixels);
return new VideoFrame(config.Width, config.Height, pixels);
}
private static Color ParseColor(string hex) =>
(Color)ColorConverter.ConvertFromString(hex)!;
private static void UnpremultiplyAlpha(byte[] pixels)
{
for (var i = 0; i < pixels.Length; i += 4)
{
var alpha = pixels[i + 3];
if (alpha == 0)
{
pixels[i] = pixels[i + 1] = pixels[i + 2] = 0;
continue;
}
if (alpha == 255) continue;
pixels[i] = Unpremultiply(pixels[i], alpha);
pixels[i + 1] = Unpremultiply(pixels[i + 1], alpha);
pixels[i + 2] = Unpremultiply(pixels[i + 2], alpha);
}
}
private static byte Unpremultiply(byte value, byte alpha) =>
(byte)Math.Min(255, (value * 255 + alpha / 2) / alpha);
private sealed record CacheEntry(RenderConfig Config, VideoFrame Frame, WriteableBitmap Preview);
private readonly record struct RenderConfig(
string Content,
string FontFamily,
double FontSize,
string Color,
bool Bold,
bool Italic,
bool Underline,
TextLayerAlignment Alignment,
int Width,
int Height);
}
+1
View File
@@ -12,6 +12,7 @@ External-facing logic: YouTube API, persistence. See
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable`. **TASK 43 rewrite (2026-09-24):** re-arms its one-shot poll on the server's `pollingIntervalMillis` (streamList semantics — reconnect on that cadence with `nextPageToken`, clamp 1000–6000ms, `maxResults=2000`); `ParsePage` (internal static seam) decodes **all six** event types into `ChatMessage.Kind`/fields (superChat / superSticker / newSponsor / membershipGifting / giftMembershipReceived / memberMilestoneChat + milestone months best-effort regex); optional `HttpClient` ctor seam endures; `InjectSimulatedMessage` (TASK 41) preserved |
| `ChatBoxRenderer.cs` | Chat overlay rasterizer (WPF FormattedText → BGRA `VideoFrame`), live rows + Super Chat/member styling |
| `ChatOverlayLayer.cs` | **The chat component (Commit G):** owns the message buffer, `ChatBoxRenderer`, fade/mock timers, per-source preview renders, live `RenderFrame`; raster-on-change cache pattern (OBS text-source contract) |
| `TextOverlayRenderer.cs` | TASK 3.17 static TextOverlay rasterizer/cache: WPF `FormattedText` runs on the dispatcher only when content/style/size changes; one straight-alpha BGRA `VideoFrame` drives both the XAML preview bitmap and compositor output, while pump reads are cache-only |
| `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 |
+3 -4
View File
@@ -5,7 +5,7 @@
**Goal:** Real video preview in the center panel — the minimal source set below, composited per scene.
### Status: 🔶 In progress — **Alerts (item 20) SHIPPED 2026-09-24 via TASK 43**; still open: item 17 (Text source), item 20's `RewardEvents` SQLite persistence half (16 compositing + 19 background removal excluded)
### Status: 🔶 In progress — **Text source (item 17) SHIPPED 2026-11-04 via TASK 3·17; Alerts (item 20) SHIPPED 2026-09-24 via TASK 43**; still open: item 20's `RewardEvents` SQLite persistence half (16 compositing + 19 background removal excluded)
1. ✅ **Milestone 1 — webcam** — MediaCapture (WinRT SDK projection) with device enumeration, CPU-first frame source, refcounted `CameraManager`, picker dialog, clip shapes (Traditional + Round) + mirror, 480×270 default placement — schema v2
2. ✅ **Schema v3 (Ship Branch A)** — multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OBS-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`) — 25 tests passing
@@ -23,7 +23,7 @@
14. ✅ **Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop** — `ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo` → `software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there.
15. ✅ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
16. ❌ **Scene compositing (D3DImage/MediaElement)** — SUPERSEDED, permanently out (2026-09-01): the software output compositor (TASK 4 step 1) + the XAML preview ARE the design; a D3D11 swap stays a possibility behind the `VideoFrame` seam, not a feature
17. ☐ **Text source** — live text ("Starting soon", "Back in 5", handle, callout)
17. ✅ **Text source (TASK 3·17, 2026-11-04)** — independent static multiline text layers with per-layer font, size, color, emphasis, alignment, position and size; additive layout persistence; editor and preview share the cached raster with compositor output
18. ✅ **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video — `ChatBoxRenderer` (WPF FormattedText → VideoFrame), configurable font size/color/badges/timestamps/max-messages via Elements panel, schema v10, persisted across save/load
19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
20. ✅ **Alerts — Super Chat / membership pop-ins (SHIPPED 2026-09-24 via TASK 43, NATIVE)** —
@@ -126,5 +126,4 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
7. **Drag/drop placement & reorder** — intuitive, visual (per design principle):
1. **Preview:** click-drag a source in the center panel to reposition it; resize via handles
2. **Scenes list:** drag rows to reorder scenes
3. **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
3. **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
+31 -7
View File
@@ -281,6 +281,7 @@ public partial class MainViewModel
UnwireScene(scene);
OnPropertyChanged(nameof(MissingScenes));
OnPropertyChanged(nameof(ShowAddScene));
RetainTextOverlaySubscriptions();
ScheduleSave();
}
@@ -306,12 +307,29 @@ public partial class MainViewModel
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
foreach (SceneElement element in e.NewItems)
element.PropertyChanged += OnElementPropertyChanged;
if (e.OldItems != null)
foreach (SceneElement element in e.OldItems)
element.PropertyChanged -= OnElementPropertyChanged;
if (e.Action != NotifyCollectionChangedAction.Move)
{
if (e.NewItems != null)
foreach (SceneElement element in e.NewItems)
{
if (element is Source { Type: SourceType.TextOverlay } text)
{
WireTextOverlaySource(text);
UpdateTextOverlayPreview(text);
}
else
element.PropertyChanged += OnElementPropertyChanged;
}
if (e.OldItems != null)
foreach (SceneElement element in e.OldItems)
{
if (element is Source { Type: SourceType.TextOverlay } text)
RemoveTextOverlayPreview(text);
else
element.PropertyChanged -= OnElementPropertyChanged;
}
}
RetainTextOverlaySubscriptions();
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanAddTicker));
@@ -320,6 +338,9 @@ public partial class MainViewModel
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is Source { Type: SourceType.TextOverlay } text)
OnTextOverlayPropertyChanged(text, e.PropertyName);
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
@@ -330,7 +351,10 @@ public partial class MainViewModel
var bakedSurface = e.PropertyName is nameof(SceneElement.X) or nameof(SceneElement.Y)
or nameof(SceneElement.Width) or nameof(SceneElement.Height)
or nameof(SceneElement.Opacity) or nameof(SceneElement.IsVisible)
or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored);
or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored)
or nameof(Source.TextContent) or nameof(Source.TextFontFamily)
or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold)
or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment);
if (bakedSurface)
{
var containingScene = Scenes.FirstOrDefault(s => s.Elements.Contains(el));
+61
View File
@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.Linq;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
private readonly TextOverlayRenderer _textOverlayRenderer = new();
private readonly HashSet<Source> _wiredTextOverlays = new();
private void UpdateConfiguredTextOverlays()
{
var sources = Scenes.SelectMany(scene => scene.Elements).OfType<Source>()
.Where(source => source.Type == SourceType.TextOverlay).ToArray();
RetainTextOverlaySubscriptions();
_textOverlayRenderer.Retain(sources);
foreach (var source in sources)
{
if (source.Width <= 0) source.Width = 900;
if (source.Height <= 0) source.Height = 140;
WireTextOverlaySource(source);
_textOverlayRenderer.UpdatePreview(source);
}
}
private void WireTextOverlaySource(Source source)
{
if (_wiredTextOverlays.Add(source))
source.PropertyChanged += OnElementPropertyChanged;
}
private void UpdateTextOverlayPreview(Source source) => _textOverlayRenderer.UpdatePreview(source);
private void RemoveTextOverlayPreview(Source source)
{
if (_wiredTextOverlays.Remove(source))
source.PropertyChanged -= OnElementPropertyChanged;
_textOverlayRenderer.Remove(source);
}
private void RetainTextOverlaySubscriptions()
{
var active = Scenes.SelectMany(scene => scene.Elements).OfType<Source>()
.Where(source => source.Type == SourceType.TextOverlay).ToHashSet();
foreach (var source in _wiredTextOverlays.ToArray())
if (!active.Contains(source))
RemoveTextOverlayPreview(source);
}
private void OnTextOverlayPropertyChanged(Source source, string? propertyName)
{
if (propertyName is nameof(Source.TextContent) or nameof(Source.TextFontFamily)
or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold)
or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment)
or nameof(Source.Width) or nameof(Source.Height))
UpdateTextOverlayPreview(source);
}
}
+4 -1
View File
@@ -47,6 +47,7 @@ public partial class MainViewModel : ViewModelBase
// born yet (startup crash regression from TASK 31, 2026-09-01; headless RealApp
// tests never ran it, so it waited for the first native launch).
private SceneGraph _sceneGraph = new();
internal SceneGraph SceneGraphForTests => _sceneGraph;
/// <summary>Alert audio plays on the default device (creator ruling 2026-11-01): the WASAPI
/// loopback then picks it up, so it is audible AND visible in the desktop sound bar.</summary>
@@ -497,6 +498,7 @@ public partial class MainViewModel : ViewModelBase
}
ClearPreviewHistory();
StagedScene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, SceneCatalog.Starting)) ?? Scenes.FirstOrDefault();
UpdateConfiguredTextOverlays();
UpdateActiveBackground();
UpdateActiveChatBoxPreviews();
UpdateActiveAlertBoxPreviews();
@@ -585,7 +587,7 @@ public partial class MainViewModel : ViewModelBase
// Scene-element → latest frame, for the live compositor. The map mirrors the
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
// images/background by AssetId. A null frame leaves the element transparent.
private VideoFrame? ResolveOutputFrame(SceneElement element)
internal VideoFrame? ResolveOutputFrame(SceneElement element)
{
var frame = ResolveOutputFrameCore(element);
// Output-side eyes (2026-09-01): preview showed the webcam but the recording
@@ -644,6 +646,7 @@ public partial class MainViewModel : ViewModelBase
// (caught by the resolver logging, 2026-09-01 take two).
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId), EffectKeyFor(webcam)),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { Type: SourceType.TextOverlay } text => _textOverlayRenderer.RenderFrame(text),
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
Source { Type: SourceType.Ticker } ticker => _alertLayer.RenderSceneTicker(ticker),
Source { Type: SourceType.Scoreboard } => _scoreboardPresenter?.Frame,
+2 -1
View File
@@ -8,7 +8,7 @@ areas' docs in their own rows keeps this index small to read.
| File | Purpose |
|------|---------|
| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Lifecycle/ctor wiring:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged` (the selection change also raises `CanToggleClipShape` — TASK 57), `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → `RenderChatBox`/`RenderAlertBox` for chat + alert layers) — the pump's stream-state consumers live in the Streaming partial. |
| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Lifecycle/ctor wiring:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged` (the selection change also raises `CanToggleClipShape` — TASK 57), `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → cached TextOverlay frame / `RenderChatBox` / `RenderAlertBox` for text, chat, and alert layers) — the pump's stream-state consumers live in the Streaming partial. |
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
@@ -30,6 +30,7 @@ areas' docs in their own rows keeps this index small to read.
| `SocialsDialogViewModel.cs` | Social Media Site Promotion dialog (six fixed slots, `SocialSlotViewModel`): row 0 = YouTube sign-in gate (sign-in provider fills the channel, delete = sign-out); rows 1-5 validated entries / empty / freemium locks 2-5 (`isPremium` seam); `DetectService` → `ISocialValidator` on confirm (input snaps to text + real logo on success, red do-not + stays editable on failure); `CanSave` blocked while any slot edits/validates/errors; `BarEnabled` switch; commit via `CommittedEntries` + `BuildSocialsConfig()` (YouTube first) + `SaveRequested`/`CancelRequested`. WPF-free — the window owns the dialogs (delete confirm, sign-in gate) and calls `StartEdit`/`ConfirmEdit`/`DeleteSlotAsync`. |
| `MainViewModel.Sources.cs` | Source creation/removal; `AddSource` creates the standalone ticker layer with one-per-scene gating and its 1920×48 default geometry; TextOverlay starts as a visible 900×140 draggable text layer. |
| `MainViewModel.TextOverlay.cs` | Raster-on-change TextOverlay preview hookup and settings/dimension invalidation; output resolution reads its immutable cached frame. |
| `MainViewModel.AlertTicker.cs` | AlertBox ticker preview routing: selects the preview scene's AlertBox settings and publishes the positioned box-sized frame to PreviewPane. |
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
+8
View File
@@ -381,6 +381,14 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
renderer — `Same()` for unchanged inputs, `NotSame()` on message/config change, null on empty).
Accepted cost pending take 6: one slow tick (~15-25ms) per arriving message; if chat-burst frame
loss shows up, the next slice moves the re-render off-tick (debounced, dispatcher-side).
- **TASK 3.17 — static TextOverlay raster-on-change:** `TextOverlayRenderer.UpdatePreview` runs WPF
`FormattedText` + `RenderTargetBitmap` only on the dispatcher when text, formatting, or box size
changes. It publishes one immutable straight-alpha BGRA `VideoFrame` and a `WriteableBitmap`
copied from those same pixels; preview and output therefore share the raster. `FramePump` calls
only `RenderFrame` (a concurrent cache read), never WPF. Text is a static `Source`, so its raster
participates in the static bake or above-split path; property changes invalidate the bake, and the
full-render signature includes the resolved text frame. Its premultiplied WPF readback is
unpremultiplied before entering `VideoFrame`, whose compositor contract is straight-alpha BGRA.
- **Slice 5 — paste cache for non-opaque layers (2026-09-04, take 7/8 data):** the render/resolve
split (added in the stamp commit) finally named the last thing honestly: `resolve ≈ 0` but
`render 26-27ms` (124-135/300, ≈2.2x) — the chat fix HAD worked; the remaining cost was the
+30
View File
@@ -540,6 +540,36 @@ public class FramePumpTests
await pump.StopAsync();
}
[Fact]
public async Task FullRenderCache_TextOverlayFrameChange_Recomposites()
{
var scene = new Scene { Name = "Text cache" };
scene.Elements.Add(new Source
{
Type = SourceType.TextOverlay,
Width = 64,
Height = 48,
});
var current = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, scene: () => scene, resolve: _ => current);
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && (encoder.Frames.Count < 6 || pump.CacheHits < 4))
await Task.Delay(10);
Assert.Equal(1, pump.CacheRenders);
current = SceneCompositorTests.Solid(64, 48, 0, 0, 255);
deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && pump.CacheRenders < 2)
await Task.Delay(10);
Assert.True(pump.CacheRenders >= 2, "the changed text raster did not invalidate the full-render cache");
AssertColor(encoder.Frames[^1], 8, 8, 0, 0, 255);
await pump.StopAsync();
}
/// <summary>One integration test for the take-8 off-UI fix: the pump loop must NOT
/// run on the starting thread's SynchronizationContext (StartAsync fires from a UI
/// command handler; the old loop inherited it, so every frame competed with the
+14 -9
View File
@@ -52,6 +52,10 @@ public sealed class TextLayerEditorPreviewTests
var leftPanel = (LeftPanel)window.FindName("LeftPanel")!;
var editor = (TextBox)leftPanel.FindName("TextLayerEditor")!;
Assert.Equal("Text", editor.Text);
var firstBitmap = first.VideoImageSource;
var secondBitmap = second.VideoImageSource;
Assert.NotNull(firstBitmap);
Assert.NotNull(secondBitmap);
Assert.True(first.IsDraggable);
Assert.Equal(900, first.Width);
Assert.Equal(140, first.Height);
@@ -62,14 +66,16 @@ public sealed class TextLayerEditorPreviewTests
Assert.Equal("Updated headline\nSecond line", first.TextContent);
Assert.Equal("Leave this caption alone", second.TextContent);
Assert.NotSame(firstBitmap, first.VideoImageSource);
Assert.Same(secondBitmap, second.VideoImageSource);
var preview = (PreviewPane)window.FindName("PreviewPane")!;
var firstPreview = FindTextPreview(preview, first);
var secondPreview = FindTextPreview(preview, second);
var firstPreview = FindTextImage(preview, first);
var secondPreview = FindTextImage(preview, second);
Assert.NotNull(firstPreview);
Assert.NotNull(secondPreview);
Assert.Equal("Updated headline\nSecond line", firstPreview!.Text);
Assert.Equal("Leave this caption alone", secondPreview!.Text);
Assert.Same(first.VideoImageSource, firstPreview!.Source);
Assert.Same(second.VideoImageSource, secondPreview!.Source);
Assert.True(firstPreview.IsVisible);
Assert.True(secondPreview.IsVisible);
}
@@ -82,15 +88,14 @@ public sealed class TextLayerEditorPreviewTests
}
}
private static TextBlock? FindTextPreview(DependencyObject parent, Source source)
private static Image? FindTextImage(DependencyObject parent, Source source)
{
if (parent is TextBlock text && Equals(text.Tag, "TextLayerPreview")
&& ReferenceEquals(text.DataContext, source))
return text;
if (parent is Image image && ReferenceEquals(image.DataContext, source))
return image;
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var result = FindTextPreview(VisualTreeHelper.GetChild(parent, i), source);
var result = FindTextImage(VisualTreeHelper.GetChild(parent, i), source);
if (result != null) return result;
}
return null;
+244
View File
@@ -0,0 +1,244 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Controls;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.ViewModels;
namespace ytLive.Tests;
[Collection("RealApp")]
public sealed class TextLayerOutputTests
{
private readonly RealAppHost _app;
public TextLayerOutputTests(RealAppHost app) => _app = app;
[Fact]
public void Text_Layers_Share_The_Preview_Raster_With_Full_And_Baked_Output_And_Refresh_On_Edit()
{
_app.Run(Run);
}
private static void Run()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"ytLlive-text-output-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = dbPath;
MainWindow? window = null;
try
{
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
vm.EditingActive = true;
var scene = new Scene { Name = "Text output test" };
vm.Scenes.Add(scene);
vm.StagedScene = scene;
var art = new Source
{
Name = "Test background",
Type = SourceType.Image,
Width = 320,
Height = 180,
};
var lowerText = new Source
{
Name = "Lower text",
Type = SourceType.TextOverlay,
TextContent = "Lower layer",
X = 12,
Y = 12,
Width = 140,
Height = 52,
TextFontSize = 32,
};
var dynamic = new Source
{
Name = "Dynamic split",
Type = SourceType.WebSource,
Width = 20,
Height = 20,
};
var upperText = new Source
{
Name = "Upper text",
Type = SourceType.TextOverlay,
TextContent = "Upper layer",
X = 150,
Y = 90,
Width = 150,
Height = 52,
TextFontSize = 30,
TextBold = true,
};
scene.Elements.Add(art);
scene.Elements.Add(lowerText);
scene.Elements.Add(dynamic);
scene.Elements.Add(upperText);
vm.SelectedElement = lowerText;
window.Show();
window.UpdateLayout();
var preview = (PreviewPane)window.FindName("PreviewPane")!;
AssertPreviewUsesTextFrame(preview, lowerText);
AssertPreviewUsesTextFrame(preview, upperText);
var background = Solid(320, 180, 24, 38, 52);
VideoFrame? Resolve(SceneElement element) => ReferenceEquals(element, art)
? background
: vm.ResolveOutputFrame(element);
var options = new CompositorOptions
{
SourceRectX = 0,
SourceRectY = 0,
SourceRectWidth = 320,
SourceRectHeight = 180,
OutputWidth = 320,
OutputHeight = 180,
};
var compositor = new SceneCompositor();
var fullBefore = compositor.Render(scene, Resolve, null, options);
AssertHasTextPixels(fullBefore, background);
var graph = vm.SceneGraphForTests;
var split = graph.GetSplitPoint(scene);
Assert.Equal(2, split);
var bakedBefore = graph.GetBakedBase(scene, Resolve, () => options);
Assert.NotNull(bakedBefore);
var mixedBefore = SceneCompositor.CompositeLayers(bakedBefore!, scene, split, Resolve, options);
AssertHasTextPixels(mixedBefore, background);
var lowerFrameBefore = Assert.IsType<VideoFrame>(vm.ResolveOutputFrame(lowerText));
var leftPanel = (LeftPanel)window.FindName("LeftPanel")!;
var editor = (TextBox)leftPanel.FindName("TextLayerEditor")!;
editor.Text = "Edited lower layer";
editor.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
var colorEditor = (TextBox)leftPanel.FindName("TextLayerColorEditor")!;
colorEditor.Text = "#FF0000";
colorEditor.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
window.UpdateLayout();
Assert.Equal("#FF0000", lowerText.TextColor);
var updatedFrame = Assert.IsType<VideoFrame>(vm.ResolveOutputFrame(lowerText));
var fullAfter = compositor.Render(scene, Resolve, null, options);
Assert.NotSame(lowerFrameBefore, updatedFrame);
Assert.False(fullBefore.BgraPixels.AsSpan().SequenceEqual(fullAfter.BgraPixels));
AssertHasTextPixels(fullAfter, background);
var bakedAfter = graph.GetBakedBase(scene, Resolve, () => options);
Assert.NotNull(bakedAfter);
Assert.NotSame(bakedBefore, bakedAfter);
var mixedAfter = SceneCompositor.CompositeLayers(bakedAfter!, scene, split, Resolve, options);
AssertHasTextPixels(mixedAfter, background);
AssertPreviewUsesTextFrame(preview, lowerText);
scene.Elements.Remove(dynamic);
vm.SaveLayoutNow();
window.Close();
window = null;
SqliteConnection.ClearAllPools();
window = new MainWindow();
var reloadedVm = (MainViewModel)window.DataContext;
reloadedVm.EditingActive = true;
var reloadedScene = reloadedVm.Scenes.Single(item => item.Name == scene.Name);
reloadedVm.StagedScene = reloadedScene;
var reloadedLowerText = reloadedScene.Elements.OfType<Source>()
.Single(source => source.Name == lowerText.Name);
var reloadedUpperText = reloadedScene.Elements.OfType<Source>()
.Single(source => source.Name == upperText.Name);
reloadedVm.SelectedElement = reloadedLowerText;
window.Show();
window.UpdateLayout();
AssertPreviewUsesTextFrame((PreviewPane)window.FindName("PreviewPane")!, reloadedLowerText);
var loadedFrameBefore = Assert.IsType<VideoFrame>(reloadedVm.ResolveOutputFrame(reloadedLowerText));
var loadedUpperBitmap = reloadedUpperText.VideoImageSource;
var loadedGraph = reloadedVm.SceneGraphForTests;
var loadedResolve = new Func<SceneElement, VideoFrame?>(element =>
string.Equals(element.Name, "Test background", StringComparison.Ordinal)
? background
: reloadedVm.ResolveOutputFrame(element));
var loadedBakeBefore = loadedGraph.GetBakedBase(reloadedScene, loadedResolve, () => options);
Assert.NotNull(loadedBakeBefore);
var reloadedPanel = (LeftPanel)window.FindName("LeftPanel")!;
var reloadedEditor = (TextBox)reloadedPanel.FindName("TextLayerEditor")!;
reloadedEditor.Text = "Edited after reload";
reloadedEditor.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
window.UpdateLayout();
var loadedFrameAfter = Assert.IsType<VideoFrame>(reloadedVm.ResolveOutputFrame(reloadedLowerText));
var loadedBakeAfter = loadedGraph.GetBakedBase(reloadedScene, loadedResolve, () => options);
Assert.NotSame(loadedFrameBefore, loadedFrameAfter);
Assert.NotSame(loadedBakeBefore, loadedBakeAfter);
Assert.Same(loadedUpperBitmap, reloadedUpperText.VideoImageSource);
Assert.Equal("Edited after reload", reloadedLowerText.TextContent);
AssertPreviewUsesTextFrame((PreviewPane)window.FindName("PreviewPane")!, reloadedLowerText);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(dbPath); } catch { /* best-effort cleanup */ }
}
}
private static void AssertPreviewUsesTextFrame(PreviewPane preview, Source source)
{
var image = FindTextImage(preview, source);
Assert.NotNull(image);
Assert.Same(source.VideoImageSource, image!.Source);
Assert.Same(source.VideoImageSource, source.DisplaySource);
Assert.True(image.IsVisible);
}
private static Image? FindTextImage(DependencyObject parent, Source source)
{
if (parent is Image image && ReferenceEquals(image.DataContext, source))
return image;
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var result = FindTextImage(VisualTreeHelper.GetChild(parent, i), source);
if (result != null) return result;
}
return null;
}
private static void AssertHasTextPixels(VideoFrame frame, VideoFrame background)
{
var changed = false;
for (var i = 0; i < frame.BgraPixels.Length; i += 4)
{
if (frame.BgraPixels[i] != background.BgraPixels[i]
|| frame.BgraPixels[i + 1] != background.BgraPixels[i + 1]
|| frame.BgraPixels[i + 2] != background.BgraPixels[i + 2])
{
changed = true;
break;
}
}
Assert.True(changed, "the text layer did not composite over the background pixels");
}
private static VideoFrame Solid(int width, int height, byte r, byte g, byte b)
{
var pixels = new byte[width * height * 4];
for (var i = 0; i < pixels.Length; i += 4)
{
pixels[i] = b;
pixels[i + 1] = g;
pixels[i + 2] = r;
pixels[i + 3] = 255;
}
return new VideoFrame(width, height, pixels);
}
}
+54
View File
@@ -0,0 +1,54 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
[Collection("RealApp")]
public sealed class TextOverlayRendererTests
{
private readonly RealAppHost _app;
public TextOverlayRendererTests(RealAppHost app) => _app = app;
[Fact]
public void Raster_IsCached_And_PreviewBitmap_Uses_The_Same_Transparent_Pixels()
{
_app.Run(() =>
{
var source = new Source
{
Type = SourceType.TextOverlay,
TextContent = "Cached text",
Width = 220,
Height = 70,
TextFontSize = 36,
TextColor = "#80FFFFFF",
TextBold = true,
TextUnderline = true,
};
var renderer = new TextOverlayRenderer();
renderer.UpdatePreview(source);
var first = Assert.IsType<VideoFrame>(renderer.RenderFrame(source));
var preview = Assert.IsType<WriteableBitmap>(source.VideoImageSource);
var pixels = new byte[first.BgraPixels.Length];
preview.CopyPixels(pixels, first.Stride, 0);
Assert.Equal(first.BgraPixels, pixels);
Assert.Contains(first.BgraPixels.Where((_, index) => index % 4 == 3), alpha => alpha > 0);
Assert.Contains(first.BgraPixels.Where((_, index) => index % 4 == 3), alpha => alpha == 0);
renderer.UpdatePreview(source);
Assert.Same(first, renderer.RenderFrame(source));
Assert.Same(preview, source.VideoImageSource);
source.TextContent = "Changed";
renderer.UpdatePreview(source);
Assert.NotSame(first, renderer.RenderFrame(source));
Assert.NotSame(preview, source.VideoImageSource);
});
}
}