TASK 21's picker slice: the decoder, manager, pacing and loop mechanism were
already shipped but nothing ever acquired a session, so a media Source rendered
nothing. Adds "Video File" to the (+) menu (video-filtered OpenFileDialog),
one decode session per distinct path with a HasSession guard, release when the
last scene reference goes, and a post-load reconcile. Loop is a manager-level
per-path answer ("any live reference loops") applied before StartAsync and
updated live via SetLooping. Properties panel shows the file with Change… and
Loop. Volume stays unwired: media audio rides the desktop loopback, TRAX-style.
Reference: OBS's media source (file → decode → composite into the scene) is the
established answer for this feature; the lifecycle shape mirrors this repo's
WebView2/ScreenCapture registration rather than inventing a new one.
Test: ytLive.Tests/MediaSourceAcquisitionTests.cs — add starts one decoder and
frames reach the layer, loop flip reaches the decoder, remove stops it.
Docs: task-21 closed, TASKS row + Day-4 row + stale 3·17 row corrected,
frame-pipeline acquisition note, HANDOFF rewritten. verify.sh green.
TextOverlay layers now have a multiline editor, Windows font and size controls, color/emphasis/alignment settings, and a visible draggable preview. Text sources follow OBS's static text-source conventions: https://obsproject.com/kb/text-sources
Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only.
Ref: https://obsproject.com/kb/obs-studio-overview
The creator found the use case that reframes the feature: a moment of privacy while the webcam is
on. A blur that can only cover the desktop cannot do that.
- SceneCompositor applies the blur group AFTER every element is composited (still before the
flash / social bar / ticker / zoom overlays), so it covers webcam, chat, alerts — anything. Its
position in the layer list no longer affects the output, so it is added at the top and the
reorder clamp is gone.
- Supersedes the earlier "blur sits below the brand" rule: a universal blur does obscure the logo
when it covers it. Called out in frame-pipeline.md so it is not re-litigated.
- Preview mirrors it exactly: the blur host moved OUT of CanvasGrid to a sibling above it, so the
VisualBrush samples every layer (and cannot sample itself). It used to sample only the backdrop,
which is why a circle over the webcam would have shown sharp pixels in the editor and blurred
ones in the stream.
Creator request 2026-11-01: a layer that hides part of the desktop from viewers. Box or circle,
drag/resize like any layer, one strength slider, several allowed.
**Z-order is policy, not preference.** `AddSource` inserts a blur directly above the background
and therefore below the brand logo, and `OnSceneElementsReordered` pushes any blur that ends up
above the brand back down. Nothing else in the app can move it into the brand's way.
**Overlap = the strongest single blur, never a stack.** `SceneCompositor.ApplyBlurLayers` collects
every visible blur the first time one is reached in the z-order, sorts them strongest-first, and
shares one coverage map: a pixel already covered by a stronger blur is skipped. Blurring
back-to-front would have produced something stronger than any slider setting allows, which is
exactly what was ruled out.
**One blur curve, shared with the webcam.** `RegionBlur` carries the constants the webcam blur
already used — downscale 4, radius 1–6 downscaled px, `RadiusFor(strength)` — and
`BackgroundEffectProcessor` now delegates to it instead of holding its own copy. The layer's
slider is the same 0–1 scale with the same 0.5 default, so "half strength" means the same blur in
both features. Cost is proportional to the blur's own rect, not the frame.
**Live scene only**, enforced in `AddSource` as well as the (+) menu (`CanAddBlur`), so a stale
menu item or a scripted call cannot plant one on BRB. `ElementKind.Dynamic`, so it is never baked
into the static base and every render path gets it — pump, preview, thumbnails, scene dump.
Persistence: `Source.BlurAmount` column + migration; shape rides the existing `ClipShape`.
Properties panel: Box | Circle plus the strength slider, gated on `IsBlurSelected`.
4 tests: outside the mask is byte-identical, inside loses its hard edges, a circle leaves its
box corners alone, and two identical stacked blurs equal one (the coverage map). Release builds
at 0 warnings.
Three creator directives (2026-11-01), done ahead of the surge.
**Gear moved to the preview rail** — first tab, orange (Brush.Attention), gear icon
only. Left-click opens the same app menu the top bar gear owned (Settings / Bug /
Feature / About), right-click goes straight to App Settings; both behaviours moved
with it, so nothing lost an entry point. TopBar keeps the wordmark and EDIT.
**EDIT right edge aligned to the layers panel.** The work area is margin 8 + a 360px
column, the top bar is padding 16, so the shared edge is 352 inside the bar. TopBar
column 0 is now a fixed 352 grid with EDIT right-aligned in it — alignment by
arithmetic instead of by eye.
**Preview undo/redo: Ctrl+Z / Ctrl+Shift+Z.** Models/PreviewHistory snapshots element
REFERENCES plus geometry, visibility, order and membership — a deep clone would mirror
every property of a dozen element subclasses and quietly miss the next one added.
One snapshot per gesture (pushed at drag/resize start, never in the move loop), bounded
at 50, redo survives multiple undos, cleared on layout load. Gated on EditingActive:
undo while the preview shows program output would rewrite a scene nobody is looking at.
Honest scope, in the type's doc comment: layout edits only. Colour/opacity/border/font
edits are not captured yet, so Ctrl+Z after one of those restores the last layout change.
6 tests in PreviewHistoryTests; 19 pass across it and the neighbouring UI classes; Debug
and Release both build at 0 warnings.
Adds SourceType.Scoreboard with the same per-scene eye toggle, drag and presence as every other
element — 'prominent on BRB, dialled back on Live' is geometry, not a second mechanism. Default
geometry is the strip: 1920 wide, 56 tall, bottom edge.
The output resolver returns the presenter's cached frame exactly like the chat box and alert box do,
and every session start clears the tallies. Preview publication writes one shared WriteableBitmap on
the UI thread (the pump thread must never touch one), refreshed only when a displayed value moves.
Guard: ScoreboardLayerTests pins the enum member, the geometry constants, the resolver case and the
session hook, so the layer cannot quietly become a special widget.
- Source.IsBrandLogo persisted (additive migration); layer healed on load, removal blocked
- Image stored as an asset; settings keep the asset id and file name
- App Settings: Brand Logo file field with Browse
- Default placement top-left; per-scene eye uses the existing element visibility
Test: BrandLogoTests; layout/background tests pass (34). Not pushed.
- LeftPanel: SCENE LAYERS header with layer count; per-type blue icons; properties header gear; layers list min/max height; properties placeholder; list and properties indent
- SceneThumbnailStrip: names/hotkeys over the tile; PROGRAM badge with blinking red dot on the selected tile; orange border on selected and live tiles; fixed the element border override that hid the style border; reflag tiles on load
- LiveScene is now set on go-live, record start, and live transitions (it was never assigned, so IsLive never fired)
- Startup stages the scene named Starting
- DESIGN.md: color roles (red = YouTube/on-air/record, orange #ff8c3a = attention, amber = game audio/Super Chat)
- Staged live tile uses background art instead of the green LIVE placeholder
Not pushed.
Replace the external StreamElements feed with NATIVE YouTube events per the creator's
2026-09-23 question.
Chat parity half:
- YouTubeChatService decodes ALL SIX liveChat/messages event types into
ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/
giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay
rows are real now — and re-arms its one-shot poll on the server's
pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms,
maxResults=2000). ParsePage internal static seam + ChatPage record for
deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage
(TASK 41) preserved.
Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no
menus/polls, sub mention = chat row only, NO viewer count):
- New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate
mirroring chat; idle = transparent).
- AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/
count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/
chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card
drawing the 'made with LlamaCasty!' brand line.
- AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail
never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview,
Advance(double) as the deterministic test clock; ChatEventKind.None rows never
enqueue.
- Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews,
Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged
(member->NewMember, superchat->SuperChat).
Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) —
ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing;
six events play pairwise-distinct moving frames then drain to null.
Gate: clean build 0 warnings; full suite 316/317, the one failure
(LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the
known environmental class (real-mouse-drag no-ops with a game/fullscreen window
focused).
References (OBS/overlay ecosystem):
- streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
Reorder: OnSceneElementsReordered now calls SaveLayoutNow() (was debounced
ScheduleSave) so a drag-drop persists instantly — one write stores the new
layer list (Source.SortOrder) and each layer's preview geometry (X/Y/W/H) in
the same rows. Reference: WPF ListBox drag-reorder requires an explicit persist
at drop; a debounce window lets a crash lose the drop.
Recording save dialog (RenameRecordingDialog.xaml): height 276→304 so the
Cancel/Save button row is no longer obscured; Save button named
SaveRecordingButton so the sizing test can assert it sits inside the client area.
Tests: deterministic seam test now asserts the dragged layer's geometry (preview
layout) is persisted with the new order; sizing test asserts 304 + textbox and
button-row bottoms within client area. 308/308 green.
List_PreviewMouseMove reorders StagedScene.Elements via RemoveAt/Insert,
bypassing SceneGraph's mutation surface, but never scheduled a save — the
new z-order was lost on restart. The drop now sets _dragReordered and
EndListDrag funnels it through MainViewModel.OnSceneElementsReordered()
(InvalidateBake + ScheduleSave, same background-save path as every other
mutation). Integration test (RealApp + temp DB) reproduces the exact
code-behind mutation and asserts the debounced save lands the new Source
SortOrder. Derivation reference: standard WPF ItemsControl drag-reorder
pattern (OBS layering semantics: bottom-most layer = index 0).
Chat's camera no longer greys Web Cam in Live (per-scene max, not app-wide);
TASK 26 superseded by creator directive (app-level resource model). Add Webcam
now places the existing app default without the picker; the picker runs only
for the initial selection. Removing the last placement keeps the identity
(_webcam never nulled) so Add stays offered. Startup pass adopts a solo camera
as the app default, so a clean layout offers the layer in Live/Chat at once.
Dynamic WebcamAddToolTip names the why (incl. the 'graduated to OBS' line).
Good Dog: WebcamMenuGateTests rewritten (real app + temp DB + camera seams) —
identity in Chat does not gray Live; Add in Live places same wc-1 no picker;
scene-with-placement stays gray; identity survives both removals (DB row 1).
WebcamStartupResourceTests single-lock fact asserts CanAddWebcam after adoption.
Docs same commit (ai.md supersession, TASKS.md, HANDOFF.md). 304/304, 0 warnings.
- Add ElementKind (Static/Dynamic) to SceneElement base; Source/WebcamSceneConfig classify
- Services/SceneGraph.cs: owns the Scenes collection (ViewModel's Scenes delegates to it),
the element mutation surface (Add/Insert/Remove/Move, each invalidating the bake), and the
queries that were scattered LINQ (GetBackground/GetWebcam/GetChatBoxes/GetSplitPoint/IsStatic)
- SceneCompositor: split-aware BakeStaticBase + CompositeLayers + Render(.., staticBase, split);
builds/caches the static base below the split point in source-rect space
- FramePump: optional SceneGraph -> optimized bake+composite path; falls back to full render
- MainViewModel: routes element mutations through the graph; invalidates the bake on static
layout/opacity/visibility/useDefaultBackground changes and after background heal/Ensure
- Integration test SceneGraphTests.BakedStaticBase_WithDynamicLayer_CompositesCorrectly
Derivative survey (mandated): tried before writing — OBS does per-source opacity/visibility
caching and static-scene baking; this mirrors OBS's 'cached static source' optimization.
3 documented defensive deviations from the spec (ChatOverlayLayer stays decoupled;
background helpers stay VM-static for direct testability; full facade peel deferred post-1.0)
in TASKS.md + ai.md. Scope check passed; clean build 0 warnings.
Core MainViewModel.cs 758 -> 495 lines. Moved the source add/remove/image
block (AddSource, NextSourceName, AddImage, BuildImageCandidates,
PickImageBytes, AddAsset, AddImageSource, AddReusedImage, RemoveElement) into a
new MainViewModel.Sources.cs partial, and WebView2 source hosting
(RegisterLoadedWebSources, InitWebView2, OnWebView2PreviewBitmapChanged) into a
new MainViewModel.Web.cs partial. Pruned now-dead usings from core.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.