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