Compare commits

..

146 Commits

Author SHA1 Message Date
gramps e7cded6879 docs: pricing ruled — $10/mo + $400 lifetime, and the 15% fee is verified for subscriptions
Reconcile the pending doc edits with the creator's actual ruling, and close the
revenue-share question that was blocking the monthly price.

Verified from the App Developer Agreement v8.10 PDF itself (downloaded and
text-extracted, not a search excerpt). Section 6(b) has only three tiers:
6(b)(i) 15% for Apps and their In-App Products *not listed in* 6(b)(iii);
6(b)(ii) 12% Games-only; 6(b)(iii) 30% for Xbox console apps/games, Xbox
non-subscription IAP, and Windows 8/Phone 8. LlamaCasty is a Windows PC App,
so 6(b)(i) governs BOTH tiers -- there is no subscription surcharge. The
agreement's own changelog (v8.0, Oct 26 2017) states it outright: "implement
the 85/15 revenue share for non-Game subscriptions."

  => $10/mo nets $8.50 (~$102/yr); $400 lifetime nets $340.

Also confirmed: the 15% applies after VAT/GST (Net Receipts definition),
payouts are monthly above a $50 threshold, and no better small-indie rate
exists in the standard terms.

Creator ruling recorded: $10/mo subscription or $400 lifetime, no annual, no
.99. This supersedes the 2026-09-21 one-time $29 -> $49 model.

Two obligations ADA 6(h) attaches to the recurring tier, recorded because they
change its risk profile and are the reason lifetime is the hedge: we must
fulfil the subscription for the entire period as marketed (on breach Microsoft
may refund the full amount plus taxes in its sole discretion), and raising the
price disables auto-renew -- so $10 is effectively locked for the product's life.

Stale facts fixed in the same change rather than appended:
- research-store-certification.md: IARC was 11.11.1/11.11.2 in one table and
  10.11.1 in another; corrected to 10.11.x.
- research-store-certification.md: policy 10.8.1/10.8.2 still asserted "Polar
  explicitly permitted" and "tick the third-party purchase box". Void now that
  Store IAP is the route and Polar is being torn down.
- task-48: the working YouTube demo account (10.3.1) was accidentally dropped
  from the certification list during the Store-IAP edit. Restored -- a reviewer
  cannot use the app without one.
- MyMistakes.md: the verified-fact-vs-decided-outcome lesson now closes its
  loop (the creator did rule the way the research pointed), and records the
  over-correction that followed.

Docs-only. No code, no build, no tests.
2026-09-27 15:02:25 -07:00
gramps b2036a38e8 docs: no annual tier — Microsoft does not pro-rate, and that is unfixable from our side
The creator raised it as instinct: "who hasn't been screwed over cancelling a
sub early to be told that the extra six months remaining are your loss?" Checking
the mechanism confirms it, and worse than expected.

Microsoft's general position: "Digital goods like apps, add-on content,
subscriptions... aren't refundable unless the offer or applicable law states that
you're eligible for a refund." Pro-rated refunds on cancel exist only in Canada,
Denmark, France, Israel, Korea, Turkey (all lengths) and Finland, Germany,
Netherlands, Poland, Portugal (renewals only). Critically: "monthly subscriptions
and initial (pre-renewal) purchases aren't eligible for a prorated refund."

A first-year annual subscriber who cancels in month 2 loses months 3-12, in most
countries, and the developer cannot refund it. That is verbatim the trap the
creator named, so we must not build it. => monthly only. Max loss $10, max
grievance a rounding error, one fewer product to declare, less support surface.

The chargeback argument gets stronger, not weaker: a customer down $89 on an
annual cliff disputes through their bank, which is frozen funds and account risk
against a solo dev. Monthly bounds that at $10.

Also records two rulings:
- No .99 prices. "Let's stop with the x.99 stuff - I find that irritating. Just say:
  ten bucks a month." The convention only makes a price look cheaper, which is
  incoherent for a brand sold on honesty and no-dark-patterns.
- No feature gating, now argued as revenue rather than taste: the free tier's reach
  is the funnel and the branding flash is an ad running inside other people's
  content.

Pricing model itself stays OPEN in TASKS.md — $10/mo is the lean, and a lifetime
product (~$300, the hedge against the no-moat renewal problem) is undecided. The
Store revenue share is still unverified: confirm the net, not the list.

MyMistakes.md records the actual error: I had offered "just refund anyone who
asks" as a mitigation without checking who holds the authority to execute it.
Store refunds run through Microsoft, not the developer. The check that followed
is what produced this constraint.

MONETIZATION.md got the full analysis but is gitignored, so it stayed local.
2026-09-27 14:33:25 -07:00
gramps 85fad1ab79 docs: distribution route decided — Microsoft Store MSIX + Store IAP
Creator ruling 2026-09-27. Criteria, verbatim: "zero headaches, minimal
maintenance (for me) while still providing accountability and a reasonably
easy upgrade flow." Route A is the only combination where all four are solved
by handing the work to Microsoft rather than to a certificate vendor: $0/yr,
no certificate, no HSM, no annual renewal, no SmartScreen ramp — plus Store
auto-update, Store-side payments/entitlements/refunds/support, and Microsoft
review as the accountability layer.

The rejected options and their reasons stay in research-store-certification.md
§3 so a later session reads the ruling instead of re-deriving it.

What this deletes:
- The entire licensing backend. PolarLicenseService, PolarLicense,
  MainViewModel.License.cs (PremiumUrl, customer portal, the OfflineGracePeriod
  = 14 days subscription-era artifact, renewal/lapse copy) and the wrong
  "Polar unlocks alerts" string all become dead code. IsPremium is derived from
  the Store entitlement instead of an HTTP call, which also removes the whole
  "network flaky -> app thinks I'm expired" bug class.
- Velopack, the update URL, and the self-hosted droplet — the Store updates.
- Distribution.md's premise: Polar as the distribution backbone, Polar file
  hosting, and code signing as our problem. The IP-protection sections (1, 5,
  6, 7) still stand and the build-posture ceiling is unchanged.

What does NOT change: the entitlement. Free gets everything; the branding
flash stays the only paid delta. Store IAP changes how IsPremium is obtained,
never what it gates.

Still open, deliberately: the price. The Store revenue share is unverified (do
not assume a percentage), and MONETIZATION.md's $29 -> $49 one-time decision is
re-opened against a fresh instinct toward ~$99/yr. No price encoded yet.

The first code unit is unchanged: bundle ffmpeg (TASK 48 item 1). That clears
the one hard certification gate and fixes a real user-facing 404.

MARCOM.md and MONETIZATION.md were edited too but are gitignored by design, so
those changes stayed local.
2026-09-27 14:23:51 -07:00
gramps e78c58fc28 docs: bank the Windows Store + signing research, and fix the dead EV-certificate line
The distribution answer existed only in conversation, so every session re-derived
it. It is now in the map, and the route decision is explicitly parked as the
creator's.

new TASKS/research-store-certification.md — Store Policies 7.20 + MSIX packaging:
which policies bind, which don't (and why), cert economics, camera/mic gating
layers, YouTube age + COPPA, the 11.12 UGC judgment call.

Two real defects surfaced, neither fixed (docs-only unit):
- FfmpegLocator downloads an unsigned exe from GitHub and runs it. That is
  policy 10.2.2 (dynamic code inclusion) verbatim, and it is the root cause of
  the 2026-09-01 404 — the pin aged out of BtbN's 14-day retention on the
  creator's first real recording attempt. -> TASK 48 item 1, not
  Store-conditional.
- Distribution.md:318 recommended a $400+/yr EV cert for a SmartScreen bypass
  Microsoft removed in March 2024. Fixed; had it shipped it would have cost
  $400+/yr to buy what $150 buys.

Also new: TASKS/task-48 (checklist, carved out of TASK 36 item 6) and
TASKS/task-49 (chat profanity filter, not blocked). ai.md gains the durable
invariants — full trust or recording breaks silently, chat is rendered never
stored — plus a correction to the FFmpeg locator section. MyMistakes.md records
the lesson: a policy citation is a claim about scope, not just text.

MARCOM.md got the privacy-copy guard but is gitignored by design, so that edit
stays local and did not travel here.
2026-09-27 14:17:13 -07:00
gramps 938c5b3de4 alert ticker: draw it inside the Stream Alerts box, not as a top-edge bar
Creator 2026-09-26: "the ticker should appear over the stream alerts video, not over
the entire preview window". It was a GLOBAL 1920x48 bar blitted last at a hardcoded
(0, 0) -- it covered the whole preview width, not the alert video.

The strip is now rendered at the alert box's own size and the box's origin travels on
the frame in a new VideoFrame.Placement ((int X, int Y)?). OriginX/OriginY default to
0 when Placement is null, so every full-canvas overlay (the branding flash) is
unaffected. Every ticker blit site now reads tickerFrame.OriginX/OriginY instead of a
literal 0, 0 -- SceneCompositor x2 and FramePump's static-bake Overlay path -- and
PreviewPane.xaml's AlertTickerElement binds the same rect (AlertTickerLeft/Top/
Width/Height), so the preview cannot drift from the recording the creator is judging.

Why the position rides on the frame rather than in a full-canvas frame: a 1920x1080
overlay is 8.3MB of large-object-heap garbage per tick, ~2.5GB churned over one 10s
alert at 30fps. A box-sized strip is ~370KB. The branding flash does render
full-canvas but RECYCLES one 8MB buffer and bumps Epoch, so it never allocates per
frame; the ticker allocates per call, so it has to stay small.

Also fixed: CopyStrip now clips ROWS to the target height. The pill rasterises at its
natural 48px, so an alert box shorter than that would have written past the end of the
target buffer once the strip became box-sized.

No alert box in the scene now means no ticker at all -- there is no global position
left for it, and this is the guard against the old bar quietly coming back.

Tests (3 new facts, 27 in the file):
  ComposedOutput_PutsTheTickerInsideTheAlertBox_NotAtTheTopEdge -- renders through
    the real SceneCompositor and asserts the pixels land at (620, 430) and NOT at the
    top-left corner. The creator is judging compositing from local recordings, so the
    OUTPUT side is the side that needed proving.
  ASceneWithNoAlertBox_PublishesNoTicker
  ABoxShorterThanTheStrip_ClipsRowsInsteadOfOverrunning
Updated AlertLayer_PublishesARealTickerFrameToThePreviewSink, which asserted the old
1920 width from a box with no geometry (defaulted to 1px); it now uses the product's
own default box (680x200 at 620,430, MainViewModel.Sources.cs:54-57) and pins the
frame size and origin.

Full suite 367/367.
2026-09-27 09:43:26 -07:00
gramps 9761b1d4be branding credit: run in every scene and in recordings, not only while live
Creator 2026-09-26: "the made with llamacasty flash should appear in all scenes,
not just live" and "should also appear in recordings". The presenter was
Start()/Stop()-ed from UpdateLiveVisuals()'s IsLive branch, so a recording made
WITHOUT ever going live carried no credit at all -- the exact case the creator hit
while judging compositing from local recordings.

It is now Start()ed once in the MainViewModel ctor and never stopped on live-state
churn. One start covers every scene, the preview, the stream and the recording,
because go-live and local recording are the SAME FramePump: both
Streaming.Operations.cs:68 and :199 call StartAsync with the same brandFlash:
delegate. Verified by reading both call sites, not assumed -- there is no second
encoder path that needed a "redirect". The licence gate needs no live branch at
all: IsPremium's setter already pushes BrandFlashPresenter.Enabled from anywhere.

Fixed a trap that app-lifetime exposed: Enabled = false stops the presenter's
DispatcherTimer to cut the advertisement mid-credit. When Start() was per-go-live
the next go-live restarted it; with a single app-lifetime Start() nothing would,
so a key entered mid-session would leave the credit dead until the process was
restarted. The setter now restarts the timer when re-enabling while _running.

Tests (12 facts in BrandFlashOutputTests, +2):
  Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt -- drives a real
    never-live VM past the 5s first-flash delay and asks the frame the pump would
    composite. Uses a new internal AdvanceBrandFlash seam; because Advance only
    advances the cadence while the presenter is running, a credit coming out
    proves Start() happened at construction.
  ADowngradeMidSession_RestartsTheCadenceTimer -- asserts the premium/downgrade
    edge decision directly (IsCadenceTimerEnabled) instead of sleeping through a
    30-60s interval, which a synchronous test body cannot observe.

Full suite 364/364.
2026-09-27 09:33:30 -07:00
gramps e3e69d941d recording save dialog: Cancel discards the footage instead of saving the default
The creator reported that clicking Cancel in the post-recording save prompt still
saved the file under the default name: "cancel should cancel the save option,
discarding the recording" — Enter is what accepts the default.

The modal was always correct (Enter -> DialogResult=true, Cancel/Escape/X -> false).
The bug was in the caller: FinalizeRecordingAsync only overwrote `stem` when the
dialog returned true and then ran File.Move UNCONDITIONALLY, so a falsy result fell
straight through into the save. The nullable stem made "I don't want this" look
like "I accept the default".

Extracted the decision into internal MainViewModel.CompleteRecordingSave so it is
testable against real files without a frame pump:
  creatorSaved == false (Cancel/Escape/X) -> delete the temp file, leave the
      videos folder empty; a failed delete returns DiscardFailed and the toast
      names the file + folder (a "discarded" recording still on disk is worse
      than no report).
  creatorSaved == true (Save, or Enter on the pre-filled default) -> File.Move.
      A blank box still means "keep the auto name", but only on an explicit Save.

Test: ytLive.Tests/RecordingSaveDialogTests.cs — 5 facts over real temp files. The
headline asserts Cancel leaves the directory EMPTY, not merely "the stem is
unchanged"; a modal's own test cannot cover the decision it feeds.

Full suite 362/362 (the RealMouseDrag flake passed this run).
2026-09-27 09:25:21 -07:00
gramps de81fa3c5d dev-only: per-instance state so two LlamaCasty instances can run side by side
Pre-1.0 the creator streams in one instance and screen-captures it from
another. Nothing prevented a second instance - there is no single-instance
mutex and no port anywhere. What broke it was SHARED STATE, and two of the
collisions were hard failures rather than annoyances:

- the layout DB. The model is read-whole-scene / write-whole-scene, so two
  instances saving different layouts clobber each other.
- the WebView2 user data folder. Chromium takes an exclusive lock on it, so
  the second instance of the same exe does not start at all.
- the auth token store. A test instance would overwrite the real YouTube
  sign-in with its own.
- startup.log, where two appenders interleave and a crash in either instance
  becomes ambiguous.

Set YTLIVE_INSTANCE=<id> and the process gets a private root at
%APPDATA%\ytLlive\instances/<id>/ for those four. Recording folder and the
ffmpeg tools cache stay shared on purpose - the cache should be shared, and
the creator picks the record folder. Global hotkeys stay un-namespaced: if
both instances register the same one, Windows refusing the second is the
correct answer.

An id that is not letters/digits/dash/underscore is rejected and degrades to
the primary profile, so the variable can never walk out of the profile
directory or name a UNC path.

The entire implementation is inside #if DEBUG. A Release build compiles to
DataRoot => DefaultRoot and WebViewDataFolder => null, and the call sites are
unconditional so Release cannot drift by forgetting an #if. The harness lives
with the tests: InstanceIsolationTests covers the two-identities contract,
the primary-instance no-op, per-instance WebView folders, path-traversal
rejection, that the real output paths actually move with the profile, and a
source-level assertion that the #else arm IS production behaviour.

Verified against a real Release build: the InstanceVariable field is absent
from its metadata and no "instances" path segment survives, while DataRoot
and WebViewDataFolder are present in both configurations. Grepping for
YTLIVE_INSTANCE proves nothing - a const is inlined at compile time and
appears in neither build, which cost one wasted verification round.

Docs for this unit (the InstanceProfile paragraph in ai.md, the 1.0 gate in
TASKS.md, and the MyMistakes/HANDOFF entries) landed in the previous commit,
because they share those files with the branding-credit work.
2026-09-27 08:56:12 -07:00
gramps f5a9d46881 TASK 36: composite the branding credit into the output, not just the preview
The credit existed only as a WPF BrandFlashLayer TextBlock in the preview at
25% opacity on a 300s timer. A viewer of the stream or the recording never saw
it, so "free tier shows branding" was not actually being delivered. The frame
pump has carried an unused per-frame flashFrame slot for exactly this.

Reverses the documented "Pre-GA posture" (ai.md Monetization), which kept the
flash preview-only so test VODs stayed clean. Creator ruling 2026-09-26: the
free tier has to be honest advertising, so it now reaches the broadcast.

One rendered VideoFrame feeds BOTH the frame pump and the preview, so the
creator's view cannot drift from what viewers get. Spec: 500ms in / 1000ms hold
/ 500ms out, first credit ~5s after go-live then every rand(30s)+30s, single
unwrapped line at a random spot inside the frame every time, neon white core
with a feathered red/blue halo.

Neon recipe is derivative work, per AGENTS.md: bright core + feathered
multi-radius halo with R and B split in opposite directions, from
https://nudaui.dev/components/neon-glow (layered text-shadow falloff),
https://help.maxon.net/rg/en-us/Content/html/Blurs-and-Glows-chromatic-glow.html
("with no displacement, the green channel is all but invisible behind your
white text" once R and B are split) and the OBS obs-stroke-glow-shadow
"feathered stroke with user-defined size and intensity". Neon blue is #4d8bff
rather than the theme's #0f3460, which renders near-black as a halo.

Also fixes a PRE-EXISTING bug found on the way: FramePump's fully-static
shortcut stretched the cached bake and returned it, silently discarding every
per-frame overlay - the social bar and the alert ticker were already lost
there. SceneCompositor.Overlay is now internal (it copies before blitting, so
the bake is never mutated) and the shortcut composites overlays first.

The credit is deliberately NOT folded into SceneGraph.GetBakedBase:
SceneRegion.Matches compares element ids only, so a credit in the cached bake
would freeze there and never expire. Per-frame only, and Epoch is stamped
every read because the 8MB master buffer is recycled - without that the
paste cache would freeze a stale credit.

BrandFlashEnabled is now derived !IsPremium and non-assignable, and the
presenter re-checks the licence on every read, so a key entered mid-credit
cuts the advertisement on the next tick instead of letting it finish.

Tests: 10 new facts. 357 total, 356 pass; the one failure is the known
environment-flaky RealMouseDrag layer test (needs an uncovered desktop
session). Adds RealAppHost.RunAsync - a frame-pump test must await inside the
collection's shared STA thread or the whole suite deadlocks silently.

NOTE: scope-check.sh flags Helpers/InstanceProfile.cs, AppLog.cs,
TokenStore.cs, WebView2Manager.cs and InstanceIsolationTests.cs as outside
this commit. That is a false positive: it uses `git diff HEAD`, which cannot
distinguish a planned second commit in the same session from an unrelated
edit. Those files are the dev multi-instance unit, committed immediately
after this one - as is the InstanceProfile paragraph in ai.md, which shares a
file with the Monetization section corrected here.
2026-09-27 08:55:55 -07:00
gramps 5aedca7305 ai.md + TASKS: record the LlamaCasty/ytLive naming split and the shipped-exe mismatch
Creator correction 2026-09-26: the product is LlamaCasty; ytLive is only the internal
repo/assembly/namespace name. Records the exact split (incl. the capital-L ytLlive app
data dir), the Distribution.md LlamaCasty.exe vs real ytLive.exe mismatch, and the three
pack URIs that hardcode the assembly name -- MainWindow.xaml:14 is the window icon, so a
naive AssemblyName rename ships a broken taskbar icon. Notes the app-data rename is
breaking and that no code compares its own assembly name.
2026-09-27 07:55:35 -07:00
gramps fae8ec5f32 TASKS: queue the shipping/release build (publish.sh + Debug-only InternalsVisibleTo)
Creator-queued 2026-09-26. Records that the csproj has no publish config, so publish
is currently framework-dependent while Distribution.md targets a self-contained
~80-120MB upload to Polar; and flags that InternalsVisibleTo(ytLive.Tests) ships in
Release. Notes that the compile side already excludes the tests (separate project,
one-way reference, explicit Compile Remove) so the real risk is packaging from
ytLive.Tests/bin, which contains a ytLive.exe next to the test DLLs.
2026-09-27 07:53:52 -07:00
gramps 7f14ffb11e TASK 47 take four: alert ticker visible in the preview + 3 display methods
The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).

- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
  is always the UI thread; MainViewModel.AlertTicker writes it into one reused
  WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
  Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
  showed the text once, entering from the right and never crossing. Paced in reads per
  alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
  from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
  alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
  Duration: choose how long your alert stays on your stream" and "Text Delay", never a
  scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
  Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
  table_info migration, appended LAST in the SELECT because the Source reader is
  positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.

Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.

Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
2026-09-26 15:10:26 -07:00
gramps e2ecc248e0 TASK 47: draw the alert clip — the preview Image was Collapsed for AlertBox
Four builds (89/90 + two) burned proving the alert video was PERFECT: real h264
1280x720, 240 frames decoded, alert audio in the live mix, and the shipped asset
byte-identical (md5 0ee1f496…) to the creator's llamacasty-dancingLlama-thankyou.mp4
with every sampled frame full bright content. Build 90's new diagnostics then showed
the frame reaching BOTH consumers every second for the whole clip —
`Alert preview: frame=680x200 a255` and `Output resolver: frame 680x200
playing=True` — an opaque, correctly-sized frame, handed over and never seen.

Root cause: Controls/PreviewPane.xaml keeps the per-element Image Collapsed unless a
DataTrigger fires, and there was no IsAlertBox trigger (only IsImageSource / IsChatBox /
IsWebSource / IsWebcam). The alert box's Image was therefore Collapsed forever. Chat
boxes rendered because they HAVE a trigger — that asymmetry is the whole clue, and it
is why removing/re-adding the layer could never fix it.

Fix: an IsAlertBox DataTrigger beside the IsChatBox one.

Two Good Dog tests, because the existing fakes had been hiding this:
- AlertBoxPreviewVisibilityTests (red `Expected: Visible / Actual: Collapsed`, now
  green) drives the real MainWindow + PreviewPane and asserts the bound Image is
  Visible — the first alert test that crosses the XAML at all.
- AlertClipOutputTests is the first test in the repo to run a REAL codec: pinned ffmpeg
  generates a clip, the real AlertClipDecoder + AlertOverlayLayer + SceneCompositor
  composite it, and the box rect must be a colourful picture that DIFFERS from idle. It
  passed while the feature was broken in the app — which is exactly why it was needed:
  it exonerated decode+layer+compositor and pointed the hunt at the last hop.

Also keeps the two once-per-second alert diagnostics (resolver + preview) that settled
it, pending the creator's call on whether to keep them.

Notes: the pinned BtbN ffmpeg has no libx264, so the test clip is -c:v mpeg4.
SceneCompositor.Render fills its base with opaque black, so the test compares against an
idle render rather than counting non-zero bytes. MediaSource has the same missing trigger
in PreviewPane.xaml — left unfixed as out of scope, noted in HANDOFF.

Full suite 324/324. Lesson recorded in MyMistakes.md rule (5): a producer that hands over
a correct frame has still delivered nothing — when output arrives and the user still sees
nothing, stop auditing the producer and audit the consumer's VISIBILITY.
2026-09-26 14:44:57 -07:00
gramps aea0670723 fix(alerts): wire the frame-rate probe so alert-clip VIDEO paces to real time
The 12:35 live session proved the decoder healthy (frames=240 audioChunks=156
failed=False both clips, alert audio in the mix at peakMix 0.277→0.733) yet the
creator still saw 'no video plays / you lost the video'. Root cause: pacing,
not decode. AlertClipDecoderFor built the decoder with no frameRateProbe, so
RunAsync computed frameDuration = TimeSpan.Zero and RunVideoAsync's pace step
was dead code — all 240 frames of the 10s clip dumped through the pipe in the
first ~1-2s (130MB as fast as ffmpeg read), then the box froze on the LAST
frame while audio chunk-paced its real-time ~7.8s. Reads exactly like a dead
decoder on screen. The media path already wired this seam
(MainViewModel.cs:308); the alert factory never supplied one.

Derivative fix (media/decoder plumbing, cited in broad consensus of players):
pass FfmpegFrameRateProbe into the per-play alert decoder exactly like
MediaVideoSource does. Good Dog:
AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate —
real AlertClipDecoder via the fake-process/fake-probe/delay-recorder seam;
red on the old factory (zero pacing delays), green with the probe (one ~10ms
delay per frame at 100fps). Full suite 321/322, one known-env flake
(RealMouseDrag reorder). Docs in this commit: task-47 addendum,
MyMistakes.md (a decoder that drops data faster than wall-clock looks
identical to a dead one), HANDOFF rewrite.
2026-09-26 12:45:40 -07:00
gramps 80038ff152 fix(alerts): silent decoder falls back to the six animations after a 1s no-frame grace
Routing and ruling both good: 'procs in the chat windows but nothing in the alert box'
with zero exceptions — every failure stage was silent by design (Debug.WriteLine-only
preview catch, bare catch{} in RunAsync firing Completed regardless). A decode run that
starts but yields neither a frame nor an audio chunk left _clip != null with
_latestClipFrame == null, so RenderClipFrame returned null forever: the box stayed
transparent for the whole alert and the six-animation fallback (which only ran on
setup-time throws) never fired.

Fix: AlertOverlayLayer.Advance gives a started clip a NoFrameFallbackSeconds (1.0s)
grace — produce no frame AND no audio inside it and the clip is torn down, _elapsed
resets, and the SAME alert continues as the animation branch (never blank, never drained
early). Diagnostics stop the lying silence: BeginClip logs box id/path/size + setup
failures; AlertClipDecoder.RunAsync logs frame/chunk end-state and the exception it used
to swallow.

Good Dog test: SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace (transparent
inside grace, disposed past it, animation renders, drains to idle).

Reference: alert playback must degrade to its fallback on dead-air, never vanish —
same contract as every player/booth failure budget (e.g. OBS source fallbacks).
https://obsproject.com/forum/threads/source-visibility-fallback.187383/
2026-09-26 12:08:27 -07:00
gramps 7b940b6a5a fix(alerts): AlertOverlayLayer marshals the chat-poller seam to the UI thread
Live test session proved the native alert box DID play (the alert ring was the
only source in the mix: micLevel/loopLevel 0.000 while peakMix went
0.375->0.733->0.891 after the sim injections) but the app crashed at
11:22:01.301 the moment a REAL message round-tripped through the poller:

  System.ArgumentException: Must create DependencySource on same Thread as
  the DependencyObject
  at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()

OnMessageReceived ran RefreshAlertPreviews on the MTA poller thread and
stamped the WPF-bound VideoImageSource with a WriteableBitmap created there;
the binding engine's cross-thread re-bind killed the process. ChatOverlayLayer
already marshals this exact seam (Dispatcher.Invoke) - mirror it, guarded for
Application.Current null so the pure test seams still run inline.

Good Dog test:
AlertLayerVideoTests.OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread
calls the seam from a raw MTA Thread while the RealApp loop runs, then asserts
on the UI thread that the preview bitmap's Dispatcher is the App's (red on the
old seam, green on the fix). WPF cross-thread rule recorded in MyMistakes.md.

Full suite 320/320, clean build 0 warnings, scope-check green.
2026-09-26 11:28:36 -07:00
gramps a11b15e444 feat(alerts): TASK 47 — alert box plays a video (built-in/custom clip) + read-time fade + message ticker
TASK 43's alert box grows a real video celebration. Per-alert IAlertClipDecoder
(ffmpeg bgra + f32le pipes, real-time paced, disposed at drain) plays the shipped
Assets/alert-default.mp4 (stamped into the Asset table at startup) unless the
creator picks their own file — path reference only, never stored in the DB; the
six AlertRenderer animations stay the fallback. ~0.3s fade rides the alpha
envelope on straight-source copies (EOF freeze-frames then fades out); audio
forwards to a new AudioMixer alert ring (8s, 48k stereo) drained at unity — no
duck, creator ruling — scaled by volume × fade. An auto-composed marquee ticker
('Funder — Super Chat · $10.00', 140px/s) scrolls top-of-frame via a
FramePump._alertTicker seam through Render/CompositeLayers, mixed into the cache
signature (dynamic overlay, never baked). New Stream Alerts section in LeftPanel.

Derivative-work references (how OBS/Streamlabs alert boxes do per-alert video):
- https://support.streamlabs.com/hc/en-us/articles/217741147-Setting-Up-Your-Streamlabs-Alerts (custom image/video per alert type + variations)
- https://obsproject.com/kb/stream-tutorial-2-alerts (alert overlay as an on-screen zone)
- https://streamlabs.com/content-hub/widgets/alert-box (per-event alert playback)

Good Dog: AlertLayerVideoTests drives a fake IAlertClipDecoder through the whole
lifecycle in one pass (custom path wins, decoder spawns/disposes, fade envelope
0→127→255, audio volume×fade, ticker scrolls, EOF fade-drain to idle). It caught
the clip branch of Advance not clearing _current before AdvanceToNext — the layer
stayed IsPlaying after drain (MyMistakes post-mortem).

Full vstest 319/319; clean build 0 warnings; scope check green.
2026-09-26 11:15:17 -07:00
gramps 2c9af9426b docs(handoff): TASK 46 verified + pushed — origin/main now at ecb329e 2026-09-25 08:20:11 -07:00
gramps ecb329e578 fix(ui): drawers close on any click outside the rail — TEST was the missing third
Creator: 'when the test slide-out is active, then any click off the div should
close the div — this behaviour applies to all tabs, not just TEST.'

MainWindow.Window_PreviewMouseLeftButtonDown already closed the Stream Settings
and YPP drawers on any click outside TextPullOutHost, but its close list skipped
TestSession — so the TEST drawer never dismissed on an outside click.

Fix: run TestSession.CloseDrawerCommand in the same outside-click branch.
All three drawers share the rail host, so the containment check is unchanged.

Good Dog: ONE integration test — the existing TestStream window section now
raises a window-root PreviewMouseLeftButtonDown (source = window => outside the
rail; deterministic, no OS mouse) and asserts the TEST and Stream Settings
drawers both close. Gate: clean build 0 warnings, full vstest 318/318.
2026-09-25 08:18:25 -07:00
gramps 0981a72eea fix(stream): chat insert 400 — insert body must declare snippet.type
The liveChatId fix (TASK 44) worked on the next Test Stream, but every Mock
Chat Input send returned 'YouTube rejected the message (error 400)'. The
runtime log's body: 400 MISSING_REQUIRED_FIELD, domain
youtube.api.v3.LiveChatMessageInsertResponse.Error.

The liveChat/messages.insert snippet requires type ('textMessageEvent' or
'pollEvent') alongside liveChatId and textMessageDetails.messageText; the
TASK 41 body omitted it, and the Good Dog test asserted only liveChatId +
messageText were present — false-green while real YouTube rejected every
send. Fix body + assert type in the same test so the field can never drop
silently again.

Reference: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/insert

Good Dog: ONE integration test (strengthened TestStream_DockTooling...).
Gate: clean build 0 warnings; full vstest 317/318 (the 1 failure is the
known environmental RealMouseDrag flake — passes 3/3 in isolation).
2026-09-25 08:12:05 -07:00
gramps 94f015044a fix(stream): TEST-tab chat — resolve liveChatId from snippet, poll until the broadcast is live
The open creator report ('I still cannot post a chat message in the TEST tab',
feedback 'Chat polling couldn't start — Mock Chat Input is disabled') was a single
cause: the liveChatId never resolved. Two YouTube API facts:
1. The id lives in snippet.liveChatId — contentDetails has no such property
   (TASK 44 read part=contentDetails: could never resolve).
   https://developers.google.com/youtube/v3/live/docs/liveBroadcasts
2. It only exists once the broadcast is LIVE — the official sample lists
   broadcastStatus=active, and our fetch ran before the frame pump pushed RTMP
   (enableAutoStart flips ready→live). A ready-state list legitimately returns
   no id.
   https://github.com/youtube/api-samples/blob/master/java/src/main/java/com/google/api/services/samples/youtube/cmdline/live/GetLiveChatId.java

Fix: GetBroadcastLiveChatIdAsync reads part=snippet and polls with a bounded
retry (10x/2s); PrepareAndStartLiveAsync starts the frame pump FIRST, then
resolves the id. Chat stays non-fatal. Docs ai.md/TASKS.md/HANDOFF.md +
MyMistakes recipe updated same commit. TASK 44.

Good Dog: ONE integration test (GetBroadcastLiveChatIdAsync_Polls_Snippet_...
) drives a broadcast that gains its id mid-retry and asserts every request used
part=snippet. Gate: clean build 0 warnings, vstest 318/318.
2026-09-25 08:06:02 -07:00
gramps d4aa588da0 feat(alerts): TASK 43 — native events & alerts (six unique animations) + chat parity
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
2026-09-24 08:27:00 -07:00
gramps 3a4e17c735 Account zone is world-independent: avatar stays in Record world
Creator on the live build: 'why do you make the youtube user icon go away?'
The TASK 42 one-surface bar had scoped the account zone to Stream only
('Record world has no YouTube identity at all'), so arming REC dropped the
avatar and the bar height bounced with it ('really fucking annoying').

Fix: ShowSignInButton / ShowAvatarButton are now plain !IsConnected /
IsConnected — app-level identity, present in both worlds, so the right
column never pops in/out and the bar height stays pinned. Test remains a
child of Stream (still stream-armed AND signed-in).

TopBarModeTests updated to the new contract: signed-out Record world shows
Sign In; flipping back to REC keeps the avatar. PillRadioTests untouched
(it only exercises the ON-AIR face). Docs corrected in-place (ai.md state
model, TASK 42 scope). Gate: clean build 0-warnings, 316/316, scope passed.
2026-09-23 16:53:25 -07:00
gramps cb750660c3 fix(ypp): refresh 403'd on every real call — drop the auditDetails part (needs a partner scope)
Creator: 'when I attempt to refresh my YPP page, I get an error about not being
able to reach YouTube. Seriously?' Real log: channels.list failed (403) x3.

Root cause #1 (the 403): channels.list?mine=true&part=statistics,auditDetails,
contentDetails returns 403 insufficientPermissions when the token lacks the
youtubepartner-channel-audit scope — which the auditDetails part ALONE requires,
per the docs ('A request that retrieves the auditDetails part ... must provide an
authorization token that contains the youtubepartner-channel-audit scope'). That
scope is MCN partner tooling with a 2-week token-revocation rule; the app must not
hold it. TASK-39's 'current scopes suffice, no re-consent' slice-1 claim was wrong
for this part; mock-fake tests never touched the real API, so it shipped green and
403'd every refresh since 2026-09-22.
https://developers.google.com/youtube/v3/docs/channels/list

Fix: part=statistics,contentDetails only; standing flags removed from
ChannelStatsService -> YppStatSnapshot surface -> YppTrackerViewModel -> drawer,
replaced by an honest deep-link row ('Channel standing isn't exposed to YouTube
apps — check the Earn page'). YppSnapshot standing columns stay (schema-stable,
always false). channels.list failures now log the response BODY — the bare code
could not name insufficientPermissions, which is what made this undiagnosable.

Root cause #2 (found by the new Good Dog, masked by the 403): statistics come back
as JSON STRINGS ('350'); raw GetInt64() throws. Tolerant ReadInt64 (ValueKind-first;
JsonElement.TryGetInt64 THROWS on strings — type-in, not try-type).

Good Dog: ChannelStatsServiceTests.CaptureCurrent_RequestsNoAuditDetails_AndStillParsesTheSnapshot
(URL asserts no auditDetails + snapshot parses); YppPullOutTests fixture updated.
Recipes for both 403/scope and statistics-strings entered in MyMistakes.md.

Also shipped in the same commit (shared PreviewPane.xaml + ai.md): the audio-sync
status dot removal from the #77 feedback round (creator: 'what is the point of the
status light? Lose it') — IntToSyncBrushConverter deleted with it.

verify.sh gate: 0 warnings, 316/316 pass, scope-check clean.
2026-09-23 16:45:56 -07:00
gramps d72949e2f9 style(topbar): build-77 feedback — big account avatar (26→40), gear nudged right, 32px action cluster
Creator feedback on the live #77 build:
1. Avatar = 'much bigger, showing the youtube login account' — the streaming
   account's face went 26→40 px (CornerRadius 20, 20pt fallback initial) so the
   identity is readable across the room.
2. Gear moved a couple more places right (margin 6→18) — cleaner separation from
   the wordmark after the TASK 42 relocation.
3. Uniform height: the REC|ON-AIR segments host, Start/Go Live, and Test now all
   sit at Height=32 so the switch + its actions read as one family ('so the user
   can intuit their purpose'). Running-world reality line untouched.

XAML-only; compile-verified (only the running-app exe-lock copy errors remain).
verify.sh full gate deferred until the app is closed (see HANDOFF).
2026-09-23 16:38:20 -07:00
gramps ac6e67aff0 fix(stream): end close-out pre-flights lifeCycleStatus — no more 403 invalidTransition noise on every stop
startup.log 2026-09-22 (user runs of the new build): ALL THREE session stops
logged 'Broadcast transition(complete) failed (403) invalidTransition → End
close-out: enableAutoStop will finish'. The old ai.md assumption ('invalidTransition
when autoStop already fired') was wrong — the blind complete POST races
YouTube/autoStop marking the broadcast complete (enableAutoStop=true is always
set, tests included), so a blind POST earned only the 403 + log noise. Per the
transition errors table, invalidTransition is a current-status problem and
complete is not gated on streamStatus (only testing/live are):
https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition

Fix: EndBroadcastAsync pre-flights liveBroadcasts.list -> status.lifeCycleStatus
and skips the POST when the broadcast is already complete/revoked; an inconclusive
pre-check still posts (old behavior) rather than silently stranding a live
broadcast; still never throws (enableAutoStop finishes every skip).

Good Dog: EndBroadcast test rewritten to the one contract — live -> GET pre-check
+ POST transition (URL shape asserted); already complete -> zero transition POSTs;
inconclusive pre-check + 403 POST -> error string, never a throw. Full suite
315/315 (one-run audio/mouse timing flakes cleared), build 0 warnings.

Recipe recorded in MyMistakes.md; ai.md / Services index / TASK-9 note corrected.
2026-09-23 08:28:59 -07:00
gramps 18ab553b0e fix(stream): health poll crashed on every start — parse the real healthStatus object shape
startup.log 2026-09-22 (via the user's run of the new build): every session
start logged 'Stream health poll failed: The requested operation requires an
element of type String, but the target element has type Object'.

Root cause: GetStreamHealthAsync did status.healthStatus.GetString(), but the
real API nests it as status.healthStatus = {status, lastUpdateTimeSeconds,
configurationIssues[]} — an OBJECT, per the liveStreams reference
https://developers.google.com/youtube/v3/live/docs/liveStreams (checked the
docs before fixing, per derivative-work rule). Our flat-string fixture was the
wrong assumption since TASK 9; the report-by-exception health banner had been
silently dead.

Fix: read the nested healthStatus.status + nested configurationIssues[], tolerating
the legacy flat-string shape so nothing else breaks. Tests flipped to the real
object shape (the Good Dog for this change). Full suite 315/315, build 0 warnings.

Also recorded in MyMistakes.md (API-shape recipe) and ai.md/Services index. End-of-session
transition(complete) 403 invalidTransition (enableAutoStop fallback) observed on all three
2026-09-22 stops — separate racy-design issue, carried, not in this change.
2026-09-22 19:25:02 -07:00
gramps e69db4d26c feat(ui): TASK 42 top bar redesign — one Record-or-Stream surface (2026-09-22)
The bar now renders ONE surface from the FIRST decision the creator makes —
Record or Stream — with each world being the whole bar. Creator directive:
"I wrote the fucking thing and I still can't figure-out how to do stuff",
"forget this one dog plan bullshit". OBS/streaming-tool reference for the
one-surface paradigm: https://obsproject.com (single mode toggle + context
actions) — cited per spin-guard habit; the go-live/test pattern follows
CEV (https://cev-desktop.aSean.xyz) precedent.

- Mode: single segmented REC|ON-AIR switch (SegmentToggle/SegmentLabel in
  Themes/Controls.xaml); radio-exclusive world selection, one tap to flip.
- Record world: switch + Start Recording, zero YouTube identity.
- Stream world: switch + Go Live + Test + account zone right (Sign In until
  connected, then avatar with Change Account/Logout context menu).
- Test is a child of Stream: procs only stream-armed AND signed-in.
- Running: one reality line "dot word elapsed" (REC/LIVE/TEST; green/red/
  gold) + End; worlds + switch retire.
- Gear moved up from bottom bar, ~3 wordmark letters past the brand, single
  click opens Settings/Bug/Feature/About menu (TASK 40 Unit B shipped early).
- Fix folded in: BeginTestStream now arms OnAirPillOn explicitly (unarmed
  pump booted with zero encoder outputs -> "At least one output is required"
  forced-stop cascade from TASK 41's pipeline).
- VM: world/reality props + RaiseTopBarModes() wired into StreamStatus,
  pill, IsRecording, IsTestStream, IsConnected setters.

Doc: ai.md top-bar model, ViewModels/index.md, Controls/index.md, TASKS.md,
HANDOFF.md, new TASKS/task-42-top-bar-redesign.md; task-40 note.

Tests: TopBarModeTests.cs (new Good Dog), PillRadioTests + TestStreamTests
gates updated. Build 0 warnings; full suite 314/315 — single abort is the
pre-existing env-dependent RealMouseDrag test (Path of Exile 2 running)
2026-09-22 17:57:06 -07:00
gramps bb5dcb4ba2 feat(stream): TASK 41 Test Stream mode — private test broadcast + TEST drawer
Test button next to Start runs the real private-only go-live pipeline as a
session variant (IsTestStream): BeginTestStream -> extracted
StartStreamingSession(alsoRecord:false) shared with the dialog path — no Go
Live dialog, no 'last live' stamp, no recording. Gold top bar + TEST badge +
'End Test' button face; TEST drawer (third right-rail pull-out, three-way
exclusivity) auto-opens once the liveChatId resolves.

Chat tooling: Mock Chat Input sends a REAL liveChat/messages.insert
(YouTubeStreamService.InsertChatMessageAsync) that round-trips the real ~2s
poll and renders through the live overlay; simulated Subscriber/New
Member/Super Chat events inject through the poller's MessageReceived seam
(YouTubeChatService.InjectSimulatedMessage, ChatMessage.IsSimulated) — the
insert API only creates textMessageEvent, so non-text events are local-only
by design (ref: developers.google.com/youtube/v3/live/docs/liveChatMessages/insert).
Scopes youtube + youtube.force-ssl already cover insert; no re-consent.

Good Dog: ytLive.Tests/TestStreamTests.cs — coordinator protocol with fake
HTTP + real-window drawer exclusivity/gate. 0 warnings; 314/314 pass (clean
run; intermittent host abort is the pre-existing WinRT-webcam flake).

Docs ride in the same change: TASKS.md row 41, TASKS/task-41-test-stream-mode.md,
ai.md (YouTube Live API constraints), HANDOFF rewrite.
2026-09-22 09:31:00 -07:00
gramps 57841d39ac docs(handoff): TASK 40 plan saved + in flight; next step = new task spec 2026-09-22 08:37:36 -07:00
gramps 4d9188ab2e docs(plan): TASK 40 App Settings round — saved plan (units A-D, one Good Dog test each) 2026-09-22 08:37:05 -07:00
gramps 197ee815a0 docs(handoff): TASK 39 slice 1 accepted + pushed (a9d5f29), clean tree 2026-09-22 08:14:57 -07:00
gramps a9d5f29084 feat(ypp): YPP journey tracker slice 1 — the YPP pull-out below Stream Settings (TASK 39, Good Dog ONE test)
Current-OAuth-scope data, no re-consent: subscriber bars for both tiers (500 fan-funding /
1,000 ad-revenue; the 2027-02-01 doubling is versioned date-aware data in YppThresholds),
3-uploads/90d, live standing from channels.list auditDetails, self-reported 2FA/AdSense
checkboxes + deep links (Google 2-Step / youtube.com/earn), and the "what counts" education.
Every refresh appends a YppSnapshot (SQLite v10) so the analytics slice has history from day one.

- ChannelStatsService (channels.list statistics,auditDetails,contentDetails + playlistItems.list,
  virtual CaptureCurrentAsync = test seam); YppTrackerViewModel mirrors LiveBroadcastFormViewModel.
- MainViewModel.Ypp.cs: Ypp property, ChannelStatsFactoryOverride seam, one-open-at-a-time drawer
  exclusivity (both directions); account sign-in/out/restore hooks in Account.cs.
- PreviewPane.xaml: TextPullOutHost is now a drawer bank [broadcast][ypp][stacked white tabs];
  outside-click collapse covers both drawers. MainWindow.xaml.cs updated.
- Wait - watchers: the whole feature already reviewed against YouTube's own docs (YPP Earn tab +
  API availability) before building — see TASKS/task-39-ypp-journey-tracker.md.
- ONE integration test: YppPullOutTests (snapshot capture/persist + checklist round-trip +
  drawer exclusivity). 313/313 pass, 0 warnings.
- Memory in same commit: TASKS.md row, ai.md Journey-tracker bullet (roadmap→reality), HANDOFF.md.

Slice 2 (Analytics yt-analytics.readonly re-consent → watch-hours/Shorts + velocity/ETA +
sparkline) is queued behind this, not merged.
2026-09-22 08:07:54 -07:00
gramps 4e0915a36e feat(backdrop): Capture Window… in-app window pin (TASK 38, Good Dog ONE test)
Click-to-pin an open window as the full-bleed Live backdrop, via the existing
window:<hwnd> capture path (same render layer as game/desktop). Session-scoped:
window:/picker: keys heal to auto on reload (HWND-recycle hazard); pin wins
while the window enumerates, then auto-fallbacks (game → desktop → static); a
dead window's capture session heals to auto, no dead ends.

Fix: ScreenCaptureSourceFactory.Resolve hex-parsed "window:0x<Hwnd>" WITHOUT
stripping the 0x prefix — NumberStyles.HexNumber rejects it, so every pin
resolved null ("No capture target for this key") and the heal silently rolled
back. Same flaw in IsAliveWindowPin (pin-wins guard was dead). Both fixed.

Commit also carries an out-of-scope prerequisite: PillRadioTests.cs:105 had a
committed stray token ("...PrimaryStartButtonLabel soil;", CS1003) blocking the
whole test-project compile — token removed.

Plus the previous session's uncommitted pricing docs (one-time $29/$49) that
share TASKS.md/ai.md/HANDOFF.md.
2026-09-22 07:00:47 -07:00
gramps 523c17b7da docs(handoff): rewrite for pushed 0c55645 (ON-AIR armable signed-out Good Dog ruling + ONE PillRadio test) 2026-09-20 16:14:35 -07:00
gramps 0c556450cb feat(pills): ON-AIR armable signed-out; Start face reads Sign-In while armed signed-out (Good Dog 2026-09-20 ruling, ai.md updated same commit) 2026-09-20 16:12:39 -07:00
gramps 94e114bfdf feat(topbar): REC/ON-AIR pills are radio-exclusive (record-OR-live)
The record-OR-live ruling (2026-09-01) was documented in ai.md but the
'pills = radios' UI constraint never landed — both pills could be armed and
StartSession/BuildEncoderOptions would dual-encode to the YouTube ingest AND
a local file at once, streaming a concurrent disk write off the same ingested
Kbps (cripples the stream on mid-range chassis; 'the VOD is already the copy').
The pill setters now clear one another (silent mutual clear); the dual-block
ffmpeg machinery stays generic and unreachable from the UI by design.
2026-09-20 09:56:07 -07:00
gramps 5a1993b6db fix(persist): one shared z-order across Source + WebcamSceneConfig rows
A webcam dragged between sources reverted to the bottom of the stack on
every relaunch: Source and WebcamSceneConfig each carried independent
per-type SortOrder counters, and Load appended all Sources before all
configs. Save now stamps both tables' SortOrder from the element's index
within scene.Elements; Load merges the two tables' rows by that shared z
(sources-first tie-break preserves legacy rows). Cross-type reorder now
survives a fresh LayoutStore reload.

Task 37 queued: defaults vs current layout split (creator directive) —
capture out-of-scope work in TASKS.md rather than folding it in.
2026-09-20 09:23:46 -07:00
gramps f91bf8715d fix(reorder): save layer list AND preview layout immediately at drop; recording save dialog +10% (276→304)
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.
2026-09-18 10:20:25 -07:00
gramps 2ac00db0da test: prove layer drag-reorder persists via REAL injected mouse input
Creator reported the layer reorder still "doesn't save" after the 08:18
fix (c117885). The existing Good Dog test short-circuited EndListDrag — it
called OnSceneElementsReordered() directly, so the actual
List_PreviewMouseLeftButtonDown/Move/Up handlers were never exercised
(synthetic RaiseEvent can't: e.GetPosition reads the physical cursor).

New RealMouseDrag_OnTheLayerList_PersistsTheReorder drives SetCursorPos +
mouse_event (Win32 input injection, the same technique UI-automation
tooling uses) so the REAL handlers run: click ImgC's row, drag it onto
ImgA's row on screen, release, then pump until the debounced save writes
the DB. Green: DB ends at [Background, ImgC, ImgA, ImgB] — the seam is
proven end-to-end. If the creator still sees a revert, the run binary was
stale (Debug exe 08:42 postdates the fix commit 08:18) or another deploy.

Reference: Win32 SendInput/mouse_event input injection for WPF e2e
(mouse_event docs / UI-automation tooling pattern).
2026-09-18 09:07:49 -07:00
gramps 0d0e55de31 fix(recording-dialog): +20% height so the file-name box is not clipped; unhook full-screen hook on shutdown
RenameRecordingDialog was 230px tall for ~225px of stacked content —
the file-name textbox clipped (old client area ~193px). Height is now 276
(+20%). Integration test shows the real dialog in the app host and asserts
the textbox bottom edge stays inside the client area; the dialog's Icon moved
to the explicit /ytLive;component/ URLC form MainWindow already uses so tests
can construct it (root-relative /Assets/... only resolves in production via
Application.ResourceAssembly).

Dependency fix: MainViewModel.Shutdown() now calls
_fullScreenDetector.StopWatching(). The global EVENT_SYSTEM_FOREGROUND hook
was never unhooked; after VM collection the next foreground event invoked a
garbage-collected WinEventProc delegate, crashing the whole test run. Harmless
in production (exits the process) but fatal in the multi-window test host.

Reference: WPF WinEvent hook lifecycle guidance — SetWinEventHook callbacks
must be unhooked before the owning object is collected (obs-projector
fullscreen-detection pattern).
2026-09-18 08:33:13 -07:00
gramps c11788574e fix(reorder): trap layer-list drag drop as a data change and persist it
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).
2026-09-18 08:18:51 -07:00
gramps fd5198df9f docs(handoff): session-end refresh — HEAD 75723ac, attainability refinement, tree clean 2026-09-17 20:16:49 -07:00
gramps 75723acc1b fix(webcam): offer the Web Cam row only when a camera is attainable (live lock), not merely selected
Creator refinement: 'offered iff there's not one already configured & attainable'.
CanAddWebcam now requires IsWebcamAttainable = identity present AND a RUNNING
session (CameraManager.IsRunning) — an identity whose camera was unplugged or
whose lock keeps failing leaves the row greyed with reason 'No webcam is
currently available…', and it un-greys the moment a session is live. Gate
re-raised at every attainability flip: staging, removal, startup lock success,
first frame, camera failure, identity swap.

Root cause the old test surfaced: the startup pass skipped acquiring when the
loaded identity's configs already held the session, so there was no independent
app base ref — removing the last placement dropped RefCount to 0 and killed the
session. The single-camera branch now ALWAYS acquires (a running session just
bumps), laying the app-wide base hold so the default outlives the scenes.

Good Dog: WebcamMenuGateTests second fact — identity loaded, session can't start
→ row NOT offered + 'No webcam is currently available…' tooltip. Positive fact
waits for WebcamStartupValidationTask to make the IsRunning read deterministic.
Docs same commit (ai.md gate + base-lock, TASKS.md, HANDOFF.md incl. proven
pre-existing audio flake). 305 tests (304 pass + known flake), 0 warnings.
2026-09-17 09:16:56 -07:00
gramps 9d00955004 feat(webcam): app-default gate slice — per-scene offer, Add places default directly, identity survives removal
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.
2026-09-17 08:56:04 -07:00
gramps 1e4017df03 feat(webcam): resource lifecycle startup slice — poll-on-start, single-cam lock, persistent Layers alert
The creator couldn't add a webcam to Live (grayed app-wide) and nothing in the
app explained why. Ground truth from the live DB: one Webcam identity AND one
WebcamSceneConfig in the Chat scene — so the gray was the single-identity rule
working, but the reason was unobservable. This slice makes the webcam a
resource the app validates and locks, mirroring how OBS reserves its devices.

At startup we enumerate the OS once (ValidateWebcamResourceStartupAsync, fired
after LoadLayout, stored as WebcamStartupValidationTask for tests to await):
- 0 webcams -> app runs on, layer inactive, no alarm
- exactly 1 -> attempt CameraManager.AcquireAsync as an app-wide lock; on
  failure show a persistent red alert at the bottom of the Layers panel
  (WebcamLockAlert + Retry) that re-polls every 5s and clears itself the
  moment the camera locks, or on any first real frame
- >=2 -> deliberately no auto-lock; camera selection belongs to the App
  Settings dialog (gear) — next slice

CameraManager.IsRunning(deviceId) tells the pass a session already exists
(started OR still starting) so loaded identity configs count as the lock and
the pass never double-acquires. Test seams mirror LayoutPathOverride:
CameraEnumeratorOverride / CameraFrameSourceFactoryOverride so the startup
probe never touches real hardware under test.

Good Dog test: WebcamStartupResourceTests x3 — single-cam locked + app runs on,
zero-cams no-alarm/no-lock, lock-fails -> red alert -> Retry -> clears. Full
suite 304/304, build 0 warnings, scope-check passed.

Docs in-commit: TASKS Open items + ai.md Webcam section + HANDOFF rewrite.
Also corrects the record: 'NVIDIA Broadcast opens the webcam exclusively' was a
suspect-list claim (CameraConflictProbe reads process names only, no handles)
— not restated as fact.
2026-09-17 08:03:58 -07:00
gramps 94a934ffa9 fix(webcam): reader-output-subtype ladder + record WinRT per-call wrap rule
A browser grabbing the C920 kills our reader startup: the camera stays in
MJPG 1920x1080@30 (another app's format) and our OBS-refusal to re-negotiate
under SharedReadOnly contention (`SetMediaStreamPropertiesAsync` throws
'file in use / CaptureMode is SharedReadOnly') leaves it there.
CreateFrameReaderAsync(.., Bgra8) + StartAsync then refuses with
OutputFormatNotSupported — the MJPG-active source only exposes NV12 at the
reader level (clue: startup.log 19:01/19:05 sessions, same hardware that
started YUY2 640x480 fine at 08:52). Fresh launches showed no webcam and
'Add Webcam' failed.

Reader creation is now a per-candidate ladder (ReaderSubtypeCandidates):
Bgra8 for uncompressed cameras (unchanged fast path); NV12 then the
source-default for MJPG cameras — converted in OnFrameArrived like any
non-BGRA frame. Each candidate is allocated AND started under its OWN
catch: WinRT answers an unsupported subtype with a throw (E_INVALIDARG),
not a status, so a single rejected format must degrade to the next
candidate instead of aborting acquisition (creator rule — see MyMistakes
WINRT resource-allocation recipe). Rejections are logged and collected
into the final error.

Good Dog: 4 unit tests lock the candidate ordering (MJPG never Bgra8,
case-insensitive, uncompressed keeps Bgra8 first, unknown/null -> Bgra8).
301/301 green, 0 warnings. [no push]
2026-09-15 19:25:39 -07:00
gramps 64a5a6d06f perf(pump): slice 18 — C4 blit-on-change composite cache
ty-1841: capture fixed (band ~20 updates/s, no tears) but FramePump stalls
on EVERY iteration (totalMs 21-44, render=full-render split=0 elements=6
dynamic=4) — the render ceiling, ~22-28 composites/s, caps the desktop in a
60fps file. SceneGraph can't help: the live backdrop is element 0 and cannot
be baked (a cached capture goes stale), so the cache lives at the pump.

RenderFull wraps both full-render call sites: BuildFullRenderSignature hashes
the full input identity (options rect, social bar, per-element layout/visual
bits + the frame each element would resolve through the SAME resolver seam,
using array identity + Epoch + CropBounds); unchanged identity reuses the last
composite with one Buffer.BlockCopy (~3ms) instead of a ~30ms re-composite.
Cache buffer is a separate long-lived array, written pre-burn/pre-recycle
(the caller burns the frame counter and recycles scratch AFTER render).
Engagement gated on the 1:1 config (the only deployed tier). Telemetry
surfaces `cache NR/WH` on the 5s stats line.

Same shape as OBS (sources cache their surface, the scene blits on update) —
docs.obsproject.com/backend-design, the pattern this repo cites since the
2026-09-04 paste-cache slice.

Good Dog: FullRenderCache_StaticInputs_RenderOnce_Then_Reuse_UntilInputChanges
(static scene renders ONCE + byte-identical reuse; new frame+Epoch invalidates).
297/297 green, 0 warnings, verify.sh scope-locked (FramePump.cs, FramePumpTests.cs
+ ai.md/HANDOFF/MyMistakes). No push — device re-verify next.
2026-09-15 07:57:09 -07:00
gramps b37b8a30f9 perf(capture): overlap GPU readbacks with monotonic publish gate (slice 17)
Measure take ty-1824 on the slice-16 build: the downscale fix worked (conv
~47ms, ring allocs 0) but desktop band was still 88% frozen at 6.8 updates/s.
Telemetry isolated the real wall — CreateCopyFromSurfaceAsync readback ~45ms
of each conversion, serialized one-in-flight => ~17/s capture cap. Docs fact:
pool-sized surfaces CLIP, not scale (Microsoft Learn), so readback stays
native; the lever is concurrency.

- MaxConcurrentConversions=3 with pool 2->5 buffers (in-flight frames fit)
- new MonotonicGate (Interlocked compare-exchange): stale OLDER completions
  are dropped, never overwrite a newer LatestFrame (mirror of 1742 tear)
- FrameRingBuffer.Rent/ConsumeAllocations now lock; downscale row scratch is
  per-conversion locals
- Good Dog test PublishGate_TryPublish_OnlyStrictlyNewerWins; 296/296 green,
  0 warnings; docs cited Microsoft screen-capture page + libyuv fixed-point.

Local only, no push.
2026-09-14 18:40:20 -07:00
gramps 71932b9756 perf(capture): fast integer downscale + 10ms cadence floor + reuse-distance ring (slice 16)
The 240Hz monitor delivery + one-in-flight conversions + naive double-per-pixel
DownscaleBgra (~150ms/frame under load) froze the desktop layer 90% of take
ty-1742 (6.1 fresh content updates/s, freeze runs to 2.8s; decoded raw-frame
audit). The render stat (33-36ms) was real but moot — the capture CONVERSION was
the wall, and the one torn frame was a ring slot rewritten under the consumer's
read. Reference: WGC delivers at DWM/monitor cadence
(https://learn.microsoft.com/en-us/windows/apps/develop/media-authoring-processing/screen-capture)
and libyuv row-simple/fixed-point scaling
(https://chromium.googlesource.com/libyuv/libyuv/) — the repo's own take-4 rule.

- DownscaleBgra: integer 8.8 fixed-point, shift-only-at-the-end (same two-stage
  math as SceneCompositor.Bilinear). ~150ms -> ~5ms per 2.5K->1080p frame.
- 10ms MinConvertInterval: the ~4.2ms 240Hz tail stopped queuing ~150ms of
  serialized conversion/s; capacity sits just above the 60/s the pump can use.
- FrameRingBuffer (depth 8, redLine 4): reuse-DISTANCE ring — a buffer is only
  rewritten >=4 rents after its last hand-out else fresh-allocated, so a frame a
  consumer still holds (session.LatestFrame survives conversions, dispatcher
  preview lags) is never read-while-overwritten. Needs no consumer Release API.
- 2s startup.log telemetry: frames/s, conv avg/max ms, skip busy/cadence, ring
  allocs — the device take is judgeable numerically.

Good Dog test: Ring_NoLap_ReusesOnlyAfterRedLineRents. 295/295 green, 0 warnings.
C4 (composite Epoch-cached downscale) deferred pending the device re-measure.
Local only, no push.
2026-09-14 18:18:34 -07:00
gramps 76f51e6f4e fix: FramePump paces one frame per deadline slot — duplicate-on-lag, never skip (slice 15)
ty-1723/1726 device takes showed accelerated playback + audio tail cut-off:
a render overrun (~35ms vs the 16.6ms slot) SKIPPED the missed slots (slice 10's
freshness choice), so a 60fps-authoring pump wrote one frame per 35ms into a
60fps container — 1723: 697 frames/11.62s vs 11.84s audio; 1726: 163/2.72s vs
2.93s, video ending 0.21-0.24s early.

OBS never leaves a wall-time hole: the video thread emits one frame per tick and
a lagging producer DUPLICATES the newest frame ("lagged frames due to rendering
lag/stalls" — obs-output.c; "If the video frame queue is full, it will duplicate
the last frame" — docs.obsproject.com/backend-design). The pump's submit is now a
bounded catch-up over the missed slots (while now >= nextTick), fresh on the first,
repeated after — duration == wall, judder not fast-forward. Safe because Channel.
TryWrite never blocks (the take-9 smear was the blocking pipe-write; each emit is
nanoseconds). Burned frame index moved inside the loop: every emitted slot carries
its own +1 (also fixes the old unconditional pre-gate bump that gapped the judge
sequence on non-submitting fast-render iterations).

Good Dog test: Pump_Overrun_Renders_EmitsEverySlot_NotSkipped (60fps, 35ms render
cost, asserts >=0.65 of the wall slots emitted). 294/294 green, 0 warnings.

No push — web/A/V work is commit-local until greenlight.
2026-09-14 17:40:59 -07:00
gramps 394d86b94c docs: HANDOFF — mark composition-capture redesign as committed locally (c01206f), still unpushed 2026-09-14 16:14:21 -07:00
gramps c01206fb8a feat: web sources frame-captured via composition (CoreWebView2CompositionController → Windows.Graphics.Capture); PNG poll + CaptureScheduler deleted
The ~30Hz CapturePreviewAsync PNG poll capped real cadence at ~20Hz
(35–165ms full-HD encode+decode), so a 60fps widget still juddered at ~1/6
speed. Replaced polling with frame-driven capture of the composition
controller's root visual — the mechanism WebView2CompositionControl and
Flutter's webview_windows use (graphics_context.cc captures the root
surface_ visual via CreateGraphicsCaptureItemFromVisual; reference:
github.com/microsoft/Windows.UI.Composition.WinUI / flutter-internal
webview_windows). Frames now arrive at the renderer's own pace; capture
memory is epoch'd ring reuse + one crop-sized shared WriteableBitmap.

New Services/WebCaptureFrameSource.cs owns GraphicsCaptureItem + free-
threaded Direct3D11CaptureFramePool + session (Straight alpha readback,
per-frame FindContentBounds → CropBounds). WebView2Manager reworked around
per-session composition controllers + one UI-thread Compositor created via
the CoreMessaging CreateDispatcherQueueController P/Invoke (the 19041
projection lacks CreateOnCurrentThread); internal seam ctor
(Dispatcher, Func<string,IScreenCaptureSource>?) for hermetic tests.
CaptureScheduler.cs deleted; the three SetCaptureInterval cadence hooks
removed; InitWebView2() moved from MainWindow ctor to Loaded (a parent
HWND must exist for the composition controller); the hidden WebViewHostPanel
overlay deleted. TransparentBackgroundScript unchanged.

Tests: WebView2ManagerTests reworked — 4 control-size + scheduler tests
dropped, FindContentBounds tests moved to WebCaptureFrameSource, ONE
integration test (Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds)
drives the seam with a FakeWebSource + background-STA DispatcherPump.
Suite 293/293, 0 warnings.

NOTE: composition path NOT yet verified on a device — the take is the
next step. Web work committed locally only (no push per standing rule).

Docs same-commit: ai.md Slice 14 + supersede marker on Slice 11, HANDOFF,
MyMistakes (CoreMessaging DQ + namespace-landmine recipe), TASK 17,
Controls/ViewModels/Services indexes.
2026-09-14 16:14:00 -07:00
gramps 40c453805d docs: HANDOFF — resolve the root-md split question (answer: no split needed), fix next-step wording 2026-09-14 15:05:24 -07:00
gramps d0e4a0fb25 docs: correct product branding — llamacasty (product) vs ytLive (repo/assembly legacy); HANDOFF refreshed to the pushed b22d08e state 2026-09-14 15:05:00 -07:00
gramps b22d08eca6 feat: signed A/V sync offset (−500..+500), negative advances by eating live stream head (OBS eat-head semantics)
Positive offsets still delay the whole mix via the delay line (lip-sync fix);
negative offsets now ARM once at StartLive and drop |N| ms off the pipe's write
head so audio events land earlier when audio runs BEHIND video. Slider relabeled
AUDIO SYNC, Min −500, locked while live/recording (IsEditMode). LayoutStore and
VM clamp to −500..500.

OBS reference for eat-the-head negative sync: https://obsproject.com/kb/obs-studio/buffering-time (negative sync values pull audio earlier by discarding buffered player audio).

Test: StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead (6x0.9 head
must be eaten before 0.2 bed reaches the wire).
2026-09-14 12:30:44 -07:00
gramps 11a7af2dc0 fix: clear pre-live capture ring backlog at StartLive (2.2s A/V sync lag)
AudioMixer.Start() begins mic/loopback capture at app startup to feed the
level meters, so both 2s ring buffers fill with pre-live audio. StartLive()
drained from the oldest tail sample, putting every recorded event ~2.2s late
in the audio track (confirmed by clap analysis + cross-correlation on two
takes: +2.11 to +2.22s).

Fix: AudioMixer.StartLive() now runs _micBuffer.Clear() + _loopbackBuffer.Clear()
immediately after the pipe starts, before the drain task runs. Recording now
begins at go-live; the <=10ms in-flight chunk evicted by Clear() is imperceptible.

Regression test: StartLive_DiscardsPreLiveBacklog_SoFirstAudioIsCurrent
saturates the loopback ring with stale 0.8 pre-live audio, then asserts the
wire carries fresh post-live 0.2 (max < 0.3).

Reference (external, per derivative-work rule): OBS 'Audio mixer' keeps its
buffers fed continuously and syncs the stream start timestamp at record time
rather than replaying pre-live capture; a go-live flush of the capture buffer
is the accepted pattern for live tools restarting a stream.

Docs: HANDOFF.md (fix shipped), MyMistakes.md (A/V sync measurement recipe).
2026-09-14 11:24:12 -07:00
gramps 5ba4d709ae docs: audio-sync offset measured (~367ms lag w/ 300 offset) -> set Audio.SyncOffsetMs=0
silencedetect audio word times cross-correlated with per-frame video motion
(tblend=difference + signalstats) peaks at +22 frames: the 300ms TASK-22
offset plus ~70ms pipeline lag. 370ms reads as one full beat behind during
the ~2.3s pauses. Zeroing the offset targets the residual ~70ms.
2026-09-12 14:04:58 -07:00
gramps 724af1499b fix: audio silence (idempotent sync-delay configure), webcam gray block, truncated videos
- AudioSyncDelay.Configure reallocated/zeroed its buffer every ~10ms tick
  (AudioMixer re-reads the UI setting each mix), so any non-zero sync offset
  erased the just-written audio -> total silence. Now early-returns when the
  delay samples are unchanged. Regression test proven both ways.
- SceneCompositor.BlitContentRaw defaulted cbW/cbH=0 when CropBounds is null
  (regression from ed9d7c1) -> webcam blit to an empty rect = gray block.
  Default to src.Width/Height. Regression test proven both ways.
- Truncated recordings: rawvideo mux stamps frames at declared 60fps by
  arrival; a scene whose first layer is dynamic (hidden elements still count)
  kills the bake cache -> full render ~35ms -> ~27fps submitted -> halved
  file length. Static scenes bake once (246ms cold, then <1ms) -> 60fps,
  full-length (probe + 13:53 take, 301/300 per 5s, 12.46s file from 12.3s
  wall). FramePump.ProbeRender names the hot render path on slow frames.
2026-09-12 13:56:55 -07:00
gramps a62a283fd4 fix: resolve build errors with missing using directive and variable name conflict
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2026-09-12 10:33:16 -07:00
gramps 992bb21bdb feat: add webcam frame diagnostics to debug gray block issue
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2026-09-12 10:32:18 -07:00
gramps 58050d9ab1 feat: add alpha probe diagnostics to webcam frame capture
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2026-09-12 10:25:18 -07:00
gramps efe88b8a0b fix(compositor): keep straight alpha in the paste-cache raster — the web widget's recording black box
The element-space raster builds on a TRANSPARENT base but BlitContentRaw's
partial-alpha branch applied the opaque-dst source-over blend: color premultiplied
by the sampled alpha, then alpha forced to 255. Pasting that raster saw a==255 and
straight-copied darkened ink over the scene — a recording box that the raw-bitmap
preview (correct alpha) never showed. Transparent margins and opaque content were
unaffected, which is why every take looped on the page/CSS while the capture was
transparent all along (15:51 dumps: alpha max 255, mean ~19, zero 57%).

BlitContentRaw now takes transparentDst; the raster call passes true and writes
straight color + straight alpha so the paste rows (BlendRowOpaque/Weighted) do the
real source-over onto the opaque master. Master paths byte-identical.

ONE integration test PasteCache_SemiTransparentLayer_RevealsBackdrop_NotOpaqueInk:
50%-blue over red reads (127,0,128) fixed vs (0,0,128) buggy — proven both ways
(verified by stashing the fix: fails before, passes after). Clean build, 0 warnings;
22/23 compositor-class tests pass, the sole failure the documented pre-existing
Composite_FullScene_MasterPixels pixel (1380,700).

Alpha-compositing model: standard source-over with producer-cached surfaces, the
OBS/libyuv paste model already cited in ai.md/MyMistakes (rawvideo recipe, row-blit
BLEND_NONE / straight-alpha branches); full story in MyMistakes (RESOLVED entry).

Per the good-dog rule: one integration test, memory updates (MyMistakes/ai.md/
HANDOFF) in the same commit.
2026-09-12 08:57:58 -07:00
gramps 0f72c53aa5 fix(web): wipe background-image too — the widget's black void is a CSS backdrop gradient, not a color
The 15:34 take's box interior is the widget's OWN full-canvas opaque paint — a
black void with wide content strips at the top/bottom and a right-edge bar —
arriving AFTER the first-second blank-transparent dumps. A background-color-only
!important wipe (b4bba4b) can't touch it because CSS gradients/backdrops are
background-IMAGE, not background-color.

OBS's fix for this exact symptom is background-image:none (obsproject/obs-studio#6659:
"set the CSS for html and body to background: none !important"). Injection now wipes
background-image:none!important on html,body,html * alongside the color wipe; HTML
overlay art (<img>/DOM/CSS shapes) survives — that is browser-source semantics.

Also re-arms WidgetDumpRemaining=5 ~30s after nav so the next take dumps the real
document INSIDE the recording window (the previous dumps proved blank at +1s and
missed the later backdrop paint).

9/9 WebView2Manager tests, 0 warnings.
2026-09-10 15:42:26 -07:00
gramps b4bba4bf68 fix(web): harden the transparency injection to the OBS-standard element-wide !important wipe
The real-widget dumps (12:54/13:50) proved the OLD inline
element.style.background='transparent' injection holds only while the page has
nothing to paint: the capture was alpha-transparent, yet the recording showed a
black opaque box over the whole element rect (1231,679 705x396) once the widget
connected and repainted a container background-COLOR — CapturePreviewAsync always
honors page CSS (MicrosoftEdge/WebView2Feedback specs/BackgroundColor.md), so any
page-painted background wins over DefaultBackgroundColor.

This is the OBS-solved class (all web-uri resources paint their own background):
browser sources use a Custom CSS override, and the decade-validated formula for
arbitrary pages is a pre-parse <style> with 'background-color: transparent
!important' — https://obsproject.com/forum/threads/translucent-transparent-browser-source.59549/
('body { background-color: rgba(0,0,0,0) !important }') plus the div-level variant
for stubborn widgets (woahtech.com OBS custom-CSS guide).

Injection is now an idempotent pre-parse style element wiping background-color on
html,body,html * with !important (outranks every page rule, runs before page parse
via AddScriptToExecuteOnDocumentCreatedAsync). Only background-COLOR is targeted —
background images and widget art survive. Regression test asserts the element-wide
!important form and that the losing inline form is gone.

9/9 WebView2Manager tests, 0 warnings.
2026-09-10 14:04:11 -07:00
gramps ea347f211c fix(web): re-point the widget diagnostic at the REAL document — the transparency premise gets verified
The one-shot dump + alpha log was bound to the FIRST capture ever = the initial
about:blank placeholder (alpha=0, rgb=0, 5/5 sessions) — a blind instrument. The
pre-parse transparency injection (1a39b09) is intact but was NEVER verified
(MyMistakes '? VERIFY'), and the 2026-09-10 take still shows a black, opaque
block. Stop guessing: NavigationCompleted for a non-about:blank document now
arms WidgetDumpRemaining=5; the next 5 post-paint captures dump
%TEMP%\ytLive-web-<id>-w1..5.png + shared AlphaStats(min/max/mean/zero%) +
FindContentBounds to startup.log.

The stats line alone names the branch: zero%≈opaque ⇒ the injection did not hold
for this widget's CSS (fix: !important stylesheet / chroma-key); large zero% +
tight bounds ⇒ capture IS transparent and the black lives in the compositor
blend. No new test: WebView2 runtime is not instantiable in the suite and the
re-point is log-only; 8/8 WebView2 tests still pass.
2026-09-10 12:08:53 -07:00
gramps f3d578c81b fix(audio): add per-5s live-loop telemetry to name the silent-recording stage
The recorded audio is full-length silent AAC (−91dB, 1124 frames/23.95s) —
the named pipe carried ~9.1MB of zeros the entire take. The mixer's loop
ran, the pipe connected, ffmpeg read it, and the file is full-length
silence. Sources started fine ('Audio: using system default mic...' logged),
no failure callbacks fired, and the existing integration test
(Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe) proves the loop→pipe path
carries real audio when fed — the fault is capture-side.

Added permanent per-5s live-loop telemetry to startup.log so the next take
names the exact stage without new code:

  Audio live: pipe connected= dropped writes= micLevel= loopLevel= drained
  N/N samples peakMix N (per 5s)

- AudioMixer.FillAndMix now returns (MicRms, MicDrained, LoopDrained) for
  the accumulation; StartLive/StopLive log start/stop lines.
- NamedPipeAudioWriter.DroppedWrites: nonzero = audio dropped before ffmpeg
  connected (names 'pipe never connected' stage).

Likely root cause: WASAPI loopback captures the default render endpoint —
if audio plays on a non-default device the recording is silently silent.
The fix requires device enumeration + selection (slice 13 candidate).

Suite 290/291 — same sole pre-existing compositor pixel failure.
2026-09-10 10:55:13 -07:00
gramps 5e78065c2d fix(web): web-layer capture cadence 10Hz → ~30Hz while recording — widget animations no longer play ~1/6 speed
The recording is 60fps but WebView2 capture was a blind 100ms DispatcherTimer = 10Hz;
each captured web frame repeated ~6x into the file caps web animation at the capture
rate, not the page's (user: 'the animation appears to be too slow').

- New CaptureScheduler (Services/CaptureScheduler.cs): per-session dispatcher timer
  that DROPS a tick while a capture is in flight (latest-wins, never queues) — the
  guard that makes a higher cadence safe: concurrent full-HD PNG CapturePreviewAsync
  calls (~10-30ms each, slow per WebView2Feedback#20) would stack CPU and publish
  stale-after-fresh. Effective cadence = max(interval, capture duration).
- Cadence: SetCaptureInterval(33) on record/stream start, (200) idle — applied via
  MainViewModel.Streaming.Operations.cs.
- De-throttle the hidden page: shared CoreWebView2Environment created BEFORE
  EnsureCoreWebView2Async with --disable-backgrounding-occluded-windows
  --disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion.
  Off-screen WebView2 is a hidden page when the host window is unfocused/covered and
  Chromium then parks rAF and clamps timers to ~1s (WebView2Feedback#1172/#3070,
  Chrome-88 timer-throttling blog).
- Telemetry: first 30 captures per session log elapsed ms (PNG encode + decode) to
  startup.log — that decides whether ~30Hz stays or drops to ~20Hz; the FramePump
  drops frames (never time-lapses, slice 10) if UI-thread GC churn starves it.
- ONE test: CaptureScheduler_Drops_Ticks_While_Capture_InFlight_And_Resumes
  (deterministic TCS-driven, no WebView2 runtime). Suite 290/291 — sole failure the
  pre-existing compositor pixel test.
- Docs same-commit: ai.md slice 11, MyMistakes.md, HANDOFF.

References: https://github.com/MicrosoftEdge/WebView2Feedback/issues/1172
https://github.com/MicrosoftEdge/WebView2Feedback/issues/3070
https://github.com/MicrosoftEdge/WebView2Feedback/issues/20
https://developer.chrome.com/blog/timer-throttling-in-chrome-88
2026-09-10 10:25:41 -07:00
gramps ec7c734bdd docs: record push gate — no push until transparency + audio issues closed 2026-09-10 10:02:08 -07:00
gramps c45cbc93b7 fix(rec): bounded encoder queue + drop policy + burned frame counter — the "1...23...4...56..." smeared-ticker take
slice 9 made the DURATION right but content still hiccuped; aggregates (301/300, uniform
file PTS) could not see it. Measured root cause: FfmpegEncoder.SubmitFrameAsync BLOCKED
on WriteAsync(8.3MB)+FlushAsync when ffmpeg lagged the pipe, and the burst while-loop
re-wrote that same stale composite per crossed slot — frozen runs.

OBS shape (derivative, wrapped pre-1.0): the encoder queue in libobs/obs-encoder.c —
encoder thread never couples back into the video thread; overflow = dropped data, never
a frozen producer. https://github.com/obsproject/obs-studio/blob/master/libobs/obs-encoder.c

- FfmpegEncoder: SubmitFrameAsync is now an enqueue (ArrayPool copy) into a bounded
  Channel (cap 120) drained by its own task; drop-newest + count when full;
  StopAsync flushes the queue then EOF (TryComplete). IFfmpegEncoder.DroppedFrames.
- FramePump: ONE fresh composite per iteration (burst loop deleted); worst-submit stat,
  stall logger (>2x interval names the stage), dropped/stalls in stats.
- Burned-in 6-digit dot-matrix frame counter (white box, bottom-right) on every composite
  — the clock-independent judge replacing the WSL ticker: +1/frame, jumps = counted drops.
- ONE new test Backpressure_QueueOverflow_DropsFrames_AndNeverBlocks (slow-sink fake:
  submit never blocks, drops counted, stop flushes exactly submitted-minus-dropped).
- Full suite 290 tests, 289 pass — sole failure the pre-existing compositor pixel test.
- Docs same-commit: ai.md slice 10 (+ encoder/stop-note corrections), MyMistakes point 8,
  HANDOFF.

Audio untouched (queued follow-up); web overlay still frozen pending timing closure.
2026-09-10 09:32:55 -07:00
gramps bd396e488c fix(rec): recordings played ~1.4x fast — CFR emission, drop -re
Root cause (measured, not guessed): the take-3 'rebase on overrun' reset
nextTick to wall-now, erasing every missed slot. Pump delivered 215-219/300
per 5s (~43fps) but rawvideo carries no timestamps — ffmpeg muxes by frame
count at -framerate 60, so every recording played fast with honest-looking
stats. A second pacer, ffmpeg -re, throttled the demux separately
('Resumed reading ... after a lag' 0.79s->4.82s).

Fix follows libobs video-io.c (https://github.com/obsproject/obs-studio)
- the video thread never resets its deadline; one frame per interval slot,
a late render repeats content (judder), never skips time:
1. FramePump: count-based emission, while(now>=nextTick){Submit; nextTick+=I}
2. FfmpegArgs: -re removed - the pump is the pacer

Muxed duration is now frame-count/fps == wall time by construction. Covers
game background + webcam (one shared pump). 0 warnings; suite 288/289 -
the one failure (Composite_FullScene_MasterPixels 1380,700) also fails with
this change stashed: pre-existing, untouched, recorded as follow-up.
2026-09-10 08:35:30 -07:00
gramps d212d5ae2c tools: WSL ticker for timing-verification takes
Absolute-deadline 1s marker (CLOCK_REALTIME clock_nanosleep, user-readable
timestamp + drift per second) so a recording's real-time pace is checkable
on screen. Deadline pacing mirrors game-loop/OBS canon -
stackoverflow.com/questions/5441464. Build: gcc -O2 -o ticker ticker.c
2026-09-10 08:35:14 -07:00
gramps 1a39b091a0 fix(web): inject transparency BEFORE page scripts — CapturePreviewAsync honors page CSS
The widget page paints html/body opaque, so CapturePreviewAsync output had
zero alpha-0 margins — every take since bccdb48 built the crop on 'the
capture is transparent' and never verified it. WebView2 spec is explicit:
'WebView will always honor a webpage's background content' and
DefaultBackgroundColor only shows through pages with no background style
(MicrosoftEdge/WebView2Feedback specs/BackgroundColor.md). The late
ExecuteScriptAsync injection (NavStarting/NavCompleted) ran after page CSS
and lost the fight.

Fix via CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync — the
documented pre-parse hook ('before the HTML document has been parsed and
before any other script included by the HTML document is run'), the same
mechanism OBS user.css uses. Nav handlers kept as post-load re-assertion.

Restores the take-23/24 stride-correct crop path (my take-25 removal was
wrong — it regressed the bounding box into a solid black box).

Adds one-shot diagnostics: raw capture PNG -> %TEMP%/ytLive-web-<id>.png
plus alpha min/max/mean/%zero and FindContentBounds result in startup.log,
so the next run proves the capture is transparent instead of guessing.

Build 0 warnings, 289/289 tests pass. MyMistakes.md records the full chain.
2026-09-08 14:19:30 -07:00
gramps e564e9884c docs: update HANDOFF + MyMistakes.md — take-24 PasteKey fix landed 2026-09-08 13:29:22 -07:00
gramps 081e4c1bf8 fix(web): add CropBounds to PasteKey — stale raster cache was corrupting transparency 2026-09-08 13:28:42 -07:00
gramps 5175c1c8c9 docs: update HANDOFF + MyMistakes.md — take-23 stride fix landed, chat missing from recording, landmines refreshed 2026-09-08 12:11:45 -07:00
gramps f6802c7124 fix(web): stride-correct CropBounds Fill rendering in compositor (take-23) 2026-09-08 12:07:54 -07:00
gramps 84aa70f532 docs: MyMistakes.md — take-22 full fix for widget scale/position + transparency 2026-09-08 11:18:12 -07:00
gramps ed9d7c1ffc fix(web): CropBounds metadata + Fill-style scaling in compositor — transparent margins preserved, widget fills element rect 2026-09-08 11:17:05 -07:00
gramps 5ca9778e23 docs: update HANDOFF + MyMistakes.md — take-21 web transparency fix landed 2026-09-06 12:58:05 -07:00
gramps e002847777 fix(web): feed full canvas to compositor — transparent margins reveal layers beneath (take-21) 2026-09-06 12:56:34 -07:00
gramps 70db34455f perf(camera): MJPEG negotiation before frame reader — camera drives at its best rate
Slice 10 from today-slices (d1126dd): UVC cameras default MediaFrameReader to their
FIRST media type (YUY2 at crippled fps — C920 720p = 5fps). A 60fps container
then shows a 5-20fps face cam as "every few frames cut out". Fix:
VideoDeviceController.SetMediaStreamPropertiesAsync negotiated to best MJPEG
>=640x360 @>=30fps (<=1280 wide) BEFORE creating the frame reader, with
device-default fallback. Plus a startup.log truth line of the actual agreed media
type so the next diagnosis starts from facts (no more re-deriving the fps).
Build 0 warnings, 289 tests.
2026-09-05 17:07:24 -07:00
gramps 0f059ece33 docs: HANDOFF — #12 landed (element rasters), MJPEG next, web transparency spin-guarded 2026-09-05 16:48:22 -07:00
gramps 6d11e8b3fc perf(compositor): reuse per-element rasters in-place — take-16 GC-churn fix
Slice 10 from today-slices (d1126dd): the paste cache minted a fresh raster
array per new source frame (webcam ~30 keys/s ≈ 18MB/s LOH churn →
gen2 pauses that ate camera frames). Fix: _elementRasters — re-rasterize
INTO the element's existing array when geometry holds, so steady-state
allocation ≈ 0. The paste cache keys on (array+epoch) still protect correctness;
_elementRasters[element] tracks the element's current raster so stale keys can
never paste a mid-update array. MaxPasteEntries 48→256 (was a leak guard,
not an allocation stream). Regression test:
PasteCache_RingRecycledSources_ReRasterInPlace_WithoutAllocationChurn.
Build 0 warnings, 289 tests.
2026-09-05 16:47:49 -07:00
gramps 87509bcf99 docs: restructure TASKS.md into a catalog — one file per task in TASKS/
TASKS.md is now the index (status table, open items, research pointer).
33 files: 32 task files + 1 research facts file. The full take-saga
narrative and all design decisions are preserved verbatim; the catalog
makes the queue readable without opening every task body. Schema and
AGENTS.md updated to reflect the new layout.
2026-09-05 16:31:46 -07:00
gramps 98d30c2056 docs: revert record + spin guard — web transparency fix (0f441d8) FAILED and reverted (1295e0e)
User tested: 'didn't work at all, and broke additional crap.' Second failed remedy for
web-source-over-webcam dark composite -> AGENTS.md spin guard: no third guess; next attempt
requires external research (OBS/CEV browser-source transparency in recorded output, cited URL)
plus the blank-page falsifier. HANDOFF OPEN list updated: research-first step added, #11 noted
landed as d35823a.
2026-09-05 16:22:41 -07:00
gramps 1295e0ed80 Revert "fix(web): composite consumes the FULL canvas, alpha crop is measure-only — transparent web margins reveal the webcam again"
This reverts commit 0f441d825a.
2026-09-05 16:21:16 -07:00
gramps 0f441d825a fix(web): composite consumes the FULL canvas, alpha crop is measure-only — transparent web margins reveal the webcam again
take-19-2/take-20 'webcam black square under the web widget': the capture was
alpha-cropped (FindContentBounds) and that CROP was fed to the compositor, whose
UniformToFill zoomed the opaque content box to cover the whole element rect the
moment the widget drew any content (idle transparent page = full-frame crop = the
correct viewport, hence take-1-good/take-2-bad). OBS model verified: the page is a
fixed 1920x1080 canvas and the element rect is a viewport onto it — measure with the
alpha crop (preview/selection), hand the composite the FULL canvas on an 8-deep ring
+ Epoch (same identity discipline as camera/screen). Regression test:
Composite_FullCanvasWebSource_TransparentMarginsRevealWebcam (the reveal contract:
margin pixel = webcam, badge pixel = widget).
2026-09-05 15:33:56 -07:00
gramps d35823a4a9 feat(ux)+fix(rings): release counter #N in the wordmark; all shared-frame rings 4->8 (#11)
Roll-forward of today-slices 6fd1d9c onto the slice-8 base, two-loop hunk dropped.

Release counter (per-build GUID read as noise; +1 per commit from git rev-list,
baseline 241 -> #13, generated by GenerateBuildStamp; GUID demotes to startup.log).
Tests pinned to Label/#N >= 13; wordmark display test asserts the Label.

Flash fix (take 14 finding): consumer holds must never outlive depth x source
period — 4 slots at high refresh lap ~27ms vs a <=50ms compositor read, so a
recycled slot flashed its new frame over the lagged old one. All shared rings 4->8
(OBS/overlay precedent for ring discipline).

Camera producer now rotates an 8-deep ring + Epoch instead of a fresh ~3.7MB
array per device frame (110-220MB/s LOH churn); WebView2 capture reuses a canvas
scratch + 8-deep output ring + a reused WriteableBitmap instead of two fresh
arrays + a fresh bitmap per 10Hz tick. Paste cache stays identity-keyed (Epoch).
2026-09-05 14:31:03 -07:00
gramps 99aeef7994 docs: HANDOFF — rollback record + roll-forward plan (2026-09-04 unfuck session) 2026-09-05 14:25:26 -07:00
gramps c46f3a1168 docs: HANDOFF — de-duplicate the sliced-6/7 history paragraph 2026-09-04 12:37:26 -07:00
gramps fbc8562cf4 perf(capture): slice 8 — buffer ring + paste-cache Epoch; gen2 visibility (take-11 spikes)
Take 11 (c10ce06c) validated the off-UI architecture: typical frames land
work ~10ms + wait ~6.8ms = 16.7 exactly on the deadline; 212/300 best yet.
The ENTIRE remaining gap is periodic 35-65ms render spikes that WORSENED
across the take (189 -> 147) — the signature of gen2 GC pauses. Biggest
churner is structural: the screen capture minted a fresh ~8.3MB byte[] per
DWM frame (~500MB/s of LOH), a producer OBS never does (it owns fixed
surface pools).

- ScreenCaptureFrameSource: 4-deep buffer ring with size-matched slots (a
  <=17ms consumer cannot be lapped at 60Hz) + reused downscale row scratch.
- VideoFrame.Epoch: monotonic per producer frame. The paste cache keys on
  array IDENTITY, so recycled arrays MUST be distinguished — epoch joins the
  PasteKey. Producers handing fresh arrays leave it 0 (key unchanged effect).
- Stats print 'gen2 +N' per 5s window: next take acquits or convicts GC
  without another guess (rule: prove the stage).
- Test (the ONE): PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits
  — same array, new content, bumped epoch; fails on the old key by
  construction. 37/37 compositor/pump, clean build.
- Next suspect if gen2 stays hot: the 10Hz WebView2 capture (full-canvas PNG
  decode + fresh arrays on the UI thread) — recorded, untouched.

Creator audio ask queued in the same working session (+40% post-mix master
gain before the -1dBFS limiter) lands as its own commit next.
2026-09-04 12:36:14 -07:00
gramps eb4c379b91 fix(pump): slice 7 — take the loop off the UI thread (the 'wait 10ms after render 22ms' contradiction resolved)
Take 10 (59a02a5b, slice 6) finally produced a self-contradicting stat: render
22.4ms + submit 2.5 against a 16.7ms deadline, yet avg wait 10ms — a rebasing
pacer CANNOT sleep after a blown deadline. The wait was queue time: StartAsync
fires from a UI command handler, and async continuations re-capture the current
SynchronizationContext — the 'WPF-free, hermetic' frame pump had been rendering
ON THE DISPATCHER behind the live preview the entire starvation saga. OBS keeps
obs_graphics_thread/video_thread off-UI for exactly this reason (dedicated
threads; see docs.obsproject.com/backend-design 'Libobs Threads').

- FramePump: _pumpTask = Task.Run(() => PumpAsync(...)) — null context inside,
  every continuation stays on the pool.
- Audited, not ignored, what that exposes: StaticPixelCache.Get now locks (pool
  miss-decodes raced UI callers); ChatOverlayLayer.RenderFrame checks its cache
  off-thread but marshals the rare raster MISS to the dispatcher (DrawingVisual
  + RenderTargetBitmap are UI-thread objects) and re-validates there; pump
  events already marshal in the VM.
- GCLatencyMode.SustainedLowLatency for the pump's life (restored in finally).
- Stats gained 'worst render Xms' — bimodal averages hid per-tick spikes.
- Webcam routes through the paste cache (the IsOpaque bypass re-sampled ~156k
  px every tick even between identical device frames).

ONE integration test: Pump_Produces_OffTheStartingContext — an inline-pumping
SynchronizationContext makes the old construction run the resolver on the
starting thread by capture; the loop must never. 70/70 per-class green, clean
build 0 warnings. Docs same commit (ai.md slice 7, TASKS take-11 gate,
MyMistakes #6, HANDOFF). take 11: ~300/300 + honest wait -> saga closed,
Unit B (two-line top bar spec, fully captured) starts.
2026-09-04 12:16:20 -07:00
gramps 09a866e6a1 perf(pump): slice 6 — break the 15.6ms sleep quantum (the REAL ceiling behind takes 7-9)
Take 9's numbers were decisive: paste cache moved work to ~25ms/frame but the
period stayed ~37ms. The missing ~12ms per tick is Task.Delay rounding every
sub-tick request up to the Windows system-clock tick (~15.6ms default —
documented: learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay).
A frame finishing 3ms early requested 3ms and slept 15.6. Producer capped at
~27fps no matter how fast the compositor got — which is why two real render
fixes read as 'zero change' in playback. Game-loop/OBS canon for this
(stackoverflow.com/questions/5441464; learn.microsoft.com/en-us/windows/win32/
api/timeapi/nf-timeapi-timebeginperiod): raise the timer resolution for the
session, sleep only the bulk of the remainder, and SPIN the last ~2ms across
the deadline.

- FramePump: timeBeginPeriod(1) on entering the pump loop, timeEndPeriod(1) in
  the finally; pacing = bulk _pacingDelay(ahead - 2ms) + bounded Thread.SpinWait
  tail; blown deadlines rebase unchanged (never burst).
- Stats now report avg wait: render+submit+wait must equal the period — the
  accounting is closed, no stage can hide in an unmeasured gap again.
- Webcam dropped its IsOpaque paste-cache bypass: it re-sampled ~156k px every
  tick even between identical device frames; cached paste beats the sampler on
  hits, costs the same on misses.

52/52 per-class green (pacing + pixel suites unchanged — output byte-stable),
clean build 0 warnings. Docs same commit (ai.md slice 6, TASKS.md take-10
gate, MyMistakes #3 + renumber, HANDOFF). User's top-bar spec remains next in
queue (Unit B) — re-sent many times, captured, no open questions.
2026-09-04 12:02:24 -07:00
gramps 6af2026906 perf(compositor): slice 5 — paste cache for non-opaque layers (take-7/8 data)
The stamped build settled what slices 3-4 could not: chat cache works (resolve
~0.0ms) but render stayed 26-27ms -> 124-135/300. The cost was the compositor
re-rasterizing EVERY layer every tick: this Live scene re-samples chat (159k) +
web widget (271k) + image (95k) + cam (156k) ~ 680k px @ ~38ns — for layers
whose pixels do not change between chat/web/cam updates.

OBS shape: cache the surface, paste per tick. BlitCachedLayer rasterizes a
non-opaque layer ONCE into an element-space, transparent-based frame keyed by
(source-array identity, src W/H, ceil'd dst rect, round, mirror), then pastes:
integer position, row alpha-blend, opacity applied at paste. Producers hand out
fresh immutable arrays -> array-identity keys cannot serve stale content; dict
bounded (48, clears whole). Drag/opacity live in paste params, not keys, so
editing stops triggering resamples too. Opaque backdrop keeps the memcpy path;
the webcam keeps the direct path via its IsOpaque flag (revisit if take 9 is
borderline).

ONE integration test: PasteCache_RepeatRender_IsByteIdentical_And_ContentChange-
Propagates (byte-exact raster-vs-paste incl. round-clip margins, new-array
propagation); existing pixel suite guards sampler semantics. 85/85 across
compositor/pump/chat/capture/session classes, clean build 0 warnings. Also:
BuildStampTests.cs was written last commit but never staged — its own scope-check
slip, added here (the run had used the on-disk file; tracked now).

Docs same commit: ai.md slice 5 + stale 'general path only 130k' claim corrected,
TASKS.md take-9 gate, MyMistakes recipe (prove the stage; a fix that doesn't move
the stat wasn't the bottleneck), HANDOFF. take 9 expectation: 300/300, render
<= ~8ms -> saga closes, Unit B starts.
2026-09-04 11:44:19 -07:00
gramps 27bf74389d diag(build): per-build GUID stamp + resolve/blit timing split (take-6 attribution failure)
Take 6 measured render 35-41ms — WORSE than take 5's 25.5 — and the run could
not be attributed to a binary: exe mtime != build contents (incremental builds
serve stale exes; a source edit without rebuild is a silent old binary). Three
takes of a perf saga had been judged against builds nobody could prove.

- ytLive.csproj GenerateBuildStamp target: fresh GUID per compile (writes
  obj/BuildStamp.g.cs -> Helpers/BuildStamp.Id/BuiltLocal). Deliberately defeats
  incremental lies: every 'dotnet build' recompiles the app project.
- Wordmark shows the id as a superscript (TopBar.xaml, x:Static, 9px grey
  BaselineAlignment=Superscript); startup.log records 'Build <id> (compiled
  <time>)' so every take is cross-readable with the visible UI.
- FramePump stats split the tick: 'avg render Xms (resolve Y), avg submit Z' —
  the resolver is timed separately (wrapper resolver on per-tick paths; bake
  keeps the raw one) so take 7 names the hot half of 'render' with data.
- Fixed a latent transition-clock bug found on the way: lastTick now restarts
  every frame (the branch rework had restarted it only during transitions,
  letting a transition begun after idle complete instantly on its first Tick).

ONE integration test family: BuildStampTests (unit: shape) +
BuildStampDisplayTests (RealApp, namescoped FindName on TopBar proves the
wordmark SHOWS the id). 47/47 per-class green, clean build 0 warnings. Docs
same commit. User's top-bar/session spec (re-sent twice) + settled Q&A
decisions folded into HANDOFF Unit B — next work unit after take 7 verdict.
2026-09-04 11:26:46 -07:00
gramps 1c48849853 perf(chat): slice 3 — raster-on-change cache in ChatOverlayLayer (take-5 fix)
Take 5: render 58.9 -> 25.5ms (138/300, still ~2.2x). The blits were fixed; the
resolver was not: ResolveOutputFrame -> RenderChatBox ran a FULL WPF raster
(FormattedText + RenderTargetBitmap + CopyPixels + channel swap) EVERY tick
whenever the chat buffer was non-empty — and the buffer survives sessions, so
even a signed-out record-only take paid it. Established answer (OBS text
sources): re-render on change, blit the cache every tick.

ChatOverlayLayer: content version bumped from Messages.CollectionChanged
(covers adds, the 500-cap removal, the fade Clear from any caller) + a config
key (size + all Chat* appearance props); RenderFrame returns the cached
VideoFrame by identity until either changes (compositor only reads cached
frames). Conservative ordering (version latched BEFORE render) makes a
mid-render message re-render next tick, never serve stale.

ONE integration test: ChatOverlayLayerCacheTests (RealApp, real renderer):
Same() for unchanged inputs, NotSame() on message/config change, null on
empty. Full regression green (62 across touched classes), clean build 0
warnings. Accepted cost pending take 6: one ~15-25ms tick per arriving
message; if live-chat bursts sag n/300, next slice = debounced off-tick
re-render. Docs same commit: ai.md pipeline section, TASKS.md TASK 18,
MyMistakes recipe (raster-on-change + session-surviving-buffer trap),
HANDOFF (take 6 -> then Unit B, spec unchanged).
2026-09-04 11:01:51 -07:00
gramps 432adfdaef perf(render): take-4 slice 2 — IsOpaque memcpy, integer bilinear, pump scratch pool
Take 4: pacing held (sync perfect) but avg render stayed 58.9ms — 2M
managed row-walk iterations + a fresh 8.3MB buffer every tick (LOH churn
into GC stalls inside the render measurement).

- VideoFrame.IsOpaque: producer-contract flag (screen capture + webcam —
  DWM/MF fill alpha 255; media/chat/web/static NOT flagged). Full-cover
  aligned opaque backdrop = ONE Buffer.BlockCopy; black pre-fill skipped
  when it covers.
- General BlitContent: integer 8.8 fixed-point bilinear + blend, row
  invariants hoisted, no per-pixel division/Math.Round. Within ±1 of the
  float reference (pixel tests allow ±2). Research per derivative-work
  rule: libyuv row/scale kernels (chromium.googlesource.com/libyuv/libyuv).
- FramePump scratch pool (max 4, length-keyed, owned-by-reference):
  release strictly AFTER SubmitFrameAsync returns (stdin write copies);
  Contains-guard makes the transition Cut alias safe.
- Removed the dead per-tick fromScene render + fromSceneProvider seam —
  BlendFrame consumes TransitionService.FromFrame captured at Start; the
  pump's render fed nothing. MainViewModel call site updated (signature).

Bugs caught by the pixel probes pre-ship (recorded MyMistakes): first
Bilinear double-shifted both stages (solid-255 sampled to ~1 -> general
path drew nothing); sentinel 0xAB collided with an x+y pixel. FakeEncoder
snapshots submitted frames (mirrors real copy semantics under recycling).

ONE integration test: Pump_Pools_ScratchBuffers_Across_Frames_Without_
Stale_Pixels (alternating backdrops + repeated backing identity). Direct
pin: Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch.
Clean build 0 warnings; 59/59 per-class + RealApp boot-smoke. take 5
verdict: expect avg render <= ~10ms, ~300/300 frames. Docs same commit:
ai.md pipeline section, TASKS.md TASK 18, HANDOFF rewritten (Unit B spec
+ settled decisions queued).
2026-09-04 09:35:59 -07:00
gramps 716a77f61a fix(pump+compositor): take-3 starvation — deadline pacing + row-blit fast paths
Two defects made the producer 17x slow (37s record -> 2.1s/127-frame file,
rawvideo stamps by arrival): FramePump slept the FULL interval after each
render (period = render+submit+interval) and SceneCompositor did per-pixel
float sampling + Math.Round blends over all 2.07M master pixels, scanning the
whole destination per overlay (258ms avg render vs 1.5ms submit).

Both solutions are established, not invented — researched before coding per
the derivative-work rule:
- deadline pacing: OBS libobs/media-io/video-io.c video_thread (nextTick +=
  intervalTicks, sleep only the remainder, rebase on overrun, never burst)
- row blits: libyuv pattern (BSD-3, chromium.googlesource.com/libyuv/libyuv)
  — 1:1 aligned identity fast path, per-pixel alpha branch, integer
  fixed-point blend, overlay clipped to the intersection rect, skip the dead
  black pre-fill when the backdrop covers

ONE integration test: Pump_Paces_To_The_Deadline_Compensating_Render_Cost
(lands after a fake-seam lesson: pacing fakes must await, not complete
synchronously, or the pump loop runs inline on StartAsync and hangs vstest).
Clean build 0 warnings; FramePumpTests 10/10, SceneCompositor/SceneGraph/
SocialBar/StretchMath 20/20. Docs same commit: ai.md pipeline section,
TASKS.md TASK 18 (webcam-in-output + rename modal verified from take 3),
MyMistakes recipe, HANDOFF rewritten. Take 4 pending on the user's machine.
2026-09-03 09:02:45 -07:00
gramps 7fcb2ad3da docs: rewrite HANDOFF — tonight's 12-commit series, take-3 checklist, pending rulings, landmines refreshed (zero known failures era begins) 2026-09-01 23:27:22 -07:00
gramps 4509befb98 docs: capture tonight's rulings — record-OR-stream (never both), scheduling scope, SYNC provenance, playout declined
- Modes: creator ruling 2026-09-01 — the VOD is the copy; dual encode degrades both
  outputs on mediocre hardware ('we're not them'). ai.md 'cheap on NVENC' claim
  retired; TASK 18 three-modes -> two; pills-become-radios noted as PENDING code.
- Design Principle gains 'we're not them' (hardware realism) as its fifth bullet.
- TASK 34 scope: countdown+notify live scheduling is the whole story — 'going live is
  our schedule'. Scheduled playout discussed & DECLINED; premiere/upload + playout +
  simultaneous-record-stream added to the closed out-of-product table.
- TASK 22: SYNC slider provenance restored — creator-requested (OBS delay-filter fix,
  native). Placement question stays open; capability does not.
- MyMistakes: the provenance rule (a fact without its who/why becomes a future argument).
Docs only — zero code in this commit, per creator instruction.
2026-09-01 23:26:12 -07:00
gramps af0d372b65 docs: record take-two outcomes — arrival-stamping rule, resolver key fix, top-bar model, TASK 18 verification status
Map corrections: ai.md encoded the webcam lookup bug verbatim (GetLatestFrame(WebcamId))
— code wins, line rewritten; frame-pipeline section gains the rawvideo arrival-stamping
lesson + stats log; TASK 18 status = running-app verification IN PROGRESS with take-3
pending on the pump starvation; state-model paragraph matches the new always-Start bar.
2026-09-01 22:27:08 -07:00
gramps 89fee6ca4a fix: webcam identity->device key for output frames; top bar always has a Start; light-pill grouping
Take two (2026-09-01) three confirmed defects, all from creator feedback + the
new resolver logging:

1. NO WEBCAM IN OUTPUT: WebcamSceneConfig.WebcamId carries the identity GUID;
   CameraManager keys sessions by DEVICE id — GetLatestFrame(guid) returned null
   forever, the compositor silently dropped the layer while the preview (bitmap
   path) looked fine. The resolver logging added in 8dcaee0 caught it red-handed.
   DeviceKeyForWebcam maps identity->device through the _webcam singleton (identity
   mismatch / unknown ids pass through). Test: WebcamOutputKeyTests.

2. START BUTTON VANISHED AFTER STOP: my pills-clear ruling met the old
   ShowPrimaryStartButton gate (needed IsConnected or a lit REC pill) — signed-out,
   pills-off = blank top bar, no session reachable. Start is now ALWAYS the idle
   face; unarmed Start records (local needs no account — no dead-end no-op); the
   Sign In button folds into Start's context menu ('Sign in to YouTube', shown while
   disconnected) with a dedicated SignInCommand. SessionTeardownTests extended:
   stopped session must leave a reachable Start.

3. TOP BAR ORDER (creator spec): [sign light] REC [pill]  [sign light] ON-AIR [pill]
   — each reality lamp now sits in front of its own intent switch (was: two pills,
   then two orphaned dots). Sign labels keep the shared StatusSignText style.

Tests: WebcamOutputKey, SessionTeardown, FramePump, WebcamMenuGate, GlobalHotkey,
BroadcastPullOut — 14/14 across the six classes, clean build 0 warnings.
2026-09-01 22:25:20 -07:00
gramps 97ffc426fe chore(pump): per-stage timing stats — render vs submit, 5s windows
Take-two forensics: rawvideo carries no per-frame timestamps — ffmpeg stamps frames
by ARRIVAL at the declared fps, so a 2fps producer yields a 30x time-lapse, 0.6s-long
'60fps' file with zero errors anywhere (explains the creator's accelerated webcam
motion + truncated length observations). The pump now logs frames-produced-vs-target
per 5s with the avg render/submit split — take three names the slow stage instead
of us theorizing. FramePumpTests 9/9. (Multi-commit working unit: remaining declared
files land in the next commit.)
2026-09-01 22:25:02 -07:00
gramps 8dcaee0b5b fix(encoder): observability — silence was the bug
First real recording (2026-09-01): ffmpeg died at ~0.9s mid-session with ZERO log
lines while the pump fed a corpse for 9 more seconds. Three blind spots closed:
- OnStderrLine discarded everything non-progress: ffmpeg's actual words (startup
  config, warnings, the reason it quit) now go to AppLog prefixed 'ffmpeg:' (400-char
  cap so a \r-choked blob can't flood).
- Unexpected exit only fired ProcessFailed when code != 0 — a CLEAN exit vanished
  silently. Any exit we didn't request is now logged and fired, any code.
- ExitCode read raced StopAsync's Dispose (tonight's 'No process is associated'
  line) — guarded.
- VM output resolver logs once/5s when a webcam element resolves to a null frame
  (preview showed the cam, recording didn't — the layer skip was invisible).
FfmpegEncoderTests + FramePumpTests 21/21 against the new code, 0 warnings.
2026-09-01 22:11:52 -07:00
gramps 7f2bda8ed3 fix(audio): game meter scales by GameAudioVolume — meter/stream/headphones all follow the one knob
Creator report 2026-09-01: with the desktop slider dragged to 20%, the game bar's
meter stayed pegged yellow/red. Root cause: the mic meter's formula (level x volume)
was cloned to the game bar WITHOUT the x-volume factor, and the clone inherited a
false premise — that WASAPI loopback capture tracks the endpoint volume. Tonight's
observation disproves it (the tap is pre-endpoint-volume), so both the meter (display)
and the mix (AudioGainProvider.LoopbackGain, fixed in 5ead064) must scale it themselves.
ai.md's two 'loopback scales with the slider' sentences corrected in the same pass.

Test seam: VolumePushOverride (internal static, mirrors LayoutPathOverride pattern) so
the test never hijacks the machine's real volume. GameMeterHonestyTests (RealApp +
temp DB): unity -> hot/red, 20% -> ~1/5 width/green, push observed.
2026-09-01 21:22:57 -07:00
gramps 5ead064d54 fix(audio): _delayedMix never initialized — one NRE line behind the 'known failure', the class hang, AND the log flood; loopback gain now honest
The creator-declared known failure (Mix_HonorsProviderGains) and AudioPipelineTests'
standalone hang shared ONE root cause, born in TASK 22: AudioMixer.FillAndMix
dereferenced _delayedMix (declared float[]? , never assigned) as delayed.Length —
every live-mix tick NRE'd before the pipe write, so NO audio ever reached the wire
(tests starved -> hung/fail; app -> swallowed catch logged only ex.Message, 10ms
flood). Now: ctor-allocated + null-check, catch logs WITH stack via AppLog and is
throttled 5s, and a cancelled token breaks out before logging.

Bonus contract fix: AudioGainProvider.LoopbackGain returned unity on a disproven
premise ('loopback scales with endpoint volume'). The creator's tonight observation —
volume at 20%, meter pegged — proves the WASAPI tap is pre-endpoint-volume, so the
mixer must multiply by GameAudioVolume for stream honesty (this is what ai.md always
specified; the 'failing' test encoded the same and was RIGHT).

AudioPipelineTests: 25/25 in 26ms, standalone, no hang. Known-failure count: ZERO.
ai.md tests paragraph rewritten (no more suite-total claims, both ex-'knowns'
explained); TASK 22 regression recorded; MyMistakes: the known-failure-label rules.
2026-09-01 21:20:45 -07:00
gramps 5a1a3c566a fix(18): stop ends everything — pills clear, pump/prep failures roll the session back
Creator ruling 2026-09-01 (stuck REC pill after the failed first recording):
- StopStream clears RecordPillOn + OnAirPillOn — intent resets with reality.
- OnFramePumpFailed: dispatcher-marshalled FULL rollback via StopStream (was:
  toast + Error-status only when live — record-only sessions zombied with
  IsRecording=true over a dead encoder; the 20:34 attempt proved it). Toast copy
  says 'Recording stopped' vs 'stream pipeline stopped'.
- Same zombie class in the three go-live prep failure branches: StreamStatus.Error
  limbo replaced by StopStream() (audio loop + recording + pills unwind; a created
  broadcast still gets the close-out).
- AudioMixer.StopLive made explicitly idempotent (Stop() twice, rollback paths that
  never reached StartLive).
- Seams: OnFramePumpFailed + IsRecording setter internal (InternalsVisibleTo; test
  pattern mirrors LayoutPathOverride).
ONE integration test: SessionTeardownTests (real window + temp DB: pump death with
lit pill -> no zombie, no End button, Offline). AudioPipelineTests confirmed to hang
STANDALONE (pre-existing, the declared-known audio class) — ai.md test-count
paragraph corrected to stop claiming a suite total that cannot currently be measured.
2026-09-01 21:15:57 -07:00
gramps 688682d5b5 feat(9): real broadcast close-out — transition(complete) in StopStream after RTMP EOF
The specced 'End stream -> transition(complete)' call never existed: stopping relied
entirely on enableAutoStop (viewers sat on a frozen stream-offline for ~a minute, VOD
finalized late). Found during the 2026-09-01 recording-verification pass while the
creator asked 'if there's proper close-out info yt needs, we'll provide it?'

EndBroadcastAsync POSTs liveBroadcasts/transition?broadcastStatus=complete&id=..&part=status,
called after the pump stops (RTMP EOF first) and only when a live session had a broadcast —
record-only stops stay offline. invalidTransition/410 (autoStop already ended it) is logged
and returned as an error string, never thrown: a stop must never fail over close-out.
ONE integration test (URL shape + never-throws on 403). ai.md/TASKS.md design lines marked
SHIPPED with the map-lie note.

Ref: https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition
2026-09-01 20:55:06 -07:00
gramps 22b780e075 fix(ffmpeg): re-pin dead BtbN tag (first real recording 404'd), wrap download failures in actionable IOException
The 2026-08-09 pin was a DAILY build; BtbN retention aged it out and the cold-cache
download 404'd on the creator's first native recording attempt (2026-09-01,
startup.log). Re-pinned to the MONTH-END build autobuild-2026-08-31-13-27
(N-126342, lgpl-shared win64 — 2-year retention; tag+variant recorded in TASKS.md
per the licensing rule). Verified alive: HEAD 200 + zip contents (bin/ffmpeg.exe,
bin/ffprobe.exe, 7 libav DLLs) match the name-agnostic extractor.
Also closes the wrap-gap: HttpRequestException escaped the locator untouched,
surfacing a raw 'Response status code... 404' from the frame pump; now wrapped in
IOException with the refresh-pin-or-install-ffmpeg message. FfmpegLocatorTests 7/7
(one updated, one added for the 404 case).
2026-09-01 20:52:23 -07:00
gramps 7294a1aa2d docs: HANDOFF — record the push (main == origin/main) 2026-09-01 19:35:50 -07:00
gramps b4f3b52b85 docs: rewrite HANDOFF — crash-fix outcomes, sole known failure (audio) + channel-init lead, verified test-run mechanics, 4 unpushed commits flagged 2026-09-01 19:34:10 -07:00
gramps 6799c40175 fix: first native launch since refactor crashed — 3 stacked faults, all closed; RoundClip 'known failure' root-caused and green
User launch (2026-09-01 19:07) NRE'd in MainViewModel ctor:
1. SceneGraph (TASK 31) was 'null!'-declared, assigned mid-ctor, but Scenes is touched
   ~120 lines earlier — field-initialized now.
2. LeftPanel extraction (c9fd1bd) moved StaticResource users (EyeButton/EyeIconStyle)
   into a UserControl while the styles stayed window-scope — invisible at parse time;
   moved to Themes/Controls.xaml (app scope, the existing rule). Full audit: these were
   the only two offenders (grep of Controls/*.xaml StaticResource keys vs app dictionary).
3. RoundClipInteractionTests — the second 'known failure' the map never explained: it
   was two stale-test layers (window.FindName across the new UserControl namescope +
   VisualTreeHelper.HitTest, which returned the IsHitTestVisible=False WebViewHostPanel
   overlay for EVERY point; UIElement.InputHitTest — the real input pipeline — shows the
   corner IS grabbable in both Traditional and Round). Test fixed, no product bug.
Verified: clean rebuild 0 warnings; app boots (log shows full MainWindow loaded; user
clicked + closed, zero new exceptions; real DB Webcam row = the genuine C920, untouched);
RoundClip + 6 RealApp classes pass natively per-class.
Docs: ai.md known-failure note → 246/247 (audio only); TASK 31 verification paragraph
corrected ('cannot run headless' overstated — per-class Windows-host vstest runs them);
MyMistakes: InputHitTest-vs-VTH recipe + namescope/app-style + shared-log facts.
Spin-guard citations: WPF Visual Tree Overview (InputHitTest vs VisualTreeHelper hit
semantics) + XAML namescope docs, learn.microsoft.com.
2026-09-01 19:33:31 -07:00
gramps 711ba54af7 docs: rewrite HANDOFF for session end; import the TASK 21 picker spec from the old handoff into TASKS.md
The picker-slice deliverables + loop-provider rule lived only in HANDOFF (rewrite-every-
session = lose-it). Moved into TASK 21 where it's durable; HANDOFF rewritten: locked
decisions 1-9, queue order, landmines (incl. the 2 unpushed docs commits), no-suggestions
note carried.
2026-09-01 19:04:21 -07:00
gramps e1d8b10388 docs: v1 = feature-complete ruling — queue TASKs 32-36, close the out-of-product list, fix the map's lies
Audit of institutional knowledge lost across the refactor (creator PM session 2026-09-01):
- New queue: TASK 32 resilience (blip retry/measured-grace sign/one-click back-on-air/
  crash-safe fragmented-MP4 recording/pre-flight), TASK 33 auto step-down (v1 - the map
  claimed it existed; it didn't), TASK 34 drawer scheduling, TASK 35 scene-linked audio,
  TASK 36 gold pass (visibility unlock, flash-live enable, screens/layers audit, native
  verification suite, expiry reminders, signing/installer/EULA/Velopack, compile-flag
  ceiling HARDENED+MOCK_REWARDS).
- 'v1 = the finished product' + closed 'Out of product' list (Stream Deck, profiles,
  chroma, virtual cam, replay buffer, restream, clipping, advanced-tab, bug-reporter,
  D3DImage preview) - the 10% margin, bounded and written.
- Corrections: ffmpeg 'does reconnect' claims (input-side flags only; retry is app-side);
  TASK 2's orphaned 'resume deferred to TASK 3' now owned by TASK 32; TASK 2 End-signs-out
  superseded by TASK 18 explicit sign-out; Alerts freed from stale 'the one paid feature'
  language (paid = flash removal only); TASK 3 item 16 superseded; TASK 9 items 4/6/7
  reconciled; TASK 10 monetization chain scoped v1; README roadmap/Structure rewritten.
2026-09-01 19:03:22 -07:00
gramps 7c6ec3e40a docs: pre-GA posture — visibility lock rationale, branding-flash preview-only + escalation, screens/layers audit retired into gold pass
- ai.md: Private-only lock is deliberate test-phase policy (channel protection); DVR/VOD stay on
  as invisible review tapes; unlock is a TASK 36 item, never opportunistic.
- ai.md: free-tier flash escalates cadence (build-time curve knob); pre-GA it renders in preview
  only, never on output/recordings; TASK 36 flips it live.
- ai.md: 2026-08-22 screens/layers audit landmine closed as a going-gold checklist requirement.
- TASKS.md: TASK 9 item 6 ☐→❌ deliberate lock, cross-referenced.
2026-09-01 15:32:16 -07:00
gramps 8fa54d2de7 finishing AIs work because tokens 2026-09-01 07:27:35 -07:00
gramps 4a321b19ff docs: rewrite HANDOFF for clean session end (fully pushed, project-wide read, no-suggestions note) 2026-08-31 20:30:50 -07:00
gramps 097dd0d9bd docs: hand off TASK 21 UI picker slice (acquisition + loop wiring spec)
All headless-testable TASK 21 decoder/mechanism slices shipped; the remaining UI
picker slice (AddMedia command + file dialog + Acquire/Release + Source.MediaIsLooping
-> IMediaFrameSource.Looping) is a GUI feature and is spec'd in HANDOFF for native
Windows build + verification. TASKS status + HANDOFF updated.
2026-08-31 20:17:09 -07:00
gramps 881addb5b4 TASK 21 slice 3: loop control in MediaVideoSource (process factory + Loop flag)
- IMediaFrameSource gains bool Looping.
- MediaVideoSource ctor takes Func<IDecodeProcess> processFactory instead of a
  single IDecodeProcess: a System.Diagnostics.Process can't be re-Start()ed, so
  each loop pass creates a fresh decoder. Decode wrapped in do-while(Looping):
  restart on natural EOF instead of raising Completed.
- Production wiring (MainViewModel media factory): passes the process factory
  AND FfmpegFrameRateProbe -- closes the slice-2b gap where production had no
  probe and therefore no pacing.
- Tests: loop test (single frame re-emits across passes, Completed only when
  loop cleared); fakes updated for the new interface member. Media tests 12/12,
  build 0 warnings.

Wiring Source.MediaIsLooping into the flag needs a manager-level per-path loop
provider -> lands with the UI-picker (acquisition) slice.

Derivative reference: looping media by restarting decode on EOF, standard in
playback/overlay tooling (OBS media source repeat).
2026-08-31 19:52:10 -07:00
gramps a2219be198 TASK 21 slice 2b: native-FPS pacing in MediaVideoSource (probe -> delay seam)
- MediaVideoSource takes optional IFrameRateProbe? + Func<TimeSpan,CancellationToken,Task>? delay
  seams (default Task.Delay); probes FPS once in RunAsync, delays by 1/fps after each
  emitted frame. No probe/unknown fps -> no pacing (ffmpeg pipe backpressure throttles).
- Test: MediaVideoSource_PacesFramesByProbedFps (fake probe returns 1000fps + recording
  delay; one delay per frame ~= 1ms). Media tests 5/5, build 0 warnings.

Derivative reference: per-frame delay pacing of decoded output, standard in media playback.
2026-08-31 19:47:38 -07:00
gramps 8fa78423e6 TASK 21 slice 2a: native-FPS probe seam (ffprobe parse + derive sibling)
- FfmpegFrameRateParser (pure): prefers avg_frame_rate= then r_frame_rate=,
  rational N/N/M, unknown/0 -> null.
- IFrameRateProbe + FfmpegFrameRateProbe: derives sibling ffprobe.exe from the
  located ffmpeg dir, reuses the IDecodeProcess seam for the ffprobe subprocess
  text; null if ffprobe absent.
- FfmpegLocator now also extracts ffprobe.exe (ProbeFileName) from the pinned
  archive, conditional so old caches without it degrade to no pacing.
- Tests: 6 pure parser units + 1 probe integration via fake locator/process;
  FfmpegLocatorTests still green. 15/15, 0 warnings.

Pacing (probe->delay) is slice 2b. Derivative reference: standard ffprobe
avg_frame_rate probing used across OBS/media tooling.
2026-08-31 19:45:48 -07:00
gramps 071ec8be0c TASK 21 slice 1 step 4: wire media into resolver + preview routing
Wire the MediaVideoSourceManager into the live pipeline (new partial
ViewModels/MainViewModel.Media.cs mirrors Webcam/Background partials):
readonly _mediaManager assigned in the core ctor (decode at 1920x1080 master,
dispatcher-coalesced preview), OnMediaPreviewBitmapChanged adopts the shared
bitmap onto every IsMediaSource with a matching MediaPath, a
Source { IsMediaSource, MediaPath } case in ResolveOutputFrame feeds
GetLatestFrame, and the manager is disposed on shutdown.

Source.DisplaySource now routes VideoImageSource for MediaSource (Source.cs),
so media previews/canvas show decoded video.

Compositor needs no change: BlitContent UniformToFill-scales any frame to the
element rect. Decoder/manager/seam derived-work mirrors ScreenCaptureManager
(citation in commit for slice-step-3).

Test (one unit for this change, mirroring the live-capture case):
Source_DisplaySource_IsVideoImageSource_WhenMediaSource. 14/14 media +
DisplaySource tests pass, build 0 warnings. Docs (TASKS/HANDOFF/ai.md) updated.
2026-08-31 19:32:41 -07:00
gramps 480ead0fc7 TASK 21 slice 1 step 3: IMediaFrameSource + MediaVideoSourceManager
Refactor MediaVideoSource onto IMediaFrameSource (Key/FrameAvailable/Completed/
StartAsync/StopAsync, renaming Start->StartAsync, FrameReady->FrameAvailable),
behavior preserved. Add MediaVideoSourceManager: app-wide decode-session owner
refcounted by MediaPath with Func<string,IMediaFrameSource?> factory seam,
Acquire/Release/ReleaseAll/GetLatestFrame, coalescing each file's frames onto
the UI dispatcher onto one shared WriteableBitmap; MediaFailed + PreviewBitmapChanged.

Mirrors ScreenCaptureManager (screen-capture session ownership + dispatcher
coalescing), per the codebase precedent and derivative-work rule.

Tests: MediaVideoSourceManagerTests (5 unit + 1 integration: single shared
bitmap, coalesce-to-latest); MediaVideoSourceTests updated for renames.
10/10 media tests pass, build 0 warnings. Docs (TASKS/HANDOFF/ai.md) updated.
2026-08-31 19:22:02 -07:00
gramps a1d9751a6f docs: record ffmpeg decode-contract verification recipe + WSL CLR boundary 2026-08-31 19:08:49 -07:00
gramps 8f490102ee TASK 21 Increment B: FFmpeg rawvideo video decoder -> VideoFrame
Adds the codec-agnostic decoder half of the media source. Spawns ffmpeg
with -f rawvideo -pix_fmt bgra (reusing the already-shipped ffmpeg via
IFfmpegLocator) and drains the raw BGRA stdout pipe into VideoFrames.

- Services/RawVideoFrameReader.cs: pure rawvideo BGRA stream -> frames
  (partial reads kept across Feed; no ffmpeg needed to test)
- Services/IDecodeProcess.cs + FfmpegDecodeProcess.cs: binary-stdout
  subprocess seam, mirror of the encoder's IEncoderProcess
- Services/MediaVideoSource.cs: owns the decode, raises FrameReady/Completed
- MediaVideoSourceTests: 3 pure reader + 1 integration (fake decode
  process through the real source loop, frames in order)

Reference (external scan): ffmpeg rawvideo pipe decode is the canonical
codec-agnostic frame feeds pattern (ffmpeg docs -f rawvideo; how OBS/media
pipelines push frames to a compositor). Verified: 4/4 tests, build 0 warnings.
Native-FPS pacing + resolver/compositor wiring are the next slice.
2026-08-31 18:57:29 -07:00
gramps f89b9f9ffa docs: state the default-device audio assumption (no device probing, no OS-level audio debugging) 2026-08-31 18:52:01 -07:00
gramps e8ff4df05f TASK 22: global audio sync offset (positive delay at the mixer out)
Adds AudioSyncOffsetMs (0..500ms, default 0) that delays the whole
interleaved-stereo mix so audio lands on the video when it runs ahead —
OBS's documented lip-sync fix. Positive-only: advancing audio needs a
video-side delay and is out of the audio layer's scope.

- Services/Audio/AudioSyncDelay.cs: pure delay line, flushed on Configure
- AudioMixer: Func<int> syncOffsetMs seam + delay applied post-limiter
- LayoutStore.Settings + MainViewModel.Audio/VM: load/save + binding
- PreviewPane mic bar: SYNC slider + status dot (IntToSyncBrushConverter)
- AudioSyncDelayTests: identity, negative/beyond-500 clamps, 10ms→960 samples

Reference (external scan): https://obs-versions.com/blog/how-to-fix-audio-delay-on-obs
(audio ahead => positive delay). Verified: build 0 warnings; 3/3 delay tests pass.
2026-08-31 18:30:03 -07:00
gramps 670fe3a10a TASK 31: SceneGraph component + baked-crust compositor optimization
- 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.
2026-08-31 17:53:34 -07:00
gramps 3bf053a3b1 TASK 31: SceneGraph design + baked-crust compositor optimization
Design captured in TASKS.md, pattern note in ai.md, handoff updated.
2026-08-31 11:38:12 -07:00
gramps 84a038c498 docs: record the true-decomposition pattern (ChatOverlayLayer) + handoff refresh
ai.md: Key patterns gains the 'true decomposition beats partial-shuffling'
rule + the glue-vs-component diagnostic (count OnPropertyChanged/SetProperty;
extract only zero/low-glue cohesive state blobs like chat's; leave binding glue
over already-extracted services alone; line count is a guideline not a goal).
ViewModels table row notes Chat.cs is now a thin facade over ChatOverlayLayer.

HANDOFF: everything through Commit G is pushed (origin/main=85893ea); the
remaining big decomposition is the scene-graph segment, flagged as design-needed
(not an unsupervised peel).
2026-08-31 11:16:02 -07:00
gramps 85893ea709 refactor: extract ChatOverlayLayer — real component decomposition, Commit G
The first true decomposition of the 4105-line god-object, not another partial
shuffle. MainViewModel.Chat.cs 194 -> 44 lines of thin delegation; all chat
behavior now lives in a self-contained Services/ChatOverlayLayer.cs (199):

- Owns the message buffer (Messages), ChatBoxRenderer, fade + mock-preview
  timers, per-source preview renders, and the live-output RenderFrame path.
- The VM keeps only the binding surface: ChatMessages delegates to
  _chatLayer.Messages (so XAML ItemsSource + LeftPanel CollectionChanged hold),
  CanAddYouTubeChat / ShowChatInactiveMessage stay computed on the VM
  (OnPropertyChanged raised from VM setters + XAML-bound), and thin forwards.
- Scenes handed in as args (no Func seam), so the layer owns no scene graph.

An AI reading ChatOverlayLayer.cs now sees the entire chat feature in one
self-contained unit. Zero behavior change; build 0 warnings; 246 pass, only the
2 known failures. Docs: ViewModels/index.md tracker updated in same commit.
2026-08-31 10:48:10 -07:00
gramps 3107f928ab refactor: extract recording-output concern into MainViewModel.Recording.cs — Commit F
The original premise (extract FFmpegEncoder / StreamHealthMonitor / FramePump
classes) was a no-op — those already exist as Services/Encoder/FfmpegEncoder.cs
and Services/Encoder/FramePump.cs. The honest functional seam was the local
recording-output concern, which was interleaved with live-stream orchestration:

- New MainViewModel.Recording.cs (121): StartRecordFile, FinalizeRecordingAsync,
  UniquePath, ChooseRecordFolder, ResetRecordFolder, DefaultRecordFolder + the
  recording fields (_recordFolder, RecordFolderDisplay, _activeRecordPath,
  _recordStartTime, _recordLength).
- MainViewModel.Streaming.Operations.cs 475 -> 377 (keeps live session lifecycle,
  health, visuals).
- MainViewModel.Streaming.cs 328 -> 323 (keeps pills/state/properties intact).

Same partial class — all MVVM bound-property glue untouched, no behavior change.
Build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md tracker updated in same commit.
2026-08-31 10:21:47 -07:00
gramps 5b9ff5b742 handoff: Phase 3 complete (all production .cs <= 500), push pending for A-E + docs
Rewrite HANDOFF.md: Phase 2 is pushed at 1f4624c (stale claim corrected);
Phase 3 commit rows A-E + Controls/index.md listed as local-only, push pending
on explicit approval. Records the re-structuring principle (split by concern,
500-line count is a ceiling not the goal) and the session's truncation-accident
lesson. MyMistakes.md gains the never-awk-into-the-source-you-read rule.
2026-08-31 09:11:32 -07:00
gramps dcb3637cca refactor: split LayoutStore by concern — 500-limit Phase 3, Commit E
LayoutStore.cs 1402 -> 47-line shell + 5 partials, grouped by functionality
(line count is a consequence, not the goal):

- LayoutStore.Migrations.cs: all DB schema migration (EnsureSchema, GetUserVersion,
  MigrateSourceTable/WebcamConfigTable/ToV3/SceneTable/SceneSocialBarColumn/
  SocialsTable/SocialEntryTable)
- LayoutStore.Load.cs: Load() scene/source read-back
- LayoutStore.Save.cs: Save() full scene tree write
- LayoutStore.Settings.cs: key/value settings + mic/record-folder/reusable-stream
  + license/transition/broadcast/hotkey/window-state GetSetting/UpsertSetting
- LayoutStore.Assets.cs: GetAssetBytes/UpsertAsset blob IO
- LayoutStore.cs: shell — Instance, fields, ctor, Dispose

Converted to public partial class LayoutStore : IDisposable. Pruned per-file
dead usings. Phase 3 DOWN: no production .cs exceeds 500 lines.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-31 09:10:17 -07:00
gramps d3271a0405 refactor: split SocialsDialogViewModel under 500 lines — 500-limit Phase 3, Commit D
SocialsDialogViewModel.cs 515 -> 334. Extracted the DialogEntry record + the
row-level SocialSlotViewModel (service/logo/lock/edit/validate state, computed
Show* flags) into a new SocialSlotViewModel.cs partial. The dialog VM keeps the
sign-in gate, validation orchestration, and commit. DialogEntry stays public in
the same namespace.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-31 08:59:38 -07:00
gramps fab2e09903 refactor: split MainViewModel.Background under 500 lines — 500-limit Phase 3, Commit C
Background.cs 567 -> 418. Moved the static scene-model helpers
(LoadBackgroundImage, CreatePlaceholderSnapshot, EnsureBackground,
CreateBackground, NormalizeBackgrounds, AllBackgrounds, ResolveAutoCaptureKey,
MonitorKeyPrefix) into a new MainViewModel.Background.Model.cs partial.
Background.cs keeps capture/live state + operations.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-30 22:32:45 -07:00
gramps 31362d1a43 refactor: split MainViewModel.Streaming under 500 lines — 500-limit Phase 3, Commit B
Streaming.cs 789 -> 330. Moved the go-live/record/health operation bodies into a
new MainViewModel.Streaming.Operations.cs partial (459 lines: StartSession,
BeginRecordOnly, BeginGoLive, StartRecordFile, PrepareAndStartLiveAsync,
PollHealthAsync/OnHealthPollTick/ApplyHealthIssue, StopStream,
FinalizeRecordingAsync, UniquePath, Choose/ResetRecordFolder, DefaultRecordFolder,
OnFramePumpFailed/HealthUpdated, ApplyHealth, ResetHealth, BuildEncoderOptions).
Streaming.cs keeps state, quality/resolution tiers, session timer, command
declarations. Pruned dead usings in both files.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-30 22:30:59 -07:00
gramps 6944db8ab4 refactor: split MainViewModel core under 500 lines — 500-limit Phase 3, Commit A
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.
2026-08-30 22:09:02 -07:00
gramps cb54637e9f docs: add Controls/index.md — the missing per-directory map for Phase-2 UserControls
Phase 2 (refactor-commit-12..18) created Controls/ with 6 UserControls that
split MainWindow.xaml, but no index.md existed — a gap against the schema.md
rule (one index.md per code directory). Documents each control, the window↔
control contract, and the Phase-2 landmines (window-resources converter rule,
control xmlns, name-looked-up host stubs, dropped-Border).
2026-08-30 21:41:18 -07:00
gramps 1f4624c636 docs: rewrite HANDOFF for Phase 2 completion (MainWindow.xaml → 6 controls)
Working tree clean. Phase 1 (pushed) + Phase 2 (local-only, commits 12-18 with
tags) both complete. Push checkpoint reached: next step is 'git push origin main
&& git push --tags' on explicit user approval. Landmines updated with the XAML
extraction gotchas (control xmlns, name-looked-up host stubs, window-resources
converter rule).
2026-08-30 21:16:50 -07:00
gramps 544208e002 refactor: extract OverlayHost (settings/bug/feature/about dialogs) — Phase 2, Commit 18
OverlayHost.xaml(.cs) takes the window-root overlay dialog grid (settings/
bug/feature/about panels); BackToAbout_Click moved in via inherited DataContext.
WebViewHostPanel + ToastArea stay as thin window-root stubs so the WebView2 ctor
init and NotificationService InArea('ToastArea') name lookups are untouched.
This completes Phase 2: MainWindow.xaml is now a 123-line shell.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in same commit.
2026-08-30 21:07:49 -07:00
gramps c9fd1bdaaa refactor: extract LeftPanel out of MainWindow.xaml — Phase 2, Commit 17
LeftPanel.xaml(.cs) takes the Row 1 Col 0 panel (layers + properties editor and
the live chat list). All property-editor/chat handlers moved into the control
(SourceNameBox edit, AddSourceButton menu, chat autoscroll self-subscribed on
Loaded, mirror/clip-shape, remove-source, background capture change/refresh,
opacity slider label, list drag-to-reorder, snapshot revert/accept family).

Window keeps only shell + routing: ctor wiring (_leftPanel), geometry, hotkeys,
taskbar overlay, BackToAbout, Window_PreviewMouseLeftButtonDown (uses
_leftPanel.IsClickInside), UpdateSelectionOverlay (calls _leftPanel.OnSelectionChanged).

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in same commit.
2026-08-30 20:09:46 -07:00
gramps 73397cfea7 refactor: extract SceneThumbnailStrip out of MainWindow.xaml — Phase 2, Commit 16
SceneThumbnailStrip.xaml(.cs) takes the Row 0 scene-picker strip (ItemsControl
over Scenes with staged/live border highlight); Thumbnail_MouseLeftButtonDown
moved into the control, transitioning via DataContext.TransitionToScene.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
2026-08-30 19:39:51 -07:00
gramps c5e3466df0 refactor: extract PreviewPane out of MainWindow.xaml — Phase 2, Commit 15
PreviewPane.xaml(.cs) takes the center preview (Row 1 Col 1): the 1920x1080
canvas (OverlayCanvas/SelectionOverlay/BrandFlashLayer/SocialBarElement),
the text pull-out drawer, and the live-controls row (TRAX / game / mic meters,
volume sliders, speaker toggles, socials button) inside the preserved
glow/background Border.

Code-behind moved into the control (drive by DataContext==MainViewModel):
preview drag/select/resize/hit-test (Preview_Mouse{Down,Move,Up},
EndPreviewDrag, HitHandle, HitElement), webcam context-menu handlers
(WebcamMenu_ChangeWebcam/SetBorderAnimation/StopBorderAnimation/
FindBorderShape/CreateBorderAnimation/AddDoubleAnimation/SetBorderColor/
HideInScene/Remove), social-bar glow (SocialBar_MouseLeftButtonDown,
SocialsButton_Mouse{Enter,Leave}, SocialGlowTick), audio controls
(VolumeSlider_*, GameVolumeSlider_*, MicSpeaker_*, GameSpeaker_*,
SetSliderValueFromClick, FindParent), and TraxButton_*.

Window keeps the left source/property/chat panel + thumbnail strip + overlay
host, and routes the selection overlay and preview hit-testing through the
control's public surface (_previewPane.UpdateSelectionOverlay(bool),
IsClickInsidePreview/Drawer, PreviewPane.IsDraggableElement). BackgroundMenu_*
stayed in the window because the left-panel source context menu uses them
(the preview's own desktop/refresh menu binds RefreshCaptureCommand instead).

Preserved the outer preview Border (background/glow/margin) that was initially
dropped in the cut — caught and restored before commit. Pruned now-unused usings
(System.Net.Http, Media.Effects, Media.Animation, Media.Imaging, Shapes,
Threading) from MainWindow.xaml.cs.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
2026-08-30 19:38:22 -07:00
gramps 90f5ad50dd refactor: extract TopBar out of MainWindow.xaml — Phase 2, Commit 14
TopBar.xaml(.cs) takes the Row 0 top controls: logo/About button, REC/ON-AIR
pills + status signs, avatar, Sign In/Start/End buttons. The avatar fetch
(AvatarHttp/_loadedAvatarUrl/LoadAvatarImage/ShowAvatarFallback) moved into the
control with async semantics preserved; the window grabs the control via
FindName in its ctor and calls _topBar.RefreshAvatar() when IsConnected or
AccountAvatarUrl changes (previously OnViewModelPropertyChanged -> LoadAvatarImage).
AccountLight is pure binding (never touched by code-behind) so it moved with the XAML.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Also dropped the now-unused System.IO using from MainWindow.xaml.cs.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
2026-08-30 09:13:47 -07:00
205 changed files with 26506 additions and 7010 deletions
+1
View File
@@ -10,3 +10,4 @@ Helpers/OAuthCredentials.cs
MONETIZATION.md
MARCOM.md
CREDENTIALS.md
.aider*
+1 -1
View File
@@ -38,7 +38,7 @@ conventions live here and in `ai.md`.
1. [`schema.md`](schema.md) — the memory-map conventions (what lives where, how to keep it true).
2. [`ai.md`](ai.md) — the AI guide: architecture, patterns, decisions, current state.
3. [`TASKS.md`](TASKS.md) — the task queue + authoritative YouTube API research.
3. [`TASKS.md`](TASKS.md) — the task catalog (index + queue state); open items and API research facts are in [`TASKS/`](TASKS/).
4. [`HANDOFF.md`](HANDOFF.md) — current operational state: what's in flight, landmines, next step.
5. `<dir>/index.md` — the index for any directory you're about to touch.
+4
View File
@@ -26,6 +26,10 @@ public partial class App : Application
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
AppLog.Write("App OnStartup begin");
// Attribution (2026-09-04): every take must be traceable to the exact binary
// that produced it — the wordmark shows the release counter (BuildStamp.Release);
// the per-build GUID is forensic only and lands here.
AppLog.Write($"Build {ytLive.Helpers.BuildStamp.Id} (compiled {ytLive.Helpers.BuildStamp.BuiltLocal})");
// Velopack auto-update bootstrap — checks for updates on startup and
// applies them (the app restarts automatically if an update is found).
Binary file not shown.
-16
View File
@@ -9,22 +9,6 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Gear icon: far left -->
<Button Grid.Column="0" ToolTip="Menu" Style="{StaticResource IconButton}"
VerticalAlignment="Center" Click="GearButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
<Separator/>
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"
Fill="#d0d0d0" Width="16" Height="16" Stretch="Uniform"/>
</Button>
<!-- Stream stats: centered under the preview pane -->
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
-11
View File
@@ -1,4 +1,3 @@
using System.Windows;
using System.Windows.Controls;
namespace ytLive.Controls;
@@ -9,14 +8,4 @@ public partial class BottomBar : UserControl
{
InitializeComponent();
}
private void GearButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
}
+607
View File
@@ -0,0 +1,607 @@
<UserControl x:Class="ytLive.Controls.LeftPanel"
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:models="clr-namespace:ytLive.Models">
<Border Background="#16213e" CornerRadius="6" Padding="12">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Edit mode (offline): layers + properties -->
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- LAYERS header + Add button -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="LAYERS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
<Button ToolTip="Add Layer" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
Click="AddSourceButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
IsEnabled="{Binding CanAddWebcam}"
ToolTip="{Binding WebcamAddToolTip}"/>
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
ToolTip="Timer overlay — Starting and BRB screens only"/>
<MenuItem Header="Web Resource" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
ToolTip="Embedded web page — set URI in Properties panel"/>
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
IsEnabled="{Binding CanAddYouTubeChat}"
ToolTip="One chat layer at a time — it's already in your stream"/>
<MenuItem Header="Stream Alerts" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.AlertBox}"
IsEnabled="{Binding CanAddAlerts}"
ToolTip="Celebration zone for Super Chat &amp; memberships — one alert layer at a time"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
</StackPanel>
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding StagedScene.Elements}"
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
PreviewMouseMove="List_PreviewMouseMove"
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
Foreground="#e0e0e0" FontSize="13">
<Helpers:FocusPreservingListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2"
Tag="{Binding DataContext.CanChangeBackground, RelativeSource={RelativeSource AncestorType=Window}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<MenuItem Header="Capture Window…"
ItemsSource="{Binding DataContext.OpenWindows, RelativeSource={RelativeSource AncestorType=Window}}">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command"
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<MenuItem Header="Refresh Window List" Click="BackgroundMenu_RefreshWindowList">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<Separator>
<Separator.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</Separator.Visibility>
</Separator>
<MenuItem Header="Remove Layer" Click="SourceMenu_Remove"
Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
</ContextMenu>
</Grid.ContextMenu>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
Padding="4,2" VerticalContentAlignment="Center"
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<Button Grid.Column="1" ToolTip="Edit Layer Name"
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}">
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
Width="13" Height="13" Stretch="Uniform"/>
</Button>
<Button Grid.Column="3" ToolTip="Remove Layer"
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
Visibility="{Binding IsBackground, Converter={StaticResource HiddenBoolToVis}}">
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</Helpers:FocusPreservingListBox>
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="#555" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
<!-- Resource alert: pinned to the bottom of the Layers panel.
Red when a required resource (the one-and-only webcam)
could not be locked; persists and re-polls until resolved. -->
<Border Grid.Row="3" Margin="0,8,0,0" Padding="8,6" CornerRadius="4"
Background="#3a1216" BorderBrush="#e94560" BorderThickness="1"
Visibility="{Binding WebcamLockAlert, Converter={StaticResource NotNullToVis}}">
<StackPanel>
<TextBlock Text="{Binding WebcamLockAlert}" Foreground="#ffb4b4" FontSize="11"
TextWrapping="Wrap"/>
<Button Content="Retry" Command="{Binding RetryWebcamLockCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="10,2"
HorizontalAlignment="Right" Margin="0,6,0,0"/>
</StackPanel>
</Border>
<!-- ELEMENTS panel: per-element config beneath Sources.
Collapses entirely when no element is selected. -->
<Rectangle Grid.Row="4" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}"/>
<StackPanel Grid.Row="5" Orientation="Horizontal" Margin="0,8,0,0"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
</StackPanel>
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<StackPanel x:Name="ElementsPanel" Margin="0,4,0,0">
<!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="Opacity" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#a0a0b0" FontSize="11"
DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0" Width="32"/>
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
</DockPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4"
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
<Button Content="{Binding SelectedElement.MirrorButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Click="MirrorButton_Click"/>
<Button Content="{Binding SelectedElement.ShapeButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Margin="8,0,0,0" Click="ShapeButton_Click"/>
</StackPanel>
<TextBlock Text="Drag to move · drag the corner dot to resize"
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
</StackPanel>
<!-- Webcam border config -->
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
ToolTip="Pick color"/>
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
MaxLength="7" ToolTip="#RRGGBB"/>
</DockPanel>
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0"
Click="WebcamBorderAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert"
Click="WebcamBorderRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="10"
Value="{Binding SelectedElement.BorderWidth, Mode=TwoWay}"
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Foreground="#a0a0b0" FontSize="11"
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
</DockPanel>
</StackPanel>
<!-- Countdown config -->
<StackPanel Visibility="{Binding SelectedElement.IsCountdown, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Timer (minutes)" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0"
Click="CountdownAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert"
Click="CountdownRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Slider DockPanel.Dock="Left" Width="120" Minimum="1" Maximum="60"
Value="{Binding SelectedElement.TimerMinutes, Mode=TwoWay}"
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
<TextBlock Text="{Binding SelectedElement.TimerMinutes}" Foreground="#a0a0b0" FontSize="11"
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
</DockPanel>
</StackPanel>
<!-- Web source config -->
<StackPanel Visibility="{Binding SelectedElement.IsWebSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
Click="WebAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert" IsTabStop="False"
Click="WebRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<TextBox Text="{Binding SelectedElement.WebUri, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
ToolTip="https://example.com"/>
</DockPanel>
</StackPanel>
<!-- Chat box config -->
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Chat Overlay" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
<!-- Font Size -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font Size" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatFontSizeSlider" Width="120" Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatFontSize, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFontSize, StringFormat={}{0:0}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
</DockPanel>
<!-- Font Family -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
SelectedValuePath="Content"
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
Width="140" VerticalAlignment="Center">
<ComboBoxItem Content="Segoe UI"/>
<ComboBoxItem Content="Arial"/>
<ComboBoxItem Content="Consolas"/>
<ComboBoxItem Content="Courier New"/>
<ComboBoxItem Content="Comic Sans MS"/>
<ComboBoxItem Content="Mouse Memoirs"/>
</ComboBox>
</DockPanel>
<!-- Font Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Member Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Super Chat Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Max Messages -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatMaxMsgsSlider" Width="120" Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatMaxMessages, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatMaxMessages}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
</DockPanel>
<!-- Background Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Background Opacity -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Bg Opacity" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatBgOpacitySlider" Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.ChatBackgroundOpacity, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatBackgroundOpacity, StringFormat={}{0:0%}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
</DockPanel>
<!-- Fade After (seconds) -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Fade After" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatFadeSlider" Width="120" Minimum="0" Maximum="60" TickFrequency="5" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatFadeSeconds, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFadeSeconds, StringFormat='{}{0}s'}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="28"/>
</DockPanel>
<!-- Toggles -->
<CheckBox Content="Show timestamps" Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,2"
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
<CheckBox Content="Show badges" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
</StackPanel>
<!-- Stream Alerts config -->
<StackPanel Visibility="{Binding SelectedElement.IsAlertBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Stream Alerts" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
<TextBlock Text="The built-in clip plays on every alert (Super Chat, membership…) unless a custom video is picked."
Foreground="#888" FontSize="10" TextWrapping="Wrap" Margin="0,0,0,6"/>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Built-in video" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<ToggleButton Style="{StaticResource PillToggle}"
IsChecked="{Binding SelectedElement.AlertUseDefaultVideo, Mode=TwoWay}"
HorizontalAlignment="Left"/>
</DockPanel>
<Grid Margin="0,0,0,4"
Visibility="{Binding SelectedElement.AlertUseDefaultVideo, Converter={StaticResource InverseBoolToVis}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding SelectedElement.AlertVideoPath, Mode=OneWay}"
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
FontSize="11" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseAlertVideoCommand}"
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
Margin="4,0,0,0" Padding="8,2"/>
</Grid>
<Button Content="Reset to built-in" Command="{Binding ResetAlertVideoCommand}"
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,4,0"/>
<TextBlock Text="Custom clips are referenced from disk — never stored in the layout."
Foreground="#888" FontSize="10" TextWrapping="Wrap" Margin="0,4,0,6"/>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Message ticker" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<ToggleButton Style="{StaticResource PillToggle}"
IsChecked="{Binding SelectedElement.AlertShowTicker, Mode=TwoWay}"
HorizontalAlignment="Left"/>
</DockPanel>
<!-- Announcement display: scroll (marquee), flash (pulse) or
solid (still). Inline items, not a bound ItemsSource —
same shape as the chat Font selector above. -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Display" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
SelectedValuePath="Tag"
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
Width="140" VerticalAlignment="Center">
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
<ComboBoxItem Content="Flash" Tag="Flash"/>
<ComboBoxItem Content="Solid" Tag="Solid"/>
</ComboBox>
</DockPanel>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Volume" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<Slider Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.AlertVideoVolume, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.AlertVideoVolume, StringFormat={}{0:0%}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
</DockPanel>
</StackPanel>
<!-- Background properties -->
<StackPanel Margin="0,8,0,0"
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Background" Style="{StaticResource SubHeader}"/>
<StackPanel Margin="4,0,0,0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Use default background:" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center"/>
<ToggleButton Style="{StaticResource PillToggle}"
IsChecked="{Binding SelectedElement.UseDefaultBackground, Mode=TwoWay}"
Margin="8,0,0,0"/>
</StackPanel>
<Grid Margin="0,0,0,4"
Visibility="{Binding SelectedElement.UseDefaultBackground, Converter={StaticResource InverseBoolToVis}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding SelectedElement.CustomBackgroundPath, Mode=OneWay}"
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
FontSize="11" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
</Grid>
<TextBlock Text="{Binding BackgroundSizeMismatchText}"
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap"
Margin="0,4,0,0"
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- Live mode: YouTube Live Chat (entire left panel replaced) -->
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
<!-- Inactive overlay (shown when NOT live) -->
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Chat is inactive" Foreground="#a0a0b0" FontSize="13"
HorizontalAlignment="Center" Margin="0,0,0,4"/>
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
Foreground="#666" FontSize="11" TextWrapping="Wrap"
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
</StackPanel>
<!-- Message feed (shown when live) -->
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ChatMessages}"
ItemContainerStyle="{StaticResource YtListBoxItem}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
x:Name="ChatListBox">
<ListBox.ItemTemplate><DataTemplate>
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
<Setter Property="Background" Value="#2a1a0a"/>
<Setter Property="BorderBrush" Value="#eab308"/>
<Setter Property="BorderThickness" Value="1,0,1,0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<TextBlock Text="&#x2B50;" FontSize="10" Margin="0,0,4,0"
VerticalAlignment="Center"
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding AuthorName}" Foreground="#e94560"
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
Foreground="#eab308" FontWeight="Bold" FontSize="12"
Margin="8,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="#eab308"
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<TextBlock Text="{Binding Message}" Foreground="#e0e0e0"
TextWrapping="Wrap" Margin="0,2,0,0"/>
</StackPanel>
</Border>
</DataTemplate></ListBox.ItemTemplate>
</ListBox>
</Grid>
</Grid>
</Border>
</UserControl>
+322
View File
@@ -0,0 +1,322 @@
using System.Collections;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Controls;
public partial class LeftPanel : UserControl
{
private Models.SceneElement? _snapshotElement;
private string _snapshotBorderColor = string.Empty;
private int _snapshotBorderWidth;
private int _snapshotTimerMinutes = 15;
private string? _snapshotWebUri;
private Point _dragStartPoint;
private int _dragIndex = -1;
private bool _isDragging;
private object? _lastHoveredItem;
private bool _dragReordered;
public LeftPanel()
{
InitializeComponent();
Loaded += (_, _) =>
{
if (DataContext is MainViewModel vm)
vm.ChatMessages.CollectionChanged += OnChatMessagesChanged;
};
Unloaded += (_, _) =>
{
if (DataContext is MainViewModel vm)
vm.ChatMessages.CollectionChanged -= OnChatMessagesChanged;
};
}
// Called by the window's UpdateSelectionOverlay (edit mode only).
public void OnSelectionChanged(MainViewModel vm)
{
if (vm.SelectedElement is { } s && PreviewPane.IsDraggableElement(s))
OpacityValueText.Text = $"{Math.Round(s.Opacity * 100)}%";
TakeSnapshot(vm);
}
// Deselection guard: true when the click is inside the source list or the
// element property scroller (window's Window_PreviewMouseLeftButtonDown).
public bool IsClickInside(DependencyObject original)
=> IsDescendantOf(original, SourceList) || IsDescendantOf(original, ElementsScrollViewer);
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
{
while (child != null && !ReferenceEquals(child, ancestor))
{
child = child is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child)
: LogicalTreeHelper.GetParent(child);
}
return child != null;
}
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
{
box.Focus();
box.SelectAll();
}
}
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
{
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
}
}
private void MirrorButton_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
webcam.IsMirrored = !webcam.IsMirrored;
}
private void ShapeButton_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
webcam.ToggleClipShape();
}
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
vm.RemoveSourceCommand.Execute(element);
}
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
{
_ = (DataContext as MainViewModel)?.ChangeBackgroundCaptureAsync();
}
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
{
(DataContext as MainViewModel)?.RefreshBackgroundAutoCapture();
}
private void BackgroundMenu_RefreshWindowList(object sender, RoutedEventArgs e)
{
(DataContext as MainViewModel)?.RefreshWindowsCommand.Execute(null);
}
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Enter or Key.Escape)
{
CommitSourceEdit(sender);
e.Handled = true;
}
}
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
{
CommitSourceEdit(sender);
}
private static void CommitSourceEdit(object sender)
{
if (sender is FrameworkElement { DataContext: SceneElement element })
element.IsEditing = false;
}
// ─── List drag-to-reorder (scenes list + sources list) ───
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var listBox = (ListBox)sender;
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
if (item == null)
{
if (ReferenceEquals(listBox, SourceList))
(DataContext as MainViewModel)!.SelectedElement = null;
_dragIndex = -1;
return;
}
if (listBox.ItemsSource is not IList items || items.Count == 0)
{
_dragIndex = -1;
return;
}
_dragIndex = listBox.Items.IndexOf(item.DataContext);
_dragStartPoint = e.GetPosition(listBox);
_isDragging = false;
_lastHoveredItem = null;
_dragReordered = false;
}
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (_dragIndex < 0) return;
var listBox = (ListBox)sender;
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
{
EndListDrag(listBox);
return;
}
var position = e.GetPosition(listBox);
if (!_isDragging)
{
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
return;
_isDragging = true;
listBox.CaptureMouse();
}
var item = FindItemContainerAt(listBox, position);
if (item == null) return;
var targetItem = item.DataContext;
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
return;
var targetIndex = listBox.Items.IndexOf(targetItem);
if (targetIndex < 0) return;
var dragged = items[_dragIndex];
items.RemoveAt(_dragIndex);
items.Insert(targetIndex, dragged);
_dragIndex = targetIndex;
_lastHoveredItem = targetItem;
_dragReordered = true;
}
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
EndListDrag((ListBox)sender);
}
private void EndListDrag(ListBox listBox)
{
if (_isDragging)
listBox.ReleaseMouseCapture();
var reordered = _dragReordered;
_dragIndex = -1;
_isDragging = false;
_lastHoveredItem = null;
_dragReordered = false;
if (reordered && DataContext is MainViewModel vm)
vm.OnSceneElementsReordered();
}
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
{
var hit = listBox.InputHitTest(position) as DependencyObject;
while (hit != null && hit != listBox)
{
if (hit is ListBoxItem item) return item;
hit = VisualTreeHelper.GetParent(hit);
}
return null;
}
private void TakeSnapshot(MainViewModel vm)
{
if (vm.SelectedElement is Models.Source src)
{
_snapshotElement = src;
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
_snapshotTimerMinutes = src.TimerMinutes;
_snapshotWebUri = src.WebUri;
}
}
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is not SceneElement src) return;
if (sender is FrameworkElement fe && fe.ContextMenu == null)
{
var menu = new System.Windows.Controls.ContextMenu();
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
foreach (var c in colors)
{
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
var brush = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
item.Icon = new System.Windows.Controls.Border
{
Width = 16, Height = 16,
Background = brush,
CornerRadius = new System.Windows.CornerRadius(2)
};
var color = c;
item.Click += (_, _) => src.BorderColor = color;
menu.Items.Add(item);
}
fe.ContextMenu = menu;
}
if (sender is FrameworkElement fe2)
fe2.ContextMenu.IsOpen = true;
}
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
src.BorderColor = _snapshotBorderColor;
src.BorderWidth = _snapshotBorderWidth;
}
}
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
}
}
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.TimerMinutes = _snapshotTimerMinutes;
}
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotTimerMinutes = src.TimerMinutes;
}
private void WebRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.WebUri = string.Empty;
_snapshotWebUri = string.Empty;
}
private void WebAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotWebUri = src.WebUri;
}
}
+172
View File
@@ -0,0 +1,172 @@
<UserControl x:Class="ytLive.Controls.OverlayHost"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers">
<Grid Grid.RowSpan="3"
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
Command="{Binding CloseOverlayCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"/>
</ControlTemplate>
</Button.Template>
</Button>
<Border Background="#0A0C1A" CornerRadius="8" Width="650" MaxHeight="700"
VerticalAlignment="Center" HorizontalAlignment="Center"
Padding="28" BorderBrush="#333" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0">
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
Command="{Binding CloseOverlayCommand}">
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
Fill="#d0d0d0" Width="14" Height="14" Stretch="Uniform"/>
</Button>
</Grid>
<!-- App Settings: broadcast fields live in the preview's
Text pull-out now — this panel keeps app-level prefs only.
(Currently empty; the overlay shell stays for future use.) -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Default Location for Recordings" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}"
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
<StackPanel Orientation="Horizontal">
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
</StackPanel>
<!-- Report Bug -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
Margin="0,0,0,12"
Text="{Binding BugReportText, UpdateSourceTrigger=PropertyChanged}"
VerticalScrollBarVisibility="Auto"/>
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding BugReportEmail, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
<Button Content="Send Report" Command="{Binding SubmitBugCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
</StackPanel>
<!-- Feature Request -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsFeatureOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="What would you like to see?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
Margin="0,0,0,12"
Text="{Binding FeatureRequestText, UpdateSourceTrigger=PropertyChanged}"
VerticalScrollBarVisibility="Auto"/>
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding FeatureRequestEmail, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
<Button Content="Send Request" Command="{Binding SubmitFeatureCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
</StackPanel>
<!-- About -->
<Grid Grid.Row="1" Margin="0,16,0,0"
Background="#171928"
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
<!-- Hub view -->
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,16,0,0"/>
<TextBlock Text="Free forever — live streams carry a brief &quot;made with LlamaCasty!&quot; flash. A subscription removes it and adds alerts."
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,12,0,0"/>
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,16,0,8"/>
<!-- Creator hub links -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
Style="{StaticResource YtButtonSecondary}"
IsEnabled="{Binding IsPremiumAvailable}"
ToolTip="Open the LlamaCasty subscription page"/>
<Button Content="Enter License Key" Command="{Binding OpenLicenseEntryCommand}"
Style="{StaticResource YtButtonSecondary}"
ToolTip="Activate a license key to remove the watermark"/>
</StackPanel>
<Button Content="Licenses &amp; legal" Command="{Binding OpenLicensesCommand}"
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
Margin="0,16,0,0"/>
</StackPanel>
<!-- Licensing/legal panel -->
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Third-party notices &amp; license texts" FontSize="15" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,8"/>
<Border Background="#10101e" CornerRadius="4" Padding="10" MaxHeight="360"
BorderBrush="#333" BorderThickness="1">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBlock Text="{Binding LicensesText}" Foreground="#b8b8c8" FontSize="11"
FontFamily="Consolas" TextWrapping="Wrap"/>
</ScrollViewer>
</Border>
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Left" Margin="0,12,0,0"
Click="BackToAbout_Click"/>
</StackPanel>
<!-- License key entry panel -->
<StackPanel Visibility="{Binding IsLicenseEntryOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Activate LlamaCasty Premium" FontSize="15" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,8"/>
<TextBlock Text="Enter your license key to remove the branding watermark."
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,12"/>
<TextBlock Text="License Key" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding LicenseKey, UpdateSourceTrigger=PropertyChanged}"
MaxLength="100"/>
<Button Content="Activate" Command="{Binding ActivateLicenseCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right"
Margin="0,0,0,8"/>
<TextBlock Text="{Binding LicenseMessage}" Foreground="#a0a0b0" FontSize="12"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Left" Margin="0,8,0,0"
Click="BackToAbout_Click"/>
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</UserControl>
+22
View File
@@ -0,0 +1,22 @@
using System.Windows;
using System.Windows.Controls;
using ytLive.ViewModels;
namespace ytLive.Controls;
public partial class OverlayHost : UserControl
{
public OverlayHost()
{
InitializeComponent();
}
private void BackToAbout_Click(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
{
vm.IsLicensesOpen = false;
vm.IsLicenseEntryOpen = false;
}
}
}
+960
View File
@@ -0,0 +1,960 @@
<UserControl x:Class="ytLive.Controls.PreviewPane"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers">
<Border Background="#0a0a1a" CornerRadius="6" Margin="8,0"
BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Preview monitor -->
<Grid x:Name="PreviewGrid" Grid.Row="0"
PreviewMouseLeftButtonDown="Preview_MouseLeftButtonDown"
PreviewMouseMove="Preview_MouseMove"
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp">
<Viewbox Stretch="Uniform">
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Show Desktop" IsCheckable="True"
IsChecked="{Binding BackgroundShowDesktop}"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
ToolTip="Show desktop when no fullscreen game is detected"/>
<MenuItem Header="Capture Desktop"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
ItemsSource="{Binding Displays}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command"
Value="{Binding DataContext.SetBackgroundDisplayCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<MenuItem Header="Capture Window…"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
ItemsSource="{Binding OpenWindows}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command"
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<MenuItem Header="Refresh Window List" Command="{Binding RefreshWindowsCommand}"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Refresh Desktop" Command="{Binding RefreshCaptureCommand}"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
</ContextMenu>
</Grid.ContextMenu>
<!-- Live desktop/game background (bottom layer; the static
image renders above it). -->
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"
IsHitTestVisible="False"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
IsHitTestVisible="False"/>
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
Width="1920" Height="1080">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Width="{Binding Width}" Height="{Binding Height}"
Opacity="{Binding Opacity}"
Background="Transparent"
RenderTransformOrigin="0.5,0.5">
<Grid.Style>
<Style TargetType="Grid">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<!-- The background is a fixed full-frame layer (rendered separately,
below the static background): never hit-testable, so it can't be
selected, dragged, or bring up the webcam context menu. -->
<DataTrigger Binding="{Binding IsBackground}" Value="True">
<Setter Property="IsHitTestVisible" Value="False"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
<Grid.RenderTransform>
<ScaleTransform ScaleX="{Binding MirrorScale}"/>
</Grid.RenderTransform>
<Image Source="{Binding DisplaySource}"
RenderOptions.BitmapScalingMode="HighQuality">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Collapsed"/>
<Setter Property="Stretch" Value="UniformToFill"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsImageSource}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsChatBox}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<!-- TASK 47: the alert box's frame (the decoded clip) is written onto
VideoImageSource exactly like the chat box's, so it needs the same
trigger. Without it the Image stayed Collapsed and every alert was
invisible in the preview while the decode logged healthy (2026-09-26). -->
<DataTrigger Binding="{Binding IsAlertBox}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsWebSource}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
<Setter Property="Stretch" Value="Fill"/>
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsWebcam}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<Viewbox Stretch="Uniform">
<Grid Width="1" Height="1">
<!-- Round webcam: a plain Image (the same efficient WriteableBitmap
path as Traditional) clipped to a unit circle and scaled by the
Viewbox — deliberately NOT an ImageBrush, whose per-frame
re-rasterization at full scale stalls the preview. -->
<Image Stretch="UniformToFill"
Source="{Binding DisplaySource}"
RenderOptions.BitmapScalingMode="HighQuality">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsWebcam}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Round"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
<Image.Clip>
<EllipseGeometry Center="0.5,0.5" RadiusX="0.5" RadiusY="0.5"/>
</Image.Clip>
</Image>
</Grid>
</Viewbox>
<!-- OBS-style border: stroked rect (Traditional) / centered circle (Round).
Animations are driven by code-behind in WebcamMenu_SetBorderAnimation
because DataTrigger storyboards in DataTemplates cannot be stopped
(no ExitActions namescope support). -->
<Rectangle IsHitTestVisible="False"
Stroke="{Binding BorderBrush}"
StrokeThickness="{Binding BorderWidth}">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding HasBorder}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
<Ellipse IsHitTestVisible="False"
Stroke="{Binding BorderBrush}"
StrokeThickness="{Binding BorderWidth}"
Width="{Binding RoundBorderSize}"
Height="{Binding RoundBorderSize}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding HasBorder}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Round"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Webcam…" Click="WebcamMenu_ChangeWebcam"
Visibility="{Binding IsWebcam, Converter={StaticResource BoolToVis}}"/>
<Separator/>
<MenuItem Header="Border Thickness">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Thickness" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="20" Margin="8,0,0,0"
Value="{Binding BorderWidth, Mode=TwoWay}"
VerticalAlignment="Center"/>
</StackPanel>
</MenuItem>
<MenuItem Header="Border Opacity">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Opacity" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="1" Margin="8,0,0,0"
Value="{Binding BorderOpacity, Mode=TwoWay}"
VerticalAlignment="Center"/>
</StackPanel>
</MenuItem>
<MenuItem Header="Border Color">
<MenuItem Header="None" Tag="" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=''}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#ffffff" Tag="#ffffff" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#ffffff}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#000000" Tag="#000000" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#000000}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#e94560" Tag="#e94560" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#e94560}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#22c55e" Tag="#22c55e" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#22c55e}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#eab308" Tag="#eab308" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#eab308}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#38bdf8" Tag="#38bdf8" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#38bdf8}"
Click="WebcamMenu_SetBorderColor"/>
</MenuItem>
<MenuItem Header="Border Effect">
<MenuItem Header="None" Tag="None" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=None}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Pulse" Tag="Pulse" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Pulse}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Chase" Tag="Chase" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Chase}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Rainbow" Tag="Rainbow" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Rainbow}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Shimmer" Tag="Shimmer" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Shimmer}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Marching Ants" Tag="MarchingAnts" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=MarchingAnts}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Glow" Tag="Glow" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Glow}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Electricity" Tag="Electricity" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Electricity}"
Click="WebcamMenu_SetBorderAnimation"/>
<MenuItem Header="Sparkles" Tag="Sparkles" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Sparkles}"
Click="WebcamMenu_SetBorderAnimation"/>
</MenuItem>
<Separator/>
<MenuItem Header="Hide in this scene" Click="WebcamMenu_HideInScene"/>
<MenuItem Header="Remove" Click="WebcamMenu_Remove"/>
</ContextMenu>
</Grid.ContextMenu>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid x:Name="SelectionOverlay" IsHitTestVisible="False"
Canvas.Left="{Binding SelectedElement.X}" Canvas.Top="{Binding SelectedElement.Y}"
Width="{Binding SelectedElement.Width}" Height="{Binding SelectedElement.Height}">
<Rectangle Stroke="#e94560" StrokeDashArray="4 3" StrokeThickness="3"/>
<Ellipse Width="26" Height="26" Fill="#e94560" Stroke="White" StrokeThickness="2"
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/>
</Grid>
<ItemsControl ItemsSource="{Binding DimRects}" IsHitTestVisible="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Rectangle Width="{Binding Width}" Height="{Binding Height}"
Fill="#000000" Opacity="0.55"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Rectangle IsHitTestVisible="False" Stroke="#e94560" StrokeThickness="3"
Canvas.Left="{Binding OutputRectX}" Canvas.Top="{Binding OutputRectY}"
Width="{Binding OutputRectWidth}" Height="{Binding OutputRectHeight}"
Visibility="{Binding IsOutputCropped, Converter={StaticResource BoolToVis}}"/>
<!-- Free-tier branding credit. The source is the SAME rendered
frame FramePump composites into the recording/stream
(BrandFlashPresenter → MainViewModel.BrandFlashFrame), so
the preview cannot drift from the broadcast. It replaces a
preview-only TextBlock that no viewer ever saw, and the
fade/spread/placement is the renderer's — there is no
storyboard here to keep in sync. -->
<Grid x:Name="BrandFlashElement" Width="1920" Height="1080"
Canvas.Left="0" Canvas.Top="0"
Background="Transparent" IsHitTestVisible="False"
Visibility="{Binding BrandFlashVisible, Converter={StaticResource BoolToVis}}">
<Image Source="{Binding BrandFlashImageSource}"
Stretch="Fill" RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
<!-- Alert announcement strip (TASK 47): the same global
1920px overlay the output compositor blits along the
top edge. Global resource — never a Source. Without it
the strip reached the stream but never the preview. -->
<!-- Alert ticker: the announcement strip draws INSIDE the Stream
Alerts box (creator ruling 2026-09-26), so this element is
placed and sized to that box's rect rather than pinned as a
1920px bar to the top edge of the preview. -->
<Grid x:Name="AlertTickerElement"
Canvas.Left="{Binding AlertTickerLeft}"
Canvas.Top="{Binding AlertTickerTop}"
Width="{Binding AlertTickerWidth}"
Height="{Binding AlertTickerHeight}"
Background="Transparent" IsHitTestVisible="False"
Visibility="{Binding AlertTickerVisible, Converter={StaticResource BoolToVis}}">
<Image Source="{Binding AlertTickerImageSource}"
Stretch="Fill" RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
<!-- Social bar: one centered horizontal row at the top or bottom of
the frame, green glow while enabled. Global resource — never a
Source, no Sources entry. Click toggles top ⇄ bottom. -->
<Grid x:Name="SocialBarElement" Width="1920" Canvas.Top="{Binding SocialBarTop}"
Background="Transparent"
Visibility="{Binding SocialBarVisible, Converter={StaticResource BoolToVis}}"
MouseLeftButtonDown="SocialBar_MouseLeftButtonDown"
Cursor="Hand">
<Grid.Effect>
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
</Grid.Effect>
<ItemsControl ItemsSource="{Binding Socials.Entries}"
HorizontalAlignment="Center" Margin="40,8"
IsHitTestVisible="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,0,20,0">
<Path Data="{Binding LogoData}" Fill="White" Width="24" Height="24"
Stretch="Uniform" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Handle}" Foreground="White"
FontSize="14" VerticalAlignment="Center"
Margin="8,0,0,0"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Canvas>
</Grid>
</Viewbox>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
Background="#CCe94560" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"
Visibility="{Binding WebcamError, Converter={StaticResource NotNullToVis}}">
<TextBlock Text="{Binding WebcamError}" Foreground="White" FontSize="12"
MaxWidth="520" TextTrimming="CharacterEllipsis"/>
</Border>
<TextBlock Text="Preview" Foreground="#333" FontSize="24"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowPreviewPlaceholder, Converter={StaticResource BoolToVis}}"/>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowEmptySceneHint, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Text="🖥️" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
<TextBlock Text="📷" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
<TextBlock Text="🖼️" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
<TextBlock Text="✏️" Foreground="White" FontSize="30"/>
</StackPanel>
<TextBlock Text="Make it yours!" Foreground="#e0e0e0" FontSize="18"
FontWeight="SemiBold" HorizontalAlignment="Center" Margin="0,18,0,0"/>
<TextBlock Text="Add layers — your webcam, your screen, images, and text — to craft a brand and identity that's unmistakably you."
Foreground="#a0a0b0" FontSize="13" Margin="0,8,0,0"
TextWrapping="Wrap" MaxWidth="440" TextAlignment="Center"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
<!-- ═══ RIGHT-EDGE PULL-OUTS (always visible) ═══
White "Stream Settings" + "YPP" tabs stacked on the preview's right
edge; each toggles its own drawer (the same animated-width pattern).
Only one drawer is open at a time — IsDrawerOpen exclusivity is
VM-enforced (opening one collapses the other). Any click outside
the rail collapses whichever is open (MainWindow code-behind). -->
<Grid Grid.Row="0">
<StackPanel x:Name="TextPullOutHost" Orientation="Horizontal"
HorizontalAlignment="Right" VerticalAlignment="Stretch">
<!-- The broadcast-metadata drawer -->
<Border Background="#16213e" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Width" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding BroadcastForm.IsDrawerOpen}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="320" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="0" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<!-- Fixed-width inner so text never reflows mid-animation -->
<Grid Width="320" Margin="14,10,14,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,8">
<TextBlock Text="BROADCAST TEXT" Style="{StaticResource SectionHeader}"
HorizontalAlignment="Left"/>
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Command="{Binding BroadcastForm.CloseDrawerCommand}"
ToolTip="Close">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
</Button>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="TITLE" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,10"
Text="{Binding BroadcastForm.Title, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="DESCRIPTION" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
<TextBox Style="{StaticResource YtTextBox}" Height="90" AcceptsReturn="True"
TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" Margin="0,0,0,10"
Text="{Binding BroadcastForm.Description, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="TAGS (COMMA-SEPARATED)" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,10"
Text="{Binding BroadcastForm.TagsCsv, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="VISIBILITY" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
<ComboBox Style="{StaticResource YtComboBox}" Margin="0,0,0,10"
ItemsSource="{Binding BroadcastForm.VisibilityOptions}"
SelectedItem="{Binding BroadcastForm.Visibility}"/>
<CheckBox Content="Made for kids" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,10"
IsChecked="{Binding BroadcastForm.MadeForKids}"/>
<TextBlock Text="SCHEDULED START" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
<TextBlock Text="{Binding BroadcastForm.ScheduledStartText}"
Foreground="#888899" FontSize="12" Margin="0,0,0,4"/>
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="2" Margin="0,10,0,0">
<Button Style="{StaticResource YtButton}" Content="Update Broadcast"
Command="{Binding BroadcastForm.UpdateBroadcastCommand}"/>
<TextBlock Text="{Binding BroadcastForm.UpdateError}"
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
Visibility="{Binding BroadcastForm.UpdateError, Converter={StaticResource NotNullToVis}}"/>
</StackPanel>
</Grid>
</Border>
<!-- The YPP-progress drawer (below Stream Settings on the rail) -->
<Border Background="#16213e" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Width" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Ypp.IsDrawerOpen}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="320" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="0" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid Width="320" Margin="14,10,14,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,8">
<TextBlock Text="YPP PROGRESS" Style="{StaticResource SectionHeader}"
HorizontalAlignment="Left"/>
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Command="{Binding Ypp.CloseDrawerCommand}"
ToolTip="Close">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
</Button>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Sign in to YouTube to see your Partner Program progress and eligibility checklist here."
Foreground="#d0d0e0" FontSize="13" TextWrapping="Wrap"
Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="Refreshing from YouTube…" Foreground="#a0a0b0" FontSize="12"
Margin="0,0,0,4"
Visibility="{Binding Ypp.IsLoading, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding Ypp.LoadError}" Foreground="#ef4444" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,4"
Visibility="{Binding Ypp.LoadError, Converter={StaticResource NotNullToVis}}"/>
<StackPanel Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="{Binding Ypp.DisplayName}" Foreground="#e0e0e0" FontSize="13"
FontWeight="SemiBold" Margin="0,0,0,10"/>
<TextBlock Text="SUBSCRIBERS — TIER 1 · FAN FUNDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="#e0e0e0" FontSize="13"/>
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,10">
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.Tier1SubsBarWidth}"/>
</Border>
<TextBlock Text="SUBSCRIBERS — TIER 2 · AD REVENUE" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.Tier2SubsText}" Foreground="#e0e0e0" FontSize="13"/>
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,10">
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.Tier2SubsBarWidth}"/>
</Border>
<TextBlock Text="PUBLIC UPLOADS (90 DAYS)" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.UploadsText}" Foreground="#e0e0e0" FontSize="13"/>
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,6">
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.UploadsBarWidth}"/>
</Border>
<TextBlock Text="LONG-FORM WATCH HOURS + SHORTS VIEWS" Style="{StaticResource YtLabel}" Margin="0,10,0,2"/>
<TextBlock Text="These arrive with the extended analytics update — subscriber + upload + standing progress is live now."
Foreground="#666677" FontSize="12" TextWrapping="Wrap"/>
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/>
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="#888899" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<CheckBox Content="Two-Step Verification switched on" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,0"
IsChecked="{Binding Ypp.TwoFactorEnabled}"/>
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
Command="{Binding Ypp.OpenTwoStepCommand}" ToolTip="Open Google's 2-Step Verification page">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="Set up on Google →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
</Button>
<CheckBox Content="AdSense account linked" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,0"
IsChecked="{Binding Ypp.AdSenseLinked}"/>
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
Command="{Binding Ypp.OpenEarnCommand}" ToolTip="Open the YouTube Earn page">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="How to link on YouTube →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
</Button>
<TextBlock Text="WHAT COUNTS" Style="{StaticResource YtLabel}" Margin="0,10,0,4"/>
<TextBlock Text="{Binding Ypp.WhatCountsText}" Foreground="#a0a0b0" FontSize="12"
TextWrapping="Wrap" Margin="0,0,0,6"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="2" Margin="0,10,0,0">
<Button Style="{StaticResource YtButton}" Content="Refresh from YouTube"
Command="{Binding Ypp.RefreshCommand}"/>
<TextBlock Text="{Binding Ypp.LastUpdatedText}" Foreground="#888899" FontSize="11" Margin="0,6,0,0"/>
</StackPanel>
</Grid>
</Border>
<!-- The Test-stream drawer (below YPP on the rail) -->
<Border Background="#16213e" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Width" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding TestSession.IsDrawerOpen}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="320" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="0" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid Width="320" Margin="14,10,14,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,8">
<TextBlock Text="TEST STREAM" Style="{StaticResource SectionHeader}"
HorizontalAlignment="Left"/>
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Command="{Binding TestSession.CloseDrawerCommand}"
ToolTip="Close">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
</Button>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="{Binding TestSession.SessionCaption}"
Foreground="#d0d0e0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,8"/>
<TextBlock Text="{Binding TestSession.ChatIdLine}"
Foreground="#888899" FontSize="11"
Visibility="{Binding TestSession.IsTestSession, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="MOCK CHAT INPUT" Style="{StaticResource YtLabel}" Margin="0,10,0,3"/>
<TextBlock Text="Sends a real live chat message — it round-trips through the actual chat poll and renders on your chat overlay."
Foreground="#a0a0b0" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,6"
Text="{Binding TestSession.SendMessageText, UpdateSourceTrigger=PropertyChanged}"/>
<Button Style="{StaticResource YtButton}" Content="Send Message"
Command="{Binding TestSession.SendTestMessageCommand}"/>
<TextBlock Text="SIMULATED EVENTS" Style="{StaticResource YtLabel}" Margin="0,14,0,3"/>
<TextBlock Text="Fired into your chat overlay locally — YouTube's insert API only creates text, so these never reach YouTube."
Foreground="#a0a0b0" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<Button Style="{StaticResource YtButtonSecondary}" Content="Subscriber" Padding="10,4" FontSize="11"
Margin="0,0,6,0" Command="{Binding TestSession.SimulateSubscriberCommand}"/>
<Button Style="{StaticResource YtButtonSecondary}" Content="New Member" Padding="10,4" FontSize="11"
Margin="0,0,6,0" Command="{Binding TestSession.SimulateMemberCommand}"/>
</StackPanel>
<Button Style="{StaticResource YtButtonSecondary}" Content="Super Chat (5.00)" Padding="10,4" FontSize="11"
HorizontalAlignment="Left" Command="{Binding TestSession.SimulateSuperChatCommand}"/>
<TextBlock Text="{Binding TestSession.LastEventText}"
Foreground="#22c55e" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
Visibility="{Binding TestSession.LastEventText, Converter={StaticResource NotNullToVis}}"/>
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="2" Margin="0,10,0,0">
<Button Style="{StaticResource YtButton}" Content="End Test"
Command="{Binding TestSession.EndTestCommand}"
Visibility="{Binding TestSession.IsTestSession, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="Test broadcasts are private — nobody is notified, and it runs through the exact same pipeline as a real live."
Foreground="#666677" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"/>
</StackPanel>
</Grid>
</Border>
<!-- The stacked white tabs -->
<StackPanel Orientation="Vertical" HorizontalAlignment="Right" VerticalAlignment="Stretch">
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="Broadcast text fields">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick"
Command="{Binding BroadcastForm.ToggleDrawerCommand}"/>
</Border.InputBindings>
<TextBlock Text="Stream Settings" Foreground="#e94560" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
</Border>
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="YouTube Partner Program progress">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick"
Command="{Binding Ypp.ToggleDrawerCommand}"/>
</Border.InputBindings>
<TextBlock Text="YPP" Foreground="#e94560" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
</Border>
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="Test stream — mock chat input and simulated events">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick"
Command="{Binding TestSession.ToggleDrawerCommand}"/>
</Border.InputBindings>
<TextBlock Text="TEST" Foreground="#9c6f1c" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
</Border>
</StackPanel>
</StackPanel>
</Grid>
<!-- ═══ LIVE CONTROLS (below preview, never overlaps) ═══
Line 1: Socials + TRAX meter (desktop/game audio)
Line 2: Mic meter + volume -->
<StackPanel Grid.Row="2" Orientation="Vertical" HorizontalAlignment="Center"
VerticalAlignment="Center" Margin="0,4">
<!-- Line 1: Socials + TRAX -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,4">
<!-- Socials button: hover glows the social bar in the preview -->
<Button x:Name="SocialsButton"
Command="{Binding OpenSocialDialogCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
FontSize="11" VerticalAlignment="Center" Margin="0,0,12,0"
ToolTip="Add or manage your social handles"
MouseEnter="SocialsButton_MouseEnter"
MouseLeave="SocialsButton_MouseLeave">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Socials"/>
</StackPanel>
</Button>
<!-- TRAX meter -->
<Border Background="#D9000000" CornerRadius="6" Padding="12,8">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource YtButtonSecondary}" Padding="8,2"
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTipService.ToolTip="{Binding TraxToolTip}"
ToolTipService.Placement="Top"
Click="TraxButton_Click"
PreviewMouseRightButtonUp="TraxButton_PreviewMouseRightButtonUp">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding TraxDotBrush}"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="TRAX"/>
</StackPanel>
</Button>
<TextBlock Text="Desktop Audio" Foreground="#a0a0b0" FontSize="11"
FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="Desktop/game audio via WASAPI loopback (automatic); TRAX music rides the same channel"/>
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
ClipToBounds="True" VerticalAlignment="Center">
<Grid>
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
<Border HorizontalAlignment="Left" Width="{Binding GameMeterFillWidth}"
Background="{Binding GameMeterBrush}" Opacity="0.75"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
Visibility="{Binding TraxError, Converter={StaticResource NotNullToVis}}"
VerticalAlignment="Center" Margin="4,0">
<TextBlock Text="{Binding TraxError}" Foreground="#e94560" FontSize="9"
FontWeight="SemiBold" VerticalAlignment="Center"/>
</ScrollViewer>
</Grid>
</Border>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding GameMuteText}"
MouseLeftButtonUp="GameSpeaker_MouseLeftButtonUp">
<Path Fill="#d0d0d0" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding GameMuted, Converter={StaticResource InverseBoolToVis}}"/>
<Path Fill="#ef4444" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
Data="M21,3 L3,21"
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Slider Width="130" Minimum="0" Maximum="1"
Value="{Binding GameAudioVolume, Mode=TwoWay}" VerticalAlignment="Center"
PreviewMouseLeftButtonDown="GameVolumeSlider_PreviewMouseLeftButtonDown"
PreviewMouseLeftButtonUp="GameVolumeSlider_PreviewMouseLeftButtonUp"
LostMouseCapture="GameVolumeSlider_LostMouseCapture"/>
</StackPanel>
</Border>
</StackPanel>
<!-- Line 2: Mic meter + volume -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Command="{Binding OpenMicPickerCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="{Binding MicStatusToolTip}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding MicStatusBrush}"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="MIC"/>
</StackPanel>
</Button>
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
ClipToBounds="True" VerticalAlignment="Center">
<Grid>
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
<TextBlock Text="{Binding MicSourceName}" Foreground="#b8b8c8" FontSize="10"
VerticalAlignment="Center" Margin="6,0,0,0" MaxWidth="270"
TextTrimming="CharacterEllipsis"
Visibility="{Binding MicSourceName, Converter={StaticResource NotNullToVis}}"/>
<Border HorizontalAlignment="Left" Width="{Binding MeterFillWidth}"
Background="{Binding MeterBrush}" Opacity="0.75"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
</Grid>
</Border>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
<Path Fill="#d0d0d0" Stretch="Uniform"
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
Visibility="{Binding MicMuted, Converter={StaticResource InverseBoolToVis}}"/>
<Path Fill="#ef4444" Stretch="Uniform"
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
Data="M21,3 L3,21"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Slider Width="130" Minimum="0" Maximum="1"
Value="{Binding MicVolume, Mode=TwoWay}" VerticalAlignment="Center"
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
<StackPanel Orientation="Horizontal" Margin="10,0,0,0" VerticalAlignment="Center"
ToolTip="Audio sync (−500..+500 ms). Positive: delays the audio when it runs ahead of the video. Negative: advances it (drops the first |N| ms of the recording) when it runs behind. Calibrate with a clap: is the clap late in the video, move negative; early, move positive.">
<TextBlock Text="AUDIO SYNC" FontSize="9" Foreground="#9898aa"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<Slider Width="70" Minimum="-500" Maximum="500"
Value="{Binding AudioSyncOffsetMs, Mode=TwoWay}"
IsEnabled="{Binding IsEditMode}"
VerticalAlignment="Center"
ToolTip="{Binding AudioSyncOffsetMs, StringFormat=Audio sync: {0} ms}"/>
</StackPanel>
</StackPanel>
</StackPanel>
</Grid>
</Border>
</UserControl>
+464
View File
@@ -0,0 +1,464 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Controls;
public partial class PreviewPane : UserControl
{
private bool _isDraggingOverlay;
private bool _isResizing;
private Point _grabOffset;
private double _resizeAspect;
private readonly System.Collections.Generic.Dictionary<string, Storyboard> _activeBorderAnimations =
new();
private DispatcherTimer? _socialGlowTimer;
private double _socialGlowAngle;
public PreviewPane()
{
InitializeComponent();
}
// Static images and the webcam share the move/resize/selection behavior.
public static bool IsDraggableElement(SceneElement element)
=> element.IsDraggable;
public void UpdateSelectionOverlay(bool selected)
{
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
}
// ─── Clicking inside the preview / pull-out drawer (used by the window's
// Window_PreviewMouseLeftButtonDown deselection logic) ───
public bool IsClickInsidePreview(DependencyObject original)
=> IsDescendantOf(original, PreviewGrid);
public bool IsClickInsideDrawer(DependencyObject original)
=> IsDescendantOf(original, TextPullOutHost);
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
{
while (child != null && !ReferenceEquals(child, ancestor))
{
child = child is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child)
: LogicalTreeHelper.GetParent(child);
}
return child != null;
}
// ─── Audio meters + TRAX + socials (ROW 2 of the preview pane) ───
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is Slider slider)
SetSliderValueFromClick(slider, e);
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetVolumeAdjusting(true);
}
private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetVolumeAdjusting(false);
}
private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetVolumeAdjusting(false);
}
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is Slider slider)
SetSliderValueFromClick(slider, e);
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(true);
}
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(false);
}
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(false);
}
private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
{
if (FindParent<System.Windows.Controls.Primitives.Thumb>(e.OriginalSource as Visual) != null)
return;
var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track;
if (track == null) return;
var pos = e.GetPosition(track);
var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1);
slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
e.Handled = true;
}
private static T? FindParent<T>(Visual? visual) where T : Visual
{
while (visual != null)
{
if (visual is T match) return match;
visual = VisualTreeHelper.GetParent(visual) as Visual;
}
return null;
}
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
{
vm.ToggleMicMuteCommand.Execute(null);
}
}
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
{
vm.ToggleGameMuteCommand.Execute(null);
}
}
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
// loaded); right-click always opens the picker. The picker is the OS file
// dialog — the "never the OS viewer" rule covers the preview, not pickers.
private void TraxButton_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxLeftClick();
}
private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxRightClick();
e.Handled = true; // the button's right-click context menu must not fire
}
// ─── Webcam / background context menus (right-click on overlay elements) ───
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
=> (DataContext as MainViewModel)?.ChangeWebcamCommand.Execute(null);
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag }
|| !Enum.TryParse<BorderAnimation>(tag, out var animation))
return;
element.BorderAnimation = animation;
StopBorderAnimation(element.Id);
if (animation == BorderAnimation.None) return;
var shape = FindBorderShape(sender as DependencyObject);
if (shape == null) return;
var sb = CreateBorderAnimation(animation, shape);
if (sb == null) return;
sb.Freeze();
_activeBorderAnimations[element.Id] = sb;
shape.BeginAnimation(Shape.OpacityProperty, null);
Storyboard.SetTarget(sb, shape);
sb.Begin();
}
private void StopBorderAnimation(string elementId)
{
if (_activeBorderAnimations.TryGetValue(elementId, out var old))
{
old.Stop();
_activeBorderAnimations.Remove(elementId);
}
}
private static Shape? FindBorderShape(DependencyObject? start)
{
var current = start;
while (current != null)
{
if (current is ContextMenu cm && cm.PlacementTarget is FrameworkElement target)
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(target); i++)
{
var child = VisualTreeHelper.GetChild(target, i);
if (child is Shape s && (s is Rectangle or Ellipse) && !s.IsHitTestVisible)
return s;
}
return null;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private static Storyboard? CreateBorderAnimation(BorderAnimation animation, Shape shape)
{
var sb = new Storyboard();
switch (animation)
{
case BorderAnimation.Pulse:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.3, TimeSpan.FromSeconds(0.8), true);
break;
case BorderAnimation.Glow:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.5, TimeSpan.FromSeconds(1.2), true);
break;
case BorderAnimation.Shimmer:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.15, TimeSpan.FromSeconds(0.5), true);
break;
case BorderAnimation.Electricity:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.2, TimeSpan.FromSeconds(0.1), true);
break;
case BorderAnimation.Sparkles:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.4, TimeSpan.FromSeconds(0.3), true);
break;
case BorderAnimation.MarchingAnts:
shape.StrokeDashArray = new DoubleCollection { 4, 2 };
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -20, TimeSpan.FromSeconds(0.8), false);
break;
case BorderAnimation.Chase:
shape.StrokeDashArray = new DoubleCollection { 8, 4 };
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -30, TimeSpan.FromSeconds(1.5), false);
break;
case BorderAnimation.Rainbow:
if (shape.Stroke is SolidColorBrush brush)
{
var frozen = brush.IsFrozen;
if (frozen) brush = brush.Clone();
shape.Stroke = brush;
var colorAnim = new ColorAnimationUsingKeyFrames
{
Duration = new Duration(TimeSpan.FromSeconds(3)),
RepeatBehavior = RepeatBehavior.Forever
};
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.Zero)));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Yellow, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Green, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.DodgerBlue, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1.5))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.MediumPurple, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2.5))));
Storyboard.SetTargetProperty(colorAnim, new PropertyPath("(Shape.Stroke).(SolidColorBrush.Color)"));
sb.Children.Add(colorAnim);
}
break;
default:
return null;
}
return sb.Children.Count > 0 ? sb : null;
}
private static void AddDoubleAnimation(Storyboard sb, DependencyProperty prop,
double from, double to, Duration duration, bool autoReverse)
{
var anim = new DoubleAnimation(from, to, duration)
{
AutoReverse = autoReverse,
RepeatBehavior = RepeatBehavior.Forever
};
Storyboard.SetTargetProperty(anim, new PropertyPath(prop));
sb.Children.Add(anim);
}
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
element.BorderColor = color;
}
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
element.IsVisible = false;
}
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
(DataContext as MainViewModel)?.RemoveSourceCommand.Execute(element);
}
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
// unusable for shaky hands (jitter around the direction deadzone), so the bar
// rides on {Binding SocialBarTop} alone and a click flips it. ───
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
(DataContext as MainViewModel)?.ToggleSocialBarPosition();
e.Handled = true;
}
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
{
if (SocialBarElement == null) return;
// Kick the social bar to full brightness so it's immediately visible
var effect = SocialBarElement.Effect as DropShadowEffect;
if (effect != null)
{
effect.BlurRadius = 40;
effect.Opacity = 1.0;
}
// Pulse the glow so it catches the eye
_socialGlowAngle = 0;
_socialGlowTimer?.Stop();
_socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
_socialGlowTimer.Tick += SocialGlowTick;
_socialGlowTimer.Start();
}
private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
{
_socialGlowTimer?.Stop();
_socialGlowTimer = null;
if (SocialBarElement?.Effect is DropShadowEffect effect)
{
effect.BlurRadius = 18;
effect.Opacity = 0.9;
}
}
private void SocialGlowTick(object? sender, EventArgs e)
{
if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
_socialGlowAngle += 0.18;
if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
// Smooth sine pulse: BlurRadius 24→48, Opacity 0.7→1.0
double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
effect.BlurRadius = 24 + t * 24;
effect.Opacity = 0.7 + t * 0.3;
}
// ─── Preview: element selection / move / resize / hit-testing ───
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var vm = DataContext as MainViewModel;
if (vm is null || !vm.IsEditMode) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
var selected = vm.SelectedElement;
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
{
_isResizing = true;
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
grid.CaptureMouse();
e.Handled = true;
return;
}
var hit = HitElement(vm, p);
if (hit != null)
{
vm.SelectedElement = hit;
_isDraggingOverlay = true;
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
grid.CaptureMouse();
e.Handled = true;
return;
}
vm.SelectedElement = null;
}
private void Preview_MouseMove(object sender, MouseEventArgs e)
{
var vm = DataContext as MainViewModel;
if (vm is null || !vm.IsEditMode) return;
if (!_isDraggingOverlay && !_isResizing) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
var selected = vm.SelectedElement;
if (selected == null)
{
EndPreviewDrag(grid);
return;
}
if (_isDraggingOverlay)
{
var minX = -(selected.Width - 20);
var maxX = 1920 - 20;
var minY = -(selected.Height - 20);
var maxY = 1080 - 20;
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
}
else if (_isResizing)
{
var isWebcam = selected is WebcamSceneConfig;
var isRound = selected.ClipShape == ClipShape.Round;
var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(vm.StagedScene?.Name) : 1920;
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(vm.StagedScene?.Name) : 1080;
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
if (proportional && newH > maxH)
{
newH = maxH;
newW = newH * _resizeAspect;
}
selected.Width = newW;
selected.Height = newH;
}
}
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> EndPreviewDrag((Grid)sender);
private void EndPreviewDrag(Grid grid)
{
if (_isDraggingOverlay || _isResizing)
grid.ReleaseMouseCapture();
_isDraggingOverlay = false;
_isResizing = false;
}
private bool HitHandle(Point mouseScreen, SceneElement element)
{
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
}
private SceneElement? HitElement(MainViewModel vm, Point p)
{
var scene = vm.StagedScene;
if (scene == null) return null;
for (var i = scene.Elements.Count - 1; i >= 0; i--)
{
var element = scene.Elements[i];
if (!IsDraggableElement(element)) continue;
if (element is Source { IsEnabled: false }) continue;
if (element is WebcamSceneConfig { IsVisible: false }) continue;
if (p.X >= element.X && p.X <= element.X + element.Width &&
p.Y >= element.Y && p.Y <= element.Y + element.Height)
return element;
}
return null;
}
}
+54
View File
@@ -0,0 +1,54 @@
<UserControl x:Class="ytLive.Controls.SceneThumbnailStrip"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Border Background="#0d1117" CornerRadius="4" Margin="0,0,0,4" Padding="4">
<ItemsControl ItemsSource="{Binding Scenes}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" Columns="5"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border CornerRadius="4" Margin="2" Padding="2"
BorderThickness="2"
BorderBrush="Transparent"
Cursor="Hand"
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderBrush" Value="#333344"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsStaged}" Value="True">
<Setter Property="BorderBrush" Value="#e94560"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="BorderBrush" Value="#22c55e"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" CornerRadius="3"
Background="#0a0a1a" ClipToBounds="True">
<Image Source="{Binding Snapshot}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="LowQuality"/>
</Border>
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="10"
HorizontalAlignment="Center"
Margin="0,2,0,0"
TextTrimming="CharacterEllipsis">
<Run Text="{Binding Name, Mode=OneWay}"/><Run Text=" "/><Run Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="#6a6a7a" FontWeight="SemiBold"/>
</TextBlock>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</UserControl>
+21
View File
@@ -0,0 +1,21 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Controls;
public partial class SceneThumbnailStrip : UserControl
{
public SceneThumbnailStrip()
{
InitializeComponent();
}
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is Border { DataContext: Scene scene } && DataContext is MainViewModel vm)
vm.TransitionToScene(scene);
}
}
+133
View File
@@ -0,0 +1,133 @@
<UserControl x:Class="ytLive.Controls.TopBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:helpers="clr-namespace:ytLive.Helpers"
x:Name="TopBarRoot">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- LOGO / BRAND — clicking opens the in-app About overlay; the gear sits
a couple characters past the build stamp and opens App Settings. -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"
Margin="0,0,24,0">
<Button Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" ToolTip="About LlamaCasty"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
BaselineAlignment="Superscript" Foreground="#8888aa"
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
</TextBlock>
</Button>
<!-- Settings gear (user directive): a couple characters beyond the brand,
nudged right a couple more places 2026-09-22 (build 77 feedback).
Moved up from the bottom bar — one menu: App Settings / Bug / Feature / About. -->
<Button Style="{StaticResource IconButton}" Margin="18,0,0,2" VerticalAlignment="Center"
ToolTip="Menu" Click="GearButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
<Separator/>
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98c0,-0.33 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.38,-0.31 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.38,0.31 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5c0,-1.93 1.57,-3.5 3.5,-3.5c1.93,0 3.5,1.57 3.5,3.5C15.5,13.93 13.93,15.5 12,15.5z"
Fill="#a0a0b0" Width="18" Height="18" Stretch="Uniform"/>
</Button>
</StackPanel>
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
the FIRST decision — Record or Stream. Each world is the whole bar; the
other world never exists on screen. While running, the bar drops to a
reality line + End so nothing mid-session can be re-armed. -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- IDLE: the mode switch + the action(s) of the armed world. The three
controls share one height (build 77 feedback) so they read as one
family — segments host, Start/Go Live, and Test all sit at 32. -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
<Border Background="#0f1622" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<ToggleButton Style="{StaticResource SegmentToggle}"
IsChecked="{Binding RecordPillOn}">
<TextBlock Text="REC" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
<ToggleButton Style="{StaticResource SegmentToggle}"
IsChecked="{Binding OnAirPillOn}">
<TextBlock Text="ON-AIR" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
</StackPanel>
</Border>
<!-- Go face names the world: "Start Recording" vs "Go Live".
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
Height="32" Margin="10,0,0,0" Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
<!-- Test is a child of Stream — a dry-run of the livestream, so it
exists only inside the Stream world (stream armed + signed in). -->
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
Height="32" Margin="8,0,0,0" VerticalAlignment="Center"
Command="{Binding StartTestCommand}"
ToolTip="Run a private test broadcast to check your chat overlay."
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding RunningVisible, Converter={StaticResource BoolToVis}}">
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding RunningDotBrush}"/>
<TextBlock Text="{Binding RunningText}" FontSize="16" FontWeight="Bold"
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="8,0,0,0"/>
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Margin="14,0,0,0" Command="{Binding EndStreamCommand}"/>
</StackPanel>
</StackPanel>
<!-- Account zone: lives only in the Stream world. Signed out → Sign In;
signed in → a big avatar of the streaming account (build 77 feedback:
"make the user icon much bigger"). Record world has no YouTube
identity at all. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="Sign In" Style="{StaticResource YtButtonSecondary}"
Margin="0,0,8,0" Command="{Binding SignInCommand}"
ToolTip="Sign in to YouTube to go live."
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
<Button Style="{StaticResource IconButton}" Margin="0,0,0,0"
ToolTip="{Binding AccountDisplayName}"
Visibility="{Binding ShowAvatarButton, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
<Separator/>
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Border Width="40" Height="40" CornerRadius="20" Background="#16213e"
ClipToBounds="True" BorderBrush="#3a4a66" BorderThickness="1">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="20"
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
</Button>
</StackPanel>
</Grid>
</UserControl>
+85
View File
@@ -0,0 +1,85 @@
using System;
using System.IO;
using System.Net.Http;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using ytLive.Helpers;
using ytLive.ViewModels;
namespace ytLive.Controls;
public partial class TopBar : UserControl
{
private static readonly HttpClient AvatarHttp = new() { Timeout = TimeSpan.FromSeconds(15) };
private string? _loadedAvatarUrl;
public TopBar()
{
InitializeComponent();
AvatarImage.Source = null;
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
}
public void RefreshAvatar()
{
_ = LoadAvatarImageAsync();
}
private void GearButton_Click(object sender, RoutedEventArgs e)
{
// One menu (Settings / Bug / Feature / About), opened on click — moved up
// from the bottom bar so app-level actions live next to the brand.
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
{
var vm = DataContext as MainViewModel;
var url = vm?.AccountAvatarUrl;
if (vm is not { IsConnected: true })
{
ShowAvatarFallback(null);
return;
}
if (string.IsNullOrWhiteSpace(url))
{
AppLog.Write("Avatar: connected but no profile image URL on the session");
ShowAvatarFallback(null);
return;
}
if (url == _loadedAvatarUrl && AvatarImage.Source != null) return;
try
{
var bytes = await AvatarHttp.GetByteArrayAsync(url);
using var ms = new MemoryStream(bytes);
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.StreamSource = ms;
bitmap.EndInit();
bitmap.Freeze();
_loadedAvatarUrl = url;
AvatarImage.Source = bitmap;
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
}
catch (Exception ex)
{
AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
ShowAvatarFallback(vm?.AccountDisplayName);
}
}
private void ShowAvatarFallback(string? name)
{
_loadedAvatarUrl = null;
AvatarImage.Source = null;
AvatarInitial.Text = string.IsNullOrWhiteSpace(name) ? "?" : name.Substring(0, 1).ToUpperInvariant();
AvatarInitial.Visibility = System.Windows.Visibility.Visible;
}
}
+61
View File
@@ -0,0 +1,61 @@
# Controls — index
The phase-2 carved pieces of what used to be one monolithic `MainWindow.xaml`
(1,681 lines → a 123-line shell). Each is a `UserControl` in the `ytLive.Controls`
namespace, extracted one roll-back commit at a time (refactor-commit-12..18).
See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
| `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 (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept |
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
## Window ↔ control contract
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
cross-cutting calls through it:
- Ctor finds each control by `x:Name` (`TopBar`, `PreviewPane`, `LeftPanel`)
AFTER `InitializeComponent`, **before** any method touches it (null-ref guard).
- `_previewPane.IsClickInsidePreview/Drawer` + `_leftPanel.IsClickInside` →
deselection guard in `Window_PreviewMouseLeftButtonDown`.
- `_previewPane.UpdateSelectionOverlay(bool)` + `_leftPanel.OnSelectionChanged(vm)`
→ selection/edit-mode routing from the VM's `SelectedElement` change.
- `_topBar.RefreshAvatar()` on `IsConnected`/`AccountAvatarUrl` change.
- Handlers in moved controls cast `(DataContext as MainViewModel)` — controls
inherit the window's DataContext; never pass the VM through a property.
## Gotchas learned the hard way
- **A UserControl cannot see `Window.Resources`** (StaticResource resolves by
element lookup, so it stops at the control boundary). The 7 converters
(`BoolToVis`, `InverseBoolToVis`, `NotNullToVis`, `NullToVis`, `HiddenBoolToVis`,
`EnumToBool`, `AllTrueToVis`) were promoted from `MainWindow.Resources` into
`Themes/Controls.xaml` (see [`Themes/index.md`](../Themes/index.md)).
- **Moved XAML can lose its namespace**: a block cut from MainWindow carries no
root xmlns, so any `models:`/`Helpers:` ref in it breaks the build with
`MC2000: Value cannot be null (key)`. Declare `xmlns:models` / `xmlns:Helpers`
on each control's root (LeftPanel hit this).
- **Name-looked-up host stubs must stay in the window**, not a control:
`WebViewHostPanel` (ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
window root — so `OverlayHost` deliberately leaves them as thin window-root
children (Commit 18). Since the 2026-09-14 composition-capture redesign only
`ToastArea` remains — `WebViewHostPanel` is deleted (web sources no longer
host a multiline XAML control; `InitWebView2()` runs from `MainWindow_Loaded`
with no panel argument).
- When cutting visual wrappers, don't drop the outer `Border`'s
background/glow/margin — Commit 15 initially lost the preview's outer border
and had to restore it same-commit.
Related: [`ViewModels/index.md`](../ViewModels/index.md) (MainViewModel the
controls bind to), [`Themes/index.md`](../Themes/index.md) (styles/converters),
[`Helpers/index.md`](../Helpers/index.md) (ViewModelBase, converters), the
`MainWindow.xaml` shell at repo root that hosts these.
+69 -8
View File
@@ -1,12 +1,42 @@
# Distribution & IP Protection Plan
> 360-degree protection strategy for LlamaCasty (.NET 8 / WPF), built around Polar.sh.
> 360-degree protection strategy for LlamaCasty (.NET 8 / WPF).
> ## ⛔ SUPERSEDED IN PART — 2026-09-27
>
> **Distribution is now the Microsoft Store: MSIX package + Store IAP.** The creator's criteria
> were *"zero headaches, minimal maintenance (for me) while still providing accountability and
> a reasonably easy upgrade flow"*, and that ruling deleted this document's central premise.
>
> **What still stands below:** §1 (code architecture / IP protection), §5 (obfuscation posture),
> §6 (legal), §7 (hardening) — those are about protecting the *binary*, and an MSIX package is
> still a binary. **§1.2's build-posture ceiling (HARDENED + MOCK_REWARDS, additive-only)
> remains the agreed ceiling.**
>
> **What is dead:**
> - **Polar as the distribution backbone** — the entire section below. Store IAP handles
> checkout, entitlements, refunds, tax, and the customer portal. So does **file hosting**:
> the Store is the delivery mechanism, so "upload `LlamaCasty.exe` to Polar as a File
> Download benefit" (§2) no longer exists as a step.
> - **Code signing as our problem** — MSIX is signed by **Microsoft**. There is no certificate
> to buy, no HSM, no annual renewal. See §4.3, which was already corrected to say this, and
> `TASKS/research-store-certification.md` §2–3.
> - **Velopack / the update URL / the DO droplet** — Store auto-update.
>
> **The authoritative plan is now [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md).**
> Keep this file for the protection sections; do not re-derive a delivery strategy from it.
---
## Polar.sh as the Distribution Backbone
## Polar.sh as the Distribution Backbone — ⛔ OBSOLETE (2026-09-27)
Polar handles everything between "customer wants to pay" and "customer has a working license key." We don't build any of that. What Polar gives us:
> **Historical record only.** Retained because the fee tables and the reasoning about *why*
> one-time beat subscription were worth working out. **None of the "How We Use It" rows are
> current**, with one exception worth keeping: the Merchant-of-Record point — *someone else
> collects and remits VAT/GST* — is still true under Store IAP, because **Microsoft** is now
> that someone. That fact alone was the strongest argument for the ruling.
Polar handled everything between "customer wants to pay" and "customer has a working license key." We didn't build any of that. What Polar *was* going to give us:
| Capability | How We Use It |
|------------|---------------|
@@ -14,13 +44,15 @@ Polar handles everything between "customer wants to pay" and "customer has a wor
| **File hosting** (up to 10GB) | Upload `LlamaCasty.exe` (self-contained, ~80-120MB) as a "File Download" benefit. Customers get signed, personal download URLs. SHA-256 checksums included. |
| **Checkout** | Polar's hosted checkout page handles payment. No Stripe integration, no PCI scope, no custom checkout UI. |
| **Customer portal** | Customers self-serve: view/copy license keys, see expiry, deactivate devices, rotate compromised keys. We don't build a portal. |
| **Subscriptions** | If we go subscription model (deferred), Polar handles recurring billing, dunning, cancellation. |
| **One-time orders** | **Chosen model (2026-09-21):** perpetual license via a one-time Polar order — no subscription. Polar also supports recurring billing if ever needed, but we do not use it. |
| **Merchant of Record** | Polar collects and remits VAT/GST/sales tax globally. We never touch tax compliance. |
| **Webhooks** | Polar notifies our backend on purchase, cancellation, key rotation. Used for optional telemetry (section 6.3). |
**Polar pricing (2026):** 5% + $0.50 per transaction (Starter plan). No monthly fee.
**Polar pricing (2026):** 5% + $0.50 per transaction (Starter plan). No monthly fee. ⛔ *No
longer paid to anyone — the equivalent cost is now inside the Store revenue share, whose rate
is unverified (see `TASKS/research-store-certification.md` §11).*
**What Polar does NOT handle:** Obfuscation, code signing, anti-tamper, runtime protection, EULA, DMCA. That's all us — sections 1-3, 5-7 below.
**What Polar did NOT handle:** Obfuscation, code signing, anti-tamper, runtime protection, EULA, DMCA. That's all us — sections 1-3, 5-7 below.
---
@@ -290,7 +322,7 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc
**Product setup on Polar:**
1. Create a product: "LlamaCasty" — one-time purchase (or subscription if deferred).
1. Create a product: "LlamaCasty" — **one-time purchase** (perpetual license; $29 founder / $49 list).
2. Add two benefits:
- **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry.
- **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included.
@@ -315,7 +347,36 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc
### 4.3 Code Signing
**Certificate:** Buy an EV (Extended Validation) code-signing certificate from DigiCert or Sectigo ($200-400/year). EV certificates get instant SmartScreen reputation; DV/OV certs trigger SmartScreen warnings for months.
> ⚠️ **The cert choice below is a live decision, and the old plan's EV recommendation is
> dead.** Microsoft **removed the SmartScreen instant-bypass for EV certificates in March
> 2024** — an EV-signed file now accrues reputation exactly like an OV-signed one, so paying
> $400+/yr buys precisely what paying ~$150 buys. **Do not buy EV.**
>
> **But the more interesting finding is that you may not need a certificate at all.** Three
> costs, current as of 2026-09-27:
>
> | Route | Cert/yr | SmartScreen | Note |
> |---|---|---|---|
> | **Microsoft Store (MSIX)** | **$0** — Microsoft re-signs | **No warning** | Requires MSIX packaging; Store review; public listing |
> | **Microsoft Store (EXE/MSI)** | $120–300 — *your* cert | Warning ramp | Store does **not** re-sign; silent install mandatory. Strictly dominated — cert required anyway |
> | **Direct + OV cert** | $150–300 | Warning ramp | Worldwide availability |
> | **Direct + Azure Artifact Signing** | ~$9.99/mo ≈ $120 | Warning ramp | **Individuals: USA/Canada only.** Identity validation required |
>
> Signing does **not** clear the warning on day one either way: a valid cert stops the
> *malware* warning immediately, but "unrecognized publisher" clears as SmartScreen accrues
> reputation from real download volume. Budget cert validity at **460 days** (CA/B Forum
> CSC-31) — this is a recurring line item, not a one-time purchase — and note that private
> keys must sit on an **HSM or hardware token**.
>
> **The full comparison, the four real combinations, and the policy analysis live in
> [`TASKS/research-store-certification.md`](TASKS/research-store-certification.md) §2–3.
> The executable checklist is [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md),
> and the route decision is the creator's.**
**Certificate:** Buy an **OV (Organization Validation)** code-signing certificate from DigiCert
or Sectigo (~$150–300/year), or an **Azure Artifact Signing** profile (~$9.99/month,
USA/Canada individuals only) if the geography allows. ⛔ **Not EV** — no SmartScreen benefit
since March 2024. **Or buy nothing at all** if the route is the Microsoft Store.
**Signing process:**
```powershell
+138 -72
View File
@@ -1,88 +1,154 @@
# HANDOFF — Session State
# HANDOFF — current state
## Branch / Commit State
**Branch:** `main` (pre-1.0, no feature branches — creator ruling 2026-08-24).
**Last pushed:** `b2036a3`. This unit (reconciling the pricing ruling + verifying the Store
revenue share) is **docs-only** — no code touched, no build, no tests run.
**`main`**, working tree is **CLEAN**.
## ✅ DECIDED 2026-09-27 — pricing: `$10/mo` subscription, or `$400` lifetime, **no annual**
Commits `46978b8` (AGENTS push policy), `fc33022` (HANDOFF), and refactor
commits `0–5` through `e286719` are **pushed** (incl. `refactor-commit-0..5`).
Refactor commits **6–11 are LOCAL ONLY** (policy 2026-08-30: no per-commit
push; `git push --tags` only at a user-agreed milestone / "push it"):
**Creator's ruling, verbatim: *"Let's just go with $10 monthly recurring subscription for option
A and, for option B, a lifetime sub for $400. No annual."* ⛔ **No `.99` prices** (earlier
ruling: *"Let's stop with the x.99 stuff - I find that irritating. Just say: ten bucks a
month"*). This **supersedes** the 2026-09-21 one-time `$29 → $49` model and the old `$99/yr`
sub. No feature gating: free gets everything, the branding flash is the only paid delta.
| Commit | Hash | Tag | Partial | Lines |
|--------|------|-----|---------|-------|
| 6 Audio | `c1126a5` | `refactor-commit-6` | `MainViewModel.Audio.cs` | 408 |
| 7 Trax | `535a501` | `refactor-commit-7` | `MainViewModel.Trax.cs` | 132 |
| 8 Socials | `c41ff7f` | `refactor-commit-8` | `MainViewModel.Socials.cs` | 161 |
| 9 Streaming | `e1006a0` | `refactor-commit-9` | `MainViewModel.Streaming.cs` | 787 |
| 10 Chat | `3b77a13` | `refactor-commit-10` | `MainViewModel.Chat.cs` | 194 |
| 11 Overlays | `1efde5a` | `refactor-commit-11` | `MainViewModel.Overlays.cs` | 194 |
**✅ Store revenue share VERIFIED — 15%, and subscriptions are NOT penalised.** Read from the
ADA v8.10 PDF itself (downloaded, text-extracted — not a search excerpt). §6(b)(i) charges
**15%** on "any Apps (and any In-App Products in such Apps) that are **not listed in Section
6(b)(iii)**"; 12% is Games-only; 30% covers Xbox console apps/games, Xbox non-subscription
IAP, and Windows 8/Phone 8. LlamaCasty is a Windows PC App — not a Game, not Xbox, not Win8 —
so **6(b)(i) governs both tiers.** The agreement's own changelog (**v8.0, Oct 26 2017**) says
it outright: *"implement the **85/15 revenue share for non-Game subscriptions**."*
Nightly summary: **Phase 1 complete.** The plan was 11 partials + scaffold;
all live in `ViewModels/`. Core `MainViewModel.cs` shrank 1,890 → **758
lines**. Every change: clean build (0 warnings), `scripts/verify.sh` (246
passed + 2 known failures only), `scripts/scope-check.sh` with explicit file
args, `ViewModels/index.md` updated in the SAME commit, tagged
`refactor-commit-N`, no per-commit push.
⇒ **`$10/mo` nets `$8.50/mo` (~$102/yr). `$400` lifetime nets `$340`.** Also confirmed: the
15% applies *after* VAT/GST (Net Receipts), payouts are monthly above a **$50** threshold, and
no better small-indie rate was found in the standard terms.
## What's In Flight
**⛔ Two obligations §6(h) attaches to the recurring tier** (why lifetime is the hedge):
(1) we must **fulfil the subscription for the entire period as marketed**, and on breach
Microsoft may refund the customer *"the full amount, plus taxes... in Microsoft's sole
discretion"*; (2) **raising the price disables auto-renew** — so `$10` is effectively locked
for the product's life absent Microsoft's price-change process.
Nothing code-in-flight — working tree clean, Phase 1 done.
**⏳ Still open:** the `$50` subscriber→lifetime upgrade-discount SKU was discussed and is
**not approved** — do not declare it. Individual vs Company Partner Center account still
unanswered (get it right before enrolling).
**Next (queued, not started): Phase 2 — split `MainWindow.xaml` (1,681 lines)
into UserControls.** Same rules: one work unit per control, docs in the same
commit, verify + scope-check, commit locally, `git push` only on user go.
## ✅ DECIDED 2026-09-27 — distribution is the Microsoft Store: MSIX + Store IAP
**Push pending:** refactor commits 6–11 + their tags are local. The Session-1
push checkpoint was never reached because the user directive was to run the
series to completion; Phase 1 completion is the natural milestone to push on
explicit user confirmation.
**Creator's criteria, verbatim: "zero headaches, minimal maintenance (for me) while still
providing accountability and a reasonably easy upgrade flow."** Route A is the only option
where all four are solved by handing the work to Microsoft rather than to a certificate
vendor: **$0/yr, no certificate, no HSM, no annual renewal, no SmartScreen ramp**, plus Store
auto-update, Store-side payments/entitlements/refunds/support, and Microsoft review as the
accountability layer. The rejected options and their reasons are in
`TASKS/research-store-certification.md` §3 — **read that before reopening the question, not
before re-deriving it.**
**The big consequence: the whole licensing backend is deleted.** `PolarLicenseService`,
`PolarLicense`, `MainViewModel.License.cs` (`PremiumUrl`, customer portal, the
`OfflineGracePeriod = 14 days` subscription-era artifact, renewal/lapse copy) and the wrong
"Polar unlocks alerts" string all become dead code. `IsPremium` comes from the **Store
entitlement** instead of an HTTP call — which also deletes the entire "network flaky → app
thinks I'm expired" bug class. **Velopack, the update URL and the DO droplet go too.**
**What does not change: the entitlement.** Free gets everything; the branding flash stays the
ONLY paid delta. Store IAP changes how `IsPremium` is *obtained*, never what it *gates*.
**⛔ The old "still open: the price" note is retired — the price is settled, see the top of
this file.** The old "~$69 floor" and "don't break the launch-price promise" notes refer to
the old Polar storefront and are **not** current.
### The first code unit: bundle ffmpeg (TASK 48 item 1)
Two real defects the research surfaced — **neither is fixed yet**, this was a docs unit:
1. **⛔ `FfmpegLocator` downloads an unsigned exe from GitHub and runs it.** Store policy
**10.2.2** (dynamic code inclusion) verbatim, *and* the root cause of the 2026-09-01
failure — the pin aged out of BtbN's 14-day retention and the download **404'd on the
creator's first real recording attempt**. `FfmpegLocator.cs:30-35` records the incident but
still reads like a design choice. **Fix: bundle it.** Also deletes the startup network
dependency.
2. **⛔ `Distribution.md:318` recommended a $400+/yr EV certificate for a SmartScreen bypass
Microsoft removed in March 2024.** Fixed last commit — had it shipped, it would have cost
$400+/yr to buy what $150 buys. Now moot anyway (MSIX is signed by Microsoft), but the
lesson in `MyMistakes.md` stands: a policy citation is a claim about **scope**, not just
text.
### ⛔ Two invariants that break silently if touched
- **Full trust, or recording breaks.** `DefaultRecordFolder()` writes to Downloads/MyVideos;
that passes through unvirtualized **only** at `mediumIL`. Flip the manifest to
`appContainer` and output vanishes with no error. A comment is owed there **when the
manifest lands** (TASK 48 item 6) — not before.
- **Chat is rendered, never stored.** That non-persistence half is the load-bearing part of
the 11.12 certification answer. No chat history, moderation log, analytics, or crash payload
may be added without re-arguing 11.12 first.
## Landmines
- **Pre-existing test failures (do not chase):**
`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` +
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`.
- **Tests occasionally abort early (236/237)** — known flake, accepted by `verify.sh`.
- **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building.
- **Implicit usings are SDK-default but partials must still carry their own
domain usings** (they do not inherit core's): `ICommand`/`ObservableCollection`/
`WriteableBitmap`/`DispatcherTimer` etc. are NOT implicit. Grep-verified usings:
- Audio: Input, Media, Threading, `NAudio.CoreAudioApi`, `ytLive`, Helpers, Models, Services, Services.Audio
- Trax: IO, Media, `Microsoft.Win32`, Helpers, Models, Services.Audio
- Socials: Windows, Input, Media, `ytLive`, Helpers, Models, Services, Services.Compositor
- Streaming: ObjectModel, IO, Windows, Input, Threading, `Microsoft.Win32`, Helpers, Models, Services, Services.Encoder
- Chat: ObjectModel, Media, Media.Imaging, Threading, Helpers, Models, Services
- Overlays: Diagnostics, IO, Input, Helpers
- **Pre-commit gate:** `scripts/verify.sh` (0-warning build + full tests) and
`scripts/scope-check.sh "file1" …` — scope-check REQUIRES explicit file args.
- Build from WSL with the Windows dotnet host only (see `AGENTS.md`).
- **Stale fact inside `TASKS.md` (left alone, flagged here per the Scope Lock).** The "1.0 gates"
section asserts "**TASK 36 is now shipped**", but the catalog row 36 reads **☐ Queued** and
`task-36-gold-pass.md` still shows items 1–6 unchecked. The line most likely means *item 2*
(branding flash goes live) shipped at `9761b1d`. **Needs a one-line fix, not a code change** —
flagging rather than fixing because it is not this unit's job.
- **A stale `ytLive.exe` (PID 2544) locked `bin/.../ytLive.exe`** and broke `dotnet run` with
MSB3027. Killed. **If the build fails to copy `ytLive.exe`, check for a running instance first.**
- **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` is flaky by
environment** (needs an interactive desktop session; 2 fail / 1 pass in isolation). **Run the
suite with ytLive CLOSED.** Not a regression.
- **`RealAppHost.RunAsync` exists for a reason** — a frame-pump test MUST `await` inside it. A
blocking wait occupies the one shared STA thread and **hangs the whole suite with no output.**
Always background a `vstest` run and poll the log; a foreground piped `vstest` returns nothing
in this shell even on success.
- **`GlobalHotkeys`: two instances registering the SAME global hotkey** — Windows refuses the
second. Left alone deliberately.
- **MSIX writes under a real package identity are unverified.** The docs say full trust passes
user-profile writes through, but confirm on a real packaged build before trusting it with
recordings (TASK 48 item 6).
## Todo state
## Test state
- MainViewModel refactor: **Phase 1 complete (11/11 partials + scaffold).**
`ViewModels/index.md` has a per-partial table row (code↔commit map in the
tracker section). Core row now describes only what remains (ctor wiring,
lifecycle, source commands, WebView2 hosting, frame-pump seam,
`SelectedElement`, `BrandFlash*`, `BuildCompositorOptions`/`ResolveOutputFrame`).
- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
- TASK 22 (audio sync offset): not started — required for v1.
- Monitoring: cross-partial private access is the point of partial classes —
no moved member re-declared anywhere; moved setters reference other partials
purely by name.
**367/367 as of `938c5b3`.** Not re-run for this unit — docs only, no code touched.
## Session summary (2026-08-30)
## Uncommitted / untracked
- Ran the MainViewModel split to completion under the standing directive
("continue until you can continue no longer" — no interruption prompts).
- Commits 9 (Streaming), 10 (Chat), 11 (Overlays) landed locally, each ~8:40
back-to-back: cut → 0-warning build → verify (246 pass + 2 known) →
scope-check → index.md in-commit → `git tag refactor-commit-N`. No push.
- Streaming was the hardest cut (interleaved props, master-frame consts,
session timer, health pipeline, record rename-on-stop); Chat and Overlays
were clean contiguous cuts. One build fix each: Streaming needed
`ObjectModel`/`Input`, Chat needed `Media.Imaging` for `WriteableBitmap`.
- Docs kept honest throughout: `index.md` rows for Streaming/Chat/Overlays,
About-hub row re-pointed to the Overlays partial, core row trimmed each time.
- `MARCOM.md`, `MONETIZATION.md` — both edited (privacy-copy guard; the Polar-obsolete banner
and the re-opened-price note) and both **gitignored by design** (confidential business
files, `.gitignore:10-11`). They stay local, as intended. Same for `CREDENTIALS.md` —
**never commit those.**
## Next
1. **Bundle ffmpeg (TASK 48 item 1)** — unblocked, first code unit, fixes a real user-facing
failure and clears the one hard certification gate.
2. **Settle the price** — one-time vs subscription, and the number. Then declare the IAP
products in Partner Center. **Blocks:** the Store listing, and the `OverlayHost.xaml` copy.
3. **The packaging project + `Package.appxmanifest`** (TASK 48 items 2–3) — full trust,
`webcam`/`microphone`, English only; Velopack deleted. Add the `DefaultRecordFolder()`
comment in the same unit.
4. **Polar teardown** (TASK 48 item 0) — `PolarLicenseService`, `PolarLicense`,
`MainViewModel.License.cs`, the `OverlayHost.xaml` string, the `csproj`/`App.xaml.cs`
Velopack sites. `IsPremium` ← Store entitlement. **Do this as one unit** — a half-torn-down
licensing path is worse than either end state.
5. **Verify the Store revenue-share rate** — before step 2, not after.
6. **Vertical recording verification** — the compositor tier is proven by
`Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop`; the *record* path has never
been run. `FramePump.cs:645` flags off-size tiers as a known follow-up.
7. **WACK + certification notes + the privacy policy** (TASK 48 items 4–5) — the policy must
state camera/mic is OS-gated, nothing is retained, and nothing is fetched at runtime.
8. **Post-session efficacy report** — roll up `CurrentHealth` (dropped frames, duration, health
message) when a stream or recording ends. `SessionTeardownTests` is the natural home.
9. **Measure whether the Windows camera toggle gates DShow** — gates all privacy-forward copy
(`MARCOM.md` guard). Not a certification risk; a trust risk.
10. **TASK 36:212 stale "shipped" line** — one-line fix, needs a hand.
**Dropped from the list** because the ruling made them moot: multi-instance's ffmpeg tools-dir
race (item 1 makes it disappear), `scripts/publish.sh` + the `LlamaCasty.exe` rename (the Store
packages and delivers — *revisit only if we ever ship outside the Store*), and the alert-gating
copy (the wrong string dies with the Polar teardown in step 4).
**Everything else in the v1 queue:** stream resilience (32), bandwidth step-down (33), scheduled
streams (34), scene-linked audio (35), the master limiter (12), text source (3.16), reward
events (3.20), the media picker (21), webcam identity (9.5), settings units A/C/D (40), and the
defaults split (37). Ship-checklist items live in `TASKS.md` -> "1.0 gates".
+3 -5
View File
@@ -5,15 +5,13 @@ namespace ytLive.Helpers;
/// <summary>
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
/// where the WPF app has no visible console. Writes to
/// %APPDATA%\ytLlive\startup.log, appending synchronously so entries survive
/// a hard crash.
/// %APPDATA%\ytLlive\startup.log (or a per-instance subfolder in DEBUG — see
/// InstanceProfile), appending synchronously so entries survive a hard crash.
/// </summary>
public static class AppLog
{
private static readonly string LogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
"startup.log");
InstanceProfile.DataRoot, "startup.log");
private static readonly object Gate = new();
+77
View File
@@ -0,0 +1,77 @@
using System;
using System.IO;
namespace ytLive.Helpers;
/// <summary>
/// Which run of LlamaCasty this process is, and where its private state lives.
/// <para><b>Dev-only affordance (creator directive, 2026-09-26).</b> Pre-1.0 the creator
/// needs two instances side by side: one streaming, one screen-capturing the other.
/// Two instances on one Windows profile collide on state, and two of those collisions
/// are hard failures rather than annoyances:
/// <list type="bullet">
/// <item><b>The SQLite layout DB.</b> The app's model is read-whole-scene / write-whole-scene,
/// so two instances saving different layouts clobber each other.</item>
/// <item><b>The WebView2 user data folder.</b> Chromium takes an exclusive lock on it; the
/// second instance of the same exe simply fails to start.</item>
/// <item><b>The auth token store.</b> A test instance would overwrite the real YouTube
/// sign-in.</item>
/// <item><b>startup.log.</b> Two appenders interleave into one file, so a crash in either
/// instance is ambiguous.</item>
/// </list>
/// So in <c>DEBUG</c> a process can claim an identity and gets its own private root
/// under <c>instances\&lt;id&gt;</c>: <c>$env:YTLIVE_INSTANCE=2; dotnet run</c>.
/// Recording, screen capture and the ffmpeg <c>tools\</c> cache are deliberately LEFT
/// shared — the cache should be shared, and the creator chooses the record folder.
/// Global hotkeys are also left alone: if both instances register the same one, Windows
/// refuses the second, which is the correct answer.</para>
/// <para><b>At 1.0 this disappears.</b> The whole implementation is inside
/// <c>#if DEBUG</c>; a Release build compiles to the two pass-throughs at the bottom —
/// no environment variable, no alternate folder, not even the variable's NAME in the
/// binary. Call sites are unconditional, so Release cannot accidentally diverge: it
/// always resolves the one real user profile.</para>
/// </summary>
public static class InstanceProfile
{
#if DEBUG
/// <summary>Environment variable naming this instance. Absent or blank = the primary
/// instance on the real user profile.</summary>
public const string InstanceVariable = "YTLIVE_INSTANCE";
/// <summary>This instance's id, or null for the primary instance.</summary>
public static string? Id => Sanitize(Environment.GetEnvironmentVariable(InstanceVariable));
/// <summary>Private state root: the real profile normally, a per-instance subfolder
/// when an id is claimed.</summary>
public static string DataRoot => Id is { } id
? Path.Combine(DefaultRoot, "instances", id)
: DefaultRoot;
/// <summary>Chromium's user data folder, or null to let WebView2 pick its default
/// (which is what Release always does). Must be per-instance or the second
/// instance dies on Chromium's exclusive lock.</summary>
public static string? WebViewDataFolder => Id is null ? null : Path.Combine(DataRoot, "webview2");
/// <summary>Reject anything that is not a plain path segment, so the variable can
/// never be used to walk out of the profile directory or name a UNC path.</summary>
private static string? Sanitize(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var trimmed = raw.Trim();
foreach (var c in trimmed)
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') return null;
return trimmed;
}
#else
// Release: one profile, no alternate paths, no environment variable. Multi-instance
// is a development affordance, not a shipping feature.
public static string DataRoot => DefaultRoot;
public static string? WebViewDataFolder => null;
#endif
/// <summary>The one real user profile: <c>%APPDATA%\ytLlive</c>. Note the historical
/// double-L — do not "fix" it, users already have their layouts there.</summary>
public static string DefaultRoot => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive");
}
+1 -3
View File
@@ -14,9 +14,7 @@ namespace ytLive.Helpers;
public static class TokenStore
{
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
"ytLlive.auth");
InstanceProfile.DataRoot, "ytLlive.auth");
public static void Save(YouTubeChannel channel, string? path = null)
{
+9 -1504
View File
File diff suppressed because it is too large Load Diff
+26 -769
View File
@@ -1,19 +1,10 @@
using System;
using System.Collections;
using System.ComponentModel;
using System.IO;
using System.Net.Http;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Threading;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive;
@@ -21,7 +12,9 @@ namespace ytLive;
public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
private readonly Dictionary<string, Storyboard> _activeBorderAnimations = new();
private Controls.TopBar _topBar = null!;
private Controls.PreviewPane _previewPane = null!;
private Controls.LeftPanel _leftPanel = null!;
public MainWindow()
{
@@ -29,13 +22,13 @@ public partial class MainWindow : Window
InitializeComponent();
AppLog.Write("MainWindow ctor: after InitializeComponent");
_viewModel = (MainViewModel)DataContext;
_viewModel.InitWebView2(WebViewHostPanel);
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
_topBar = (Controls.TopBar)FindName("TopBar")!;
_previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
_leftPanel = (Controls.LeftPanel)FindName("LeftPanel")!;
UpdateTaskbarOverlay();
UpdateSelectionOverlay();
AvatarImage.Source = null;
AvatarInitial.Visibility = Visibility.Collapsed;
_topBar.RefreshAvatar();
RestoreWindowGeometry();
AppLog.Write("MainWindow ctor: end");
}
@@ -43,6 +36,10 @@ public partial class MainWindow : Window
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
AppLog.Write("MainWindow loaded");
// Web sources need the window HWND as the composition controller's parent,
// which does not exist in the ctor — InitWebView2 runs here (Loaded), after
// SourceInitialized. Nothing renders from web frames before this.
_viewModel.InitWebView2();
}
// ─── Window geometry persistence (launch size/position) ───
@@ -116,7 +113,7 @@ public partial class MainWindow : Window
UpdateSelectionOverlay();
else if (e.PropertyName == nameof(MainViewModel.AccountAvatarUrl)
|| e.PropertyName == nameof(MainViewModel.IsConnected))
LoadAvatarImage();
_topBar.RefreshAvatar();
}
private void MainWindow_Closing(object? sender, CancelEventArgs e)
@@ -141,772 +138,32 @@ public partial class MainWindow : Window
: null;
}
private static readonly HttpClient AvatarHttp = new() { Timeout = TimeSpan.FromSeconds(15) };
private string? _loadedAvatarUrl;
private async void LoadAvatarImage()
{
var url = _viewModel.AccountAvatarUrl;
if (!_viewModel.IsConnected)
{
ShowAvatarFallback(null);
return;
}
if (string.IsNullOrWhiteSpace(url))
{
AppLog.Write("Avatar: connected but no profile image URL on the session");
ShowAvatarFallback(null);
return;
}
if (url == _loadedAvatarUrl && AvatarImage.Source != null) return;
try
{
var bytes = await AvatarHttp.GetByteArrayAsync(url);
using var ms = new MemoryStream(bytes);
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.StreamSource = ms;
bitmap.EndInit();
bitmap.Freeze();
_loadedAvatarUrl = url;
AvatarImage.Source = bitmap;
AvatarInitial.Visibility = Visibility.Collapsed;
}
catch (Exception ex)
{
AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
ShowAvatarFallback(_viewModel.AccountDisplayName);
}
}
private void ShowAvatarFallback(string? name)
{
_loadedAvatarUrl = null;
AvatarImage.Source = null;
AvatarInitial.Text = string.IsNullOrWhiteSpace(name) ? "?" : name.Substring(0, 1).ToUpperInvariant();
AvatarInitial.Visibility = Visibility.Visible;
}
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
{
box.Focus();
box.SelectAll();
}
}
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is Slider slider)
SetSliderValueFromClick(slider, e);
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetVolumeAdjusting(true);
}
private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetVolumeAdjusting(false);
}
private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetVolumeAdjusting(false);
}
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is Slider slider)
SetSliderValueFromClick(slider, e);
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(true);
}
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(false);
}
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
vm.SetGameVolumeAdjusting(false);
}
private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
{
if (FindParent<System.Windows.Controls.Primitives.Thumb>(e.OriginalSource as Visual) != null)
return;
var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track;
if (track == null) return;
var pos = e.GetPosition(track);
var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1);
slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
e.Handled = true;
}
private static T? FindParent<T>(Visual? visual) where T : Visual
{
while (visual != null)
{
if (visual is T match) return match;
visual = VisualTreeHelper.GetParent(visual) as Visual;
}
return null;
}
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
{
vm.ToggleMicMuteCommand.Execute(null);
}
}
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
{
vm.ToggleGameMuteCommand.Execute(null);
}
}
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
// loaded); right-click always opens the picker. The picker is the OS file
// dialog — the "never the OS viewer" rule covers the preview, not pickers.
private void TraxButton_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxLeftClick();
}
private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxRightClick();
e.Handled = true; // the button's right-click context menu must not fire
}
private void BackToAbout_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
{
vm.IsLicensesOpen = false;
vm.IsLicenseEntryOpen = false;
}
}
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
// ─── Preview image selection / move / resize ───
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is Border { DataContext: Scene scene })
_viewModel.TransitionToScene(scene);
}
private bool _isDraggingOverlay;
private bool _isResizing;
private Point _grabOffset;
private double _resizeAspect;
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is not DependencyObject original) return;
var form = _viewModel.BroadcastForm;
if (form.IsDrawerOpen && !IsDescendantOf(original, TextPullOutHost))
form.CloseDrawerCommand.Execute(null);
if (IsDescendantOf(original, PreviewGrid)) return;
if (IsDescendantOf(original, SourceList)) return;
if (IsDescendantOf(original, ElementsScrollViewer)) return;
var ypp = _viewModel.Ypp;
var test = _viewModel.TestSession;
if (!_previewPane.IsClickInsideDrawer(original))
{
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
}
if (_previewPane.IsClickInsidePreview(original)) return;
if (_leftPanel.IsClickInside(original)) return;
_viewModel.SelectedElement = null;
}
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
{
while (child != null && !ReferenceEquals(child, ancestor))
{
child = child is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child)
: LogicalTreeHelper.GetParent(child);
}
return child != null;
}
private void UpdateSelectionOverlay()
{
if (!_viewModel.IsEditMode)
{
SelectionOverlay.Visibility = Visibility.Collapsed;
_previewPane.UpdateSelectionOverlay(false);
return;
}
var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
if (selected)
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
TakeSnapshot();
}
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
{
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
}
}
// Static images and the webcam share the move/resize/selection behavior.
private static bool IsDraggableElement(SceneElement element)
=> element.IsDraggable;
private void MirrorButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
webcam.IsMirrored = !webcam.IsMirrored;
}
private void ShapeButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
webcam.ToggleClipShape();
}
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
=> _viewModel.ChangeWebcamCommand.Execute(null);
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag }
|| !Enum.TryParse<BorderAnimation>(tag, out var animation))
return;
element.BorderAnimation = animation;
StopBorderAnimation(element.Id);
if (animation == BorderAnimation.None) return;
var shape = FindBorderShape(sender as DependencyObject);
if (shape == null) return;
var sb = CreateBorderAnimation(animation, shape);
if (sb == null) return;
sb.Freeze();
_activeBorderAnimations[element.Id] = sb;
shape.BeginAnimation(Shape.OpacityProperty, null);
Storyboard.SetTarget(sb, shape);
sb.Begin();
}
private void StopBorderAnimation(string elementId)
{
if (_activeBorderAnimations.TryGetValue(elementId, out var old))
{
old.Stop();
_activeBorderAnimations.Remove(elementId);
}
}
private static Shape? FindBorderShape(DependencyObject? start)
{
var current = start;
while (current != null)
{
if (current is ContextMenu cm && cm.PlacementTarget is FrameworkElement target)
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(target); i++)
{
var child = VisualTreeHelper.GetChild(target, i);
if (child is Shape s && (s is Rectangle or Ellipse) && !s.IsHitTestVisible)
return s;
}
return null;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private static Storyboard? CreateBorderAnimation(BorderAnimation animation, Shape shape)
{
var sb = new Storyboard();
switch (animation)
{
case BorderAnimation.Pulse:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.3, TimeSpan.FromSeconds(0.8), true);
break;
case BorderAnimation.Glow:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.5, TimeSpan.FromSeconds(1.2), true);
break;
case BorderAnimation.Shimmer:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.15, TimeSpan.FromSeconds(0.5), true);
break;
case BorderAnimation.Electricity:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.2, TimeSpan.FromSeconds(0.1), true);
break;
case BorderAnimation.Sparkles:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.4, TimeSpan.FromSeconds(0.3), true);
break;
case BorderAnimation.MarchingAnts:
shape.StrokeDashArray = new DoubleCollection { 4, 2 };
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -20, TimeSpan.FromSeconds(0.8), false);
break;
case BorderAnimation.Chase:
shape.StrokeDashArray = new DoubleCollection { 8, 4 };
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -30, TimeSpan.FromSeconds(1.5), false);
break;
case BorderAnimation.Rainbow:
if (shape.Stroke is SolidColorBrush brush)
{
var frozen = brush.IsFrozen;
if (frozen) brush = brush.Clone();
shape.Stroke = brush;
var colorAnim = new ColorAnimationUsingKeyFrames
{
Duration = new Duration(TimeSpan.FromSeconds(3)),
RepeatBehavior = RepeatBehavior.Forever
};
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.Zero)));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Yellow, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Green, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.DodgerBlue, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1.5))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.MediumPurple, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2.5))));
Storyboard.SetTargetProperty(colorAnim, new PropertyPath("(Shape.Stroke).(SolidColorBrush.Color)"));
sb.Children.Add(colorAnim);
}
break;
default:
return null;
}
return sb.Children.Count > 0 ? sb : null;
}
private static void AddDoubleAnimation(Storyboard sb, DependencyProperty prop,
double from, double to, Duration duration, bool autoReverse)
{
var anim = new DoubleAnimation(from, to, duration)
{
AutoReverse = autoReverse,
RepeatBehavior = RepeatBehavior.Forever
};
Storyboard.SetTargetProperty(anim, new PropertyPath(prop));
sb.Children.Add(anim);
}
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
element.BorderColor = color;
}
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
element.IsVisible = false;
}
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
_viewModel.RemoveSourceCommand.Execute(element);
}
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
{
_ = _viewModel.ChangeBackgroundCaptureAsync();
}
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
{
_viewModel.RefreshBackgroundAutoCapture();
}
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
_viewModel.RemoveSourceCommand.Execute(element);
}
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
// unusable for shaky hands (jitter around the direction deadzone), so the bar
// rides on {Binding SocialBarTop} alone and a click flips it. ───
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
_viewModel.ToggleSocialBarPosition();
e.Handled = true;
}
private DispatcherTimer? _socialGlowTimer;
private double _socialGlowAngle;
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
{
if (SocialBarElement == null) return;
// Kick the social bar to full brightness so it's immediately visible
var effect = SocialBarElement.Effect as DropShadowEffect;
if (effect != null)
{
effect.BlurRadius = 40;
effect.Opacity = 1.0;
}
// Pulse the glow so it catches the eye
_socialGlowAngle = 0;
_socialGlowTimer?.Stop();
_socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
_socialGlowTimer.Tick += SocialGlowTick;
_socialGlowTimer.Start();
}
private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
{
_socialGlowTimer?.Stop();
_socialGlowTimer = null;
if (SocialBarElement?.Effect is DropShadowEffect effect)
{
effect.BlurRadius = 18;
effect.Opacity = 0.9;
}
}
private void SocialGlowTick(object? sender, EventArgs e)
{
if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
_socialGlowAngle += 0.18;
if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
// Smooth sine pulse: BlurRadius 24→48, Opacity 0.7→1.0
double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
effect.BlurRadius = 24 + t * 24;
effect.Opacity = 0.7 + t * 0.3;
}
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (!_viewModel.IsEditMode) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
var selected = _viewModel.SelectedElement;
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
{
_isResizing = true;
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
grid.CaptureMouse();
e.Handled = true;
return;
}
var hit = HitElement(p);
if (hit != null)
{
_viewModel.SelectedElement = hit;
_isDraggingOverlay = true;
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
grid.CaptureMouse();
e.Handled = true;
return;
}
_viewModel.SelectedElement = null;
}
private void Preview_MouseMove(object sender, MouseEventArgs e)
{
if (!_viewModel.IsEditMode) return;
if (!_isDraggingOverlay && !_isResizing) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
var selected = _viewModel.SelectedElement;
if (selected == null)
{
EndPreviewDrag(grid);
return;
}
if (_isDraggingOverlay)
{
var minX = -(selected.Width - 20);
var maxX = 1920 - 20;
var minY = -(selected.Height - 20);
var maxY = 1080 - 20;
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
}
else if (_isResizing)
{
var isWebcam = selected is WebcamSceneConfig;
var isRound = selected.ClipShape == ClipShape.Round;
var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(_viewModel.StagedScene?.Name) : 1920;
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.StagedScene?.Name) : 1080;
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
if (proportional && newH > maxH)
{
newH = maxH;
newW = newH * _resizeAspect;
}
selected.Width = newW;
selected.Height = newH;
}
}
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> EndPreviewDrag((Grid)sender);
private void EndPreviewDrag(Grid grid)
{
if (_isDraggingOverlay || _isResizing)
grid.ReleaseMouseCapture();
_isDraggingOverlay = false;
_isResizing = false;
}
private bool HitHandle(Point mouseScreen, SceneElement element)
{
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
}
private SceneElement? HitElement(Point p)
{
var scene = _viewModel.StagedScene;
if (scene == null) return null;
for (var i = scene.Elements.Count - 1; i >= 0; i--)
{
var element = scene.Elements[i];
if (!IsDraggableElement(element)) continue;
if (element is Source { IsEnabled: false }) continue;
if (element is WebcamSceneConfig { IsVisible: false }) continue;
if (p.X >= element.X && p.X <= element.X + element.Width &&
p.Y >= element.Y && p.Y <= element.Y + element.Height)
return element;
}
return null;
}
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Enter or Key.Escape)
{
CommitSourceEdit(sender);
e.Handled = true;
}
}
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
{
CommitSourceEdit(sender);
}
private static void CommitSourceEdit(object sender)
{
if (sender is FrameworkElement { DataContext: SceneElement element })
element.IsEditing = false;
}
// ─── List drag-to-reorder (scenes list + sources list) ───
private Point _dragStartPoint;
private int _dragIndex = -1;
private bool _isDragging;
private object? _lastHoveredItem;
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var listBox = (ListBox)sender;
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
if (item == null)
{
if (ReferenceEquals(listBox, SourceList))
_viewModel.SelectedElement = null;
_dragIndex = -1;
return;
}
if (listBox.ItemsSource is not IList items || items.Count == 0)
{
_dragIndex = -1;
return;
}
_dragIndex = listBox.Items.IndexOf(item.DataContext);
_dragStartPoint = e.GetPosition(listBox);
_isDragging = false;
_lastHoveredItem = null;
}
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (_dragIndex < 0) return;
var listBox = (ListBox)sender;
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
{
EndListDrag(listBox);
return;
}
var position = e.GetPosition(listBox);
if (!_isDragging)
{
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
return;
_isDragging = true;
listBox.CaptureMouse();
}
var item = FindItemContainerAt(listBox, position);
if (item == null) return;
var targetItem = item.DataContext;
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
return;
var targetIndex = listBox.Items.IndexOf(targetItem);
if (targetIndex < 0) return;
var dragged = items[_dragIndex];
items.RemoveAt(_dragIndex);
items.Insert(targetIndex, dragged);
_dragIndex = targetIndex;
_lastHoveredItem = targetItem;
}
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
EndListDrag((ListBox)sender);
}
private void EndListDrag(ListBox listBox)
{
if (_isDragging)
listBox.ReleaseMouseCapture();
_dragIndex = -1;
_isDragging = false;
_lastHoveredItem = null;
}
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
{
var hit = listBox.InputHitTest(position) as DependencyObject;
while (hit != null && hit != listBox)
{
if (hit is ListBoxItem item) return item;
hit = VisualTreeHelper.GetParent(hit);
}
return null;
}
private Models.SceneElement? _snapshotElement;
private string _snapshotBorderColor = string.Empty;
private int _snapshotBorderWidth;
private int _snapshotTimerMinutes = 15;
private string? _snapshotWebUri;
private void TakeSnapshot()
{
if (_viewModel.SelectedElement is Models.Source src)
{
_snapshotElement = src;
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
_snapshotTimerMinutes = src.TimerMinutes;
_snapshotWebUri = src.WebUri;
}
}
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedElement is not SceneElement src) return;
if (sender is FrameworkElement fe && fe.ContextMenu == null)
{
var menu = new System.Windows.Controls.ContextMenu();
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
foreach (var c in colors)
{
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
var brush = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
item.Icon = new System.Windows.Controls.Border
{
Width = 16, Height = 16,
Background = brush,
CornerRadius = new System.Windows.CornerRadius(2)
};
var color = c;
item.Click += (_, _) => src.BorderColor = color;
menu.Items.Add(item);
}
fe.ContextMenu = menu;
}
if (sender is FrameworkElement fe2)
fe2.ContextMenu.IsOpen = true;
}
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
src.BorderColor = _snapshotBorderColor;
src.BorderWidth = _snapshotBorderWidth;
}
}
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
}
}
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.TimerMinutes = _snapshotTimerMinutes;
}
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotTimerMinutes = src.TimerMinutes;
}
private void WebRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.WebUri = string.Empty;
_snapshotWebUri = string.Empty;
}
private void WebAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotWebUri = src.WebUri;
var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s);
_previewPane.UpdateSelectionOverlay(selected);
_leftPanel.OnSelectionChanged(_viewModel);
}
}
+16
View File
@@ -19,6 +19,17 @@ public enum BorderAnimation
Sparkles
}
/// <summary>Whether an element's pixel content is fixed (bake once into the
/// compositor's cached base) or changes every frame (composite each tick).
/// See ai.md "SceneGraph component + baked-crust compositor" (TASK 31).</summary>
public enum ElementKind
{
/// <summary>Pixels never change at runtime (images, background art).</summary>
Static,
/// <summary>Pixels change every frame (webcam, live capture, chat, web source).</summary>
Dynamic
}
/// <summary>
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
/// or a singleton resource's per-scene config (webcam). Carries the shared
@@ -44,6 +55,11 @@ public abstract class SceneElement : INotifyPropertyChanged
private string _name = string.Empty;
public string Name { get => _name; set => Set(ref _name, value); }
/// <summary>Whether this element's pixels are fixed (Static) or change every
/// frame (Dynamic). The compositor bakes all Static layers below the first
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
public virtual ElementKind Kind => ElementKind.Static;
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
public virtual bool IsWebcam => false;
+72 -3
View File
@@ -13,7 +13,10 @@ public enum SourceType
Countdown,
WebSource,
ChatBox,
MediaSource
MediaSource,
/// <summary>The designated alert zone: the six monetization events play their
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
AlertBox
}
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
@@ -37,6 +40,19 @@ public enum ClipShape
Round
}
/// <summary>How an alert box presents its announcement strip (TASK 47). The strip is
/// the full-width pill naming the event ("Viewer — Super Chat · $12.34").</summary>
public enum AlertDisplayMethod
{
/// <summary>Marquee: the pill scrolls across the top edge, paced so the text is
/// read <see cref="AlertOverlayLayer.TickerReadsPerAlert"/> times per alert.</summary>
TickerScroll,
/// <summary>Pulse: the static pill blinks on and off for the whole alert.</summary>
Flash,
/// <summary>Static: the pill sits centred and still for the whole alert.</summary>
Solid
}
/// <summary>
/// A multi-instance scene object: image/background/text (screen/window later).
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
@@ -61,7 +77,12 @@ public class Source : SceneElement
}
}
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox;
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox or SourceType.AlertBox;
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
? ElementKind.Dynamic : ElementKind.Static;
public override bool IsWebSource => Type == SourceType.WebSource;
@@ -113,7 +134,7 @@ public class Source : SceneElement
public override ImageSource? DisplaySource => IsLiveCapture
? (VideoImageSource ?? _imageSource)
: (Type is SourceType.ChatBox or SourceType.WebSource ? VideoImageSource : _imageSource);
: (Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox ? VideoImageSource : _imageSource);
public override bool IsImageSource => Type == SourceType.Image;
@@ -171,6 +192,54 @@ public class Source : SceneElement
public bool IsMediaSource => Type == SourceType.MediaSource;
// Alert video config (TASK 47): the shipped default clip lives in the Asset
// table (seeded like image assets); the creator's replacement is a FILE PATH
// reference only — never into the DB (creator ruling, 2026-09-26). Runtime
// order: replacement path exists → DB default → six celebration animations.
private string? _alertVideoAssetId;
public string? AlertVideoAssetId
{
get => _alertVideoAssetId;
set => Set(ref _alertVideoAssetId, value);
}
private string? _alertVideoPath;
public string? AlertVideoPath
{
get => _alertVideoPath;
set => Set(ref _alertVideoPath, value);
}
private bool _alertUseDefaultVideo = true;
public bool AlertUseDefaultVideo
{
get => _alertUseDefaultVideo;
set => Set(ref _alertUseDefaultVideo, value);
}
private bool _alertShowTicker = true;
public bool AlertShowTicker
{
get => _alertShowTicker;
set => Set(ref _alertShowTicker, value);
}
private double _alertVideoVolume = 1.0;
public double AlertVideoVolume
{
get => _alertVideoVolume;
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
}
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
public AlertDisplayMethod AlertDisplayMethod
{
get => _alertDisplayMethod;
set => Set(ref _alertDisplayMethod, value);
}
public bool IsAlertBox => Type == SourceType.AlertBox;
// Chat box config (TASK 3.18)
private double _chatFontSize = 16;
public double ChatFontSize
+1
View File
@@ -13,5 +13,6 @@ public class WebcamSceneConfig : SceneElement
public string WebcamId { get; init; } = string.Empty;
public override bool IsWebcam => true;
public override bool IsDraggable => true;
public override ElementKind Kind => ElementKind.Dynamic;
public override ImageSource? DisplaySource => VideoImageSource;
}
+27
View File
@@ -0,0 +1,27 @@
namespace ytLive.Models;
/// <summary>One user-visible open window offered by the "Capture Window…"
/// submenu. Carries its owning process name for disambiguation and the raw
/// HWND that becomes the "window:&lt;hwnd&gt;" capture key.</summary>
public sealed class WindowInfo
{
public WindowInfo(string title, string processName, long hwnd, int processId)
{
Title = title;
ProcessName = processName;
Hwnd = hwnd;
ProcessId = processId;
}
public string Title { get; }
public string ProcessName { get; }
public long Hwnd { get; }
public int ProcessId { get; }
/// <summary>Menu label: "Title — ProcessName" (e.g. "bash — WindowsTerminal").</summary>
public string Label => $"{Title} — {ProcessName}";
/// <summary>The capture key fed to the screen-capture factory
/// ("window:&lt;hwnd&gt;").</summary>
public string CaptureKey => $"window:0x{Hwnd:X}";
}
+39
View File
@@ -18,6 +18,21 @@ public record ReusableStream(string Id, string IngestionAddress, string StreamNa
public string RtmpUrl => $"{IngestionAddress}/{StreamName}";
}
/// <summary>The six monetization events YouTube's live chat feed emits (the ONLY
/// native event stream there is — free subscribers produce no event). Each kind
/// drives its own celebration animation in the AlertBox. <c>None</c> = a normal
/// chat row: it never enqueues an alert (subscriber mentions stay chat rows).</summary>
public enum ChatEventKind
{
None,
SuperChat,
SuperSticker,
NewMember,
MemberGift,
GiftReceived,
MemberMilestone,
}
public class ChatMessage
{
public string Id { get; init; } = Guid.NewGuid().ToString();
@@ -29,6 +44,30 @@ public class ChatMessage
public bool IsSuperChat { get; set; }
public double SuperChatAmount { get; set; }
public string? SuperChatCurrency { get; set; }
/// <summary>YouTube's own pre-rendered amount ("$12.34") — preferred over
/// recomposing amount+currency ourselves, which mangles non-dollar locales.</summary>
public string? SuperChatDisplayString { get; set; }
public bool IsMember { get; set; }
public string? MembershipLevel { get; set; }
public ChatEventKind Kind { get; set; }
public string? SuperStickerDescription { get; set; }
/// <summary>membershipGiftingEvent: how many memberships were bought.</summary>
public int GiftNewMembersCount { get; set; }
/// <summary>giftMembershipReceivedEvent: who gifted this one.</summary>
public string? GifterName { get; set; }
/// <summary>memberMilestoneChatEvent: streak months (0 = YouTube didn't
/// expose a count — the animation falls back to the message text).</summary>
public int MilestoneMonths { get; set; }
/// <summary>TASK 41: the Test Stream dock injects synthetic member / SuperChat /
/// subscriber events locally (YouTube's insert API only supports text), so the
/// buffer can label them honestly — simulated events never reach YouTube.</summary>
public bool IsSimulated { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
namespace ytLive.Models;
/// <summary>One captured snapshot of a connected channel's YPP-relevant numbers
/// (TASK 39). Appended on every refresh and archived in SQLite so a later slice
/// can compute velocity/(de)acceleration/ETA from the history.</summary>
public sealed class YppStatSnapshot
{
public DateTime CapturedAtUtc { get; set; }
public long SubscriberCount { get; set; }
public long VideoCount { get; set; }
public long TotalViews { get; set; }
public int UploadsLast90Days { get; set; }
public bool CommunityGuidelinesGoodStanding { get; set; }
public bool CopyrightStrikesGoodStanding { get; set; }
public bool ContentIdClaimsGoodStanding { get; set; }
public bool OverallGoodStanding { get; set; }
}
+31
View File
@@ -0,0 +1,31 @@
namespace ytLive.Models;
/// <summary>YPP eligibility thresholds as versioned, date-aware DATA — never
/// constants (ai.md "Journey tracker"). Tier 1 (fan funding) is time-invariant;
/// Tier 2 (ad revenue) doubles its long-form-hours and Shorts-view bars for NEW
/// applicants after 2027-02-01. Hardcoding the pre-2027 numbers would show the
/// wrong ETA once the doubling lands, so callers resolve the active Tier 2
/// threshold through <see cref="Tier2For"/>.</summary>
public static class YppThresholds
{
public sealed record TierThreshold(string Name, long Subscribers, long WatchHours, long ShortsViews);
/// <summary>The date the Tier-2 ad-revenue bars double for new applicants.</summary>
public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
/// <summary>Fan-funding tier (before the full ad/YouTube Premium revenue tier).</summary>
public static TierThreshold Tier1 { get; } = new("Fan funding", 500, 3_000, 3_000_000);
/// <summary>The Tier-2 bar for a given point in time — 4,000h / 10M Shorts
/// before the doubling date, 8,000h / 20M Shorts on or after it.</summary>
public static TierThreshold Tier2For(DateTime when) => when >= Tier2DoublingDate
? new TierThreshold("Ad revenue", 1_000, 8_000, 20_000_000)
: new TierThreshold("Ad revenue", 1_000, 4_000, 10_000_000);
/// <summary>The currently-active Tier-2 threshold (resolved against today).</summary>
public static TierThreshold Tier2Now => Tier2For(DateTime.UtcNow);
/// <summary>Valid public long-form uploads required in the trailing 90 days
/// to keep each tier route eligible.</summary>
public const int UploadsRequiredPer90Days = 3;
}
+2 -2
View File
@@ -7,14 +7,14 @@ Plain data types. No logic beyond what a property can carry. See
|------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) — since TASK 43 every message carries `Kind` (`ChatEventKind` None/SuperChat/SuperSticker/NewMember/MemberGift/GiftReceived/MemberMilestone) + event payload fields (`SuperChatDisplayString`, `SuperStickerDescription`, `GiftNewMembersCount`, `GifterName`, `MilestoneMonths`) |
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
+1133
View File
File diff suppressed because it is too large Load Diff
+24 -13
View File
@@ -28,6 +28,14 @@ ytLlive assumes you already have a YouTube creator account — connecting uses "
to link that existing account. **ytLlive does not create accounts.** If you don't have a YouTube
channel yet, set one up on YouTube first, then connect it here.
## Audio Assumption
ytLlive uses your **default** microphone and desktop/game output — it never hunts for a device or
contends for one. **It assumes your audio is already configured and working** (default input and
output set, not hijacked by another app). Audio issues are a Windows/device matter outside ytLlive's
scope: we don't debug, detect, or try to fix them. If it doesn't sound right, fix the system's audio
settings first — it's on the OS, not on us.
## Run
```bash
@@ -37,25 +45,28 @@ dotnet run
## Version Roadmap
| Version | Scope |
|---------|-------|
| v0.1 | Scene/source management, YouTube RTMP ingest, YouTube OAuth2, live chat, stream health |
| v0.2 | Recording to local file |
| v0.3 | Stream scheduling |
| v0.4 | Multi-destination restreaming |
| 1.0 | General availability |
**v1 is the finished product** (creator ruling, 2026-09-01 — there are no v1.x releases).
Everything still queueing lands in v1; anything not on the feature list is on the closed
**"Out of product — permanently"** list in `TASKS.md` — read that page before asking for a feature.
What v1 ships: the five-scene director's control surface, webcam + screen capture, media + web +
text sources, chat on-stream, the social bar, mic/desktop audio with auto-duck and TRAX, local
recording, variable quality tiers with auto step-down, one-click go-live + scheduling, stream
resilience (auto-reconnect inside YouTube's grace, one-click back on air), built-in monetization
awareness (reward capture, session reports, YPP journey tracker), Alerts — all free and ungated;
the one-time license only removes the branding flash.
## Structure
| Path | Role |
|------|------|
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat, Socials (social bar) |
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel, SocialsDialogViewModel |
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite), SocialValidator (social link/fediverse validation + nodeinfo icon resolution) |
| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, FocusPreservingListBox, OAuthCredentials |
| `Models/` | Scene, Source, WebcamSceneConfig, SceneElement, SceneCatalog, QualityOption, StreamConfig/Health, chat, Socials, BroadcastMetadata, Music, MicStatus |
| `ViewModels/` | MainViewModel (partial classes per area) + dialogs: GoLive, CameraPicker, MicPicker, HotkeyConfig, Socials, LiveBroadcastForm |
| `Services/` | YouTube (OAuth, streams/broadcasts, chat polling), LayoutStore (SQLite), capture (camera/screen-capture/media-file via FFmpeg), SceneCompositor + FramePump, FFmpeg encoder + RTMP, audio pipeline (WASAPI loopback + mic, mixer, filters, ducker, limiter, TRAX), SocialValidator, notifications, hotkeys, SceneGraph, ChatOverlayLayer |
| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, TokenStore (DPAPI), OAuthCredentials, converters |
| `Themes/` | `Controls.xaml` — the dark-theme control styles, merged once in `App.xaml` |
| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar |
| `SocialsDialog.xaml` | Social Media Site Promotion dialog — 6-slot social bar editor with sign-in gate and validation |
| `MainWindow.xaml` | Dark-theme control surface: thumbnails + central preview, two-state left panel (layers/properties ↔ live chat), top bar, footer |
| `Distribution.md` | GA-time plan: signing, installer, EULA, hardening — executed as TASK 36 |
## Docs (memory map)
+3 -3
View File
@@ -1,8 +1,8 @@
<Window x:Class="ytLive.RenameRecordingDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Rename Recording" Height="230" Width="460"
Icon="/Assets/llamacasty-icon.png"
Title="Rename Recording" Height="304" Width="460"
Icon="/ytLive;component/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
@@ -32,7 +32,7 @@
Margin="0,20,0,0">
<Button Content="Cancel" Click="Cancel_Click"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Save" Click="Save_Click" Style="{StaticResource YtButton}" MinWidth="90"/>
<Button Content="Save" Click="Save_Click" Style="{StaticResource YtButton}" MinWidth="90" x:Name="SaveRecordingButton"/>
</StackPanel>
</Grid>
</Window>
+6 -2
View File
@@ -6,12 +6,16 @@ namespace ytLive;
/// <summary>Modal that lets the creator rename an auto-named recording before it is
/// finalized. Returns the chosen file stem (no extension) via <see cref="ResultFileName"/>,
/// or <c>null</c> on Cancel / keep-auto-name.</summary>
/// or <c>null</c> when the creator cleared the box and hit Save (keep the auto name).
/// <para><c>DialogResult == false</c> means DISCARD — the caller deletes the footage
/// rather than saving it (CREATOR RULING 2026-09-26). Enter on the pre-filled default
/// is a Save.</para></summary>
public partial class RenameRecordingDialog : Window
{
private readonly string _autoStem;
/// <summary>Stem (no extension) the creator chose, or <c>null</c> to keep auto-name.</summary>
/// <summary>Stem (no extension) the creator chose, or <c>null</c> when the box was
/// cleared on an explicit Save. Never set on Cancel.</summary>
public string? ResultFileName { get; private set; }
public RenameRecordingDialog(string autoStem)
+227
View File
@@ -0,0 +1,227 @@
using System.Diagnostics;
using System.IO;
using ytLive.Helpers;
using ytLive.Services.Audio;
using ytLive.Services.Encoder;
namespace ytLive.Services;
/// <summary>
/// TASK 47: one alert clip's playback — video frames + on-stream audio from the
/// same file, both paced to real time. The alert box owns a decoder per event
/// (start at play, dispose at drain) — unlike MediaSource's app-wide refcounted
/// session, an alert REPLAYS the same clip each event, so a fresh decode per
/// play is required. Video is rawvideo BGRA scaled to the box rect (the
/// <see cref="MediaVideoSource"/> shape); audio is ffmpeg <c>f32le</c> at the
/// mixer's native 48 kHz stereo (so the mixer ring needs no resampler). The
/// fade is applied by the alert layer, not here.
/// </summary>
public interface IAlertClipDecoder : IDisposable
{
/// <summary>Starts the decode loop (video + audio); raises the events off the
/// calling thread. Paced to real time so playback matches wall clock.</summary>
void Start();
/// <summary>Stops and kills any running decode subprocesses.</summary>
void Stop();
/// <summary>One decoded video frame (box-sized, straight-alpha BGRA).</summary>
event Action<VideoFrame>? FrameAvailable;
/// <summary>One chunk of on-stream audio (48 kHz stereo float).</summary>
event Action<AudioSample>? AudioReady;
/// <summary>Raised once when both streams have fully played out.</summary>
event Action? Completed;
}
/// <summary>Default <see cref="IAlertClipDecoder"/>: two ffmpeg subprocesses on
/// the same file. Video via <see cref="RawVideoFrameReader"/> on a <c>rawvideo
/// bgra</c> pipe (-an), audio via an <c>f32le -ac 2 -ar 48000</c> pipe (-vn).
/// The seams (locator, process factory, frame-rate probe, delay) let tests drive
/// a fake decoder without a real codec or Windows.</summary>
public sealed class AlertClipDecoder : IAlertClipDecoder
{
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioReady;
public event Action? Completed;
private const int AudioSampleRate = 48000;
private const int AudioChannels = 2;
private const int AudioChunkFloats = 4800; // 50ms of stereo
private readonly string _path;
private readonly int _width;
private readonly int _height;
private readonly IFfmpegLocator _locator;
private readonly Func<IDecodeProcess> _processFactory;
private readonly IFrameRateProbe? _probe;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly CancellationTokenSource _cts = new();
private Task? _run;
public AlertClipDecoder(
string path,
int width,
int height,
IFfmpegLocator locator,
Func<IDecodeProcess> processFactory,
IFrameRateProbe? frameRateProbe = null,
Func<TimeSpan, CancellationToken, Task>? delay = null)
{
_path = path;
_width = width;
_height = height;
_locator = locator;
_processFactory = processFactory;
_probe = frameRateProbe;
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
}
public void Start()
{
if (_run != null) return;
_run = Task.Run(RunAsync, CancellationToken.None);
}
public void Stop() => _cts.Cancel();
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
private async Task RunAsync()
{
var ct = _cts.Token;
var videoFrames = 0;
var audioChunks = 0;
var failed = false;
try
{
double? frameRate = null;
if (_probe != null)
frameRate = await _probe.ProbeAsync(_path, ct).ConfigureAwait(false);
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
var ffmpegPath = await _locator.LocateAsync(ct).ConfigureAwait(false);
var videoTask = RunVideoAsync(ffmpegPath, frameDuration, ct);
var audioTask = RunAudioAsync(ffmpegPath, ct);
videoFrames = await videoTask.ConfigureAwait(false);
audioChunks = await audioTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
failed = true;
return;
}
catch (Exception ex)
{
// A failed decode is not fatal: the alert layer falls back to the six
// animations (or the frame it already holds) exactly as a missing file.
// Logged so a silent blank box traces back to a decode failure.
failed = true;
AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})");
}
if (!ct.IsCancellationRequested)
{
AppLog.Write($"Alert clip end: {Path.GetFileName(_path)} frames={videoFrames} audioChunks={audioChunks} failed={failed}");
Completed?.Invoke();
}
}
private async Task<int> RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
{
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
using var process = _processFactory();
process.Start(new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
});
var reader = new RawVideoFrameReader(_width, _height);
var buffer = new byte[65536];
var stream = process.StandardOutput;
var frames = 0;
while (!ct.IsCancellationRequested && !process.HasExited)
{
var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
if (read <= 0) break;
var chunk = new byte[read];
Buffer.BlockCopy(buffer, 0, chunk, 0, read);
foreach (var frame in reader.Feed(chunk))
{
frames++;
FrameAvailable?.Invoke(frame);
if (frameDuration > TimeSpan.Zero)
await _delay(frameDuration, ct).ConfigureAwait(false);
}
}
return frames;
}
private async Task<int> RunAudioAsync(string ffmpegPath, CancellationToken ct)
{
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f f32le -ac {AudioChannels} -ar {AudioSampleRate} -vn -";
using var process = _processFactory();
process.Start(new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
});
var stream = process.StandardOutput;
var raw = new byte[65536];
var carry = new byte[0];
var floatScratch = new float[AudioChunkFloats];
var floatIndex = 0;
var chunks = 0;
// The pipe is byte-delimited only: a read may split a float, so leftover
// bytes are carried into the next read instead of dropped (a dropped
// half-float would desync the entire stream).
while (!ct.IsCancellationRequested && !process.HasExited)
{
var read = await stream.ReadAsync(raw, 0, raw.Length, ct).ConfigureAwait(false);
if (read <= 0) break;
var total = carry.Length + read;
var bytes = total <= raw.Length ? raw : new byte[total];
if (carry.Length > 0)
{
Buffer.BlockCopy(carry, 0, bytes, 0, carry.Length);
Buffer.BlockCopy(raw, 0, bytes, carry.Length, read);
}
var floatsTo = total - (total % 4);
for (var i = 0; i < floatsTo && floatIndex < floatScratch.Length; i += 4)
floatScratch[floatIndex++] = BitConverter.ToSingle(bytes, i);
carry = total > floatsTo ? bytes[floatsTo..total] : new byte[0];
while (floatIndex == floatScratch.Length)
{
chunks++;
AudioReady?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels));
// Pace the NEXT chunk so the whole clip's audio lands at real-time
// cadence (the fade in the alert layer tracks the same wall clock).
await _delay(TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)), ct).ConfigureAwait(false);
floatScratch = new float[AudioChunkFloats];
floatIndex = 0;
}
}
return chunks;
}
}
+541
View File
@@ -0,0 +1,541 @@
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services.Audio;
using ytLive.Services.Compositor;
namespace ytLive.Services;
/// <summary>
/// The native alert box (OBS-style celebration zone, no third party). Owns the
/// event queue and the animation clock: <see cref="ChatEventKind"/> events from
/// the chat feed are enqueued one-at-a-time and played through a celebration
/// animation (<see cref="AlertRenderer"/>) OR — when the alert-box video is
/// configured and resolves — through a decoded clip. Frames are served to the
/// stream compositor via <see cref="RenderFrame"/> and mirrored to the preview
/// via <see cref="UpdatePreview"/>. Idle = transparent (null frame); playback is
/// advanced by a 33ms dispatch ticker. The clock is exposed as
/// <see cref="Advance"/> so tests drive playback deterministically without the
/// timer. Ordinary chat rows (<see cref="ChatEventKind.None"/>) never enqueue —
/// a subscriber mention is a chat row, not an alert.
///
/// TASK 47 (Stream Alerts video): a video-backed alert replaces the animation
/// while a clip decodes (<see cref="IAlertClipDecoder"/>, one per play). The
/// clip plays at its own media pace; the layer applies a ~300ms fade-in/out
/// alpha envelope to the frames AND the audio samples (freeze-frame fade-out on
/// the tail), clips the alert to the clip length, and forwards the scaled audio
/// to the mixer's alert ring via <paramref name="audioSink"/> (no duck). The
/// per-source config (path/default/ticker/volume) is resolved through
/// <paramref name="clipPathResolver"/>; the ticker strip is served via
/// <see cref="AlertTickerFrame"/>.
/// </summary>
public sealed class AlertOverlayLayer : IDisposable
{
/// <summary>The fade-in/out ramp length (video alpha AND audio gain). Measured
/// against a real clip in the AlertLayerVideoTests unit; keep the ramp short
/// enough to read as a "pop-in" on stream (see ai.md).</summary>
internal const double FadeDurationSeconds = 0.30;
/// <summary>How long an alert waits for the first decoded frame or audio slab
/// before treating the clip as unplayable and falling back to the animations
/// (a silent decoder must never blank the alert box).</summary>
internal const double NoFrameFallbackSeconds = 1.0;
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(33);
private readonly AlertRenderer _renderer;
private readonly Func<string, int, int, IAlertClipDecoder>? _clipDecoderFactory;
private readonly Func<Source, string?>? _clipPathResolver;
private readonly Action<AudioSample>? _audioSink;
private readonly Action<VideoFrame?>? _tickerPreviewSink;
private readonly Queue<ChatMessage> _queue = new();
private readonly DispatcherTimer? _ticker;
private ChatMessage? _current;
private Source? _currentBox;
private IAlertClipDecoder? _clip;
private VideoFrame? _latestClipFrame;
private bool _clipEof;
private double _clipEofElapsed;
private int _clipWidth;
private int _clipHeight;
private bool _clipSawFrame;
private bool _clipAudioHeard;
private string? _tickerText;
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
private double _elapsed;
private IEnumerable<Scene>? _scenes;
private int _contentVersion;
private int _frameVersion = -1;
private string? _frameKey;
private VideoFrame? _cachedFrame;
private DateTime _lastPreviewLog = DateTime.MinValue;
public AlertOverlayLayer(
AlertRenderer renderer,
Func<string, int, int, IAlertClipDecoder>? clipDecoderFactory = null,
Func<Source, string?>? clipPathResolver = null,
Action<AudioSample>? audioSink = null,
Action<VideoFrame?>? tickerPreviewSink = null)
{
_renderer = renderer;
_clipDecoderFactory = clipDecoderFactory;
_clipPathResolver = clipPathResolver;
_audioSink = audioSink;
_tickerPreviewSink = tickerPreviewSink;
if (Application.Current != null)
{
_ticker = new DispatcherTimer { Interval = TickInterval };
_ticker.Tick += (_, _) =>
{
Advance(TickInterval.TotalSeconds);
RefreshAlertPreviews();
};
}
}
/// <summary>Production entry from the chat feed: enqueue one event. Messages
/// arrive on the chat poller thread, so this marshals to the UI thread (same
/// rule as <see cref="ChatOverlayLayer.OnMessageReceived"/>) — the preview
/// writes create WPF <see cref="WriteableBitmap"/> instances, and creating a
/// DependencySource off the UI thread crashes the WPF binding engine
/// (ArgumentException "Must create DependencySource on same Thread").</summary>
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
{
var app = Application.Current;
if (app != null && !app.Dispatcher.CheckAccess())
{
app.Dispatcher.Invoke(() => Ingest(message, scenes));
return;
}
Ingest(message, scenes);
}
private void Ingest(ChatMessage message, IEnumerable<Scene> scenes)
{
_scenes = scenes;
Enqueue(message);
_ticker?.Start();
RefreshAlertPreviews();
}
/// <summary>Queue an event (pure — also the test seam). Chat rows carry
/// <see cref="ChatEventKind.None"/> and are ignored: no alert for a mention.</summary>
public void Enqueue(ChatMessage message)
{
if (message.Kind == ChatEventKind.None) return;
if (_queue.Count >= 10) return; // back-to-back floods drop the tail, never stall playback
_queue.Enqueue(message);
if (_current == null)
{
_current = _queue.Dequeue();
_elapsed = 0;
BeginClip();
}
_contentVersion++;
}
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
/// tests call this directly and never start the timer). Draining the queue to
/// nothing stops the ticker and leaves the box transparent.</summary>
public void Advance(double deltaSeconds)
{
// Video-backed alert: duration is clip-driven. The clip plays at media
// pace; once it EOFs the last frame sweeps through the fade-out before
// the alert moves on (a hard cut at EOF would pop, not fade).
if (_clip != null)
{
_elapsed += deltaSeconds;
// A decoder that starts but produces not a single frame (or slab of
// audio) is dead weight — the box would sit transparent for the whole
// alert. Give it a short grace, then fall back to the animations so an
// alert ALWAYS renders (the live decode failure at 2026-09-26 11:44 was
// this exact void, invisible in the log).
if (!_clipSawFrame && !_clipAudioHeard && _elapsed >= NoFrameFallbackSeconds)
{
System.Diagnostics.Debug.WriteLine("Alert clip yielded no frames in 1s — falling back to the animation");
StopClip();
_elapsed = 0;
_contentVersion++;
return;
}
if (_clipEof && _elapsed - _clipEofElapsed >= FadeDurationSeconds)
{
_clipEof = false;
StopClip();
_current = null;
_elapsed = 0;
AdvanceToNext();
}
_contentVersion++;
return;
}
if (_current == null)
{
if (_queue.Count == 0) return;
_current = _queue.Dequeue();
_elapsed = 0;
BeginClip();
_contentVersion++;
return;
}
_elapsed += deltaSeconds;
if (_elapsed >= DurationOf(_current))
{
_current = null;
_elapsed = 0;
AdvanceToNext();
}
_contentVersion++;
}
/// <summary>Whether an alert is currently playing or queued.</summary>
public bool IsPlaying => _current != null || _queue.Count > 0;
/// <summary>The live-output frame for one AlertBox element: the decoded clip
/// frame (faded) while a clip is active, else the animation frame, null
/// (transparent) when idle.</summary>
public VideoFrame? RenderFrame(Source alertBox)
{
if (_current == null)
{
_frameKey = null;
return null;
}
if (_clip != null)
return RenderClipFrame(alertBox);
var width = (int)Math.Max(1, alertBox.Width);
var height = (int)Math.Max(1, alertBox.Height);
var key = $"{width}x{height}|{_current.Id}|{Progress()}";
if (_frameKey == key && _frameVersion == _contentVersion)
return _cachedFrame;
var app = Application.Current;
if (app != null && !app.Dispatcher.CheckAccess())
{
var capturedKey = key;
var capturedVersion = _contentVersion;
return app.Dispatcher.Invoke(() =>
{
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
return _cachedFrame;
return RenderCore(alertBox, width, height, capturedKey, capturedVersion);
});
}
return RenderCore(alertBox, width, height, key, _contentVersion);
}
/// <summary>The ticker strip as the alert box shows it: a marquee, a pulse or a
/// static pill naming the event while an alert plays, else null. Pure byte-math
/// per call so the frame pump can read it on any thread (the text strip is
/// rasterized once on the UI thread and cached).
/// <para><b>Creator ruling 2026-09-26:</b> the strip is composited INSIDE the
/// Stream Alerts box, not as a full-width bar at the top edge of the frame — "the
/// ticker should appear over the stream alerts video, not over the entire preview
/// window". So the frame is rendered at the box's own size and carries the box's
/// origin on <see cref="VideoFrame.Placement"/>; every blit site reads
/// <see cref="VideoFrame.OriginX"/>/<see cref="VideoFrame.OriginY"/> instead of
/// a hardcoded 0. <b>No alert box in the scene means no ticker at all</b> — there
/// is no longer a global position for it to live in.</para></summary>
public VideoFrame? AlertTickerFrame
{
get
{
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
var box = _currentBox;
if (box == null) return null;
var w = (int)Math.Max(1, Math.Round(box.Width));
var h = (int)Math.Max(1, Math.Min(
Math.Round(box.Height), AlertTickerRenderer.Height));
var frame = AlertTickerRenderer.Render(
_tickerText!, _elapsed, _tickerMethod, AlertSeconds(),
TickerReadsPerAlert, w, h);
return frame == null
? null
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
{
Placement = ((int)Math.Round(box.X), (int)Math.Round(box.Y)),
};
}
}
/// <summary>How many times the marquee must cross the frame per alert, so the
/// announcement is actually readable inside a 10s clip (2026-09-26: the old
/// fixed 140px/s took ~17s per pass, so a 10s alert showed it barely once).</summary>
internal const int TickerReadsPerAlert = 3;
/// <summary>Assumed alert length for the marquee rhythm. The clip's real length
/// isn't known until it EOFs, and the shipped default clip is 10s — the number
/// the creator's "three reads" target is stated against.</summary>
internal const double AssumedAlertSeconds = 10.0;
private double AlertSeconds() => _clip != null ? AssumedAlertSeconds : DurationOf(_current!);
private VideoFrame? RenderClipFrame(Source alertBox)
{
var frame = _latestClipFrame;
if (frame == null) return null;
var w = (int)Math.Max(1, alertBox.Width);
var h = (int)Math.Max(1, alertBox.Height);
if (frame.Width != w || frame.Height != h)
frame = StretchMath.BilinearScale(frame, w, h);
var fade = FadeAt();
if (fade >= 1.0) return frame;
// Straight-alpha source-over on the compositor side: scale the source
// alpha by the envelope so the clip blends out (a 680x200 copy per fade
// tick is trivial; outside the ramps fade==1 returns the raw frame).
var copy = new byte[frame.BgraPixels.Length];
Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length);
var a = (byte)(fade * 255 + 0.5);
for (var i = 3; i < copy.Length; i += 4)
copy[i] = (byte)((copy[i] * a) >> 8);
return new VideoFrame(frame.Width, frame.Height, copy);
}
private double FadeAt()
{
if (_clip == null) return 1.0;
if (_clipEof)
return Math.Clamp(1.0 - (_elapsed - _clipEofElapsed) / FadeDurationSeconds, 0.0, 1.0);
return Math.Clamp(_elapsed / FadeDurationSeconds, 0.0, 1.0);
}
private void BeginClip()
{
StopClip();
_tickerText = null;
if (_current == null) return;
var box = _scenes?.SelectMany(s => s.Elements).OfType<Source>()
.FirstOrDefault(s => s.Type == SourceType.AlertBox);
_currentBox = box;
if (box != null && box.AlertShowTicker)
{
_tickerText = ComposeTicker(_current);
_tickerMethod = box.AlertDisplayMethod;
}
if (box == null || _clipDecoderFactory == null) return;
var path = _clipPathResolver?.Invoke(box);
if (string.IsNullOrWhiteSpace(path)) return;
_clipWidth = (int)Math.Max(1, box.Width);
_clipHeight = (int)Math.Max(1, box.Height);
try
{
_clipSawFrame = false;
_clipAudioHeard = false;
_clip = _clipDecoderFactory(path, _clipWidth, _clipHeight);
_clip.FrameAvailable += OnClipFrame;
_clip.AudioReady += OnClipAudio;
_clip.Completed += OnClipCompleted;
_clip.Start();
AppLog.Write($"Alert clip start: box {box.Id} path '{path}' {_clipWidth}x{_clipHeight}");
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert clip setup failed");
StopClip(); // decode setup failed (bad path) — fall back to animations
}
}
private void StopClip()
{
var clip = _clip;
_clip = null;
_latestClipFrame = null;
_clipEof = false;
_clipSawFrame = false;
_clipAudioHeard = false;
if (clip == null) return;
clip.FrameAvailable -= OnClipFrame;
clip.AudioReady -= OnClipAudio;
clip.Completed -= OnClipCompleted;
try { clip.Stop(); } catch { /* already stopped */ }
clip.Dispose();
}
private void OnClipFrame(VideoFrame frame)
{
_clipSawFrame = true;
_latestClipFrame = frame;
}
private void OnClipCompleted()
{
_clipEof = true;
_clipEofElapsed = _elapsed;
}
private void OnClipAudio(AudioSample sample)
{
if (_audioSink == null) return;
_clipAudioHeard = true;
var volume = (float)Math.Clamp(_currentBox?.AlertVideoVolume ?? 1.0, 0.0, 1.0);
if (volume <= 0f) return;
var fade = FadeAt();
if (fade <= 0.0) return;
var data = sample.Samples;
if (fade < 1.0 || Math.Abs(volume - 1f) > 0.001f)
{
data = new float[data.Length];
var gain = volume * (float)fade;
for (var i = 0; i < sample.Samples.Length; i++)
data[i] = sample.Samples[i] * gain;
}
_audioSink(new AudioSample(data, sample.SampleRate, sample.Channels));
}
private void AdvanceToNext()
{
if (_queue.Count > 0)
{
_current = _queue.Dequeue();
_elapsed = 0;
BeginClip();
}
else
{
_ticker?.Stop();
}
}
private VideoFrame? RenderCore(Source alertBox, int width, int height, string key, int version)
{
_frameKey = key;
_frameVersion = version;
if (_current == null) return _cachedFrame = null;
return _cachedFrame = _renderer.Render(_current, Progress(), width, height);
}
private double Progress()
{
if (_current == null) return 0;
return Math.Clamp(_elapsed / DurationOf(_current), 0.0, 1.0);
}
private static double DurationOf(ChatMessage m) => m.Kind switch
{
ChatEventKind.SuperChat => 4.6,
ChatEventKind.SuperSticker => 4.2,
ChatEventKind.NewMember => 4.0,
ChatEventKind.MemberGift => 5.0,
ChatEventKind.GiftReceived => 4.4,
ChatEventKind.MemberMilestone => 4.8,
_ => 4.0,
};
private static string ComposeTicker(ChatMessage m)
{
var label = m.Kind switch
{
ChatEventKind.SuperChat => "Super Chat",
ChatEventKind.SuperSticker => "Super Sticker",
ChatEventKind.NewMember => "New Member",
ChatEventKind.MemberGift => "Membership Gift",
ChatEventKind.GiftReceived => "Gifted Membership",
ChatEventKind.MemberMilestone => "Member Milestone",
_ => "Stream Alert",
};
var amount = m.Kind is ChatEventKind.SuperChat or ChatEventKind.SuperSticker
&& m.SuperChatDisplayString is { Length: > 0 } s
? $" · {s}"
: "";
return $"{m.AuthorName} — {label}{amount}";
}
/// <summary>Render the current state onto one AlertBox preview (mirrors chat):
/// the playing frame, or null when idle so the source shows nothing until an
/// event actually fires.</summary>
public void UpdatePreview(Source alertBox)
{
try
{
var frame = RenderFrame(alertBox);
// Preview eyes (TASK 47, 2026-09-26): a healthy decode painted nothing on
// the canvas. UpdatePreview swallows its failures to Debug, so say once a
// second whether the box is being handed a frame at all.
if (IsPlaying)
{
var now = DateTime.UtcNow;
if (now - _lastPreviewLog >= TimeSpan.FromSeconds(1))
{
_lastPreviewLog = now;
AppLog.Write($"Alert preview: box {alertBox.Id} frame="
+ (frame == null ? "null" : $"{frame.Width}x{frame.Height} a{frame.BgraPixels[3]}"));
}
}
if (frame == null)
{
alertBox.VideoImageSource = null;
return;
}
var width = (int)Math.Max(1, alertBox.Width);
var height = (int)Math.Max(1, alertBox.Height);
var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
alertBox.VideoImageSource = bitmap;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Alert preview failed: {ex.Message}");
}
}
/// <summary>Wire property-change subscriptions for all AlertBox sources across
/// every scene (geometry changes re-render), and clear/reset their previews.</summary>
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
{
_scenes = scenes;
foreach (var scene in scenes)
foreach (var alertBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.AlertBox))
{
alertBox.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.Width) or nameof(Source.Height))
UpdatePreview(alertBox);
};
UpdatePreview(alertBox);
}
}
private void RefreshAlertPreviews()
{
// The announcement strip is a GLOBAL 1920px overlay, not a per-box frame, so
// it gets its own sink: until this existed the strip went only to the frame
// pump and was therefore invisible in the preview pane the creator watches
// (2026-09-26 — "I do not see the scrolling text").
_tickerPreviewSink?.Invoke(AlertTickerFrame);
if (_scenes == null) return;
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.AlertBox))
UpdatePreview(alertBox);
}
public void Dispose()
{
_ticker?.Stop();
_queue.Clear();
_current = null;
StopClip();
}
}
+389
View File
@@ -0,0 +1,389 @@
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Renders one event's celebration animation into a BGRA8 frame for the AlertBox.
/// Six event kinds, six UNIQUE animations — each a distinct gesture, palette and
/// layout (the creator's ruling: these are the monetization shout-outs and free
/// branded product placement; a one-template recoloring loses all interest in two
/// iterations). Each card carries the bottom-right brand line so every broadcast
/// of an event is an in-video ad placement.
///
/// <paramref name="t01"/> walks 0→1 over the whole animation; each kind splits it
/// into enter / hold / exit phases with its own easing.
/// </summary>
public sealed class AlertRenderer
{
private const double EnterEnd = 0.16;
private const double ExitStart = 0.80;
private const string Brand = "made with LlamaCasty!";
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
private static readonly Color White = Colors.White;
private static readonly Color[] ConfettiPalette =
{
Color.FromRgb(0xf4, 0x2f, 0x5f), Color.FromRgb(0xff, 0xd1, 0x66),
Color.FromRgb(0x38, 0xb6, 0xf8), Color.FromRgb(0xa7, 0x8b, 0xfa),
Color.FromRgb(0x39, 0xd3, 0x53),
};
public VideoFrame Render(ChatMessage e, double t01, int width, int height)
{
return e.Kind switch
{
ChatEventKind.SuperChat => SuperChat(e, t01, width, height),
ChatEventKind.SuperSticker => SuperSticker(e, t01, width, height),
ChatEventKind.NewMember => NewMember(e, t01, width, height),
ChatEventKind.MemberGift => MemberGift(e, t01, width, height),
ChatEventKind.GiftReceived => GiftReceived(e, t01, width, height),
ChatEventKind.MemberMilestone => Milestone(e, t01, width, height),
_ => SuperChat(e, t01, width, height),
};
}
private static VideoFrame SuperChat(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.03;
var cardX = margin;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = cardX + cardW / 2;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var exitEase = EaseInCubic(exit);
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
cardY = Lerp(cardY, -cardH, exitEase);
DrawCard(ctx, cardX, cardY, cardW, cardH, "#0d3b2b", "#14532d", 14);
DrawBrand(ctx, cardX, cardY, cardW, cardH);
var shownAmount = e.SuperChatDisplayString
?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : $"${e.SuperChatAmount:0.00}");
var fontSize = cardH * 0.24;
DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW);
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.56, cardH * 0.13, FontWeights.SemiBold, White, cardW);
DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xdd, 0xff, 0xf0), cardW * 0.92);
if (t >= EnterEnd && t <= ExitStart)
{
var sweep = (t - EnterEnd) / (ExitStart - EnterEnd);
var shineX = cardX + (sweep * 2 - 0.5) * cardW;
ctx.DrawRectangle(
new LinearGradientBrush(
Colors.Transparent,
Color.FromArgb(0x30, 0xff, 0xff, 0xff),
new Point(0, 0),
new Point(1, 0)),
null,
new Rect(shineX - cardW * 0.25, cardY, cardW * 0.5, cardH));
}
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame SuperSticker(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.04;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var scale = EaseOutBack(enter) * (0.85 + 0.15 * (1 - exit));
if (t > EnterEnd && t <= ExitStart)
scale = 1.0 + 0.02 * Math.Sin(t * 26.0);
var alpha = 1.0 - exit;
ctx.PushOpacity(alpha);
var slick = new RotateTransform(-6, w / 2.0, h / 2.0);
ctx.PushTransform(slick);
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
DrawCard(ctx, margin, margin, cardW, cardH, "#4a044e", "#7e22ce", 20);
DrawBrand(ctx, margin, margin, cardW, cardH);
var desc = string.IsNullOrWhiteSpace(e.SuperStickerDescription) ? "SUPER STICKER" : e.SuperStickerDescription.ToUpperInvariant();
DrawCentered(ctx, desc, cardCX, h / 2.0 - cardH * 0.12, cardH * 0.17, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, Color.FromRgb(0xff, 0xb3, 0xd9), cardW * 0.9);
var tier = e.SuperChatDisplayString ?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : null);
if (!string.IsNullOrWhiteSpace(tier))
DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
ctx.Pop();
ctx.Pop();
ctx.Pop();
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame NewMember(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.035;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var cardY = Lerp(-cardH, margin, EaseOutCubic(enter));
cardY = Lerp(cardY, h, EaseInCubic(exit));
DrawCard(ctx, margin, cardY, cardW, cardH, "#2e1065", "#5b21b6", 16);
DrawBrand(ctx, margin, cardY, cardW, cardH);
if (t <= ExitStart)
{
var bandP = EaseOutCubic(Clamp01(t / 0.22));
ctx.DrawRectangle(
new LinearGradientBrush(Color.FromArgb(0, 0xff, 0xff, 0xff), Colors.Transparent, new Point(0, 0), new Point(0, 1)),
null,
new Rect(margin, cardY + cardH * 0.30, cardW * bandP, cardH * 0.18));
}
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.36, cardH * 0.22, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, Color.FromRgb(0xf5, 0xd0, 0xfe), cardW * 0.9);
var level = string.IsNullOrWhiteSpace(e.MembershipLevel) ? "MEMBER" : e.MembershipLevel.ToUpperInvariant();
DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame MemberGift(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.035;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var cardX = Lerp(-cardW, margin, EaseOutCubic(enter));
cardX = Lerp(cardX, -cardW, EaseInCubic(exit));
DrawCard(ctx, cardX, margin, cardW, cardH, "#422006", "#a16207", 16);
DrawBrand(ctx, cardX, margin, cardW, cardH);
var count = Math.Max(1, e.GiftNewMembersCount);
var spread = 1.0 - exit;
var hero = count > 1 ? $"×{count}" : "MEMBERSHIP";
DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.8);
DrawCentered(ctx, $"{e.AuthorName} is gifting", cardCX, margin + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
var chipCount = Math.Min(count, 6);
for (var i = 0; i < chipCount; i++)
{
var slot = (chipCount - 1) * 0.5;
var gap = cardH * 0.13 * EaseOutCubic(Clamp01(t / 0.30)) * spread;
var chipX = cardCX + (i - slot) * gap;
var chipY = margin + cardH * 0.70 + Math.Sin((i + t * 6) * 2.4) * cardH * 0.015;
ctx.DrawRoundedRectangle(
new SolidColorBrush(Color.FromRgb(0xff, 0xf3, 0xc4)),
null,
new Rect(chipX - cardH * 0.05, chipY, cardH * 0.10, cardH * 0.10),
cardH * 0.03, cardH * 0.03);
}
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame GiftReceived(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.04;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / (EnterEnd * 1.4));
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var scale = 0.5 + 0.5 * EaseOutBack(enter);
var alpha = Math.Min(1.0, enter * 1.6) * (1 - exit);
ctx.PushOpacity(alpha);
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
DrawCard(ctx, margin, margin, cardW, cardH, "#14532d", "#4c1d95", 18);
DrawBrand(ctx, margin, margin, cardW, cardH);
// Confetti falling inside the card during the hold (deterministic seed).
if (t >= EnterEnd && t <= ExitStart)
{
var hold = (t - EnterEnd) / (ExitStart - EnterEnd);
for (var i = 0; i < 14; i++)
{
var seed = i * 37 + 11;
var dotX = margin + cardW * (((seed * 53) % 97) / 97.0);
var dotY = margin + cardH * (((seed + hold * 0.85) % 1.0));
var size = cardH * 0.02 + (i % 3) * cardH * 0.006;
ctx.DrawRectangle(
new SolidColorBrush(ConfettiPalette[i % ConfettiPalette.Length]),
null,
new Rect(dotX, dotY, size, size));
}
}
DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
var giver = string.IsNullOrWhiteSpace(e.GifterName) ? e.AuthorName : e.GifterName;
DrawCentered(ctx, giver, cardCX, margin + cardH * 0.42, cardH * 0.20, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, Color.FromRgb(0xdb, 0xff, 0xf0), cardW * 0.9);
ctx.Pop();
ctx.Pop();
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame Milestone(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.04;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
cardY = Lerp(cardY, -cardH, EaseInCubic(exit));
DrawCard(ctx, margin, cardY, cardW, cardH, "#431407", "#c2410c", 14);
DrawBrand(ctx, margin, cardY, cardW, cardH);
var hero = e.MilestoneMonths > 0 ? $"{e.MilestoneMonths}" : "MILESTONE";
DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, Color.FromRgb(0xff, 0xdd, 0x66), cardW * 0.8);
var sub = e.MilestoneMonths > 0 ? "MONTHS STRONG" : "SUPPORTING THE STREAM";
DrawCentered(ctx, sub, cardCX, cardY + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xff, 0xe8, 0xd0), cardW * 0.9);
// Tenure bar: grows through the first half, retracts on exit.
var barP = Clamp01((t - 0.10) / 0.55) * (1 - exit);
var barW = cardW * 0.6 * barP;
ctx.DrawRoundedRectangle(
new SolidColorBrush(Color.FromRgb(0xff, 0xd1, 0x66)),
null,
new Rect(cardCX - barW / 2, cardY + cardH * 0.84, barW, cardH * 0.045),
4, 4);
}
return CreateFrame(visual, w, h, dpi);
}
// ─── Shared drawing ───
private static void DrawCard(DrawingContext ctx, double x, double y, double w, double h, string fromHex, string toHex, double radius)
{
var brush = new LinearGradientBrush(ParseColor(fromHex), ParseColor(toHex), new Point(0, 0), new Point(1, 1));
ctx.DrawRoundedRectangle(brush, new Pen(new SolidColorBrush(Color.FromArgb(0x66, 0xff, 0xff, 0xff)), Math.Max(1, radius * 0.06)), new Rect(x, y, w, h), radius, radius);
}
private static void DrawBrand(DrawingContext ctx, double x, double y, double w, double h)
{
var formatted = new FormattedText(
Brand, Invariant, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
Math.Max(9, h * 0.055),
new SolidColorBrush(Color.FromArgb(0x88, 0xff, 0xff, 0xff)),
96.0);
ctx.DrawText(formatted, new Point(x + w - formatted.Width - h * 0.08, y + h - formatted.Height - h * 0.05));
}
private static void DrawCentered(DrawingContext ctx, string text, double cx, double cy, double size, FontWeight weight, Color color, double maxWidth)
{
var formatted = new FormattedText(
text, Invariant, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, weight, FontStretches.Normal),
size,
new SolidColorBrush(color),
96.0);
formatted.MaxTextWidth = maxWidth;
formatted.MaxLineCount = 1;
formatted.Trimming = TextTrimming.CharacterEllipsis;
ctx.DrawText(formatted, new Point(cx - formatted.Width / 2, cy - formatted.Height / 2));
}
private static Color ParseColor(string hex)
{
hex = hex.Trim().TrimStart('#');
if (hex.Length == 6)
{
var r = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
var g = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
var b = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
return Color.FromRgb(r, g, b);
}
if (hex.Length == 8)
{
var a = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
var r = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
var g = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
var b = byte.Parse(hex.AsSpan(6, 2), NumberStyles.HexNumber);
return Color.FromArgb(a, r, g, b);
}
return Colors.White;
}
private static VideoFrame CreateFrame(DrawingVisual visual, int width, int height, double dpi)
{
var rtb = new RenderTargetBitmap(width, height, dpi, dpi, PixelFormats.Pbgra32);
rtb.Render(visual);
var stride = width * 4;
var pixels = new byte[stride * height];
rtb.CopyPixels(pixels, stride, 0);
for (var i = 0; i < pixels.Length; i += 4)
{
(pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]);
}
return new VideoFrame(width, height, pixels);
}
private static double Clamp01(double v) => Math.Clamp(v, 0.0, 1.0);
private static double Lerp(double a, double b, double p) => a + (b - a) * p;
private static double EaseInCubic(double p) => p * p * p;
private static double EaseOutCubic(double p) => 1 - Math.Pow(1 - p, 3);
private static double EaseOutBack(double p)
{
const double c1 = 1.70158;
const double c3 = c1 + 1;
return 1 + c3 * Math.Pow(p - 1, 3) + c1 * Math.Pow(p - 1, 2);
}
}
+8 -4
View File
@@ -28,8 +28,12 @@ public sealed class AudioGainProvider
/// <summary>Mic gain for the live mix (0 = muted).</summary>
public double MicGain() => _micMuted() ? 0 : _micVolume();
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted, 1 = unity).
/// Unity because the slider now drives system volume via PushSystemVolume,
/// so the WASAPI loopback signal is already scaled by the user's volume choice.</summary>
public double LoopbackGain() => _gameMuted() ? 0 : 1.0;
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted).
/// Honesty rule (2026-09-01): WASAPI loopback capture does NOT shrink with the
/// endpoint volume — the creator dropped system volume to 20% and the captured
/// level stayed pegged — so the mixer must multiply by GameAudioVolume itself
/// for the stream to follow the knob (the old unity assumption left the stream
/// at full desktop volume while the headphones went quiet; ai.md always
/// specified loopbackGain = GameAudioVolume — the code had drifted).</summary>
public double LoopbackGain() => _gameMuted() ? 0 : _gameVolume();
}
+153 -10
View File
@@ -1,3 +1,4 @@
using ytLive.Helpers;
using ytLive.Services;
namespace ytLive.Services.Audio;
@@ -13,7 +14,9 @@ namespace ytLive.Services.Audio;
/// queues, applies the honest gains (mic = MicVolume, loopback = unity
/// × auto-duck when the mic is hot — the slider drives system volume via mixes them to stereo float, runs the
/// master limiter (a -1 dBFS ceiling so the sum never clips the encoder), and
/// writes the chunk to the encoder's audio pipe.
/// writes the chunk to the encoder's audio pipe. Since TASK 47 the alert layer's
/// clip audio joins the same mix (pre-scaled, unity-added, never ducked) through
/// <see cref="EnqueueAlertAudio"/>.
/// </summary>
public sealed class AudioMixer : IDisposable
{
@@ -29,12 +32,16 @@ public sealed class AudioMixer : IDisposable
private readonly Func<double>? _micGain;
private readonly Func<double>? _loopbackGain;
private readonly TimeSpan _mixInterval;
private readonly Func<int>? _syncOffsetMs;
private readonly AudioSyncDelay _syncDelay;
private int _advanceSamplesRemaining;
private bool _started;
// Live path (TASK 9): per-source resampling → voice chain on the mic →
// thread-safe queues → the ducker + gain/mix loop → the encoder's pipe.
private readonly AudioRingBuffer _micBuffer;
private readonly AudioRingBuffer _loopbackBuffer;
private readonly AudioRingBuffer _alertBuffer;
private readonly VoiceFilterChain _voiceChain;
private readonly AutoDucker _ducker;
private readonly MasterLimiter _masterLimiter = new();
@@ -44,7 +51,14 @@ public sealed class AudioMixer : IDisposable
private NamedPipeAudioWriter _pipe = new();
private float[]? _micChunk;
private float[]? _loopbackChunk;
private float[]? _alertChunk;
private float[]? _mixBuffer;
private float[]? _delayedMix;
private DateTime _lastLoopErrorLogged = DateTime.MinValue;
private DateTime _nextStatsLog = DateTime.UtcNow + TimeSpan.FromSeconds(5);
private long _micDrainedTotal;
private long _loopDrainedTotal;
private float _peakMix;
public AudioMixer(
IAudioSource mic,
@@ -52,6 +66,7 @@ public sealed class AudioMixer : IDisposable
Action<string>? log = null,
Func<double>? micGain = null,
Func<double>? loopbackGain = null,
Func<int>? syncOffsetMs = null,
TimeSpan? mixInterval = null)
{
_mic = mic;
@@ -61,17 +76,22 @@ public sealed class AudioMixer : IDisposable
_log = log;
_micGain = micGain;
_loopbackGain = loopbackGain;
_syncOffsetMs = syncOffsetMs;
_syncDelay = new AudioSyncDelay(OutputSampleRate);
_mixInterval = mixInterval ?? DefaultMixInterval;
var seconds = _mixInterval.TotalSeconds;
var framesPerTick = Math.Max(1, (int)Math.Round(OutputSampleRate * seconds));
_micBuffer = new AudioRingBuffer(OutputSampleRate * 2);
_loopbackBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 2);
_alertBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 8);
_voiceChain = new VoiceFilterChain(OutputSampleRate);
_ducker = new AutoDucker();
_micChunk = new float[framesPerTick];
_loopbackChunk = new float[framesPerTick * 2];
_alertChunk = new float[framesPerTick * 2];
_mixBuffer = new float[framesPerTick * 2];
_delayedMix = new float[framesPerTick * 2];
_mic.Started += OnMicStarted;
_mic.SampleReady += OnMicSample;
@@ -146,19 +166,48 @@ public sealed class AudioMixer : IDisposable
if (_liveCts != null)
return;
// Capture runs from app STARTUP for the meters, so both rings carry up
// to a full ring depth (~2s) of pre-live backlog by the time the user
// hits record; draining it made every recorded event land ~2s late in
// the audio track (2026-09-14, two confirmed takes). Discard it so the
// recording starts at go-live. The writers keep running concurrently;
// Clear under the ring's lock, so only the in-flight chunk is dropped.
_micBuffer.Clear();
_loopbackBuffer.Clear();
_alertBuffer.Clear();
// Negative sync offset (audio runs BEHIND video): OBS's fix is to "eat
// the head of the buffer" — discard the first |N| ms of the live stream
// so every subsequent audio event lands |N| ms EARLIER in the recording.
// Armed once at go-live (you can't eat the head mid-stream); positive
// offsets stay live-reactive through the delay line in FillAndMix.
var syncMs = _syncOffsetMs?.Invoke() ?? 0;
_advanceSamplesRemaining = syncMs < 0
? (int)Math.Round(OutputSampleRate / 1000.0 * -syncMs) * 2
: 0;
if (_advanceSamplesRemaining > 0)
_log?.Invoke($"Audio live: advance armed ({-syncMs} ms ahead)");
var cts = new CancellationTokenSource();
_liveCts = cts;
_pipe = new NamedPipeAudioWriter();
_pipe.Start(pipeName);
_log?.Invoke($"Audio live: started, pipe '{pipeName}'");
_ = Task.Run(() => LiveLoopAsync(_pipe, cts.Token));
}
/// <summary>Ends the live mix loop and closes the audio pipe — ffmpeg sees
/// EOF on the audio input. Safe when not live.</summary>
/// EOF on the audio input. Safe when not live: the writer's Stop() is idempotent
/// (StopStream now calls this on every rollback path).</summary>
public void StopLive()
{
_liveCts?.Cancel();
var cts = _liveCts;
_liveCts = null;
if (cts != null)
{
_log?.Invoke("Audio live: stopped");
cts.Cancel();
}
_pipe.Stop();
}
@@ -204,6 +253,34 @@ public sealed class AudioMixer : IDisposable
LoopbackLevelChanged?.Invoke(level);
}
/// <summary>Drops the alert clip's audio into its own ring (TASK 47 — the
/// stream-alert video's soundtrack, pre-scaled by the alert layer to
/// volume × fade). Mixed at unity into the live output — NO auto-duck: an
/// alert must be heard over the mic, so the Creator's per-alert volume
/// slider is the honest gain and nothing else is stacked on top. Monno and
/// non-48k sources are normalized (the decoder already emits 48k stereo).</summary>
public void EnqueueAlertAudio(AudioSample sample)
{
var data = sample.Samples;
if (data.Length == 0) return;
if (sample.SampleRate != OutputSampleRate)
data = ResampleLoopback(data, sample.SampleRate);
if (sample.Channels == 1)
data = UpmixToStereo(data);
_alertBuffer.Write(data);
}
private static float[] UpmixToStereo(float[] mono)
{
var stereo = new float[mono.Length * 2];
for (var i = 0; i < mono.Length; i++)
{
stereo[i * 2] = mono[i];
stereo[i * 2 + 1] = mono[i];
}
return stereo;
}
private void OnMicFailed(Exception ex)
{
_log?.Invoke($"Mic capture failed: {ex.Message}");
@@ -263,8 +340,42 @@ public sealed class AudioMixer : IDisposable
var nextTick = DateTime.UtcNow + _mixInterval;
try
{
FillAndMix(micChunk, loopbackChunk, mixBuffer);
await pipe.WriteAsync(mixBuffer, cancellationToken).ConfigureAwait(false);
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer);
// Negative offset: skip the armed |N| ms of stream head. The pipe
// writer tolerates a partial chunk (any float length is valid);
// dropping the head re-anchors the audio stream against the video
// stream, making each event land earlier — OBS's eat-the-head fix.
var writeFrom = 0;
if (_advanceSamplesRemaining > 0)
{
var drop = Math.Min(_advanceSamplesRemaining, mixBuffer.Length);
_advanceSamplesRemaining -= drop;
writeFrom = drop;
}
if (writeFrom < mixBuffer.Length)
await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom), cancellationToken).ConfigureAwait(false);
_micDrainedTotal += micDrained;
_loopDrainedTotal += loopDrained;
for (var i = writeFrom; i < mixBuffer.Length; i++)
{
var abs = Math.Abs(mixBuffer[i]);
if (abs > _peakMix) _peakMix = abs;
}
var now = DateTime.UtcNow;
if (now >= _nextStatsLog && _log != null)
{
_nextStatsLog = now + TimeSpan.FromSeconds(5);
_log($"Audio live: pipe connected={pipe.IsConnected} dropped writes={pipe.DroppedWrites} " +
$"micLevel={_meter.Level:F3} loopLevel={_loopbackMeter.Level:F3} " +
$"drained {_micDrainedTotal:N0}/{_loopDrainedTotal:N0} samples peakMix {_peakMix:F3} (per 5s)");
_micDrainedTotal = 0;
_loopDrainedTotal = 0;
_peakMix = 0;
}
}
catch (OperationCanceledException)
{
@@ -272,8 +383,19 @@ public sealed class AudioMixer : IDisposable
}
catch (Exception ex)
{
// Throttled + never on a dead session: the 2026-09-01 first-launch flood
// was one logged NRE every 10ms tick (see _delayedMix fix). Log at most
// once per 5s, and stop looping if the pipe writer is gone for good.
if (cancellationToken.IsCancellationRequested)
break;
var now = DateTime.UtcNow;
if (now - _lastLoopErrorLogged >= TimeSpan.FromSeconds(5))
{
_lastLoopErrorLogged = now;
AppLog.Write(ex, "Audio live loop error");
_log?.Invoke($"Audio live loop error: {ex.Message}");
}
}
var delay = nextTick - DateTime.UtcNow;
if (delay > TimeSpan.Zero)
@@ -291,11 +413,16 @@ public sealed class AudioMixer : IDisposable
}
/// <summary>Drains one tick's worth of mic + loopback, silence-fills any
/// underrun, applies the ducker and the honest gains, and mixes to stereo.</summary>
private float FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix)
/// underrun, applies the ducker and the honest gains, and mixes to stereo.
/// Returns the pre-duck mic RMS and how many samples each buffer contributed
/// (the 5s live-loop telemetry reads those to name whether capture or the
/// pipe starved a silent recording).</summary>
private (float MicRms, int MicDrained, int LoopDrained) FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix)
{
var micCount = _micBuffer.Read(micChunk, micChunk.Length);
var loopCount = _loopbackBuffer.Read(loopbackChunk, loopbackChunk.Length);
var alertChunk = _alertChunk!;
var alertCount = _alertBuffer.Read(alertChunk, alertChunk.Length);
float micRms = 0;
if (micCount == 0)
@@ -321,6 +448,15 @@ public sealed class AudioMixer : IDisposable
Array.Clear(loopbackChunk, loopCount, loopbackChunk.Length - loopCount);
}
if (alertCount == 0)
{
Array.Clear(alertChunk, 0, alertChunk.Length);
}
else if (alertCount < alertChunk.Length)
{
Array.Clear(alertChunk, alertCount, alertChunk.Length - alertCount);
}
var duck = _ducker.Update(micRms);
var micGain = (float)(_micGain?.Invoke() ?? 1.0);
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
@@ -329,10 +465,17 @@ public sealed class AudioMixer : IDisposable
for (var i = 0; i < frames; i++)
{
var m = micChunk[i] * micGain;
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain;
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain + alertChunk[i * 2];
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain + alertChunk[i * 2 + 1];
}
_masterLimiter.Process(mix);
return micRms;
_syncDelay.Configure(_syncOffsetMs?.Invoke() ?? 0);
var delayed = _delayedMix;
if (delayed is null || delayed.Length < mix.Length)
delayed = _delayedMix = new float[mix.Length];
_syncDelay.Process(mix, delayed);
Array.Copy(delayed, mix, mix.Length);
return (micRms, micCount, loopCount);
}
}
+94
View File
@@ -0,0 +1,94 @@
using System;
namespace ytLive.Services.Audio;
/// <summary>
/// TASK 22: a fixed end-to-end audio delay applied to the interleaved stereo
/// mix in <see cref="AudioMixer"/>. This is OBS's documented fix for the
/// "audio is ahead of video" lip-sync case: delay the audio by a few
/// milliseconds so it lands on the video. It is a pure delay line — input in,
/// the same audio N ms later out — so it is trivially testable and carries no
/// state outside its own ring.
///
/// Positive offsets only (0..500 ms). Advancing audio (the audio runs BEHIND
/// the video) is handled upstream in <see cref="AudioMixer"/>, not here: the
/// mixer eats the first |N| ms of the live stream head (OBS's negative sync —
/// see <see cref="AudioMixer.StartLive"/>), keeping this line a pure delay.
/// Changing the delay flushes the line, so a live slider change clicks rather
/// than smearing. Safe to call from any thread — the mixer owns it.
/// </summary>
public sealed class AudioSyncDelay
{
private readonly int _sampleRate;
private float[] _buffer = Array.Empty<float>();
private int _delaySamples;
private int _writePos;
public AudioSyncDelay(int sampleRate)
{
_sampleRate = Math.Max(1, sampleRate);
Configure(0);
}
/// <summary>Sets the delay in whole milliseconds (clamped to 0..500). A no-op
/// when the delay hasn't actually changed — <see cref="AudioMixer.FillAndMix"/>
/// calls this every ~10ms tick (it re-reads the live UI setting each mix), and
/// unconditionally reallocating+zeroing the line every tick (the 2026-09-12
/// total-silence bug: any non-zero saved offset wiped the ring before its own
/// delayed samples could ever be read back — mic/loop meters, sampled BEFORE
/// this stage, kept showing real levels while the pipe stayed pure silence)
/// discarded the entire buffered history every 10ms. Changing the delay still
/// flushes the line (a live slider change clicks rather than smearing).</summary>
public void Configure(int delayMs)
{
var delaySamples = Frames(delayMs);
if (delaySamples == _delaySamples && _buffer.Length > 0) return;
_delaySamples = delaySamples;
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
_writePos = 0;
}
/// <summary>Delays an interleaved stereo chunk by the configured offset.
/// The returned length equals <paramref name="input"/>'s; the head is
/// silence until the line fills.</summary>
public void Process(ReadOnlySpan<float> input, Span<float> output)
{
if (output.Length < input.Length)
throw new ArgumentException("output must be at least as long as input", nameof(output));
var len = input.Length;
if (_delaySamples == 0)
{
input.CopyTo(output);
return;
}
var line = _buffer;
var lineLen = line.Length;
for (var i = 0; i < len; i++)
{
line[_writePos] = input[i];
_writePos++;
if (_writePos == lineLen)
_writePos = 0;
}
var readStart = (_writePos - _delaySamples - len + lineLen * 2) % lineLen;
for (var i = 0; i < len; i++)
{
var idx = readStart + i;
if (idx >= lineLen)
idx -= lineLen;
output[i] = line[idx];
}
}
private int Frames(int delayMs)
{
var clamped = Math.Clamp(delayMs, 0, 500);
// Interleaved stereo: each frame is two samples (L, R), so the delay
// in buffer positions is twice the frame count.
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * 2;
}
}
+13
View File
@@ -17,6 +17,12 @@ public interface IAudioPipeWriter : IDisposable
/// <summary>True once ffmpeg has connected and the pipe is writable.</summary>
bool IsConnected { get; }
/// <summary>Count of <see cref="WriteAsync"/> calls that wrote nothing because
/// the pipe wasn't connected yet or was already closed. Part of the silent-
/// recording diagnosis: nonzero means audio was dropped before ffmpeg ever
/// connected to the pipe.</summary>
long DroppedWrites { get; }
/// <summary>Writes interleaved float samples; dropped until the client connects.</summary>
Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
@@ -29,6 +35,9 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
private readonly object _sync = new();
private NamedPipeServerStream? _pipe;
/// <inheritdoc/>
public long DroppedWrites { get; private set; }
public void Start(string pipeName)
{
lock (_sync)
@@ -64,7 +73,11 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
pipe = _pipe;
}
if (pipe == null || !pipe.IsConnected || samples.IsEmpty)
{
if (pipe?.IsConnected == false || pipe == null)
DroppedWrites++;
return;
}
var bytes = MemoryMarshal.AsBytes(samples.Span).ToArray();
await pipe.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
+12
View File
@@ -171,6 +171,18 @@ public sealed class CameraManager : IDisposable
toStop.PreviewBitmap = null;
}
/// <summary>
/// True while a session exists for the device — started, or still starting.
/// Used by the app's startup resource check to avoid double-acquiring a lock
/// that the saved identity's scene configs already hold. A rolled-back (failed)
/// session is removed, so this goes false and the startup pass can retry.
/// </summary>
public bool IsRunning(string deviceId)
{
lock (_gate)
return _sessions.ContainsKey(deviceId);
}
public VideoFrame? GetLatestFrame(string deviceId)
{
lock (_gate)
+132
View File
@@ -0,0 +1,132 @@
using System.Net.Http;
using System.Text.Json;
using ytLive.Models;
using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
/// <summary>
/// Pulls a connected channel's YPP-relevant numbers from the YouTube Data API
/// (TASK 39, slice 1): subscriptions/views/videos via channels.list, and the
/// count of public uploads in the trailing 90 days via playlistItems.list on the
/// uploads playlist. Long-form watch hours and Shorts-feed views need the
/// Analytics API (a later slice behind the IChannelStatsProvider seam).
///
/// NOTE (root cause, 2026-09-23): channels.list must NOT request the
/// `auditDetails` part — that part alone requires the
/// `youtubepartner-channel-audit` scope the app doesn't (and mustn't) hold, and
/// the whole request 403s `insufficientPermissions` over it. Standing flags are
/// not exposed to ordinary apps; the drawer deep-links to YouTube Studio instead.
/// </summary>
public class ChannelStatsService
{
private readonly YouTubeAuthService _auth;
private readonly HttpClient _http;
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
public ChannelStatsService(YouTubeAuthService auth, HttpClient? http = null)
{
_auth = auth;
_http = http ?? new HttpClient();
}
private async Task<bool> EnsureToken()
{
if (_auth.CurrentChannel == null) return false;
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
return await _auth.RefreshToken();
return true;
}
/// <summary>Captures the channel's current stats into a snapshot, or null when
/// there's no session, a request fails, or the API returns nothing. Never throws.</summary>
public virtual async Task<YppStatSnapshot?> CaptureCurrentAsync()
{
if (!await EnsureToken()) return null;
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
var response = await _http.GetAsync(
$"{ApiBase}/channels?mine=true&part=statistics,contentDetails");
if (!response.IsSuccessStatusCode)
{
// Log the response body: YouTube's error.reason names the failure
// (insufficientPermissions / quotaExceeded / …) — the bare code was
// undiagnosable for the 2026-09-23 auditDetails 403.
var errorBody = await response.Content.ReadAsStringAsync();
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}");
return null;
}
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
return null;
var channel = items[0];
var statistics = channel.GetProperty("statistics");
var uploadsPlaylistId = channel.TryGetProperty("contentDetails", out var contentDetails)
&& contentDetails.TryGetProperty("relatedPlaylists", out var related)
&& related.TryGetProperty("uploads", out var uploads)
? uploads.GetString()
: null;
var uploadsLast90Days = uploadsPlaylistId != null
? await CountUploadsLast90DaysAsync(uploadsPlaylistId)
: 0;
return new YppStatSnapshot
{
CapturedAtUtc = DateTime.UtcNow,
SubscriberCount = ReadInt64(statistics, "subscriberCount"),
VideoCount = ReadInt64(statistics, "videoCount"),
TotalViews = ReadInt64(statistics, "viewCount"),
UploadsLast90Days = uploadsLast90Days,
};
}
/// <summary>YouTube serializes statistics as JSON STRINGS ("350"); a tolerant
/// read that also accepts a number (the real 403 hid this parse bug until the
/// auditDetails removal surfaced the string shape in 2026-09-23's Good Dog).
/// NOTE: JsonElement.TryGetInt64 THROWS on a non-Number token — ValueKind must
/// be checked first (try-type-in, not try-catch).</summary>
private static long ReadInt64(JsonElement parent, string name)
{
if (!parent.TryGetProperty(name, out var value)) return 0;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n)) return n;
if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n)) return n;
return 0;
}
/// <summary>Counts uploads published within the trailing 90 days, scanning the
/// most recent 50 from the uploads playlist (generous beyond the 3/90-day YPP
/// requirement; the YPP tab labels it as the recent-50 window).</summary>
private async Task<int> CountUploadsLast90DaysAsync(string uploadsPlaylistId)
{
var response = await _http.GetAsync(
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
return 0;
}
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
if (!json.TryGetProperty("items", out var items)) return 0;
var cutoff = DateTime.UtcNow.AddDays(-90);
var count = 0;
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("snippet", out var snippet)
|| !snippet.TryGetProperty("publishedAt", out var published)
|| !DateTime.TryParse(published.GetString(), out var publishedAt))
{
continue;
}
if (publishedAt.ToUniversalTime() >= cutoff)
count++;
}
return count;
}
}
+249
View File
@@ -0,0 +1,249 @@
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>Owns the YouTube-chat overlay behavior: the message buffer, the
/// renderer, and the fade/mock-preview timers. The MainViewModel keeps only the
/// thin binding surface (the shared <see cref="Messages"/> collection + two
/// computed gate props) and hands the scene list in at each call — this class
/// owns all chat logic so an AI can read one self-contained unit.</summary>
public sealed class ChatOverlayLayer
{
private readonly ChatBoxRenderer _renderer;
// Mock chat messages for the ChatBox preview — branded placeholders so the
// creator sees where the overlay lands before going live.
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
{
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
};
// Chat fade timer: clears chat overlay after N seconds of inactivity.
private DispatcherTimer? _fadeTimer;
private CancellationTokenSource? _mockCts;
/// <summary>The live chat message buffer. Shared with the ViewModel (which
/// binds it to the panel) via <c>ChatMessages =&gt; _chatLayer.Messages</c>.</summary>
public ObservableCollection<ChatMessage> Messages { get; } = new();
// Output-frame cache (take-5 starvation fix): the pump called RenderFrame
// every tick and the renderer did a FULL WPF raster (FormattedText +
// RenderTargetBitmap + CopyPixels + channel swap) each time — 15-25ms of a
// 25.5ms render. Established answer (OBS text sources): raster on change,
// blit the cache every tick. _contentVersion bumps on every buffer mutation
// (CollectionChanged — covers adds, the 500-cap removal, and the fade
// Clear from any caller); the config key covers the box geometry/appearance.
// The cached VideoFrame is treated as immutable: the compositor only reads.
private int _contentVersion;
private int _frameVersion = -1;
private string? _frameKey;
private VideoFrame? _cachedFrame;
public ChatOverlayLayer(ChatBoxRenderer renderer)
{
_renderer = renderer;
Messages.CollectionChanged += (_, _) => _contentVersion++;
}
/// <summary>Ingest a message from the poller. UI-thread marshalled: appended
/// to the buffer (capped at 500), mock preview stopped, swipe to real
/// messages, and the fade timer reset. Scenes are handed in so the layer can
/// refresh every ChatBox preview without owning the scene graph.</summary>
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
Messages.Add(message);
if (Messages.Count > 500)
Messages.RemoveAt(0);
// Reset chat fade timer on every message
ResetChatFadeTimer(scenes);
StopMockPreview();
// Switch ChatBox previews from mock to real messages
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox))
UpdatePreview(chatBox);
});
}
private void ResetChatFadeTimer(IEnumerable<Scene> scenes)
{
_fadeTimer?.Stop();
var chatBoxes = scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
if (chatBoxes.Count == 0) return;
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
_fadeTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(delay) };
_fadeTimer.Tick += (_, _) =>
{
_fadeTimer?.Stop();
_fadeTimer = null;
Messages.Clear();
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox))
UpdatePreview(chatBox);
StartMockPreview(scenes);
};
_fadeTimer.Start();
}
/// <summary>Renders the live-output frame for one ChatBox element (the
/// compositor path), or null when no messages are buffered yet. Mirrors the
/// preview render but without writing onto the source's VideoImageSource.</summary>
public VideoFrame? RenderFrame(Source chatBox)
{
var width = (int)Math.Max(1, chatBox.Width);
var height = (int)Math.Max(1, chatBox.Height);
var key =
$"{width}x{height}|{chatBox.ChatFontSize}|{chatBox.ChatFontFamily}|{chatBox.ChatFontColor}"
+ $"|{chatBox.ChatBackgroundColor}|{chatBox.ChatMaxMessages}|{chatBox.ChatShowTimestamps}"
+ $"|{chatBox.ChatShowBadges}|{chatBox.ChatMemberColor}|{chatBox.ChatSuperChatColor}";
if (_frameKey == key && _frameVersion == _contentVersion)
return _cachedFrame; // same content + same config — the cached raster stands
// The raster is WPF (DrawingVisual + RenderTargetBitmap = UI-thread objects)
// and the frame pump now renders from its own thread (take-8 off-UI fix) —
// so only the cache CHECK runs off-thread; the rare miss marshals to the
// dispatcher and re-validates there (a second message landing mid-hop must
// not be answered by a snapshot taken before it).
var app = System.Windows.Application.Current;
if (app != null && !app.Dispatcher.CheckAccess())
{
var capturedKey = key;
var capturedVersion = _contentVersion;
var frame = app.Dispatcher.Invoke(() =>
{
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
return _cachedFrame; // someone already re-rasterized on the UI thread
return RenderCore(chatBox, width, height, capturedKey, capturedVersion);
});
return frame;
}
return RenderCore(chatBox, width, height, key, _contentVersion);
}
private VideoFrame? RenderCore(Source chatBox, int width, int height, string key, int version)
{
_frameKey = key;
_frameVersion = version;
if (Messages.Count == 0) return _cachedFrame = null;
return _cachedFrame = _renderer.Render(
Messages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
}
/// <summary>Renders one ChatBox preview: real messages when connected, mock
/// when not. Writes the resulting bitmap onto the source's
/// <c>VideoImageSource</c>.</summary>
public void UpdatePreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
{
try
{
messages ??= Messages.Count > 0 ? (IReadOnlyList<ChatMessage>)Messages : MockChatMessages;
var width = (int)Math.Max(32, chatBox.Width);
var height = (int)Math.Max(32, chatBox.Height);
var frame = _renderer.Render(
messages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
chatBox.VideoImageSource = bitmap;
}
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
}
private async Task RunMockPreviewAsync(Source chatBox, CancellationToken ct)
{
var n = 0;
while (!ct.IsCancellationRequested)
{
UpdatePreview(chatBox, MockChatMessages.Take(n + 1).ToList());
await Task.Delay(1000, ct);
n++;
if (n == MockChatMessages.Count)
{
await Task.Delay(3000, ct);
n = 0;
}
}
}
public void StartMockPreview(IEnumerable<Scene> scenes)
{
StopMockPreview();
_mockCts = new CancellationTokenSource();
var ct = _mockCts.Token;
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
_ = RunMockPreviewAsync(chatBox, ct);
}
public void StopMockPreview()
{
_mockCts?.Cancel();
_mockCts?.Dispose();
_mockCts = null;
}
/// <summary>Render previews + wire property-change subscriptions for all
/// ChatBox sources across every scene. Called after layout load so existing
/// ChatBox sources show mock content and respond to config changes.</summary>
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
{
foreach (var scene in scenes)
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
{
chatBox.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
UpdatePreview(chatBox);
};
UpdatePreview(chatBox);
}
}
}
+182
View File
@@ -0,0 +1,182 @@
using System.Collections.Generic;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Models;
namespace ytLive.Services.Compositor;
/// <summary>
/// Rasterizes the stream-alert ticker into a transparent BGRA strip that the
/// output compositor overlays along the very top edge of the master frame
/// (TASK 47). The strip is text ("Viewer — Super Chat · $12.34") inside a dark
/// pill with the LlamaCasty accent bar, rendered ONCE per unique text on the UI
/// thread (WPF <c>RenderTargetBitmap</c>, Pbgra32→straight-alpha like
/// <see cref="SocialBarRenderer"/>), then cached. Each <see cref="Render"/> call
/// is pure byte-math on that cached raster producing a scrolling marquee frame,
/// so the frame pump can call it from any thread for every output frame at
/// 30fps without a Dispatcher round-trip.
/// </summary>
public static class AlertTickerRenderer
{
/// <summary>Master-frame width — the ticker spans the full 1920px output.</summary>
public const int Width = 1920;
/// <summary>Strip height (pill + vertical padding).</summary>
public const int Height = 48;
private const int RepeatGap = 120;
private const int CacheLimit = 48;
/// <summary>How long each blink half lasts in <see cref="AlertDisplayMethod.Flash"/>
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
public const double FlashHalfSeconds = 0.5;
private static readonly object Gate = new();
private static readonly Dictionary<string, VideoFrame> StripCache = new();
/// <summary>A frame of <paramref name="text"/> presented per
/// <paramref name="method"/> at <paramref name="elapsedSeconds"/> into the alert,
/// or null when there is nothing to show (no text, or a Flash in its off half).
/// Thread-safe: reads the cached raster and does row copies only.
/// <para><see cref="AlertDisplayMethod.TickerScroll"/> paces itself from
/// <paramref name="readsPerAlert"/> over <paramref name="alertSeconds"/> rather
/// than a fixed pixels-per-second: the old constant (140px/s ≈ a 17s pass) meant
/// a 10s alert showed the text barely once, entering from the right and never
/// crossing. The creator's target is three readable passes per alert.</para>
/// <para><paramref name="width"/>/<paramref name="height"/> default to the
/// full-master strip. They are parameters because the ticker now renders INTO the
/// Stream Alerts box (creator ruling 2026-09-26) and therefore has to be the box's
/// size: a 1920-wide strip clipped to an 800px box would hide the announcement
/// off the right edge for most of its pass, which is the opposite of readable.</para></summary>
public static VideoFrame? Render(string? text, double elapsedSeconds,
AlertDisplayMethod method = AlertDisplayMethod.TickerScroll,
double alertSeconds = 0.0, int readsPerAlert = 3,
int width = Width, int height = Height)
{
if (string.IsNullOrWhiteSpace(text)) return null;
if (width <= 0 || height <= 0) return null;
var strip = GetStrip(text!);
if (strip == null) return null;
if (method == AlertDisplayMethod.Flash
&& elapsedSeconds % (FlashHalfSeconds * 2) >= FlashHalfSeconds)
return null; // off half of the pulse
var x = method == AlertDisplayMethod.TickerScroll
? ScrollX(strip.Width, width, elapsedSeconds, alertSeconds, readsPerAlert)
: (width - strip.Width) / 2; // Solid + Flash: centred, still
var pixels = new byte[width * height * 4];
CopyStrip(pixels, width, height, strip, x);
if (method == AlertDisplayMethod.TickerScroll && x + strip.Width <= 0)
CopyStrip(pixels, width, height, strip, x + width + RepeatGap); // wrapped tail enters
return new VideoFrame(width, height, pixels);
}
/// <summary>Left edge of the pill for a marquee that must complete
/// <paramref name="readsPerAlert"/> passes in <paramref name="alertSeconds"/>:
/// one pass (off the right edge, across, out of sight) takes that long.
/// <para>The run is phase-started half a frame in, so the very first frame of an
/// alert already shows the announcement instead of a blank strip sitting exactly
/// off the right edge.</para></summary>
private static int ScrollX(int stripWidth, int width, double elapsedSeconds,
double alertSeconds, int readsPerAlert)
{
var cycle = width + stripWidth + RepeatGap;
var pass = Math.Max(0.5, alertSeconds) / Math.Max(1, readsPerAlert);
var speed = cycle / pass;
var pos = (width / 2 + (int)(elapsedSeconds * speed)) % cycle;
return width - pos;
}
private static void CopyStrip(byte[] dst, int dstW, int dstH, VideoFrame strip, int dstX)
{
if (dstX >= dstW || dstX + strip.Width <= 0) return;
var x0 = Math.Max(0, dstX);
var x1 = Math.Min(dstW, dstX + strip.Width);
var sx0 = x0 - dstX;
var copyBytes = (x1 - x0) * 4;
var sw = strip.Width;
// The strip is rasterised at its natural 48px; the target box can be shorter,
// and copying strip.Height rows into a shorter dst would run off the end of the
// array. Clip the rows, not the buffer.
var rows = Math.Min(strip.Height, dstH);
for (var y = 0; y < rows; y++)
Buffer.BlockCopy(strip.BgraPixels, (y * sw + sx0) * 4, dst, (y * dstW + x0) * 4, copyBytes);
}
private static VideoFrame? GetStrip(string text)
{
lock (Gate)
{
if (StripCache.TryGetValue(text, out var cached)) return cached;
}
var strip = RenderStrip(text);
if (strip == null) return null;
lock (Gate)
{
if (StripCache.Count >= CacheLimit) StripCache.Clear();
if (!StripCache.TryGetValue(text, out var existing))
StripCache[text] = strip;
return StripCache[text];
}
}
private static VideoFrame? RenderStrip(string text)
{
// WPF rendering is single-threaded; the pump thread resolves to the UI
// thread at most once per unique text (then hits the cache).
var app = Application.Current;
if (app != null && !app.Dispatcher.CheckAccess())
{
VideoFrame? result = null;
app.Dispatcher.Invoke(() => result = RenderStrip(text));
return result;
}
const double pillHeight = 34;
const double accentW = 6;
const double padX = 16;
const double gap = 8;
var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
var ft = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
font, 22, Brushes.White, 1.0);
var pillW = (int)Math.Ceiling(padX + accentW + gap + ft.Width + padX);
var top = (Height - pillHeight) / 2.0;
var textTop = top + (pillHeight - ft.Height) / 2.0;
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
var pill = new Rect(0, top, pillW, pillHeight);
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(0xE6, 0x10, 0x13, 0x24)), null, pill, pillHeight / 2, pillHeight / 2);
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromRgb(0xe9, 0x45, 0x60)), null,
new Rect(padX, top + (pillHeight - 12) / 2.0, accentW, 12), 2, 2);
dc.DrawText(ft, new Point(padX + accentW + gap, textTop));
}
var bitmap = new RenderTargetBitmap(pillW, Height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var pixels = new byte[pillW * Height * 4];
bitmap.CopyPixels(pixels, pillW * 4, 0);
// Pbgra32 is premultiplied — unpremultiply so the compositor's
// straight-alpha source-over doesn't halo the text edges.
for (var i = 0; i < pixels.Length; i += 4)
{
var a = pixels[i + 3];
if (a == 0 || a == 255) continue;
pixels[i] = (byte)(pixels[i] * 255 / a);
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
}
return new VideoFrame(pillW, Height, pixels);
}
}
+421
View File
@@ -0,0 +1,421 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
namespace ytLive.Services.Compositor;
/// <summary>
/// The free-tier branding flash: a "made with LlamaCasty!" credit that is COMPOSITED
/// INTO THE OUTPUT (recording or stream), not merely shown in the preview.
/// <para>2026-09-26. This used to exist only as a WPF <c>BrandFlashLayer</c> in
/// <c>PreviewPane.xaml</c> — a 0.25-opacity centred TextBlock the creator could see and
/// no viewer ever could. The compositor has carried an unused <c>flashFrame</c> slot
/// since then; this type is the producer that finally feeds it. It is wired on the
/// PER-FRAME path only: <c>SceneGraph.GetBakedBase</c> also accepts a <c>flashFrame</c>,
/// and that one is a trap — <c>SceneRegion.Matches</c> compares element ids only, so a
/// flash folded into the baked static base would freeze there and never expire.</para>
/// <para>The credit is deliberately obnoxious (creator directive 2026-09-26): full-opacity
/// neon, a different random position every presentation, and a hard sell every 30–60s.
/// It is gated on LICENCE state, never on a UI toggle — see <see cref="Enabled"/>.</para>
/// </summary>
public sealed class BrandFlashPresenter : IDisposable
{
/// <summary>The credit line, kept identical to the old preview TextBlock so the
/// wording can never drift between the preview and the broadcast.</summary>
public const string Text = "made with LlamaCasty!";
// Presentation envelope (creator-specified 2026-09-26): 500ms fade in, the credit
// held at full opacity for 1000ms, then a 500ms fade out — 2000ms end to end.
public const double FadeInSeconds = 0.5;
public const double HoldSeconds = 1.0;
public const double FadeOutSeconds = 0.5;
public const double PresentationSeconds = FadeInSeconds + HoldSeconds + FadeOutSeconds;
/// <summary>Cadence: <c>rand(0, 30s) + 30s</c> ⇒ a flash every 30–60s, so it never
/// lands on a beat a viewer could tune out.</summary>
public const int IntervalFloorMs = 30_000;
public const int IntervalJitterMs = 30_000;
/// <summary>The first flash lands shortly after go-live (the stream-start
/// impression); the random cadence takes over from there.</summary>
public const double FirstFlashDelaySeconds = 5.0;
/// <summary>Master-space canvas. Global overlays in this codebase are authored at
/// master size and scaled by the compositor.</summary>
public const int MasterWidth = 1920;
public const int MasterHeight = 1080;
/// <summary>Neon recipe taken from the established overlay look (see commit
/// message): a bright near-white CORE plus a feathered multi-radius HALO, with the
/// red and blue channels pushed in OPPOSITE directions — Maxon's "Chromatic Glow"
/// notes the green channel is all but invisible behind white text once R and B are
/// split, which is exactly the look.</summary>
private static readonly Color NeonRed = Color.FromRgb(0xe9, 0x45, 0x60); // brand red, exact
private static readonly Color NeonBlue = Color.FromRgb(0x4d, 0x8b, 0xff); // brand blue #0f3460 lifted for luminance
private static readonly Color CoreColor = Colors.White;
/// <summary>Font size chosen so the credit spans roughly half the master width:
/// big enough to read as advertising, small enough that the random placement has
/// real travel room (a full-width line could only ever sit at x≈0).</summary>
private const double FontSize = 120;
private const double GlowPadding = 34;
private const double TickSeconds = 0.033;
private readonly Random _random;
private readonly DispatcherTimer? _timer;
private readonly object _gate = new();
private bool _enabled = true;
private bool _running;
private double _sinceStart;
private double _nextFlashAt; // absolute time of the next flash, _sinceStart's reference
private double _elapsed; // seconds into the presentation; >= PresentationSeconds means idle
private VideoFrame? _raster; // tight, full-opacity glowing credit (straight alpha)
private byte[]? _buffer; // reused master-space BGRA — hence the Epoch bump below
private int _atX, _atY;
private long _epoch;
public BrandFlashPresenter(Random? random = null)
{
_random = random ?? new Random();
if (Application.Current is { } app)
{
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(TickSeconds) };
_timer.Tick += (_, _) => Advance(TickSeconds);
}
}
/// <summary>The licence gate, driven by <c>MainViewModel.IsPremium</c> (Polar
/// validation) and NOT by a user-facing toggle: once this credit is composited into
/// the broadcast it is the thing standing between a paying customer's stream and an
/// advertisement, so it is re-checked on every read. A premium user mid-presentation
/// loses the flash on the very next tick rather than at the end of the credit.</summary>
public bool Enabled
{
get { lock (_gate) return _enabled; }
set
{
bool restart;
lock (_gate)
{
if (_enabled == value) return;
_enabled = value;
if (!value)
{
// Licence state only ever goes premium → no flash. Cut the current
// presentation short instead of running it out ungated.
_elapsed = PresentationSeconds;
_timer?.Stop();
restart = false;
}
else
{
// …but a DOWNGRADE (key entered mid-session) must bring the cadence
// back. The presenter now runs for the life of the app — Start() is
// called once at startup, not per go-live — so stopping the timer on
// the premium edge without restarting it here would leave the credit
// dead until the process is restarted, even after a valid key.
restart = _running;
}
}
if (restart) _timer?.Start();
}
}
/// <summary>True while a credit is on screen (drives the preview's visibility).</summary>
public bool IsPresenting
{
get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; }
}
/// <summary>Test-only: is the <see cref="DispatcherTimer"/> that drives
/// <see cref="Advance"/> actually running? The premium/downgrade edge is the only
/// thing that ever stops and restarts it now that the presenter is started once for
/// the life of the app, and a synchronous test body cannot observe a real timer
/// tick — so assert the decision directly instead of sleeping through a 30–60s
/// interval. Never call this from product code.</summary>
internal bool IsCadenceTimerEnabled => _timer?.IsEnabled ?? false;
/// <summary>Begin the cadence: first flash <see cref="FirstFlashDelaySeconds"/>
/// after the call, then every <c>rand(30s) + 30s</c>.</summary>
public void Start()
{
EnsureRaster();
lock (_gate)
{
_running = true;
_sinceStart = 0;
_nextFlashAt = FirstFlashDelaySeconds;
}
if (_enabled) _timer?.Start();
}
public void Stop()
{
lock (_gate)
{
_running = false;
_elapsed = PresentationSeconds;
}
_timer?.Stop();
}
/// <summary>Advance the presentation clock. Also the deterministic test seam: tests
/// drive this directly and never call <see cref="Start"/>, so no timer is running
/// and the clock only moves when the test moves it (mirrors <c>AlertOverlayLayer.Advance</c>).</summary>
public void Advance(double deltaSeconds)
{
// Rasterise BEFORE taking the lock. WPF text formatting is UI-thread affine, so
// this can hop to the dispatcher — and a blocking hop while holding _gate would
// deadlock against a timer tick that wants the same lock.
EnsureRaster();
lock (_gate)
{
if (!_enabled) return;
if (_running)
{
_sinceStart += deltaSeconds;
if (_sinceStart >= _nextFlashAt)
{
// The interval runs FROM THIS FLASH, not from Start(). An absolute
// deadline here made the gaps shrink every cycle (5s, then whatever
// was left of the previous interval) and collapse toward zero.
_nextFlashAt = _sinceStart + NextIntervalSeconds();
BeginPresentation();
}
}
if (_elapsed < PresentationSeconds)
{
_elapsed += deltaSeconds;
if (_elapsed >= PresentationSeconds) _elapsed = PresentationSeconds;
}
}
Notify();
}
/// <summary>Invoked on the UI thread at the end of every tick with the frame that
/// tick produced (null when nothing is due) — the preview's repaint hook, so the
/// preview and the broadcast are driven by ONE clock.</summary>
public Action<VideoFrame?>? OnFrame { get; set; }
private void Notify()
{
var cb = OnFrame;
if (cb == null) return;
// Read OUTSIDE the state lock: the callback is arbitrary code and must never
// run while _gate is held.
var frame = Frame;
if (frame == null) { cb(null); return; }
if (Application.Current is { } app && !app.Dispatcher.CheckAccess())
{
// Off the UI thread (a test driving the clock). The frame's buffer is
// recycled, so hand the deferred copy its own pixels.
var copy = new byte[frame.BgraPixels.Length];
Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length);
app.Dispatcher.BeginInvoke(() => cb(new VideoFrame(frame.Width, frame.Height, copy)));
}
else
{
cb(frame);
}
}
/// <summary>Force a presentation now — the "show me the flash" path and the test seam.</summary>
public void Show()
{
EnsureRaster();
lock (_gate)
{
if (!_enabled) return;
BeginPresentation();
}
}
private double NextIntervalSeconds()
=> (IntervalFloorMs + _random.Next(IntervalJitterMs)) / 1000.0;
private void BeginPresentation()
{
if (_raster == null) return;
_buffer ??= new byte[MasterWidth * MasterHeight * 4];
// A new presentation can land anywhere, so the previous credit's pixels must go
// before this one is written or the old spot ghosts through the fade. One 8MB
// clear per presentation (not per tick) keeps that cheap.
Array.Clear(_buffer);
var spot = ComputePlacement(_raster.Width, _raster.Height, MasterWidth, MasterHeight, _random);
_atX = (int)spot.X;
_atY = (int)spot.Y;
_elapsed = 0;
}
/// <summary>
/// The credit for the output compositor and the preview: a master-space frame holding
/// the glowing text at this presentation's random spot, alpha-scaled to the current
/// point on the fade envelope. Null when idle, or unlicensed.
/// <para><b>The frame's pixel buffer is recycled across ticks</b> (only the text rect
/// is rewritten) and its <see cref="VideoFrame.Epoch"/> is bumped every read, so the
/// compositor's buffer-identity paste cache can never false-hit a stale credit.
/// Consumers blit synchronously — copy the pixels if you need to keep them.</para></summary>
public VideoFrame? Frame
{
get
{
lock (_gate)
{
if (!_enabled || _elapsed >= PresentationSeconds || _raster == null || _buffer == null)
return null;
var opacity = OpacityAt(_elapsed);
if (opacity <= 0) return null;
Paint(_buffer, _raster, _atX, _atY, opacity);
return new VideoFrame(MasterWidth, MasterHeight, _buffer) { Epoch = ++_epoch };
}
}
}
/// <summary>Full → hold → empty. Public and static so the preview and the tests
/// agree with the renderer instead of re-deriving the curve.</summary>
public static double OpacityAt(double elapsedSeconds)
{
if (elapsedSeconds < 0 || elapsedSeconds >= PresentationSeconds) return 0;
if (elapsedSeconds < FadeInSeconds) return elapsedSeconds / FadeInSeconds;
if (elapsedSeconds < FadeInSeconds + HoldSeconds) return 1.0;
return 1.0 - ((elapsedSeconds - (FadeInSeconds + HoldSeconds)) / FadeOutSeconds);
}
/// <summary>
/// A random top-left offset that keeps the whole credit ON the canvas. The text is a
/// single pre-measured line, so it can never wrap — this is the "how far can the
/// left margin travel" limit the creator described: <c>0 … (canvas - creditWidth)</c>
/// horizontally, likewise vertically. Pure + static, so the rule is directly testable.
/// </summary>
public static Point ComputePlacement(int creditWidth, int creditHeight,
int canvasWidth, int canvasHeight, Random random)
{
var maxX = Math.Max(0, canvasWidth - creditWidth);
var maxY = Math.Max(0, canvasHeight - creditHeight);
return new Point(random.Next(0, maxX + 1), random.Next(0, maxY + 1));
}
/// <summary>Rewrite the credit rect into the master buffer with alpha scaled to
/// <paramref name="opacity"/>. The rect is fully overwritten each tick and the spot
/// is fixed for the whole presentation, so nothing stale accumulates and no clear
/// pass is needed here.</summary>
private static void Paint(byte[] buffer, VideoFrame raster, int atX, int atY, double opacity)
{
for (var y = 0; y < raster.Height; y++)
{
var dst = ((atY + y) * MasterWidth + atX) * 4;
var src = y * raster.Stride;
for (var x = 0; x < raster.Width; x++)
{
var a = raster.BgraPixels[src + x * 4 + 3];
if (a == 0) continue;
buffer[dst + x * 4] = raster.BgraPixels[src + x * 4];
buffer[dst + x * 4 + 1] = raster.BgraPixels[src + x * 4 + 1];
buffer[dst + x * 4 + 2] = raster.BgraPixels[src + x * 4 + 2];
buffer[dst + x * 4 + 3] = (byte)Math.Round(a * opacity);
}
}
}
/// <summary>Build the credit raster once, on the UI thread, and publish it under
/// the lock. Called from every clock entry point, so it is a no-op after the first.</summary>
private void EnsureRaster()
{
lock (_gate) { if (_raster != null) return; }
var raster = RenderNeonText(Text);
lock (_gate) { if (raster != null) { _raster = raster; } }
}
// ── rasterisation ──────────────────────────────────────────────────────────
/// <summary>
/// Rasterize the credit once, tight to the glyphs plus glow, on the UI thread (WPF
/// <c>RenderTargetBitmap</c>, Pbgra32→straight alpha exactly like
/// <see cref="AlertTickerRenderer"/>) and cached — this is per-text work, not
/// per-frame, so the pump can afford to run every tick at 30fps.
/// <para>The neon is a bright core plus a feathered halo: the glyphs redrawn at
/// shrinking radii with rising alpha, the two colours offset in opposite directions,
/// which is what makes it read as a lit tube rather than a drop shadow.</para></summary>
private static VideoFrame? RenderNeonText(string text)
{
if (Application.Current is { } app && !app.Dispatcher.CheckAccess())
{
VideoFrame? result = null;
app.Dispatcher.Invoke(() => result = RenderNeonText(text));
return result;
}
var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal,
FontWeights.Bold, FontStretches.Normal);
var probe = new FormattedText(text, CultureInfo.InvariantCulture,
FlowDirection.LeftToRight, font, FontSize, Brushes.White, 1.0);
var w = (int)Math.Ceiling(probe.Width) + (int)(GlowPadding * 2);
var h = (int)Math.Ceiling(probe.Height) + (int)(GlowPadding * 2);
if (w <= 0 || h <= 0) return null;
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
// Three halo rings per colour. Wide+dim first, then progressively tighter and
// brighter, so the falloff is feathered instead of a hard-edged blob.
foreach (var (radius, alpha) in new[] { (30.0, 0.16), (20.0, 0.26), (11.0, 0.40) })
{
DrawHalo(dc, text, font, radius, alpha, NeonRed, +1);
DrawHalo(dc, text, font, radius, alpha, NeonBlue, -1);
}
// A tight white bloom hugging the glyphs sells the brightness of the tube.
DrawHalo(dc, text, font, 4.0, 0.55, CoreColor, +1);
// The core itself, sharp and opaque, drawn last.
dc.DrawText(new FormattedText(text, CultureInfo.InvariantCulture,
FlowDirection.LeftToRight, font, FontSize, new SolidColorBrush(CoreColor), 1.0),
new Point(GlowPadding, GlowPadding));
}
var bitmap = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var pixels = new byte[w * h * 4];
bitmap.CopyPixels(pixels, w * 4, 0);
// Pbgra32 is premultiplied — unpremultiply, or straight-alpha source-over in the
// compositor haloes the glyph edges.
for (var i = 0; i < pixels.Length; i += 4)
{
var a = pixels[i + 3];
if (a == 0 || a == 255) continue;
pixels[i] = (byte)(pixels[i] * 255 / a);
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
}
return new VideoFrame(w, h, pixels);
}
private static void DrawHalo(DrawingContext dc, string text, Typeface font,
double radius, double alpha, Color color, int direction)
{
var brush = new SolidColorBrush(color);
const int spokes = 12;
for (var i = 0; i < spokes; i++)
{
var angle = 2 * Math.PI * i / spokes;
brush.Opacity = alpha;
var ft = new FormattedText(text, CultureInfo.InvariantCulture,
FlowDirection.LeftToRight, font, FontSize, brush, 1.0);
dc.DrawText(ft, new Point(
GlowPadding + Math.Cos(angle) * radius * direction,
GlowPadding + Math.Sin(angle) * radius * direction));
}
}
public void Dispose() => _timer?.Stop();
}
+573 -43
View File
@@ -1,3 +1,4 @@
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services.Compositor;
@@ -14,12 +15,32 @@ namespace ytLive.Services.Compositor;
/// </summary>
public sealed class SceneCompositor
{
// Paste cache (take-8 fix, 2026-09-04): the Live scene re-sampled ~680k bilinear
// pixels EVERY tick even though only the camera actually changes at that rate —
// chat/web/image layers are identical frame after frame. Same shape as OBS (the
// source surface is cached, the compositor blits): a scaled/round-clipped/mirrored
// layer rasterizes once and is pasted (row-copy/blend) on every later tick.
// Keyed by the SOURCE FRAME IDENTITY (array + Epoch + CropBounds) — producers hand
// out fresh immutable byte[], or recycled-ring arrays whose monotonic Epoch distinguishes
// each generation (screen capture, take-11 fix). CropBounds must be in the key because
// the same frame (same Pixels/Epoch/Width/Height) can have different CropBounds as
// the element is resized — a stale cached raster with wrong crop pixels would corrupt
// the output. Bounds-checked eviction clears wholesale.
private sealed record PasteKey(byte[] Pixels, long Epoch, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror, (int X, int Y, int W, int H)? CropBounds);
private static readonly Dictionary<PasteKey, VideoFrame> _pasteCache = new();
private static readonly Dictionary<SceneElement, VideoFrame> _elementRasters = new(ReferenceEqualityComparer.Instance);
private static readonly object _pasteGate = new();
private const int MaxPasteEntries = 256;
/// <summary>
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
/// front): live background (the scene's <c>IsBackground</c> source) → background image →
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
/// branding flash → social bar (a global overlay; spans the master width at
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
/// <paramref name="socialBarTop"/>) → stream-alert ticker (drawn INSIDE the Stream
/// Alerts box, at the origin the frame carries on <see cref="VideoFrame.Placement"/>;
/// creator ruling 2026-09-26 — it used to be a global bar pinned to the very top).
/// Transparent regions read opaque black.
/// </summary>
public VideoFrame Render(
Scene scene,
@@ -27,7 +48,11 @@ public sealed class SceneCompositor
VideoFrame? flashFrame,
CompositorOptions options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0)
int socialBarTop = 0,
VideoFrame? staticBase = null,
int split = -1,
byte[]? scratch = null,
VideoFrame? tickerFrame = null)
{
if (scene == null) throw new ArgumentNullException(nameof(scene));
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
@@ -39,14 +64,36 @@ public sealed class SceneCompositor
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
var buffer = new byte[cropW * cropH * 4];
for (var i = 3; i < buffer.Length; i += 4)
buffer[i] = 255; // opaque black base — video frames are never transparent
var buffer = scratch != null && scratch.Length == cropW * cropH * 4
? scratch
: new byte[cropW * cropH * 4];
// staticBase != null: the caller pre-baked all static layers below `split`.
// We copy it (never mutate the cached base) and composite elements[split..] on top.
if (staticBase != null)
{
if (staticBase.Width != cropW || staticBase.Height != cropH)
throw new ArgumentException("The static base must match the source rect.", nameof(staticBase));
Buffer.BlockCopy(staticBase.BgraPixels, 0, buffer, 0, buffer.Length);
}
else
{
if (split < 0) split = scene.Elements.Count;
var elements = scene.Elements;
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
// A master-sized live backdrop paints every pixel of the base — the
// opaque-black pre-fill (2M byte writes) would be dead work. Capture
// frames are opaque BGRA by contract (the WinRT path ignores alpha),
// so the 1:1 row walk covers the full rect.
var covered = liveCaptureFrame != null
&& liveCaptureFrame.Width == cropW && liveCaptureFrame.Height == cropH;
if (!covered)
for (var i = 3; i < buffer.Length; i += 4)
buffer[i] = 255; // opaque black base
if (liveCaptureFrame != null)
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
@@ -63,22 +110,29 @@ public sealed class SceneCompositor
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
continue; // background/background are their own layers; Text isn't shipped
continue;
}
var fullFrame = frameFor(element);
if (fullFrame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, fullFrame);
}
}
// Composite the above-split layers (dynamic + any static above them) per frame.
for (var i = Math.Max(0, split); i < scene.Elements.Count; i++)
{
var element = scene.Elements[i];
if (!element.IsVisible) continue;
switch (element)
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
continue;
}
var frame = frameFor(element);
if (frame == null) continue;
var ex = (float)(element.X - options.SourceRectX);
var ey = (float)(element.Y - options.SourceRectY);
var ew = (float)element.Width;
var eh = (float)element.Height;
var isRound = element.ClipShape == ClipShape.Round;
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame,
(float)element.Opacity, isRound, element.IsMirrored);
if (element.HasBorder)
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
}
if (flashFrame != null)
@@ -87,10 +141,242 @@ public sealed class SceneCompositor
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
return StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}
/// <summary>
/// Render the static base of a scene: the opaque black layer, the live-capture
/// background, the background image, and every <paramref name="split"/> z-layers
/// below the split point (all of which are static by construction — see
/// <see cref="SceneElement.Kind"/>). The result is a source-rect sized frame that
/// <see cref="Render"/> can start from and composite the dynamic/above-split layers
/// onto each tick. Returns null when there is no static layer at all.
/// </summary>
public static VideoFrame? BakeStaticBase(
Scene scene,
int split,
Func<SceneElement, VideoFrame?> frameFor,
Func<CompositorOptions> options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? flashFrame = null)
{
if (split <= 0) return null;
var opts = options();
if (opts.SourceRectWidth <= 0 || opts.SourceRectHeight <= 0) return null;
var cropW = opts.SourceRectWidth;
var cropH = opts.SourceRectHeight;
var buffer = new byte[cropW * cropH * 4];
for (var i = 3; i < buffer.Length; i += 4)
buffer[i] = 255;
var elements = scene.Elements;
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (liveCapture != null)
{
var liveCaptureFrame = liveCapture.IsVisible ? frameFor(liveCapture) : null;
if (liveCaptureFrame != null && split > elements.IndexOf(liveCapture))
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
}
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (background != null)
{
var backgroundFrame = frameFor(background);
if (backgroundFrame != null && split > elements.IndexOf(background))
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
}
for (var i = 0; i < split; i++)
{
var element = scene.Elements[i];
if (!element.IsVisible) continue;
switch (element)
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
continue;
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, opts, element, frame);
}
var baked = new VideoFrame(cropW, cropH, buffer);
if (flashFrame != null)
baked = Overlay(baked, opts, flashFrame, 0, 0);
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
baked = Overlay(baked, opts, socialBarFrame, 0, socialBarTop);
return baked;
}
/// <summary>The compositor's dynamic/above-split path: start from an existing frame
/// buffer and composite the z-layers from <paramref name="split"/> onward on top.
/// When <paramref name="baseFrame"/> is null the caller must fall back to full render.
/// The social bar never reaches here (it IS baked into the static base) but the alert
/// ticker must: it is dynamic by construction — only ever present while an alert plays —
/// so it overlays LAST, pinned to the very top (TASK 47).</summary>
public static VideoFrame CompositeLayers(
VideoFrame baseFrame,
Scene scene,
int split,
Func<SceneElement, VideoFrame?> frameFor,
CompositorOptions options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? flashFrame = null,
byte[]? scratch = null,
VideoFrame? tickerFrame = null)
{
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
// We must not mutate the cached base: copy it into a fresh (or pooled) buffer.
var buffer = scratch != null && scratch.Length == baseFrame.BgraPixels.Length
? scratch
: new byte[baseFrame.BgraPixels.Length];
Buffer.BlockCopy(baseFrame.BgraPixels, 0, buffer, 0, baseFrame.BgraPixels.Length);
for (var i = split; i < scene.Elements.Count; i++)
{
var element = scene.Elements[i];
if (!element.IsVisible) continue;
switch (element)
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
continue;
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
}
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}
private static void BlitBakedElement(
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame)
{
var ex = (float)(element.X - options.SourceRectX);
var ey = (float)(element.Y - options.SourceRectY);
var ew = (float)element.Width;
var eh = (float)element.Height;
var isRound = element.ClipShape == ClipShape.Round;
// All rect-placed elements route through the paste cache (take-9 lesson): the
// IsOpaque bypass kept re-sampling the webcam (~156k px) EVERY tick even when
// the device frame had not changed; an opaque source rasterizes to an opaque
// raster whose paste is a per-pixel copy branch, so hits beat the sampler and
// misses cost exactly what the bypass cost.
BlitCachedLayer(buffer, cropW, cropH, ex, ey, ew, eh, frame, element, isRound);
if (element.HasBorder)
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
}
/// <summary>Non-opaque layer blit through the paste cache: the element-space raster
/// (scaled to the element rect, round-clipped, mirrored) depends only on the source
/// frame + rect + shape/mirror — NOT on position or opacity. Static content (chat,
/// web overlays, images, the media frame between updates) is sampled ONCE and pasted
/// at integer position with per-tixel blend; only layers whose source frame actually
/// changed (webcam at device fps) pay resampling.</summary>
private static void BlitCachedLayer(
byte[] buffer, int cropW, int cropH,
float ex, float ey, float ew, float eh,
VideoFrame frame, SceneElement element, bool isRound)
{
if (ew <= 0 || eh <= 0) return;
var dw = (int)System.Math.Ceiling(ew);
var dh = (int)System.Math.Ceiling(eh);
if (dw <= 0 || dh <= 0) return;
var mirror = element.IsMirrored;
PasteKey? key = null;
VideoFrame? raster = null;
lock (_pasteGate)
{
key = new PasteKey(frame.BgraPixels, frame.Epoch, frame.Width, frame.Height, dw, dh, isRound, mirror, frame.CropBounds);
_pasteCache.TryGetValue(key, out raster);
}
if (raster == null)
{
// Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255
// alpha only where it actually draws, so untouched margins (outside the
// round clip, past the UniformToFill edges) stay alpha 0 and the paste
// reveals the master beneath instead of black. The ARRAY is reused per
// element (see _elementRasters note) — new source generations re-raster
// in place, so the steady state allocates nothing.
lock (_pasteGate)
{
if (_elementRasters.TryGetValue(element, out var existing)
&& existing.Width == dw && existing.Height == dh)
raster = existing;
}
if (raster == null) raster = new VideoFrame(dw, dh, new byte[dw * dh * 4]);
Array.Clear(raster.BgraPixels);
BlitContentRaw(raster.BgraPixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror, transparentDst: true);
lock (_pasteGate)
{
// Only the SOURCE frame's identity is a real key owner; drop any older
// entry that shared this element's reused raster so a stale key can never
// paste a mid-update array.
if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear();
_pasteCache[key!] = raster;
_elementRasters[element] = raster;
}
}
// Paste at integer position; opacity blends. The raster is never flagged
// IsOpaque — round clips/margins carry alpha 0 — so the row blend always runs.
var op = (int)(System.Math.Clamp(element.Opacity, 0f, 1f) * 256);
if (op <= 0) return;
var px = (int)System.Math.Floor(ex);
var py = (int)System.Math.Floor(ey);
var x0 = System.Math.Max(0, px);
var y0 = System.Math.Max(0, py);
var x1 = System.Math.Min(cropW, px + dw);
var y1 = System.Math.Min(cropH, py + dh);
if (x0 >= x1 || y0 >= y1) return;
var d = buffer.AsSpan();
var s = raster.BgraPixels;
for (var y = y0; y < y1; y++)
{
var si = ((y - py) * dw + (x0 - px)) * 4;
var len = x1 - x0;
if (op == 256)
BlendRowOpaque(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len);
else
BlendRowWeighted(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len, op);
}
}
/// <summary>Copy <paramref name="frame"/> and blit a global overlay onto the copy
/// at (sx0, sy0) in crop space. internal (not private) because the frame pump's
/// fully-static path needs it: that path stretches the cached bake straight to the
/// output, so any per-frame overlay — social bar, alert ticker, brand flash — has to
/// be composited here first. Copying is the whole point: the bake is cached, and a
/// flash written straight into it would freeze there and never expire.</summary>
internal static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0)
{
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
var buffer = new byte[frame.BgraPixels.Length];
Buffer.BlockCopy(frame.BgraPixels, 0, buffer, 0, buffer.Length);
BlitOverlay(buffer, cropW, cropH, options, overlay, sx0, sy0);
return new VideoFrame(cropW, cropH, buffer);
}
/// <summary>
/// Render a cross-fade blend between two scenes. Used by <see cref="TransitionService"/>
/// during fade/move transitions: the caller renders each scene individually, then blends
@@ -131,26 +417,109 @@ public sealed class SceneCompositor
{
if (ew <= 0 || eh <= 0) return;
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
// 1:1 aligned fast path (the full-frame backdrop, master-sized overlays):
// bilinear sampling at scale 1 with integer offsets is the identity, so the
// blit is a row walk, not 2M float samples + rounds. Research-first (2026-09-03,
// take-3 fix; libyuv's row-based BLEND_NONE pattern —
// https://chromium.googlesource.com/libyuv/libyuv/): the 258ms/frame render
// that time-lapsed take 3 lived in the general loop below.
if (!isRound && !isMirror && scale == 1f && ox == 0f && oy == 0f
&& ex == MathF.Floor(ex) && ey == MathF.Floor(ey))
{
Blit1To1Rows(dst, dstW, dstH, (int)ex, (int)ey, src, opacity);
return;
}
BlitContentRaw(dst, dstW, dstH, ex, ey, ew, eh, src, opacity, isRound, isMirror);
}
/// <summary>The general scaled/clipped/mirrored sampler (integer fixed-point
/// bilinear + source-over). Also rasterizes paste-cache entries in ELEMENT space
/// onto a TRANSPARENT base (alpha 0) so pasting over the master keeps the round
/// clip and transparency of the untouched margins.</summary>
private static void BlitContentRaw(
byte[] dst, int dstW, int dstH,
float ex, float ey, float ew, float eh,
VideoFrame src, float opacity, bool isRound, bool isMirror,
bool transparentDst = false)
{
if (ew <= 0 || eh <= 0) return;
float scale, ox, oy, invScale;
float drawnW, drawnH;
// No CropBounds means "the whole source canvas is the crop region" — cbW/cbH
// MUST default to the full source dims, not 0. A zero cbW/cbH makes every
// sxCrop/syCrop below evaluate to 0 regardless of position, collapsing the
// entire sampled rect to source pixel (0,0) (the webcam gray-block regression,
// 2026-09-12: introduced by the CropBounds Fill-style scaling work the same
// day — CropBounds-bearing sources, i.e. the web widget, were never affected).
var cbX = 0;
var cbY = 0;
var cbW = src.Width;
var cbH = src.Height;
if (src.CropBounds is { } cb)
{
cbX = cb.X;
cbY = cb.Y;
cbW = cb.W;
cbH = cb.H;
scale = Math.Max(ew / cbW, eh / cbH);
invScale = 1f / scale;
drawnW = cbW * scale;
drawnH = cbH * scale;
ox = (ew - drawnW) / 2f;
oy = (eh - drawnH) / 2f;
}
else
{
var (uScale, uOx, uOy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
scale = uScale;
invScale = 1f / uScale;
ox = uOx;
oy = uOy;
drawnW = src.Width * uScale;
drawnH = src.Height * uScale;
}
var radius = Math.Min(ew, eh) / 2f;
var cx = ew / 2f;
var cy = eh / 2f;
var x0 = Math.Max(0, (int)Math.Floor(ex));
var y0 = Math.Max(0, (int)Math.Floor(ey));
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew));
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh));
if (x0 > x1 || y0 > y1) return;
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
var drawnW = src.Width * scale;
var drawnH = src.Height * scale;
var radius = Math.Min(ew, eh) / 2f;
var cx = ew / 2f;
var cy = eh / 2f;
var op = (int)(Math.Clamp(opacity, 0f, 1f) * 256);
if (op <= 0) return;
var s = src.BgraPixels;
var srcStride = src.Width; // canvas stride in pixels (not bytes)
for (var y = y0; y <= y1; y++)
{
for (var x = x0; x <= x1; x++)
{
var px = x - ex; // element space
var py = y - ey;
if (px < ox || px > ox + drawnW || py < oy || py > oy + drawnH) continue;
if (py < oy || py > oy + drawnH) continue;
// syNorm: normalized [0,1] position within the drawn content (vertically)
var syNorm = Math.Clamp((py - oy) / drawnH, 0f, 1f);
// syCrop: pixel position within the crop region
var syCrop = syNorm * cbH;
// syCanvas: canvas pixel row (for buffer indexing)
var syCanvas = Math.Clamp(cbY + syCrop, 0f, src.Height - 1f);
var syi = (int)syCanvas;
var sy1i = Math.Min(syi + 1, src.Height - 1);
var fy8 = (int)((syCanvas - syi) * 256);
var fyInv = 256 - fy8;
var di = (y * dstW + x0) * 4;
for (var x = x0; x <= x1; x++, di += 4)
{
var px = x - ex;
if (px < ox || px > ox + drawnW) continue;
if (isRound)
{
var dx = px - cx;
@@ -158,12 +527,172 @@ public sealed class SceneCompositor
if (dx * dx + dy * dy > radius * radius) continue;
}
var sx = (px - ox) / scale;
var sy = (py - oy) / scale;
if (isMirror) sx = src.Width - 1 - sx;
var sample = StretchMath.SampleBgra(src.BgraPixels, src.Width, src.Height, sx, sy);
BlendPixel(dst, (y * dstW + x) * 4, sample, opacity);
// sxNorm: normalized [0,1] position within the drawn content (horizontally)
var sxNorm = Math.Clamp((px - ox) / drawnW, 0f, 1f);
// sxCrop: pixel position within the crop region
var sxCrop = sxNorm * cbW;
// sxCanvas: canvas pixel column (for buffer indexing)
var sxCanvas = Math.Clamp(cbX + sxCrop, 0f, src.Width - 1f);
if (isMirror) sxCanvas = src.Width - 1 - sxCanvas;
var sxi = (int)sxCanvas;
var sx1i = Math.Min(sxi + 1, src.Width - 1);
var fx8 = (int)((sxCanvas - sxi) * 256);
var fxInv = 256 - fx8;
// ALL pixel indexing uses srcStride (canvas stride), NOT crop width
var p00 = (syi * srcStride + sxi) * 4;
var p10 = (syi * srcStride + sx1i) * 4;
var p01 = (sy1i * srcStride + sxi) * 4;
var p11 = (sy1i * srcStride + sx1i) * 4;
var sb = Bilinear(s[p00], s[p10], s[p01], s[p11], fx8, fxInv, fy8, fyInv);
var sg = Bilinear(s[p00 + 1], s[p10 + 1], s[p01 + 1], s[p11 + 1], fx8, fxInv, fy8, fyInv);
var sr = Bilinear(s[p00 + 2], s[p10 + 2], s[p01 + 2], s[p11 + 2], fx8, fxInv, fy8, fyInv);
var saFull = Bilinear(s[p00 + 3], s[p10 + 3], s[p01 + 3], s[p11 + 3], fx8, fxInv, fy8, fyInv);
var sa = saFull * op >> 8;
if (sa >= 255)
{
dst[di] = (byte)sb;
dst[di + 1] = (byte)sg;
dst[di + 2] = (byte)sr;
dst[di + 3] = 255;
}
else if (sa > 0)
{
if (transparentDst)
{
// Element-raster pass on a TRANSPARENT base (the paste cache):
// keep straight color + straight alpha so the paste rows can
// source-over onto the opaque master. Premultiplying here (the
// opaque-dst blend) stamped alpha 255 onto already-alpha-scaled
// color — the paste over-wrote the scene with darkened ink, the
// web widget's "black box" in recordings.
dst[di] = (byte)sb;
dst[di + 1] = (byte)sg;
dst[di + 2] = (byte)sr;
dst[di + 3] = (byte)sa;
}
else
{
var inv = 255 - sa;
dst[di] = (byte)((sb * sa + dst[di] * inv + 127) / 255);
dst[di + 1] = (byte)((sg * sa + dst[di + 1] * inv + 127) / 255);
dst[di + 2] = (byte)((sr * sa + dst[di + 2] * inv + 127) / 255);
dst[di + 3] = 255;
}
}
}
}
}
/// <summary>Two-stage bilinear in 8.8 fixed point; result within ±1 of the
/// float StretchMath.SampleBgra (well inside every pixel test's ±2 tolerance).
/// The first stage must NOT shift back to 8 bits — shifting twice collapsed
/// every solid-255 sample to 1 (green overwrites silently degraded to no-ops;
/// caught by the SceneCompositor/SceneGraph pixel probes 2026-09-04).</summary>
private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv)
{
// Both stages in one 16.8 scale: top/bot ≤ 65280, ×256 + round ≤ 16.8M — int-safe.
var top = tl * fxInv + tr * fx8;
var bot = bl * fxInv + br * fx8;
return (top * fyInv + bot * fy8 + 32768) >> 16;
}
/// <summary>Row walk for a 1:1 aligned blit at integer offset (ex, ey): per-pixel
/// branch on source alpha — opaque rows copy straight, transparent pixels are
/// skipped, partial alpha blends in integer fixed-point (libyuv's pattern). The
/// master buffer's destination alpha is always opaque (black base paints 255 and
/// every blend writes 255), so dst alpha is never read — matching BlendPixel's
/// source-over result for opaque dst within ±1.</summary>
private static void Blit1To1Rows(byte[] dst, int dstW, int dstH, int ex, int ey, VideoFrame src, float opacity)
{
var op = (int)(Math.Clamp(opacity, 0f, 1f) * 256);
if (op <= 0) return;
var x0 = Math.Max(0, ex);
var y0 = Math.Max(0, ey);
var x1 = Math.Min(dstW, ex + src.Width);
var y1 = Math.Min(dstH, ey + src.Height);
if (x0 >= x1 || y0 >= y1) return;
// The take-4 fix: a full-canvas opaque capture (the Live backdrop) is ONE
// memcpy, not 2.07M managed loop iterations — the row walk below still cost
// ~58ms/frame dominated by bounds-check arithmetic and GC of the per-pixel
// loop's slowness, i.e. the same starvation shape as take 3, one floor lower.
if (op == 256 && src.IsOpaque && x0 == 0 && y0 == 0 && x1 == dstW && y1 == dstH)
{
Buffer.BlockCopy(src.BgraPixels, 0, dst, 0, dstW * dstH * 4);
return;
}
var d = dst.AsSpan();
var s = src.BgraPixels.AsSpan();
var rowLen = x1 - x0;
if (op == 256)
{
for (var y = y0; y < y1; y++)
{
var si = ((y - ey) * src.Width + (x0 - ex)) * 4;
BlendRowOpaque(d, (y * dstW + x0) * 4, s, si, rowLen);
}
}
else
{
for (var y = y0; y < y1; y++)
{
var si = ((y - ey) * src.Width + (x0 - ex)) * 4;
BlendRowWeighted(d, (y * dstW + x0) * 4, s, si, rowLen, op);
}
}
}
private static void BlendRowOpaque(Span<byte> dst, int di, ReadOnlySpan<byte> src, int si, int count)
{
for (var n = 0; n < count; n++)
{
var a = src[si + 3];
if (a == 255)
{
dst[di] = src[si];
dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2];
dst[di + 3] = 255;
}
else if (a != 0)
{
var inv = 255 - a;
dst[di] = (byte)((src[si] * a + dst[di] * inv + 127) / 255);
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inv + 127) / 255);
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inv + 127) / 255);
dst[di + 3] = 255;
}
si += 4;
di += 4;
}
}
private static void BlendRowWeighted(Span<byte> dst, int di, ReadOnlySpan<byte> src, int si, int count, int op)
{
for (var n = 0; n < count; n++)
{
var a = src[si + 3] * op >> 8;
if (a >= 255)
{
dst[di] = src[si];
dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2];
dst[di + 3] = 255;
}
else if (a > 0)
{
var inv = 255 - a;
dst[di] = (byte)((src[si] * a + dst[di] * inv + 127) / 255);
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inv + 127) / 255);
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inv + 127) / 255);
dst[di + 3] = 255;
}
si += 4;
di += 4;
}
}
@@ -201,21 +730,22 @@ public sealed class SceneCompositor
}
/// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
/// active source rect. The overlay is positioned in master space by (sx0, sy0).</summary>
/// active source rect. The overlay is positioned in master space by (sx0, sy0).
/// Iterates only the intersection rect — the old loop walked every destination
/// pixel to blit a ~64px strip (2M iterations of bounds checks per overlay).</summary>
private static void BlitOverlay(
byte[] dst, int dstW, int dstH, CompositorOptions options,
VideoFrame overlay, int sx0, int sy0)
{
for (var y = 0; y < dstH; y++)
var dx0 = Math.Max(0, sx0 - options.SourceRectX);
var dy0 = Math.Max(0, sy0 - options.SourceRectY);
var dx1 = Math.Min(dstW, sx0 - options.SourceRectX + overlay.Width);
var dy1 = Math.Min(dstH, sy0 - options.SourceRectY + overlay.Height);
for (var y = dy0; y < dy1; y++)
{
for (var x = 0; x < dstW; x++)
{
var sx = x + options.SourceRectX - sx0;
var sy = y + options.SourceRectY - sy0;
if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
}
var si = ((y + options.SourceRectY - sy0) * overlay.Width
+ dx0 + options.SourceRectX - sx0) * 4;
BlendRowOpaque(dst, (y * dstW + dx0) * 4, overlay.BgraPixels, si, dx1 - dx0);
}
}
+5
View File
@@ -16,6 +16,10 @@ public static class StaticPixelCache
public static VideoFrame? Get(string assetId)
{
if (string.IsNullOrWhiteSpace(assetId)) return null;
// Locked: the frame pump decodes from its own thread now (take-8 off-UI fix),
// so the dictionary genuinely races with UI-path callers (snapshots, heals).
lock (Cache)
{
if (Cache.TryGetValue(assetId, out var frame)) return frame;
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
@@ -25,6 +29,7 @@ public static class StaticPixelCache
if (decoded != null) Cache[assetId] = decoded;
return decoded;
}
}
public static VideoFrame? Decode(byte[] bytes)
{
+3 -2
View File
@@ -2,7 +2,9 @@ namespace ytLive.Services.Encoder;
/// <summary>
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
/// raw BGRA frames via stdin (paced <c>-re</c>), real audio via the named pipe
/// raw BGRA frames via stdin (paced by the frame pump — the pump holds a strict
/// CFR cadence, so <c>-re</c> would only add a second, fighting pacer), real
/// audio via the named pipe
/// (TASK 9 — the mixer streams f32le at 48 kHz stereo into <c>\\.\pipe\&lt;name&gt;</c>;
/// WASAPI loopback + the mic ride the pipe instead of the old anullsrc silence),
/// H.264 + AAC encoding, FLV muxing to the ingestion URL. Pure — the encoder
@@ -16,7 +18,6 @@ public static class FfmpegArgs
{
"-hide_banner", "-loglevel", "info",
"-stats", "-stats_period", "0.5",
"-re",
"-f", "rawvideo", "-pix_fmt", "bgra",
"-video_size", $"{options.Width}x{options.Height}",
"-framerate", options.Fps.ToString(),
+141 -19
View File
@@ -1,4 +1,6 @@
using System.Buffers;
using System.Diagnostics;
using System.Threading.Channels;
using ytLive.Helpers;
using ytLive.Models;
@@ -30,6 +32,21 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
private Task? _stderrLoop;
private bool _stopRequested;
// Pipe decoupling (slice 10, 2026-09-10): a bounded pending-frame queue drained
// by its own task (the OBS video-thread → encoder-queue model — the encoder's
// thread never couples back into the video thread; libobs obs-encoder.c). The
// pump's SubmitFrameAsync now ENQUEUES (copying into a pooled buffer) instead of
// blocking on WriteAsync when ffmpeg lags the pipe; overflow DROPS the newest
// frame (skip-newest) and counts it. BoundedChannelFullMode.Wait + TryWrite gives
// exactly that: when full, TryWrite returns false and the caller drops the item.
private const int QueueCapacity = 120; // ~2s at the tier's 60fps
private readonly Channel<PendingWrite> _frames = Channel.CreateBounded<PendingWrite>(
new BoundedChannelOptions(QueueCapacity) { SingleReader = true });
private Task? _drainTask;
private int _droppedBackpressure;
private readonly record struct PendingWrite(byte[] Buffer, int Length);
public FfmpegEncoder(
IFfmpegLocator locator,
Func<IEncoderProcess>? processFactory = null)
@@ -40,6 +57,13 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
public bool IsRunning { get; private set; }
/// <summary>Frames dropped by the bounded queue because the drain task couldn't
/// keep the pipe fed (the encoder lagging the real-time capture rate). The pump
/// reports this in its stats; the burned-in frame counter in the recording
/// shows the identical jumps. Distinct from <see cref="StreamHealth.DroppedFrames"/>
/// (ffmpeg's own progress-derived estimate).</summary>
public int DroppedFrames => Volatile.Read(ref _droppedBackpressure);
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
{
if (options == null) throw new ArgumentNullException(nameof(options));
@@ -93,26 +117,40 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
_stopRequested = false;
_stderrLoop = RunStderrLoopAsync(process);
StartDrainLoop(process);
}
/// <summary>
/// Write one raw BGRA frame to ffmpeg's stdin. Serialized internally; callers
/// (the compositor pump) may race freely. Frames are written as-is — the caller
/// paces to capture rate (the compositor's job, ship step 5).
/// Enqueue one raw BGRA frame for ffmpeg's stdin (slice 10). The caller races
/// freely (the compositor pump); this never blocks on the pipe. The pixels are
/// copied into a pooled buffer first because — unlike the old direct write — the
/// write happens later on the drain thread, so the caller may (and does) recycle
/// its scratch buffer the moment this returns. When the bounded queue is full the
/// NEWEST frame is dropped and counted (freshness over coverage; the encoder is
/// already behind, so the stale-content failure mode never encodes).
/// </summary>
public async Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
{
if (frame == null) throw new ArgumentNullException(nameof(frame));
IEncoderProcess? process;
lock (_gate)
{
if (!IsRunning) throw new InvalidOperationException("The encoder is not running.");
process = _process;
}
var bytes = frame.BgraPixels;
await process!.StandardInput.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
if (cancellationToken.IsCancellationRequested)
return Task.FromCanceled(cancellationToken);
var pooled = ArrayPool<byte>.Shared.Rent(frame.BgraPixels.Length);
Buffer.BlockCopy(frame.BgraPixels, 0, pooled, 0, frame.BgraPixels.Length);
if (_frames.Writer.TryWrite(new PendingWrite(pooled, frame.BgraPixels.Length)))
return Task.CompletedTask;
// Queue full (encoder lagging) or the channel completed during stop — drop the
// newest and count it. The caller never blocks; the encoder catches up or the
// session ends with a (reported) short gap instead of a frozen stall.
ArrayPool<byte>.Shared.Return(pooled);
Interlocked.Increment(ref _droppedBackpressure);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken = default)
@@ -127,13 +165,17 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
loop = _stderrLoop;
}
// Flush every queued frame, then EOF: TryComplete lets the drain task write
// the buffered frames, close stdin → ffmpeg finalizes and exits by itself
// (the "did not exit after EOF" kill below is only the backstop).
try
{
process!.StandardInput.Dispose(); // EOF → ffmpeg finalizes + exits
_frames.Writer.TryComplete();
if (_drainTask != null) await _drainTask.ConfigureAwait(false);
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: closing stdin failed");
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop");
}
try
@@ -178,6 +220,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
public void Dispose()
{
_frames.Writer.TryComplete(); // unblock a stuck drain loop alongside the kill
lock (_gate)
{
if (!IsRunning) return;
@@ -188,6 +231,54 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
}
/// <summary>
/// The drain task (slice 10): owns every stdin write, so pipe backpressure — the
/// pump's old stall — lives HERE, on a thread the pump never touches. Frames are
/// written exactly as queued (the pooled array through its recorded length —
/// ArrayPool may return a larger buffer), returned to the pool after the write
/// copies into the pipe, and stdin is closed (EOF) once the queue drains.
/// </summary>
private void StartDrainLoop(IEncoderProcess process)
{
_drainTask = Task.Run(async () =>
{
try
{
try
{
while (await _frames.Reader.WaitToReadAsync().ConfigureAwait(false))
{
while (_frames.Reader.TryRead(out var pending))
{
await process.StandardInput.WriteAsync(pending.Buffer, 0, pending.Length)
.ConfigureAwait(false);
await process.StandardInput.FlushAsync().ConfigureAwait(false);
ArrayPool<byte>.Shared.Return(pending.Buffer);
}
}
}
catch (ChannelClosedException)
{
// channel completed while waiting — fall through to EOF
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted");
}
finally
{
// EOF on every path — a byte that sits in the pool when the pipe
// breaks is garbage anyway, and ffmpeg MUST see the close to finalize.
process.StandardInput.Dispose();
}
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted");
}
});
}
private async Task<string> ProbeEncoderAsync(string ffmpegPath, CancellationToken cancellationToken)
{
try
@@ -230,14 +321,35 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
OnStderrLine(process, line);
}
var code = process.ExitCode;
var stillRunning = false;
lock (_gate) stillRunning = IsRunning;
if (stillRunning && !_stopRequested && code != 0)
// Read the exit code defensively: the stop path disposes the process
// while this loop may still be waking (2026-09-01 "No process is
// associated with this object" — a cosmetic race that poisoned the log).
int exitCode;
try
{
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly ({code})");
_health.LastError = $"FFmpeg exited with code {code}";
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {code}");
exitCode = process.ExitCode;
}
catch (Exception)
{
exitCode = -1;
}
var stillRunning = false;
var stopping = false;
lock (_gate)
{
stillRunning = IsRunning;
stopping = _stopRequested;
}
if (stillRunning && !stopping)
{
// ANY exit we did not ask for is a failure — including code 0.
// The old `code != 0` gate let ffmpeg's clean early exit vanish
// without a word while the pump kept feeding a corpse
// (2026-09-01 first real recording: died at ~0.9s, zero logs).
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})");
_health.LastError = $"FFmpeg exited with code {exitCode}";
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {exitCode}");
}
}
catch (Exception ex)
@@ -250,7 +362,17 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
private void OnStderrLine(IEncoderProcess process, string line)
{
var progress = FfmpegProgressParser.TryParse(line);
if (progress == null) return;
if (progress == null)
{
// THE EYES: everything ffmpeg says that isn't a progress line — startup
// config, warnings, the real reason it quit. Was silently discarded
// (2026-09-01: encoder died at ~0.9s, log silent). Truncated so a
// \r-choked stats blob can't flood; noise now beats blindness.
var text = line.Trim('\r', ' ', '\t');
if (text.Length > 0)
AppLog.Write($"ffmpeg: {(text.Length > 400 ? text[..400] + "…" : text)}");
return;
}
var dropped = Math.Max(0, (long)Math.Round(progress.Value.Fps * progress.Value.Duration.TotalSeconds) - progress.Value.Frame);
+30 -2
View File
@@ -20,9 +20,22 @@ public sealed class FfmpegLocator : IFfmpegLocator
{
public const string FileName = "ffmpeg.exe";
/// <summary>Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md).</summary>
/// <summary>Sibling probe binary — extracted alongside ffmpeg so the media
/// frame-rate probe (<see cref="ytLive.Services.FfmpegFrameRateProbe"/>) can
/// derive its path from the located ffmpeg directory.</summary>
public const string ProbeFileName = "ffprobe.exe";
/// <summary>
/// Pinned BtbN LGPL-shared win64 build — <c>autobuild-2026-08-31-13-27</c>, the
/// ffmpeg N-126342 build (immutable tag; recorded in TASKS.md per the licensing rule).
/// Deliberately a MONTH-END tag: BtbN retains the last ~14 dailies but month-end
/// builds for ~2 years — the previous pin (a 2026-08-09 daily) aged out of retention
/// and 404'd on a cold cache exactly as ai.md predicted (2026-09-01, the first real
/// recording attempt). Dated tags carry versioned asset names (ffmpeg-N-…-win64-lgpl-shared.zip);
/// extraction is name-agnostic.
/// </summary>
public const string PinnedUrl =
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip";
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip";
private readonly string[] _searchDirs;
private readonly string _toolsDir;
@@ -60,6 +73,17 @@ public sealed class FfmpegLocator : IFfmpegLocator
ExtractBinaries(zip, _toolsDir);
return cached;
}
catch (HttpRequestException ex)
{
// The old gap: this exception type escaped unwrapped, so a dead pin surfaced
// as a raw "Response status code does not indicate success: 404" from the
// frame pump. Locator failures are IOException with an actionable message.
AppLog.Write(ex, "FFmpeg locator: download failed");
throw new IOException(
$"FFmpeg download failed ({ex.Message}). The pinned build may have aged out of " +
"retention — update FfmpegLocator.PinnedUrl, or install FFmpeg (LGPL-shared build) " +
"and make sure 'ffmpeg' is on PATH.", ex);
}
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException)
{
AppLog.Write(ex, "FFmpeg locator: download/extract failed");
@@ -85,6 +109,10 @@ public sealed class FfmpegLocator : IFfmpegLocator
e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}.");
ExtractOne(exe, Path.Combine(staging, FileName));
var probe = archive.Entries.FirstOrDefault(
e => e.FullName.EndsWith("/" + ProbeFileName, StringComparison.OrdinalIgnoreCase));
if (probe != null)
ExtractOne(probe, Path.Combine(staging, ProbeFileName));
foreach (var entry in archive.Entries)
{
if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
+586 -17
View File
@@ -29,15 +29,92 @@ public sealed class FramePump : IDisposable
private readonly Action<string>? _log;
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
private readonly Func<VideoFrame?>? _alertTicker;
private readonly Func<VideoFrame?>? _brandFlash;
private readonly TransitionService? _transition;
private readonly Func<Scene?>? _fromSceneProvider;
private readonly SceneGraph? _sceneGraph;
private readonly SceneCompositor _compositor = new();
// Master-buffer scratch pool (take-4 starvation fix, slice 2 of 2): a fresh
// 8.3MB byte[] every tick is ~500MB/s of LOH churn — GC stalls masquerading
// as render cost. The pump pools ONLY buffers it handed out (reference-equality
// set), so bake-cache / social-bar / static-cache frames are never touched;
// SubmitFrameAsync copies the bytes to the encoder's stdin before returning,
// so recycling after submit is safe (MyMistakes recipe).
private readonly HashSet<byte[]> _ownedScratch = new(ReferenceEqualityComparer.Instance);
private readonly List<byte[]> _freeScratch = new();
private const int MaxScratchPooled = 4;
private byte[] AcquireScratch(int size)
{
for (var i = _freeScratch.Count - 1; i >= 0; i--)
{
if (_freeScratch[i].Length != size) continue;
var buffer = _freeScratch[i];
_freeScratch.RemoveAt(i);
return buffer;
}
var fresh = new byte[size];
_ownedScratch.Add(fresh);
return fresh;
}
private void ReleaseScratch(byte[]? buffer)
{
if (buffer == null || !_ownedScratch.Contains(buffer)) return;
if (_freeScratch.Count >= MaxScratchPooled || _freeScratch.Contains(buffer)) return;
_freeScratch.Add(buffer);
}
// Windows sleep quantum (take-9 finding, 2026-09-04): Task.Delay rounds every
// request up to the system clock tick (~15.6ms default — learn.microsoft.com/en-us/
// dotnet/api/system.threading.tasks.task.delay: "approximately 15 milliseconds on
// Windows systems"), so a pacer requesting 3-15ms actually sleeps 15.6ms. Takes
// 5-9 measured work ~25ms but period ~37ms: one padded wait per frame hid every
// compositor improvement. Established media-app practice (game-loop/OBS canon —
// stackoverflow.com/questions/5441464; and raise the resolution for the session —
// learn.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod):
// timeBeginPeriod(1) while the pump runs, sleep only the BULK of the remainder,
// and spin the last ~2ms across the deadline.
[System.Runtime.InteropServices.DllImport("winmm.dll")]
private static extern uint timeBeginPeriod(uint uMilliseconds);
[System.Runtime.InteropServices.DllImport("winmm.dll")]
private static extern uint timeEndPeriod(uint uMilliseconds);
private static readonly long SpinTailTicks = System.Diagnostics.Stopwatch.Frequency * 2 / 1000; // 2ms
private readonly object _gate = new();
private IFfmpegEncoder? _encoder;
private CancellationTokenSource? _cts;
private Task? _pumpTask;
private bool _started;
private long _outputIndex;
/// <summary>Captured by <see cref="ProbeRender"/> when a render exceeds ~20ms —
/// the 2026-09-12 half-speed-render probe. Appended (once) to the next stats or
/// stall line, then cleared.</summary>
private string _renderDetail = "";
/// <summary>C4 blit-on-change cache (2026-09-15): the FULL-render path (split=0
/// when the live-capture backdrop sits at element 0, or no SceneGraph) re-composites
/// the ENTIRE frame every tick even when no input changed — the ty-1841 take logged
/// a render stall on EVERY iteration (totalMs 21-44, one composite per ~30ms while
/// the content moved ~20 updates/s). The SceneGraph split can never help here: the
/// backdrop is element 0 and must stay dynamic (a baked capture goes stale), so the
/// cache lives at the pump: the last full-render output plus the input identity that
/// produced it. Unchanged identity → ONE BlockCopy (~3ms) reuses the composite;
/// changed identity → re-composite. The cache buffer is a SEPARATE long-lived array,
/// never the scratch pool — the caller burns the frame counter and recycles the
/// scratch AFTER RenderScene returns, so the cache is written from the rendered
/// scratch BEFORE returning (pre-burn, pre-recycle). The identity mirror is
/// <see cref="BuildFullRenderSignature"/>: it resolves the same frames the compositor
/// will (same resolver seam), so a changed frame (new capture, new webcam, new chat
/// raster) forces a re-render and an unchanged one never serves stale bytes. Internal
/// counters feed the Good Dog test + the 5s cache detail in the stats line.</summary>
private byte[]? _fullCachePixels;
private bool _fullCacheValid;
private ulong _lastFullSignature;
internal long CacheHits;
internal long CacheRenders;
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
public event EventHandler<StreamHealth>? HealthUpdated;
@@ -54,8 +131,10 @@ public sealed class FramePump : IDisposable
Action<string>? log = null,
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
Func<VideoFrame?>? alertTicker = null,
Func<VideoFrame?>? brandFlash = null,
TransitionService? transition = null,
Func<Scene?>? fromSceneProvider = null)
SceneGraph? sceneGraph = null)
{
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
@@ -65,12 +144,19 @@ public sealed class FramePump : IDisposable
_log = log;
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
_socialBar = socialBar;
_alertTicker = alertTicker;
_brandFlash = brandFlash;
_transition = transition;
_fromSceneProvider = fromSceneProvider;
_sceneGraph = sceneGraph;
}
public bool IsRunning { get; private set; }
/// <summary>The burned counter of the LAST frame submitted to the encoder. Stable
/// across a tick's multiple resolver passes (it advances only at burn/submit, which
/// happens AFTER render) — tests key per-tick content on it.</summary>
internal long OutputIndex => _outputIndex;
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
/// so the VM can fire-and-forget it from a sync command handler.</summary>
public async Task StartAsync(CancellationToken cancellationToken = default)
@@ -107,7 +193,15 @@ public sealed class FramePump : IDisposable
// synchronously on this thread before PumpAsync even returns.
IsRunning = true;
_cts = new CancellationTokenSource();
_pumpTask = PumpAsync(options, _cts.Token);
// The loop runs on the thread pool ON PURPOSE (take-8 finding, 2026-09-04):
// Task.Run installs no SynchronizationContext, so every await continuation
// stays off the UI dispatcher. Before this, the pump inherited the UI
// thread's sync context (StartAsync is fired from a command handler), so
// "render 22ms, wait 10ms" was the producer sitting in the dispatcher
// queue behind the live preview it is meant to be independent of — the
// stats quantum fix made the wait VISIBLE; this removes its cause.
// OBS's video threads are dedicated for exactly this reason.
_pumpTask = Task.Run(() => PumpAsync(options, _cts.Token));
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
}
catch (Exception ex)
@@ -208,6 +302,8 @@ public sealed class FramePump : IDisposable
{
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
}
_freeScratch.Clear();
_ownedScratch.Clear();
}
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
@@ -215,6 +311,85 @@ public sealed class FramePump : IDisposable
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
var lastTick = System.Diagnostics.Stopwatch.StartNew();
// Deadline pacing (2026-09-03, take-3 fix): the frame interval is a DEADLINE,
// not an afterthought sleep — the OBS libobs video-io.c pattern (researched
// before coding; see https://github.com/obsproject/obs-studio/blob/master/
// libobs/media-io/video-io.c). The old loop slept the FULL interval after
// each render, so period = render + submit + interval: at take-3's 258ms
// render that was 3.6fps stamped into a 60fps container — rawvideo stamps by
// arrival, so 30 wall-seconds muxed as a 2.1s time-lapse, no error anywhere.
var intervalTicks = Math.Max(1, (long)Math.Round(interval.TotalSeconds * System.Diagnostics.Stopwatch.Frequency));
var nextTick = System.Diagnostics.Stopwatch.GetTimestamp();
// Stage timing (2026-09-01, take two): rawvideo carries no per-frame
// timestamps — ffmpeg stamps frames by ARRIVAL at the declared fps. A producer
// slower than the declared rate yields a time-lapsed, short file (observed:
// 39 frames in 27 wall-seconds ≈ 30x at 60fps) with no error anywhere.
// Log the render/submit split every 5s so the next take names the stage.
var renderSw = new System.Diagnostics.Stopwatch();
var submitSw = new System.Diagnostics.Stopwatch();
var waitSw = new System.Diagnostics.Stopwatch();
// Resolve-vs-composite split (2026-09-04, take-6 ambiguity): "render" was a
// black box — the stats line now reports resolver time separately so a take
// names the stage (get-frame vs blit) instead of feeding another guess.
var resolveSw = new System.Diagnostics.Stopwatch();
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0, worstRender = 0, worstSubmit = 0;
int statFrames = 0;
int stalls = 0;
long lastCacheHits = 0, lastCacheRenders = 0;
var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
void ReportStats()
{
if (DateTime.UtcNow < statsNext) return;
var target = 5d / interval.TotalSeconds; // frames expected per window
IFfmpegEncoder? encoder;
lock (_gate) encoder = _encoder;
var dropped = encoder == null ? 0 : encoder.DroppedFrames;
var detail = _renderDetail;
_renderDetail = "";
var cacheDetail = "";
if (statFrames > 0)
{
// C4 window detail: rendered composites (R) vs cache reuses (H) since the
// last report — hard numeric proof the blit-on-change cache engaged.
var hits = CacheHits - lastCacheHits;
var renders = CacheRenders - lastCacheRenders;
lastCacheHits = CacheHits;
lastCacheRenders = CacheRenders;
if (hits > 0 || renders > 0) cacheDetail = $", cache {renders}R/{hits}H";
}
_log?.Invoke(statFrames == 0
? "FramePump stats: NO frames produced in 5s (loop stalled?)" + (detail.Length > 0 ? " | " + detail : "")
: $"FramePump stats: {statFrames}/{target:F0} frames per 5s, " +
$"avg render {renderTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms " +
$"(resolve {resolveTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}), " +
$"avg submit {submitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
+ $"avg wait {waitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
+ $"worst render {worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
+ $"worst submit {worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
+ $"dropped {dropped}, stalls {stalls}" + cacheDetail + (detail.Length > 0 ? " | " + detail : ""));
worstRender = 0;
worstSubmit = 0;
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
statFrames = 0;
stalls = 0;
statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
}
// One wrapper shared by every render of the run — resolve time accumulates
// inside the render measurement, and the stats line reports the split.
timeBeginPeriod(1); // pairs with timeEndPeriod in the finally — see field note
var previousGcMode = System.Runtime.GCSettings.LatencyMode;
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
VideoFrame? TimedResolver(SceneElement element)
{
resolveSw.Restart();
var frame = _frameResolver(element);
resolveSw.Stop();
resolveTicks += resolveSw.ElapsedTicks;
return frame;
}
try
{
while (!ct.IsCancellationRequested)
@@ -222,6 +397,7 @@ public sealed class FramePump : IDisposable
var scene = _sceneProvider();
if (scene != null)
{
var iterStart = System.Diagnostics.Stopwatch.GetTimestamp();
var compositorOptions = _compositorOptions();
VideoFrame? socialBarFrame = null;
var socialBarTop = 0;
@@ -235,30 +411,125 @@ public sealed class FramePump : IDisposable
: compositorOptions.SourceRectHeight - barFrame.Height;
}
// The alert ticker is a per-frame overlay (it scrolls while an alert
// plays), NEVER baked into the static base. Lawn-mower seam: read it
// each tick and let the compositor pin it to the very top.
var tickerFrame = _alertTicker?.Invoke();
// The free-tier branding credit. Read per-frame for the same reason
// as the ticker: it fades and sits on a fresh random spot, so it can
// never live in the baked base or the blit-on-change cache.
var flashFrame = _brandFlash?.Invoke();
VideoFrame frame;
if (_transition is { Active: true } transition && _fromSceneProvider != null)
var scratchSize = compositorOptions.SourceRectWidth
* compositorOptions.SourceRectHeight * 4;
// The transition "from" frame lives in TransitionService.FromFrame
// (captured at Start by the VM). The old per-tick fromScene render
// here was dead weight — a full extra scene composite every
// transition tick that BlendFrame never read; removed with the
// pooling change because its buffer's only consumer was its own release.
renderSw.Restart();
var scratch = AcquireScratch(scratchSize);
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver);
if (_transition is { Active: true } transition)
{
var fromScene = _fromSceneProvider();
var fromFrame = fromScene != null
? _compositor.Render(fromScene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop)
: _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
var toFrame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
frame = transition.BlendFrame(toFrame);
frame = transition.BlendFrame(frame);
transition.Tick(lastTick.Elapsed.TotalMilliseconds);
lastTick.Restart();
}
else
{
frame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
// Restarted EVERY frame (transition or not): a transition begun
// after idle must not inherit a giant ElapsedMs and complete
// instantly on its first Tick.
lastTick.Restart();
}
renderSw.Stop();
renderTicks += renderSw.ElapsedTicks;
if (renderSw.ElapsedTicks > worstRender) worstRender = renderSw.ElapsedTicks;
IFfmpegEncoder? encoder;
lock (_gate) encoder = _encoder;
if (encoder == null) break;
// Deadline pacing, OBS duplicate-on-lag (slice 15): emit ONE frame per
// deadline slot — a fresh composite when the render kept up, a REPEAT
// of this iteration's latest composite for every slot the render
// overran. The OBS model never leaves a wall-time hole: libobs
// media-io/video-io.c runs on its own clock and duplicates the latest
// frame when the video thread lags, logging "lagged frames due to
// rendering lag/stalls" — never skipped time (docs.obsproject.com/
// backend-design: "If the video frame queue is full, it will duplicate
// the last frame"). Slice 10 chose the opposite for freshness: an
// overrun SKIPPED the missed slots, so a 60fps-authoring pump emitting
// one frame per 35ms overrun authored ~1.7x playback (ty-1726: 163
// video frames for 2.93s of audio) and ended the video 0.22s before the
// audio tail. Counting duplicates instead of skips keeps recording
// duration == wall duration (no acceleration, no audio tail cut) at the
// cost of a short judder during a stall — the accepted trade. The burst
// is microseconds (the enqueue never blocks, and the loop is clamped to
// deadlineNow) so it can't smear the way the take-9 blocking burst did.
var deadlineNow = System.Diagnostics.Stopwatch.GetTimestamp();
while (!ct.IsCancellationRequested && deadlineNow >= nextTick)
{
// Burned-in frame counter (slice 10 judge): a clock-independent
// pacing witness burned literally into the composite. Decode the
// recording and read the bottom-right strip: the number must
// advance +1 per authored frame and jump only by counted drops
// (queue overflow). Burned here — on the buffer finally submitted,
// which the enqueue's snapshot copies before the next iteration
// rewrites it — so every file frame carries its own index and a
// fast-render iteration that submitted nothing leaves no phantom
// gap (the slice-10 unconditional bump before the gate could).
_outputIndex++;
BurnFrameIndex(frame.BgraPixels, frame.Width, frame.Height, _outputIndex);
submitSw.Restart();
await encoder.SubmitFrameAsync(frame, ct);
submitSw.Stop();
statFrames++;
submitTicks += submitSw.ElapsedTicks;
if (submitSw.ElapsedTicks > worstSubmit) worstSubmit = submitSw.ElapsedTicks;
nextTick += intervalTicks;
}
// SubmitFrameAsync copied the bytes into the encoder's queue — the
// tick's buffers are recyclable once the enqueue snapshot them.
// The free-list Contains guard keeps the Cut path (BlendFrame
// returns toFrame itself, aliasing scratch) safe.
ReleaseScratch(frame.BgraPixels);
ReleaseScratch(scratch);
// Sleep the BULK of the remainder, SPIN the 2ms tail — never hand
// a sub-tick remainder to the sleep quantum (the timeBeginPeriod
// note). If the renderer already ate the budget there is nothing
// left to sleep and the loop renders the next frame straight away.
waitSw.Restart();
var ahead = nextTick - System.Diagnostics.Stopwatch.GetTimestamp();
if (ahead > SpinTailTicks)
await _pacingDelay(TimeSpan.FromSeconds(
(ahead - SpinTailTicks) / (double)System.Diagnostics.Stopwatch.Frequency), ct);
while (!ct.IsCancellationRequested
&& System.Diagnostics.Stopwatch.GetTimestamp() < nextTick)
Thread.SpinWait(400);
waitSw.Stop();
waitTicks += waitSw.ElapsedTicks;
// Stall logger (slice 10): an iteration spanning more than two full
// intervals is the old bug's fingerprint — name the stage instead of
// guessing. With the queue, submit should be ~1ms, so a stall here
// means RENDER or RESOLVE (the stage terms of the last take).
var iterWall = System.Diagnostics.Stopwatch.GetTimestamp() - iterStart;
if (iterWall > 2 * intervalTicks)
{
stalls++;
var detail = _renderDetail;
_renderDetail = "";
_log?.Invoke($"FramePump stall: iteration {iterWall / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms " +
$"(> 2× the {interval.TotalMilliseconds:F0}ms interval): worst render " +
$"{worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, worst submit " +
$"{worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, dropped {encoder.DroppedFrames}" +
(detail.Length > 0 ? " | " + detail : ""));
}
ReportStats();
}
await _pacingDelay(interval, ct);
}
}
catch (OperationCanceledException)
@@ -281,12 +552,310 @@ public sealed class FramePump : IDisposable
}
finally
{
System.Runtime.GCSettings.LatencyMode = previousGcMode;
timeEndPeriod(1);
lock (_gate) IsRunning = false;
}
}
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
/// <summary>Render a scene, using the baked-crust optimization when a <see cref="SceneGraph"/>
/// is wired in: bake/cache the static layers below the split point, then composite the
/// dynamic/above-split layers per frame. Without a SceneGraph, falls back to a full render
/// (identical output — see SceneCompositorTests). <paramref name="scratch"/> is a pooled
/// master buffer when supplied; the fully-static path returns the bake-cache frame itself
/// (never pooled — the release side checks owned-by-reference).</summary>
private VideoFrame RenderScene(
Scene scene,
CompositorOptions options,
VideoFrame? socialBarFrame,
int socialBarTop,
VideoFrame? tickerFrame,
VideoFrame? flashFrame,
byte[]? scratch = null,
Func<SceneElement, VideoFrame?>? resolver = null)
{
resolver ??= _frameResolver;
if (_sceneGraph == null)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
return frame;
}
var split = _sceneGraph.GetSplitPoint(scene);
if (split == scene.Elements.Count)
{
// Fully static scene: bake once, reuse.
var sw = System.Diagnostics.Stopwatch.StartNew();
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
if (baked != null)
{
// BUG (pre-existing, found while wiring the branding credit 2026-09-26):
// this path returned the stretched bake and silently DROPPED the social
// bar and the alert ticker. Overlay() copies before blitting, so the
// cached bake is not mutated and the flash still cannot get sticky in it.
if (socialBarFrame != null || tickerFrame != null || flashFrame != null)
{
var withOverlays = baked;
if (flashFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, flashFrame, 0, 0);
if (tickerFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
if (socialBarFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, socialBarFrame, socialBarTop, 0);
baked = withOverlays;
}
var stretched = StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
ProbeRender("fully-static", scene, split, sw.ElapsedMilliseconds, (int)sw.ElapsedMilliseconds);
return stretched;
}
}
var swBase = System.Diagnostics.Stopwatch.StartNew();
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
if (baseFrame != null)
{
var frame = SceneCompositor.CompositeLayers(
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch);
ProbeRender("base+layers", scene, split, swBase.ElapsedMilliseconds, 0);
return frame;
}
// No static base (first layer is dynamic or empty scene) — full render.
var swFull = System.Diagnostics.Stopwatch.StartNew();
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
return frame2;
}
/// <summary>The full-render path (no SceneGraph, or no static base) wrapped in the
/// C4 blit-on-change cache: when every resolved input plus every element's
/// layout/visual bits match the last composite, reuse it with one BlockCopy instead
/// of re-compositing. Mirror the compositor's resolution <i>before</i> deciding so a
/// hit costs ~3ms of pure copy and a miss costs exactly the old full render.</summary>
private VideoFrame RenderFull(
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
VideoFrame? tickerFrame, VideoFrame? flashFrame,
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver)
{
// 1:1 guard: the cache stores the OUTPUT bytes and scratch is source-sized —
// at 1:1 they are the same buffer size. Off-size tiers (a vertical 1080×1920
// tier, an H264-1080p tier) fall back to a per-tick full render; the deployed
// config is master-sized and caching a per-tick fresh scale buffer is the known
// vertical-tier follow-up (see ai.md), not silently baked here.
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
&& options.SourceRectHeight == options.OutputHeight;
var signature = useCache
? BuildFullRenderSignature(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, resolver)
: 0UL;
if (useCache && _fullCacheValid && signature == _lastFullSignature
&& _fullCachePixels!.Length == scratch!.Length)
{
Buffer.BlockCopy(_fullCachePixels, 0, scratch, 0, scratch.Length);
CacheHits++;
return new VideoFrame(options.SourceRectWidth, options.SourceRectHeight, scratch);
}
var rendered = _compositor.Render(scene, resolver, flashFrame, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame);
CacheRenders++;
if (useCache)
{
var pixels = rendered.BgraPixels;
if (_fullCachePixels == null || _fullCachePixels.Length != pixels.Length)
_fullCachePixels = new byte[pixels.Length];
// PRE-burn / PRE-recycle: the caller burns the counter and recycles this
// scratch after RenderScene returns — the cache keeps a clean copy, and the
// burn on the next hit writes to a FRESH scratch copy, never the cache.
Buffer.BlockCopy(pixels, 0, _fullCachePixels, 0, pixels.Length);
_lastFullSignature = signature;
_fullCacheValid = true;
}
else
{
_fullCacheValid = false; // size changed — never reuse a wrong-size buffer
}
return rendered;
}
/// <summary>Hash the complete set of inputs the compositor's FULL render consumes:
/// the crop/output dimensions, the social bar, and — in element order, mirroring
/// <c>SceneCompositor.Render</c>'s iteration — every element's layout/visual fields
/// plus the frame each one resolves (through the SAME resolver seam the render will
/// use). A changed frame (new capture, new webcam Epoch, new chat raster) changes the
/// hash → re-composite; an unchanged one reuses the cache. Buffer-array identity +
/// Epoch + CropBounds is the compositor's own paste-key shape — producers hand out
/// fresh immutable arrays, or recycled-ring arrays whose monotonic Epoch
/// distinguishes generations (the capture take-11 fix), so address-identity alone is
/// never trusted to key recycled content.</summary>
private static ulong BuildFullRenderSignature(
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
VideoFrame? tickerFrame,
VideoFrame? flashFrame,
Func<SceneElement, VideoFrame?> resolver)
{
var h = 14695981039346656037UL; // FNV-1a offset basis
h = Mix(h, (ulong)options.SourceRectX);
h = Mix(h, (ulong)options.SourceRectY);
h = Mix(h, (ulong)options.SourceRectWidth);
h = Mix(h, (ulong)options.SourceRectHeight);
h = Mix(h, (ulong)options.OutputWidth);
h = Mix(h, (ulong)options.OutputHeight);
h = Mix(h, (ulong)socialBarTop);
if (socialBarFrame != null) h = MixFrame(h, socialBarFrame);
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
if (tickerFrame != null) h = MixFrame(h, tickerFrame);
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
// BrandFlashPresenter recycles one buffer and bumps Epoch every read, so this
// invalidates the cache on every tick of a credit — a cached frame would freeze
// the fade and pin a stale spot.
if (flashFrame != null) h = MixFrame(h, flashFrame);
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
var backdropResolved = false;
foreach (var element in scene.Elements)
{
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(element));
h = Mix(h, element.IsVisible ? 1UL : 0UL);
h = MixDouble(h, element.X);
h = MixDouble(h, element.Y);
h = MixDouble(h, element.Width);
h = MixDouble(h, element.Height);
h = MixDouble(h, element.Opacity);
h = Mix(h, (ulong)element.ClipShape);
h = Mix(h, element.IsMirrored ? 1UL : 0UL);
h = Mix(h, (ulong)element.BorderWidth);
h = MixDouble(h, element.BorderOpacity);
if (element.TryGetBorderColor(out var bcR, out var bcG, out var bcB))
h = Mix(h, (ulong)((bcR << 16) | (bcG << 8) | bcB));
else
h = Mix(h, 0UL);
// Mirror the compositor's per-element frame resolution EXACTLY, so the hash
// reacts to the same frames a render uses and never to a frame a render
// ignores (a false-positive change only wastes one render; a false NEGATIVE
// would serve stale bytes — that is what this mirror prevents).
VideoFrame? frame = null;
if (element is Source { IsBackground: true })
{
h = Mix(h, 1UL); // the live backdrop — first visible one is resolved, once
if (element.IsVisible && !backdropResolved)
{
frame = resolver(element);
backdropResolved = true;
}
}
else if (element is Source { Type: SourceType.TextOverlay })
{
h = Mix(h, 2UL); // skipped kind — never resolved by the compositor
}
else if (element is Source { Type: SourceType.Background })
{
h = Mix(h, 3UL); // background image — resolved even when invisible (Render does)
frame = resolver(element);
}
else
{
h = Mix(h, 4UL); // regular layer — resolved only when visible
if (element.IsVisible) frame = resolver(element);
}
if (frame != null) h = MixFrame(h, frame);
else h = Mix(h, 0UL);
}
return h;
}
private static ulong Mix(ulong h, ulong v) => (h ^ v) * 0x9E3779B97F4A7C15UL;
private static ulong MixDouble(ulong h, double v)
=> Mix(h, unchecked((ulong)BitConverter.DoubleToInt64Bits(v)));
private static ulong MixFrame(ulong h, VideoFrame frame)
{
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(frame.BgraPixels));
h = Mix(h, (ulong)frame.Epoch);
h = Mix(h, (ulong)frame.Width);
h = Mix(h, (ulong)frame.Height);
if (frame.CropBounds is { } cb)
{
h = Mix(h, (ulong)cb.X);
h = Mix(h, (ulong)cb.Y);
h = Mix(h, (ulong)cb.W);
h = Mix(h, (ulong)cb.H);
}
else
{
h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
}
return h;
}
/// <summary>2026-09-12 half-speed-render probe: the pump only produces ~27fps
/// (render ~35ms vs the 16.7ms deadline), and rawvideo muxes at the DECLARED fps,
/// so every take muxes at ~half its wall length (the truncation complaint). This
/// names WHICH compositor path ate the slow frame so the fix targets the real
/// stage. Records only when a frame takes ≥20ms (or carries the previous detail).</summary>
private void ProbeRender(string path, Scene scene, int split, long totalMs, int bakeMs)
{
if (totalMs < 20 && _renderDetail.Length == 0) return;
var dynamics = 0;
foreach (var e in scene.Elements)
if (e.Kind == ElementKind.Dynamic) dynamics++;
_renderDetail = $"render={path} split={split} elements={scene.Elements.Count} dynamic={dynamics}" +
(bakeMs > 0 ? $" bakeMs={bakeMs}" : "") + $" totalMs={totalMs}";
}
// Dot-matrix digits (5×7, one row per raster line, '1' = lit) burned into the
// bottom-right of every composite. Basic OCR-safe shapes, sized so the strip is
// a 36×8 white box in the corner of a 1920×1080 frame — readable with a zoomed
// player, invisible at normal size.
private static readonly string[][] DigitGlyphs =
{
new[] { "01110","10001","10001","10001","10001","10001","01110" }, // 0
new[] { "00100","01100","00100","00100","00100","00100","01110" }, // 1
new[] { "01110","10001","00001","00010","00100","01000","11111" }, // 2
new[] { "11111","00001","00010","00110","00001","10001","01110" }, // 3
new[] { "00010","00110","01010","10010","11111","00010","00010" }, // 4
new[] { "11111","10000","11110","00001","00001","10001","01110" }, // 5
new[] { "01110","10001","10000","11110","10001","10001","01110" }, // 6
new[] { "11111","00001","00010","00100","01000","01000","01000" }, // 7
new[] { "01110","10001","10001","01110","10001","10001","01110" }, // 8
new[] { "01110","10001","10001","01111","00001","10001","01110" }, // 9
};
private static void BurnFrameIndex(byte[] bgra, int width, int height, long index)
{
const int digitW = 5, digitH = 7, gap = 1, margin = 2, digitCount = 6;
var stripW = digitCount * (digitW + gap) - gap;
var left = width - margin - stripW;
var top = height - margin - digitH;
// Solid white box under the digits — the underlying scene can be anything.
for (var y = top; y < top + digitH; y++)
for (var x = left; x < left + stripW; x++)
{
var i = (y * width + x) * 4;
bgra[i] = 255;
bgra[i + 1] = 255;
bgra[i + 2] = 255;
}
var text = index.ToString("D6");
for (var d = 0; d < digitCount; d++)
{
var glyph = DigitGlyphs[text[d] - '0'];
for (var row = 0; row < digitH; row++)
for (var col = 0; col < digitW; col++)
{
if (glyph[row][col] != '1') continue;
var i = ((top + row) * width + (left + d * (digitW + gap) + col)) * 4;
bgra[i] = 0;
bgra[i + 1] = 0;
bgra[i + 2] = 0;
}
}
}
private void OnProcessFailed(object? sender, string message)
{
_log?.Invoke($"FramePump: encoder process failed: {message}");
+5
View File
@@ -22,4 +22,9 @@ public interface IFfmpegEncoder : IDisposable
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
Task StopAsync(CancellationToken cancellationToken = default);
/// <summary>Frames dropped by the encoder's bounded queue since start (the encoder
/// lagging the real-time capture rate). Zero in a healthy session; fed to the pump's
/// stats and mirrored by gaps in the burned-in frame counter of the recording.</summary>
int DroppedFrames { get; }
}
+36
View File
@@ -0,0 +1,36 @@
using System.Diagnostics;
using System.IO;
namespace ytLive.Services;
/// <summary>
/// The real <see cref="IDecodeProcess"/>: a <see cref="Process"/> with stdout
/// redirected as raw bytes for reading <c>rawvideo</c> frames. Constructed by
/// <see cref="MediaVideoSource"/> for the media-file decoder subprocess.
/// </summary>
public sealed class FfmpegDecodeProcess : IDecodeProcess
{
private readonly Process _process;
public FfmpegDecodeProcess() => _process = new Process { EnableRaisingEvents = true };
public void Start(ProcessStartInfo startInfo)
{
_process.StartInfo = startInfo;
_process.Start();
}
public Stream StandardOutput => _process.StandardOutput.BaseStream;
public bool HasExited => _process.HasExited;
public int ExitCode => _process.ExitCode;
public void Kill()
{
if (!_process.HasExited) _process.Kill();
}
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
=> _process.WaitForExitAsync(cancellationToken);
public void Dispose() => _process.Dispose();
}
+53
View File
@@ -0,0 +1,53 @@
using System.Globalization;
namespace ytLive.Services;
/// <summary>
/// TASK 21 (slice 2): parses the native frame rate of a video from ffprobe
/// <c>-show_entries stream=avg_frame_rate,r_frame_rate</c> output, which reports
/// each as a rational like <c>30/1</c> or <c>60000/1001</c> (or <c>0/0</c> when
/// unknown). Prefers <c>avg_frame_rate</c> (the true display rate over the whole
/// file, correct for VFR) and falls back to <c>r_frame_rate</c> (the container/
/// codec rate). Pure: no ffprobe, no I/O, unit-testable.
/// </summary>
public static class FfmpegFrameRateParser
{
public static double? ParseFrameRate(string probeOutput)
{
foreach (var key in new[] { "avg_frame_rate=", "r_frame_rate=" })
{
var value = FindValue(probeOutput, key);
if (value is not null && TryParseRational(value, out var fps))
return fps;
}
return null;
}
/// <summary>The trimmed value of the first line beginning with <paramref name="key"/>.</summary>
private static string? FindValue(string text, string key)
{
foreach (var line in text.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith(key, StringComparison.Ordinal))
return trimmed.Substring(key.Length).Trim();
}
return null;
}
/// <summary>Parses <c>v</c> as <c>N</c> (integer) or <c>N/M</c> (rational) into
/// a frames-per-second double. <c>0</c> and <c>0/M</c> count as unknown.</summary>
private static bool TryParseRational(string value, out double fps)
{
fps = 0;
var parts = value.Split('/');
if (parts.Length == 0 || !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var num))
return false;
var den = 1;
if (parts.Length > 1 && !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out den))
return false;
if (num <= 0 || den <= 0) return false;
fps = (double)num / den;
return true;
}
}
+67
View File
@@ -0,0 +1,67 @@
using System.Diagnostics;
using System.IO;
using ytLive.Services.Encoder;
namespace ytLive.Services;
/// <summary>
/// TASK 21 (slice 2): resolves the native frame rate of a media file so the
/// decoder can pace frames to real time. Seam so <see cref="MediaVideoSource"/>
/// never touches ffprobe directly and tests can fake the whole probe.
/// </summary>
public interface IFrameRateProbe
{
/// <summary>Native video FPS for <paramref name="path"/>, or null when unknown
/// (no video stream, ffprobe unavailable, unparsable).</summary>
Task<double?> ProbeAsync(string path, CancellationToken cancellationToken = default);
}
/// <summary>
/// Default probe: locates ffmpeg (via <see cref="IFfmpegLocator"/>), derives the
/// sibling <c>ffprobe.exe</c> from its directory, and asks ffprobe for the
/// <c>avg_frame_rate</c>/<c>r_frame_rate</c>. Reuses the binary-stdout
/// <see cref="IDecodeProcess"/> seam for the subprocess (ffprobe text is read from
/// the same raw stdout). Returns null (no pacing) if ffprobe is unavailable.
/// </summary>
public sealed class FfmpegFrameRateProbe : IFrameRateProbe
{
private readonly IFfmpegLocator _locator;
private readonly Func<IDecodeProcess> _probeProcessFactory;
public FfmpegFrameRateProbe(IFfmpegLocator locator, Func<IDecodeProcess> probeProcessFactory)
{
_locator = locator;
_probeProcessFactory = probeProcessFactory;
}
public async Task<double?> ProbeAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path)) return null;
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
var ffprobePath = Path.Combine(Path.GetDirectoryName(ffmpegPath) ?? string.Empty, "ffprobe.exe");
if (!File.Exists(ffprobePath)) return null;
var startInfo = new ProcessStartInfo
{
FileName = ffprobePath,
Arguments =
$"-v error -select_streams v:0 " +
$"-show_entries stream=avg_frame_rate,r_frame_rate " +
$"-of default=noprint_wrappers=1 \"{path}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = _probeProcessFactory();
process.Start(startInfo);
string output;
using (var reader = new StreamReader(process.StandardOutput))
output = reader.ReadToEnd();
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
return FfmpegFrameRateParser.ParseFrameRate(output);
}
}
+90
View File
@@ -0,0 +1,90 @@
using System;
namespace ytLive.Services;
/// <summary>
/// A depth-bounded ring of scratch frame buffers that never writes into memory a live
/// consumer may still be reading. A slot's byte[] is only rewritten after at least
/// <paramref name="redLine"/> rents have cycled since it was last handed out; a slot
/// still inside its red line is skipped, and if the whole ring is inside its red line a
/// fresh buffer is handed out rather than lapping a loaned slot. Length-mismatched
/// buffers are always replaced by a fresh allocation (the previous array is orphaned,
/// never overwritten), so a hand-out keeps its bytes for as long as anyone holds it.
///
/// The screen capture's consumer contract makes this structural necessity (slice 16,
/// 2026-09-14): <c>ScreenCaptureManager</c> keeps each delivered frame as
/// <c>session.LatestFrame</c> across conversions and the dispatcher preview copy lags,
/// so a held frame can survive several conversions. The 1742 take recorded one torn
/// frame (new-top/old-bottom at the webcam split) — a ring slot rewritten under the
/// consumer's read. Depth 8 × the conversion interval already exceeded the worst
/// observed hold (~50ms, take-14 rule); the red line makes the guarantee structural
/// instead of a sizing coincidence.
/// </summary>
internal sealed class FrameRingBuffer
{
private readonly byte[]?[] _slots;
private readonly long[] _lastHandout;
private readonly int _redLine;
private readonly object _lock = new();
private int _next;
private long _seq;
private long _allocations;
public FrameRingBuffer(int depth, int redLine)
{
_slots = new byte[depth][];
_lastHandout = new long[depth];
for (var i = 0; i < depth; i++) _lastHandout[i] = -redLine;
_redLine = redLine;
}
/// <summary>Buffers freshly allocated after the last <see cref="ConsumeAllocations"/>.</summary>
public long Allocations
{
get { lock (_lock) return _allocations; }
}
/// <summary>Returns the allocation count since the last call and resets it.</summary>
public long ConsumeAllocations()
{
lock (_lock)
{
var count = _allocations;
_allocations = 0;
return count;
}
}
/// <summary>Hands out a scratch buffer of <paramref name="size"/> bytes.</summary>
public byte[] Rent(int size)
{
lock (_lock)
{
var seq = ++_seq;
for (var tries = 0; tries < _slots.Length; tries++)
{
var idx = (_next + tries) % _slots.Length;
// Red line: rewriting this slot could hit a frame a consumer still reads.
if (seq - _lastHandout[idx] < _redLine) continue;
_next = (idx + 1) % _slots.Length;
if (_slots[idx] is { Length: var len } buf && len == size)
{
_lastHandout[idx] = seq;
return buf;
}
// Length mismatch (or never allocated): a fresh array, never an in-place
// overwrite — the previous loan's bytes stay valid for whoever holds it.
_allocations++;
var fresh = new byte[size];
_slots[idx] = fresh;
_lastHandout[idx] = seq;
return fresh;
}
// Defensive: the whole ring is inside its red line — do not lap a loaned slot.
_allocations++;
return new byte[size];
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Diagnostics;
using System.IO;
namespace ytLive.Services;
/// <summary>
/// TASK 21: seam around the media decoder's subprocess (ffmpeg <c>rawvideo</c>).
/// Exposes the raw binary stdout (frames) and exit control, so
/// <see cref="MediaVideoSource"/> never touches <c>System.Diagnostics.Process</c>
/// directly and tests can fake the whole decode. Mirrors the encoder's
/// <c>IEncoderProcess</c> but with a binary <see cref="StandardOutput"/>.
/// </summary>
public interface IDecodeProcess : IDisposable
{
void Start(ProcessStartInfo startInfo);
/// <summary>Raw binary stdout — the <c>rawvideo</c> BGRA frame bytes.</summary>
Stream StandardOutput { get; }
bool HasExited { get; }
int ExitCode { get; }
void Kill();
Task WaitForExitAsync(CancellationToken cancellationToken = default);
}
+26
View File
@@ -0,0 +1,26 @@
namespace ytLive.Services;
/// <summary>
/// TASK 21: a media file decoder exposed to <see cref="MediaVideoSourceManager"/>.
/// Mirrors <c>IScreenCaptureSource</c>/<c>ICameraFrameSource</c> so the manager
/// (and tests) treat a local video file like any other frame source: start/stop
/// and a per-frame <see cref="FrameAvailable"/> event consumers coalesce.
/// </summary>
public interface IMediaFrameSource : IDisposable
{
string Key { get; }
/// <summary>When true, the decoder restarts from the beginning on natural EOF
/// instead of raising <see cref="Completed"/> (used for BRB/starting-soon loops).</summary>
bool Looping { get; set; }
/// <summary>Raised (on a worker thread) for each decoded frame.</summary>
event Action<VideoFrame>? FrameAvailable;
/// <summary>Raised when the file finishes decoding (natural EOF) — not raised
/// while <see cref="Looping"/> is true.</summary>
event Action? Completed;
Task StartAsync(CancellationToken cancellationToken = default);
Task StopAsync();
}
+15
View File
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>Session-scoped seam that lists open top-level windows for the
/// "Capture Window…" submenu. Mirrors <see cref="IFullScreenDetector"/> so the
/// VM never depends on a specific Win32 implementation (and tests hand in a
/// fake enumerator).</summary>
public interface IWindowEnumerator
{
/// <summary>Visible, user-facing top-level windows belonging to other
/// processes, ordered for display (title, process name).</summary>
IReadOnlyList<WindowInfo> Enumerate();
}
+41
View File
@@ -0,0 +1,41 @@
using System.Security.Cryptography;
using Microsoft.Data.Sqlite;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public byte[]? GetAssetBytes(string assetId)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
cmd.Parameters.AddWithValue("$id", assetId);
return cmd.ExecuteScalar() as byte[];
}
public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
{
var hash = Convert.ToHexString(SHA256.HashData(data));
var id = Guid.NewGuid().ToString();
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
VALUES ($id, $hash, $data, $w, $h)
ON CONFLICT(Hash) DO NOTHING;
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$hash", hash);
cmd.Parameters.AddWithValue("$data", data);
cmd.Parameters.AddWithValue("$w", pixelWidth);
cmd.Parameters.AddWithValue("$h", pixelHeight);
cmd.ExecuteNonQuery();
cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
return (string)cmd.ExecuteScalar()!;
}
// ─── License (TASK 10 — Polar billing) ───
/// <summary>The saved license key, or null if never entered.</summary>
}
+240
View File
@@ -0,0 +1,240 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public List<Scene> Load()
{
Webcam = null;
Socials = null;
Music = null;
var scenes = new List<Scene>();
var sourcesByScene = new Dictionary<string, List<(Source Source, int Sort)>>();
var configsByScene = new Dictionary<string, List<(WebcamSceneConfig Config, int Sort)>>();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
scenes.Add(new Scene
{
Id = reader.GetString(0),
Name = reader.GetString(1),
IsHidden = reader.GetInt32(2) != 0,
IsChatScene = reader.GetInt32(3) != 0,
HasBackground = reader.GetInt32(4) != 0,
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
});
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Webcam = new Webcam
{
Id = reader.GetString(0),
DeviceId = reader.GetString(1),
Name = reader.GetString(2),
};
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Socials = new SocialsConfig
{
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
};
var socialsId = reader.GetString(0);
using var entryCmd = _connection.CreateCommand();
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
entryCmd.Parameters.AddWithValue("$id", socialsId);
using var entryReader = entryCmd.ExecuteReader();
while (entryReader.Read())
{
Socials.Entries.Add(new SocialEntry
{
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
Handle = entryReader.GetString(1),
ProfileUrl = entryReader.GetString(2),
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
});
}
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Music = new Music
{
TrackPath = reader.GetString(0),
IsEnabled = reader.GetInt32(1) != 0,
};
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
IsBackground, CaptureKey, ShowDesktop,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
AlertDisplayMethod
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var sceneId = reader.GetString(1);
var type = Enum.Parse<SourceType>(reader.GetString(3));
var source = new Source
{
Id = reader.GetString(0),
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
Type = type,
Name = reader.GetString(4),
IsEnabled = reader.GetInt32(5) != 0,
X = reader.GetDouble(6),
Y = reader.GetDouble(7),
Width = reader.GetDouble(8),
Height = reader.GetDouble(9),
Opacity = reader.GetDouble(10),
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
IsMirrored = reader.GetInt32(13) != 0,
// Rows saved before the Type setter derived the flag carry
// IsBackground=0; derive from Type, keeping the persisted bit
// for legacy DisplayCapture backdrops.
IsBackground = type == SourceType.Background || reader.GetInt32(14) != 0,
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
ShowDesktop = reader.GetInt32(16) != 0,
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
ChatFontColor = reader.IsDBNull(18) ? "#FFFFFF" : reader.GetString(18),
ChatBackgroundColor = reader.IsDBNull(19) ? "#80000000" : reader.GetString(19),
ChatMaxMessages = reader.IsDBNull(20) ? 5 : reader.GetInt32(20),
ChatShowTimestamps = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
MediaPath = reader.IsDBNull(26) ? null : reader.GetString(26),
MediaIsLooping = reader.IsDBNull(27) ? false : reader.GetInt32(27) != 0,
MediaVolume = reader.IsDBNull(28) ? 1.0 : reader.GetDouble(28),
MediaPlaybackState = reader.IsDBNull(29)
? MediaPlaybackState.Stopped
: Enum.TryParse<MediaPlaybackState>(reader.GetString(29), out var ps)
? ps
: MediaPlaybackState.Stopped,
AlertVideoAssetId = reader.IsDBNull(30) ? null : reader.GetString(30),
AlertVideoPath = reader.IsDBNull(31) ? null : reader.GetString(31),
AlertUseDefaultVideo = reader.IsDBNull(32) ? true : reader.GetInt32(32) != 0,
AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0,
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>();
list.Add((source, reader.GetInt32(35)));
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
RectWidth, RectHeight, SortOrder
FROM WebcamSceneConfig ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var sceneId = reader.GetString(0);
var config = new WebcamSceneConfig
{
WebcamId = reader.GetString(1),
Name = Webcam?.Name ?? "Webcam",
IsVisible = reader.GetInt32(2) != 0,
X = reader.GetDouble(3),
Y = reader.GetDouble(4),
Width = reader.GetDouble(5),
Height = reader.GetDouble(6),
Opacity = reader.GetDouble(7),
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
IsMirrored = reader.GetInt32(9) != 0,
BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
BorderAnimation = Enum.TryParse<BorderAnimation>(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
};
if (!configsByScene.TryGetValue(sceneId, out var list))
configsByScene[sceneId] = list = new List<(WebcamSceneConfig, int)>();
list.Add((config, reader.GetInt32(16)));
}
}
foreach (var scene in scenes)
{
// Source and WebcamSceneConfig rows share ONE z-order space per scene
// (both SortOrders = the element's index within scene.Elements). Merge
// the two tables' rows by that shared z rather than appending all
// sources before all configs — a webcam slotted between sources must
// reload in place. Legacy rows (written before the unified space) may
// tie at 0: break ties sources-first, so old data reads back stacked
// the same way it did pre-unification.
var ordered = new List<(int Sort, SceneElement Element)>();
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
foreach (var (source, sort) in sources)
ordered.Add((sort, source));
if (configsByScene.TryGetValue(scene.Id, out var configs))
foreach (var (config, sort) in configs)
ordered.Add((sort, config));
foreach (var (_, element) in ordered.OrderBy(e => e.Sort).ThenBy(e => e.Element is Source ? 0 : 1))
scene.Elements.Add(element);
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg != null)
{
// Load from source-keyed settings (new) or scene-keyed (legacy migration)
var path = GetSetting($"BackgroundPath_{bg.Id}");
if (string.IsNullOrWhiteSpace(path))
path = GetSetting($"BackgroundPath_{scene.Id}");
if (!string.IsNullOrWhiteSpace(path))
bg.CustomBackgroundPath = path;
var useDefault = GetSetting($"BackgroundUseDefault_{bg.Id}");
if (string.IsNullOrWhiteSpace(useDefault))
useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
if (useDefault == "0")
bg.UseDefaultBackground = false;
}
}
return scenes;
}
}
+552
View File
@@ -0,0 +1,552 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
private void EnsureSchema()
{
string[] statements =
{
"""
CREATE TABLE IF NOT EXISTS Scene (
Id TEXT PRIMARY KEY,
Name TEXT NOT NULL,
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
HasBackground INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Asset (
Id TEXT PRIMARY KEY,
Hash TEXT NOT NULL UNIQUE,
Data BLOB NOT NULL,
PixelWidth INTEGER NOT NULL DEFAULT 0,
PixelHeight INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Source (
Id TEXT PRIMARY KEY,
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
AssetId TEXT REFERENCES Asset(Id),
Type TEXT NOT NULL,
Name TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
MonitorIndex INTEGER,
DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
IsBackground INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Webcam (
Id TEXT PRIMARY KEY,
DeviceId TEXT NOT NULL UNIQUE,
Name TEXT NOT NULL
);
""",
"""
CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
IsVisible INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
RectWidth REAL,
RectHeight REAL,
BorderColor TEXT,
BorderOpacity REAL NOT NULL DEFAULT 1,
BorderWidth INTEGER NOT NULL DEFAULT 0,
BorderAnimation TEXT NOT NULL DEFAULT 'None',
SortOrder INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (SceneId, WebcamId)
);
""",
"""
CREATE TABLE IF NOT EXISTS Socials (
Id TEXT PRIMARY KEY,
BarPosition TEXT NOT NULL DEFAULT 'Bottom',
BarJustify TEXT NOT NULL DEFAULT 'Center'
);
""",
"""
CREATE TABLE IF NOT EXISTS SocialEntry (
Id TEXT PRIMARY KEY,
SocialsId TEXT NOT NULL REFERENCES Socials(Id) ON DELETE CASCADE,
Service TEXT NOT NULL,
Handle TEXT NOT NULL,
ProfileUrl TEXT NOT NULL,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Music (
Id TEXT PRIMARY KEY,
TrackPath TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Settings (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL
);
""",
// v9 → v10 (TASK 39): YPP journey-tracker snapshots, appended on every
// stats refresh. The history is the raw data a later analytics slice
// reads to compute velocity/(de)acceleration/ETA.
"""
CREATE TABLE IF NOT EXISTS YppSnapshot (
CapturedAtUtc TEXT NOT NULL PRIMARY KEY,
SubscriberCount INTEGER NOT NULL,
VideoCount INTEGER NOT NULL,
TotalViews INTEGER NOT NULL,
UploadsLast90Days INTEGER NOT NULL,
CommunityGuidelinesGoodStanding INTEGER NOT NULL,
CopyrightStrikesGoodStanding INTEGER NOT NULL,
ContentIdClaimsGoodStanding INTEGER NOT NULL,
OverallGoodStanding INTEGER NOT NULL
);
""",
};
foreach (var sql in statements)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
MigrateSourceTable();
MigrateWebcamConfigTable();
MigrateSceneTable();
MigrateSceneSocialBarColumn();
MigrateSocialsTable();
MigrateSocialEntryTable();
if (GetUserVersion() < 3)
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 10;";
cmd.ExecuteNonQuery();
}
}
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
// get the columns here instead.
private void MigrateSourceTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Source);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (!columns.Contains("ClipShape"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("AlertDisplayMethod"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertDisplayMethod INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("IsMirrored"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (columns.Contains("IsBackdrop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
cmd.ExecuteNonQuery();
}
else if (!columns.Contains("IsBackground"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("CaptureKey"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
cmd.ExecuteNonQuery();
}
// TASK 3.18: ChatBox overlay source
if (!columns.Contains("ChatFontSize"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatFontColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatBackgroundColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatMaxMessages"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatShowTimestamps"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatShowBadges"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatMemberColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatSuperChatColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ShowDesktop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("WebUri"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN WebUri TEXT;";
cmd.ExecuteNonQuery();
}
// TASK 21: Media source (video/audio file playback)
if (!columns.Contains("MediaPath"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPath TEXT;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaIsLooping"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaIsLooping INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaVolume"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaVolume REAL NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaPlaybackState"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
cmd.ExecuteNonQuery();
}
// TASK 47: alert video config. The default clip asset id + the creator's
// replacement path (a FILE reference, never a stored BLOB) + the pill,
// ticker toggle and volume live on the row.
if (!columns.Contains("AlertVideoAssetId"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoAssetId TEXT;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("AlertVideoPath"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoPath TEXT;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("AlertUseDefaultVideo"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertUseDefaultVideo INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("AlertShowTicker"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertShowTicker INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("AlertVideoVolume"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoVolume REAL NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
// Rename legacy "Backdrop" display name to "Background" in existing rows
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
cmd.ExecuteNonQuery();
}
}
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
// so a reloaded Round webcam restores its aspect on toggle-back).
private void MigrateWebcamConfigTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (!columns.Contains("RectWidth"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("RectHeight"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
cmd.ExecuteNonQuery();
}
}
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
// one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
// per scene that had one, then the webcam rows are deleted. No backfill:
// scenes without the webcam stay webcam-free.
private void MigrateToV3()
{
var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add((
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetDouble(3),
reader.GetDouble(4),
reader.GetDouble(5),
reader.GetDouble(6),
reader.GetDouble(7),
reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
reader.GetString(9),
reader.GetInt32(10) != 0));
}
}
if (rows.Count == 0) return;
using var tx = _connection.BeginTransaction();
var webcamId = Guid.NewGuid().ToString();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", webcamId);
cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
cmd.Parameters.AddWithValue("$name", rows[0].Name);
cmd.ExecuteNonQuery();
}
foreach (var row in rows)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
$clip, $mirrored, NULL, 1, 0, 'None', 0);
""";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
cmd.Parameters.AddWithValue("$webcamId", webcamId);
cmd.Parameters.AddWithValue("$x", row.X);
cmd.Parameters.AddWithValue("$y", row.Y);
cmd.Parameters.AddWithValue("$w", row.W);
cmd.Parameters.AddWithValue("$h", row.H);
cmd.Parameters.AddWithValue("$opacity", row.Opacity);
cmd.Parameters.AddWithValue("$clip", row.ClipShape);
cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
tx.Commit();
}
// v5 → v6: Scene gains HasBackground. Added columns default to true; the
// ONE-TIME backfill (runs only when the column is first added) turns every
// non-Live canonical scene off and drops their background sources — the fix for
// DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds
// re-runs the same normalization on every load for DBs that miss the backfill.
private void MigrateSceneTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Scene);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("HasBackdrop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
cmd.ExecuteNonQuery();
}
else if (columns.Contains("HasBackground"))
{
return;
}
if (!columns.Contains("HasBackground"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
const string noBackground = "('starting', 'brb', 'chat', 'ending')";
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = $"""
DELETE FROM Source
WHERE IsBackground = 1 AND SceneId IN
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
""";
cmd.ExecuteNonQuery();
}
}
// v6 → v7: Scene gains HasSocialBar (the per-scene social-bar toggle).
// Defaults to 0 (off) — the creator adds it explicitly via the footer [+].
private void MigrateSceneSocialBarColumn()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Scene);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("HasSocialBar")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE Scene ADD COLUMN HasSocialBar INTEGER NOT NULL DEFAULT 0;";
alter.ExecuteNonQuery();
}
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
// BarJustify column stays for back-compat but is no longer read or written.
private void MigrateSocialsTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Socials);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("BarEnabled")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
alter.ExecuteNonQuery();
}
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
// software name) so a reloaded entry still shows the right service logo.
private void MigrateSocialEntryTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(SocialEntry);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("Software")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
alter.ExecuteNonQuery();
}
}
+368
View File
@@ -0,0 +1,368 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
{
using var tx = _connection.BeginTransaction();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Source;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Webcam;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM SocialEntry;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Socials;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Music;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Scene;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $sort)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var sort = 0;
foreach (var scene in scenes)
{
idP.Value = scene.Id;
nameP.Value = scene.Name;
hiddenP.Value = scene.IsHidden ? 1 : 0;
chatP.Value = scene.IsChatScene ? 1 : 0;
backgroundP.Value = scene.HasBackground ? 1 : 0;
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
sortP.Value = sort++;
cmd.ExecuteNonQuery();
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
AlertDisplayMethod)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
$alertDisplayMethod)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
var assetIdP = cmd.Parameters.Add("$assetId", SqliteType.Text);
var typeP = cmd.Parameters.Add("$type", SqliteType.Text);
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
var enabledP = cmd.Parameters.Add("$isEnabled", SqliteType.Integer);
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var isBackgroundP = cmd.Parameters.Add("$isBackground", SqliteType.Integer);
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
var showDesktopP = cmd.Parameters.Add("$showDesktop", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var chatFontSizeP = cmd.Parameters.Add("$chatFontSize", SqliteType.Real);
var chatFontColorP = cmd.Parameters.Add("$chatFontColor", SqliteType.Text);
var chatBgColorP = cmd.Parameters.Add("$chatBgColor", SqliteType.Text);
var chatMaxMsgsP = cmd.Parameters.Add("$chatMaxMsgs", SqliteType.Integer);
var chatTimestampsP = cmd.Parameters.Add("$chatTimestamps", SqliteType.Integer);
var chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer);
var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
var webUriP = cmd.Parameters.Add("$webUri", SqliteType.Text);
var mediaPathP = cmd.Parameters.Add("$mediaPath", SqliteType.Text);
var mediaIsLoopingP = cmd.Parameters.Add("$mediaIsLooping", SqliteType.Integer);
var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
var alertVideoAssetIdP = cmd.Parameters.Add("$alertVideoAssetId", SqliteType.Text);
var alertVideoPathP = cmd.Parameters.Add("$alertVideoPath", SqliteType.Text);
var alertUseDefaultVideoP = cmd.Parameters.Add("$alertUseDefaultVideo", SqliteType.Integer);
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
// table, so a webcam slotted between sources survives save→load.
// (Pre-unified rows tie at 0 — load breaks ties sources-first.)
foreach (var scene in scenes)
{
var z = 0;
foreach (var element in scene.Elements)
{
if (element is not Source source) { z++; continue; }
idP.Value = source.Id;
sceneIdP.Value = scene.Id;
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
typeP.Value = source.Type.ToString();
nameP.Value = source.Name;
enabledP.Value = source.IsEnabled ? 1 : 0;
xP.Value = source.X;
yP.Value = source.Y;
wP.Value = source.Width;
hP.Value = source.Height;
opacityP.Value = source.Opacity;
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
clipP.Value = source.ClipShape.ToString();
mirroredP.Value = source.IsMirrored ? 1 : 0;
isBackgroundP.Value = source.IsBackground ? 1 : 0;
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
sortP.Value = z++;
chatFontSizeP.Value = source.ChatFontSize;
chatFontColorP.Value = source.ChatFontColor;
chatBgColorP.Value = source.ChatBackgroundColor;
chatMaxMsgsP.Value = source.ChatMaxMessages;
chatTimestampsP.Value = source.ChatShowTimestamps ? 1 : 0;
chatBadgesP.Value = source.ChatShowBadges ? 1 : 0;
chatMemberColorP.Value = source.ChatMemberColor;
chatSuperChatColorP.Value = source.ChatSuperChatColor;
webUriP.Value = (object?)source.WebUri ?? DBNull.Value;
mediaPathP.Value = (object?)source.MediaPath ?? DBNull.Value;
mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
mediaVolumeP.Value = source.MediaVolume;
mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString();
alertVideoAssetIdP.Value = (object?)source.AlertVideoAssetId ?? DBNull.Value;
alertVideoPathP.Value = (object?)source.AlertVideoPath ?? DBNull.Value;
alertUseDefaultVideoP.Value = source.AlertUseDefaultVideo ? 1 : 0;
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
alertVideoVolumeP.Value = source.AlertVideoVolume;
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
cmd.ExecuteNonQuery();
}
}
}
if (webcam != null)
{
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", webcam.Id);
cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
cmd.Parameters.AddWithValue("$name", webcam.Name);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
RectWidth, RectHeight, SortOrder)
VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
$clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
$rectWidth, $rectHeight, $sort)
""";
cmd.Transaction = tx;
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
foreach (var scene in scenes)
{
var z = 0;
foreach (var element in scene.Elements)
{
if (element is not WebcamSceneConfig config) { z++; continue; }
sceneIdP.Value = scene.Id;
webcamIdP.Value = config.WebcamId;
visibleP.Value = config.IsVisible ? 1 : 0;
xP.Value = config.X;
yP.Value = config.Y;
wP.Value = config.Width;
hP.Value = config.Height;
opacityP.Value = config.Opacity;
clipP.Value = config.ClipShape.ToString();
mirroredP.Value = config.IsMirrored ? 1 : 0;
colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
borderOpacityP.Value = config.BorderOpacity;
borderWidthP.Value = config.BorderWidth;
animationP.Value = config.BorderAnimation.ToString();
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
sortP.Value = z++;
cmd.ExecuteNonQuery();
}
}
}
}
if (socials != null && socials.Entries.Count > 0)
{
var socialsId = Guid.NewGuid().ToString();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", socialsId);
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
cmd.ExecuteNonQuery();
}
using var entryCmd = _connection.CreateCommand();
entryCmd.CommandText = """
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
VALUES ($id, $socialsId, $service, $handle, $url, $software, $sort)
""";
entryCmd.Transaction = tx;
var eId = entryCmd.Parameters.Add("$id", SqliteType.Text);
var eSocialsId = entryCmd.Parameters.Add("$socialsId", SqliteType.Text);
var eService = entryCmd.Parameters.Add("$service", SqliteType.Text);
var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text);
var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text);
var eSoftware = entryCmd.Parameters.Add("$software", SqliteType.Text);
var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer);
var entrySort = 0;
foreach (var entry in socials.Entries)
{
eId.Value = Guid.NewGuid().ToString();
eSocialsId.Value = socialsId;
eService.Value = entry.Service.ToString();
eHandle.Value = entry.Handle;
eUrl.Value = entry.ProfileUrl;
eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
eSort.Value = entrySort++;
entryCmd.ExecuteNonQuery();
}
}
if (music != null && !string.IsNullOrWhiteSpace(music.TrackPath))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "INSERT INTO Music (Id, TrackPath, IsEnabled) VALUES ($id, $path, $enabled);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
cmd.Parameters.AddWithValue("$path", music.TrackPath);
cmd.Parameters.AddWithValue("$enabled", music.IsEnabled ? 1 : 0);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
DELETE FROM Asset WHERE Id NOT IN (
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
UNION
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
UNION
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
);
""";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
// Save custom background paths and use-default flags per background source
foreach (var scene in scenes)
{
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg == null) continue;
var pathKey = $"BackgroundPath_{bg.Id}";
if (!string.IsNullOrWhiteSpace(bg.CustomBackgroundPath))
UpsertSetting(pathKey, bg.CustomBackgroundPath);
else
{
using var del = _connection.CreateCommand();
del.CommandText = "DELETE FROM Settings WHERE Key = $key;";
del.Parameters.AddWithValue("$key", pathKey);
del.ExecuteNonQuery();
}
var defaultKey = $"BackgroundUseDefault_{bg.Id}";
UpsertSetting(defaultKey, bg.UseDefaultBackground ? "1" : "0");
}
// Purge background settings left behind by deleted/duplicated rows
// (TASK 25 heal drops extras; their keys must not accumulate).
using (var purge = _connection.CreateCommand())
{
purge.Transaction = tx;
purge.CommandText =
@"DELETE FROM Settings
WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\'
OR Key LIKE 'BackgroundPath\_%' ESCAPE '\')
AND SUBSTR(Key, INSTR(Key, '_') + 1) NOT IN (SELECT Id FROM Source);";
purge.ExecuteNonQuery();
}
tx.Commit();
}
}
+329
View File
@@ -0,0 +1,329 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
/// <summary>The last picked mic source name, or null if never chosen. Restored
/// at startup so the sound bar reconnects to the same, already-vetted device.</summary>
public string? LoadMicSourceName()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'MicSourceName';";
var value = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public void SaveMicSourceName(string? name)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'MicSourceName';";
cmd.ExecuteNonQuery();
if (string.IsNullOrWhiteSpace(name))
return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('MicSourceName', $name);";
insert.Parameters.AddWithValue("$name", name);
insert.ExecuteNonQuery();
}
/// <summary>The global audio-sync offset in milliseconds (TASK 22), −500..500,
/// default 0: positive delays the mix (audio ahead of video), negative
/// advances it (audio behind video). Restored at startup so the mixer starts
/// on the user's offset.</summary>
public int LoadAudioSyncOffsetMs()
{
var value = GetSetting("Audio.SyncOffsetMs");
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
}
public void SaveAudioSyncOffsetMs(int ms)
{
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
}
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
public string? LoadRecordFolder()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'RecordFolder';";
var value = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public void SaveRecordFolder(string? folder)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'RecordFolder';";
cmd.ExecuteNonQuery();
if (string.IsNullOrWhiteSpace(folder))
return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('RecordFolder', $folder);";
insert.Parameters.AddWithValue("$folder", folder);
insert.ExecuteNonQuery();
}
/// <summary>The cached reusable stream (TASK 5), or null if never created.
/// Survives restarts so the pump has its RTMP URL the moment go-live starts
/// instead of waiting on the YouTube round-trip.</summary>
public ReusableStream? LoadReusableStream()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'ReusableStream';";
var value = cmd.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(value)) return null;
var parts = value.Split('\u001F');
return parts.Length == 3
? new ReusableStream(parts[0], parts[1], parts[2])
: null;
}
public void SaveReusableStream(ReusableStream? stream)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'ReusableStream';";
cmd.ExecuteNonQuery();
if (stream == null) return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('ReusableStream', $value);";
insert.Parameters.AddWithValue("$value", $"{stream.Id}\u001F{stream.IngestionAddress}\u001F{stream.StreamName}");
insert.ExecuteNonQuery();
}
public string? LoadLicenseKey()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseKey';";
var value = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public void SaveLicenseKey(string? key)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseKey';";
cmd.ExecuteNonQuery();
if (string.IsNullOrWhiteSpace(key)) return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseKey', $value);";
insert.Parameters.AddWithValue("$value", key);
insert.ExecuteNonQuery();
}
/// <summary>When the license was last validated online (ISO 8601), or null if never.</summary>
public DateTime? LoadLicenseValidatedAt()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseValidatedAt';";
var value = cmd.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(value)) return null;
return DateTime.TryParse(value, out var dt) ? dt : null;
}
public void SaveLicenseValidatedAt(DateTime? dt)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseValidatedAt';";
cmd.ExecuteNonQuery();
if (dt == null) return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseValidatedAt', $value);";
insert.Parameters.AddWithValue("$value", dt.Value.ToString("o"));
insert.ExecuteNonQuery();
}
/// <summary>Cached premium state (1 = premium, 0 = free). Used for offline
/// grace period — the app checks online at startup but uses this cached
/// value when offline.</summary>
public bool LoadIsPremium()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'IsPremium';";
var value = cmd.ExecuteScalar() as string;
return value == "1";
}
public void SaveIsPremium(bool isPremium)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'IsPremium';";
cmd.ExecuteNonQuery();
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('IsPremium', $value);";
insert.Parameters.AddWithValue("$value", isPremium ? "1" : "0");
insert.ExecuteNonQuery();
}
public (string TransitionType, double DurationMs) LoadTransitionSettings()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionType';";
var type = cmd.ExecuteScalar() as string;
using var cmd2 = _connection.CreateCommand();
cmd2.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionDurationMs';";
var dur = cmd2.ExecuteScalar() as string;
return (type ?? "Fade", dur != null && double.TryParse(dur, out var d) ? d : 300);
}
public void SaveTransitionSettings(string transitionType, double durationMs)
{
UpsertSetting("TransitionType", transitionType);
UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
}
// ─── Broadcast metadata (broadcast pull-out) + window geometry ───
public Models.BroadcastMetadata LoadBroadcastMetadata()
{
return new Models.BroadcastMetadata
{
Title = GetSetting("Broadcast.Title") ?? string.Empty,
Description = GetSetting("Broadcast.Description") ?? string.Empty,
TagsCsv = GetSetting("Broadcast.TagsCsv") ?? string.Empty,
Visibility = GetSetting("Broadcast.Visibility") ?? "private",
MadeForKids = GetSetting("Broadcast.MadeForKids") == "1",
ScheduledStartTime = DateTime.TryParse(GetSetting("Broadcast.ScheduledStartTime"), out var t) ? t : null,
};
}
public void SaveBroadcastMetadata(Models.BroadcastMetadata meta)
{
UpsertSetting("Broadcast.Title", meta.Title);
UpsertSetting("Broadcast.Description", meta.Description);
UpsertSetting("Broadcast.TagsCsv", meta.TagsCsv);
UpsertSetting("Broadcast.Visibility", meta.Visibility);
UpsertSetting("Broadcast.MadeForKids", meta.MadeForKids ? "1" : "0");
UpsertSetting("Broadcast.ScheduledStartTime",
meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
}
// ─── Hotkey bindings (TASK 20 step 2) ───
/// <summary>
/// Load saved hotkey bindings. Returns only entries that have been explicitly
/// saved — missing HotkeyIds are unbound (not in the returned dictionary).
/// Caller fills in defaults for any missing entries as needed.
/// </summary>
public Dictionary<HotkeyId, HotkeyChord> LoadHotkeyBindings()
{
var result = new Dictionary<HotkeyId, HotkeyChord>();
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
{
var raw = GetSetting($"Hotkey.{id}");
if (raw == null) continue; // not saved = unbound
if (raw == "none") continue; // explicitly unbound
try
{
result[id] = HotkeyChord.ParseStorage(raw);
}
catch (FormatException)
{
// corrupt entry — skip, will fall back to default
}
}
return result;
}
/// <summary>
/// Save hotkey bindings. Null entries in the dictionary mean "unbound" (stored
/// as "none"). Only the provided entries are saved; previously saved entries
/// for HotkeyIds not in the dictionary are deleted.
/// </summary>
public void SaveHotkeyBindings(Dictionary<HotkeyId, HotkeyChord?> bindings)
{
// Delete all existing hotkey settings first
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
cmd.Parameters.AddWithValue("$key", $"Hotkey.{id}");
cmd.ExecuteNonQuery();
}
// Save the new bindings
foreach (var (id, chord) in bindings)
{
var value = chord?.ToString() ?? "none";
UpsertSetting($"Hotkey.{id}", value);
}
}
public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
{
var left = GetSetting("Window.Left");
var top = GetSetting("Window.Top");
var width = GetSetting("Window.Width");
var height = GetSetting("Window.Height");
var state = GetSetting("Window.State");
if (left == null || top == null || width == null || height == null) return null;
if (!double.TryParse(left, out var l) || !double.TryParse(top, out var t) ||
!double.TryParse(width, out var w) || !double.TryParse(height, out var h))
return null;
return (l, t, w, h, state ?? "Normal");
}
public void SaveWindowState(double left, double top, double width, double height, string state)
{
UpsertSetting("Window.Left", left.ToString("F0"));
UpsertSetting("Window.Top", top.ToString("F0"));
UpsertSetting("Window.Width", width.ToString("F0"));
UpsertSetting("Window.Height", height.ToString("F0"));
UpsertSetting("Window.State", state);
}
private string? GetSetting(string key)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = $key;";
cmd.Parameters.AddWithValue("$key", key);
return cmd.ExecuteScalar() as string;
}
private void UpsertSetting(string key, string value)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
cmd.Parameters.AddWithValue("$key", key);
cmd.ExecuteNonQuery();
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ($key, $value);";
insert.Parameters.AddWithValue("$key", key);
insert.Parameters.AddWithValue("$value", value);
insert.ExecuteNonQuery();
}
internal const string AlertDefaultVideoKey = "AlertDefaultVideoAssetId";
/// <summary>Stamps the shipped stream-alert placeholder clip (TASK 47): like any
/// source image it lives as a BLOB in the internal Asset table, tracked by this
/// Setting so the Save pruner keeps it alive. The creator's OWN replacement
/// videos are never stored — they are plain path references (the 2026-09-26
/// decision: the file the user picks stays at its real location on disk).
/// <c>UpsertAsset</c> is hash-keyed, so re-stamping the same bytes returns the
/// same id.</summary>
public string EnsureAlertDefaultVideo(byte[] data)
{
var saved = GetSetting(AlertDefaultVideoKey);
if (!string.IsNullOrWhiteSpace(saved) && GetAssetBytes(saved) != null)
return saved;
var id = UpsertAsset(data, 0, 0);
UpsertSetting(AlertDefaultVideoKey, id);
return id;
}
/// <summary>The stored default clip's Asset id, or null when never stamped.</summary>
public string? GetAlertDefaultVideoId() => GetSetting(AlertDefaultVideoKey);
}
+90
View File
@@ -0,0 +1,90 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
// ─── YPP journey-tracker snapshots (TASK 39) ───
/// <summary>Appends one YPP stats snapshot (append-only history; velocity/ETA
/// read it in a later analytics slice).</summary>
public void SaveYppSnapshot(YppStatSnapshot snapshot)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO YppSnapshot (CapturedAtUtc, SubscriberCount, VideoCount, TotalViews,
UploadsLast90Days, CommunityGuidelinesGoodStanding,
CopyrightStrikesGoodStanding, ContentIdClaimsGoodStanding,
OverallGoodStanding)
VALUES ($captured, $subs, $videos, $views, $uploads, $cg, $cs, $cic, $overall);
""";
cmd.Parameters.AddWithValue("$captured", snapshot.CapturedAtUtc.ToString("o"));
cmd.Parameters.AddWithValue("$subs", snapshot.SubscriberCount);
cmd.Parameters.AddWithValue("$videos", snapshot.VideoCount);
cmd.Parameters.AddWithValue("$views", snapshot.TotalViews);
cmd.Parameters.AddWithValue("$uploads", snapshot.UploadsLast90Days);
cmd.Parameters.AddWithValue("$cg", snapshot.CommunityGuidelinesGoodStanding ? 1 : 0);
cmd.Parameters.AddWithValue("$cs", snapshot.CopyrightStrikesGoodStanding ? 1 : 0);
cmd.Parameters.AddWithValue("$cic", snapshot.ContentIdClaimsGoodStanding ? 1 : 0);
cmd.Parameters.AddWithValue("$overall", snapshot.OverallGoodStanding ? 1 : 0);
cmd.ExecuteNonQuery();
}
/// <summary>Most recent snapshots, newest first (defaults to the full history).</summary>
public List<YppStatSnapshot> LoadYppSnapshots(int limit = 365)
{
var result = new List<YppStatSnapshot>();
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT CapturedAtUtc, SubscriberCount, VideoCount, TotalViews, UploadsLast90Days,
CommunityGuidelinesGoodStanding, CopyrightStrikesGoodStanding,
ContentIdClaimsGoodStanding, OverallGoodStanding
FROM YppSnapshot ORDER BY CapturedAtUtc DESC LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$limit", limit);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
result.Add(new YppStatSnapshot
{
CapturedAtUtc = DateTime.TryParse(reader.GetString(0), out var captured) ? captured : DateTime.MinValue,
SubscriberCount = reader.GetInt64(1),
VideoCount = reader.GetInt64(2),
TotalViews = reader.GetInt64(3),
UploadsLast90Days = reader.GetInt32(4),
CommunityGuidelinesGoodStanding = reader.GetInt32(5) != 0,
CopyrightStrikesGoodStanding = reader.GetInt32(6) != 0,
ContentIdClaimsGoodStanding = reader.GetInt32(7) != 0,
OverallGoodStanding = reader.GetInt32(8) != 0,
});
}
return result;
}
/// <summary>The manually self-reported YPP checklist state (2FA / AdSense are
/// not readable from any YouTube API — the creator ticks them in-app).</summary>
public (bool TwoFactorEnabled, bool AdSenseLinked) LoadYppChecklist()
{
return (GetSetting("Ypp.TwoFactorEnabled") == "1", GetSetting("Ypp.AdSenseLinked") == "1");
}
public void SaveYppChecklist(bool twoFactorEnabled, bool adsenseLinked)
{
UpsertSetting("Ypp.TwoFactorEnabled", twoFactorEnabled ? "1" : "0");
UpsertSetting("Ypp.AdSenseLinked", adsenseLinked ? "1" : "0");
}
/// <summary>UTC timestamp of the last successful stats refresh (used to gate
/// the once-daily auto-refresh against the analytics quota-thin API).</summary>
public DateTime? LoadYppLastRefreshedAtUtc()
{
var value = GetSetting("Ypp.LastRefreshedAtUtc");
return DateTime.TryParse(value, out var dt) ? dt : null;
}
public void SaveYppLastRefreshedAtUtc(DateTime utc)
{
UpsertSetting("Ypp.LastRefreshedAtUtc", utc.ToString("o"));
}
}
+1 -1356
View File
File diff suppressed because it is too large Load Diff
+153 -8
View File
@@ -86,6 +86,20 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
}
}
/// <summary>
/// Ordered reader-output-subtype candidates for a camera whose live media type
/// is <paramref name="activeSubtype"/>. Bgra8 is the fast path (OnFrameArrived
/// skips conversion) and works for uncompressed cameras; an MJPG-active source
/// cannot be read as Bgra8 by a MediaFrameReader (no BGRA converter on the MJPG
/// pipeline — StartAsync answers OutputFormatNotSupported), so MJPG leads with
/// NV12 and falls back to the reader's default (null). Every candidate that is
/// not Bgra8 is converted in OnFrameArrived.
/// </summary>
internal static string?[] ReaderSubtypeCandidates(string? activeSubtype) =>
string.Equals(activeSubtype, "MJPG", StringComparison.OrdinalIgnoreCase)
? new string?[] { MediaEncodingSubtypes.Nv12, null }
: new string?[] { MediaEncodingSubtypes.Bgra8, MediaEncodingSubtypes.Nv12, null };
private async Task<bool> TryStartAsync(bool preferVideoRecord)
{
MediaCapture? capture = null;
@@ -102,6 +116,43 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
};
await capture.InitializeAsync(settings);
// MediaFrameReader defaults to the camera's FIRST media type — on UVC webcams
// (Logitech C920 et al.) that is often YUY2 at a crippled rate (720p5!), and a
// real-time 60fps container then shows a 5-20fps face cam as "every few frames
// cut out". OBS's mechanism, available via VideoDeviceController (VideoProfile
// is NOT projected in the 19041 SDK): negotiate the stream property. Pick the
// best MJPEG >=640x360 @>=30fps (largest, fastest), set it BEFORE the frame
// reader is created, fall back to device default on refusal, and log the live
// media type so the next diagnosis starts from facts.
try
{
var controller = capture.VideoDeviceController;
var candidates = controller == null
? new System.Collections.Generic.List<Windows.Media.MediaProperties.VideoEncodingProperties>()
: controller
.GetAvailableMediaStreamProperties(MediaStreamType.VideoPreview)
.OfType<Windows.Media.MediaProperties.VideoEncodingProperties>()
.Where(v => string.Equals(v.Subtype, "MJPG", StringComparison.OrdinalIgnoreCase)
&& v.Width >= 640 && v.Height >= 360
&& v.FrameRate.Denominator > 0
&& v.FrameRate.Numerator / v.FrameRate.Denominator >= 30.0)
.OrderByDescending(v => v.FrameRate.Numerator / (double)Math.Max(1, v.FrameRate.Denominator))
.ThenByDescending(v => (long)v.Width * v.Height)
.ToList();
if (candidates.Count > 0)
{
var pick = candidates
.OrderBy(v => (long)v.Width * v.Height)
.LastOrDefault(v => v.Width <= 1280) ?? candidates[0];
await controller!.SetMediaStreamPropertiesAsync(MediaStreamType.VideoPreview, pick);
}
}
catch (Exception fmtEx)
{
AppLog.Write($"MediaCaptureFrameSource: MJPG negotiation refused ({fmtEx.Message}); " +
"keeping device-default media type");
}
// Post-init resource validation: the capture must actually be bound to
// the device we asked for and must expose a live video controller.
if (!string.Equals(capture.MediaCaptureSettings?.VideoDeviceId, _deviceId,
@@ -143,22 +194,82 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
capture.Failed += OnCaptureFailed;
capture.CameraStreamStateChanged += OnCameraStreamStateChanged;
reader = await capture.CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8);
// The reader must output a subtype the source can actually produce, else
// StartAsync refuses (OutputFormatNotSupported). An uncompressed camera
// converts to Bgra8 fine (the fast path); a camera sitting in MJPG — which
// is what we find when ANOTHER app left it there and ours can't re-negotiate
// under SharedReadOnly contention — cannot be read as Bgra8 by a
// MediaFrameReader (the MJPG pipeline exposes NV12 output, not BGRA). Walk
// the candidate ladder; anything that isn't Bgra8 is converted in
// OnFrameArrived anyway.
string? activeSubtype = null;
try
{
var active = capture.VideoDeviceController?
.GetMediaStreamProperties(colorSource.Info.MediaStreamType);
if (active is Windows.Media.MediaProperties.VideoEncodingProperties vep)
{
activeSubtype = vep.Subtype;
AppLog.Write($"Camera '{_deviceId}' media type: {vep.Subtype} " +
$"{vep.Width}x{vep.Height} @ {vep.FrameRate.Numerator}/{vep.FrameRate.Denominator}fps");
}
}
catch { /* logging must never kill acquisition */ }
// Each candidate is allocated AND started under its own catch: a WinRT
// call answering an unsupported subtype can THROW (E_INVALIDARG / "value
// does not fall within the expected range") instead of returning a start
// status, so every throw must degrade to the next candidate — never let
// one rejected format abort the whole acquisition. Individual rejections
// are logged and collected; only the last-resort net below (the outer
// try/catch of this method) keeps cowboying device-level errors.
var refused = new List<string>();
foreach (var outputSubtype in ReaderSubtypeCandidates(activeSubtype))
{
var attempt = outputSubtype ?? "<default>";
try
{
reader = outputSubtype is null
? await capture.CreateFrameReaderAsync(colorSource)
: await capture.CreateFrameReaderAsync(colorSource, outputSubtype);
if (reader == null)
throw new InvalidOperationException($"Camera '{_deviceId}' created no frame reader.");
{
refused.Add($"{attempt}: no frame reader");
continue;
}
reader.FrameArrived += OnFrameArrived;
var status = await reader.StartAsync();
if (status != MediaFrameReaderStartStatus.Success)
throw new InvalidOperationException(
$"Camera '{_deviceId}' frame reader refused to start: {status}.");
if (status == MediaFrameReaderStartStatus.Success)
{
_capture = capture;
_frameReader = reader;
_lastError = null;
_isFailed = false;
return true;
}
reader.FrameArrived -= OnFrameArrived;
reader.Dispose();
reader = null;
refused.Add($"{attempt}: {status}");
}
catch (Exception ex)
{
AppLog.Write($"MediaCaptureFrameSource: reader as '{attempt}' refused: {ex.Message}");
if (reader != null)
{
reader.FrameArrived -= OnFrameArrived;
reader.Dispose();
reader = null;
}
refused.Add($"{attempt}: {ex.Message}");
}
}
throw new InvalidOperationException(
$"Camera '{_deviceId}' frame reader refused to start: {string.Join(", ", refused)}.");
}
catch (Exception ex)
{
_lastError = ex.Message;
@@ -209,6 +320,35 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
}
}
// Buffer recycling (same lesson as the screen capture): a fresh ~3.7MB byte[]
// per device frame at 30-60fps is 110-220MB/s of LOH churn — gen2 pauses land
// on the frame pump as 35-40ms render spikes. An 8-deep ring (take-14 rule:
// depth × source period must exceed the worst consumer hold), rotated on the
// single frame-reader thread, with per-frame Epoch so the identity-keyed paste
// cache can never serve a recycled array as unchanged.
private readonly byte[]?[] _frameRing = new byte[8][];
private int _ringNext;
private long _epoch;
private byte[] RentRingBuffer(int size)
{
for (var tries = 0; tries < _frameRing.Length; tries++)
{
var idx = (_ringNext + tries) % _frameRing.Length;
var buf = _frameRing[idx];
if (buf != null && buf.Length == size)
{
_ringNext = (idx + 1) % _frameRing.Length;
return buf;
}
}
var slot = _ringNext;
_ringNext = (slot + 1) % _frameRing.Length;
var fresh = new byte[size];
_frameRing[slot] = fresh;
return fresh;
}
private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
{
using var frame = sender.TryAcquireLatestFrame();
@@ -225,9 +365,14 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
using var reference = buffer.CreateReference();
if (WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var pixelsPtr, out var capacity))
{
var pixels = new byte[capacity];
var pixels = RentRingBuffer((int)capacity);
Marshal.Copy(pixelsPtr, pixels, 0, (int)capacity);
FrameAvailable?.Invoke(new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels));
// MediaCapture video carries no alpha plane — the OS fills 255.
var outFrame = new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels)
{ IsOpaque = true, Epoch = ++_epoch };
FrameAvailable?.Invoke(outFrame);
}
}
catch (Exception ex)
+147
View File
@@ -0,0 +1,147 @@
using System.Diagnostics;
using System.IO;
using ytLive.Services.Encoder;
namespace ytLive.Services;
/// <summary>
/// TASK 21: decodes a local media file (any format ffmpeg handles) into
/// <see cref="VideoFrame"/>s for a MediaSource. Implements
/// <see cref="IMediaFrameSource"/>: raises <see cref="FrameAvailable"/> per
/// frame and <see cref="IMediaFrameSource.Completed"/> on natural EOF (unless
/// <see cref="IMediaFrameSource.Looping"/>). Spawns ffmpeg with
/// <c>-f rawvideo -pix_fmt bgra</c> and drains the raw BGRA pipe via
/// <see cref="RawVideoFrameReader"/>. The decoder process and emitters are seams
/// so tests run without a real codec or audio device.
/// </summary>
public sealed class MediaVideoSource : IMediaFrameSource
{
public event Action<VideoFrame>? FrameAvailable;
public event Action? Completed;
public string Key => _path;
public bool Looping { get; set; }
private readonly string _path;
private readonly int _width;
private readonly int _height;
private readonly IFfmpegLocator _locator;
private readonly Func<IDecodeProcess> _processFactory;
private readonly IFrameRateProbe? _probe;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly CancellationTokenSource _cts = new();
private IDisposable? _run;
private IDecodeProcess? _active;
public MediaVideoSource(
string path,
int width,
int height,
IFfmpegLocator locator,
Func<IDecodeProcess> processFactory,
IFrameRateProbe? frameRateProbe = null,
Func<TimeSpan, CancellationToken, Task>? delay = null)
{
_path = path;
_width = width;
_height = height;
_locator = locator;
_processFactory = processFactory;
_probe = frameRateProbe;
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
}
public Task StartAsync(CancellationToken cancellationToken = default)
{
if (_run != null) return Task.CompletedTask;
_run = _cts.Token.Register(StopInternal);
_ = Task.Run(RunAsync, CancellationToken.None);
return Task.CompletedTask;
}
public Task StopAsync()
{
_cts.Cancel();
_run = null;
return Task.CompletedTask;
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
_active?.Dispose();
}
private void StopInternal() => KillProcess();
private async Task RunAsync()
{
try
{
double? frameRate = null;
if (_probe != null)
frameRate = await _probe.ProbeAsync(_path, _cts.Token).ConfigureAwait(false);
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
var ffmpegPath = await _locator.LocateAsync(_cts.Token).ConfigureAwait(false);
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
do
{
using (var process = _processFactory())
{
_active = process;
process.Start(startInfo);
var reader = new RawVideoFrameReader(_width, _height);
var buffer = new byte[65536];
var stream = process.StandardOutput;
while (!_cts.IsCancellationRequested && !process.HasExited)
{
var read = await stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token).ConfigureAwait(false);
if (read <= 0) break;
foreach (var frame in reader.Feed(buffer.AsSpan(0, read).ToArray()))
{
FrameAvailable?.Invoke(frame);
if (frameDuration > TimeSpan.Zero)
await _delay(frameDuration, _cts.Token).ConfigureAwait(false);
}
}
}
_active = null;
}
while (Looping && !_cts.IsCancellationRequested);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
Fail($"Media decode failed: {ex.Message}");
return;
}
if (!_cts.IsCancellationRequested)
Completed?.Invoke();
}
private void Fail(string message) => Console.Error.WriteLine(message);
private void KillProcess()
{
try { _active?.Kill(); } catch { /* already exited */ }
}
}
+256
View File
@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Helpers;
namespace ytLive.Services;
/// <summary>
/// TASK 21: owns media-file decode sessions app-wide, refcounted by file path.
/// One file = at most one decoder, one shared WriteableBitmap; the last release
/// stops and disposes the source. Frames arrive on a worker thread and are
/// coalesced onto the UI dispatcher (at most one pending copy per session, using
/// the latest frame). Mirrors ScreenCaptureManager/CameraManager.
/// </summary>
public sealed class MediaVideoSourceManager : IDisposable
{
private sealed class MediaSession
{
public string Key { get; }
public IMediaFrameSource Source { get; }
public Action<VideoFrame>? FrameHandler;
public int RefCount;
public bool Started;
public WriteableBitmap? PreviewBitmap;
public VideoFrame? LatestFrame;
public bool FramePending;
public MediaSession(string key, IMediaFrameSource source)
{
Key = key;
Source = source;
RefCount = 1;
}
}
private readonly Func<string, IMediaFrameSource?> _sourceFactory;
private readonly Dispatcher? _uiDispatcher;
private readonly Dictionary<string, MediaSession> _sessions = new();
private readonly object _gate = new();
/// <summary>Raised on the UI thread when a media file's shared preview bitmap is first created.</summary>
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
/// <summary>Raised when a media decoder cannot be created (bad path).</summary>
public event Action<string, string>? MediaFailed;
public MediaVideoSourceManager(Func<string, IMediaFrameSource?> sourceFactory, Dispatcher? uiDispatcher = null)
{
_sourceFactory = sourceFactory;
_uiDispatcher = uiDispatcher;
}
/// <summary>Increments the refcount for a file, starting the decoder the first time.</summary>
public async Task<bool> AcquireAsync(string key)
{
if (string.IsNullOrWhiteSpace(key)) return false;
MediaSession session;
bool shouldStart;
lock (_gate)
{
if (_sessions.TryGetValue(key, out var existing))
{
existing.RefCount++;
shouldStart = false;
session = existing;
}
else
{
IMediaFrameSource? source;
try
{
source = _sourceFactory(key);
}
catch (Exception ex)
{
AppLog.Write($"MediaSource: creating decoder '{key}' failed: {ex.Message}");
MediaFailed?.Invoke(key, ex.Message);
return false;
}
if (source == null)
{
MediaFailed?.Invoke(key, "No decoder for this file");
return false;
}
session = new MediaSession(key, source);
session.FrameHandler = frame => OnFrameAvailable(session, frame);
session.Source.FrameAvailable += session.FrameHandler;
_sessions[key] = session;
shouldStart = true;
}
}
if (!shouldStart) return session.Started;
try
{
await session.Source.StartAsync();
session.Started = true;
return true;
}
catch (Exception ex)
{
lock (_gate)
_sessions.Remove(key);
session.Source.FrameAvailable -= session.FrameHandler;
AppLog.Write($"MediaSource: failed to start decoder '{key}': {ex.Message}");
MediaFailed?.Invoke(key, ex.Message);
await SafeStopAsync(session.Source);
return false;
}
}
public async Task ReleaseAsync(string key)
{
MediaSession? toStop = null;
lock (_gate)
{
if (!_sessions.TryGetValue(key, out var session)) return;
if (--session.RefCount > 0) return;
_sessions.Remove(key);
toStop = session;
}
if (toStop == null) return;
toStop.Source.FrameAvailable -= toStop.FrameHandler;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
/// <summary>Releases a file unconditionally (every ref) — used on layout reloads.</summary>
public async Task ReleaseAllAsync(string key)
{
MediaSession? toStop = null;
lock (_gate)
{
if (!_sessions.TryGetValue(key, out var session)) return;
session.RefCount = 0;
_sessions.Remove(key);
toStop = session;
}
if (toStop == null) return;
toStop.Source.FrameAvailable -= toStop.FrameHandler;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
/// <summary>The most recent decoded frame for a file, or null before the first
/// frame (or if the file has no session). The live compositor reads media from here.</summary>
public VideoFrame? GetLatestFrame(string key)
{
lock (_gate)
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
}
/// <summary>The shared preview bitmap for a file (so a config created after the
/// first frame can adopt the running bitmap), or null.</summary>
public WriteableBitmap? GetPreviewBitmap(string key)
{
lock (_gate)
return _sessions.TryGetValue(key, out var session) ? session.PreviewBitmap : null;
}
public void Dispose()
{
List<MediaSession> sessions;
lock (_gate)
{
sessions = new List<MediaSession>(_sessions.Values);
_sessions.Clear();
}
foreach (var session in sessions)
{
session.Source.FrameAvailable -= session.FrameHandler;
_ = SafeStopAsync(session.Source);
}
}
private static async Task SafeStopAsync(IMediaFrameSource source)
{
try
{
await source.StopAsync();
}
catch (Exception ex)
{
AppLog.Write($"MediaSource: stopping decoder failed: {ex.Message}");
}
}
private bool TryGetActiveSession(MediaSession session)
{
lock (_gate)
return _sessions.TryGetValue(session.Key, out var current) && ReferenceEquals(current, session);
}
private void OnFrameAvailable(MediaSession session, VideoFrame frame)
{
if (!TryGetActiveSession(session)) return;
session.LatestFrame = frame;
if (session.PreviewBitmap == null)
{
if (_uiDispatcher == null) return;
if (_uiDispatcher.CheckAccess())
EnsurePreviewBitmap(session);
else
_uiDispatcher.BeginInvoke(() =>
{
if (TryGetActiveSession(session))
EnsurePreviewBitmap(session);
}, DispatcherPriority.Render);
return;
}
if (_uiDispatcher == null || _uiDispatcher.CheckAccess())
{
CopyFrame(session);
return;
}
if (session.FramePending) return;
session.FramePending = true;
_uiDispatcher.BeginInvoke(() =>
{
session.FramePending = false;
if (TryGetActiveSession(session) && session.PreviewBitmap != null)
CopyFrame(session);
}, DispatcherPriority.Render);
}
private void EnsurePreviewBitmap(MediaSession session)
{
if (session.PreviewBitmap != null || session.LatestFrame == null) return;
var frame = session.LatestFrame;
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);
session.PreviewBitmap = bitmap;
PreviewBitmapChanged?.Invoke(session.Key, bitmap);
}
private void CopyFrame(MediaSession session)
{
var bitmap = session.PreviewBitmap;
var frame = session.LatestFrame;
if (bitmap == null || frame == null) return;
if (frame.Width != bitmap.PixelWidth || frame.Height != bitmap.PixelHeight) return;
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
}
}
+30
View File
@@ -0,0 +1,30 @@
using System;
using System.Threading;
namespace ytLive.Services;
/// <summary>
/// An atomic "publish only if strictly newer" gate for the capture's latest-wins
/// hand-out. Screen capture conversions may now overlap (slice 17), so two
/// conversions can finish briefly out of order; without a monotonic gate the slower
/// (older) completion would overwrite the faster (newer) one's LatestFrame and the
/// compositor would render STALE content — a time hole in the other direction.
/// Sequence must be a strictly increasing per-source counter
/// (<c>Interlocked.Increment</c> in <c>ScreenCaptureFrameSource</c>).
/// </summary>
internal sealed class MonotonicGate
{
private long _last;
/// <summary>Returns true iff <paramref name="seq"/> is strictly newer than every
/// seq accepted so far (and records it).</summary>
public bool TryPublish(long seq)
{
while (true)
{
var current = Interlocked.Read(ref _last);
if (seq <= current) return false;
if (Interlocked.CompareExchange(ref _last, seq, current) == current) return true;
}
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace ytLive.Services;
/// <summary>
/// TASK 21: parses a stream of tightly-packed raw BGRA frames into
/// <see cref="VideoFrame"/>s. The consumer of an ffmpeg <c>-f rawvideo -pix_fmt
/// bgra</c> stdout pipe: every frame is exactly <c>width*height*4</c> bytes, so a
/// frame is emitted whenever that many bytes have accumulated. Pure and fully
/// deterministic, so it is tested with a synthetic byte stream — no ffmpeg, no
/// process, no audio device needed.
/// </summary>
public sealed class RawVideoFrameReader
{
private readonly int _frameBytes;
private readonly int _width;
private readonly int _height;
private byte[]? _pending;
public RawVideoFrameReader(int width, int height)
{
_width = width;
_height = height;
_frameBytes = width * height * 4;
}
/// <summary>Feeds <paramref name="bytes"/> read from the pipe and returns any
/// whole frames reconstructed so far. Partial/pending bytes are kept for the
/// next call — frames never split or merge across reads.</summary>
public List<VideoFrame> Feed(byte[] bytes)
{
var pending = _pending;
byte[] combined;
if (pending != null && pending.Length > 0)
{
combined = new byte[pending.Length + bytes.Length];
Buffer.BlockCopy(pending, 0, combined, 0, pending.Length);
Buffer.BlockCopy(bytes, 0, combined, pending.Length, bytes.Length);
_pending = null;
}
else
{
combined = bytes;
}
var frames = new List<VideoFrame>();
var offset = 0;
while (combined.Length - offset >= _frameBytes)
{
var frame = new byte[_frameBytes];
Buffer.BlockCopy(combined, offset, frame, 0, _frameBytes);
frames.Add(new VideoFrame(_width, _height, frame));
offset += _frameBytes;
}
if (offset < combined.Length)
_pending = combined[offset..];
return frames;
}
}
+159
View File
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using ytLive.Models;
using ytLive.Services.Compositor;
namespace ytLive.Services;
/// <summary>
/// The scene graph (TASK 31): single owner of the scene collection and the
/// element inventory / mutation surface, plus the static/dynamic classification
/// queries the compositor optimization depends on.
///
/// Scenes are referenced (not owned): the same <see cref="Scene"/> instances live
/// on the ViewModel's bindable surface; SceneGraph adds/removes/reorders their
/// <see cref="Scene.Elements"/> and answers queries (background / webcam / chat /
/// split point) that used to be scattered LINQ across MainViewModel.
///
/// Classification: <see cref="ElementKind.Static"/> (images, background art) is
/// baked once; <see cref="ElementKind.Dynamic"/> (webcam, live capture, chat, web)
/// is composited per frame. The <b>split point</b> is the index of the first
/// dynamic element — everything below it forms the baked base.
/// </summary>
public sealed class SceneGraph
{
private readonly Dictionary<string, SceneRegion> _regionCache = new(StringComparer.Ordinal);
private readonly object _gate = new();
/// <summary>The shared scene collection (same instances the ViewModel binds).</summary>
public ObservableCollection<Scene> Scenes { get; } = new();
/// <summary>The one Background row for a scene — null when the scene has none.</summary>
public Source? GetBackground(Scene scene)
=> scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
/// <summary>This scene's webcam usage, if any.</summary>
public WebcamSceneConfig? GetWebcam(Scene scene)
=> scene.Elements.OfType<WebcamSceneConfig>().FirstOrDefault();
/// <summary>All chat-box elements across every scene (for ChatOverlayLayer).</summary>
public IEnumerable<Source> GetChatBoxes()
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.Type == SourceType.ChatBox));
/// <summary>Index of the first dynamic element (the split point). If no dynamic
/// element exists this equals <see cref="Scene.Elements"/> count — fully baked.</summary>
public int GetSplitPoint(Scene scene)
{
var elements = scene.Elements;
for (var i = 0; i < elements.Count; i++)
{
if (elements[i].Kind == ElementKind.Dynamic)
return i;
}
return elements.Count;
}
/// <summary>True when the whole scene is static (no dynamic element) — the
/// compositor can bake it once and skip per-frame compositing.</summary>
public bool IsStatic(Scene scene)
=> scene.Elements.All(e => e.Kind == ElementKind.Static);
/// <summary>Invalidate the cached bake region for a scene after any mutation
/// (element add/remove/reorder, static resize/opacity/visibility, asset change).</summary>
public void InvalidateBake(Scene scene)
{
lock (_gate) _regionCache.Remove(scene.Id);
}
// ─── Mutation surface (single owner of element operations) ───
/// <summary>Append an element to a scene and (re)bake accordingly.</summary>
public void AddElement(Scene scene, SceneElement element)
{
scene.Elements.Add(element);
InvalidateBake(scene);
}
/// <summary>Insert an element at a specific z-index and (re)bake accordingly.</summary>
public void InsertElement(Scene scene, int index, SceneElement element)
{
scene.Elements.Insert(index, element);
InvalidateBake(scene);
}
/// <summary>Remove an element and (re)bake accordingly.</summary>
public bool RemoveElement(Scene scene, SceneElement element)
{
var removed = scene.Elements.Remove(element);
if (removed) InvalidateBake(scene);
return removed;
}
/// <summary>Reorder an element in z-space and (re)bake accordingly.</summary>
public void MoveElement(Scene scene, SceneElement element, int newIndex)
{
var current = scene.Elements.IndexOf(element);
if (current < 0) return;
scene.Elements.Move(current, newIndex);
InvalidateBake(scene);
}
/// <summary>Add a scene to the collection (wiring is handled by the ViewModel).</summary>
public void AddScene(Scene scene) => Scenes.Add(scene);
/// <summary>Get (or bake) the cached static base for a scene. Null means "no static
/// base" (first layer is dynamic or the scene is empty) — caller falls back to full render.</summary>
public VideoFrame? GetBakedBase(
Scene scene,
Func<SceneElement, VideoFrame?> frameFor,
Func<CompositorOptions> options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? flashFrame = null)
{
var split = GetSplitPoint(scene);
if (split == 0) return null;
lock (_gate)
{
if (_regionCache.TryGetValue(scene.Id, out var cached)
&& cached.Matches(scene, split))
return cached.Frame;
}
var baked = SceneCompositor.BakeStaticBase(
scene, split, frameFor, options, socialBarFrame, socialBarTop, flashFrame);
if (baked == null) return null;
lock (_gate)
{
_regionCache[scene.Id] = new SceneRegion(baked, scene.Elements.Take(split).ToList());
}
return baked;
}
/// <summary>The baked frame + the element identity it was baked from (for invalidation).</summary>
private sealed class SceneRegion
{
public VideoFrame Frame { get; }
private readonly string[] _staticElementIds;
public SceneRegion(VideoFrame frame, List<SceneElement> staticBelow)
{
Frame = frame;
_staticElementIds = staticBelow.Select(e => e.Id).ToArray();
}
/// <summary>Only static layers below the split are part of the base. Two bakes
/// match iff the same static element identities, in the same order, are below
/// the split — dynamic-only pixel/layout changes never invalidate.</summary>
public bool Matches(Scene scene, int split)
{
var below = scene.Elements.Take(split).Select(e => e.Id).ToArray();
return below.Length == _staticElementIds.Length
&& below.SequenceEqual(_staticElementIds);
}
}
}
+135 -29
View File
@@ -1,6 +1,8 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading;
using System.Threading.Tasks;
using Windows.Graphics;
using Windows.Graphics.Capture;
@@ -27,9 +29,23 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
private GraphicsCaptureSession? _session;
private SizeInt32 _poolSize;
private bool _started;
private bool _framePending;
private int _convertInFlight;
private DateTime _lastErrorLog = DateTime.MinValue;
// Buffer recycling (take-11 spikes, 2026-09-04; slice 16, 2026-09-14): a fresh
// ~8.3MB byte[] per DWM frame ≈ 500MB/s of LOH churn — the gen2 pauses it forced
// surfaced as the "worst render 35-65ms" spikes that capped fps at ~42. The pool
// is a reuse-distance ring: a buffer is only rewritten after ≥ redLine rents have
// cycled since its last hand-out (FrameRingBuffer), so a frame a consumer still
// holds — session.LatestFrame survives across conversions and the dispatcher
// preview copy lags — can never be overwritten in place (the 1742 tear, a ring
// slot rewritten under the consumer's read, handed the compositor one
// new-top/old-bottom frame). Each hand-out still carries an Epoch so identity-keyed
// consumers (the compositor's paste cache) cannot false-hit a recycled array.
private readonly FrameRingBuffer _ring = new(8, redLine: 4);
private long _epoch;
private readonly MonotonicGate _publishGate = new();
// The composition master frame (see ai.md "Resolution tiers"): the background
// is an input layer, so we never hold a CPU frame bigger than the master.
private const int MaxBackgroundWidth = 1920;
@@ -38,6 +54,28 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
// A failing conversion must not re-flood the log at frame rate.
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
// Conversion cadence (slice 16, 2026-09-14; slice 17, 2026-09-14): the monitor
// delivers at the 240Hz DWM cadence (~4.2ms) while slots are 16.6ms. A 10ms floor
// between conversion starts keeps the open edge above the ~60 conversions/s the pump
// can use. What actually bounded the desktop feed on takes 1742/1824 was the SERIAL
// GPU→CPU readback (`CreateCopyFromSurfaceAsync` ≈ 40-50ms of the ~47ms conversion
// on a 240Hz-HDR box shared with the encoder), capping captures at ~17-20/s.
// Slice 17 overlaps up to MaxConcurrentConversions readbacks (pool sized to
// accommodate in-flight frames) and publishes only monotonically newer frames
// (MonotonicGate — a slow older completion must never overwrite a newer LatestFrame).
private static readonly TimeSpan MinConvertInterval = TimeSpan.FromMilliseconds(10);
private static readonly TimeSpan TelemetryInterval = TimeSpan.FromSeconds(2);
private const int MaxConcurrentConversions = 3;
private const int PoolBufferCount = 5;
private DateTime _lastConvertAt = DateTime.MinValue;
private DateTime _telemetryFrom = DateTime.UtcNow;
private DateTime _lastTelemetry = DateTime.UtcNow;
private long _skippedCadence;
private long _skippedBusy;
private long _conversions;
private long _convertMsTotal;
private long _convertMsMax;
public string Key { get; }
public event Action<VideoFrame>? FrameAvailable;
@@ -74,7 +112,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
var item = _item;
var device = Direct3D11Helper.CreateDevice();
var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size);
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, PoolBufferCount, item.Size);
_poolSize = item.Size;
var session = framePool.CreateCaptureSession(item);
framePool.FrameArrived += OnFrameArrived;
@@ -91,7 +129,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
lock (_gate)
{
_started = false;
_framePending = false;
_convertInFlight = 0;
if (_framePool != null)
_framePool.FrameArrived -= OnFrameArrived;
_session?.Dispose();
@@ -106,6 +144,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
{
var frame = sender.TryGetNextFrame();
if (frame == null) return;
lock (_gate)
{
if (!_started)
@@ -113,33 +152,54 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
frame.Dispose();
return;
}
}
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
{
sender.Recreate(Direct3D11Helper.CreateDevice(),
DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, frame.ContentSize);
DirectXPixelFormat.B8G8R8A8UIntNormalized, PoolBufferCount, frame.ContentSize);
_poolSize = frame.ContentSize;
}
if (_framePending)
// Up to MaxConcurrentConversions readbacks in flight (slice 17), spaced by
// MinConvertInterval (slice 16): the 240Hz delivery otherwise queued one
// conversion every ~4.2ms, and the serial ~47ms readback pinned the desktop
// layer to ~17 updates/s on the 1824 take.
if (_convertInFlight >= MaxConcurrentConversions)
{
_skippedBusy++;
frame.Dispose();
return;
}
_framePending = true;
var now = DateTime.UtcNow;
if (now - _lastConvertAt < MinConvertInterval)
{
_skippedCadence++;
frame.Dispose();
return;
}
_lastConvertAt = now;
_convertInFlight++;
}
_ = ProcessFrameAsync(frame);
}
private async Task ProcessFrameAsync(Direct3D11CaptureFrame frame)
{
var sw = Stopwatch.StartNew();
try
{
using (frame)
using (var softwareBitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(
frame.Surface, BitmapAlphaMode.Ignore))
{
FrameAvailable?.Invoke(CopyToVideoFrame(softwareBitmap));
// Monotonic sequencing across the overlapping conversions (slice 17): a
// completed readback is published only if strictly newer than the last
// one published — a slow older completion must never overwrite a newer
// LatestFrame (that would be a time hole in the other direction).
var epoch = Interlocked.Increment(ref _epoch);
var videoFrame = CopyToVideoFrame(softwareBitmap, epoch);
if (_publishGate.TryPublish(epoch))
FrameAvailable?.Invoke(videoFrame);
}
}
catch (Exception ex)
@@ -153,11 +213,41 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
}
finally
{
_framePending = false;
lock (_gate)
{
EmitTelemetry(sw.ElapsedMilliseconds);
_convertInFlight--;
}
}
}
private static VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap)
private void EmitTelemetry(long conversionMs)
{
_conversions++;
_convertMsTotal += conversionMs;
if (conversionMs > _convertMsMax) _convertMsMax = conversionMs;
var now = DateTime.UtcNow;
if (now - _lastTelemetry < TelemetryInterval) return;
var span = now - _telemetryFrom;
var perSecond = span.TotalSeconds > 0 ? _conversions / span.TotalSeconds : 0;
AppLog.Write(
$"ScreenCapture telemetry [{Key}]: {_conversions} frames in {span.TotalSeconds:F1}s " +
$"({perSecond:F0}/s), conv avg {( _conversions == 0 ? 0 : _convertMsTotal / _conversions )}ms " +
$"max {_convertMsMax}ms, skip busy {_skippedBusy} cadence {_skippedCadence}, " +
$"ring allocs {_ring.ConsumeAllocations()}");
_lastTelemetry = now;
_telemetryFrom = now;
_conversions = 0;
_convertMsTotal = 0;
_convertMsMax = 0;
_skippedBusy = 0;
_skippedCadence = 0;
}
private VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap, long epoch)
{
var sw = bitmap.PixelWidth;
var sh = bitmap.PixelHeight;
@@ -177,45 +267,61 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
var scale = Math.Min(MaxBackgroundWidth / (double)sw, MaxBackgroundHeight / (double)sh);
var dw = Math.Max(1, (int)(sw * scale));
var dh = Math.Max(1, (int)(sh * scale));
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh));
// DWM delivers an opaque surface (alpha 255); bilinear keeps it 255.
var scaled = _ring.Rent(dw * dh * 4);
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh, scaled))
{ IsOpaque = true, Epoch = epoch };
}
var pixels = new byte[count];
var pixels = _ring.Rent(count);
Marshal.Copy(data, pixels, 0, pixels.Length);
return new VideoFrame(sw, sh, pixels);
return new VideoFrame(sw, sh, pixels) { IsOpaque = true, Epoch = epoch };
}
// Bilinear downscale to the master frame. Reads each source row pair through
// Marshal.Copy (no unsafe), writing tightly packed BGRA output.
private static byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh)
// Integer 8.8 fixed-point bilinear downscale to the master frame (slice 16,
// 2026-09-14: the two-stage math from SceneCompositor.Bilinear — the same "shift
// only at the end" rule from MyMistakes item on fixed-point blending). The previous
// double-per-pixel version ran ~30-45ms per 2.5K→1080p frame on a quiet desktop
// and the 1742 take's stall measured ~150ms/frame under load (90%-frozen desktop,
// ~6 fresh frames/s); integer math keeps the same bilinear result within ±1 while
// dropping the cost to the row-pair Marshal.Copy. Rows are read once per source
// row pair through Marshal.Copy (no unsafe), writing tightly packed BGRA output.
private byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh, byte[] dst)
{
// Row scratch is per-conversion (overlapping conversions since slice 17 each
// bring their own — two 10KB arrays, no shared state). Size: a 2560-wide row
// pair, the largest the monitor path delivers before the downscale.
var row0 = new byte[srcStride];
var row1 = new byte[srcStride];
var dst = new byte[dw * dh * 4];
var xs = sw / (double)dw;
var ys = sh / (double)dh;
for (var y = 0; y < dh; y++)
{
var sy = Math.Min(sh - 1, (int)(y * ys));
var sy8 = (int)((long)y * sh * 256 / dh);
var sy = sy8 >> 8;
var sy1 = Math.Min(sh - 1, sy + 1);
var fy = (y * ys) - sy;
var fy8 = sy8 & 255;
var fyInv = 256 - fy8;
Marshal.Copy(IntPtr.Add(src, sy * srcStride), row0, 0, srcStride);
Marshal.Copy(IntPtr.Add(src, sy1 * srcStride), row1, 0, srcStride);
var dRow = y * dw * 4;
for (var x = 0; x < dw; x++)
{
var sx = Math.Min(sw - 1, (int)(x * xs));
var sx8 = (int)((long)x * sw * 256 / dw);
var sx = Math.Min(sw - 1, sx8 >> 8);
var sx1 = Math.Min(sw - 1, sx + 1);
var fx = (x * xs) - sx;
for (var c = 0; c < 4; c++)
var fx8 = sx8 & 255;
var fxInv = 256 - fx8;
var i0 = sx * 4;
var i1 = sx1 * 4;
var j = dRow + x * 4;
for (var c = 0; c < 4; c++, i0++, i1++, j++)
{
var i0 = sx * 4 + c;
var i1 = sx1 * 4 + c;
var top = row0[i0] + (row0[i1] - row0[i0]) * fx;
var bottom = row1[i0] + (row1[i1] - row1[i0]) * fx;
dst[dRow + x * 4 + c] = (byte)(top + (bottom - top) * fy);
// Two-stage in one 16.8 scale: top/bot ≤ 65280, ×256 + round ≤ 33.5M — int-safe.
var top = row0[i0] * fxInv + row0[i1] * fx8;
var bot = row1[i0] * fxInv + row1[i1] * fx8;
dst[j] = (byte)((top * fyInv + bot * fy8 + 32768) >> 16);
}
}
}
+5 -1
View File
@@ -38,7 +38,11 @@ public sealed class ScreenCaptureSourceFactory
if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase))
{
var hexText = key.AsSpan("window:".Length);
// Keys are written as "window:0x<Hwnd:X>" (CaptureInterop/WindowInfo),
// but NumberStyles.HexNumber does not accept the "0x" prefix — strip it.
var hexText = key.AsSpan("window:".Length).TrimStart();
if (hexText.Length >= 2 && hexText[0] == '0' && (hexText[1] == 'x' || hexText[1] == 'X'))
hexText = hexText[2..];
if (long.TryParse(hexText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hwnd))
return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd));
return null;
+237
View File
@@ -0,0 +1,237 @@
using System;
using System.Threading.Tasks;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// TASK 41 — the Test Stream pull-out (the "TEST" tab at the bottom of the Live
/// screen's right rail). Drives a private test broadcast's chat tooling:
/// Mock Chat Input sends a REAL liveChat/messages.insert (round-tripping through
/// the normal ~2s poll and the live overlay path), and the simulated-event buttons
/// inject synthetic member / SuperChat / subscriber rows through the exact
/// MessageReceived seam the real poll uses — local-only, never sent to YouTube
/// (the insert API only creates text). The broadcast itself runs through the real
/// private go-live pipeline; this VM only owns the dock.
/// </summary>
public sealed class TestSessionViewModel : ViewModelBase
{
private readonly YouTubeStreamService _stream;
private readonly YouTubeChatService _chat;
private readonly Action? _onEndStream;
private bool _isDrawerOpen;
private bool _isTestSession;
private string? _broadcastId;
private string? _liveChatId;
private string _sendMessageText = string.Empty;
private bool _isSending;
private string? _lastEventText;
private int _eventCounter;
/// <summary>Fake viewer names for simulated events — several backs make the
/// rows look like a real muted room.</summary>
private static readonly string[] SimulatedViewers = { "PixelPasta", "NightskyNick", "LunaGlow", "CactusFox" };
public TestSessionViewModel(
YouTubeStreamService stream,
YouTubeChatService chat,
Action? onEndStream = null)
{
_stream = stream;
_chat = chat;
_onEndStream = onEndStream;
SendTestMessageCommand = new RelayCommand(_ => _ = SendTestMessageAsync(), _ => CanSendTestMessage);
SimulateSubscriberCommand = new RelayCommand(_ => Simulate(SimulatedSubscriber()));
SimulateMemberCommand = new RelayCommand(_ => Simulate(SimulatedMember()));
SimulateSuperChatCommand = new RelayCommand(_ => Simulate(SimulatedSuperChat()));
EndTestCommand = new RelayCommand(_ => _onEndStream?.Invoke());
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
}
public ICommand SendTestMessageCommand { get; }
public RelayCommand SimulateSubscriberCommand { get; }
public RelayCommand SimulateMemberCommand { get; }
public RelayCommand SimulateSuperChatCommand { get; }
public RelayCommand EndTestCommand { get; }
public RelayCommand ToggleDrawerCommand { get; }
public RelayCommand CloseDrawerCommand { get; }
public bool IsDrawerOpen
{
get => _isDrawerOpen;
set => SetProperty(ref _isDrawerOpen, value);
}
/// <summary>Whether a test broadcast is currently live (ended by BeginTest /
/// EndTestSilently; EndTestCommand asks the app to stop, which routes back here).</summary>
public bool IsTestSession
{
get => _isTestSession;
private set
{
if (SetProperty(ref _isTestSession, value))
{
OnPropertyChanged(nameof(SessionCaption));
OnPropertyChanged(nameof(ChatIdLine));
OnPropertyChanged(nameof(CanSendTestMessage));
}
}
}
public string? BroadcastId => _broadcastId;
public string ChatIdLine => _liveChatId == null ? "liveChatId: —" : $"liveChatId: {_liveChatId}";
public string SessionCaption => IsTestSession
? "Test stream is live — private to you, zero notifications, real chat."
: "No test in progress — hit the Test button in the top bar to start a private broadcast and check your chat overlay.";
public string SendMessageText
{
get => _sendMessageText;
set
{
if (SetProperty(ref _sendMessageText, value))
OnPropertyChanged(nameof(CanSendTestMessage));
}
}
public bool IsSending
{
get => _isSending;
private set
{
if (SetProperty(ref _isSending, value))
OnPropertyChanged(nameof(CanSendTestMessage));
}
}
public bool CanSendTestMessage => IsTestSession && !IsSending && _liveChatId != null
&& !string.IsNullOrWhiteSpace(SendMessageText);
/// <summary>Feedback line for the last send / simulated event (null = nothing yet).</summary>
public string? LastEventText
{
get => _lastEventText;
private set => SetProperty(ref _lastEventText, value);
}
/// <summary>Called once by MainViewModel when the test broadcast is live and its
/// liveChatId resolved — opens the dock so the chat tools are immediately usable.</summary>
public void BeginTest(string broadcastId, string? liveChatId)
{
_broadcastId = broadcastId;
_liveChatId = string.IsNullOrWhiteSpace(liveChatId) ? null : liveChatId;
SendMessageText = string.Empty;
LastEventText = _liveChatId == null
? "Chat polling couldn't start — Mock Chat Input is disabled."
: null;
IsTestSession = true;
IsDrawerOpen = true;
}
/// <summary>Called by MainViewModel's stop path when the broadcast ends — the
/// dock forgets the ended session (idempotent).</summary>
public void EndTestSilently()
{
_broadcastId = null;
_liveChatId = null;
IsTestSession = false;
IsSending = false;
}
/// <summary>Mock Chat Input: real liveChat/messages.insert. The inserted text
/// arrives through the normal poll and renders via the live overlay path — the
/// whole point is that the round-trip is the real one.</summary>
public async Task SendTestMessageAsync()
{
if (!CanSendTestMessage || _liveChatId == null) return;
var text = SendMessageText.Trim();
IsSending = true;
SendMessageText = string.Empty;
LastEventText = null;
try
{
var error = await _stream.InsertChatMessageAsync(_liveChatId, text);
LastEventText = error == null
? $"Sent — “{text}” should appear on the chat overlay within a couple of seconds."
: $"Couldn't send: {error}";
}
catch (Exception ex)
{
AppLog.Write($"Test chat message send failed: {ex.Message}");
LastEventText = "Couldn't send that message — try again.";
}
finally
{
IsSending = false;
}
}
private void Simulate(ChatMessage message)
{
if (!IsTestSession) return;
_chat.InjectSimulatedMessage(message);
_eventCounter++;
LastEventText = $"Fired #{_eventCounter}: {Describe(message)} — renders on any visible chat overlay.";
AppLog.Write($"Test chat simulation {_eventCounter} injected");
}
/// <summary>Subscriptions aren't chat events on YouTube, so a "subscriber" row
/// is a plain text-shaped message carrying the count — honest: the chat overlay
/// shows it as a regular row, which is exactly what a sub looks like in chat.
/// It carries <see cref="ChatEventKind.None"/> on purpose: no alert animation
/// fires for free subs — the chat mention is the announcement (creator ruling).</summary>
private ChatMessage SimulatedSubscriber()
{
var author = NextViewer();
return NewSimulated(author, "New subscriber — welcome to the herd! 🦙 (simulated subscribe)");
}
private ChatMessage SimulatedMember()
{
var author = NextViewer();
var message = NewSimulated(author, $"🎉 {author} became a member!");
message.IsMember = true;
message.MembershipLevel = "New member";
message.Kind = ChatEventKind.NewMember;
return message;
}
private ChatMessage SimulatedSuperChat()
{
var author = NextViewer();
var message = NewSimulated(author, "Keep the streams flowing, llama power 🦙");
message.IsSuperChat = true;
message.SuperChatAmount = 5.00;
message.SuperChatCurrency = "$";
message.SuperChatDisplayString = "$5.00";
message.Kind = ChatEventKind.SuperChat;
return message;
}
private ChatMessage NewSimulated(string author, string text)
{
return new ChatMessage
{
AuthorName = author,
Message = text,
Timestamp = DateTime.Now,
IsSimulated = true,
};
}
private string NextViewer() => SimulatedViewers[_eventCounter % SimulatedViewers.Length];
private static string Describe(ChatMessage m)
{
if (m.IsSuperChat) return $"{m.AuthorName} pledged {m.SuperChatCurrency}{m.SuperChatAmount:0.00} in a Super Chat";
if (m.IsMember) return $"{m.AuthorName} just became a member ({m.MembershipLevel})";
return $"{m.AuthorName} subscribed";
}
}
+37
View File
@@ -13,6 +13,43 @@ public sealed class VideoFrame
public byte[] BgraPixels { get; }
public int Stride => Width * 4;
/// <summary>Producer frame epoch — a monotonically increasing number stamped by
/// sources that RECYCLE their pixel buffers (the screen-capture ring, take-11 spike
/// fix). Consumers that key caches on buffer identity (the compositor's paste cache)
/// MUST include it, or a recycled array false-hits with stale content. Producers
/// that hand out fresh arrays per frame leave it 0 — identity alone is then enough.</summary>
public long Epoch { get; init; }
/// <summary>Producer contract: every alpha byte in <see cref="BgraPixels"/> is 255
/// (DWM capture surfaces and MediaCapture video carry no alpha — the OS fills 255).
/// Lets the compositor take a straight-copy fast path for a full-canvas opaque
/// layer instead of 2M per-pixel blends. NEVER set it for paths whose pixels can
/// be transparent (static art, web overlays, chat, the social-bar strip).</summary>
public bool IsOpaque { get; init; }
/// <summary>WebSource crop metadata: the alpha bounding box of the widget content
/// within the full canvas (X/Y/W/H in pixels). When set, the compositor uses these
/// bounds for Fill-style scaling (stretch to cover, no aspect preservation) instead of
/// UniformToFill. The transparent margins of the full canvas are preserved so they
/// reveal layers beneath — the crop defines the actual content region.</summary>
public (int X, int Y, int W, int H)? CropBounds { get; init; }
/// <summary>Where a SMALLER-than-canvas overlay composites, in master pixels
/// (creator ruling 2026-09-26: the alert ticker draws INSIDE the Stream Alerts video
/// box, not as a full-width strip pinned to the top edge). <c>null</c> — the default
/// — means the canvas origin, which is what every full-canvas overlay wants.
/// <para>This is deliberately NOT a full-canvas frame: at 30–60fps a 1920×1080
/// overlay is 8.3MB of large-object-heap garbage per tick, and a 10s alert would
/// churn ~2.5GB. A box-sized strip is ~370KB, so the position travels on the frame
/// instead of in a 1920-wide transparent margin.</para></summary>
public (int X, int Y)? Placement { get; init; }
/// <summary>Composite origin X: <see cref="Placement"/> if set, else 0.</summary>
public int OriginX => Placement?.X ?? 0;
/// <summary>Composite origin Y: <see cref="Placement"/> if set, else 0.</summary>
public int OriginY => Placement?.Y ?? 0;
public VideoFrame(int width, int height, byte[] bgraPixels)
{
Width = width;
+232
View File
@@ -0,0 +1,232 @@
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading.Tasks;
using Windows.Graphics;
using Windows.Graphics.Capture;
using Windows.Graphics.DirectX;
using Windows.Graphics.DirectX.Direct3D11;
using Windows.Graphics.Imaging;
using ytLive.Helpers;
namespace ytLive.Services;
/// <summary>
/// The frame-driven web capture for one widget: a GraphicsCaptureItem backed by
/// the widget's Windows.UI.Composition visual (see WebView2Manager). Owns the
/// item, a free-threaded Direct3D11CaptureFramePool and the capture session;
/// frames arrive in step with Chromium's own presentation (~60Hz on a real
/// animation), replacing the old CapturePreviewAsync PNG poll whose ~35-165ms
/// encode cost capped the recording at ~10Hz and the preview at ~5fps.
///
/// The capture surface holds a REAL alpha channel (the controller renders onto
/// a transparent composition visual), so the GPU→CPU copy uses
/// <see cref="BitmapAlphaMode.Straight"/> and the frame is
/// <see cref="VideoFrame.IsOpaque"/> = false — the compositor's straight-alpha
/// blend math ("web layer transparency", 2026-09-13) requires the readback to
/// keep premultiplication OFF. Unlike the screen source there is no master-size
/// downscale: the controller renders at the RAW 1920×1080 master canvas, so the
/// CPU never holds a larger frame.
///
/// Per-frame the alpha bounding box of the widget (<see cref="FindContentBounds"/>)
/// is measured on this capture worker thread and stamped onto the frame as
/// <see cref="VideoFrame.CropBounds"/> — same alpha-bbox contract as the old
/// PNG path, but off the UI thread (a 2M-pixel scan per frame there is exactly
/// the cost this redesign exists to remove).
/// </summary>
public sealed class WebCaptureFrameSource : IScreenCaptureSource
{
private readonly object _gate = new();
private GraphicsCaptureItem _item;
private Direct3D11CaptureFramePool? _framePool;
private GraphicsCaptureSession? _session;
private SizeInt32 _poolSize;
private bool _started;
private bool _framePending;
private DateTime _lastErrorLog = DateTime.MinValue;
// Buffer recycling: identical to the screen source's ring+Epoch contract
// (see ScreenCaptureFrameSource) — the compositor's paste cache is keyed on
// array identity, so recycled buffers must carry a monotonic Epoch or a
// reused array false-hits with stale content.
private readonly byte[]?[] _frameRing = new byte[8][];
private int _ringNext;
private long _epoch;
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
public string Key { get; }
public event Action<VideoFrame>? FrameAvailable;
internal WebCaptureFrameSource(string key, GraphicsCaptureItem item)
{
Key = key;
_item = item;
}
/// <summary>Wraps an item created from a composition visual by the web manager.</summary>
public static WebCaptureFrameSource CreateForVisual(string key, GraphicsCaptureItem item)
=> new(key, item);
public Task StartAsync()
{
lock (_gate)
{
if (_started) return Task.CompletedTask;
var item = _item;
var device = Direct3D11Helper.CreateDevice();
var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size);
_poolSize = item.Size;
var session = framePool.CreateCaptureSession(item);
framePool.FrameArrived += OnFrameArrived;
session.StartCapture();
_framePool = framePool;
_session = session;
_started = true;
}
return Task.CompletedTask;
}
public Task StopAsync()
{
lock (_gate)
{
_started = false;
_framePending = false;
if (_framePool != null)
_framePool.FrameArrived -= OnFrameArrived;
_session?.Dispose();
_session = null;
_framePool?.Dispose();
_framePool = null;
}
return Task.CompletedTask;
}
private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args)
{
var frame = sender.TryGetNextFrame();
if (frame == null) return;
lock (_gate)
{
if (!_started)
{
frame.Dispose();
return;
}
}
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
{
sender.Recreate(Direct3D11Helper.CreateDevice(),
DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, frame.ContentSize);
_poolSize = frame.ContentSize;
}
if (_framePending)
{
frame.Dispose();
return;
}
_framePending = true;
_ = ProcessFrameAsync(frame);
}
private async Task ProcessFrameAsync(Direct3D11CaptureFrame frame)
{
try
{
using (frame)
using (var softwareBitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(
frame.Surface, BitmapAlphaMode.Straight))
{
FrameAvailable?.Invoke(CopyToVideoFrame(softwareBitmap));
}
}
catch (Exception ex)
{
var now = DateTime.UtcNow;
if (now - _lastErrorLog >= ErrorLogThrottle)
{
_lastErrorLog = now;
AppLog.Write($"WebCaptureFrameSource: frame conversion failed: {ex.Message}");
}
}
finally
{
_framePending = false;
}
}
private byte[] RentRingBuffer(int size)
{
for (var tries = 0; tries < _frameRing.Length; tries++)
{
var idx = (_ringNext + tries) % _frameRing.Length;
var buf = _frameRing[idx];
if (buf is { Length: var len } && len == size)
{
_ringNext = (idx + 1) % _frameRing.Length;
return buf;
}
}
var slot = _ringNext;
_ringNext = (slot + 1) % _frameRing.Length;
var fresh = new byte[size];
_frameRing[slot] = fresh;
return fresh;
}
private VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap)
{
var sw = bitmap.PixelWidth;
var sh = bitmap.PixelHeight;
using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
using var reference = buffer.CreateReference();
if (!WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var data, out var capacity))
throw new InvalidOperationException("Could not access the frame buffer.");
var srcStride = sw * 4;
var count = (int)Math.Min(capacity, (uint)(sh * srcStride));
var pixels = RentRingBuffer(count);
Marshal.Copy(data, pixels, 0, pixels.Length);
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, sw, sh, srcStride);
return new VideoFrame(sw, sh, pixels)
{
IsOpaque = false,
Epoch = ++_epoch,
CropBounds = (cropX, cropY, cropW, cropH),
};
}
// The widget's extent is measured from the captured frame itself: the
// bounding box of non-transparent pixels (the background is injected
// transparent, so the alpha channel IS the widget). Immune to DOM timing
// (fonts/images/animations), needs no JS round-trip, and CANNOT break the
// render: a widget that fills the canvas gets the full frame; a widget that
// floats in transparent margin gets exactly its content box — used for the
// preview bitmap / selection. The frame handed to the compositor is always
// the FULL canvas, so the page's transparent margins keep revealing the
// layers beneath (the "black square over webcam" was the crop fed to the
// compositor: UniformToFill zoomed the opaque content box over the element).
internal static (int X, int Y, int W, int H) FindContentBounds(byte[] pixels, int pixW, int pixH, int stride)
{
int minX = pixW, minY = pixH, maxX = -1, maxY = -1;
for (var y = 0; y < pixH; y++)
{
var row = y * stride;
for (var x = 0; x < pixW; x++)
{
if (pixels[row + x * 4 + 3] == 0) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (maxX < minX || maxY < minY) return (0, 0, pixW, pixH);
return (minX, minY, maxX - minX + 1, maxY - minY + 1);
}
}
+369 -158
View File
@@ -1,86 +1,93 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Text.Json;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using Windows.Graphics.Capture;
using Windows.System;
using Windows.UI.Composition;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Owns the composition-based web sources (one per widget). A session renders
/// its widget through a <see cref="CoreWebView2CompositionController"/> whose
/// visual tree lives off-screen on the WPF UI thread (OBS web-source model:
/// the page renders at the MASTER CANVAS size 1920×1080, stable, never tracked);
/// the controller's root visual is captured by a <see cref="WebCaptureFrameSource"/>
/// at Chromium's own presentation cadence. The preview is crop-copied to a shared
/// WriteableBitmap on the UI thread (dispatcher-coalesced latest-wins, mirror of
/// ScreenCaptureManager); the FramePump reads <see cref="GetLatestFrame"/>.
///
/// The composition stack needs a DispatcherQueue on this thread (CsWinRT does not
/// project <c>CreateOnCurrentThread</c> on the 19041 SDK — only the dedicated
/// variant — so it is installed via a CoreMessaging P/Invoke, the documented
/// fallback the WebView2CompositionControl / webview_windows embedders use).
/// Everything composition-side therefore runs on the WPF UI thread; only the
/// frame conversion and alpha-bbox scan run on the capture worker.
///
/// Seam: an internal <c>Func&lt;string, IScreenCaptureSource&gt;</c> source
/// factory substitutes the whole WebView2+item creation in tests — the manager
/// then owns the session/coalesce/crop/preview plumbing exactly as in production.
/// </summary>
public sealed class WebView2Manager : IDisposable
{
private readonly Panel _hostPanel;
private readonly Dispatcher _dispatcher;
private readonly Func<string, IScreenCaptureSource>? _sessionSourceFactory;
private readonly Dictionary<string, WebSourceSession> _sessions = new();
private Task<CoreWebView2Environment>? _environmentTask;
private DispatcherQueueController? _dispatcherQueueController;
private Windows.UI.Composition.Compositor? _compositor;
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
private sealed class WebSourceSession
{
public required WebView2 Control { get; init; }
public required DispatcherTimer CaptureTimer { get; init; }
public required string Key { get; init; }
public CoreWebView2? Web;
public CoreWebView2CompositionController? Controller;
public IScreenCaptureSource? Source;
public Action<VideoFrame>? FrameHandler;
public VideoFrame? LatestFrame;
public WriteableBitmap? PreviewBitmap;
// Buffer reuse (2026-09-04 lesson): the preview crop copy reuses one
// scratch array while the widget's extent holds — no per-frame LOH mint.
public byte[]? CropScratch;
public bool Disposed;
public bool Initialized;
public bool FramePending;
public bool FirstCaptureLogged;
public int WidgetLogsRemaining;
}
public WebView2Manager(Panel hostPanel, Dispatcher dispatcher)
public WebView2Manager(Dispatcher dispatcher)
: this(dispatcher, null)
{
}
/// <summary>Test seam: the source factory replaces WebView2+composition
/// creation, so the session/coalesce/crop/preview plumbing is testable
/// without the runtime.</summary>
internal WebView2Manager(Dispatcher dispatcher, Func<string, IScreenCaptureSource>? sessionSourceFactory)
{
_hostPanel = hostPanel;
_dispatcher = dispatcher;
_sessionSourceFactory = sessionSourceFactory;
}
public void Register(Source source)
{
if (_sessions.ContainsKey(source.Id)) return;
// OBS model: the page renders at the MASTER CANVAS size (1920×1080),
// stable, never tracked. Full-bleed widgets (designed for the canvas)
// render fully instead of being clipped to a small box-sized viewport;
// dragging the box never reflows the page. The capture is tightly
// cropped to the widget's ALPHA bounding box (FindContentBounds) and
// the display (Stretch=Fill) maps it flush under the box, anchored at
// (0,0) — no DOM query, so no timing fragility; worst case is the
// full frame, which is the confirmed-good rendering.
var webView = new WebView2
{
Width = 1920,
Height = 1080,
DefaultBackgroundColor = System.Drawing.Color.Transparent,
Visibility = System.Windows.Visibility.Visible,
};
Canvas.SetLeft(webView, -5000);
Canvas.SetTop(webView, -5000);
_hostPanel.Children.Add(webView);
var timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(100),
};
var session = new WebSourceSession
{
Control = webView,
CaptureTimer = timer,
};
_sessions[source.Id] = session;
timer.Tick += (_, _) =>
{
if (!session.Disposed && session.Initialized)
_ = CaptureFrame(source.Id);
};
_ = InitializeAsync(source, session);
_sessions[source.Id] = new WebSourceSession { Key = source.Id };
_ = InitializeAsync(source, _sessions[source.Id]);
}
public void Unregister(string sourceId) => RemoveSession(sourceId);
@@ -93,7 +100,7 @@ public sealed class WebView2Manager : IDisposable
if (string.IsNullOrEmpty(source.WebUri))
{
session.LatestFrame = null;
session.Control.CoreWebView2?.NavigateToString(
session.Web?.NavigateToString(
"<html><body style='background:transparent;margin:0'></body></html>");
return;
}
@@ -106,42 +113,116 @@ public sealed class WebView2Manager : IDisposable
return _sessions.TryGetValue(sourceId, out var session) ? session.LatestFrame : null;
}
// Injected BEFORE the document is parsed (AddScriptToExecuteOnDocumentCreatedAsync) so
// the widget page's own CSS/scripts cannot repaint html/body opaque — the reason the
// late NavigationStarting/NavigationCompleted ExecuteScriptAsync injection lost the
// fight (page styles ran first). A transparent capture REQUIRES the transparency in
// place before the page paints, on the composition surface too.
//
// The OLD inline `element.style.background='transparent'` form lost to any page CSS:
// a widget that paints a background-COLOR on a container (or html/body with stronger
// specificity) after connect re-opaques the capture → the "black opaque box" in the
// recording while the early frames were transparent. This is a solved problem in the
// overlay ecosystem — OBS browser sources use a Custom CSS override and the decade-
// validated formula for arbitrary pages is a `!important` background-color wipe
// (OBS Forums 2016 "body { background-color: rgba(0,0,0,0) !important }",
// https://obsproject.com/forum/threads/translucent-transparent-browser-source.59549/,
// and the div-level variant for stubborn widgets, woahtech.com OBS custom-CSS guide).
// A `<style>` node injected before parse, with `!important`, outranks every page rule
// (only an author !important beats a later document-order !important; ours runs first).
// Only background-COLOR was wiped at first — the 15:34 take showed the widget's own
// full-canvas backdrop surviving as the box's black void: that paint is a CSS
// background-IMAGE (gradient/backdrop), and the OBS fix for the same symptom is
// `background: none !important` / background-image none, not color alone
// (obsproject/obs-studio#6659 — "set the CSS for html and body to background: none
// !important"). Wipe background-image too; HTML overlay art (the widget's content) is
// <img>/DOM/CSS shapes and survives — only container decorations are removed, which is
// OBS-browser-source semantics for a transparent overlay.
internal const string TransparentBackgroundScript =
"(function(){" +
"if(document.getElementById('ytl-transparent-bg'))return;" +
"var s=document.createElement('style');" +
"s.id='ytl-transparent-bg';" +
"s.appendChild(document.createTextNode(" +
"'html,body{background-color:transparent!important;background-image:none!important;margin:0!important;overflow:hidden!important;}'" +
"'html,body,html *{background-color:transparent!important;background-image:none!important;}'" +
"));" +
"(document.head||document.documentElement).appendChild(s);" +
"})();";
private async Task InitializeAsync(Source source, WebSourceSession session)
{
try
{
await session.Control.EnsureCoreWebView2Async();
var cws = session.Control.CoreWebView2!;
cws.NavigationStarting += (_, _) =>
IScreenCaptureSource sourceImpl;
if (_sessionSourceFactory != null)
{
_ = cws.ExecuteScriptAsync(
"document.documentElement.style.background='transparent';" +
"document.documentElement.style.overflow='hidden';" +
"document.body.style.background='transparent';" +
"document.body.style.overflow='hidden';");
// Test path: the factory hands over a fully-configured source; the
// branch has no awaits, so Register() completes the session inline.
sourceImpl = _sessionSourceFactory(source.Id);
}
else
{
var controller = await CreateControllerAsync();
if (controller == null)
{
RemoveSession(source.Id);
return;
}
session.Controller = controller;
session.Web = controller.CoreWebView2;
if (session.Web == null)
{
RemoveSession(source.Id);
return;
}
// Pre-page hook: runs after the global object exists but BEFORE the document
// parses / any page script runs — must complete before navigating away from
// about:blank for it to apply to the widget document.
await session.Web.AddScriptToExecuteOnDocumentCreatedAsync(TransparentBackgroundScript);
session.Web.NavigationStarting += (_, _) =>
{
_ = session.Web?.ExecuteScriptAsync(TransparentBackgroundScript);
};
cws.NavigationCompleted += (_, _) =>
session.Web.NavigationCompleted += (_, e) =>
{
_ = cws.ExecuteScriptAsync(
"document.documentElement.style.background='transparent';" +
"document.documentElement.style.overflow='hidden';" +
"document.body.style.background='transparent';" +
"document.body.style.overflow='hidden';");
_ = CaptureFrame(source.Id);
_ = session.Web?.ExecuteScriptAsync(TransparentBackgroundScript);
if (e.IsSuccess && session.Web?.Source is { } src && src != "about:blank")
{
// Log-only diagnostic on the REAL widget document (a few post-paint
// frames so styles/backgrounds settle). The first-capture dump only
// ever saw about:blank; the old PNG widget dumps are gone with the
// PNG path — alpha stats now ride the frame-driven channel.
session.WidgetLogsRemaining = 3;
var rearm = session;
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(30));
if (!rearm.Disposed) rearm.WidgetLogsRemaining = 3;
});
}
};
session.Initialized = true;
session.Control.CoreWebView2.NavigateToString(
session.Web.NavigateToString(
"<html><body style='background:transparent;margin:0'></body></html>");
if (!string.IsNullOrEmpty(source.WebUri))
Navigate(source.WebUri, session);
session.CaptureTimer.Start();
session.Initialized = true;
sourceImpl = new WebCaptureFrameSource(source.Id, CreateCaptureItem(controller));
}
session.Source = sourceImpl;
session.FrameHandler = frame => OnFrameAvailable(session, frame);
sourceImpl.FrameAvailable += session.FrameHandler;
await sourceImpl.StartAsync();
session.Initialized = true;
}
catch (Exception ex)
{
@@ -150,113 +231,243 @@ public sealed class WebView2Manager : IDisposable
}
}
// The environment MUST exist before the controller is created — it's created once
// and shared by every session (one browser process). These flags (the
// Electron-class embedder answer to Chromium throttling hidden pages — the moment
// the app window is unfocused or covered, rAF parks and timers clamp to ~1s) keep
// the renderer at full rate: --disable-backgrounding-occluded-windows,
// --disable-renderer-backgrounding, and disable the native-window occlusion
// detector that marks the page hidden.
private Task<CoreWebView2Environment> GetEnvironmentAsync()
{
if (_environmentTask != null) return _environmentTask;
var options = new CoreWebView2EnvironmentOptions
{
AdditionalBrowserArguments =
"--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion",
};
// Per-instance user data folder in DEBUG: Chromium locks it exclusively, so
// without this the second dev instance of the same exe fails to start at all.
// Release passes null and WebView2 uses its default.
var userData = InstanceProfile.WebViewDataFolder;
if (userData != null) Directory.CreateDirectory(userData);
return _environmentTask = CoreWebView2Environment.CreateAsync(null, userData, options);
}
/// <summary>Creates the composition controller off-screen under the main
/// window (the parent HWND is required, the visual never mounts a window
/// target). Set up is copied from the WebView2CompositionControl /
/// webview_windows embedders: raw-pixel bounds at the master canvas,
/// fixed rasterization scale, transparent default background.</summary>
private async Task<CoreWebView2CompositionController?> CreateControllerAsync()
{
var mainWindow = Application.Current?.MainWindow;
var hwnd = mainWindow == null
? IntPtr.Zero
: new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle;
if (hwnd == IntPtr.Zero)
{
AppLog.Write("WebView2Manager: main window handle unavailable — web source not created");
return null;
}
var env = await GetEnvironmentAsync();
if (_compositor == null) _compositor = CreateCompositorForCurrentThread();
var controller = await env.CreateCoreWebView2CompositionControllerAsync(hwnd);
controller.Bounds = new Rectangle(0, 0, 1920, 1080);
controller.BoundsMode = CoreWebView2BoundsMode.UseRawPixels;
controller.ShouldDetectMonitorScaleChanges = false;
controller.RasterizationScale = 1.0;
controller.IsVisible = true;
controller.DefaultBackgroundColor = System.Drawing.Color.Transparent;
return controller;
}
private void Navigate(string url, WebSourceSession session)
{
try { session.Control.CoreWebView2?.Navigate(url); }
try { session.Web?.Navigate(url); }
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
}
// The widget's extent is measured from the captured frame itself: the
// bounding box of non-transparent pixels (the background is injected
// transparent, so the alpha channel IS the widget). This is immune to
// DOM timing (fonts/images/animations), needs no JS round-trip, and
// CANNOT break rendering: if the widget fills the canvas the box equals
// the full frame (identical to the confirmed-good uncropped display); if
// it floats in transparent margin, the crop maps it flush under the box.
internal static (int X, int Y, int W, int H) FindContentBounds(byte[] pixels, int pixW, int pixH, int stride)
// The visual tree webview_windows wires (offscreen mode): a root container
// visual sized to the master canvas (the captured subject — the Flutter
// plugin calls GraphicsCaptureItem.CreateFromVisual on this root), with the
// controller's root visual target set to a child that fills the root via
// RelativeSizeAdjustment. BoundsMode raw pixels + RasterizationScale 1.0 keep
// the capture at exactly 1920×1080 regardless of monitor scale.
private GraphicsCaptureItem CreateCaptureItem(CoreWebView2CompositionController controller)
{
int minX = pixW, minY = pixH, maxX = -1, maxY = -1;
for (var y = 0; y < pixH; y++)
var compositor = _compositor!;
var root = compositor.CreateContainerVisual();
root.Size = new System.Numerics.Vector2(1920f, 1080f);
root.IsVisible = true;
var webViewVisual = compositor.CreateContainerVisual();
webViewVisual.RelativeSizeAdjustment = new System.Numerics.Vector2(1f, 1f);
root.Children.InsertAtTop(webViewVisual);
controller.RootVisualTarget = webViewVisual;
return GraphicsCaptureItem.CreateFromVisual(root);
}
// CsWinRT does not project DispatcherQueueController.CreateOnCurrentThread
// on the 19041 SDK (only CreateOnDedicatedThread [CS0117, verified by probe]);
// the documented fallback — "how to create a DispatcherQueue on the current
// thread" (learn.microsoft.com CoreMessaging docs) — is CreateDispatcherQueueController
// from CoreMessaging.dll, wrapped via FromAbi (the CaptureInterop pattern). One
// per UI thread, then new Compositor() (which requires the DQ) works here.
private Windows.UI.Composition.Compositor CreateCompositorForCurrentThread()
{
var options = new DispatcherQueueOptions
{
DwSize = Marshal.SizeOf<DispatcherQueueOptions>(),
ThreadType = 2, // DQTYPE_THREAD_CURRENT
ApartmentType = 2, // DQTAT_COM_STA — the WPF UI thread is STA
};
var hr = CreateDispatcherQueueController(options, out var controllerPtr);
if (hr != 0)
throw Marshal.GetExceptionForHR(hr)!;
_dispatcherQueueController = DispatcherQueueController.FromAbi(controllerPtr);
return new Windows.UI.Composition.Compositor();
}
[StructLayout(LayoutKind.Sequential)]
private struct DispatcherQueueOptions
{
public int DwSize;
public int ThreadType;
public int ApartmentType;
}
[DllImport("CoreMessaging.dll")]
private static extern int CreateDispatcherQueueController(
DispatcherQueueOptions options, out IntPtr dispatcherQueueController);
private void OnFrameAvailable(WebSourceSession session, VideoFrame frame)
{
if (session.Disposed) return;
session.LatestFrame = frame;
if (_dispatcher.CheckAccess())
{
UpdatePreview(session);
return;
}
if (session.FramePending) return;
session.FramePending = true;
_dispatcher.BeginInvoke(() =>
{
session.FramePending = false;
if (!session.Disposed)
UpdatePreview(session);
}, DispatcherPriority.Render);
}
private void UpdatePreview(WebSourceSession session)
{
var frame = session.LatestFrame;
if (frame == null) return;
var (cropX, cropY, cropW, cropH) = frame.CropBounds ?? (0, 0, frame.Width, frame.Height);
if (cropW < 1 || cropH < 1)
{
cropX = 0; cropY = 0;
cropW = frame.Width; cropH = frame.Height;
}
// The preview is sized to the widget's alpha bounding box (the compositor
// gets the FULL canvas + CropBounds; the crop is only for the preview).
// A fresh box (widget resizes / first content paints) mints a new bitmap
// once — WPF re-renders a reused mutable bitmap after WritePixels, so
// later frames only re-copy into the same one.
if (session.PreviewBitmap == null
|| session.PreviewBitmap.PixelWidth != cropW
|| session.PreviewBitmap.PixelHeight != cropH)
{
session.PreviewBitmap = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
PreviewBitmapChanged?.Invoke(session.Key, session.PreviewBitmap);
}
var need = cropW * cropH * 4;
var scratch = session.CropScratch is { } s && s.Length >= need
? s
: (session.CropScratch = new byte[need]);
var stride = frame.Stride;
if (cropW == frame.Width && cropH == frame.Height && cropX == 0 && cropY == 0)
Buffer.BlockCopy(frame.BgraPixels, 0, scratch, 0, need);
else
for (var r = 0; r < cropH; r++)
Buffer.BlockCopy(frame.BgraPixels, (cropY + r) * stride + cropX * 4, scratch, r * cropW * 4, cropW * 4);
session.PreviewBitmap.WritePixels(new Int32Rect(0, 0, cropW, cropH), scratch, cropW * 4, 0);
// alpha[min/max/mean/zero%] — the one number that decides whether a widget's
// capture really has transparent margins (0% = opaque, ~100% if the page
// painted transparent-only). Sampled every 16th pixel like the original log.
if (!session.FirstCaptureLogged)
{
session.FirstCaptureLogged = true;
AppLog.Write(
$"WebView2Manager: first capture for '{session.Key}' {frame.Width}x{frame.Height} " +
$"{AlphaStats(frame.BgraPixels, frame.Width, frame.Height, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
}
if (session.WidgetLogsRemaining > 0)
{
var dumpNo = 3 - session.WidgetLogsRemaining + 1;
session.WidgetLogsRemaining--;
AppLog.Write(
$"WebView2Manager: widget capture [{dumpNo}/3] for '{session.Key}' {frame.Width}x{frame.Height} " +
$"{AlphaStats(frame.BgraPixels, frame.Width, frame.Height, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
}
}
private static string AlphaStats(byte[] pixels, int pixW, int pixH, int stride)
{
int minA = 255, maxA = 0;
long sumA = 0, nA = 0, zeroA = 0;
for (var y = 0; y < pixH; y += 16)
{
var row = y * stride;
for (var x = 0; x < pixW; x++)
for (var x = 0; x < pixW; x += 16)
{
if (pixels[row + x * 4 + 3] == 0) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
var a = pixels[row + x * 4 + 3];
if (a < minA) minA = a;
if (a > maxA) maxA = a;
sumA += a;
if (a == 0) zeroA++;
nA++;
}
}
if (maxX < minX || maxY < minY) return (0, 0, pixW, pixH);
return (minX, minY, maxX - minX + 1, maxY - minY + 1);
}
private async Task CaptureFrame(string sourceId)
{
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
try
{
var webView = session.Control;
if (webView.CoreWebView2 == null) return;
using var ms = new MemoryStream();
await webView.CoreWebView2.CapturePreviewAsync(
CoreWebView2CapturePreviewImageFormat.Png, ms);
ms.Position = 0;
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.StreamSource = ms;
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
var formatted = new FormatConvertedBitmap(bitmap,
PixelFormats.Bgra32, null, 0);
var pixW = formatted.PixelWidth;
var pixH = formatted.PixelHeight;
if (pixW < 1 || pixH < 1) return;
var pixels = new byte[pixW * pixH * 4];
formatted.CopyPixels(pixels, pixW * 4, 0);
var stride = pixW * 4;
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride);
var outPixels = pixels;
if (cropW != pixW || cropH != pixH || cropX != 0 || cropY != 0)
{
var dstStride = cropW * 4;
outPixels = new byte[dstStride * cropH];
for (var r = 0; r < cropH; r++)
Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * dstStride, dstStride);
}
var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
wb.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0);
wb.Freeze();
session.LatestFrame = new VideoFrame(cropW, cropH, outPixels);
_ = _dispatcher.BeginInvoke(() =>
{
PreviewBitmapChanged?.Invoke(sourceId, wb);
});
}
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { }
catch (Exception ex)
{
AppLog.Write($"WebView2Manager: capture failed for '{sourceId}': {ex.Message}");
}
return $"alpha[min={minA},max={maxA},mean={sumA / (double)nA:F1},zero={100.0 * zeroA / nA:F1}%]";
}
private void RemoveSession(string sourceId)
{
if (!_sessions.TryGetValue(sourceId, out var session)) return;
session.Disposed = true;
session.CaptureTimer.Stop();
_hostPanel.Children.Remove(session.Control);
try { session.Control.Dispose(); } catch { }
var src = session.Source;
if (src != null)
{
if (session.FrameHandler != null)
src.FrameAvailable -= session.FrameHandler;
_ = SafeStopAsync(src);
}
try { session.Controller?.Close(); } catch { }
session.Web = null;
_sessions.Remove(sourceId);
}
private static async Task SafeStopAsync(IScreenCaptureSource source)
{
try { await source.StopAsync(); }
catch (Exception ex) { AppLog.Write($"WebView2Manager: stopping web capture failed: {ex.Message}"); }
}
public void Dispose()
{
foreach (var id in _sessions.Keys.ToList())
+122
View File
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Win32 implementation of <see cref="IWindowEnumerator"/>. Enumerates visible
/// top-level windows for the "Capture Window…" submenu, mirroring the style of
/// <see cref="Win32FullScreenDetector"/>: visible, non-cloaked, zero-rect
/// excluded, and windows belonging to this process excluded (the app must never
/// offer itself for pinning). Ordered by process name, then title.
/// </summary>
public sealed class Win32WindowEnumerator : IWindowEnumerator
{
private static readonly IntPtr OwnProcessId = new(Environment.ProcessId);
public IReadOnlyList<WindowInfo> Enumerate()
{
var results = new List<WindowInfo>();
EnumWindows((hwnd, _) =>
{
if (TryDescribe(hwnd, out var info))
results.Add(info);
return true; // keep enumerating
}, IntPtr.Zero);
results.Sort(static (a, b) =>
{
var byProcess = string.Compare(a.ProcessName, b.ProcessName, StringComparison.OrdinalIgnoreCase);
return byProcess != 0 ? byProcess
: string.Compare(a.Title, b.Title, StringComparison.OrdinalIgnoreCase);
});
return results;
}
private static bool TryDescribe(IntPtr hwnd, out WindowInfo info)
{
info = null!;
if (GetWindowThreadProcessId(hwnd, out var pid) == 0) return false;
if (pid == 0 || new IntPtr((long)pid) == OwnProcessId) return false;
if (!IsWindowVisible(hwnd)) return false;
if (IsCloaked(hwnd)) return false;
if (!TryGetRect(hwnd, out var rect) || rect.Right <= rect.Left || rect.Bottom <= rect.Top) return false;
var title = GetTitle(hwnd);
if (string.IsNullOrWhiteSpace(title)) return false;
var processName = ProcessName((int)pid);
if (string.IsNullOrWhiteSpace(processName)) return false;
info = new WindowInfo(title, processName, hwnd.ToInt64(), (int)pid);
return true;
}
private static string ProcessName(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
return process.ProcessName;
}
catch { return string.Empty; }
}
private static string GetTitle(IntPtr hwnd)
{
var length = GetWindowTextLength(hwnd);
if (length <= 0) return string.Empty;
var sb = new StringBuilder(length + 1);
GetWindowText(hwnd, sb, sb.Capacity);
return sb.ToString();
}
private static bool IsCloaked(IntPtr hwnd)
{
const int DWMWA_CLOAKED = 14;
var cloaked = 0;
return DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, out cloaked, sizeof(int)) == 0 && cloaked != 0;
}
private static bool TryGetRect(IntPtr hwnd, out Rect rect)
{
rect = default;
return GetWindowRect(hwnd, out rect);
}
[StructLayout(LayoutKind.Sequential)]
private struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hWnd, out Rect lpRect);
[DllImport("dwmapi.dll")]
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out int pvAttribute, int cbAttribute);
}
+158 -31
View File
@@ -6,16 +6,22 @@ namespace ytLive.Services;
/// <summary>
/// Polls YouTube Live Chat API for messages.
/// YouTube doesn't have WebSocket for chat — polling is the only option.
/// YouTube doesn't have WebSocket for chat — polling is the only option. The
/// cadence follows the server's <c>pollingIntervalMillis</c> (the streamList
/// connection semantics: reconnect with nextPageToken on that cadence, never
/// faster than the refresh rate — YouTube rejects requests sent too quickly).
/// The chat feed IS YouTube's event feed: Super Chats, stickers, memberships,
/// gifts and milestones arrive here as typed messages (see <see cref="ChatEventKind"/>).
/// </summary>
public class YouTubeChatService : IDisposable
{
private readonly YouTubeAuthService _auth;
private readonly HttpClient _http = new();
private readonly HttpClient _http;
private Timer? _pollTimer;
private string? _nextPageToken;
private string? _liveChatId;
private bool _isRunning;
private int _intervalMs = 2000;
public event Action<ChatMessage>? MessageReceived;
@@ -24,9 +30,10 @@ public class YouTubeChatService : IDisposable
PropertyNameCaseInsensitive = true
};
public YouTubeChatService(YouTubeAuthService auth)
public YouTubeChatService(YouTubeAuthService auth, HttpClient? http = null)
{
_auth = auth;
_http = http ?? new HttpClient();
}
public void Start(string liveChatId, int pollIntervalMs = 2000)
@@ -34,7 +41,9 @@ public class YouTubeChatService : IDisposable
_liveChatId = liveChatId;
_isRunning = true;
_nextPageToken = null;
_pollTimer = new(async _ => await Poll(), null, 0, pollIntervalMs);
_intervalMs = Math.Clamp(pollIntervalMs, 1000, 6000);
_pollTimer = new Timer(async _ => await Poll(), null, Timeout.Infinite, Timeout.Infinite);
_pollTimer.Change(0, Timeout.Infinite);
}
public void Stop()
@@ -44,16 +53,24 @@ public class YouTubeChatService : IDisposable
_pollTimer = null;
}
/// <summary>TASK 41: injects a synthetic message through the exact seam the real
/// poll uses (MessageReceived), so a simulated member / SuperChat / subscriber
/// event renders through the whole overlay pipeline. YouTube's insert API only
/// creates text messages — these events are local-only by design and never
/// reach YouTube (marked <see cref="ChatMessage.IsSimulated"/>).</summary>
public void InjectSimulatedMessage(ChatMessage message)
=> MessageReceived?.Invoke(message);
private async Task Poll()
{
try
{
if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return;
try
{
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
await _auth.RefreshToken();
var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=50";
var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=2000";
if (_nextPageToken != null)
url += $"&pageToken={_nextPageToken}";
@@ -62,41 +79,90 @@ public class YouTubeChatService : IDisposable
if (!response.IsSuccessStatusCode) return;
var json = await response.Content.ReadAsStringAsync();
var page = ParsePage(json);
if (page.NextPageToken != null)
_nextPageToken = page.NextPageToken;
if (page.PollIntervalMs > 0 && page.PollIntervalMs != _intervalMs)
_intervalMs = page.PollIntervalMs;
foreach (var message in page.Messages)
MessageReceived?.Invoke(message);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Chat poll error: {ex.Message}");
}
finally
{
// Re-arm the one-shot poll on the server's cadence. Guarded by
// _isRunning so a concurrent Stop() (dispose + null) can't race a
// Change() back onto the disposed timer.
if (_isRunning)
_pollTimer?.Change(_intervalMs, Timeout.Infinite);
}
}
/// <summary>One liveChat/messages page → typed chat messages + pagination +
/// the server's poll cadence. Internal + separated from the HTTP so the six
/// event parses are deterministically testable without any network.</summary>
internal static ChatPage ParsePage(string json)
{
var data = JsonSerializer.Deserialize<JsonElement>(json);
var messages = new List<ChatMessage>();
if (data.TryGetProperty("nextPageToken", out var token))
_nextPageToken = token.GetString();
var nextPageToken = data.TryGetProperty("nextPageToken", out var token)
? token.GetString()
: null;
if (!data.TryGetProperty("items", out var items)) return;
var intervalMs = data.TryGetProperty("pollingIntervalMillis", out var interval)
&& interval.ValueKind == JsonValueKind.Number
? interval.GetInt32()
: 0;
if (!data.TryGetProperty("items", out var items)) return new ChatPage(messages, nextPageToken, intervalMs);
foreach (var item in items.EnumerateArray())
{
var snippet = item.GetProperty("snippet");
var author = item.GetProperty("authorDetails");
var type = snippet.GetProperty("type").GetString() ?? "textMessageEvent";
var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString()
: "textMessageEvent";
var message = new ChatMessage
{
Id = item.GetProperty("id").GetString()!,
AuthorName = author.GetProperty("displayName").GetString()!,
AuthorChannelId = author.GetProperty("channelId").GetString()!,
AuthorImageUrl = author.GetProperty("profileImageUrl").GetString()!,
Timestamp = DateTime.Parse(snippet.GetProperty("publishedAt").GetString()!),
IsMember = author.GetProperty("isChatSponsor").GetBoolean(),
MembershipLevel = author.TryGetProperty("badgeText", out var badgeText)
? badgeText.GetString()
: null
Id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? Guid.NewGuid().ToString() : Guid.NewGuid().ToString(),
Timestamp = DateTime.UtcNow,
};
if (item.TryGetProperty("authorDetails", out var author))
{
message.AuthorName = author.TryGetProperty("displayName", out var dn) ? dn.GetString() ?? string.Empty : string.Empty;
message.AuthorChannelId = author.TryGetProperty("channelId", out var cid) ? cid.GetString() ?? string.Empty : string.Empty;
message.AuthorImageUrl = author.TryGetProperty("profileImageUrl", out var im) ? im.GetString() ?? string.Empty : string.Empty;
message.IsMember = author.TryGetProperty("isChatSponsor", out var sp) ? sp.GetBoolean() : false;
message.MembershipLevel = author.TryGetProperty("badgeText", out var badge)
? badge.GetString()
: null;
}
if (snippet.TryGetProperty("publishedAt", out var published)
&& DateTime.TryParse(published.GetString(), out var publishedAt))
message.Timestamp = publishedAt;
switch (type)
{
case "superChatEvent":
message.IsSuperChat = true;
message.Kind = ChatEventKind.SuperChat;
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
{
message.SuperChatAmount = scDetails.TryGetProperty("amountMicros", out var amt)
? amt.GetDouble() / 1_000_000.0
: 0;
if (scDetails.TryGetProperty("displayString", out var display)
&& display.ValueKind == JsonValueKind.String)
message.SuperChatDisplayString = display.GetString();
if (scDetails.TryGetProperty("amountMicros", out var amt)
&& amt.ValueKind == JsonValueKind.Number)
message.SuperChatAmount = amt.GetDouble() / 1_000_000.0;
message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur)
? cur.GetString()
: null;
@@ -106,7 +172,30 @@ public class YouTubeChatService : IDisposable
}
break;
case "superStickerEvent":
message.Kind = ChatEventKind.SuperSticker;
if (snippet.TryGetProperty("superStickerDetails", out var stickerDetails))
{
message.SuperStickerDescription = stickerDetails.TryGetProperty("stickerDescription", out var desc)
? desc.GetString()
: stickerDetails.TryGetProperty("description", out var descAlt)
? descAlt.GetString()
: null;
if (stickerDetails.TryGetProperty("displayString", out var display)
&& display.ValueKind == JsonValueKind.String)
message.SuperChatDisplayString = display.GetString();
message.SuperChatCurrency = stickerDetails.TryGetProperty("currency", out var cur)
? cur.GetString()
: null;
message.Message = stickerDetails.TryGetProperty("userComment", out var comment)
? comment.GetString() ?? string.Empty
: string.Empty;
}
break;
case "newSponsorEvent":
message.Kind = ChatEventKind.NewMember;
message.IsMember = true;
message.Message = $"🎉 {message.AuthorName} became a member!";
if (snippet.TryGetProperty("membershipDetails", out var memDetails))
{
@@ -116,7 +205,44 @@ public class YouTubeChatService : IDisposable
}
break;
default: // textMessageEvent
case "membershipGiftingEvent":
message.Kind = ChatEventKind.MemberGift;
if (snippet.TryGetProperty("membershipGiftingDetails", out var giftingDetails)
&& giftingDetails.TryGetProperty("giftNewMembersCount", out var count)
&& count.ValueKind == JsonValueKind.Number)
message.GiftNewMembersCount = count.GetInt32();
message.Message = message.GiftNewMembersCount > 0
? $"🎁 {message.AuthorName} gifted {message.GiftNewMembersCount} memberships!"
: $"🎁 {message.AuthorName} gifted memberships!";
break;
case "giftMembershipReceivedEvent":
message.Kind = ChatEventKind.GiftReceived;
if (snippet.TryGetProperty("giftMembershipReceivedDetails", out var giftReceived)
&& giftReceived.TryGetProperty("gifterName", out var gifter))
message.GifterName = gifter.GetString();
message.Message = message.GifterName != null
? $"{message.GifterName} gifted you a membership! 🎉"
: $"{message.AuthorName} received a membership gift! 🎉";
break;
case "memberMilestoneChatEvent":
message.Kind = ChatEventKind.MemberMilestone;
message.IsMember = true;
message.Message = snippet.TryGetProperty("textMessageDetails", out var msd)
? msd.TryGetProperty("messageText", out var mst)
? mst.GetString() ?? string.Empty
: string.Empty
: string.Empty;
if (message.Message.Length > 0)
{
var match = System.Text.RegularExpressions.Regex.Match(message.Message, @"(\d+)\s*month", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out var months))
message.MilestoneMonths = months;
}
break;
default: // textMessageEvent and anything else — anonymous chat rows only.
message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails)
? textDetails.TryGetProperty("messageText", out var text)
? text.GetString() ?? string.Empty
@@ -125,13 +251,10 @@ public class YouTubeChatService : IDisposable
break;
}
MessageReceived?.Invoke(message);
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Chat poll error: {ex.Message}");
messages.Add(message);
}
return new ChatPage(messages, nextPageToken, intervalMs);
}
public void Dispose()
@@ -140,3 +263,7 @@ public class YouTubeChatService : IDisposable
_http.Dispose();
}
}
/// <summary>Decoded liveChat/messages response: the messages + where to resume +
/// how long to wait before reconnecting.</summary>
internal sealed record ChatPage(List<ChatMessage> Messages, string? NextPageToken, int PollIntervalMs);
+142 -17
View File
@@ -124,6 +124,60 @@ public class YouTubeStreamService
return $"YouTube rejected the update ({(int)response.StatusCode})";
}
/// <summary>Proper close-out (TASK 9 design decision 6 — implemented 2026-09-01;
/// until then we relied entirely on enableAutoStop, leaving viewers on a frozen
/// "stream offline" screen for ~a minute): POST liveBroadcasts.transition
/// broadcastStatus=complete. MUST be called AFTER the encoder closed the RTMP
/// push so no frames post-date the end. **Pre-flight (2026-09-22):** the POST is
/// now gated on the broadcast's OWN lifeCycleStatus — every 2026-09-22 end
/// logged 403 invalidTransition because enableAutoStop/YouTube had already marked
/// the broadcast complete; a blind complete only earned the 403 + log noise. We
/// skip ONLY on a confirmed already-ended status (complete/revoked) — an
/// inconclusive check still posts (old behavior) rather than silently stranding a
/// live broadcast. The POST is never-throwing: a rare still-failing transition
/// surfaces as an error string, never a throw — the stop path must not fail over a
/// cosmetic close-out.</summary>
public async Task<string?> EndBroadcastAsync(string broadcastId)
{
if (!await EnsureToken()) return "not signed in";
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
var lifeCycle = await GetLifeCycleStatusAsync(broadcastId);
if (lifeCycle is "complete" or "revoked")
{
// Already ended (autoStop/YouTube raced us) — a blind complete only
// earns a 403 + noise. enableAutoStop finished it.
AppLog.Write($"End close-out: {broadcastId} already at lifeCycleStatus '{lifeCycle}' — skipping complete");
return null;
}
var response = await _http.PostAsync(
$"{ApiBase}/liveBroadcasts/transition?broadcastStatus=complete&id={Uri.EscapeDataString(broadcastId)}&part=status",
content: null);
if (response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}");
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
}
/// <summary>One liveBroadcasts.list (part=status) read of the broadcast's own
/// lifeCycleStatus — the only reliable "can we complete?" signal. Null when the
/// list fails or the status is missing (caller degrades gracefully).</summary>
private async Task<string?> GetLifeCycleStatusAsync(string broadcastId)
{
var response = await _http.GetAsync(
$"{ApiBase}/liveBroadcasts?part=status&id={Uri.EscapeDataString(broadcastId)}");
if (!response.IsSuccessStatusCode) return null;
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return null;
if (!items[0].TryGetProperty("status", out var status)) return null;
return status.TryGetProperty("lifeCycleStatus", out var lifeCycle)
? lifeCycle.GetString()
: null;
}
/// <summary>Returns the channel's reusable stream (TASK 5 design decision 2):
/// lists existing streams first and reuses the one with cdn.isReusable=true,
/// creating it with variable resolution/frame rate on first use. Binding to a
@@ -209,18 +263,35 @@ public class YouTubeStreamService
if (items.GetArrayLength() == 0) return null;
var status = items[0].GetProperty("status");
var health = new StreamHealth
// The real API nests health as status.healthStatus = { status, lastUpdateTimeSeconds,
// configurationIssues[] }. The flat "healthStatus":"bad" shape is our old wrong
// assumption — accept both so neither crashes the report-by-exception poll.
var health = new StreamHealth();
var issueElements = new List<JsonElement>();
if (status.TryGetProperty("healthStatus", out var healthStatus))
{
HealthStatus = status.TryGetProperty("healthStatus", out var healthStatus)
? healthStatus.GetString()
: null,
};
if (status.TryGetProperty("configurationIssues", out var issues))
if (healthStatus.ValueKind == JsonValueKind.String)
{
foreach (var issue in issues.EnumerateArray())
health.HealthStatus = healthStatus.GetString();
}
else if (healthStatus.TryGetProperty("status", out var inner) &&
inner.ValueKind == JsonValueKind.String)
{
var severity = issue.TryGetProperty("severity", out var sev) ? sev.GetString() : null;
var type = issue.TryGetProperty("type", out var t) ? t.GetString() : null;
health.HealthStatus = inner.GetString();
}
if (healthStatus.TryGetProperty("configurationIssues", out var nested))
issueElements.AddRange(nested.EnumerateArray());
}
if (status.TryGetProperty("configurationIssues", out var flat))
issueElements.AddRange(flat.EnumerateArray());
foreach (var issue in issueElements)
{
var severity = issue.TryGetProperty("severity", out var sev) && sev.ValueKind == JsonValueKind.String
? sev.GetString()
: null;
var type = issue.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString()
: null;
health.ConfigurationIssues.Add(new StreamConfigurationIssue
{
Severity = severity switch
@@ -232,20 +303,74 @@ public class YouTubeStreamService
Type = type,
});
}
}
return health;
}
/// <summary>Returns the liveChatId for a broadcast by fetching its contentDetails.
/// The liveChatId is needed to poll chat messages. Called after broadcast creation
/// in PrepareAndStartLiveAsync.</summary>
public async Task<string?> GetBroadcastLiveChatIdAsync(string broadcastId)
/// <summary>TASK 41 — mock chat input: posts a real text message into the live
/// chat via <c>liveChat/messages.insert</c>. Requires the same OAuth scopes the
/// app already holds (youtube.force-ssl). Returns itself or a human-readable
/// error; never throws. The inserted message round-trips back through the normal
/// chat poll (~2s) and renders through the live overlay path.</summary>
public async Task<string?> InsertChatMessageAsync(string liveChatId, string messageText)
{
if (!await EnsureToken()) return "not signed in";
var body = new
{
snippet = new
{
liveChatId,
type = "textMessageEvent",
textMessageDetails = new { messageText }
}
};
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
var response = await _http.PostAsJsonAsync(
$"{ApiBase}/liveChat/messages?part=snippet", body);
if (response.IsSuccessStatusCode)
{
AppLog.Write($"Test chat message inserted into {liveChatId}");
return null;
}
var errorBody = await response.Content.ReadAsStringAsync();
AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}");
return $"YouTube rejected the message ({(int)response.StatusCode})";
}
/// <summary>Resolves the broadcast's liveChatId (needed to poll chat). Two facts
/// pin this down (2026-09-25, from the official liveBroadcasts reference + the
/// GetLiveChatId.java sample):
/// 1. It lives under <c>snippet.liveChatId</c> — <c>contentDetails</c> has no such
/// property, so part=contentDetails could NEVER resolve it (the "Chat polling
/// couldn't start" report).
/// 2. YouTube only populates it once the broadcast is <c>live</c> (the sample lists
/// broadcastStatus=active) — fetching right after insert (lifecycleStatus
/// <c>ready</c>) returns no id. So this polls with a bounded retry, which the
/// caller must run AFTER the encoder starts pushing RTMP (enableAutoStart flips
/// the broadcast to live). Returns the id or null after the retries exhaust;
/// never throws.</summary>
public async Task<string?> GetBroadcastLiveChatIdAsync(
string broadcastId, int maxAttempts = 10, int delayMs = 2000)
{
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
if (attempt > 0) await Task.Delay(delayMs);
var liveChatId = await FetchLiveChatIdAsync(broadcastId);
if (liveChatId != null) return liveChatId;
}
return null;
}
private async Task<string?> FetchLiveChatIdAsync(string broadcastId)
{
if (!await EnsureToken()) return null;
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
var response = await _http.GetAsync(
$"{ApiBase}/liveBroadcasts?part=contentDetails&id={broadcastId}");
$"{ApiBase}/liveBroadcasts?part=snippet&id={broadcastId}");
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
@@ -253,8 +378,8 @@ public class YouTubeStreamService
var items = data.GetProperty("items");
if (items.GetArrayLength() == 0) return null;
var contentDetails = items[0].GetProperty("contentDetails");
if (contentDetails.TryGetProperty("liveChatId", out var liveChatId))
var snippet = items[0].GetProperty("snippet");
if (snippet.TryGetProperty("liveChatId", out var liveChatId))
return liveChatId.GetString();
return null;
+282
View File
@@ -0,0 +1,282 @@
using System.Diagnostics;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Backing VM for the YPP pull-out (the "YPP" tab below Stream Settings on the
/// Live screen's right edge). Shows the connected channel's current position
/// toward the YPP tier thresholds (subscribers + 90-day uploads via the Data
/// API), a compliance checklist (2FA/AdSense self-reported — channel standing is
/// NOT exposed to ordinary apps, so the drawer deep-links to the Earn page for it)
/// and the "what counts" education the creator would otherwise have to leave the
/// app to find. Snapshots are appended to SQLite on every refresh so a later
/// analytics slice can compute velocity/ETA from history.
/// Drawer open/close lives here (mirrors LiveBroadcastFormViewModel).
/// </summary>
public sealed class YppTrackerViewModel : ViewModelBase
{
private const double BarMaxWidth = 220;
private readonly Func<LayoutStore> _store;
private readonly Func<ChannelStatsService> _stats;
private readonly Func<bool> _isConnected;
private readonly Func<string?> _displayName;
private bool _isDrawerOpen;
private bool _isLoading;
private bool _isRefreshing;
private string? _loadError;
private string _displayNameText = string.Empty;
private long _subscriberCount;
private long _videoCount;
private long _totalViews;
private int _uploadsLast90Days;
private bool _hasSnapshot;
private bool _twoFactorEnabled;
private bool _adsenseLinked;
private DateTime? _lastRefreshedAtUtc;
public YppTrackerViewModel(
Func<LayoutStore> store,
Func<ChannelStatsService> stats,
Func<bool> isConnected,
Func<string?> displayName)
{
_store = store;
_stats = stats;
_isConnected = isConnected;
_displayName = displayName;
RefreshCommand = new RelayCommand(_ => _ = RefreshAsync());
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
OpenEarnCommand = new RelayCommand(_ => OpenUrl(EarnUrl));
OpenTwoStepCommand = new RelayCommand(_ => OpenUrl(TwoStepVerificationUrl));
OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl));
(_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist();
_lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc();
}
// Deep links — the parts of the checklist YouTube does NOT expose to any API
// resolve in the browser; everything API-derived stays in-app.
private const string EarnUrl = "https://www.youtube.com/earn";
private const string TwoStepVerificationUrl = "https://myaccount.google.com/signinoptions/two-step-verification";
private const string AdsenseGuideUrl = "https://support.google.com/youtube/answer/72851";
public RelayCommand RefreshCommand { get; }
public RelayCommand ToggleDrawerCommand { get; }
public RelayCommand CloseDrawerCommand { get; }
public RelayCommand OpenEarnCommand { get; }
public RelayCommand OpenTwoStepCommand { get; }
public RelayCommand OpenAdsenseCommand { get; }
public bool IsDrawerOpen
{
get => _isDrawerOpen;
set
{
if (SetProperty(ref _isDrawerOpen, value) && value)
_ = EnsureLoadedAsync();
}
}
public bool IsLoading
{
get => _isLoading;
private set => SetProperty(ref _isLoading, value);
}
public bool IsSignedOut => !_isConnected();
public string? LoadError
{
get => _loadError;
private set => SetProperty(ref _loadError, value);
}
public string DisplayName => _displayNameText;
// ─── Live numbers (from the Data API snapshot) ───
public long SubscriberCount => _subscriberCount;
public long VideoCount => _videoCount;
public long TotalViews => _totalViews;
public int UploadsLast90Days => _uploadsLast90Days;
/// <summary>True once a snapshot has successfully captured at least once this
/// session (drives the standing detail text's "sign in to load" state).</summary>
public bool HasSnapshot => _hasSnapshot;
// ─── Manual checklist (2FA / AdSense — no API exposes them) ───
public bool TwoFactorEnabled
{
get => _twoFactorEnabled;
set
{
if (SetProperty(ref _twoFactorEnabled, value))
PersistChecklist();
}
}
public bool AdSenseLinked
{
get => _adsenseLinked;
set
{
if (SetProperty(ref _adsenseLinked, value))
PersistChecklist();
}
}
// ─── Progress rows (subscribers is fixed; uploads is a 3/90d bar) ───
public double Tier1SubsProgress => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers);
public double Tier2SubsProgress => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers);
public double UploadsProgress => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days);
public double Tier1SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers) * BarMaxWidth;
public double Tier2SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers) * BarMaxWidth;
public double UploadsBarWidth => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days) * BarMaxWidth;
public string Tier1SubsText => $"{SubscriberCount:N0} / {YppThresholds.Tier1.Subscribers:N0}";
public string Tier2SubsText => $"{SubscriberCount:N0} / {YppThresholds.Tier2Now.Subscribers:N0}";
public string UploadsText => $"{UploadsLast90Days} / {YppThresholds.UploadsRequiredPer90Days} in the last 90 days";
public string StandingDetailText
{
get
{
if (!HasSnapshot) return "Sign in to load your channel's progress from YouTube.";
return "Channel standing isn't exposed to YouTube apps — check the Earn page for your live status.";
}
}
public string LastUpdatedText
{
get
{
if (_lastRefreshedAtUtc == null) return "Not refreshed yet";
var local = _lastRefreshedAtUtc.Value.ToLocalTime();
return local.Date == DateTime.Today
? $"Updated today at {local:HH:mm}"
: $"Updated {local:g}";
}
}
public string WhatCountsText { get; } =
"Long-form watch hours: valid public VODs and livestreams watched in the trailing 12 " +
"months — Shorts-feed views don't count, and private, unlisted, deleted or ad-campaign " +
"time is excluded. Shorts views: valid public Shorts viewed in the Shorts feed over 90 " +
"days. Uploads: valid public long-form videos posted in the last 90 days. Zero active " +
"Community Guidelines strikes, 2-Step Verification switched on, and a linked AdSense " +
"account complete the checklist. Figures here are API-derived and close to — but not " +
"identical to — YouTube Studio's Earn tab.";
/// <summary>Account sign-in/out changed (fires from MainViewModel): reset the
/// signed-out state and refresh when a session now exists.</summary>
public void OnAccountChanged()
{
OnPropertyChanged(nameof(IsSignedOut));
OnPropertyChanged(nameof(DisplayName));
if (_hasSnapshot && !_isConnected())
{
_hasSnapshot = false;
OnPropertyChanged(nameof(HasSnapshot));
}
if (_isConnected())
_ = EnsureLoadedAsync();
}
/// <summary>Loads once per session/day (quota-honest: the analytics slice that
/// gives watch-hours/shorts has a thin targeted-query budget, so this gate is
/// baked in now). A manual "Refresh" always goes to the wire.</summary>
public async Task EnsureLoadedAsync()
{
if (!_isConnected() || _hasSnapshot) return;
var last = _store().LoadYppLastRefreshedAtUtc();
if (last != null && DateTime.UtcNow - last.Value < TimeSpan.FromHours(24)) return;
await RefreshAsync();
}
public async Task RefreshAsync()
{
if (_isRefreshing) return;
_isRefreshing = true;
IsLoading = true;
LoadError = null;
try
{
if (!_isConnected())
{
_lastRefreshedAtUtc = null;
OnPropertyChanged(nameof(LastUpdatedText));
return;
}
var snapshot = await _stats().CaptureCurrentAsync();
if (snapshot == null)
{
LoadError = "Couldn't reach YouTube — try again.";
return;
}
ApplySnapshot(snapshot);
_store().SaveYppSnapshot(snapshot);
_lastRefreshedAtUtc = snapshot.CapturedAtUtc;
_store().SaveYppLastRefreshedAtUtc(snapshot.CapturedAtUtc);
OnPropertyChanged(nameof(LastUpdatedText));
}
catch (Exception ex)
{
AppLog.Write($"YPP refresh failed: {ex}");
LoadError = "Refresh failed — try again.";
}
finally
{
_isRefreshing = false;
IsLoading = false;
}
}
private void ApplySnapshot(YppStatSnapshot snapshot)
{
_displayNameText = _displayName() ?? string.Empty;
_subscriberCount = snapshot.SubscriberCount;
_videoCount = snapshot.VideoCount;
_totalViews = snapshot.TotalViews;
_uploadsLast90Days = snapshot.UploadsLast90Days;
_hasSnapshot = true;
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(SubscriberCount));
OnPropertyChanged(nameof(VideoCount));
OnPropertyChanged(nameof(TotalViews));
OnPropertyChanged(nameof(UploadsLast90Days));
OnPropertyChanged(nameof(HasSnapshot));
OnPropertyChanged(nameof(Tier1SubsProgress));
OnPropertyChanged(nameof(Tier2SubsProgress));
OnPropertyChanged(nameof(UploadsProgress));
OnPropertyChanged(nameof(Tier1SubsBarWidth));
OnPropertyChanged(nameof(Tier2SubsBarWidth));
OnPropertyChanged(nameof(UploadsBarWidth));
OnPropertyChanged(nameof(Tier1SubsText));
OnPropertyChanged(nameof(Tier2SubsText));
OnPropertyChanged(nameof(UploadsText));
OnPropertyChanged(nameof(StandingDetailText));
}
private void PersistChecklist() => _store().SaveYppChecklist(TwoFactorEnabled, AdSenseLinked);
private static double Progress(long current, long target)
=> target <= 0 ? 0 : Math.Clamp((double)current / target, 0, 1);
private static void OpenUrl(string url)
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
}
+14 -6
View File
@@ -6,10 +6,15 @@ External-facing logic: YouTube API, persistence. See
| File | Purpose |
|------|---------|
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` (NOTE 2026-09-22: the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` as an OBJECT — the parse reads the nested `.status` and tolerates the old flat-string shape; the object shape used to crash every poll) | **`EndBroadcastAsync` (2026-09-01; hardened 2026-09-22)** posts `liveBroadcasts/transition?broadcastStatus=complete&part=status` AFTER RTMP EOF with a pre-flight `liveBroadcasts.list` read of the broadcast's OWN `lifeCycleStatus`: only transitions from `live`/`testing`, skips already-complete broadcasts silently (2026-09-22: every blind end logged `invalidTransition` — autoStop races us to complete); `enableAutoStop` finishes every skip |
| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
| `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) |
| `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, and `AlertTickerFrame` composes the scrolling message strip |
| `AlertClipDecoder.cs` | **Per-play alert video decoder (TASK 47):** `IAlertClipDecoder` (Start/Stop/Dispose + `FrameAvailable`/`AudioReady`/`Completed`) + ffmpeg child emitting `rawvideo bgra` (`-vf scale=W:H`, read via `RawVideoFrameReader`) and `f32le -ar 48000 -ac 2` on a **carry-buffer** audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) |
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
@@ -28,13 +33,16 @@ External-facing logic: YouTube API, persistence. See
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
| `WebView2Manager.cs` | **Web-source ownership (TASK 17, composition-capture since 2026-09-14):** per registered `Source { Type: WebSource }` it owns a `CoreWebView2CompositionController` + its visual tree (`RootVisualTarget` child under a 1920×1080 root `ContainerVisual` on one `Windows.UI.Composition.Compositor` created on the UI thread via the CoreMessaging `CreateDispatcherQueueController` P/Invoke), a wrapped `WebCaptureFrameSource`, and the shared crop-sized `WriteableBitmap` preview (dispatcher-coalesced copy at Render priority, `PreviewBitmapChanged` raised only on recreation). Nav + `TransparentBackgroundScript` injection unchanged. Public ctor `(Dispatcher)`; internal seam ctor `(Dispatcher, Func<string, IScreenCaptureSource>?)` for hermetic tests (no WinRT). Caller: `MainViewModel.InitWebView2()` (now invoked from `MainWindow_Loaded` — the parent HWND must exist). See `../ai.md` "Slice 14" |
| `WebCaptureFrameSource.cs` | The `IScreenCaptureSource` for a web session: owns the `GraphicsCaptureItem` (`CreateFromVisual`) + a free-threaded `Direct3D11CaptureFramePool` (2 buffers) + session; readback via `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, **`BitmapAlphaMode.Straight`** — the compositor blends straight alpha); per-frame `FindContentBounds` alpha-bbox (internal static, unit-tested) stamps `VideoFrame.CropBounds`; ring up to 2 fresh frames + Epoch reuse; `IsOpaque=false`. Mirror of `ScreenCaptureFrameSource` |
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). **TASK 47:** `Render` + `CompositeLayers` take a trailing `tickerFrame` blitted at (0,0) after the bar — the alert ticker is a message-scrolling dynamic overlay, never baked. Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action<string>` log for tests |
| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread |
| `Compositor/AlertTickerRenderer.cs` | **Alert message ticker strip (TASK 47):** marquee overlay rendered per call by pure byte-math onto a 1920×48 transparent BGRA frame — the text pill ("Author — Kind · amount") is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha) and scrolled at 140px/s with a repeat-gap wrap, so the pump can call `Render(elapsed, text)` from its own thread without locking. **Dynamic overlay — never baked** (unlike the social bar) |
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` |
@@ -43,7 +51,7 @@ External-facing logic: YouTube API, persistence. See
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. See `ai.md` "Live frame pipeline" |
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47:** adds an `alertTicker: Func<VideoFrame?>?` ctor seam (parallel to `socialBar`), read per tick and threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`** so the ticker scroll changes the cache key. See `ai.md` "Live frame pipeline" |
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
| `INotificationService.cs` | **Toast seam (TASK 24)**: `Show(title, message, AppNotificationSeverity)` + `AppNotificationSeverity { Info, Success, Warning, Error }` + `Info/Success/Warning/Error` extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread |
| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (MainWindow bottom-right above the footer); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" |
@@ -51,7 +59,7 @@ External-facing logic: YouTube API, persistence. See
| `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
| `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func<string?>` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) |
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) |
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed). **TASK 47 — alert audio:** `EnqueueAlertAudio` writes a dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` pre-limiter and added at **unity (never ducked)**; `StartLive` clears the ring |
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1, with **+10 dB input amplification** so real speech peaks hit the red zone at maxed volume) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise) |
| `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored |
| `Audio/MasterLimiter.cs` | **Master output limiter**: a **−1 dBFS ceiling** (`Ceiling = 0.891`) applied after the live mix so the honest gains can never sum past 0 dBFS and clip the AAC encode. Instant attack per frame (a hot frame is scaled exactly to the ceiling — no overshoot), smoothed release toward unity so loud passages don't pump. Pure — unit-tested |
+290 -1205
View File
File diff suppressed because it is too large Load Diff
+461
View File
@@ -0,0 +1,461 @@
# ytLlive — Windows Store & code-signing certification research (authoritative)
> Catalog: [`TASKS.md`](../TASKS.md) · memory map: [`schema.md`](../schema.md) · distribution plan: [`Distribution.md`](../Distribution.md)
>
> **Researched 2026-09-27** against **Microsoft Store Policies version 7.20** (published
> 2026-09-15, effective 2026-10-22) and the MSIX packaging docs. Policy version moves —
> re-check the version before acting on any line here.
>
> **This file is the "why".** The executable checklist lives in
> [`task-48-distribution-msix.md`](task-48-distribution-msix.md). No code changed as a
> result of this research; it only decides what task-48 will eventually do.
---
## 1. Bottom line
**✅ ROUTE DECIDED 2026-09-27 — the creator chose route A: Microsoft Store, MSIX package,
Store IAP.** The research below is what the decision was made from, and it stays as the
audit trail so the choice is never re-litigated. The executable checklist is
`TASKS/task-48-distribution-msix.md`; the ruling and the criteria behind it are in that
file's item 0.
**MSIX packaging is viable.** One real blocker, and it is a code change we should want
anyway (§4). Three technical blockers that normally kill a Store submission — drivers,
per-user services, elevation — we **do not trip** (§7).
**Nothing else is open on distribution.** The only number still unverified is the Store
revenue-share rate (§11 item 4), which is a *pricing* input, not a distribution one.
---
## 2. ⚠️ The one line in `Distribution.md` that is now wrong
`Distribution.md:318` currently says:
> Buy an EV (Extended Validation) code-signing certificate from DigiCert or Sectigo
> ($200-400/year). EV certificates get instant SmartScreen reputation; DV/OV certs
> trigger SmartScreen warnings for months.
**The EV half of that is false.** Microsoft removed the SmartScreen instant-bypass for EV
certificates in **March 2024**. An EV-signed file now accumulates reputation exactly the
way an OV-signed file does. Paying $400+/yr buys precisely what paying $150 buys.
`schema.md` rule 4: stale facts get FIXED, not appended. Corrected in `Distribution.md`
(2026-09-27).
Also worth knowing before budgeting:
- **Cert validity is capped at 460 days** (CA/B Forum CSC-31, effective 2026-03-01). This
is a recurring annual line item, not a one-time purchase.
- **Private keys must live on an HSM or hardware token** (CA/B rule since 2023-05). Budget
for the token or a cloud HSM.
- **Signing does not clear the warning on day one.** A valid cert stops the *malware*
warning immediately; the "unrecognized publisher" warning clears as SmartScreen accrues
reputation from real download volume. Expect a ramp on every route.
- **Third-party AV vendors layer their own reputation blocking on top of SmartScreen**, so
signed-but-new can still warn on consumer machines. The Store route is the only one that
is warning-free, because Microsoft holds the reputation.
---
## 3. Signing economics
| Route | Cert cost | SmartScreen | Install UX | Notes |
|---|---|---|---|---|
| **Microsoft Store, MSIX package** | **$0** — Microsoft re-signs | No warning | Normal | Store review, public listing, updates handled by Store |
| **Microsoft Store, EXE/MSI (10.2.9)** | **$150–300/yr — your own cert** | Warning ramp | **Silent install only** | Store does NOT re-sign; you maintain a versioned URL |
| **Direct + OV cert** | $150–300/yr | Warning ramp | Yours | Worldwide availability |
| **Direct + Azure Artifact Signing** | ~$9.99/mo ≈ $120/yr | Warning ramp | Yours | **Individuals: USA/Canada only.** Identity validation required |
| **Direct + EV cert** | $400+/yr | Same as OV | Yours | **Do not buy.** No benefit since March 2024 |
| **Unsigned direct** | $0 | Strong block | Yours | Unusable for public download |
### Four real combinations
| # | Distribute | Verify payment | Cert | Revenue cut |
|---|---|---|---|---|
| **A** | Store | Store IAP | $0 | yes |
| **B** | Store | **Polar** | $0 | no |
| **C** | Polar file hosting | Polar | $150–300/yr | no |
| **D** | Store EXE (10.2.9) | Polar | $150–300/yr | no |
**✅ A was chosen (2026-09-27)** — the creator's criteria were "zero headaches, minimal
maintenance (for me) while still providing accountability and a reasonably easy upgrade
flow," and A is the only option where *every* one of those is solved by handing the work to
Microsoft rather than to a certificate vendor. The rest of this section stays as the record
of what was rejected and why, so the decision is not reopened by a later session that
remembers only that "certs cost $150/yr."
**A** is the only one where Polar becomes unnecessary — the Store handles payment,
entitlement, and refunds, and `PolarLicenseService` and the customer-portal plumbing are
deleted. That is a *distribution* choice that happens to subsume licensing.
**B** is the compromise that was **not** taken: the Store would have solved the SmartScreen
problem and the annual cert bill while Polar stayed the licensing system. Rejected because
it keeps a licensing backend, a customer portal, activation limits, and an offline-grace
machine alive — the exact maintenance the ruling was made to eliminate.
**C** is the status quo previously sketched in `Distribution.md:14` / `:296` — Polar hosts
the signed exe (10GB, signed personal download URLs, SHA-256 included). **The Store is not
needed for delivery at all**; its only unique value is *free Microsoft signing with no
SmartScreen warning*, and its cost is MSIX packaging work, Store review, and a public
listing. Note the irony: C is the only option with a recurring cost *and* the only one where
the creator maintains payment plumbing themselves.
**D is dominated** — strictly worse than both A and C: cert required anyway, silent install
required, and we maintain the versioned download URL. Documented in one line so it is
never re-litigated.
---
## 4. ⛔ The one real blocker: 10.2.2 (dynamic code inclusion)
**Policy 10.2.2:**
> Your product must not attempt to fundamentally change or extend its described
> functionality or introduce features or functionality that is in violation of Store
> Policies **through any form of dynamic inclusion of code**. Your product should not, for
> example, **download a remote script and subsequently execute that script** in a manner
> that is not consistent with the described functionality.
**What we do today:** `Services/Encoder/FfmpegLocator.cs:37-38` pins a GitHub release URL,
and `LocateAsync` downloads the zip (line 69), extracts it (line 73), and the encoder then
executes that binary. On a cold cache the app **downloads an unsigned executable from the
internet and runs it.**
That is the pattern 10.2.2 names. The word is "script," so a narrow reviewer might quibble
about a PE binary — but 10.2.3 reinforces it, and 10.2.9 separately requires all PE files
to chain to the Microsoft Trusted Root Program, which a downloaded BtbN build does not.
**The fix:** bundle `ffmpeg.exe` + `ffprobe.exe` + the `libav*.dll` family **inside the
package** instead of downloading. No dynamic code inclusion, so 10.2.2 is satisfied.
**This is a fix we want regardless of route.** `FfmpegLocator.cs:30-35` records that the
previous daily pin aged out of GitHub retention and **404'd on the first real recording
attempt (2026-09-01)**. Bundling kills that entire class of cold-start failure permanently,
and it also removes the startup network dependency.
The LGPL-shared licensing reasoning in `FfmpegLocator` is unaffected — dynamic linking was
the deliberate choice for LGPL §6 compliance, not for download avoidance. Confirm
`THIRD-PARTY-NOTICES.txt` (already shipped) covers the bundled build.
**What I got wrong initially:** I believed MSIX might forbid *launching* a bundled
executable. It does not — see §5.
---
## 5. MSIX technical facts (full trust vs AppContainer)
MSIX packages run at one of two trust levels, chosen by the manifest:
| Trust level | Behavior |
|---|---|
| **`mediumIL` / full trust** | **Same permissions as a standard desktop app.** Package identity + some file/registry virtualization. **Can spawn child processes.** ← this is what a WPF broadcast tool wants |
| `appContainer` | Strictly isolated. Process and children see only explicitly granted resources. File/registry virtualized. |
**A packaged WPF app declaring `runFullTrust` runs at medium integrity — it is not an
AppContainer.** Bundling and launching ffmpeg from inside the package is therefore fine.
This is the "Desktop Bridge" / `packagedClassicApp` model.
**Critical file-write consequence:** "Writes to user profile locations (such as AppData) are
redirected to per-package locations for AppContainer apps, or **pass through for full-trust
apps**." The package install folder itself is read-only.
**Manifest this app needs:**
- `rescap:Capability Name="runFullTrust"` — medium integrity
- `webcam` and `microphone` capabilities
- `uap10:TrustLevel="mediumIL"`
- `uap10:RuntimeBehavior="packagedClassicApp"`
### The full-trust recording invariant (durable architectural fact)
`ViewModels/MainViewModel.Recording.cs` writes recordings to
`%USERPROFILE%\Downloads` or `SpecialFolder.MyVideos` (`DefaultRecordFolder()`, line
173–179), with `Microsoft.Win32.OpenFolderDialog` for overrides (`ChooseRecordFolder()`,
line 151). The whole temp-write-then-move lifecycle stays inside one directory
(line 131–132).
**This works only because we are full trust.** At `mediumIL` those writes pass through
unvirtualized and `OpenFolderDialog` is an ordinary Win32 folder browser with no capability
token involved. **No `broadFileSystemAccess` is needed.**
⚠️ **If anyone ever flips the manifest to `appContainer`, recording silently breaks** —
`Directory.CreateDirectory` and `File.Move` start resolving to a per-package virtualized
location. A code comment belongs at `DefaultRecordFolder()` **when the manifest lands**, not
before (a comment describing a manifest that does not exist is worse than no comment).
---
## 6. Store policy verdicts — what binds, what doesn't
Policy 7.20. "Binds" = must be satisfied to submit.
### ⛔ Binds — blockers or real work
| Policy | Requirement | Our status |
|---|---|---|
| **10.2.2** | No dynamic code inclusion (download-and-execute) | ❌ **VIOLATED** — see §4. Fix: bundle ffmpeg |
| **10.3.1** | Login required → supply a **working demo account** in certification notes | ☐ TODO — we require YouTube sign-in |
| **10.5.1** | **Privacy policy mandatory**; URL in Partner Center. Desktop Bridge + Win32 **always** need one. Must state what PI is accessed/collected/transmitted, how used/stored/secured, types of parties disclosed to, controls the user has, compliance with law, and be kept current | ☐ TODO — `llamachile.shop` can host it |
| **10.5.4** | Collect/store/transmit PI securely with modern cryptography | ✅ DPAPI for tokens, HTTPS to YouTube |
| **10.5.5** | No highly sensitive PI (health/**financial**) unless tied to functionality + express consent | ⚠️ **SuperChat/reward path touches financial data** — keep display-text-only processing, never log payloads |
| **10.6** | Declared capabilities must relate to the product's functions; **must not circumvent OS checks** | ✅ camera/mic for a broadcast tool is self-evident; must use standard MF/DShow/WASAPI paths |
| **10.7** | Localize for every declared language; declare **English only** | ☐ Keep the manifest to one language |
| **10.1.1** | Title must be unique, **no marketing or descriptive text, no extraneous keywords** | Store name is exactly `LlamaCasty` |
| **10.1.3** | Search terms: max 7, no pricing terms, **no other product titles** unless you published them | ☐ Cannot list `OBS` or `StreamYard` as search terms |
| **10.1.4** | Distinct informative metadata + **active Store presence** | ☐ Listing must not be a stub |
| **10.2.7** | Must enable clean uninstall | ☐ MSIX gives this free |
| **10.4.2** | Start promptly, stay responsive, shut down gracefully, survive native API exceptions | ✅ `AppLog` + dispatcher handlers exist |
| **10.11.1** | **IARC age-rating questionnaire** required | ☐ General-audience streaming tool, 12+ territory |
| **10.11.2** | Keep the rating accurate as features change | ☐ Re-visit if audience-facing features shift |
| **11.12** | **User Generated Content** — see §9 | ⚠️ Judgment call, documented not built |
| **10.8.1** | Non-game PC products **may** use a secure third-party purchase API **or** Store IAP | ✅ **Superseded 2026-09-27: we chose Store IAP, not Polar.** The third-party-purchase path is no longer the plan |
| **10.8.2** | Third-party purchase: identify provider, authenticate user, obtain confirmation, PCI DSS, note in Partner Center | ⛔ **Void — Polar is being torn down (TASK 10).** Store IAP is the Microsoft commerce engine, so the third-party-provider/PCI-DSS obligations do not apply |
| **10.8.3** | **Individual** accounts cannot require financial info for primary functionality; requiring it forces a **Company** account | ⚠️ **Open question** — see §11 |
| **10.14** | Individual accounts usually fine for a solo dev; Company required if the product needs financial info for primary functionality, or a consumer would read the name as a business | ⚠️ Same question as 10.8.3 |
| **10.8.7** | Pricing must follow the FTC Guides Against Deceptive Pricing and must not be irrationally high vs. features | ☐ A pricing decision, not a code task |
| **10.5.8** | Must **follow** applicable safety/privacy laws re children | ☐ Follow-the-law obligation — see §8 |
| **10.1.5** | May enable acquisition of your other Store products or add-ons | N/A |
### ✅ Does NOT bind — recorded so it is never re-raised
| Policy | Why it doesn't apply |
|---|---|
| **10.10** (Advertising) | **Ads are injected server-side by YouTube after the RTMP handoff.** LlamaCasty displays no ads, renders no ad SDK, and controls no ad decision. Every 10.10 clause is conditioned on ads *your product displays*. Not applicable. |
| **10.2.5** (Store-only install) | 7.20 applies "installed and updated only through the Store" to **game products and Xbox console products**. A non-game PC app is not locked to MSIX. *(The 2018 v7.7 wording said "all of your apps" — that language has been narrowed.)* |
| **10.2.3** (secondary software) | ffmpeg **enhances** the product's functionality, and once bundled it is not "offered to install" as separate software |
| **10.2.8** (Windows settings) | `RegisterHotKey` and standard DShow/MF paths are supported APIs. No accessibility-API or undocumented-API tricks |
| **10.10.1 / 10.10.2** | Follows from 10.10 — no ads displayed |
| **11.13** (Storefront) | Not a storefront |
| **11.14** (Gambling) | Not applicable |
| **11.15** (Child Safety) | Prohibits products enabling exploitation/grooming/sexualization/sextortion/trafficking, and adult-themed products targeting children. A broadcast tool is not in this class. Nothing LlamaCasty does creates, uploads, or distributes child-exploiting content |
| **10.5.2** | We do not publish a customer's PI to an outside service beyond the stream they themselves broadcast |
| **10.5.7** (location) | No device location access |
| **10.2.9** | Only if we chose the Store EXE route. It would bind — see §3, route D is dominated |
### Certification mechanics
- Technical compliance is tested by the **Windows App Certification Kit** before submission.
- Certification takes **up to 3 business days**; the listing appears ~15 min after publish.
- MSIX package requirements: SHA2-256 block map, `StoreManifest`, 25 GB max.
- Partner Center account: individual vs company — see §11.
---
## 7. MSIX disqualifiers we do NOT trip
Verified by grep across the codebase 2026-09-27. The MSIX prep docs list four hard
disqualifiers; **all four are clear**:
| Disqualifier | Our status |
|---|---|
| "Your application requires a Windows driver. MSIX doesn't support Windows drivers." | ✅ We **consume** DShow filters, we don't install a driver. (A creator's third-party Virtual Capture Device is their hardware, not something we ship.) |
| "Your application requires a user Windows service. MSIX doesn't support per-user Windows services." | ✅ No service installation anywhere |
| Apps requiring **elevation** for any functionality are not accepted | ✅ No `requireAdministrator` / `highestAvailable` / UAC manifest |
| "Your app's modules are loaded in-process to processes that are not in your Windows app package" (shell extensions, etc.) | ✅ No shell extensions, no in-process module loading, no jump lists |
Also verified: there is **no `.manifest` file in the repo at all** today, so packaging is
purely additive — a new `Package.appxmanifest` plus a packaging project. The existing
unpackaged exe keeps working.
### Other store rules worth knowing while listing
- **10.1.4** requires an active Store presence — the listing cannot be a placeholder.
- **10.7** — declare English only, or localize the description into every declared language.
- **10.9** — the app must stay functional when notifications are disabled. Our toast stack
must not be load-bearing.
---
## 8. Minors, COPPA, and the age question
### The operator is 13+ by construction
YouTube Terms of Service:
- **"You must be at least 13 years old to use the Service"**
- 13–17 permitted **with parent/guardian permission**
- **Under 13 cannot create an account** — exceptions are YouTube Kids and supervised
accounts via Family Link or Google Workspace for Education
- Google Accounts: 13 is the minimum to self-manage
- **The floor varies by region** — at least one YouTube ToS variant states 14, tracking EU
digital-consent ages. Treat it as 13–14, not a fixed 13.
**Why this does real work:** LlamaCasty *requires* a YouTube account to operate, because
streaming requires one. So the person running the broadcast is 13+ by construction. A
COPPA theory premised on "a service collecting personal information from children under 13"
has no purchase against the operator — and that is true **by construction, not by promise**.
That is the cleanest sentence available for a certification note.
### The residual exposure
The **viewers** may still be under 13 on supervised/family-linked accounts, so the chat
ingestion point does not fully disappear. It weakens substantially, because:
- Live chat is restricted/disabled on supervised YouTube kid accounts
- YouTube moderates the messages before we ever see them (§9)
- Our mitigation: **retain nothing** — parse, display, discard (see `task-49`)
### COPPA is very unlikely to bind
COPPA applies to child-directed services and to operators collecting PI from children under
13. The three-factor test (subject matter, audience, evidence of child focus) is nowhere
near met by a broadcast tool aimed at a gaming audience. **10.5.8 is a follow-the-law
obligation, not a be-child-safe obligation** — it says "follow all applicable laws," it does
not impose COPPA duties on us directly.
**But note the enforcement trend** (FTC, September 2025 Apitor settlement): operators are
held responsible for **what third-party components collect on their behalf**, and the FTC
expects a real inquiry into third-party collection practices. That case was about an SDK;
the principle is what matters for a chat pipe. This is a documentation obligation, and the
answer is "we integrate no third-party analytics or ad SDKs, and we persist no chat or
reward payloads."
---
## 9. 11.12 User Generated Content — the judgment call
11.12 triggers on "content that users contribute to a product and which can be viewed or
accessed by other users **in an online state**." **Chat is literally that.** If a reviewer
classifies the chat surface as UGC, the requirements are:
1. Published **terms of service, code of conduct, and/or content guidelines** for UGC,
accessible **in-product and on your website**
2. A **means for users to report** inappropriate/illegal/harmful UGC to the developer for
review, and/or proactive detection
3. Respect **user safety, parental, and content privilege settings**, and handle restricted
access gracefully
4. **Remove or disable UGC when requested by Microsoft**
5. Disclose that content is **not created by the developer/publisher** where appropriate
### The argument: YouTube discharges the moderation obligation upstream
| 11.12 asks for | How YouTube already covers it |
|---|---|
| Published ToS / CoC / content guidelines | Community Guidelines, linkable from our listing |
| A reporting mechanism | Creator-side chat reporting; YouTube enforces |
| Respect parental / privilege settings | YouTube's restricted + supervised accounts |
| Remove/disable on request | YouTube removes; we never hosted the original |
| Disclose third-party UGC origin | Visibly YouTube chat; YouTube is platform of record |
**And the enforcement is demonstrably real.** YouTube's transparency report for
**Jan–Mar 2026**: approximately **1.6 billion comments removed in a single quarter**, with
**child safety the #2 removal reason at 124,624,293** (hateful/abusive 15,977,127;
harassment/cyberbullying 71,326,861; spam/scams 1.31 B).
**The certification-note sentence:**
> UGC is mirrored from YouTube, which moderates it at industrial scale upstream. We render
> transiently, persist nothing, and provide no user-to-user communication surface.
**The "persist nothing" half is ours to keep** — and it is the same property
`task-49` (profanity filter) needs. Convenient, not coincidental.
**Our read:** 11.12 targets products hosting a UGC *community*. A chat *mirror* rendered
from YouTube — where YouTube is the platform of record and already supplies reporting,
age-gating, and parental controls — is arguably not "our" UGC. **This is a judgment call,
and we resolve it by documenting the position rather than building features on a guess.**
State the reasoning in the certification notes; Microsoft reads them.
### The documentation we owe regardless
| Artifact | Requirement |
|---|---|
| **Privacy policy** on `llamachile.shop` | 10.5.1. Must state: camera/mic access is user-directed and OS-gated; **no retention** of chat or reward payloads; no viewer data collected; **ffmpeg is bundled, nothing is fetched at runtime** |
| **Code of conduct + content guidelines** | 11.12 / 11.15. General audience; explicitly not directed at under-13s |
| **IARC age-rating questionnaire** | 10.11.1 |
| **Certification notes** | the 11.12 reading, the YouTube age-gate argument, the Polar third-party purchase box, the demo account |
---
## 10. Camera, microphone, and "only when the user says so"
**We do not write that certification. Windows does it — and the Store forbids us from
bypassing it.** Three layers:
1. **Policy 10.6** — "You must not circumvent operating system checks for capability
usage." Route through the standard OS consent path; do not roll our own.
2. **Windows privacy settings** — Settings → Privacy & security → Camera / Microphone.
Since **Windows 10 1903**, *"Let desktop apps access your camera/microphone"* is the
toggle that governs a Win32 broadcast app. Turn it off and the app loses access. **That
is the "explicit user direction" mechanism, enforced by the OS, not by our EULA.**
3. **Hardware/OS indicators** — camera light (or a notification when there is no light) and
a microphone icon in the taskbar tray. Automatic. The OS is the disclosure mechanism, so
we get neither credit nor blame from it.
### ⚠️ The open question — measure, don't assume
Microsoft's own support page carries this caveat:
> Desktop apps may not always appear in the list of apps available to the Camera and
> Microphone settings pages or might still be able to access your camera or microphone even
> when these settings are turned off.
**Untested: does the Win11 desktop-app camera toggle reliably gate a DShow webcam and a
capture card?**
- If it **does** gate them, we can make privacy-forward claims after verifying.
- If it **does not**, that is **not** a Store certification failure — we declared `webcam`
honestly and use supported APIs — but it is a real user-trust issue, and we must not
write privacy-forward marketing copy on the assumption that it works.
**Tracked as an open item in `TASKS.md`. A guard note in `MARCOM.md` blocks privacy-forward
copy until it passes.**
---
## 11. Open questions
1. **Individual or Company Partner Center account?** (10.8.3 / 10.14.) Individual accounts
are "usually appropriate for a single developer working on their own," and a Company
account is required if the product requires financial account information **for primary
functionality**. Polar collects payment externally and we never ask the user for card
details, so we look like a clean individual case — but it is a question for Partner
Center, and getting it wrong means re-enrolling.
2. **Does the desktop-app camera toggle gate DShow?** (§10) — gates the `MARCOM.md`
privacy claim.
3. **MSIX write behavior under a real package identity** — full trust passes writes through
per the docs, but worth confirming on a real packaged build before trusting it with
recordings.
4. **Store revenue-share terms** — do not plan on any specific percentage. The terms have
moved more than once and small indie apps sometimes qualify for better rates. Verify
current terms and conditions before pricing against them.
5. **`liveBroadcasts.insert` requires `status.selfDeclaredMadeForKids`** (COPPA) — already
known, see [`research-youtube-api.md`](research-youtube-api.md) item 1. Confirm the
dialog wording is right for a tool that is never child-directed.
---
## 12. Profanity filter — design constraints (feeds `task-49`)
YouTube's Community Guidelines carry a specific **Vulgar language policy** under
"Sensitive content," alongside nudity/sexual content, child safety, and self-harm. So
profanity is a named policy surface, not an edge case.
Three external reasons it earns a slot, plus the real one:
1. YouTube treats it as policy-relevant, so 11.12's "proactive detection" language has
something to attach to
2. Competitive parity — overlay tools in this lane generally offer it
3. It is a reasonable reading of 11.12's proactive-detection language
4. **The real reason: we control what gets projected on screen.** That is the creator's
call, not YouTube's.
**Four constraints, because they protect the privacy story rather than complicate it:**
1. **Local and on-device only.** No server, no telemetry of message content, nothing leaves
the machine.
2. **Non-persistent.** Filter at ingest, discard the raw text, keep only the display
string. Same property that makes the 11.12 answer in §9 strong.
3. **User-supplied word list**, opt-in, stored locally. **Not a hardcoded slur list baked
into the binary** — that is an unpleasant artifact, generates false positives across
dialects and languages, and becomes our problem the moment someone extracts it. A
user-curated list is also simply more useful: streamers know their community's
euphemisms and we do not.
4. **Match display text only.** Never the SuperChat amount or any reward-event field —
those are financial data under 10.5.5, and matching on them would be a real mistake.
The existing alert box already has a ticker and three display methods
(`Controls/OverlayHost.xaml`, `ViewModels/MainViewModel.Chat.cs`), so there is probably a
**single insertion point** rather than scattered call sites. Verify before building.
+33
View File
@@ -0,0 +1,33 @@
# ytLlive — YouTube Live API research facts (authoritative)
> Catalog: [`TASKS.md`](../TASKS.md) · memory map: [`schema.md`](../schema.md).
## YouTube Live API — research facts (authoritative, v3 build)
Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`).
1. **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
2. **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** — changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out.
3. **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
4. **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
5. **Transition to live** only allowed when the bound stream's `status.streamStatus == active`.
### Two features that reshape the design
1. **enableAutoStart / enableAutoStop** — instant one-click go-live, no transition call. With `enableAutoStart=true` we never call `transition(live)`: the broadcast auto-goes-live the moment the encoder starts. Combined with `enableMonitorStream=false` (our preview pane replaces YouTube's monitor stream — the thing that forces a testing stage), the flow is **create → bind → Start Stream → encoder starts → YouTube brings it live**. No testing, no transition polling, no liveStarting stuck-state handling.
2. **`cdn.resolution=variable` / `cdn.frameRate=variable`** — free auto step-down. YouTube auto-detects what we send; since we ARE the encoder we can drop bitrate/resolution on the fly with zero API calls. Declaring an explicit resolution instead (e.g. 1080p) requires a new stream, which can't happen mid-broadcast. Variable is the enabler for the whole auto step-down feature. **(Claim-check 2026-09-01: the enabler is real, the governor is not built — the drop-side policy ships as TASK 33, v1 per the complete-v1 ruling.)**
### Compliance gotchas (maps perfectly to report-by-exception)
1. `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception — poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic.
2. Encoder must comply or YouTube flags it: keyframes ≤ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
3. Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
### Tips we should take advantage of
1. **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline.
2. **Backup ingestion address**: YouTube provides a simultaneous-push backup — future hardening, not v1.
3. **`recordFromStart` + `enableDvr` default true** → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
4. **`latencyPreference`: `normal | low | ultraLow`** — for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
5. **Broadcast ID == Video ID** — one ID to track everything.
+15
View File
@@ -0,0 +1,15 @@
# TASK 1 — Initial Scaffold
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** Working C# / WPF project with MVVM architecture, dark-theme main window, and YouTube service stubs.
### Status: ✅ Done
1. ✅ Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
2. ✅ Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
3. ✅ MainViewModel: scene management, stream controls, chat
4. ✅ MainWindow: scene/source panel, preview area, chat panel, status bar
5. ✅ Clean build, 0 warnings (WSL + Windows)
+37
View File
@@ -0,0 +1,37 @@
# TASK 2 — YouTube OAuth2 Authentication
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** Fully working Google OAuth2 flow — user clicks "YouTube", browser opens, authorization callback lands, channel info is stored.
### Status: ✅ Done
1. ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
2. ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch
3. ✅ Token persistence via Windows DPAPI (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`), best-effort reload + proactive refresh at startup, saved after every exchange/refresh
4. ✅ Account sign-in/change surfaced in the GoLive dialog (saved account shown with "Change Account"; "Sign in to YouTube" when none; Start disabled until signed in)
5. ✅ ~~**End Livestream signs out**~~ **SUPERSEDED by TASK 18 (2026-08-29): explicit sign-out only** — `StopStream()` no longer clears the session (sign out via Logout / Change Account, `SignOutYouTubeAsync`); stopping a stream or recording leaves the creator signed in. Originally shipped as: a graceful end completes the session: `StopStream()` calls `YouTubeAuthService.ClearSession()` + `TokenStore.Clear()` + `IsConnected = false`, so the next Start Stream dialog requires a fresh sign-in. A crash never runs End, so the DPAPI token survives and the creator stays signed in. Resume/reconnect after a midstream crash is deliberately deferred to TASK 3: the socket can't be resumed (it dies with the process), so "resume" = fast reconnect with a saved broadcast ID/stream key within YouTube's disconnect-grace window; too slow and `enableAutoStop` ends the broadcast. **Orphan closed 2026-09-01:** this mechanism is owned by **TASK 32 (Stream resilience)** — in-session blip retry inside the grace window; cross-process "resume" of a dead broadcast is officially out-of-product (the API makes it impossible — creator ruling).
6. ✅ Tests in `ytLive.Tests` (xUnit, net8.0-windows): TokenStore DPAPI roundtrip/corrupt/missing/clear + mocked exchange channel-parse + refresh expiry bump + `ClearSession` — 7 passing
**Design constraint:** Sign-in must NEVER block core exploration. Users can build scenes, add sources, and audition the software without authenticating. But **going live requires authentication** — the "Start Stream" dialog is where the account sign-in lives, alongside all stream metadata.
**Two-state flow:** There is no separate "Connect" button. The top bar shows a single button — **Start Stream** when idle, **End Stream** when live. Clicking Start Stream opens one dialog that supplies everything: account (previously-saved account shown as default, with a Change Account action) + title/description/visibility.
**Live indicators (unmissable):** Top bar + window title bar flip red, a pulsing **● LIVE** badge with elapsed timer appears in the top bar, the preview area gets a red glow, and the taskbar icon shows a red overlay dot. Window title bar shows the stream title once validated.
### Requirements:
1. **Google Cloud OAuth credentials** — client ID + secret, **baked into `Helpers/OAuthCredentials.cs`** (desktop "Desktop app" OAuth client; loopback callback — no console redirect URI registration needed; creators never configure)
2. **Local HTTP listener** — `HttpListener` on `http://localhost:PORT/oauth2/callback` to catch the redirect
3. **Browser launch** — open the authorization URL in the default browser
4. **Token persistence** — store access/refresh tokens securely (Windows DPAPI), reload on startup
5. **UI state** — account shown in the Start Stream dialog; "Change Account" action triggers re-auth
6. **Go Live gated on auth** — Start Stream dialog requires sign-in to enable the Start button; scene building works without it
### Tests:
1. Mock token exchange response, verify channel info parsed
2. Verify token refresh triggers when near expiry
3. Verify credential load/save roundtrip
+130
View File
@@ -0,0 +1,130 @@
# TASK 3 — Capture Pipeline (Scenes/Sources)
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**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)
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
3. ✅ **Schema v4** — round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load
4. ✅ **Screen backdrop (ship task #1, schema v5)** — live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing
5. ✅ **Schema v6 — backdrop Live-only by policy** — `Scene.HasBackdrop`, enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix
6. ✅ **The five-scene catalog (`SceneCatalog`)** — Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones) — 62 tests passing
7. ✅ **Webcam-after-session-start fix** — a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 65 tests passing
8. ✅ **Chat scene webcam size cap** — raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better
9. ✅ **"Add Webcam" always opens the camera picker** — deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"
10. ✅ **Webcam resource validation + first-frame proof** — `MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties ≥1, `reader.StartAsync()` status read + throws on non-Success); subscribes `capture.Failed` + `CameraStreamStateChanged` → `SourceFailed` event on the seam; fallback ladder (VideoPreview → VideoRecord). `CameraManager.AcquireAsync` requires first-frame proof (4s timeout): returns true only after a real frame arrives — silent empty box impossible. `MainViewModel` subscribes `CameraFailed` → red `WebcamError` chip in preview + MessageBox names suspect apps (`CameraConflictProbe`). 19041 SDK projection gaps: `Exclusive`/`DeviceLost` not projected; `CameraStreamState.Failed` compared by `(int)2`. 81 tests passing
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule). **Post-test follow-up (2026-08-13, game audio bar branch):** the **game audio bar** (desktop/game, a mirror of the mic bar: meter + mute + volume) is **overlaid at the bottom of the preview window** (bottom-center dark chip — monitoring UI only, never on the live output) and appears only while a **full-screen game is producing sound** in the preview (`IGameAudioDetector` seam + default `GameAudioDetector` polling `IFullScreenDetector` + the live loopback level, floor 0.5%, into a pure `GameAudioHysteresis`: SHOW after ~500ms of fullscreen+sound, HIDE ~1s after leaving fullscreen, **silence never hides an active bar**; the VM polls it on a 250ms `DispatcherTimer`); **capture now runs for the app's lifetime** (mixer started once at startup via `StartMicCaptureAsync`, disposed in `Shutdown` — not go-live) so the meters preview live; the **MIC label became a button with a status dot** (`Models/MicStatus`: green = connected via the source `Started` event, yellow = mic problem, red = no device); picking a mic takes effect immediately (`AudioMixer.RestartMic`, loopback keeps running); fixed a latent `?.Invoke(meter.Push(...))` short-circuit that skipped the meter update when nothing was subscribed. **Round 2 (2026-08-13):** the meters were dead on a flat scale (real speech/game RMS is ~0.01..0.1 linear) — the raw level is now mapped via `AudioLevelMeter.ToDisplay` (−60..0 dBFS spread across 0..1) so typical input reads ~1/3..2/3 of the bar at default volume. 169 tests passing, 0 warnings. **Round 3 (2026-08-13):** the mic bar gained a **mic mute icon** (a microphone glyph in the speaker's 16px style, red + slash when muted) between the meter and the speaker — both mutes adjacent with spacing between them, same `ToggleMicMuteCommand`; and the top-center LIVE badge became an **always-visible REC sign** — dark gray dot + dim "REC" offline, bright red (#e94560) + "REC" + elapsed while live, **darker red (#8f1f1f)** when live with a **private** stream (driven by the dialog's chosen visibility) — `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`, pulsing while live. **Queued:** task 22 (voice filters). Voice-filter note: the meter's `ToDisplay` input is the pre-filter mic level; when filters land they must apply BEFORE the meter/encoder mix
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
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)
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)** —
free and ungated (see Monetization in `ai.md`). **v1 IN** per the complete-v1 ruling. The
deliverable is now **native events + native animations, no third-party widget**: the chat feed
(`liveChat/messages`, six types decoded — TASK 43 half 1) drives a `SourceType.AlertBox`
("Stream Alerts", one celebration zone per layout) where each event plays its **own unique
branded animation** (`AlertRenderer`/`AlertOverlayLayer`, 33ms ticker, `Advance` test clock).
**Creator rulings:** a free-subscriber mention is a plain chat row — NO sub alert (YouTube
emits no public event for free subs), NO viewer count (vetoed), and every card draws the
"made with LlamaCasty!" brand line (each broadcast = free product placement). **Precursor
(still open): the reward-event persistence half — the canonical `RewardEvents` SQLite table
(all six types persisted per broadcast) + `superChatEvents.list` backfill + the session-report
rollup (TASK 10 → "Related work — monetization awareness"). The shipped alert path parses
events into `ChatMessage` directly in memory; the report reads that table once it exists.**
21. ✅ **Show Desktop toggle** — `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem).
22. ✅ **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` → GoLiveWindow. `ShowStartStream` now requires `IsConnected`.
23. ✅ **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear.
24. ✅ **All backdrops seeded + EnsureDefaultBackdrop** — Live, Chat, Ending backdrops added (ending-backdrop.jpg replaced). `EnsureDefaultBackdrop()` called on every scene switch. `SeedEndingBackdrop` replaces existing (not skip-if-exists). Chat scene no longer skipped.
25. ✅ **SceneCatalog fix** — Chat scene display name corrected to "Chat" (was "YouTube Chat" — that name belongs to the layer/SourceType.ChatBox, not the scene).
26. ✅ **Left panel spacing + context menu reorder** — HR margins matched, Row 4 changed from `*` to `Auto`. Context menu reordered: Border Thickness first, then Opacity, Color, Effect.
21. ✅ **Logo + richer in-app About (2026-08-13, queued → 2026-08-14 SHIPPED)** — the ytLlive wordmark in the top bar opens the About overlay (already wired); the About overlay is now the **creator hub**: the real logo (the "llama fortnite superman logo" from the creator's vault, copied to `Assets/llama-logo.png` — the creator's own art, no third-party license), plus **creator-hub links** — llama chile shop on YouTube (`MainViewModel.ChannelUrl`), Mastodon (`https://mastodon.llamachile.tube/@gramps`), **Buy me a coffee** (`https://buymeacoffee.com/llamachiley` — live), and **Unlock Premium** (greyed "coming soon" — the **billing** product URL is a tabled seam, `PremiumUrl`, until TASK 10 picks the subscription provider). A **Licenses & legal** button flips the About overlay to an in-app scrolling panel that loads the full `THIRD-PARTY-NOTICES.txt` text (`MainViewModel.ShowLicenses` reads the shipped file from the executable directory on first open; graceful "not found" fallback — **never the OS viewer, everything stays in-app**); "← Back to About" returns. The About overlay is also the in-app home of the notices — the top-bar About button that opened the file in the OS viewer was removed on 2026-08-13 for exactly this reason. Integration test (Good Dog Rule — ONE): `AboutHubTests.About_Opens_InApp_Licensing_Loads_Shipped_Notices` drives the real window + VM, asserting the hub opens, the link URLs are real, the licensing panel loads the shipped notices text (contains "Third-Party Notices" + "LGPL"), and back returns to the hub. 174 tests passing, 0 warnings
22. ✅ **Voice filters on the mic channel (2026-08-13 queued → SHIPPED 2026-08-14 inside TASK 9, the audio milestone)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Noise suppression = a **pure-C# noise gate** (creator chose over RNNoise / a second ffmpeg `afftdn` pipe, 2026-08-14 — KISS). Always-on — no UI knobs; the mic stays the creator's single audio control
27. ✅ **Webcam row gates on the app-wide identity (2026-08-24, TASK 26)** — the (+) menu's Webcam item greys out whenever a webcam exists **anywhere** (`CanAddWebcam` = `StagedScene != null && _webcam == null`; renamed from `CanAddWebcamToStagedScene` whose per-scene rule let a second picker run from a scene lacking the config), raised at both `_webcam` mutation sites (create / last-config removal) + scene staging + elements change. Creator's visual pass found it: minis/no-capture-rows/capture-controls all good. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity` (real window + temp DB seeded with a webcam in Starting; asserts greyed while Live staged, re-enabled after `RemoveSourceCommand` clears the last config + the `Webcam` DB row). Stale map fixed in the same commit: ai.md's "empty-canvas right-click Show Webcam" claim dropped — that XAML never shipped (`ShowWebcamCommand`/`CanShowWebcamInStagedScene` are wired but unbound dead code, audit item). 224 tests (223 pass; the pre-existing AudioPipelineTests failure is unrelated)
28. ✅ **YouTube Chat layer: one-per-layout gate + legacy label heal (2026-08-24, TASK 27)** — same rule as TASK 26 applied to the chat layer: the (+) menu's YouTube Chat item greys out while any scene carries a ChatBox source (`CanAddYouTubeChat`, raised on staging + elements change; `AddSource` refuses a second), tooltip "One chat layer at a time — it's already in your stream". Plus the creator's label fix: layers added by commit `65641d8` were named "Chat"; LoadLayout now heals exactly that un-renamed default to "YouTube Chat" so the Layers row matches the (+) picklist (creator renames untouched, idempotent). ONE integration test `ChatLayerGateTests.LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer` (real window + temp DB with a legacy "Chat" row: healed on load + persisted on save, greyed cross-scene and in-scene, re-enabled after remove). 225 tests (224 pass; pre-existing AudioPipelineTests failure unrelated)
29. ✅ **Broadcast metadata pull-out + launch geometry (2026-08-24)** — creator ask: "a tab-pullout on the right side of the preview pane — white tab, YouTube-red label reading 'Text' — opening a form with all settable liveBroadcast fields; fields that can't be filled before launch greyed out; saving/updating remote content; field data saved and pre-loaded on app run". Shipped as specified with one correction: the API is the inverse of "more fields once live" for contentDetails (those lock in created/ready) — the pull-out carries the always-editable snippet/status set: Title, Description, Tags (csv), Visibility (private/unlisted/public), Made-for-Kids, plus read-only Scheduled Start. **Always visible** (creator revised same day from Live-only gating). White 30px tab, rotated red "Text", right edge vertically centered; click slides a dark 320px drawer left over the preview (200ms CubicEase). Every edit persists to Settings keys (`Broadcast.*`) immediately; **Update Broadcast** button calls the new `YouTubeStreamService.UpdateBroadcast` (PUT `liveBroadcasts?part=snippet,status`, echoes scheduledStartTime because update replaces the whole snippet part). Go Live prefills from the form and captures what was inserted (`CaptureGoLive`). The old un-persisted Default Stream Title/Description fields came OFF the App Settings overlay (replaced by the form); `DefaultStreamTitle/Description` VM properties deleted. **Launch geometry** (same unit): default 1920×1040, MinWidth 1366, MinHeight 768; `WindowStartupLocation=Manual` + window size/position persisted on close via `RestoreBounds` (maximized-safe), restored in ctor clamped to minimums and the primary work area (disconnected-secondary fallback). ONE integration test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`. 228 tests (227 pass; pre-existing AudioPipelineTests failure unrelated)
30. ✅ **Gear menu cleanup + Default Location for Recordings + single Start button (2026-08-29)** — **KISS: removed** Gear-menu `Save Layout`/`Save Layout As…`/`Open Layout…` (redundant with auto-save; multi-layout = OBS) + dead `SaveLayoutCommand`/`SaveLayoutAsCommand`/`OpenLayoutCommand` + `SaveLayoutAs()`/`OpenLayoutFile()` methods. **App Settings panel:** first real setting — `Default Location for Recordings` (configured folder via `ChooseRecordFolderCommand` + "Use Downloads" reset via `ResetRecordFolderCommand`, resolved path shown). Fallback chain: configured → `%USERPROFILE%\Downloads` → `MyVideos`. `StartRecordFile()` + `ChooseRecordFolder()` `InitialDirectory` updated to use the new fallback. **Single Start button:** `PrimaryButtonText`/`IsStreamingStart` + their `OnPropertyChanged` calls removed; button `Content="Start"` in XAML — pills are the intent surface, button is always just Start. `Choose Record Folder` fast-path kept in the button context menu. Monetization (About links/premium) deferred to a later task. ONE fallback test `DefaultRecordFolder_Falls_Back_To_Downloads`. build 0 warnings, 247 tests (2 known failures, pre-existing).
### The Minimal Source Set (design decision — do not expand casually)
ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship
the hot few and nothing esoteric. If a user needs more, they've graduated to OBS.
1. **Webcam** — the face cam. Non-negotiable.
2. **Screen** — the main event (game, slides, browser). One source; a picker chooses a monitor *or* a
window. (Window capture is absorbed here — no separate source type.)
3. **Background** — a full-canvas backdrop image. Fills the whole scene automatically, zero fiddling.
Kept separate from Image on purpose: same pixels, but this one needs no positioning.
4. **Image** — a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned.
5. **Text** — live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this.
6. **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native.
7. **Alerts** — Super Chat / membership pop-ins. The dopamine source. **The one big lift — DONE
(2026-09-24, TASK 43): native events + native animations.** One `SourceType.AlertBox`
celebration zone; six event types = six unique branded animations; free-sub mention = chat row
only; no viewer count. **Free and ungated** — paid = flash removal only (corrected 2026-09-01).
Pending: the `RewardEvents` SQLite persistence half (item 20; the report + journey read it,
TASK 10 → related work).
Deliberately NOT supported: game capture, browser source, media playlist, VLC, color-key voodoo, MIDI.
### Source memory model (design decision)
1. A scene has resources. Resources can be shared across scenes.
2. A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
scenes = one loaded bitmap).
3. Every resource carries a **catalog of scenes**: one usage entry per scene it appears in, each
entry dictating that scene's use — placement (X/Y/Width/Height), opacity, z-order, enabled,
scale mode, crop.
4. Usages are named `{resourceName}.{sceneName}` — whatever the user named the resource, dot, the
scene name: `logo.starting`, `logo.live`, `myPic.brb`. Not a hardcoded "logo".
5. A webcam in two scenes = one capture session, two catalog entries.
6. Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
7. The resource (not a per-scene node) owns everything `IDisposable`.
### Scene transitions (design decision)
Scene switching while live must never stutter. Supported types, most → least economical:
1. **Cut** — instant switch. The default. Zero cost.
2. **Fade** — short crossfade (~300ms).
3. **Move** — a simple, economical move transition, done to perfection and memory-efficient. The
smart streamer's bread and butter.
4. **Custom (media) transitions** — require media elements (video/stinger playback during the
transition). Heavier, but creators pay for these, so we support them. Their media follows the
same resource memory model: loaded once, catalogued by scene.
Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four above.
### Requirements:
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
2. **Webcam** — MediaCapture (WinRT SDK projection) with device enumeration — ✅ **milestone 1 done**:
1. TFM bumped to `net8.0-windows10.0.19041.0` (app **and** tests) so the WinRT projection resolves from the SDK reference packs — no NuGet package, no capability manifest (unpackaged desktop app)
2. `MediaCaptureFrameSource` (CPU-first: `MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`), `MediaCaptureCameraEnumerator` (`DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)`)
3. `CameraManager`: refcounted by `DeviceId`, one shared `WriteableBitmap` app-wide, dispatcher-coalesced UI updates (~render rate, latest-frame drop), placeholder/`AppLog` + warning on failure
4. `CameraPickerDialog` (mirror of `ReuseImageDialog`) — "Searching for cameras…" / list / "No cameras found" states
5. One webcam app-wide: Add → Webcam greyed out once one exists ("it's already in your stream" tooltip); persisted `DeviceId` re-acquires after layout load
6. Default placement 16:9 **480×270**, bottom-right, 32px margin; drag/resize/selection shared with Image sources
7. **Clip shapes: Traditional + Round** (phone view dropped — the 9:16 phone output is the vertical output-crop tier); **mirror**; both persisted in the layout DB (schema v2) and toggled from the source chip
8. **Background removal = milestone 2** (ONNX Runtime + DirectML, MediaPipe Selfie Segmentation) — not in this build
3. **Background / Image / Text** — static sources positioned/scaled/opacity
4. **Chat box** — rendered from the live chat poll (right panel is the same feed, raw)
5. **Scene compositing** — per-scene source layering (z-order = sources list order, top-to-bottom
back-to-front), preview rendered via D3DImage or MediaElement
6. **Branding flash** — the topmost full-frame "made with ytLlive!" layer at ~25% opacity, ~1s on /
300s off (see Monetization in `ai.md`), gated on `BrandFlashEnabled` + live/recording. Lives in the
preview compositor now (`BrandFlashLayer` in `MainWindow.xaml` CanvasGrid, driven by
`BrandFlashActive`/`BrandFlashTimer` in `MainViewModel`); the encoder output renders the same layer,
and v0.2 local recordings carry it too
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
+339
View File
@@ -0,0 +1,339 @@
# TASK 4 — RTMP Ingest to YouTube
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** Push encoded video to YouTube's RTMP ingest.
### Status: ✅ Done — all 7 items shipped
1. ✅ **Ship step 1 — the output compositor SHIPPED** (2026-08-10)
2. ✅ **Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10)
3. ✅ **Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below)
4. ✅ **WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below)
5. ✅ **Frame-pipeline wiring SHIPPED** (2026-08-12) — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below)
6. ✅ **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 9 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets)
7. ✅ **One-click go live + private-only enforcement SHIPPED** (2026-08-14) — the Go Live dialog is **locked to Private** (no dropdown, `GoLiveViewModel.Visibility` is a get-only "Private"); `YouTubeStreamService.CreateBroadcast` **always sends `privacyStatus = "private"`** (dialog + service enforcement, requirement 8 — nothing can go out non-private) and gained an injectable `HttpClient? http = null` seam for tests; `BeginGoLive` now calls `CreateBroadcastAsync` and remembers `_currentBroadcastId` for TASK 9's bind/transition (failure → `StreamStatus.Error`, never a crash; `StopStream` clears the ID); the REC sign shows a **PRIVATE badge** (dark-red border, next to REC, `IsLivePrivate`) when the live stream is private; settings' dead "Default Visibility" dropdown + `MainViewModel.Visibilities`/`DefaultStreamVisibility` removed. The broadcast-insert integration test asserts the request body carries `"privacyStatus":"private"` (2 new tests → 173 passing, 0 warnings)
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
without the preview's editing chrome) → **audio capture** (WASAPI, feeds the meter) → **H.264+AAC encode**
→ **vertical-tier crop/scale** → **RTMP push** → **health stats** into the bottom bar. Nothing can encode
until a frame source exists, so the compositor is ship step 1. The pipeline is
`CameraManager + ScreenCaptureManager → compositor resolver → compositor → encoder → RTMP`.
### Requirements:
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are. **License guardrails (never violate — see `ai.md` → "Licensing — do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen.
2. **RTMP push** — **FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push. **No self-heal (claim-check 2026-09-01): ffmpeg's reconnect flags are input-side; an RTMP *output* push does not reconnect itself — retry is app-side, owned by TASK 32.** **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (**BtbN LGPL-shared win64** zip, ~75 MB — gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract `ffmpeg.exe` **plus the `libav*.dll` family**) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
1. 720p30 @ 6 Mbps
2. 720p60 @ 6 Mbps
3. 1080p30 @ 8 Mbps
4. **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
machine never notices; upload headroom stays comfortable)
5. Vertical 1080×1920 @ 60fps @ 8 Mbps (9:16 phone tier)
The composition master is always 1920×1080; a tier is an output rect + target resolution
(see `ai.md` "Resolution tiers"). Vertical output = the centered 607×1080 crop of the master
scaled to 1080×1920 (semi-crop preview is already implemented; the encoder applies the same rect).
1080p60 is the ceiling by design — "if you want 1440 or 4K or 8K → OBS is your solution"; the app
targets the most mainstream creator, not power users.
Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required).
Quality is greyed out while live because the declared resolution can't change mid-stream — but
with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls
(no stream recreation); 60fps presumes a hardware encoder — no hardware encoder → auto
fallback to 720p60/1080p30
4. **Stream key management** — reuse the cached reusable stream (one per channel) instead of creating a new one per go-live; prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2`
5. **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
6. **One-click go live** — defaults that work out of the box
7. **Audio capture (feeds the meter — this task ships the wiring)** — WASAPI loopback (desktop/game at unity, zero UI — "it just is") + the picked mic (`MicSourceName` from the `MicPickerDialog`). The mic capture feeds `AudioLevel` so the realtime meter comes alive (today it reads 0 — the mixer feed is pending, see `ai.md` audio notes). AAC mono/stereo @ 48 kHz per the compliance rules.
8. **Private-only go live until v1 (reputation guard, decided 2026-08-10)** — until the v1 release, go-live is **locked to private streams only** so a software error can never publish something public/unlisted that damages the creator's reputation. RTMP push itself has no privacy — privacy lives on the YouTube **live broadcast object**, which this app already controls via its OAuth API calls. So the lock is purely API-side: the Go Live flow always creates/updates the broadcast with `privacyStatus = "private"` and a guard **refuses** to set anything else (same spirit as the Live-only backdrop policy). The UI shows a clear "PRIVATE" badge next to the stream state so the creator always knows who can see them. Enforcement must be verifiable in the auth-service tests (fake the broadcast-insert/update call, assert `privacyStatus` is forced to private).
9. **v1 release gate: bundle the full license texts (decided 2026-08-10)** — `THIRD-PARTY-NOTICES.txt` currently links the canonical license texts rather than embedding them. At the **v1 (GA) release**, the full texts of every license it names (LGPL v2.1+, BSD-2-Clause, MIT, Apache-2.0) MUST be bundled alongside it (shipped in the app output, e.g. a `licenses/` folder next to the notices file, still reachable from the About screen). This is a **release blocker for v1, not a task to queue early** — do it in the release pass. The repo should treat this like the private-only go-live gate: a checkbox that cannot silently lapse.
#### Ship step 1 — Scene compositor (the frame source)
**Goal:** a pure-CPU software compositor producing the encoder's master frame (BGRA8, the `VideoFrame`
seam) from the scene model. The preview stays XAML (the editing view); the compositor is the **output
view** — WPF's `RenderTargetBitmap` can't be used (software-rendered + captures chrome). Two renderers
must agree, so the XAML (`MainWindow.xaml` CanvasGrid + element DataTemplate) is the contract.
**Decisions (locked 2026-08-10):** **Path A CPU blitter** — GPU effort belongs to NVENC (the encoder),
not composition; with an FFmpeg subprocess the master crosses a CPU readback to the pipe every frame
anyway, so GPU compositing buys ~nothing at this layer count (2-3 live layers; static layers
pre-composite once). A D3D11 compositor can replace this one later **behind the same seam** (the CPU
master buffer stays the contract). **Render the output rect directly**: compositor is constructed with
`CompositorOptions {SourceRectX/Y/W/H, OutputWidth, OutputHeight}`; 16:9 tiers = full 1920×1080 1:1;
vertical (9:16) = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920. Reuses
`MainViewModel.OutputRectX/Y/W/H` (note `(1920−607)/2 = 656.5` → align to integer pixels for output).
**Render spec (back → front, mirror the XAML exactly):**
1. Backdrop — the Live scene's `IsBackdrop` Source (`CaptureKey` → live frame), `UniformToFill`
full-frame (XAML's separate `BackdropImage` layer; the backdrop *element* renders nothing — its
DataTemplate Image is Collapsed for DisplayCapture).
2. Background — the scene's `Background` Source, `UniformToFill` full-frame (the `ActiveBackgroundImage`
layer, not per-element).
3. Elements in `Scene.Elements` order (back→front), skip `IsVisible=false`. What actually renders:
1. `Source` Type `Image` → static asset, `UniformToFill` cover-crop into (X, Y, W, H)
2. `WebcamSceneConfig` → latest frame by `DeviceId`: Traditional = `UniformToFill` rect; Round = circle
diameter `min(W,H)` (alpha 0 outside — true circle, not oval); mirror = horizontal flip around
element center (`MirrorScale`); opacity = per-pixel multiply (content + border); border = stroked
rect / centered circle at `RoundBorderSize`, width `BorderWidth`, alpha `BorderOpacity`
3. `Background` / `IsBackdrop` / `TextOverlay` are NOT per-element (layers above; Text not shipped)
4. Branding flash — pre-rendered full-frame "made with ytLlive!" at 25% alpha when live +
`BrandFlashEnabled` + timer active. Passed in as a `VideoFrame?` (compositor core stays pure byte-math,
no WPF; likely a bundled asset rather than runtime text rendering).
5. NOT in output (preview chrome only): SelectionOverlay, DimRects, output-rect outline, badge, placeholder.
**New files (all in `Services/Compositor/`):**
1. `SceneCompositor.cs` — `Render(Scene, frameFor: Func<SceneElement, VideoFrame?>, flashFrame:
VideoFrame?, CompositorOptions) → VideoFrame` (output-sized). The caller's `frameFor` resolver maps
each element to its frame (webcam → DeviceId, image → AssetId via `StaticPixelCache`, backdrop →
CaptureKey) — the compositor stays pure/hermetic/no WPF.
2. `CompositorOptions.cs` — source-rect + output W×H.
3. `StretchMath.cs` — `UniformToFill` cover-crop, ellipse mask, bilinear scale (pure, unit-tested).
4. `StaticPixelCache.cs` — asset `byte[]` → cached BGRA `VideoFrame` (WPF `BitmapDecoder` + `CopyPixels`,
decode once per content hash).
**Test plan (Good Dog Rule — ONE integration test):** `SceneCompositorTests` — a scene with backdrop
(solid red fake frame) + round webcam (solid green) + image (solid blue) → render 16:9 master → assert
per-layer probe pixels (corner = backdrop color, element center = webcam color, outside the round clip =
backdrop color, mirrored element swaps left/right); a vertical-tier variant asserts 1080×1920 output +
crop fidelity. Focused unit tests on `StretchMath`. Tests push frames directly — no capture managers
involved (they wire in a later step).
**Same-PR housekeeping:** fix the stale comment `MainViewModel.cs:324` ("shown under the meter on line 2"
→ "shown left-justified INSIDE the meter bar" — `ai.md` is the authority); this task's requirements now
include the explicit audio-capture/meter wiring (#7 above).
**Out of scope (later ship steps):** FFmpeg locator + license posture (covered in requirements 1-2),
encoder + RTMP push, WASAPI audio capture (loopback + mic) feeding `AudioLevel`, wiring
`CameraManager`/`ScreenCaptureManager` into the frame pipeline, brand-flash timer wiring, health stats
(bitrate/FPS/dropped).
**Built (2026-08-10):** all four files shipped in `Services/Compositor/`, `SceneElement.TryGetBorderColor`
made public (shared hex parse with the compositor — no duplicated color parsing), the stale
`MainViewModel.cs:324` comment corrected, and the pre-existing CS1998 in `YouTubeAuthServiceTests`
cleaned up — build **0 warnings**. Tests: the `SceneCompositorTests` integration test (full-scene master
pixels, vertical tier, flash) + 4 `StretchMath` units — **72 passing**.
#### Ship step 2 — FFmpeg locator (the encoder's binary)
**Goal:** resolve a usable `ffmpeg.exe` on demand (the encoder's one external dependency), never shipping
a binary in the repo. Returns an absolute path; downloads only when neither PATH nor the local cache
provides one.
**Decisions (locked 2026-08-10):**
1. **BtbN LGPL-shared win64 build** — not gyan.dev (gyan's "essentials" is GPLv3 and ships libx264, which
violates requirement 1's license posture) and **not the static lgpl build**: LGPLv2.1 §6 wants
relinkable object files for static linking, but the **shared** (dynamic-DLL) variant sidesteps that —
compliance is "license text + source offer + unmodified binaries" (see `THIRD-PARTY-NOTICES.txt` and
`ai.md` → Licensing). Drops libx264/libx265 while keeping NVENC/QSV/AMF, libopenh264 (the LGPL-legal
H.264 software fallback) and native AAC — exactly the requirement-1 encoder profile.
2. **Pinned URL** — **RE-PINNED 2026-09-01**: `https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip`
(BtbN **lgpl-shared** variant, ffmpeg N-126342; ~75 MB zip — earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention
keeps the last 14 daily builds + each month-end build for 2 years, so a cold cache after retention
expiry 404s — a logged, recoverable failure (the seam throws; the encoder step surfaces it). The
first pin (`autobuild-2026-08-09-13-03`, a daily) aged out on 2026-09-01 — the first real recording
attempt — proving the rule; the new pin is deliberately the **month-end** build (2-year retention).
Dated tags carry versioned asset names (`ffmpeg-N-…-win64-lgpl-shared.zip`), extraction is
name-agnostic; download failures now wrap in `IOException` with an actionable message
("refresh `FfmpegLocator.PinnedUrl` or install ffmpeg on PATH") instead of leaking a raw 404. Once
cached, the URL is never touched again. The pin is a single `const`, bumpable in one place — and must
always stay on the **shared** variant (never `gpl`, `nonfree`, or static; see ai.md Licensing).
3. **Check-then-pull order** — (1) PATH probe (the user's own install wins), (2) cached
`%APPDATA%\ytLlive\tools\ffmpeg.exe`, (3) download + extract. Extract `ffmpeg.exe` **plus the
`libav*.dll` family** (the shared build's bin/ folder; Windows resolves the DLLs from the exe's own
directory) into a staging dir then move into place — a crash never leaves a corrupt or partial cache.
4. **Seam** — `IFfmpegLocator.LocateAsync(CancellationToken)`: search dirs, tools dir, and the downloader
(`Func<string, CancellationToken, Task<byte[]>>`) are constructor-injected with production defaults, so
tests fake the network (feeding a real in-memory zip) and never touch disk outside a temp dir.
**New files (all in `Services/Encoder/`):**
1. `IFfmpegLocator.cs` — the seam.
2. `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`.
3. `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build
output. *(Surfacing changed on 2026-08-13: the top-bar About button that opened the file in the OS
viewer is GONE — the notices are reachable in-app via the logo → About overlay instead.)*
**Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and
a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without
downloading, cache hit skips the network, cold cache downloads → extracts → `ffmpeg.exe` lands in the
tools dir, and a second call serves the cache (downloader invoked exactly once). Focused unit tests:
**shared-build DLLs extract alongside the exe**, empty zip throws, missing entry throws, empty download
throws, downloader failure propagates, zero-byte cache is refreshed.
**Same-PR housekeeping:** requirement 2's stale binary facts corrected in this plan (~30 MB → ~75 MB zip;
"gyan.dev/BtB N" → BtbN LGPL-shared only, with the why); the "never do" licensing guardrails recorded in
`ai.md` so the reasoning survives.
**Out of scope (later ship steps):** the FFmpeg subprocess encoder (frames in via stdin, stderr health
parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health stats.
**Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the
**lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output; the "never do" licensing
guardrails are recorded in `ai.md` — build **0 warnings**.
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
#### Ship step 3 — Encoder + RTMP push (the FFmpeg subprocess)
**Goal:** encode raw BGRA master frames into H.264+AAC FLV and push them to the reusable stream's RTMP
ingestion URL — one battle-tested subprocess doing encode + mux + push (retry is app-side, TASK 32 — ffmpeg does not self-reconnect an RTMP output), the app feeding
frames via stdin and parsing stderr for health (req 2).
**Decisions (locked):** the encoder is a thin orchestrator over `ffmpeg.exe` — no H.264/AAC code in the
app. Arguments (pure `FfmpegArgs.Build`): `-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS
-i pipe:0` (frames in), a **silent placeholder audio track** via `-f lavfi -i anullsrc` (the WASAPI
capture step replaces this input), `-c:v <encoder> -b:v K -maxrate K -bufsize 2K` + **`-g fps×4`
`-keyint_min fps×4` `-sc_threshold 0` `-bf 0` `-pix_fmt yuv420p`** (the keyframe ≤4s / closed-GOP /
H.264 compliance), `-c:a aac -ar 48000 -ac 2`, `-f flv <rtmpUrl>`. Encoder choice is **probed from the
binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure): hardware NVENC → QSV → AMF, then OpenH264
software fallback — **never libx264** (GPL; see `ai.md` → Licensing). The seam (`IFfmpegEncoder` +
`IEncoderProcess`, constructor-injected locator + process factory) keeps it hermetic — tests fake the
whole subprocess (probe + encoder), no real binary.
**Behavior:** `StartAsync` (locate → probe → spawn → stderr loop), `SubmitFrameAsync` (serialized BGRA
stdin writes, ~2 Hz health via `HealthUpdated`/`StreamHealth` — bitrate/FPS/duration/dropped-from-frame-
count), `StopAsync` (stdin EOF → ffmpeg finalizes + exits by itself; 10s watchdog kill), `ProcessFailed`
on a non-zero unexpected exit.
**Built (2026-08-12):** `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/
`FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` in
`Services/Encoder/`. Not yet constructed by the app (the frame-pipeline wiring, ship step 5, owns it).
Tests: `FfmpegEncoderTests` integration (probe → spawn with NVENC preferred → frames into stdin →
progress parsed → graceful stop, no kill) + units (args compliance/GOP, progress parser, picker
preference + GPL guard, no-URL/not-running/noop stops, process-death `ProcessFailed`) — **122 passing**.
#### Ship step 4 — WASAPI audio capture (the meter comes alive)
**Goal:** capture desktop/game audio (loopback) and the picked mic, feed the mic level into `AudioLevel`
so the realtime meter reads something other than 0, run capture **only while live** (req 7).
**Decisions (locked):**
1. **NAudio `NAudio.Wasapi` 2.2.1** — the wasapi feature package (not the `NAudio` meta-package): it
carries the capture types (`WasapiCapture`/`WasapiLoopbackCapture` + the MMDevice enumeration) with
`NAudio.Core`/`NAudio.Asio` pulled in transitively. MIT — recorded in `THIRD-PARTY-NOTICES.txt` (item 9).
2. **`IAudioSource` seam** (`Start`/`Stop`/`SampleReady`/`Failed`, IDisposable) — the app consumes the
seam; the two WASAPI implementations wrap NAudio; tests inject hermetic fakes (no real audio devices,
no timers). Loopback = `WasapiLoopbackCapture` on the default render device; mic = `WasapiCapture`
with the NAudio device resolved by `FriendlyName` matching `MicSourceName` (the app only persists the
DisplayName), falling back to the default capture endpoint. Mic device resolution is re-read at each
`Start` via a name provider so a mic picked mid-session takes effect next go-live.
3. **`AudioMixer` owns both sources** — starts/stops both with go-live (`BeginGoLive` success → `Start`,
`StopStream` → `Stop`). Mic samples feed a pure `AudioLevelMeter` (RMS, exponential smoothing) and
raise `MicLevelChanged`, marshalled to the UI thread into `AudioLevel`; desktop samples are currently
dropped (consumed by the encoder's AAC mix in a later step). Capture failures are logged via `AppLog`
(mic failure also zeroes the meter); loopback failure doesn't kill the mic.
4. **Byte→float** — pure `WaveToFloat.Convert` handles the WASAPI mix formats: IEEE float 32-bit (direct)
and PCM 16-bit (normalized to -1..1), including `WaveFormatExtensible` with the IEEE-float subformat
GUID. Trailing partial samples are ignored.
**Built (2026-08-12):** `Services/Audio/` ships `IAudioSource` + `AudioSample`, `WasapiLoopbackAudioSource`,
`WasapiMicAudioSource`, `AudioMixer`, `AudioLevelMeter`, `WaveToFloat`; `MainViewModel` constructs the
mixer (mic source fed `() => MicSourceName`), starts it on go-live and stops it on end-stream, and maps
`MicLevelChanged` → `AudioLevel`. A pre-existing CS8602 in `FfmpegEncoder.cs:139` surfaced during this
step's rebuild and was fixed (`process!`) — build **0 warnings**. Tests: `AudioMixerTests` (mixer
lifecycle/forwarding/failure against fakes, meter RMS/smoothing/reset, `WaveToFloat` float/PCM16/
extensible/truncation) — **139 passing**.
**Deferred (later ship steps):** wiring the desktop-capture samples into the encoder's AAC mix (replaces
the `-f lavfi -i anullsrc` placeholder; the encoder construction itself shipped in ship step 5).
*(The "capture while not live" + "audio UI beyond the mic controls" deferrals were SHIPPED on the
2026-08-13 game audio bar branch — capture is now always-on for preview and the game bar is the second
audio UI. The mixer's short-circuit meter fix + `Started`/`RestartMic` seams live in the same branch.)*
#### Ship step 5 — Frame-pipeline wiring (the encoder gets a frame source)
**Goal:** the chain `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven while
live by a paced frame pump: snapshot the active scene → resolve each element to its latest frame →
composite into the tier's output frame → pace into the encoder's stdin at the tier's FPS.
**Decisions (locked via user Q&A, 2026-08-12):**
1. **Video pipeline first** — the `-f lavfi -i anullsrc` silent track stays; mixing the loopback/mic
WASAPI samples into the encoder's AAC track is its own later step.
2. **RTMP URL via a provider seam** — `MainViewModel._rtmpUrlProvider` is a `Func<string?>` returning
null today (the reusable stream's ingest URL lands with TASK 9); when it yields null the pump logs
and skips the encoder entirely, so go-live runs the existing visual flow without pushing.
**Design:**
1. `Services/Encoder/FramePump.cs` — the frame producer. All collaborators constructor-injected seams
(`Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, injectable pacing delay) so it
stays free of WPF and of the capture managers and is hermetic in tests. `StartAsync` never throws
(failures log + surface via `Failed` — the VM fires-and-forgets from the sync command handler);
loop = snapshot → render → `SubmitFrameAsync`, paced at `1/options.Fps` (default `Task.Delay`; tests
inject `Task.Yield`). `StopAsync` stops the encoder (closes stdin) BEFORE awaiting the loop — closing
stdin unblocks a write stuck on pipe backpressure, so stop can't deadlock on the pump. `ProcessFailed`
self-stops the pump. `HealthUpdated` forwards the encoder's stats (ship step 6 binds the bottom bar).
2. `ScreenCaptureManager.GetLatestFrame(key)` — mirrors `CameraManager.GetLatestFrame(deviceId)`; the
backdrop's live frame for the compositor.
3. `MainViewModel` — owns the resolver (`WebcamSceneConfig` → `GetLatestFrame(WebcamId)`;
`Source.IsLiveCapture` → `GetLatestFrame(CaptureKey)`; image/background → `StaticPixelCache.Get(AssetId)`),
builds `CompositorOptions` from the tier + `OutputRect*` (doubles rounded to ints — the vertical
607.5 half-pixel crop rounds to a perfectly-centered 608), builds `EncoderOptions` from the tier when
the URL provider returns one, constructs the real `FfmpegEncoder(new FfmpegLocator())`, starts the
pump on go-live, stops it on end-stream, disposes in `Shutdown`, and flips `StreamStatus.Error` when
the pump fails while live (minimal — detailed health surfacing is ship step 6).
**Test plan (Good Dog Rule — ONE integration test):** `FramePumpTests.Start_CompositesScene_FeedsEncoder_StopsCleanly`
drives the full lifecycle against fakes — real `SceneCompositor` + real `FramePump`, fake `IFfmpegEncoder`
— asserting the composited red backdrop frame actually reaches the encoder at the tier size and that stop
tears everything down. Units: no-URL start skips the encoder, re-entrant start/stop no-ops, encoder
start-failure raises `Failed` + disposes, `ProcessFailed` self-stops the pump, `HealthUpdated` forwards.
`ScreenCaptureManagerTests.GetLatestFrame_ReturnsLatestPump_UntilReleased` pins the new accessor.
**Out of scope (later ship steps):** the loopback/mic → AAC mix (replaces `anullsrc`), health stats in the
bottom bar (ship step 6), scene-switching transitions, and any flash-frame wiring.
**Built (2026-08-12):** `FramePump` shipped in `Services/Encoder/`, `ScreenCaptureManager.GetLatestFrame`
added, `MainViewModel` wired end-to-end (resolver + both option builders + pump lifecycle), `FramePumpTests`
(7) + `GetLatestFrame` test (1) added — build **0 warnings**, **147 tests passing**. Known consideration:
the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation
exception is contained (logged + `Failed` + pump stops) rather than crashing.
#### Ship step 5.5 — Social bar bug fixes + the bar on the live output (2026-08-13)
**Bug 1 — bar wouldn't reliably change position (root cause found, then simplified):** the original
drag set `Canvas.SetTop(bar, …)` with a local value, which permanently overrides the
`Canvas.Top="{Binding SocialBarTop}"` binding — the release-time `SetSocialBarPosition` →
`PropertyChanged(SocialBarTop)` could never beat it. First fix added direction-snapping during the drag
(`SocialBarSnap.Decide`, ±6px deadzone) + `bar.ClearValue(Canvas.TopProperty)` on release — but that
still misbehaved for shaky hands (jitter around the deadzone: it snapped up reliably, then refused to
come back down and snapped back to top). **Superseded by a click-toggle (KISS, user decision):** clicking
the bar in the preview flips it top ⇄ bottom (`MainViewModel.ToggleSocialBarPosition` → the existing
`SetSocialBarPosition`), the bar rides `{Binding SocialBarTop}` alone (no local values, no deadzone, no
jitter sensitivity), and `SocialBarSnap` was removed. The `ClearValue` lesson stands: never set a local
value on a property the binding owns.
**Bug 2 — Mastodon showed the generic 7-star honeycomb (root cause found):** the DB row
`@gramps@llamachile.tube` had `Software = NULL` — nodeinfo was only ever resolved against the identity domain
(`llamachile.tube`, a landing page), never probed for the real instance at `mastodon.llamachile.tube`.
Fixed on three fronts: `HttpSocialValidator` now **probes well-known subdomains** (mastodon. → social. →
… `FediverseSubdomainCandidates`) when the identity domain and its redirect both come up empty, under a
~15s linked-CTS budget, with an optional `Action<string>` log; `MainViewModel` **heals** any fediverse entry
missing a software name on layout load (`HealFediverseSoftwareAsync` — static, testable; instance wrapper
runs it off the UI thread, applies via the dispatcher, saves); `SocialEntry.FediverseSoftware` is now
**settable** and raises `PropertyChanged` for `LogoData`, so the heal updates the icon in place. If the user
later re-saves the entry with the icon fixed, the name persists with it.
**Compositor rendering (user-approved scope):** the social bar now appears **on the live output**, not just
the preview — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a transparent straight-alpha
BGRA strip (1920-wide, 40px content + 24px glow pad, green `#2ecc71` glow baked in) via `RenderTargetBitmap`
(WPF glue like `StaticPixelCache`; the compositor core stays pure). `SceneCompositor.Render` takes optional
`socialBarFrame` + `socialBarTop` (master space) and blits it **last — above the branding flash** (the old
`BlitFlash` generalized to offset `BlitOverlay`). `FramePump` gains a `socialBar:` seam
(`Func<(VideoFrame?, SocialBarPosition)>`, re-read every frame) and places the bar at `0` or
`SourceRectHeight − bar height`. `MainViewModel` owns the frame (`RenderSocialBarFrame`, re-rendered on
load/save/notify) and feeds the seam.
**Tests (+6 → 153 passing, 0 warnings):** settable `FediverseSoftware` updates `LogoData`,
subdomain-probe unit + null-when-silent unit, the branch's **one integration test**
`Socials_HealMissingFediverseSoftware_RoundTripsThroughDb` (temp-DB roundtrip: NULL software → healed via
the validator → persisted), compositor bar overlay (top/bottom + above-flash), `FramePump` bar pass-through
(Top then flipped to Bottom mid-run — the seam is re-read each frame), and both `ISocialValidator` fakes
(`FakeValidator`/`BlockingValidator`) gained `ResolveFediverseSoftwareAsync`. The drag-snap units
(`SocialBarSnap`) were removed with the click-toggle supersession.
**Not included (say the word):** rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`.
+47
View File
@@ -0,0 +1,47 @@
# TASK 5 — Layout Persistence (SQLite)
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** Scenes, sources, and asset bytes survive restarts; assets are always available.
### Status: ✅ Done
1. ✅ SQLite database (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned via `PRAGMA user_version` (currently **v8**)
2. ✅ Assets live in the DB (BLOB keyed by SHA-256 content hash), never file paths — deleting the original file never breaks a scene
3. ✅ File-model save/open — the active layout file is tracked (default is the AppData DB); **Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active
4. ✅ Auto-save (invisible) — ~1.5s debounce on scene/source add/remove/reorder/rename/hide + any source transform change; flush on window close
5. ✅ Startup — load the active file; seed the five canonical scenes only when the DB is empty; (+) re-adds a missing canonical scene and is hidden once all five are present; adding beyond the five is rejected
6. ✅ Schema v1 → v9 — webcam columns (v2), singleton `Webcam` + per-scene `WebcamSceneConfig` (v3), `RectWidth`/`RectHeight` round-to-rect restore (v4), `Source.IsBackdrop` + `Source.CaptureKey` (v5), `Scene.HasBackdrop` — backdrop **Live-only by policy** (v6, one-time backfill + `EnforceBackdropPolicy` on every load), `Scene.HasSocialBar` (v7, dropped per-scene toggle — column back-compat, unread), `Socials.BarEnabled` (v8); the `SocialEntry.Software` fediverse-software column is a **column-presence migration** (commented v8→v9, no version bump — `user_version` stays 8); v9 = single-row `Music`. **Load-time rule:** sources load with `IsBackground` derived from `Type` (OR'd with the persisted column so legacy DisplayCapture backdrops keep their flag) — rows saved before the Type setter derived the flag heal on load
7. ✅ WindowHandle stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned. **v9 lands in the TASK 8 audio milestone** (single-row `Music` — `TrackPath`/`IsEnabled`)
### Design decisions
1. **SQLite database** (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned
via `PRAGMA user_version`.
2. **Assets live in the DB, not on disk** — `Asset` table stores image bytes (BLOB) keyed by a
SHA-256 content hash (unique). Identical image content collapses to one row regardless of file
name — the 1:M resource memory model, enforced by the database. No file paths; deleting the
original file never breaks a scene.
3. **File-model save/open** — the active layout file is tracked (default is the AppData DB).
**Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active.
4. **Auto-save (invisible)** — ~1.5s debounce on scene add/remove/reorder/rename/hide, source
add/remove/reorder, and any source transform change; flush on window close.
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder), `Asset` (Id, Hash, Data,
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder), `Socials` (Id,
BarPosition, BarJustify — back-compat, unread, BarEnabled added via `ALTER`), `SocialEntry` (Id,
SocialsId FK cascade, Service, Handle, ProfileUrl, SortOrder) — `user_version` **8** (v1 → v2 =
`ALTER TABLE` adds the two webcam columns; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`;
v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the round-to-rect restore; v5 = `Source.IsBackdrop`
+ `Source.CaptureKey` for the live-capture backdrop; v6 = `Scene.HasBackdrop` — the backdrop is
**Live-only by policy** (one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop
sources; `EnforceBackdropPolicy` re-normalizes every load); v7 = `Scene.HasSocialBar` (per-scene toggle
dropped — column back-compat, unread); v8 = `Socials.BarEnabled`). The `SocialEntry.Software`
fediverse-software column is a column-presence migration (commented v8→v9, no version bump).
`WindowHandle` stays in-memory (per-session). Save = transactional rewrite; orphaned assets pruned.
6. **Startup** — load the active file; seed the five canonical scenes
(Starting/Live/BRB/Chat/Ending, `SceneCatalog`) only when the DB is empty. The (+)
button re-adds a missing canonical scene and is hidden once all five are present;
adding beyond the five is rejected — work with less, never more.
+21
View File
@@ -0,0 +1,21 @@
# TASK 6 — UI polish batch (scenes/sources rows, dedup naming, social bar)
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** clean up the two side lists and the social bar per gramps's review.
### Status: ✅ Done
1. ✅ Scene rows are pure selection rows — the per-row edit/trash/visibility icons and the inline rename TextBox are gone (`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` + handlers + `Scene.IsEditing` removed; `IsHidden` stays persisted + dims hidden rows)
2. ✅ Source rows gained the trio — edit (inline rename via new `EditElementCommand` + `SceneElement.IsEditing`), visibility eye (new `ToggleElementVisibilityCommand` flips `SceneElement.IsVisible`; the eye style now binds `IsVisible`, open/slashed + row dims to 0.45 when hidden), and the existing trash
3. ✅ Duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3…) — next free number derived from the names actually in the scene, so deleting a middle resource never collides (`AddSource` + `AddReusedImage` both use it)
4. ✅ Social bar renders the full validated handle — `MaxWidth=200` + `TextTrimming` removed from BOTH `SocialBarRenderer` and the preview template (mastodon `@gramps@…` no longer cuts off)
5. ✅ Side panels stay fixed-width (left 220 / right 300) — deliberate: they never re-layout on resize, the preview absorbs it
6. ✅ Focus-loss capture lag documented as a known OS limit in `ai.md` — deferred by user decision (no code change)
### Design decisions
1. **Next free number from names, not type counts** — the old scheme counted elements by `SourceType` (`count == 0 ? baseName : base+count+1`), which collided after deletions; the new helper scans actual names.
2. **One WPF App per AppDomain** — the real-App tests (round-clip + naming) share `RealAppHost` (a dedicated STA thread owning the single `App`) via the `RealApp` serial collection, instead of each calling `new App()`.
+17
View File
@@ -0,0 +1,17 @@
# TASK 7 — Meter scaling amplification (voice meter uses the full bar)
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** the mic (and game) meters feel light — speech peaks should peg into the red at maxed volume.
### Status: ✅ Done
1. ✅ `AudioLevelMeter.ToDisplay` now adds **+10 dB of input amplification** before the −60..0 dBFS → 0..1 log mapping (was raw dB): speech peaks (~0.2 RMS, −14 dBFS) read ~0.93 → red zone; normal speech (~0.05, −26 dBFS) ~0.73 → yellow; background noise ≤0.001 linear (−60 dBFS) still reads 0 (the meter never idles on it)
2. ✅ Unit tests updated to the new mapping + new `ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume` (0.2 → 0.92..0.95, 0.05 → 0.72..0.75)
### Design decisions
1. **Amplify at the mapping, not in the VM** — one knob (`ToDisplay`), shared by the mic bar and the game bar; the `× MicVolume` gain-on-noise behavior is untouched (raising the slider still moves ambient noise up the bar).
2. **+10 dB, not more** — louder boosts push quiet speech into the lower half and make the noise floor visible; +10 puts speech peaks solidly in red while idle stays at zero.
+33
View File
@@ -0,0 +1,33 @@
# TASK 8 — Audio milestone: real stream audio + voice filters + TRAX music (2026-08-14)
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** every audio issue done and tested in one branch — real mic/game audio reaches the encoder (replacing the `anullsrc` silence), `MicVolume`/`GameAudioVolume`/mute become real pre-AAC gains, TASK 10's voice filters land, auto-duck keeps the creator's voice over game + music, and a free background-music source ("TRAX") plays into the sound bar and the stream.
### Status: ✅ SHIPPED 2026-08-14 — 196 tests passing, 0 warnings (ONE integration test: `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe`)
1. ✅ **Real audio into the encoder** — the mixer's loopback samples were **dropped today** (`AudioMixer.OnLoopbackSample` metered only) and `MicVolume` was meter-only; `FfmpegArgs.cs:28` ran `-f lavfi -i anullsrc` (silence). Transport: **ffmpeg reads a Windows named pipe** — `-f f32le -ar 48000 -ac 2 -i \\.\pipe\ytllive_audio` replaces the anullsrc block, plus explicit `-map 0:v -map 1:a`; pipe name via `EncoderOptions.AudioPipeName` (`DefaultAudioPipeName = "ytllive_audio"`). Mixer stays **two inputs (mic + loopback)** — no N-source abstraction, no `StereoMixer` class. The VM owns the pipe lifecycle (`BeginGoLive` → `_audioMixer.StartLive(pipeName)`, `StopStream` → `_audioMixer.StopLive()` before the pump stops); `FfmpegEncoder.cs` is untouched.
2. ✅ **Honest gains reach the stream** — `MicVolume` scales the mic channel; `GameAudioVolume` + mute scale the loopback channel (the "mute in preview but stream still plays" footgun dies with it). One knob per input = KISS. Implemented as `Func<double>` gain seams on `AudioMixer` (`micGain`/`loopbackGain`), wired by `MainViewModel`.
3. ✅ **Voice filters (TASK 10, before the meter AND the mix)** — `VoiceFilterChain`: bass (`LowShelfFilter` 120 Hz +4 dB) → treble (`HighShelfFilter` 8 kHz +3 dB) → **noise gate** (pure C#, creator's choice) → compressor (threshold 0.5, 4:1). Pure stateful DSP (TDF2), sine-in unit tests. Meter stays post-filter.
4. ✅ **Auto-duck** — pure envelope (`AutoDucker`): mic RMS > 0.02 → game+music dip ×0.25 (~12 dB), attack 0.05 / release 0.005, recover on release. Always-on, no knobs. Strong form of the creator's "voice always over the game volume" idea.
5. ✅ **TRAX — background music, FREE** (keeps the one paid line = Alerts + flash removal). `MusicPlayer`: NAudio `MediaFoundationReader` (mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** (fixed, not changeable) → `WaveOutEvent` on the default device (added the sibling `NAudio.WinMM` 2.2.1 package — `WaveOutEvent` isn't in `NAudio.Wasapi`), loop on end. **No 3rd mixer input and no `MusicVolume`** — music plays on the desktop, the existing loopback picks it up: heard in headphones, the sound-bar meter bounces, and the stream carries it through the loopback channel (ducked with game when the voice is hot). Known wrinkle (out of scope): YouTube mutes VODs carrying copyrighted music — future "music on live, off VOD".
6. ✅ **TRAX footer control (final spec, agreed 2026-08-14)** — `YtButtonSecondary`, **status dot + "TRAX"** text, in the center footer stack beside MIC:
- **Status dot** (same 8px Ellipse pattern as MIC/Socials): **red** no track loaded · **yellow** loaded not playing · **green** playing.
- **Left-click** → toggle play/pause; no track loaded → opens the picker instead.
- **Right-click** → always opens the in-app track picker (`TraxButton_PreviewMouseRightButtonUp`, code-behind pattern like `GameSpeaker_MouseLeftButtonUp`, `e.Handled = true` so no context menu).
- **Tooltip** → playing/loaded track name; "No track — right-click to choose background music" when empty.
- **No slider** — volume is the 0.20 constant. Picker = plain OS `OpenFileDialog` filtered to mp3/wav/m4a/aac/flac/ogg.
7. ✅ **Sound bar shows music** — `IsGameAudioBarVisible = gameDetectorProducingSound || isMusicPlaying`; relabelled "Game Audio Capture" → **"Desktop Audio"** (TRAX rides the same channel). **Follow-up (2026-08-15): the gating is gone — the game audio bar is ALWAYS visible** (the `IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` stack + the VM's 250ms poll timer and their tests were deleted; the bar kept hiding the desktop meter when no full-screen game with sound was up, and the creator wants it pinned).
8. ✅ **Capture hardening (design changed at build time)** — the plan's "force IEEE-float 48 kHz on both WASAPI sources" was **dropped**: NAudio 2.2.1's `WasapiCapture` exposes no overridable `GetDefaultMixFormat`, so both sources capture the device's own mix format and the mixer's pure `TinyResampler` normalizes any rate/channel count to 48 kHz stereo (the resampler IS the design, not a fallback). Stop path disposes the audio pipe (EOF) in `MainViewModel.StopStream` before the pump stops, so both ffmpeg inputs end in order.
9. ✅ **Schema v9** — single-row `Music` (`TrackPath`, `IsEnabled`; volume is the 0.20 constant) + migration in `LayoutStore.cs` (`user_version` 9; `Save` gained an optional `Music? music = null` param so existing 3-arg callers still compile; load query + null reset).
10. ✅ **Docs in the same commit** — `ai.md` audio section rewritten ("one knob per input", 2-in mix, auto-duck, TRAX free, VOD-mute wrinkle), TASK 10's status, this task's status, HANDOFF.
**Files (new):** `Services/Audio/VoiceFilterChain.cs` (+ `LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`), `Services/Audio/AudioRingBuffer.cs` (async-arrival source buffers), `Services/Audio/AutoDucker.cs`, `Services/Audio/TinyResampler.cs`, `Services/Audio/MusicPlayer.cs`, `Services/Audio/NamedPipeAudioWriter.cs` (+ `IAudioPipeWriter` seam), `Models/Music.cs`.
**Files (changed):** `AudioMixer.cs` (filter chain before meter+mix, loopback into the mix, gains via `Func<double>` seams, ducker, silence-filler so the pipe never stalls, `StartLive`/`StopLive`), `WasapiMicAudioSource.cs` (device mix format — resampler normalizes; no forced format), `FfmpegArgs.cs` (named-pipe input + `-map`, name via `EncoderOptions.AudioPipeName`), `EncoderOptions.cs` (`AudioPipeName`), `ytLive.csproj` (`NAudio.WinMM` 2.2.1 for `WaveOutEvent`), `LayoutStore.cs` (schema v9), `MainViewModel.cs`/`MainWindow.xaml`/`MainWindow.xaml.cs` (footer TRAX group, sound-bar visibility OR music, go-live starts the pipe, end closes it first).
**Testing (Good Dog Rule — ONE integration test):** `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe` — real filter chain + mixer + ducker + `NamedPipeAudioWriter`, fake `IAudioSource`s, test-side `NamedPipeClientStream` reads the bytes and asserts post-filter/post-gain/post-duck mixed stereo (ring buffers pre-filled so the first pipe tick already carries real audio — avoids a start-of-stream silence race). Units: each DSP stage (known sine-in → expected gain), ring buffer wrap/underflow/overwrite, ducker envelope, resampler (down/up/stateful/identity), `FfmpegArgs` pipe+map, schema v9 roundtrip + no-music-row. Existing tests stay green.
**Out of scope (this branch):** IP webcam (video-only when it lands — never an audio input), chat box source (TASK 3 item 18), alt-key crop, credits, background removal, music-off-VOD, `PremiumUrl` (TASK 10 seam).
+37
View File
@@ -0,0 +1,37 @@
# TASK 9 — YouTube Live Stream Management
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** Create/bind broadcasts, monitor YouTube-side stream health — the v3 way.
### Status: ⏳ In progress — items 1–3 SHIPPED (reusable stream 2026-08-16; report-by-exception health 2026-08-16); item 4 SHIPPED (status reconciled 2026-09-01); item 5 open; item 6 = deliberate lock (TASK 36); item 7 scoped to core fields — advanced tab is out-of-product
1. ☑ **Broadcast creation** — title/description/privacy/scheduledStartTime via API, with the v3 flags above (SHIPPED: `CreateBroadcast` sends `enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`, `selfDeclaredMadeForKids=false`)
2. ☑ **Reusable stream** — create once, cache + reuse; bind to broadcast (SHIPPED: `GetOrCreateReusableStreamAsync` lists-then-inserts the `variable`/`isReusable` stream, cached via `LayoutStore` Settings, bound at broadcast insert via `boundStreamId`; `_rtmpUrlProvider` yields the ingest URL so go-live actually encodes + pushes)
3. ☑ **Health monitoring** — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error (SHIPPED: `GetStreamHealthAsync(streamId)` 30s while live; pure `StreamHealthReporter.BannerFor` = report-by-exception; banner strip under the top bar, amber warning / dark-red error, via `HealthIssueBanner`/`HealthIssueBackground`; poll failures log-only; ONE integration test `GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error`)
4. ☑ Live chat — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges — **SHIPPED** (status reconciled 2026-09-01: `YouTubeChatService` polls + parses `superChatEvent`/`newSponsorEvent` with badge/level on `ChatMessage`; the panel MOVED to the left-panel live state by Control Surface UX, "right panel" superseded; the remaining reward types land with the monetization chain, TASK 10 → related work)
5. ☐ Error handling — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
6. ❌ **Visibility picker** — DELIBERATE TEST-PHASE LOCK (creator decision 2026-09-01), not open work: "always Private" stays during multi-month real-world testing so breakage VODs never clutter the channel. `recordFromStart`/DVR stay on — Private VODs are invisible review tapes; bulk-delete pre-GA. Unlock = TASK 36 gold-pass item (remove the override in `YouTubeStreamService.CreateBroadcast`, wire the dialog selection). See `ai.md` → YouTube Live API constraints.
7. 🔶 **Full broadcast form** — RESCOPED 2026-09-01, not the old two-tab plan. **Core fields (SHIPPED as the Text drawer, 2026-08-24):** title, description, tags, visibility, made-for-kids — live-editable. **Scheduling:** ships as **TASK 34** (Scheduled checkbox + datetime in the same drawer). **Advanced tab is PERMANENTLY OUT** (the 10% margin): latency (locked `low`), DVR/record-from-start (locked on), embed, projection, CC, region restrictions stay fixed at sane defaults, invisible — every exposed field is a support ticket. `categoryId` removal: done (not a `liveBroadcast` field). Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field
### Design decisions (v3)
1. **One-click go-live** — `liveBroadcasts.insert` with `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. No `transition(live)` call, no testing stage, no liveStarting polling. Encoder starts → YouTube brings it live by itself.
2. **Variable reusable stream** — `cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`. Create once per channel, cache ingestion URL + stream name, reuse for every broadcast. Any quality tier works without recreation; auto step-down needs no API calls.
3. **Report-by-exception** — poll `liveStreams.list`; banner only on `healthStatus` warning/error issues (`configurationIssues[]`). Bottom strip = YouTube logo + green/red connection dot (clickable → opens the dialog).
4. **One dialog, three states** — `not connected` (sign-in) / `connected-offline` (all editable) / `live` (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile.
5. **Live edits** — `liveBroadcasts.update` with part=`snippet,status` for title/description/privacy.
6. **End stream** — stop encoder → `transition(complete)`, with `enableAutoStop` as the safety net. **SHIPPED 2026-09-01** (`EndBroadcastAsync`, wired into `StopStream` after RTMP EOF; the call had been specced since TASK 9 but never existed — found during recording verification). **Hardened 2026-09-22:** a blind complete 403s `invalidTransition` once autoStop/YouTube marks the broadcast complete (seen on every 2026-09-22 end) — the call now pre-flights `liveBroadcasts.list→lifeCycleStatus` and only transitions from `live`/`testing`, skipping already-complete broadcasts silently (`enableAutoStop` finishes every skip).
7. **Broadcast ID == Video ID** — one ID to track status, health, and the auto-created VOD (`recordFromStart` + `enableDvr`).
### Requirements:
1. **Broadcast creation** — title/description/privacy/scheduledStartTime via API, with the v3 flags above
2. **Reusable stream** — create once, cache + reuse; bind to broadcast
3. **Health monitoring** — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error
4. **Live chat** — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges
5. **Error handling** — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
6. **Visibility picker** — Private/Unlisted/Public from the go-live dialog
7. **Full broadcast form** — Core + Advanced tabs with all API-supported fields
+44
View File
@@ -0,0 +1,44 @@
# TASK 10 — Monetization: watermark-only one-time license + Polar billing
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** a **one-time perpetual license** via Polar.sh that removes the branding watermark. All features
are free — the only difference between free and paid is the watermark. (Model changed 2026-09-21 from
annual subscription to one-time "own it": $29 founder / $49 list lifetime. See `MONETIZATION.md` →
Pricing for the reasoning.)
**Related work — monetization awareness (2026-09-01, ungated, free):** this task's billing is the
*payer* side; the *product* also carries built-in, ungated monetization awareness for every creator —
reward-event capture (all 7 `liveChatMessage` reward event types → canonical `RewardEvent` → SQLite
persistence), per-session/broadcast reports (members, Super Chat $, stickers, gifts, milestones — the
in-app replacement for the emailed "stream activity report"), and a journey tracker (position % +
ETA vs. versioned, date-aware YPP thresholds; the Tier-2 bar doubles for new applicants 2027-02-01).
Details and design notes: `ai.md` → Monetization. This is tracked as related work under this task's
narrative, not a separate task number — it ships in its own code slices with one integration test each.
**Scope: v1** (2026-09-01 complete-v1 ruling — there is no v1.x): slice order = reward-event capture →
session report → journey tracker → Alerts (TASK 3 item 20).
**Business details (pricing, Polar product/checkout/discounts) in `MONETIZATION.md` (gitignored).**
**Polar product:** `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (LlamaCasty, ⚠️ currently configured as
$99/yr **subscription** — must be re-created as a **one-time** product: $29 founder / $49 list lifetime)
**Checkout:** `llamacasty.com` → Polar hosted page (~80% configured)
**Discounts:** `LLAMAFOUNDER` (100% off, 50 uses, `once`), `LLAMA50` (50% off, `once`)
**Org ID:** `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` can be omitted from API calls)
**Key prefix:** `LCYT-`
**Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation)
### Status: 🔶 In progress — steps 1–7 shipped, Velopack update URL pending
1. ✅ **Billing provider** — Polar (polar.sh) chosen and configured. Product, price, license key benefit, discounts, checkout link all created.
2. ✅ **License key entry + verification UI** — About overlay swap panel (same pattern as Licenses panel). Text input + "Activate" button + status message. "Enter License Key" button in the hub.
3. ✅ **Offline entitlement** — `LayoutStore` Settings table: `LicenseKey`, `LicenseValidatedAt`, `IsPremium`. 14-day offline grace period. Startup re-validates online, falls back to cache.
4. ✅ **BrandFlash → watermark toggle** — `IsPremium` property setter flips `BrandFlashEnabled = !value`. Flash stops immediately on premium activation. Flash asset VideoFrame rendering deferred to TASK 4 compositor.
5. ✅ **Remove social slot locks** — all 6 social bar slots open to everyone (no `IsPremium` gating on slots 3-6) — shipped as TASK 10c
6. ✅ **"Unlock Premium" button** — `PremiumUrl` set to Polar checkout; `IsPremiumAvailable` is true; button lights up in the About hub.
7. ✅ **Licensing model** — startup re-validates against Polar API. **Perpetual key (2026-09-21): the
license never expires, `IsPremium` never lapses, and the flash does not return.** The former
renewal/lapse path is dead. Offline grace period (14 days) still applies to the validation call only.
8. 🔶 **Velopack auto-updates** — NuGet packages + bootstrap in `App.xaml.cs`. Update URL self-hosted on DO droplet (pending configuration).
+46
View File
@@ -0,0 +1,46 @@
# TASK 11 — Post-pause polish batch: the creator's 8 review issues (2026-08-15, shipped)
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Status: ✅ SHIPPED 2026-08-15 — 197 tests passing, 0 warnings (ONE integration test:
`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`).**
The creator reviewed the TASK 8 build and filed 8 issues. All fixed in one branch on `main`:
1. ✅ **Desktop/game audio volume slider had no effect** (headset OR stream) — the `AudioMixer`'s
`micGain`/`loopbackGain` seams defaulted to unity because the VM never passed them; the sliders
were decorative. Fixed: new `AudioGainProvider` (`Services/Audio/`) hands `MicMuted`/`MicVolume`/
`GameMuted`/`GameAudioVolume` to the mixer at construction, read live each mix tick.
2. ✅ **Desktop/game mute button had no effect** — same root cause; `GameMuted` now zeroes the
loopback on the stream. Locally, `MusicPlayer.LocalGain` (new) is scaled by the game bar on every
volume/mute change + on TRAX load, so the creator **hears** the control work on the music (the
track rides the loopback channel, so it ducks locally exactly as it does on stream).
3. ✅ **TRAX played once then stopped** — `MusicPlayer.OnPlaybackStopped` only looped when
`Position >= Length`, unreliable for `MediaFoundationReader`; now any clean stop
(`e.Exception == null`) rewinds + replays; only errors/explicit stops surface via `PlaybackEnded`.
4. ✅ **TRAX/MIC buttons swapped** — TRAX no longer sits between MIC and the (mic-specific) meter;
the footer's mic cluster is MIC + meter + mute + volume.
5. ✅ **Mic source persists across restarts** — `LayoutStore` gained a `Settings` key/value table
(`SaveMicSourceName`/`LoadMicSourceName`); the VM saves on pick and restores before the mixer's
first `Start`, so a restart reconnects the same already-vetted device (green dot) or reports it
missing (yellow) instead of falling back to the default endpoint.
6. ✅ **TRAX moved into the game audio bar** — left of the "Desktop Audio" label in the preview
overlay (the creator's follow-up pick), no longer in the footer; its tooltip teaches the clicks
("left-click pauses/plays, right-click chooses a track").
7. ✅ **Backdrop's icons shifted right** — the trash button's `Collapsed` released its column; a new
`HiddenBoolToVisibilityConverter` keeps the slot reserved (`Hidden`), so edit/eye stay in their
fixed columns for every source row.
8. ✅ **No separation between scenes and sources** — a 1px hairline with top/bottom padding now sits
between the two listboxes in the left panel.
### Design decisions
1. **The gain seams are the one source of truth** — `AudioGainProvider` is a thin seam (four Funcs)
so the mixer contract and the mute⇔zero-volume rule are testable without constructing the VM.
2. **Stream-honest AND locally audible** — the game bar's controls now do two things: scale the
loopback on the stream and scale the music in the headphones. Native game audio is untouched
(system output, OBS-style non-monitored); an OBS-style monitor loop is out of scope.
3. **Persist the DisplayName, not the device ID** — the FriendlyName match was already the mic's
identity end-to-end; `Settings` just makes it survive restarts.

Some files were not shown because too many files have changed in this diff Show More