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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }:
|
||||
|
||||
@@ -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 })
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user