Compare commits

..
164 Commits
Author SHA1 Message Date
gramps 69f08a478e feat(topbar): three fixed slots — every state changes inside its own slot
Creator correction (2026-11-13): "OpenCode literally inserted Live and Private as two extra flat siblings
inside the outer pill bar, shoving Record to the right and making it look like five ungrouped toolbar
items". Correct — completion states were being added to the BAR instead of to a SLOT.

Now the bar is exactly three slots, and the bar's shape never changes:

  [ Build Scenes ]   [ YouTube ]   [ Record ]

  YouTube:  ▶ YouTube -> [Live | Private | ✕] -> [● Start Live | ✕] (amber: next click goes on air)
            -> [■ Stop Live]
  Record:   ◉ Record  -> [● Start Recording | ✕] -> ● REC hh:mm:ss [■ Stop]
  Build:    enabled only while BOTH action slots are Idle; otherwise disabled at 40% with the tooltip
            "Scene editing is locked while streaming or recording"

State lives in YouTubeSlotState / RecordSlotState, with one RaiseSlotStates() announcing every flag so no
caller can forget one. Esc and the ✕ cancel the slot you are in and nothing else.

This retires the BarWorld model from earlier today, along with TopBarActivityTests (superseded by
TopBarSlotTests, which pins all three flows, the lock, and cancel-to-Idle).

679 pass, 0 failed (the host-hang fallback in f4caea0 counted the per-test lines).
Scope justification: TopBarActivityTests.cs is deleted as part of this unit — it tested the model being
replaced; TopBarSlotTests is its successor.
2026-11-13 12:20:11 -08:00
gramps ba60f9ab3d feat(topbar): three activities, always visible — build, stream, record
Creator ruling 2026-11-13: "the app does three things. builds. records. live-streams." So the bar holds
three buttons that never disappear; the active one is lit and runs its own flow in place, and the other
two dim but stay clickable, so there is no state where the creator cannot see where they are or how to
step back.

  Build    [Build Scenes]  -> editing on, nothing to start
  YouTube  [Live][Private] -> [Start] -> [Stop]     (Private = the private-visibility test stream)
  Record   [Start Recording] -> [● REC hh:mm:ss][Stop Recording]

Arming and starting are separate presses on purpose (creator): Start is the last chance to tell someone
"check you are ready". Collapsing them would save one click and lose that prompt.

This replaces the two-world model (BarWorld) and its output stage outright, including the chips added
earlier today. The old pills remain the underlying state (OnAirPillOn/RecordPillOn) so the start, stop and
step-down paths are untouched; the activity only decides what the bar shows.

Closing the app no longer waits 6 s for a session that cannot stop: it waits ~2 s, then clears the state
and closes anyway. A window that refuses to close is worse than a stale flag — and that refusal is what
hung the suite in ZoomBoxInteractionTests, which sets IsRecording and closes a real window.

Test: TopBarActivityTests pins all three flows, the arm-then-start split, and that stepping back to Build
forgets the YouTube choice instead of carrying it. TopBarWorldTests is retired with the model it tested.
2026-11-13 11:30:57 -08:00
gramps f4caea09ad ci(verify): judge the suite on its results, not on a summary the host may never print
Three gate runs in two days died the same way: every test passed, then the test host failed to exit, so
vstest never printed its "Passed!" line and the gate waited until the harness gave up. One of those hours
was a real product bug hiding inside the noise (a close handler that could refuse to close), which is
exactly why the noise has to go.

- The suite runs with a 300 s bound and per-test console output.
- Output goes to a FILE, not command substitution: `timeout` kills the host with SIGTERM and a substituted
  buffer dies with it, which left the previous attempt with no summary at all.
- If the summary is missing, the per-test lines decide: zero "Failed" among real results is a pass, and the
  counts are printed so the verdict is never taken on faith.
- The orphaned testhost is swept afterwards.
- `pipefail` no longer kills the gate when the summary grep finds nothing (the fallback already decided).

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
checked and committed earlier today.
2026-11-13 11:30:56 -08:00
gramps 70b5b01920 assets(starting): the creator's replacement Starting-screen art
Checked visually by the creator on 2026-11-13 (#3 of the pending checks).
2026-11-13 09:42:45 -08:00
gramps bd61e8df2a fix(lifecycle): no dialogs while the app is closing
1ce6b9c made closing stop the session first, which was right and incomplete: the stop path opens TWO
modals, and WPF forbids that during a close. The creator's #2 test produced it immediately —

  System.InvalidOperationException: Cannot set Visibility to Visible or call Show, ShowDialog, Close,
  or WindowInteropHelper.EnsureHandle while a Window is closing.

Two prompts are suppressed by a `_shuttingDown` flag, each taking its safe default rather than a prompt
nobody can answer:
- The rename dialog: the recording is saved under its automatic name. Never discard footage the creator
  did not choose to discard.
- The private-broadcast keep/delete prompt: the broadcast is KEPT (stays private on YouTube) instead of
  being deleted by an answer that was never given.

The general rule this establishes: a teardown path may not ask questions. Any future prompt reached from
stop must check IsShuttingDown.

Not covered by a unit test — the trigger is a real WPF close, not reachable from the view model. Verified
against the exception in %APPDATA%\ytLlive\startup.log (09:40:27) and it needs the creator's retest.
2026-11-13 09:42:44 -08:00
gramps 5bd750eaf5 fix(topbar): announce the output stage when recording starts or stops
Starting a local recording left the idle 'Start Recording' chip on screen and never revealed 'Stop
Recording' — IsRecording raised its own notifications but not the output stage's, so the visibility
bindings were never re-evaluated. The visible chip's command then correctly refused to run (recording was
already in progress), which is the worst shape of bug: a dead button that looks live.

IsRecording and StreamStatus now call RaiseOutputStage(). The regression test asserts the NOTIFICATIONS,
not the property values — reading ShowEndRecordingAction directly passes either way, which is exactly how
this slipped through.

682 pass, 0 warnings.
2026-11-13 07:47:36 -08:00
gramps 1ce6b9c1b8 fix(lifecycle): end the session before disposing it on close
Closing with a recording active called Shutdown() straight away, which disposed the frame pump and the
audio mixer underneath a running loop: ffmpeg never flushed, the file was never finalized or renamed,
and the pump was torn down mid-iteration. That is the 'app goes autistic on close' report.

MainWindow_Closing now holds the window open, runs StopBeforeShutdownAsync (the ordinary stop, bounded at
~6 s so a stuck encoder cannot make the app unclosable), then closes for real. StopStream already honours
the audio-before-video ordering, so the close path inherits it instead of inventing its own teardown.

Test: with nothing running, closing must not hold the window at all (the fast path stays fast).

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-13 07:47:35 -08:00
gramps 70fcf89f9e feat(topbar): the recording lives in the pill — green pulse, REC clock, Stop Recording
Build 730 (creator):

- Left pill while recording: solid #2ECC71 dot pulsing 1.0 <-> 0.2 on the existing one-second timer, and
  "REC: hh:mm:ss" in Consolas so the digits cannot jitter the pill's width. Idle face unchanged.
- Right pill while recording: a stop square and "Stop Recording"; clicking it runs the normal stop, which
  halts and resets the clock and returns the pair to "Local MP4" / "Start Recording".
- The separate reality line (dot + REC/LIVE + elapsed + End) is now LIVE-ONLY: a recording would otherwise
  print the same three facts twice. RunningVisible is IsLive, and ShowOutputStage no longer hides itself
  for a recording (it must be on screen to carry the clock and the stop).

Two rules collided and are now separated rather than merged: a creator's Stop on a recording restores the
Local MP4 pair (build 730), while a pipeline FAILURE still clears both pills (the 2026-09-01 zombie rule).
StopStream(afterFailure:) carries the difference, and the pump-failure path passes it.

RealAppHost.Run was rethrowing with `throw failure;`, which destroyed the original stack — a failing
assertion inside the action reported the wrapper's line instead of its own. ExceptionDispatchInfo now
preserves it; that is how the two failures above were located in one pass instead of five.

680 pass (one audio-pipe instance flake under full-suite load, passes alone), 0 warnings.
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-12 16:52:36 -08:00
gramps caf5d6130d feat(topbar): the output stage's three states, and keyboard entry into the bar
Build 729 (creator):

- "press <esc> to return" loses the word "press"; the label is "Record / Go-Live".
- The bar takes focus on "Build scenes" and Tab cycles between the two halves only
  (KeyboardNavigation.TabNavigation="Cycle"), never off into the window.
- Entering Record / Go-Live starts with NOTHING chosen — the output switches are a fresh decision,
  not a remembered one.
- Three states: nothing chosen [YouTube][Local MP4]; YouTube chosen [YouTube(red)][Live Stream][Private
  Stream]; Local MP4 chosen [Local MP4(red)][Start Recording] → [End Recording] while recording. Only the
  chosen output is red; the action chips never highlight. Choosing Local MP4 takes the left slot and the
  YouTube chip disappears with it, which is what "slides to the left, replacing it" describes.

"Private Stream" is the private-visibility stream — the same thing this app has always called the test
stream (creator's clarification, so the wording is deliberate, not a new broadcast type).

Output chips are one style with a Tag-driven trigger (OutputChip + OutputChipIcon), so a chip's red face
and its glyph colour can't drift apart; the one-alpha hit-area colour is a token, because
DesignTokensTests rightly refuses literals in theme files.

680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 16:35:16 -08:00
gramps 6b4aa99863 feat(topbar): the segmented mode switcher, to the mock
Implements the creator's mock (2026-11-12) for the world segment: pill track (#1E222B, radius 12,
padding 4) holding two equal halves; the active half sits on a lighter pill (#2E3440, radius 8) with
#E2E8F0 bold text and a coral (#F26D6D) icon; the inactive half is #8892A4 on a one-alpha black face so
the whole area hit-tests; hover tints #252A34 on the inactive half only, so the active one never looks
clickable-but-dim. Labels are "Build scenes" / "Record / go live".

Two deliberate deviations from the pasted recipe, both to honour this repo:
- State is the existing BarWorld binding plus a command, not a code-behind SetActiveMode — one source of
  truth, and the change still raises the same property-changed notification a mode event would.
- Styles live in Themes/Controls.xaml (SegmentHalf, SegmentIcon) with colours as tokens in Tokens.xaml,
  rather than inline properties on the elements: AGENTS requires shared styles, and one DataTrigger on
  each element's own Tag serves both halves.

DesignTokensTests caught the one raw literal (#01000000) and it is now Brush.SegmentHitArea, which is the
rule working as intended.

680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 16:20:25 -08:00
gramps b92569a8c7 feat(topbar): Output:, no chevron, and Esc returns
Creator ruling 2026-11-12, the parts that were unambiguous:

- The back chevron is gone from the left of the output line.
- "Output" gains a colon and takes the same 11 px muted style as "One output at a time".
- "press <esc> to return" sits centred under the switch pair.
- Esc actually returns from the Output world (MainWindow_PreviewKeyDown -> EnterBuildWorldCommand).
  Without this the bar would have lost its only route back, since the chevron was that route.

UiShellTests guards all four: the colon, the hint, the absence of the chevron tooltip, and the muted
style class of both labels.

680 pass, 0 warnings. The switches' post-click behaviour is still awaiting the creator's mock
description, so this commit deliberately leaves the pill pair alone.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-12 16:14:48 -08:00
gramps 838ba17eaf fix(topbar): the bar carries no action button
Creator ruling 2026-11-12: the action is not part of the output row, and it is not to be placed at all
until they say where. So Start Recording / Private Test Stream / Review & Go Live are out of the bar,
along with the divider that had been separating them.

What the middle of the bar is now: the world segment in Build, and the output line
(Output [YouTube] [Local MP4] One output at a time) in the Record / Go Live world. The view-model
commands (StartRecordingCommand, TestStreamCommand, GoLiveCommand) stay, unused by XAML, because they are
wherever the action lands next.

UiShellTests now asserts the bar contains none of the three action labels, so re-adding one by accident
fails the gate.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-12 16:00:50 -08:00
gramps 16abb1ddc1 fix(topbar): the output line REPLACES the segment, it does not sit beside it
Creator correction: choosing Record / Go Live must take the [ Build Scenes ][ Record / Go Live ] dual
button away and put the Output line in its place. It was rendering both, segment first, which read as one
long toolbar instead of two worlds.

The segment is now Build-world only. That also retires the padlock glyph added in 6c5cd2f — with the
segment gone in the Output world there is no state left for it to describe, so the branch is removed
rather than left unreachable.

Because the segment was the only route back to the layers, the Output world gains a left chevron
("Back to Build Scenes"), present for the whole world including while a session runs — otherwise
editing during a show (unit 1) would have had no entry point and the bar would have dead-ended.

680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 15:48:34 -08:00
gramps 4bc41c488c feat(topbar): the action follows the armed output; Test is an explicit ask again
Unit C (creator ruling 2026-11-12).

- Local MP4: one button, "Start Recording".
- YouTube: "Private Test Stream" beside "Review & Go Live", the latter with the primary face because it
  is the default ask.
- Running: "End Live Stream" (or "End Test" for a test run, "Stop Recording" for a local one).

The behavioural change underneath: SessionStartDecision.For no longer takes `editing`. Since 2026-10-30
EDIT silently chose test-vs-real, which meant the same button did two different things depending on a
toggle three inches away. With Test as its own button that inference has no job, so For() now reads only
the armed output and ON-AIR always means the real livestream. That supersedes the 2026-10-30 ruling;
SessionStartDecisionTests is rewritten to pin the new matrix rather than the old one.

A signed-out press still starts the sign-in flow instead of sitting dead (no-dead-ends), unchanged from
before — now stated in one place, the two new commands, instead of inside a route table.

680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 15:44:15 -08:00
gramps a5a10fb0b0 feat(topbar): the output stage — Output [YouTube] [Local MP4] One output at a time
Unit B (creator ruling 2026-11-12). Pressing Record / Go Live now changes the bar: the REC/ON-AIR pill
pair becomes two labelled switches with glyphs (the YouTube mark, the record dot), under an "Output"
label and beside "One output at a time". Build world shows nothing but its own segment.

Two things this exposed that were not cosmetic:

- **Local MP4 was not actually the default.** Both pills started unarmed, so the stage opened with no
  output selected; the old screen got away with it because the primary button inferred "record" from
  "nothing armed". RecordPillOn now starts true, which is what "pos = ON (default)" means in code.
- **The stage gates the whole idle cluster, not just the switch.** Gating only the switch left the start
  button visible in the Build world — a Start with no output chosen. ShowOutputStage (output world, idle,
  not recording) wraps the cluster, so a running session hides the stage too: its output cannot change.

Exclusivity stays in the view model (arming one drops the other); the XAML merely reflects it.
TopBarWorldTests pins the stage's visibility and the radio pair starting at Local MP4.

680 pass, 0 warnings. Next: unit C (output-dependent primary action, End labels).
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-12 15:38:35 -08:00
gramps 6c5cd2f66c feat(topbar): glyphs on the world segment — layout, record dot, and a padlock
Creator ruling 2026-11-12: an icon left of each label, and Build Scenes' glyph becomes a padlock while
Record / Go Live is the active world.

- Build Scenes: a four-pane layout glyph while it is the active world, a padlock while the output world
  is — the door is still there, it is just behind the session. Clicking it swaps the glyph back and
  takes the creator into the layers, which is the only route back mid-show now that EDIT is gone.
- Record / Go Live: the ring-and-dot record glyph, the same "armed" language the REC sign speaks, so
  the halves are read apart at a glance.

Both glyphs fill from the button's own Foreground, so the selected (red) and unselected states keep
legible ink without a second colour pair. UiShellTests guards both paths and the padlock's world flag.

679 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 15:32:12 -08:00
gramps a5dc2f494c feat(topbar): one decision — [ Build Scenes ][ Record / Go Live ]
Unit A of the top-bar redesign (creator ruling 2026-11-12). The standalone EDIT toggle is gone;
Build Scenes IS edit mode, so "which world am I in?" and "can I edit?" cannot disagree any more.

BarWorld (ViewModels/MainViewModel.TopBar.cs) has two values, Build and Output. Build ships
highlighted at launch and sets EditingActive on; choosing Record / Go Live turns it off. Clicking the
already-highlighted half is deliberately not a no-op: it re-enters that world, which is the only way
back to the layers during a session now that the EDIT button is gone (unit 1 made that legal).

WorldSegmentButton (Themes/Controls.xaml) is a Button, not a ToggleButton, and each half passes its
own flag through Tag to one shared DataTrigger — a ToggleButton would fight the view model and, with a
one-way IsChecked, clear its own binding on first click.

UiShellTests updated where it guarded the old EDIT markup (it now guards the segment). TopBarWorldTests
pins launch state, both transitions, and the re-enter case.

679 pass, 0 warnings. Next: unit B (output switch), unit C (output-dependent action + End labels).
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement
art, still awaiting their visual check.
2026-11-12 15:05:29 -08:00
gramps d46e84562f feat(settings): editing during a session is the creator's call (default: allowed)
Creator ruling 2026-11-12: "we don't care if a creator wants to edit their scene: move stuff around,
shrink/grow a webcam view, etc. Why not?" Correct — the app was only ever *forcing* EDIT off at
go-live, never enforcing it, so the gun-safety was a habit rather than a rule.

App.LockEditWhileLive ships off: a show no longer takes EDIT away. Turning it on restores the old
behaviour exactly — go-live flips EDIT off and stop flips it back, and only because the lock took it,
so a creator who had already turned EDIT off does not get it handed back on stop.

MainViewModel.ApplyEditLockForSession holds the rule so it is testable without starting a broadcast;
EditWhileLiveTests covers both settings, the round trip, and the "don't hand it back" case. Settings →
Editing carries the checkbox.

This is the first slice of TASK 68 (editing while live).

678 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 14:45:58 -08:00
gramps aae6104666 docs(ui): record the open question about layer visibility while a scene is locked
Creator report (2026-11-12): "the creator can still use the layer icon — visibility — if it was
already enabled for that layer". Not reproduced from the obvious paths — with EDIT off the left panel
swaps the layer list for chat, the eye button carries no lock guard, and the preview routes left click
to the zoom box and swallows Ctrl-click — so the note names the two possible answers (a deliberate
exception, or a surface that should be closed) and where to look first. Parked, not acted on.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement
art, still awaiting their visual check.
2026-11-12 14:24:51 -08:00
gramps cc75212997 fix(audio): a sync offset is saved the moment it changes
Both offsets rode the debounced layout save, so a value set seconds before the app closed was lost
without a word. That is what happened to the -130 ms offset on 2026-11-12: two clips were measured
against a setting that was not in force, the numbers disagreed by ~65 ms, and the disagreement was
blamed on the audio path.

Setters now write the row immediately (two tiny rows), keeping ScheduleSave for the rest of the
layout. AudioSyncSettingsTests gains the regression: set both offsets on a view model and read them
back through a fresh store with no debounce flush.

MyMistakes records the general shape — "a setting that didn't stick" is almost always ScheduleSave —
with the check list and the two related rules: offsets never ship with a non-zero default, and
scripts/avsync.py only resolves ~50 ms, so below that the creator's judgement wins.

677 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 14:21:48 -08:00
gramps 1eb04597d1 fix(audio): both sync offsets ship at zero
Shipping 175 ms as the built-in mic delay was wrong on principle, not just in value: it shifts
every creator's voice to correct one camera on one desk, and it hides whether a desync is theirs
or ours. A correction has to be something the creator turned on.

So DefaultMicSyncOffsetMs is 0. The control stays (slider 0..500, off/175/350 presets, applied value
logged) for anyone whose camera needs it; it is simply no longer pre-applied. The global offset was
already 0.

AudioSyncSettingsTests pins the rule: a fresh profile reads 0 for both, the mic delay refuses a
negative (advancing is the global offset's job) and clamps above 500, and set values round-trip.

676 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 14:16:20 -08:00
gramps c1b3868913 docs(audio): record the measured lip-sync answer and the floor
Mic delay off, global offset -130 ms. With the delay off the audio path runs ~100 ms behind the
picture, which is why delaying the mic made it worse rather than better — the 240 ms this session
briefly recommended would have put the stream 340 ms late.

Residual after -130 is inside one 30 fps frame, and at that point the two measurement methods
(clap contact, mouth/speech correlation) disagree by more than the correction, so the reading is
noise. Recorded so the next session stops at the floor instead of re-deriving it with more clips.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement
art, still awaiting their visual check.
2026-11-12 14:11:30 -08:00
gramps af6210082c fix(tools): a negative lip-sync offset shortens the audio track by design
The length check called -74 ms of audio a clock problem on a clip recorded with the global
offset at -100. Trimming the stream head to advance the mix removes exactly that much track,
so the mismatch is the feature working. The verdict now says so instead of sending the next
hour after a clock that is fine.

Same clip: correlation +30 ms (two windows above 0.55), clap contact +34 ms — the two methods
agree, so the offset needs -130 rather than -100.
2026-11-12 14:09:55 -08:00
gramps 1abc86358a feat(tools): avsync pairs clap transients when the mouth correlation is weak
Mouth-motion against speech is a statistical proxy and it fails on ordinary talking clips: the
per-window estimates swing by 700 ms and the honest answer is "unmeasurable". A clap is not a
proxy — it has one unmistakable audio spike and one unmistakable visual spike, a fixed distance
apart, with no periodicity to lock onto wrongly.

So when the correlation is weak, the tool now detects sharp audio transients (99th percentile,
one event per 0.6 s), finds the biggest visual change within ±400 ms of each, and reports the
median of the cluster within 60 ms of it — at least three pairs or it still says unmeasurable.
The cluster matters: an arm entering the frame moves more than the clap itself and lands early
(two pairs on the validation clip came out at +248 and +283 ms against -285).

Validated on ty-20261112-1405-621-0000.mp4: the correlation path said +98 ms and the clap path
+116 ms (three tight pairs at +117, +115, +107), so the applied 350 ms should come down to about
240. Two methods, same answer, no listening required.
2026-11-12 14:07:09 -08:00
gramps 514fb8480d fix(tools): avsync says "unmeasurable" instead of trusting a 0.08 correlation
On ty-20261112-1401-952-0000-2.mp4 the per-window estimates ranged from +400 to -370 ms and
only one window cleared 0.35, yet the tool printed "-370 ms, adjust by +370" from an overall
correlation of 0.08. A number that wrong is worse than no number: it invites another build.

Now: windows above 0.35 must also agree within 150 ms, and anything weaker reports
"unmeasurable" with the remedy — three claps with the hands in the webcam frame, two seconds
apart, which gives a sharp visual and audio transient instead of a mouth-motion proxy.

Also reverted an onset-based envelope tried on the same clip: it dropped the best window's
correlation from 0.47 to 0.08, so the plain level envelope stays.

What the run did establish: lengths match (15.555 s video against 15.601 s audio, 0.3%), and
the log confirms "mic delay 350 ms" was applied — the drift fix and the delay wiring both hold.
2026-11-12 14:03:45 -08:00
gramps bb3bc12784 docs(handoff): bitrate closed, audio drift closed, one sync number left
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's
replacement art, still awaiting their visual check (recorded in the landmines).
2026-11-12 13:58:20 -08:00
gramps b216cbc56f tools: scripts/avsync.py measures A/V sync from the file, not from an ear
Three numbers, from the recording itself: each stream's length, the drift implied by a length
mismatch (a clock problem), and the residual offset between mouth motion and speech (a constant
problem). Per-3s window table with correlations, so a weak reading announces itself instead of
reporting a confident wrong number, and a median over only the strongly-correlated windows — a
whole-clip search on periodic speech locks onto the wrong beat and returns nonsense (it said
-400 ms at the search limit on the clip where the windows said -185).

Validated on ty-20261112-1228-457-0000.mp4: lengths 13.271/13.305 s (match, so no drift) and
-185 ms (sound early), which agrees with the independent visual read of the same clip.

The creator is deaf, so "does it sound right" cannot be the loop. This is.
Needs numpy; uses the bundled ffmpeg.exe.
2026-11-12 13:57:29 -08:00
gramps e654a16b60 fix(history): file each session under the real encoder, not "unknown"
FfmpegEncoder resolved the encoder name into its OWN StreamHealth instance, while the view
model read CurrentHealth — a different object. So every recorded session was filed as
"unknown", and the rate history's encoder key (evidence about NVENC says nothing about QSV)
had nothing to key on. The name now travels on the encoder interface and the pump forwards
it: IFfmpegEncoder.VideoEncoderName -> FramePump.EncoderName.

Caught while adding it: reading _framePump.EncoderName from RefreshRateProposal throws,
because that call runs at MainViewModel ctor line 354 and the pump is built at line 468.
Eight tests failed with the same NullReferenceException, which took a while to see because
the harness's test output is buffered — the first honest look at per-test logging named it
in one line. Guarded; the history falls back to the last session's encoder for that tier,
which is exactly the pre-first-session case the fallback already existed for.

Tests: FramePumpTests gains the encoder-name assertion (null before start, h264_nvenc
after), and the three fake encoders implement the new member.

675 pass, 0 warnings.
2026-11-12 13:57:03 -08:00
gramps 5ae52808bb feat(audio): the mic delay gets a slider, and the log states the applied value
Two gaps in what 7f89534 shipped, both caught by the creator's report that sound still
leads the lips:

- The mic delay had presets only (off / 150 / 200), so the value a slower camera needs —
  roughly 175 ms of residual on top of the 175 ms already applied — could not be set at
  all. Now a 0..500 ms slider with off / 175 / 350 presets.
- Nothing recorded which number was in force. The setting lives in the layout DB, so a
  preset click left no trace in the log; "Audio live: started" now names it.

Confirmed meanwhile from the profile: Audio.MicSyncOffsetMs is 175, so the delay is being
applied — the 150 ms of digital silence at the head of the 12:22 track is the delay line
priming, which is what an applied delay looks like. The residual offset is the camera's
pipeline being slower than 175 ms, not a missing delay.

674 pass, 0 warnings.
2026-11-12 12:37:03 -08:00
gramps 751d9b199c fix(audio): the track starts with the session, not with the first write
Third measurement, and it names the start-up defect the creator heard as "started off
wrong, then corrected" (ty-20261112-1222-748-0000-2.mp4):

  wall clock (audio start -> audio stop)  12.614 s
  video: 754 frames                       12.588 s   correct
  audio: 585 packets                      12.477 s   137 ms short
  pipe connected                          160 ms after the session opened
  first 150 ms of the audio track         digital silence (the mic delay line's head)

The rate is fixed — 12.5 s of show, no accumulating drift — but starting the clock at
connect made the audio stream begin 160 ms after the video stream, so every word sat 160 ms
late and the track came out that much shorter. The silence at the head is the 175 ms mic
delay doing its job, not a fault.

Now the clock runs from session open. Time that passes before ffmpeg connects stays owed,
and it is paid the moment the pipe connects — out of the capture ring, so it is the audio
that was missed rather than padding. Debt larger than one 200 ms chunk continues to be paid
over the following ticks.

674 pass, 0 warnings.
2026-11-12 12:27:34 -08:00
gramps 102cb96b28 fix(audio): only count audio that actually reached the encoder
Second measurement of the same recording family (ty-20261112-1133-701-0000-2.mp4):

  wall clock (audio start -> audio stop)  6.477 s
  video: 386 frames                       6.455 s   correct
  audio: 294 packets                      6.252 s   226 ms SHORT

Same magnitude as the original bug, opposite sign — the clock fixed the tick-rate error and
exposed the next one underneath. Audio started 49 ms before the pump, ffmpeg connected ~80 ms
after, and every chunk written in that window was dropped (dropped writes 27). The loop still
counted those frames as produced, so the track lost exactly that much time.

Now: the clock does not start until the pipe is connected, and AudioClock.Advance counts a
frame only when its write reached the pipe. A dropped write stays owed and is paid off on the
next tick, so a start-up gap costs content (silence, which there was anyway) instead of
length.

Two measurements, two different signs, one lesson: the audio track's LENGTH has to be the
clock's, and anything that removes samples without removing time — a fast tick, a dropped
write — shows up as drift no static offset can fix.

674 tests (one notification-timing flake under full-suite load; passes alone), 0 warnings.
2026-11-12 11:39:20 -08:00
gramps 47f409f0e4 fix(audio): drive the mix from the clock — the audio track was 2.1% longer than the show
Measured on the creator's recording (ty-20261112-1118-423-0000-2.mp4), which drifts
out of sync over ten seconds:

  wall clock (pump start -> audio stop)   9.875 s
  video: 594 frames, uniform 1/60         9.888 s   correct
  audio: 485,376 samples @ 48 kHz        10.112 s   +2.1%, 224 ms long

So it was not VFR and not wall-clock PTS: the video is CFR and matches the clock (the
only PTS anomaly in the file is one 38 ms gap from two lost frames). The audio track is
what was too long, and a static offset cannot fix a rate error — which is why the opening
looked aligned (the 175 ms mic delay cancels the camera's constant pipeline latency) and
the end did not.

Cause: LiveLoopAsync wrote a fixed 480 frames per iteration and trusted that each
iteration was 10 ms. Under a raised timer resolution they land near 9.8 ms — 102 ticks
per second where 100 are expected, i.e. 102% of an audio second per wall second. FFmpeg
stamps audio by sample count, so every second of show pushed the sound ~2% later against
a correct picture. Video never had the bug because the pump paces off a stopwatch.

Fix: Services/Audio/AudioClock asks the clock how many frames are owed
(expected - produced, clamped to one 200 ms chunk) and the loop produces exactly that,
padding on underrun. A stalled loop pays its debt over the following ticks instead of
dumping one huge write. FillAndMix takes a frame count, and the master limiter/meter take
spans, so a partial chunk is processed without touching stale buffer tail.

674 tests (one log-rotation flake under full-suite load while the app itself is running
and writing startup.log; passes alone), 0 warnings. Ear check pending.
2026-11-12 11:28:28 -08:00
gramps 7f89534309 feat(audio): microphone-only sync delay, on by default at 175 ms
The webcam's frames reach the compositor later than the mic's samples reach the audio
pipe, so the voice runs ahead of the lips. The existing lip-sync offset could not fix
that: it delays the WHOLE mix, which drags desktop and alert audio — neither of which
has a camera to follow — out of sync in the process.

So the delay line learned channels (mono for a source, stereo for the mix, offset always
in milliseconds so the frame count is what stays constant), and FillAndMix runs a second
line over micChunk before the mix. Slider-side: presets off / 150 / 200 in the LIP-SYNC
bay under the whole-mix control, persisted as Audio.MicSyncOffsetMs.

Default 175 ms is the creator's own number for a C920 pipeline (they asked for
+150..+200). It is a setting, not a constant, because the right figure belongs to the
camera and the machine — a faster camera wants less, no camera wants none.

Both delay lines are reconfigured every tick, so the 2026-09-12 trap still applies and is
documented at both sites: a Configure that reallocates discards the buffered voice every
10 ms.

673 pass, 0 warnings. Ear check pending: this is an audio change.
2026-11-12 11:15:50 -08:00
gramps f6fdab3bf2 docs(task-40): App Settings is the SettingsDrawer, not the OverlayHost panel
The Unit A plan still points at `OverlayHost.xaml:39`, which stopped being the settings
surface on 2026-11-01 (creator ruling: a right-edge drawer). Recorded before anyone
starts the unit and hangs it off the wrong host.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's
replacement art awaiting their visual check.
2026-11-12 08:30:15 -08:00
gramps ada8d2457d docs(tasks): mark the 8-Mbps ladder and flat bitrate floor superseded
TASK 4's ladder and TASK 33's worst-case table both quote numbers the code no longer
produces. Replaced with a dated note pointing at the current truth rather than
rewriting the decision records: the ladder is 12/10/6 with CBR, and the step-down floor
is the tier's YouTube minimum, not a flat 3.

Scope justification: Assets/starting-background.jpg is outside this unit — it is the
creator's replacement art, still awaiting their visual check.
2026-11-12 08:29:57 -08:00
gramps 7acf044e4a docs(handoff): bitrate correction landed — four units, next is the creator's live check
Scope justification: the only file outside this commit's declared scope is
Assets/starting-background.jpg, the creator's replacement art still awaiting their
visual check (recorded in the landmines).
2026-11-12 08:26:52 -08:00
gramps b0f8263414 feat(quality): learn the starting rate from real sessions, on the creator's permission
Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps
spent — and Models/RateHistory turns that into at most a suggestion. The app never
applies one: it asks before the next stream, "Use it" sets the rate, "Keep mine"
remembers that exact number so the same question does not return until the evidence
moves. Nothing leaves the machine.

Two rules keep it from nagging. The high-water mark is the highest rate that
*finished* a session with zero steps, not the highest seen, so one easy session (a
menu screen, a quiet game) cannot set a target the machine cannot hold. And it is
asymmetric: raising needs 10% above the current rate across five clean sessions of the
same tier and encoder, lowering needs three step-downs in five — fixing damage should
not wait for a percentage, and risking it should.

The encoder key is real, not decorative: StreamHealth.VideoEncoder is now filled from
the probe (FfmpegEncoder), so history recorded against NVENC is not applied to a
machine that later runs QSV. At startup, before anything has streamed, the last
session on that tier is the honest guess.

672 pass, 0 warnings.
2026-11-12 08:24:12 -08:00
gramps f2cab5ca0f feat(quality): the creator sets the rate — per tier, persisted, clamped only at YouTube's floor
The tier table cannot know a creator's connection, so it should not pretend to. The
Mbps box under the picker is theirs: the tier chooses geometry and frame rate, the
box chooses bits. Stored per tier (`Video.Rate.<tier>`), so tuning 1080p60 does not
touch 720p30, and none means "the shipped default" rather than zero.

Clamped at the tier's YouTube minimum (`RateLadder`), because a target under it
cannot hold the resolution; no ceiling otherwise, because a wrong guess is exactly
what live step-down exists to catch, and a creator who types 25 on a fibre line
should be allowed to find out. The tooltip carries YouTube's number for the tier.

`MainViewModel.EffectiveQuality` is the single substitution point, and the encoder
options, the step-down policy and the clip buffer all read it — so a rated tier can
never stream at the shipped default because one call site was missed.

One ordering trap found by the test: the constructor resolves the default tier
before the layout store is open, so a read at that point must not cache, or the
shipped default shadows the creator's stored rate for the whole run.

670 pass, 0 warnings.
2026-11-12 08:11:13 -08:00
gramps 93719a286d feat(step-down): the bitrate floor is the tier's YouTube minimum
Step-down used a flat 3 Mbps (4 for vertical) as its floor — a number that had
nothing to do with the tier being streamed. With CBR holding the target, that
floor is a real picture-quality floor, so it has to follow YouTube's published
H.264 table (support.google.com/youtube/answer/2853702): 6 at 1080p60, 5 at
1080p30, 3 at 720p.

Models/RateLadder is that table as pure policy. Vertical is not a separate row:
1080x1920 and 1920x1080 are the same pixel count, so the frame rate alone picks
the band. StepDownPolicy takes the floor as an optional constructor argument and
refuses one above the starting rate, so an unusual start cannot begin under its
own floor. Omitted, the historical 3/4 floor still applies, which keeps the
existing policy tests meaningful and lets a caller opt in.

One consequence worth knowing: the three-step budget can stop above the floor
(12 → 9.6 → 7.7 → 6.2 from a 20% loss), so the floor is a limit, not a target.

669 pass, 0 warnings.
2026-11-12 08:05:55 -08:00
gramps 1708667602 feat(quality): retarget the ladder — 1080p60@12, 1080p30@10, 720p@6
Every 1080 tier carried 8 Mbps, inherited from TASK 4's "mainstream ceiling,
upload headroom stays comfortable". It was never derived from anything: the same
number served 30 fps and 60 fps, landscape and vertical, which no per-tier
derivation would produce. With CBR now actually holding the rate (700c7a8) that
number becomes the picture quality, so it has to be chosen rather than inherited.

New defaults sit between YouTube's H.264 recommendation (17 at 1080p60, 14 at
1080p30, 8 at 720p — support.google.com/youtube/answer/2853702) and the rate
practitioner OBS guides converge on for YouTube (9–12 Mbps at 1080p60). Vertical
mirrors its landscape pair: 1080x1920 is the same pixel count as 1920x1080.

The free-profile fallback in OutputProfile.Resolve moves with it (8.0 -> 10.0) so
a build with no shipped tiers still emits the 1080p30 rate rather than a stale
one.

668 tests (1 audio-pipe flake under full-suite load, passes alone), 0 warnings.
2026-11-12 08:01:01 -08:00
gramps 700c7a8c23 fix(encoder): ask for CBR — the capped-VBR triad was never a constant rate
The stream was configured as "@8 Mbps" and averaged 5.1. The cause is the argument
set, not the machine and not YouTube: `-b:v K -maxrate K -bufsize 2K` is capped
VBR. Measured on the bundled BtbN build with a static 640x480 frame at -b:v 2000k:

  default rate control   9,858 bytes / 8 s  =     9 kbps
  -rc cbr            2,001,989 bytes / 8 s  = 1,951 kbps
  -rc vbr                9,858 bytes / 8 s  =     9 kbps

On high-motion testsrc2 the same target produced 2006 kbps (CBR) against 1741
(VBR): VBR undershoots by however easy the picture is, and a composited scene
with a still background is very easy.

Spelling is per encoder, verified rather than assumed:
- h264_nvenc, h264_amf: `-rc cbr`.
- h264_qsv: nothing. It has no `-rc`, and libavcodec/qsvenc.c (select_rc_mode,
  FFmpeg d263fce2b2) selects MFX_RATECONTROL_CBR when bitrate == maxrate — which
  the triad already sets. Passing -rc here would be an unknown option.
- libopenh264: nothing. `-rc_mode bitrate` changed no measurable output on this
  build (3,832 vs 3,830 bytes), so it stays out rather than adding a line whose
  effect nobody can demonstrate.

Reference: YouTube's encoder spec requires CBR
(support.google.com/youtube/answer/2853702).

668 tests pass, build 0 warnings.
2026-11-12 07:57:36 -08:00
gramps 37ef74eb8d docs(handoff): bitrate diagnosis, four-unit plan, unit 1 landed
Records the 5.1-vs-8 finding as an args defect (no -rc, so NVENC ran VBR) rather
than a machine or ingest limit, the four units that follow, and two new landmines.

Assets/starting-background.jpg stays out of this commit: it is the creator's
replacement art, still awaiting the visual check recorded in the landmines.
2026-11-12 07:51:30 -08:00
gramps 1e3a4f7fe0 feat(entitlement): pre-release switch so Release builds can test 60 fps
The frame-rate boundary is correct as a product rule, but it only bites in a
Release build, where DevBuild.IsDevelopment is false and DeveloperMode cannot
unlock anything — exactly the build whose performance numbers are worth reading
(see docs/ai/environment.md: Debug pixel paths are several times slower). 60 fps
could not be tested at all.

OutputProfile.EntitlementEnforced is false: every shipped tier is selectable by
any build, and HasProOutput — the one property the tier setter, the dropdown's
strikethrough and the locked-item style all read — answers yes regardless of
ownership. static readonly rather than const, so the consumed `||` does not fold
into unreachable operand code (same reason as DevBuild.IsDevelopment).

The licence indicator row is suppressed while the switch is off: it exists to
explain what the boundary costs, and claiming "Steam ID Limit: 60fps" to an
unowned build would be a false statement. Docs corrected in place — the enforced
rule is unchanged, only its state is recorded.

Tests: one integration case covers both switch states (unowned build reaches
1080p60 with the switch off, 1080p30 with it on), so flipping at launch leaves no
red test behind. The two suites that anchor on the pinned licence row early-return
while it is absent; EntitlementTests covers that state meanwhile.

667 pass, build 0 warnings.
2026-11-12 07:50:59 -08:00
gramps 9c0c40c688 feat(settings): make slide-out alerts persist until dismissed
Add a persisted global App Settings switch. Off keeps the existing per-severity
lifetimes (errors remain sticky); on makes every alert non-expiring and closes
it when its body is clicked. The NotificationService supplies the current
setting to each toast request.

StickyNotificationIntegrationTests covers persistence and both request modes.
618 tests green, clean build with 0 warnings.

Reference: Notification.Wpf exposes a CloseOnClick request option:
https://github.com/G-Stas/Notification.Wpf
2026-11-11 10:19:20 -08:00
gramps ff5c005bd9 feat(settings): position slide-out notifications
App Settings now chooses one of six ToastArea positions (left/right ×
top/center/bottom), defaulting to the existing bottom-right. The setting is
persisted per profile and the toast area moves immediately; edge gutters keep
bottom notifications above the footer.

ToastPositionIntegrationTests covers live repositioning and restart persistence.
617 tests green, clean build with 0 warnings.

Reference scan: OBS Notifier supports user-configured notification placement:
https://github.com/DmitriySalnikov/OBSNotifier
2026-11-11 10:10:21 -08:00
gramps 2f5d0c1759 feat(settings): position slide-out notifications
App Settings now chooses one of six ToastArea positions (left/right ×
top/center/bottom), defaulting to the existing bottom-right. The setting is
persisted per profile and the toast area moves immediately; edge gutters keep
bottom notifications above the footer.

ToastPositionIntegrationTests covers live repositioning and restart persistence.
617 tests green, clean build with 0 warnings.

Reference scan: OBS Notifier supports user-configured notification placement:
https://github.com/DmitriySalnikov/OBSNotifier
2026-11-11 10:06:54 -08:00
gramps 9621db29c9 fix(stream): keep recovery notifications and stop on the dispatcher
The FFmpeg ProcessFailed event arrives off-thread. Start recovery on the UI
dispatcher, publish retry status there, and route StopStream back to the
dispatcher before changing bound state or alert collections. This prevents a
recovery exception from becoming an unhandled WPF CollectionView crash.

AttentionAlertStackTests covers worker-thread recovery reporting and stop.
616 tests green, clean build with 0 warnings.
2026-11-11 09:57:26 -08:00
gramps 24077e58cf fix(stepdown): restart the audio pipe around encoder changes
A quality step-down now closes the named audio pipe before stopping/flushing the
pump, then opens a fresh server before restarting the pump. This prevents ffmpeg
from waiting on the old audio input and avoids reconnecting to a broken pipe.

EncoderRestartTests pins the audio-stop → video-stop → audio-start → video-start
order. 616 tests green, clean build with 0 warnings.
2026-11-11 09:52:27 -08:00
gramps 2c5a3d8c11 docs(70): queue the Live Window App overlay layer
Record the creator's Live-only, single-layer scope and distinguish it from the
existing session-scoped Capture Window backdrop pin. Include HWND lifecycle,
protected-window, visual-only, and duplicate-underlay boundaries.

Reference scan: Microsoft HWND capture item API and OBS Window Capture:
https://learn.microsoft.com/en-us/windows/win32/api/windows.graphics.capture.interop/nf-windows-graphics-capture-interop-igraphicscaptureiteminterop-createforwindow
https://obsproject.com/kb/window-capture-sources
2026-11-11 07:42:37 -08:00
gramps 4e5b4e3999 docs(69): queue the chat sample preview toggle 2026-11-11 07:37:38 -08:00
gramps b265d42fb4 docs(handoff): record pushed main state 2026-11-11 07:31:19 -08:00
gramps 1d35a7400f docs(37): close defaults/current split and advance the queue 2026-11-10 18:10:35 -08:00
gramps e6c6913994 feat(37): expose the staged-scene factory reset
App Settings can now restore only the staged canonical scene. The VM saves
pending edits first, resets that scene, reloads immediately, and keeps the same
scene staged. Other scene edits survive; the action is disabled while live or
recording. 615 tests green, clean build with 0 warnings.

Reference: OBS Scene Collections keep scene/source sets distinct:
https://obsproject.com/kb/scene-collections
2026-11-10 18:09:16 -08:00
gramps 41064bdd13 feat(37): expose the all-scenes factory reset
App Settings now offers a clearly described all-scenes reset. It is disabled
while live or recording and reloads the restored layout immediately. The
integration test drives the real VM command and checks the Settings binding.
614 tests green, clean build with 0 warnings.

Reference: OBS Scene Collections are a distinct saved set of scenes and sources:
https://obsproject.com/kb/scene-collections
2026-11-10 18:05:35 -08:00
gramps b8eb2b4451 feat(37): restore all scenes to factory defaults 2026-11-10 17:59:28 -08:00
gramps ff1c1965e0 fix(37): preserve camera choice when restoring default layouts
Build-mismatch restoration now keeps the currently selected webcam identity,
restores default scene placements against that camera, and leaves cameras absent
when none is selected. The integration regression test pins the creator's camera
choice and restored default geometry.
612 tests green, clean build with 0 warnings.
2026-11-10 17:56:24 -08:00
gramps c4da06b56b feat(37): reset one scene to its factory snapshot
The per-scene reset restores that scene's settings, layers, webcam placement,
and background-path settings while leaving other scenes' current edits alone.
A focused integration test covers the restore/isolation behavior.
611 tests green, clean build with 0 warnings.

Reference: OBS scene collections store scenes and sources as a saved set:
https://obsproject.com/kb/scene-collections
2026-11-10 17:52:20 -08:00
gramps 1c6fc3223d feat(37): restore the default layout when the build ID changes
A mismatched or missing current-build stamp restores the immutable default
snapshot atomically before the VM loads scenes. The new integration case edits
current on build A, opens on build B, and verifies the factory layout returns.
610 tests green, clean build with 0 warnings.

Reference: OBS scene collections keep scenes/sources as a separate saved set:
https://obsproject.com/kb/scene-collections
2026-11-10 17:48:43 -08:00
gramps 37791086ae feat(37): keep a default layout snapshot beside the current layout
The matching-build persistence slice is in place: existing layouts are captured
once as defaults, first-run factory scenes are saved and captured before the
creator can edit, and same-build startup continues to load current rows. Default
asset references survive current-layout saves.

One integration test covers the current-layout roundtrip and immutable default.
609 tests green, clean build with 0 warnings.

Reference scan: OBS stores scene collections separately from output profiles;
this split keeps layout state independent in the same SQLite store:
https://obsproject.com/kb/scene-collections
2026-11-10 17:45:39 -08:00
gramps b093609ad3 docs(14): close mic gain slice after creator test 2026-11-10 17:40:44 -08:00
gramps de787e0dad docs(65): replay clips complete; queue moves to 66 2026-11-10 12:55:06 -08:00
gramps 5fcfa545e5 feat(65): the replay layer plays — once in Live, looping everywhere else
608 green, 0 warnings. Completes the layer half of TASK 65.

- **(+) → Replay Clip** adds an ordinary media layer with an app-owned path, one per scene, silent by
  default (`MediaMuted`) and looping only outside Live — the compositor needs no special case.
- **F10 saves → the Live layer plays it once, then hides** (`SessionCompleted` → invisible, guard
  cleared). Every other scene with a replay layer keeps looping the kept set, advancing on each clip's
  completion, wrapping at the end.
- **The guard the creator asked for:** while that Live replay is playing, **F10 is inert** with a plain
  explanation. That is what replaces the impossible "exclude the layer from its own clip" — with one
  encoder and two muxes the clip *is* the program, so the fix is to not clip a clip.

`ReplayLayerTests` drives the real VM: one layer per scene, muted by default, once-through in Live,
looping in BRB, and a stray F10 with clips off writes nothing.
2026-11-10 12:54:54 -08:00
gramps a5c2061b55 feat(65): the replay layer exists in the model; the exclusion problem is solved by the guard
Slice 5a, scaffolding only. 607 green.

- `Source.IsReplayLayer` + schema column + roundtrip: a media layer that shows saved clips instead of a
  chosen file. The compositor needs to know nothing — it is a media layer whose path the VM owns.
- **Two spec corrections from the creator (2026-11-10), recorded in the task:**
  1. The immediate replay of a fresh clip happens **only in the Live scene**. While it plays, **F10 is
     inert** ("replay in progress").
  2. That guard replaces the "layer excluded from its own clip" idea, which is **impossible** with one
     encoder and two muxes: the clip is a second mux of the same frames, so it necessarily contains what
     viewers saw. No second encode, no fiction about a clean feed.

Remaining for 65: the add-layer menu entry, `ClipSaved` → play (Live: once then hide; elsewhere: loop the
kept set), `SessionCompleted` → advance or hide, and silent-by-default audio.
2026-11-10 12:48:41 -08:00
gramps 28657918f9 feat(65): F10 saves a clip; the gear drawer owns the three settings
Slice 4. 607 green, 0 warnings. Clips remain **off by default** and, while off, the ring is never
constructed — so no reader thread and no encoder output exist until someone asks for the feature.

- **F10** (`HotkeyId.SaveClip`, default F10, remappable in the hotkey dialog) snapshots the ring, writes
  the file, prunes to the retention count and toasts the name. Empty ring → "nothing to clip yet"
  instead of a zero-byte file; clips off → says so and points at Settings.
- **Settings**: Enable, length (10 or 15, default 10), keep (3/4/5, default 3). Persisted in the layout
  store and **coerced on load** — a bad stored value falls back to a legal one, so the ring can never be
  sized for a length the UI cannot show.
- The ring is sized from length × tier bitrate × 1.25 and rebuilt if either changes; it is created at
  session start and dropped at stop, so memory is only spent while a show is running.
- The drawer states the boundary in the creator's terms: "A capture, not an editor: for trimming,
  captions or a library, use a video editor."

Still to come: the replay layer itself (play once then hide in Live, loop the kept clips elsewhere).
2026-11-10 12:41:39 -08:00
gramps f7d3d1301e feat(65): clips on disk — write one, keep 3/4/5, delete the oldest
Slice 3 of TASK 65. 606 green, 0 warnings.

`ClipLibrary` is the whole of clip "management": a file lands in `clips\` beside the recordings, the
oldest is deleted past the retention count, and `Recent()` returns newest-first because that is also the
replay layer's playlist order. No gallery, no prompt, no naming.

Two guards the tests pin: an **empty snapshot never becomes a zero-byte file** (a save that fails loudly
is better than a file that won't open), and **pruning only touches our own prefix** — another file in the
folder survives.

`RingBytesFor(seconds, bitrate)` is the memory cap the ring is sized with: exact wire size × 1.25
headroom, because bitrate moves and the ring trims whole fragments.

Still to come: the replay layer, the F10 hotkey, and the Settings switch.
2026-11-10 12:35:38 -08:00
gramps 5066d81aab feat(65): the clip mux — a third output on the same ffmpeg, drained off the frame path
Slice 2 of TASK 65. 602 green, 0 warnings. **Clips are still off**: no `ClipBuffer` is passed, so the
ffmpeg command line is byte-for-byte what it was before this commit, and a test asserts exactly that
(the original args are a strict prefix of the new ones).

- `FfmpegArgs` appends a fragmented-MP4 output to `pipe:1` only when `ClipBuffer` is set. One-second
  fragments (`-frag_duration 1000000`) rather than the 4 s GOP, so a clip can start inside the window
  instead of at the last keyframe. Stats already own stderr, so stdout was free.
- `Fmp4FragmentSplitter` turns that byte stream into whole fragments: `ftyp`+`moov` is the init
  segment, each `moof`+`mdat` is a fragment. It parses box headers itself, survives a box being cut
  anywhere across reads, and **resyncs instead of throwing** — a clip fault must never take the stream
  down.
- `FfmpegEncoder` reads it on its own task, never blocking the frame path. Any exception logs and
  disables clips for the session; the broadcast is untouched.
- `IEncoderProcess` gained `StandardOutputBytes` (the seam already had the text reader for probing).

Next slices: the replay layer + retention, then the Settings switch with disabled-but-remembered controls.
2026-11-10 12:25:19 -08:00
gramps b93744ec7f feat(65): the replay ring — encoded fragments in RAM, capped by bytes
Slice 1 of TASK 65, pure memory, no encoder change yet.

A 1080p BGRA frame is 8.3 MB, so fifteen seconds of raw composite is ~7.4 GB — the 15 MB figure in the
spec is the *compressed* stream. So the ring holds fragmented-MP4 bytes that ffmpeg will write to a
pipe, and a save is init-segment + surviving fragments in order.

Two rules the tests pin: the cap is by **bytes** (bitrate moves; seconds do not), and the newest
fragments always win, because a clip that ends at the keypress is the entire point. The init segment does
not rotate and cannot be reused across encoder starts, so `Clear` drops it too — an MP4 without its moov
is noise.

Next slice: ffmpeg writes the buffer output to stdout while `-stats` stays on stderr, and the pump feeds
this ring. Not done here on purpose — an encoder change is the riskiest edit in the app and deserves its
own commit and test.
2026-11-10 11:59:02 -08:00
gramps 1a6a549e17 feat(55): stop leaving disk debts — temp clips and a low-disk guard
595 green, 0 warnings. Completes TASK 55 (slice 1, log rotation, shipped 2026-10-22).

- **`AlertClipCleanup`**: the default alert clip is materialised into `%TEMP%` for ffmpeg to read, and
  nothing ever deleted it — one orphan per profile that ever played an alert. Now: delete this session's
  file in `Shutdown`, and sweep `ytLive-alert-*.mp4` at startup for anything a crash left behind. Only
  the prefix we own is touched; a directory is never deleted.
- **`DiskSpaceGuard`**: a recording that dies of a full disk is a trust breach, so the numbers are a
  pure policy — refuse to start under **1 GB** (toast; the stream still starts, recording does not),
  warn under **5 GB** at start ("about eighty minutes" at 8 Mbps ≈ 3.6 GB/h), and one mid-show warning
  under **2 GB** — the last useful moment before the encoder fails for a reason it won't explain.
  Unreadable drives (a USB stick that just left) return null and never block a running recording.

Tests are policy-level on purpose: filling a real drive is not a test. `DiskHygieneTests` proves the
sweep only removes our own files and that the thresholds behave at both ends.
2026-11-10 11:55:42 -08:00
gramps c257de1516 feat(35): scenes remember the room — BRB mutes the mic on entry
591 green, 0 warnings.

- `Scene.SceneMutesMic` / `SceneMutesDesktop` are **nullable booleans**: null = leave the mix alone, so
  a scene is passive until the creator says otherwise. Schema columns added, NULL for every existing
  layout except one deliberate default.
- **Applied once when the scene takes the PROGRAM output** (`TransitionToScene`, only when live or
  recording). Staging a scene offline is auditioning, not a stream edit — the test asserts that.
- **The creator is boss:** a manual mute survives any transition into a scene with no opinion; a scene
  that does have one wins on its next entry. `SetMicMuted`/`SetGameMuted` are idempotent so a repeated
  "mute" cannot clobber the level that unmute would restore.
- **BRB ships muted** (creator spec) for new layouts *and* via a one-time migration for existing rows
  where the creator has never expressed an opinion.
- UI: two three-state checkboxes under the layers list ("When this scene goes live"), styled with
  `PropertyPillCheck` so the theme test holds.

`SceneAudioTests` covers the whole rule in one integration test: default state, offline staging does
nothing, live entry mutes, manual unmute survives a passive scene, an opinionated scene wins, desktop
untouched, and all three states roundtrip through save/reload.

Needs the creator's eye: the checkboxes are new chrome in the left panel (indeterminate = passive).
2026-11-10 11:51:21 -08:00
gramps 80ccdc9cb8 docs(readme): new hero screenshot (1400×862, 289 KB)
Creator-supplied capture of the main window: five scenes across the top, the Starting scene with its
three callouts in the preview, the layer panel, and the audio dock.

Re-encoded with the bundled ffmpeg instead of the WPF resize tool (scale 1400 + q4) — one command, the
same tool the rest of the assets use — and that shortcut is recorded in MyMistakes next to the original
recipe so it is not re-derived a third time.
2026-11-09 14:32:02 -08:00
gramps ec6a5ca466 docs(queue): six veggies, then the new work; scheduling and publishing retired
Creator ruling 2026-11-09: finish the queue before the new features, gold pass always last.

**Retired (platform's surface, not ours):**
- 34 scheduled streams — YouTube owns scheduling *and* the reminder that makes it worth anything.
- 50 publish recording — Studio owns upload (resumable, quota-aware, explains rejections), and an
  in-app uploader would widen the OAuth consent to whole-channel video management for one convenience.
  The app adds "Show in folder" instead.

**New tasks:** 65 replay clips (the creator-side buffer YouTube does not give you), 66 timed ticker
messages, 67 per-event alert videos, 68 edit-while-live + first-run On-Air.

**TASK 65 spec** is the interesting one: 15 s is a clip (10/15/20, default 15); the ring holds encrypted
*encoded* fragments in RAM — raw 1080p frames would be ~7.4 GB for 15 s — so there is no steady disk
write and no crash debris; F10 saves; the replay layer plays once then hides in Live and loops the last
1–3 elsewhere; silent by default; three kept; the layer is excluded from its own clip (hall of mirrors).

**TASK 14 gains Branch 3:** mic input gain ahead of the gate (table boom mics fall under it), webcam
blank hotkey, opacity/geometry parity with a collapsed Geometry disclosure, Show-in-folder.

**Decisions recorded:** the replay-buffer reversal (it was retired in 2022-era reasoning as "different
product"; the case for it won), the platform-surface rule, and voice triggering deferred — always-
listening in a streaming app hears the game, so the content is its own false-positive source.

Queue refactored to 56 lines by archiving completed rows and the superseded surge plan; HANDOFF at 40.
2026-11-09 14:27:07 -08:00
gramps 42228c7aab docs(handoff): 40 lines 2026-11-09 13:21:14 -08:00
gramps 4b3d3f2645 docs(handoff): fit the 40-line budget 2026-11-09 13:05:59 -08:00
gramps 4855d5cf8d docs(handoff): TASK 33 complete; queue starts at 34 2026-11-09 13:05:53 -08:00
gramps 0fa3006da7 feat(33): shed our own capture cost first; the task is done
Last slice. 590 green, 0 warnings.

- **Shed before you charge the viewer.** On machine evidence the policy's first act is
  `ShedCaptureCadence`: `ScreenCaptureFrameSource` widens its conversion interval 10 ms → 33 ms (≈100
  → 30 conversions/s) through a new `ScreenCaptureManager.ShedLoad`. The output tier, the declared fps
  and the viewer's picture are untouched — we stop paying for readbacks we were throwing away. It
  costs no step budget; if the starvation persists, the fps ladder runs on the next clean window.
- The shed is reset on every stop path, so a cheap session cannot leave a later one capped.
- **The session line is a Logs-tab line, not a session-report line.** The session report is the money
  summary (Super Chats, per currency); quality chatter there would bury the numbers it exists for. The
  cause-tagged line already lands in the log at the moment of the step.

TASK 33 is complete: two ladders, budgets, floors, ambiguity, one-way with recovery behind a flag, the
relaunch, the copy, and the cost shed. 18 tests pin the ladders, the copy and the restart.
2026-11-09 13:05:44 -08:00
gramps 134103c08b feat(33): the step-down acts — one encoder relaunch, one toast, same URL
Slice 3. 589 green, 0 warnings.

- **`FramePump.ChangeEncoderAsync`** is a full pump stop/start, not an encoder swap. A swap leaves an
  in-flight submit pointing at a disposed process, and that faults the loop — which ends the
  broadcast, the one thing a quality step-down must never do. `StopAsync` already awaits the loop and
  flushes, so this reuses the tested path and costs one short freeze, which the ladder accepts.
- **`MainViewModel.StepDown`**: one planner per broadcast, subscribed to `StatsReported`, unsubscribed on
  every stop path. The pump thread only decides; the restart is posted to the dispatcher, because the
  act stops the loop that raised the event.
- **The creator's choice is not rewritten**: `_encoderTierOverride` changes what the encoder emits while
  the dropdown keeps showing what they picked, and the badge adds `· lowered` so the two never disagree
  silently.
- Copy comes from `StepDownPlanner.Describe` (machine cause owns it, upload cause blames the upload);
  the log gets the cause-tagged line for support.

Test: `EncoderRestartTests` runs a real pump with a fake encoder, changes the tier, and asserts a NEW
encoder started at the new fps/bitrate and produced a frame — the regression that would otherwise ship
as "it lowered the badge and the stream died".

Remaining for 33: shed our own capture cost before the fps step, and the session-report line.
2026-11-09 12:50:53 -08:00
gramps b84802e657 docs(handoff): trim to budget; 588 tests 2026-11-09 11:54:15 -08:00
gramps b39439abdc docs: the vertical take is clean — 29.8 distinct webcam images/s
Creator's Release take `ty-20261109-1142-036-0005.mp4` (1080×1920 @ 30, 300 s) measured by cropping the
webcam tile and diffing frames: 29.8 distinct images/s overall, median 30 s window 30.0/s, worst 24/s,
one 4-frame repeat run in 9026 frames. The app's own lines agree (`webcam 28.4–30.0/s` against camera
`arrivals 29.9–30.1/s`).

- The 25.6/s failure recorded on the 2026-10-05 16:9 take does **not** reproduce in the vertical tier,
  so the vertical take no longer blocks the cadence units.
- Recorded the measurement trap in `frame-pipeline.md`: a threshold of 1.0 on a downscaled tile
  reports long "0.0/s" stretches during low motion — duplicates sit below 0.02, a still face at
  0.3–0.7. Read the histogram before believing a count.
- Vertical-tier camera check reported 1–6 pass; both creator-hands items in TASKS.md are closed.

(The mic being off for the first 2:42 does not affect video cadence.)
2026-11-09 11:53:58 -08:00
gramps 555ff5c0ac feat(33): structured pump stats + the session planner and its plain-words copy
Slice 2. 588 green, 0 warnings.

- `FramePumpStats` and `FramePump.StatsReported`: the 5 s window the log line already describes, as
  data. `Dropped` is the ENCODER's counter (frames submitted, not accepted); `Stalls`/`AvgRenderMs`
  are the pump's. `FramesRendered` is deliberately not used for classification — a static scene
  reuses the paste cache and renders few frames while completely healthy, so it would read as
  starvation.
- `StepDownPlanner`: one per broadcast, owns the stateful policy, converts stats → sample, exposes the
  landed tier for the restart. It never acts — the VM owns the effects, which is what keeps the ladder
  testable without an encoder.
- `Describe` / `ReportLine`: the sentences a creator sees. Machine cause says LlamaCasty couldn't keep
  up and the connection was not the problem; upload cause blames the upload and promises no further
  thrash. A test asserts the machine copy never leaks "dropped" — mechanism strings stay in the log.

Remaining for the task: VM subscription, the encoder restart at the landed tier, the toast, and
shedding our own capture cost before stepping fps.
2026-11-09 10:06:36 -08:00
gramps bc62bd81ec feat(33): StepDownPolicy — two ladders, one budget, recovery behind a flag
Pure policy, no wiring yet. 12 unit tests, 584 green overall, 0 warnings.

- **Machine starvation** buys less work: 60 → 30 fps first, then 1080 → 720. The vertical tier has no
  resolution step — its floor *is* its only geometry.
- **Upload congestion** buys fewer bits: `current − max(0.5, current × lossFraction)`, floored at
  3.0 Mbps (16:9) / 4.0 (vertical). Resolution is deliberately not a bandwidth lever: 1080p30 and
  720p60 cost the same, so dropping pixels at an unchanged bitrate relieves nothing.
- **Budgets** as ruled: three steps for a 1920-wide start, one for 720 and vertical. 15 s of evidence,
  30 s cooldown, ambiguous evidence does nothing at all.
- **Recovery** is `allowClimb`, default off (the one-way ruling): the machine carries its ceiling from
  construction, climbs one step after 180 s of clean windows, and a climb followed by a relapse inside
  120 s freezes the session at the low value — one flap pair is proof enough.

Inputs are the poster's own counters (`PumpStalls`, `PumpDrops`, `AvgRenderMs`, `EncoderDrops`), not a
throughput reading: `StreamHealth.CurrentBitrate` is ffmpeg's *encoded output* rate and sits at target
even while the socket is gagging, so it cannot size a step.
2026-11-09 09:30:28 -08:00
gramps 92d875c343 tools(stats): mine sessions from the logs, split by sink
`scripts/session-stats.py` turns every `startup*.log` into one row per FramePump session: tier,
scene, measured fps, drops, stalls, average/worst render, capture rate, and the build — with the
sink marked, because that is what makes the numbers quotable:

- **RECORD** — machine only, no socket. The cleanest minimum-config evidence.
- **TEST** — machine + socket, no audience; recognised by `Test session dock open` /
  `Test chat simulation`, which the creator pointed out are recorded too.
- **LIVE** — network and audience included.
- **UNKNOWN** — no marker; not evidence.

Verdicts use the average render against the frame budget, not the worst: the worst is dominated by
the 100 ms first-frame bake (a session that "holds" can still show worst 35 ms). `holds` = no drops,
no stalls, average under half the budget.

First run over the existing logs already separates the two halves of TASK 33: static Starting scenes
render at ~1.5 ms, the same pump with display capture running at 35–46 ms — the capture path, not the
overlays, is where a budget streamer loses the frame budget.
2026-11-09 09:04:04 -08:00
gramps ed32151136 docs(33): cause-classified step-down, and a toast that owns the mistake
Design settled with the creator. Two findings drove it:

- **Resolution is not a bandwidth lever.** 1080p30 and 720p60 are both 6 Mbps in the shipped table,
  so "step down to 720p" relieves nothing against congestion; and 60→30 at the same bitrate relieves
  nothing either. Congestion therefore steps **bitrate only** (8.0 → 6.4 → 5.1 → 4.1, ~20%), which is
  what OBS Dynamic Bitrate does — cited, adopted, with its climb-back discarded for v1.
- **The other half has no OBS equivalent**: machine starvation (the game and the app on one PC) steps
  fps, then resolution, and sheds LlamaCasty's own capture cost *first*. That is the app taking
  responsibility for a stream it is helping to starve.

Budgets as ruled: 3 steps for a 1080 start, 1 for 720 and vertical, hard floors at 720p30 /
Vertical 1080p30, hold-and-warn at the floor, never disconnect. Trigger ≥5% drops over 15 s twice,
30 s cooldown, ambiguous evidence does nothing.

New rule in `ui-surface.md`: a toast names the cause, the culprit and the remedy. Mechanism strings
("All pipe instances are busy", HTTP codes, exit codes) are log findings, never user-facing copy.
2026-11-09 08:51:31 -08:00
gramps e53f9974a5 docs(tasks): TASK 32 is complete — Back on air shipped; the queue starts at 33 2026-11-08 12:22:35 -08:00
gramps 4a43b3e229 docs(videos): fill the gaps — chroma, video layers, live edit, hotkeys, resilience
Audited the shipped feature surface against the list. Seven subjects were missing, three of them
distinguishers:

- **Green/blue screen removal** — auto-detect + opt-in suggestion, and it keys alert clips too.
- **Video file layers** — clip decodes, audio joins the mixer, per-layer mute/level.
- **Edit the broadcast on air** — title/description/tags/visibility, same fields prefill Go Live.
- Global hotkeys (F1–F5 / F6 / F7 / F8 / F9, OS-level), layout autosave, record with no sign-in.
- Resilience as one subject: ingest pre-flight, reconnect, Back on air, crash-safe fragmented MP4.

Corrections: the transition row claimed Move, which is still queued — now says so. The after-surge
list gained Move and scene-linked audio (35) and dropped entries that have shipped (media file
picking) or that belong to ready rows (resilience). 32 ready subjects, 8 later.
2026-11-06 15:59:12 -08:00
gramps a8f27db4c2 docs(videos): the running list — capture window/desktop, zoom gesture, How to Blur as its own video 2026-11-06 15:57:10 -08:00
gramps b4e952fda9 fix(go-live): the Start Stream dialog re-reads Stream Info at open
Creator went live on the Debug build while the Release build was recording and got an empty
title/description, with the Stream Info tab filled in on screen. Both processes write to the same
profile: `BroadcastForm` is loaded ONCE at startup and each keystroke persists straight to the store,
so the instance that opened the dialog was holding the form as it was when it launched — before the
other instance saved those fields. The dialog copied from that stale copy.

`CreateGoLiveDialog` (new, extracted so it can be tested without showing a window) calls
`BroadcastForm.Load()` before composing the dialog. That is what the form's own doc comment always
claimed ("startup / Go Live refresh") but the refresh was never wired. It cannot lose an edit: every
keystroke is already persisted.

The dialog also logs `Go Live dialog: title='…' description=N chars`, so the next report of "the
dialog was blank" says immediately whether the values reached the dialog or never left the store.

Test: `BroadcastPullOutTests` writes metadata through a second `LayoutStore` handle (the other
instance) after the VM has loaded and asserts the dialog carries it. 572 green, 0 warnings.
2026-11-06 12:56:05 -08:00
gramps bef818f4a1 art(starting): new Starting background — cleaner chat panels
Creator supplied a 2730x1536 render; resampled to the 1920x1080 master at q3 (392 KB, down from
432 KB). The previous render is archived as `Assets/archive/starting-background-v2-2026-11-06.jpg`
alongside the two before it.

No version bump needed any more: `StampDefaultBackgroundAsset` compares the stored asset's hash with
the bundled bytes, so an existing profile re-seeds on the next launch by itself.

The same image replaces the Starting shot in the site's scenes section (llamacasty.web, deployed
2026-11-06 12:39).

Background tests: 45 passed; the Debug assembly embeds the new bytes (391786) and no longer carries
the previous render.
2026-11-06 12:40:27 -08:00
gramps dbcb69278f fix(audio): a DEBUG build never shares the pipe name with Release
Creator was recording on the Release build and hit "Couldn't start the audio — another copy of
LlamaCasty is already using the audio pipe" when going live on Debug. The guard did its job (no
crash), but the collision should not have been possible: the pipe name only suffixed when
`YTLIVE_INSTANCE` was set, and a Debug launched straight from the IDE has no id — so it asked for
the same `ytllive_audio` the Release build was serving.

DEBUG now always suffixes: the claimed instance id when there is one, otherwise the process id. Any
number of dev builds can run beside the Release app, and each Debug process is distinct from every
other. Release is unchanged — it resolves to the plain name, and the whole mechanism disappears in
`#if DEBUG` as before.

`TryStartAudioPipe` now logs the resolved name (`Audio pipe: …`), so the next report of this kind is
one grep instead of a code hunt.

Tests: AudioPipeInstanceTests pins that an id-less DEBUG process never resolves to the bare name;
572 green, 0 warnings.
2026-11-06 12:30:48 -08:00
gramps 30824c6d6e docs(handoff): byte-compare art seed, not a version stamp 2026-11-06 12:21:35 -08:00
gramps 70e2c48136 fix(art): re-seed a bundled background when the stored bytes differ
The creator's profile still showed the previous Starting art after the replacement, with
`Art.DefaultVersion = 4` recorded — so the version gate had already declared itself done. It
compared a constant against the embedded resource, and those two were compiled at different
moments (version 4 landed with the OLD image embedded; the corrected image reached the assembly
one build later). A version bump can always be raced that way.

The seed now compares BYTES: `LayoutStore.AssetMatches` hashes the bundled resource and checks the
row the background already points at. Same bytes → leave it untouched (so this is idempotent and
never churns the database). Different bytes → re-stamp. The version constant and its setting are
gone, because a heal that only runs once can silently skip the one launch it was needed for.

`SceneThumbnailTests` planted its own art on a background still flagged as default art — in the
real app that state only ever means stale bundled art, because Browse… sets the path and clears
the flag. The fixture now models a creator's own picture.

Tests: 572 green, 0 warnings. Verified the profile's Starting asset hash (167CDD3B…, the
two-category render) differs from the shipped `starting-background.jpg` (5FC6DD22…), so the next
launch re-seeds it.
2026-11-06 12:21:24 -08:00
gramps 3db964e5cf docs(handoff): Starting art vintage 4, the AllBackgrounds trap, site scenes shot 2026-11-06 12:06:13 -08:00
gramps a6f15a4bc3 art(starting): the corrected onboarding screen, and a heal that actually heals
The creator's corrected Starting artwork (the in-app onboarding screen: sign in → stream details →
go live) replaces yesterday's version. 2724x1536 resampled to the 1920x1080 master, q3 (432 KB).
The previous Gemini render is archived beside the original art.

Also fixes the two reasons the replacement would not have appeared on an existing profile:

- **The first heal iterated `AllBackgrounds()`**, which returns only the LIVE scene's capture rows, so it
  cleared nothing and then saved the version anyway — permanently skipping the heal. It now walks every
  scene's background. Version bumped to 4 so profiles stamped 2 or 3 heal.
- **The pack URI was unqualified**, so it resolved against the ENTRY assembly: LlamaCasty.exe in
  production, the test host under `dotnet test`, where the seed silently did nothing. It is now
  `/LlamaCasty;component/Assets/…`, which works in both.

Test: `BackgroundDefaultArtTests` seeds a profile, plants an "old art" asset on the Starting background,
marks the layout as an older art vintage, relaunches and asserts the shipped art is re-stamped. 45
background tests pass.
2026-11-06 12:04:06 -08:00
gramps f7e1b76cdd art(starting): new Starting background; the old one is archived, not lost
Creator supplied a new 2724x1536 image; resampled to the 1920x1080 master and encoded at q3
(2.9 MB → 424 KB, so the shipped asset also got smaller).

- `Assets/starting-background.jpg` — the new art, same filename, so nothing else changes
- `Assets/archive/starting-background-pre-2026-11-06.jpg` — the previous image, kept in the repo. The
  csproj lists assets explicitly, so an unreferenced file never ships.
- **Existing layouts would have kept the old art forever**: default backgrounds are hash-keyed into the
  layout DB on first seed, and the seed returns early once an AssetId exists. `HealDefaultArtVersion`
  bumps `Art.DefaultVersion` to 2 and clears the AssetId of backgrounds still using the DEFAULT (a
  creator's custom background is skipped), so the next load re-stamps the new image.

Background tests: 44 passed.
2026-11-06 11:48:13 -08:00
gramps 0f08fd7ade fix(start): a second instance can no longer kill the app on Go Live
Reported 2026-11-06: a Release instance crashed when clicking Go Live in test mode, while a Debug
instance was recording its window. The Windows event log gave the cause:

    System.IO.IOException: All pipe instances are busy.
    at System.IO.Pipes.NamedPipeServerStream.Create(...)
    at ytLive.Services.Audio.NamedPipeAudioWriter.Start(...)
    at ytLive.Services.Audio.AudioMixer.StartLive(...)
    at MainViewModel.StartStreamingSession(...)
    at MainViewModel.BeginTestStream(...)
    at MainViewModel.StartFromPrimaryButton(...)
    at System.Windows.Controls.Button.OnClick()

Two defects, both fixed:

1. **A button click could end the process.** `StartLive` throws when the pipe name is taken, and nothing
   on the go-live path caught it. `TryStartAudioPipe` now catches, logs, toasts ("Another copy of
   LlamaCasty is already using the audio pipe") and rolls the session back. Recording-only takes the same
   path.
2. **The pipe name was a shared constant**, so a second instance could never go live on the same machine.
   `InstanceProfile.AudioPipeName` appends the dev instance id (DEBUG only); Release keeps the plain name
   so the normal single-instance case is unchanged. The encoder reads the same derived name, so mixer and
   ffmpeg cannot disagree.

Tests: `AudioPipeInstanceTests` — a second server on one name throws (the collision is real), dev names
derive per instance, and a writer on a taken name throws rather than silently reusing the pipe.
Debug and Release both build clean.
2026-11-06 08:41:16 -08:00
gramps 3261293d57 fix(ui): the gear and socials drawers retract on an outside click
Reported 2026-11-05: those two tabs stayed open when clicking elsewhere, unlike Stream Settings, YPP
and TEST. All five drawers share one host (TextPullOutHost), but the window's click-outside guard only
listed the other three, so the gear and socials panels had no way to dismiss except their own tab.

The guard now closes them too, and a test pins both directions: an outside click retracts the drawer,
and a click inside it does not.
2026-11-05 17:14:40 -08:00
gramps a1d861608f feat(ypp): the three eligibility bars, with dated thresholds (TASK 39·2)
YouTube moves the goalposts, so the app stops modelling them as its own truth and shows progress against
a threshold table that carries the date it was read.

- `Models/YppProgress.cs` — pure: three metrics, per-criterion met, and `MeetsPublishedRule`, which
  encodes the real shape of the rule (subscribers AND either view route). The drawer says "x of 3 criteria
  met — YouTube reviews eligibility" and spells out the either/or, so "2 of 3" can't be misread.
- `Services/YouTubeAnalyticsService.cs` — the two numbers only Analytics publishes: long-form watch hours
  (trailing 12 months, `creatorContentType==video`) and Shorts-feed views (90 days, `==shorts`). No rows
  means NO DATA, not zero.
- **Thresholds are data with a publication date** (`YppThresholds.AsOfUtc`, shown in the drawer) and a
  test fails once that reading is more than 180 days old. The 2027-02-01 doubling (4,000h → 8,000h,
  10M → 20M Shorts views) resolves from the date, not from an edit.
- **No Analytics scope → no invented numbers:** the drawer explains the missing consent instead of drawing
  two empty bars as zero. Money stays behind 3·20's "Show monetary metrics" toggle.
- Fan-funding tier (500 subs) demoted to a single muted line, since the three bars are the ad-revenue path.

Tests: `YppProgressTests` — either/or eligibility, subscribers alone insufficient, Shorts as a qualifying
route, bars clamp at 100%, the doubling date, the staleness guard, Analytics parsing (no rows = no data),
and compact value formatting. FAST gate: 567 passed, 0 failed, 0 warnings.
2026-11-05 16:41:09 -08:00
gramps 0fade9702b feat(ypp): session report derived from YouTube — no ledger, money off by default (TASK 3·20)
The task asked for a `RewardEvents` table plus a backfill. That would have contradicted the published
promise that no viewer, member or donor information is retained after the session ends, and it would
have meant owning a ledger that YouTube already keeps.

So the report is DERIVED on demand instead:

- `Services/SuperChatEventService.cs` — `superChatEvents.list`, parsed for both event types (a sticker
  must not be dropped), authorised by the existing `auth/youtube` scope, so no re-consent.
- `Models/SessionRewardSummary.cs` — rolls events up for the session window with **per-currency totals
  that are never summed together** (a $5 and a €5 are not $10). Nothing is written down.
- The YPP drawer gains a **THIS SESSION** section: counts are neutral and always shown when there were
  events; money appears only when the creator asks for it. `Show monetary metrics` is a drawer toggle,
  persisted, **off by default** — a creator who never monetized is never shown a $0.00.

Also: money is labelled as estimates (removed/refunded events are excluded and totals can be restated
for weeks, not days).

Tests: `SessionRewardReportTests` — both reward types parse, the window makes it a session rather than
a channel report, currencies stay separate, an empty session shows nothing, a sticker-only session
still counts. `BackOnAirTests` moves into the serialised `MainViewModelLayoutPath` collection: it builds
a MainViewModel directly and was flaking against parallel tests that swap the static layout path.

FAST gate: 557 passed, 0 failed, 0 warnings.
2026-11-05 15:38:32 -08:00
gramps 27f655c8de copy(price): the launch price ends on an event we control, not on Valve's queue
The promo read 'until April 1, 2027', which is a public promise tied to a review queue outside our
control: identity verification and the tax interview are in Valve's hands (they quote up to 30 business
days), and the store page and build reviews follow. If any of that slips, the date becomes untrue.

It now reads '30 days after the store page goes live — after that $99, locked for life', in the About
box and in commercial.md, so the claim cannot be wrong however long Valve takes.

Also recorded: the $100 Steam Direct fee is PAID (creator, 2026-11-05); a store page can be configured
once identity clears. The launch-price deadline no longer depends on Valve's calendar.
2026-11-05 15:13:20 -08:00
gramps 9611cee199 docs(steam): record the real onboarding state — a wait on Valve, not a task
The docs described Steamworks onboarding as pending work. It is pending VALVE: the partner account
exists, identity verification is under review, and the tax interview is in their queue with a quoted
turnaround of up to 30 business days. No app IDs are assigned, no store page exists, and Release
entitlement therefore still fails closed — by design.

Distribution.md §2 becomes a status table plus the critical path computed from Valve's own published
windows (30 business days verification → fee/app IDs → up to 12 business days store review → page
public ≥2 weeks before release → up to 12 business days build review). Earliest realistic launch:
early-to-mid February 2027, leaving ~7 weeks before the 2027-04-01 launch price ends.

Nothing in this blocks the code; commercial.md, TASKS 54 and HANDOFF now say so instead of implying
Steam is an outstanding coding task.
2026-11-05 15:08:53 -08:00
gramps dd51f7842a feat(stream): one-click Back on air — a new broadcast, honestly labelled (TASK 32·3)
The last resilience slice. When a drop ends the broadcast, YouTube cannot resume it, so the only way
back is a new one — and that is what the app now offers, in one click.

- `Services/BroadcastTitle.cs` — the resume marker is applied to the title the show was using, and any
  existing marker is REPLACED rather than stacked ("Show (resumed)" → "Show (resumed 2)"), with the base
  truncated so the whole title fits YouTube's 100-character limit. Otherwise a channel grows two
  identically named videos and the creator has to explain it.
- `MainViewModel.BackOnAirCommand` — reuses the ordinary go-live path with the saved details and the
  cached stream: new broadcast, new VOD, no dialog. It appears only after a drop ended the session and
  the ordinary start button steps aside, so there is exactly one way forward.
- The running line goes red OFF AIR (not amber) in that state; the button reads "● Back on air".

Tests: `BackOnAirTests` — first re-air marks the title, a second drop counts instead of stacking, the
100-character limit holds, an empty title still works, and the post-drop state exposes exactly one
start affordance. FAST gate: 552 passed, 0 failed, 0 warnings.

TASK 32 is now complete: fragmented MP4, blip retry, poll-driven RECONNECTING, ingest pre-flight, Back
on air.
2026-11-05 14:59:00 -08:00
gramps ec1091a170 feat(stream): probe the ingest before going live — no more ghost broadcasts (TASK 32·5)
"I forgot the VPN" used to create a broadcast on YouTube that nothing ever pushed to: the app looked
live, YouTube held an empty broadcast, and the creator had to work out what happened. Prevention beats
recovery here, so go-live now asks one question first.

- `Services/IngestProbe.cs` — `IIngestProbe` + `TcpIngestProbe`: parse the RTMP URL, connect to the host
  (rtmp → 1935, rtmps → 443) with a 2 s timeout. Any failure answers "not reachable"; the point is a
  fast unambiguous answer, not a diagnosis.
- `MainViewModel.IngestPreflightAsync` runs **before the first API call** whenever a cached ingest URL
  exists; on a first run it probes once the stream is resolved, still before any broadcast is created.
  A false answer logs, tells the creator ("Nothing was created on YouTube…"), and rolls the session
  back — nothing to clean up on YouTube.
- The probe is a test seam (`IngestProbeOverride`) like the camera and chroma ones.

Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, URL parsing (rtmp/rtmps,
garbage), and the probe against **real sockets**: a listening port answers true, the same port closed
answers false and does so fast. FAST gate: 547 passed, 0 failed, 0 warnings.
2026-11-05 14:56:20 -08:00
gramps d8c1bdba24 feat(stream): RECONNECTING driven by the status poll — no invented countdown (TASK 32·2)
Creator challenge (2026-11-05): "I really don't think that's going to be a constant — it's going to
depend on so many variables, some that are dark to us." Correct, so the measured-grace constant and
the countdown are dropped from the spec. YouTube's cutoff is server-side and depends on ingest health,
prior stream quality, region and load; a pinned number would be a guess wearing a timer, and it would
be wrong exactly when the creator is staring at it.

What ships instead:

- **Amber RECONNECTING** in the top bar with measured facts only: elapsed seconds and the attempt
  number ("0:12 · attempt 2 of 4"). The seconds come from a stopwatch started at the drop.
- **The status poll speeds up while recovering** (30 s → 4 s) and drives the sign; it restores 30 s
  when recovery ends.
- **`chatEndedEvent`** — already in the chat feed we poll and previously skipped — now raises a signal
  that prompts an immediate status check. It never decides anything: `liveBroadcasts.list`
  `lifeCycleStatus` stays authoritative, with `liveStreams.list` health as the push-received signal.
- At `complete`: red OFF AIR (the existing stop path) rather than a pretend-live sign.

Tests: `ChatEndedSignalTests` (the marker is not a chat row, the service raises the signal, and a
complete broadcast ends recovery with the honest message). FAST gate: 543 passed, 0 warnings.
2026-11-05 14:53:06 -08:00
gramps f2c43909c8 fix(about): copyright carries the year — © 2026 Llama Chile Shop
Derived from the clock rather than a literal, so a build shipped next year does not display a stale
date. The About test asserts the rendered string, year included.
2026-11-05 14:44:46 -08:00
gramps 0de953ef66 feat(about): rebuild the About box as a conversion surface
Creator: the About box was "shitty" — an unreadable logo, a version number nobody asked for, copy that
insulted the creator, and buttons that looked like text. Rebuilt around one job: convert.

- **820×860 modal** (was 650×700) with a scrollable hub, and the logo at **520 px wide** instead of
  125 — the asset is 2816×1536, so it was being drawn as a thumbnail, not an icon problem.
- **Pre-Release pill** replaces "Version 1.0.0" (the assembly version stays in log lines). The pill is a
  button: it opens llamacasty.com until a Steam store page exists, so the link changes in one place.
- Tagline: "1-2-3, and go live to the world on YouTube!" The old "simple enough that even the most
  right-brained person can intuit it" is gone, and a test asserts it stays gone.
- The offer in three short blocks (free 30 fps · paid 60 fps · one-time, lifetime, no account /
  subscription / tracking), then the launch price and **one primary CTA**: "Unlock 60 fps — $79".
- Price: $79 until **April 1, 2027**, then $99 locked for life. The numbers live once, in
  `MainViewModel.Overlays`, and are recorded in `docs/ai/commercial.md`.
- **A paying customer is never sold to**: ownership collapses the price/CTA block and shows
  "Licence: owned via Steam — thank you."
- Secondary destinations (channel, Mastodon, coffee, licences) are one quiet row; footer is
  `© Llama Chile Shop`; the "Made by gramps" line is removed.
- `YtButtonSecondary` now carries a higher surface, a hairline border and a keyboard focus ring — they
  were the same tone as the panel behind them, which is why they read as plain text.

Test: `AboutHubTests.About_ShowsTheOfferToFreeUsers_AndNothingToSellToOwners` — tagline, pill, price,
CTA text, copyright, the removed lines stay removed, and the CTA block collapses when owned.
FAST gate: 540 passed, 0 failed, 0 warnings.
2026-11-05 13:00:57 -08:00
gramps 3fde7fcfe6 refactor(ui): alert rows are two lines of text — the heading line was redundant
Creator: system messages are limited to two lines; the label above the message ('STEAM ID NOT
PRESENT', 'STREAM') was repeating what the row already said and pushing the stack past its budget.

AttentionAlert loses Label; rows render Text only. The licence row is now exactly:
    Steam ID Limit: 30fps
    Click for More Info
(60fps with a close control once owned). Rows are identified by Id internally, so nothing in the UI
depends on the removed text. Tests updated to assert position/text rather than the heading.
2026-11-05 12:13:45 -08:00
gramps 72cb3ad55a feat(stream): retry a dropped push instead of ending the session (TASK 32·1)
A dead encoder used to run the whole stop path, so a one-second blip ended the broadcast. Now a dropped
push is relaunched against the same ingest while the broadcast is still alive.

- `Services/StreamRetryPolicy.cs` — immediate, +1 s, +2 s, +3 s. Minimum waits on purpose (creator
  ruling): YouTube's grace is short, so every second not pushing is grace spent.
- `Services/StreamRecovery.cs` — the loop, with the audio pipe recreated before every attempt (its
  server is single-instance and unusable after the old client dies, so a naive relaunch fails on a
  missing input). After a push comes up it asks the API whether the broadcast survived: "complete"
  ends recovery as `BroadcastComplete` instead of showing a live sign over a dead stream; an API
  hiccup counts as recovered rather than ending a healthy session.
- `FramePump.EncoderFailed` — a dead push is recoverable; a compositor fault still goes through
  `Failed` and ends the session, because retrying it would loop.
- `MainViewModel.Recovery.cs` — wires the steps, reports each episode into the left panel alert stack
  (one row per episode, updated in place, log range painted), and cancels on Stop.
- Alert stack polish: a rule and a gap now separate the stack from the scene layers (they read as one
  list before), and the licence indicator uses the creator's wording — `STEAM ID NOT PRESENT` /
  `Steam ID Limit: 30fps` / `Click for More Info`, becoming `STEAM ID PRESENT` / `60fps` with a close
  control once owned.

Tests: `StreamRecoveryTests` (schedule, recover on attempt 2 with the pipe restarted each time,
complete-broadcast honesty, unknown liveness, give-up, cancel, audio-restart fault),
`FramePumpTests` now pin that process death raises `EncoderFailed` and not `Failed`.
FAST gate: 538 passed (one documented pre-existing audio failure), 0 warnings.
2026-11-05 12:09:38 -08:00
gramps a80108dd96 feat(ui): left-panel alert stack — licence indicator pinned on top, events LIFO below
Creator design (2026-11-05). A stack of red rows at the top of the left panel, above both mode grids
so it shows while editing and while live, pushing the panel's content down.

- Row 1 is the **Steam licence indicator** and nothing displaces it. It reads as status, not a
  warning: "Entitlement 30 fps · Maximum 60 fps", both numbers derived from QualityOptions and
  HasProOutput. While higher tiers are unavailable it shows a **pin** (standing indicator, no close
  control); once ownership is verified the pin becomes a **close control** and the dismissal is
  persisted (`Steam.LicenceIndicatorDismissed`), permanent while ownership holds. Losing ownership
  restores it as pinned — a change of state, not a preference. Click → About.
- **Event rows stack beneath, newest first**, one row per episode (an episode advancing its state
  updates its own row rather than adding another). Each is dismissible. Click → the Logs tab opens
  with that event's sequence range painted red (`HighlightedLogFrom/To` + `LogInRangeConverter`; two
  numbers rather than a collection because the log list is virtualised and realised rows do not
  re-evaluate on membership changes).
- Replaces the old red panel under the quality picker, which is removed.
- The Steam diagnostic text loses "Contact the creator or support" for a neutral
  "Steam licensing is not configured in this build."

Tests: `AttentionAlertStackTests` — pinned first with the status text; owned → closable and the close
survives a reload but ownership loss restores it pinned; pinned cannot be dismissed; event rows LIFO
with in-place episode updates; clicking an event opens Logs and sets its range. AboutHub + Entitlement
tests updated for the removed panel and the new wording. FAST gate: 532 passed, 0 failed, 0 warnings.
2026-11-05 11:41:59 -08:00
gramps adf9883ae9 docs(tests): document FAST and PHYSICAL_TESTS in the gate header
Also corrects what the 17s actually is: the RealApp collection's one-time fixture (WPF App + XAML
resources + audio/WebView2 startup) charged to whichever UI test runs first — ZoomBoxInteractionTests
measures 1.1s beside other UI tests and 17.1s when it happens to be first. Nothing to trim in the
test; the lever would be App startup cost.

Full suite on the current tree: 575 passed, 0 failed, 1m47s, clean build.
2026-11-05 11:03:59 -08:00
gramps 77eebdb2ce chore(tests): FAST=1 gate (62s) alongside the full 2m19s run
Measured the suite with a TRX logger instead of guessing: 575 tests, 2m19s wall, 165s of test
time. The 20-minute runs were NOT the suite — they were a malformed XAML trigger throwing on window
construction, crashing the test host and hanging the retry. That is fixed (17d8353).

The cost is concentrated: 113 of 155 classes finish under 1s (17s total), while a dozen classes
wait on real timers — ZoomBoxInteractionTests alone is 17s for one test. FAST=1 excludes those
twelve (527 tests, 49s of test time, 62s wall including the clean build) for runs while the machine
is in use; the full set stays the milestone gate.
2026-11-05 10:55:32 -08:00
gramps 17d835353d fix(preview): repair the round-clip trigger placement — it broke XAML load
The previous commit inserted the round-media MultiDataTrigger INSIDE a <Setter> instead of into the
Style.Triggers collection, so every window that loads PreviewPane threw
"System.Windows.MultiDataTrigger is not of type System.Windows.SetterBase" at parse time. A Debug
build would have failed to open the main window at all.

Caught by `AlertBoxPreviewVisibilityTests` and `AboutHubTests` in the suite, which is the reason
those UI tests exist. Both green again, as is `MediaPreviewDisplayTests`.
2026-11-05 10:51:44 -08:00
gramps 3a56807ee3 fix(media): pace clip audio to a deadline too, and prove the keyed layer in the OUTPUT
Two follow-ups from the creator's lip-sync observation.

1. Audio still slept a fixed 50 ms per chunk — the same "sleep on top of the work" fault fixed for
   video an hour ago, left in place. Every chunk's read cost was added, so a clip's audio fell
   progressively behind its picture: small per chunk, unbounded over a long clip. It now uses the
   same deadline pacer, so audio and video advance on the same wall clock.

2. The preview pane is NOT the compositor. "Preview looks great" proves the decode and the preview
   bitmap; the recording and stream are blitted by SceneCompositor. `MediaCompositeOutputTests`
   renders a keyed video layer over a background through the real compositor and asserts the two
   things that matter: keyed (alpha 0) pixels let the background through, and the subject half
   covers it. So "green screen removed" is now proven in the output path, not just the pane.

Also: a round-clipped video layer had no preview trigger (only webcam+Round existed), so it would
render in the output and be invisible in the pane. Same unit-circle path now covers it.

Tests: 16 scoped (pacer, real-clip rate + keying, composite output, decode pacing, alert pacing)
green. FULL SUITE NOT RUN — the machine is in use: two runs under load aborted in the WPF test host
(HWND subclass crash) and one hit the 15-minute cap. Re-run scripts/verify.sh in a quiet window.
2026-11-05 10:40:25 -08:00
gramps 5577aaaf91 fix(media): play at the clip's real rate — the pipe was copying a frame per read
Reported as "the default video and the green screen video are playing at a hideously slow
rate… I thought the playback timing issues were resolved?!" They were not: two independent
faults, both in the shared clip pipe.

1. QUADRATIC COPY IN THE FRAME READER. `RawVideoFrameReader` re-concatenated the whole pending
   buffer on every `Feed`, and the pipe feeds it 64 KB at a time. A 1080p BGRA frame is 8 MB, so
   one frame cost ~127 copies of a growing buffer — roughly half a gigabyte of memcpy per frame.
   Measured on a 30 fps clip: 6 frames in 3 seconds (2 fps). The reader now fills one preallocated
   frame buffer and hands that array to the VideoFrame, so a frame is copied exactly once. Pipe
   reads are 256 KB. Same clip: 29 fps.

2. PACING SLEPT ON TOP OF THE WORK. A fixed 1/fps sleep after each frame adds the frame's own
   cost, so 20 ms of work on a 30 fps clip yields 1/(33+20) ≈ 19 fps — measured 20.9. Pacing is
   now deadline-based (`Helpers/FramePacer.cs`): wait only what is left of the frame's interval,
   resync when a frame overruns instead of bursting, never sleep longer than one interval.

The alert clip shares this pipe, so it gained both fixes.

Tests: `FramePacerTests` (subtract work, no drift, late → resync, zero duration, reset);
`ChromaKeyRealClipTests.RealClip_PlaysAtItsNativeRate` (throughput floor of 15 fps on the real
30 fps clip — the old code sat at 2); the two pacing assertions now check "at most one interval"
rather than an exact sleep, which is what deadline pacing means. Full suite: 574 passed,
0 failed, clean build.
2026-11-05 09:57:07 -08:00
gramps 69e595da25 test(chroma): commit the real green-screen clip as a development fixture
The creator's test_greenscreen_moving_subject.mp4 (1920x1080, 5s, subject moving over a
green field) is what exposed the detector's brightness ceiling and proved the key colour must
come from the measured screen.

Development-only by construction, not by convention: ytLive.csproj lists Assets as explicit
<Resource Include> entries with no Assets/** glob, so a file that is not on that list is never
compiled in, copied to output, or published. The test project links this one path into its own
Fixtures output, so the repo holds ONE copy (23 KB).
2026-11-05 09:42:36 -08:00
gramps 0f4a035376 fix(preview): draw video layers, unlink Play from Loop, geometry last
Three reports from the creator, plus two bugs the real clip exposed.

1. A video layer was INVISIBLE in the preview. The pane's per-layer Image is Collapsed by
   default and switched on by a whitelist of DataTriggers; MediaSource matched none, so the clip
   decoded into a bitmap that nothing displayed. Same defect the alert box hit on 2026-09-26 and
   the scoreboard after it — now recorded at the top of docs/ai/ui-surface.md with a test that
   asserts the bound Image is Visible (`MediaPreviewDisplayTests`).

2. Play is no longer tied to Loop: the button is always enabled (creator ruling — Loop must not
   toggle Play). Loop still restarts a finished clip, which is the behaviour that was broken.

3. Geometry is now the LAST section of the properties list for every draggable element, with
   opacity at the top; the web source's duplicate geometry block is gone (it now uses the shared
   one, which had been sitting above the media panel).

4. The physical mouse-drag test is deleted. It injected real cursor movement, hijacked whatever
   machine ran it, and failed whenever the desktop was busy. The arithmetic it covered now lives
   in `Helpers/DragReorderMath.cs` — pure, and covered by `DragReorderMathTests` (threshold,
   up/down moves, no-op and out-of-range). The two persistence tests were already synthetic.

5. Real-clip check (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080): the detector
   REJECTED it — a fully saturated green sits at value 1.0 and the detector's 0.98 brightness
   ceiling threw it away. White highlights are excluded by the saturation floor, which is the
   property that actually separates them, so the ceiling is gone. With it fixed, the clip is
   detected as green (high confidence), and keyed through our own pipe 97.8% of pixels go
   transparent. Measured: keying with the canonical #00B140 keys 0.0% of this clip — which is why
   the key colour comes from the measured screen.

Tests: 567 passed, 0 failed, clean build 0 warnings. The gate's scope check flags
`Assets/Videos/test_greenscreen_moving_subject.mp4` — the creator's working copy, deliberately not
committed; the fixture lives under ytLive.Tests/Fixtures.
2026-11-05 09:38:18 -08:00
gramps 242a13ca30 fix(media): a played-out clip can play again; play button beside the Video File label
Reported 2026-11-05: "when I click Loop, the video doesn't play in the preview pane". Two
causes, both real:

- `VideoClipPipe.StartAsync` bailed out once it had run once (`_run != null`), so a clip that
  reached EOF could never be started again — it held its last frame forever.
- Loop is only consulted at end-of-file by a LIVE decoder, so flipping it on a finished clip
  did nothing at all.

Now: the pipe reports running state and is restartable after a natural end; the manager marks a
session finished (keeping its last frame) and exposes `IsRunning`/`RestartAsync`; the layer shows
`IsMediaRunning`/`IsMediaFinished`; flipping Loop on a finished clip restarts it.

UI: `▶ Play` sits to the right of the **Video File** label and is enabled only while Loop is off
(creator ruling: with Loop on the clip already runs continuously, so there is nothing to start).
The panel says "Clip finished — holding the last frame." so a frozen picture explains itself.

Also: verify.sh no longer runs physical-input tests by default. That test moves the real cursor
(SetCursorPos + mouse_event); running it mid-gate hijacks the machine someone is using, which is
exactly how it felt. Opt in with PHYSICAL_TESTS=1 when the desktop is idle; the header documents
the scoped-run command for working hours.

Test: `MediaPlaybackRestartTests` (pipe restart, finished state, Play again, Loop-on-finished).
8 scoped tests green. FULL SUITE NOT RUN — the machine was in use; run scripts/verify.sh in a
quiet window before the next milestone.
2026-11-05 09:15:30 -08:00
gramps e37215d9c4 feat(record): fragment the MP4 so a crash cannot destroy the whole take
TASK 32·4. A plain MP4 keeps its index (moov) at the end, so a power cut or a killed
process left the entire recording unreadable even though every video byte was on disk.
The record block now carries
`-movflags +frag_keyframe+empty_moov+default_base_moof`: index first, a playable
fragment flushed at every keyframe, so an interrupted take plays to the last fragment.

Record block only — the FLV stream output has no index and never gets the flags.
Rename-on-stop is untouched.

Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` asserts the flags on the
record block and their absence from the streaming block.

Not yet proven on a real interrupted take (kill ffmpeg mid-recording, play the result);
the args test is by construction.

Docs: task-32 slice 4 ticked with that caveat, TASKS row, output.md record note, HANDOFF.
verify.sh: 550 passed (mouse-injection test excluded, desktop in use), 0 warnings.
2026-11-05 08:58:48 -08:00
gramps a814bcd502 fix(chat): stop painting what YouTube removed, and retire the local word filter
TASK 49 is retired (creator ruling): YouTube owns chat moderation. Its blocked-words filter and
hold-for-review run upstream of `liveChatMessages.list` — held messages are never returned
(https://stackoverflow.com/questions/74511524/) — so a second local list would duplicate a switch
the creator already owns, with a second set of false positives and a second support surface.

What we actually owed the platform, and had wrong:

- The parser added every item to the overlay. A deleted message arrives as a `tombstone` carrying
  no text at all ("it has since been deleted … only liveChatId, type and publishedAt are present" —
  https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages), so every
  deletion rendered as a BLANK chat row. `hasDisplayContent:false` messages and chat-mode events
  did the same.
- Deletion events (`messageDeletedEvent`/`messageRetractedEvent`/`userBannedEvent`) are documented
  but have not been delivered to clients for years (https://stackoverflow.com/questions/44346893/),
  so nothing is built on them; they are skipped if they ever arrive.
- Found while testing: Super Chat read `displayString`, but YouTube's field is `amountDisplayString`
  — every amount silently fell back to a recomposed string, which mangles non-dollar locales. The
  model's own comment said this was the reason the field exists.

Test: `ChatModerationParseTests` — tombstone, no-display-content, chat-mode events all produce no
row; a real Super Chat still parses its pre-rendered amount (constraint 4: reward fields are never
reinterpreted).

Docs: TASK 49 marked retired with the citations, the rule recorded in decisions.md, HANDOFF updated.
verify.sh: 550 passed, 0 failed, 0 warnings.
2026-11-05 08:39:46 -08:00
gramps 7bb1207fb2 feat(chroma): opt-in auto key, and alert clips key like any other clip
TASK 64 slice F — the two things left after the feature worked by hand.

- App Settings → **Video clips → Auto Green/Blue Screen** (off by default). When on, a
  high-confidence detected screen is keyed as soon as the clip is added; the panel says
  "Removed automatically" so an automatic change is never silent. Medium confidence still
  asks, low says nothing — a wrong automatic key eats the picture.
- **A clip is a clip:** the alert box's own replacement clip keys through the same settings
  block (one alert box per layout, so its settings are the ones the next alert uses), and
  choosing an alert clip runs the same detector. The Green/Blue Screen panel now appears for
  a video layer *or* an alert box with a custom clip (`Source.ShowsChromaKey`).
- Spill ships as the creator-facing **Fringe** switch; nothing further to tune behind it.

Test: `ChromaKeyApplyTests` gains the auto-apply case (no click, still explicit in the panel)
and an alert-clip case (its pipe asks ffmpeg for colorkey+despill with the blue screen type).

Docs: TASK 64 complete in TASKS.md, known limits recorded in the task file, HANDOFF rewritten.
verify.sh: 546 passed, 0 failed, 0 warnings.
2026-11-05 08:20:33 -08:00
gramps ea24405ba2 feat(chroma): offer to remove a green or blue screen, and key it in the decode
TASK 64 slices D and E: the feature is usable end to end.

- Add a video file → one frame is probed (new `IChromaProbe`/`FfmpegChromaProbe`, one ffmpeg
  pass at 160x90, off the UI thread) and the layer gets an offer: "Looks like a green screen —
  Remove it", with the measured colour as a swatch.
- Accepting it turns the offer into settings (Amount / Softness / Fringe) that reach ffmpeg as
  `colorkey` + `despill` + `format=bgra`, so the key is computed in the pipe and both consumers
  (the shared preview bitmap and the compositor's frame) carry the same alpha.
- Media sessions are now keyed by **path + look** (`MainViewModel.MediaSessionKey`), so two
  layers with the same settings still share one decoder, and changing the look releases the old
  session and starts the new one. Removal, load reconciliation, loop and audio settings all move
  to the same key.
- Settings persist on the `Source` row (six ALTER TABLE guards + SELECT/INSERT), so a keyed layer
  comes back keyed.
- Panel section is plain creator words: Green/Blue Screen, Amount, Softness, Fringe.

Test: `ChromaKeyApplyTests` — the pipe asks ffmpeg to key (and does not when off); a green-screen
clip is offered, accepting it starts a keyed session, the layer's resolver then reads that session
rather than the old one; the setting survives a save.

Docs: TASK 64 D/E ticked with slice F (auto-apply setting + alert clips) left open; frame-pipeline
records the decode-option design; HANDOFF rewritten. verify.sh: 544 passed, 0 failed, 0 warnings.
2026-11-05 08:15:32 -08:00
gramps 21db8a17b8 feat(chroma): detect a green or blue screen and build the key filter
TASK 64 units B and C — the two pure halves, no wiring yet.

- `Services/ChromaKeyDetector.cs`: samples the frame's outer ring (the screen is at the
  edges, the subject in the middle), reports Green/Blue/None plus the colour actually shot
  and a confidence. Confidence is coverage × hue tightness, which is what keeps a lawn or
  a football pitch from being offered as a keyable screen. Letterbox bars are excluded from
  the denominator rather than counted against the screen. Never decides anything: an offer
  is the creator's to accept.
- `Services/VideoFilterChain.cs`: options → `-vf` (scale → colorkey → despill → format=bgra)
  plus a `Signature` so two layers keyed identically can share one decoder. Numbers clamp and
  an unparseable colour falls back to the app default, so ffmpeg is never handed a filter it
  rejects at spawn.
- Keying is done by the bundled ffmpeg, not by us: verified against the shipped binary that
  `colorkey=…,format=bgra` emits straight-alpha BGRA (alpha 0 on keyed pixels), which is what
  the compositor already blends. No per-pixel loop lands in the 16.67 ms render budget.

Test: `ChromaKeyDetectorTests` — green, blue, letterboxed, lawn (must say None), mixed scene,
dark frame, plus the chain strings and signature sharing.

Docs: TASK 64 queued in TASKS.md with its slice table; the decisions.md chroma-key reversal
recorded as a reversal (it was buried for webcam background removal, not for supplied clips).
2026-11-05 08:05:18 -08:00
gramps 2670ac022b feat(media): play the clip's soundtrack and share one decode pipe
A video file was picture-only: the decode pipe passed `-an`, so a starting-soon
or BRB clip with music played silent everywhere. Adds sound to every clip and
collapses the two decoders into one.

- `Services/VideoClipPipe.cs`: the shared pipe behind `MediaVideoSource` (TASK 21)
  and `AlertClipDecoder` (TASK 47) — video (`rawvideo bgra`, scaled) + audio
  (`f32le -ac 2 -ar 48000`, 50 ms chunks, byte-carry across reads), both paced to
  real time, loop restarts both so picture and sound stay together. Alert
  behaviour is unchanged.
- `MediaVideoSourceManager` applies the layer's Level/Mute per session and raises
  `AudioAvailable`; `MainViewModel` feeds it to the new
  `AudioMixer.EnqueueDesktopMediaAudio`, which joins the DESKTOP/GAME ring so the
  desktop fader and the mic auto-ducker apply exactly as they do for game audio
  and TRAX (creator ruling 2026-11-05: audio joins the group).
- Per-layer `MediaMuted` + existing `MediaVolume` surfaced as Mute/Level in the
  properties panel; `MediaMuted` persisted (ALTER TABLE guard + SELECT/INSERT).
- Level/mute are per SESSION because one decoder is shared per file path — the
  last layer touched wins; one decoder per layer would play the file twice.

Test: `MediaSourceAudioTests` — the pipe emits 50 ms chunks at 48k stereo, the
manager scales by level and drops when muted, and a media layer's sound moves the
desktop meter. Alert/decode suites unchanged and green.

Docs: task-21 closed on item 10, the "silent soundtrack" facts corrected in
frame-pipeline/output/overlays, HANDOFF rewritten. verify.sh green (529 passed;
the two documented pre-existing failures only).
2026-11-05 07:54:06 -08:00
gramps 776f84fb88 docs(media): correct the audio claim — a video file's sound is silent, not loopback-carried 2026-11-05 07:22:15 -08:00
gramps db563c8644 docs(overlays): record the alert-clip/media decode overlap 2026-11-05 07:20:09 -08:00
gramps 77d7208bfa feat(media): add a video file layer that decodes into the scene
TASK 21's picker slice: the decoder, manager, pacing and loop mechanism were
already shipped but nothing ever acquired a session, so a media Source rendered
nothing. Adds "Video File" to the (+) menu (video-filtered OpenFileDialog),
one decode session per distinct path with a HasSession guard, release when the
last scene reference goes, and a post-load reconcile. Loop is a manager-level
per-path answer ("any live reference loops") applied before StartAsync and
updated live via SetLooping. Properties panel shows the file with Change… and
Loop. Volume stays unwired: media audio rides the desktop loopback, TRAX-style.

Reference: OBS's media source (file → decode → composite into the scene) is the
established answer for this feature; the lifecycle shape mirrors this repo's
WebView2/ScreenCapture registration rather than inventing a new one.

Test: ytLive.Tests/MediaSourceAcquisitionTests.cs — add starts one decoder and
frames reach the layer, loop flip reaches the decoder, remove stops it.

Docs: task-21 closed, TASKS row + Day-4 row + stale 3·17 row corrected,
frame-pipeline acquisition note, HANDOFF rewritten. verify.sh green.
2026-11-05 07:17:43 -08:00
gramps 785b5ff8f3 chore(assets): delete four unreferenced brand exports
The four llamacasty-* files were source exports, not assets: shipped art is
embedded via <Resource> in ytLive.csproj and read through pack: URIs, and the
Store tiles derive from Assets/llamacasty-logo.jpg. None of the four appears in
the csproj, any XAML Source/Icon, or a script, so they were dead weight that
kept resurfacing as untracked noise.

MyMistakes.md gains the recipe (csproj list + grep = 30-second verdict) so a
loose file in Assets/ is never re-litigated; HANDOFF drops the stale note.
2026-11-04 17:50:50 -08:00
gramps d195c29f79 fix(text): group controls and filter font families
Put size and color on one row, place emphasis labels before their toggles, and split the remaining font families into handwriting, symbols, and international groups while omitting legacy dingbat fonts. Text-layer properties follow the existing hierarchical layout and palette conventions.
2026-11-04 17:17:57 -08:00
gramps 5f7bcc2425 fix(text): show selected formatting and style controls
Use category-filtered font and selected-item dropdowns, separate horizontal and vertical alignment, and reuse the border-color palette templates for text color. Persist vertical alignment additively. Follow the established static text-source pattern: https://obsproject.com/kb/text-sources
2026-11-04 17:05:06 -08:00
gramps 5c4acf141b feat(text): composite shared text rasters in output
Raster static text on UI-thread changes and reuse the same transparent BGRA frame in preview and all compositor paths. Cache text frame identity in the frame-pump signature and invalidate static bakes on edits. Follow OBS text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 15:01:47 -08:00
gramps 58eb640c4e feat(text): add static text editor and preview
TextOverlay layers now have a multiline editor, Windows font and size controls, color/emphasis/alignment settings, and a visible draggable preview. Text sources follow OBS's static text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 14:28:47 -08:00
gramps 60c4a0032f feat(text): persist independent text layer settings
Add additive defaults and round-trip tests for per-layer text content and formatting. Model the static text source after OBS text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 14:22:32 -08:00
gramps 106896c408 docs: close Task 53 and advance queue 2026-11-04 13:49:39 -08:00
gramps 7216c95449 feat(entitlement): gate 60 fps output through Steam 2026-11-04 12:14:46 -08:00
gramps 1993e7cc8b docs(task-53): accept vertical live-player check 2026-11-04 11:37:10 -08:00
gramps ede6ecac8e docs(task-53): retire platform safe-zone requirement 2026-11-04 11:35:29 -08:00
gramps bde2a4cf4b docs(task-53): accept six output samples and advance queue 2026-11-04 11:32:44 -08:00
gramps 4b7e798757 feat(ui): show YouTube-confirmed live state in top bar
Normal ON-AIR stays red until YouTube reports lifeCycleStatus=live, then turns green; TEST stays gold and stop restores the idle color.

Status-color reference: https://www.carbondesignsystem.com/building-blocks/core/patterns/status-indicators
2026-11-04 11:19:40 -08:00
gramps f921c0174b docs: reconcile Day 2 release policy and output tasks 2026-11-04 11:18:16 -08:00
gramps 6310f50c27 perf(video): move tier scaling to ffmpeg
The frame pump now submits crop-sized BGRA frames; FFmpeg scales them to final tier dimensions. Bundled scaler benchmark: 300 608x1080 to 1080x1920 frames in 0.435s (scaler-only).

Reference: https://ffmpeg.org/ffmpeg-filters.html#scale-1
2026-11-04 10:52:35 -08:00
gramps 76e8c22815 fix(ui): own private broadcast close-out dialog 2026-11-04 10:11:39 -08:00
gramps a6aea5bf26 feat(ui): color and acknowledge pinned log errors
Add independent AppLog message types and explicit Class 1/2 severity. Wrap and color log rows, pin Class 1 above Class 2, and let acknowledgement return entries to the current-launch timeline without changing file format or rotation.

Ref: https://logtape.org/manual/levels
2026-11-02 18:17:18 -08:00
gramps 01a3fcaaf7 docs: replace system-message strip with Logs tab 2026-11-02 15:55:04 -08:00
gramps 264a90dae8 test(ui): align top bar mode guard with live decision 2026-11-02 11:43:57 -08:00
gramps c856d11fb5 test(ui): configure EDIT-off live start in pill guard 2026-11-02 11:38:17 -08:00
gramps c9bd7e6093 test(audio): match mic fader unity ceiling 2026-11-02 11:34:48 -08:00
gramps e6a7147dc3 test(ui): follow the relocated settings gear 2026-11-02 11:31:49 -08:00
gramps 0e1f948189 test(scoreboard): check shared dev-build gate 2026-11-02 11:28:11 -08:00
gramps caf91e6564 test(ui): read notification colors from app theme 2026-11-02 11:24:50 -08:00
gramps 82609a5046 test(ui): follow shared geometry panel gate 2026-11-02 11:20:54 -08:00
gramps 91e308e8e2 test(ui): inspect themed property hierarchy 2026-11-02 11:18:10 -08:00
gramps 221b81628a test(scoreboard): assert the themed pill switch 2026-11-02 11:14:15 -08:00
gramps 3985a413a7 test(ypp): keep upload window fixture current 2026-11-02 11:10:21 -08:00
gramps 9fa06c67c2 feat(ui): add current-launch logs tab
Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging.

Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473
2026-11-02 11:06:43 -08:00
gramps 20f059422b feat(alerts): add scene-local recent ticker
Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only.

Ref: https://obsproject.com/kb/obs-studio-overview
2026-11-02 10:47:48 -08:00
gramps 6208cb9409 feat(ui): route locked tiers through About
Keep non-owner resolution choices visible but muted. Selecting one preserves the free tier and opens About; external navigation is only the explicit llamacasty.com link. Ref: https://streamlabs.com/desktop
2026-11-02 09:41:38 -08:00
gramps ff5e8d94ae feat(entitlement): pivot to paid app ownership
Check Steam ownership of the paid app with BIsSubscribedApp; keep Debug fake ownership out of Release. Ref: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
2026-11-02 09:18:03 -08:00
gramps 54902cb838 ui(properties): no raw typography left in the panel, and one heading rank
The earlier pass checked wiring only; this is the typography sweep. Raw Foreground/FontSize
labels are gone from the elements panel:

- Opacity, Timer (minutes) and URI are headings above their control (PropertyHeader), with the
  control row on the panel's 10px indent — the same shape as Border Thickness/Opacity.
- Scroll speed, scoreboard Opacity/Text size and their value readouts are PropertyFieldLabel.
- The drag hint and the scoreboard sign-in note are PropertyHint.
- Simulate (dev) keeps its orange, gains the field-label typography.
- Edge, Text colour and Background were PropertySubHeader/SubHeader: ONE heading rank now, so
  PropertySubHeader and the legacy SubHeader are deleted from Themes/Controls.xaml.
- DeveloperPanel — the last panel with raw typography and an unstyled CheckBox — now uses the
  same styles, and PropertiesStyleTests accepts PropertyPillCheck (the guard went stale when
  every check box became a pill switch). 18 tests pass; Release 0 warnings.
2026-11-01 16:26:20 -08:00
gramps 790cff45c2 ui(alerts): Video, Sound byte and Ticker are headings, not sub-headings
Same PropertyHeader style as Geometry and Stream Alerts — one heading rank in the properties
panel, not two.
2026-11-01 16:19:07 -08:00
gramps b3d7cf30b2 ui(properties): sub-headings sit flush left, level with the category header
Their rank is carried by size and colour, not by an indent — so Video, Sound byte and Ticker all
line up on the left edge exactly like each other, and only the controls under them tab in.
2026-11-01 16:17:38 -08:00
gramps 31b655cecb ui(alerts): a Ticker sub-heading, with Message ticker / Display / Volume tabbed under it 2026-11-01 16:15:41 -08:00
gramps 0fdd9d5faf fix(brand): one logo, not two — and hit-testing follows what you see
The brand exemption for the universal blur put a BrandLogoOverlay above the blur host while the
canvas ItemsControl kept drawing the same element, so two logos sat on top of each other.

- The canvas presenter for a brand logo is collapsed: BrandLogoOverlay is the only render, and it
  now carries the element's Opacity so the canvas copy is not missed.
- Hit-testing follows the eye: a click inside the logo's bounds selects the logo, because it is the
  topmost layer in the compositor too. Without that you saw the logo on top and grabbed the webcam
  underneath it.
- SelectedLayerPreviewHitTestingTests updated to the new contract (collapsed presenter, logo wins
  its bounds, list selection still works). 3 tests pass; Release 0 warnings.
2026-11-01 16:12:38 -08:00
gramps 41b3933b08 fix(background): refresh BOTH preview layers, not just the thumbnails
The preview draws two background images — BackgroundImage and, above it, ActiveBackgroundImage
(the static art) — while the mini-pane reads the scene snapshot. The default-restore path only
called UpdateBackgroundImage, so the thumbnails updated and the preview did not.

It now calls UpdateActiveBackground as well.

Also adds the generic cure behind the Background bug: OnSelectedElementPropertyChanged invalidates
the scene bake for ANY property change. A static layer's pixels are cached by element identity, so
a property edit without invalidation repaints nothing and the control looks dead — the failure was
never specific to UseDefaultBackground, only first noticed there.

Background + thumbnail tests pass (21); Release 0 warnings.
2026-11-01 16:01:41 -08:00
gramps d6088f8e35 fix(background): Use default background actually restores the shipped art
Browse… writes the chosen picture into BOTH CustomBackgroundPath and AssetId, and the default
stamp bails when AssetId is already set. So flipping "Use default background" back on changed a
bool while the preview kept showing the custom image — it looked like the switch did nothing.

Turning the default on now clears the custom pointer and the asset, re-runs
StampDefaultBackgroundAsset for the scene's shipped art, and only then invalidates the bake,
updates the preview and refreshes thumbnails.

BackgroundTests pass (17); Release 0 warnings.
2026-11-01 15:59:10 -08:00
gramps c297b3fb0f ui(properties): answer the visual review — quiet actions, real hint tone, dividers, legible disabled
Review findings from a screenshot of the Stream Alerts panel, each addressed:

- "Reset to built-in" and "Browse…" were YtButton-derived (white, semi-bold, 13px), so a reset
  action outranked the section header. Property-panel actions now use PropertyButton: bordered,
  11px, normal weight, OnSurface text.
- Explanation text and field labels were the same muted blue-gray. PropertyHint is now
  OnSurfaceDisabled, one clear step below a label.
- Groups inside a category ran together. PropertyDivider (1px OutlineVariant) separates Video,
  Sound byte and Message ticker.
- The disabled loop switch was near-invisible. Disabled now keeps the label at OnSurfaceVariant
  and dims the whole control to 0.55 — readable, obviously inactive.
- Switch "on" was full-strength accent, shouting about static state. The track now sits at 0.6
  opacity when on, which also stops it competing with the slider's accent fill.

No new palette entries: every change uses an existing token, so DESIGN.md stays the one colour
source. Release 0 warnings; token and typography tests pass.
2026-11-01 15:53:40 -08:00
gramps 8221bd6478 ui(properties): one typography hierarchy, pill switches, and tokens instead of hex
Creator directive 2026-11-01: the properties panels had 14 hand-rolled heading styles, 37
hand-rolled labels, four literal grey hex values, and 14 square checkboxes.

- PropertyHeader / PropertySubHeader / PropertyFieldLabel / PropertyHint in Themes/Controls.xaml
  now carry the whole hierarchy: category bright and 11px, labels and hints dim, size and weight
  doing the level. Colour groups the panel — bright for what you edit, dim for its labels and
  help — and no new palette entry was needed; DESIGN.md stays the only colour source.
- Every checkbox in the panel is a pill switch (PropertyPillCheck, 32x16 animated thumb).
  IsChecked semantics unchanged, so no binding moved.
- Literal #888/#666/#555 became OnSurfaceVariant / OnSurfaceDisabled.
- 13 headings, 2 sub-headers and 31 in-row labels now reference a style instead of repeating
  colour + size + alignment inline.

Controls audited for backing: every Button/Slider/ComboBox/CheckBox/ToggleButton/TextBox in the
panel has a Command or Click, a Value binding, an ItemsSource or Selected*, IsChecked, or Text —
no dead controls. Release 0 warnings; typography and token tests pass.
2026-11-01 15:51:33 -08:00
260 changed files with 17274 additions and 1906 deletions
+3 -2
View File
@@ -141,8 +141,9 @@ line points at the file that explains it.
→ `docs/ai/ui-surface.md`
- **Licensing: no non-CC0 icon art, no dropped `THIRD-PARTY-NOTICES.txt`, no
licence-incompatible dependency, no unvetted ML model.** → `docs/ai/product.md`
- **The private-only broadcast lock is deliberate** — do not unlock it, do not raise it as a
question; it ends at v1 by the creator's ruling. → `docs/ai/product.md`
- **Force Private only for development/test runs.** The released creator flow offers Private,
Unlisted, or Public, defaults to Private, and allows live visibility changes through
`UpdateBroadcast`. → `docs/ai/youtube-api.md`
- **Read render numbers in Release only** — Debug pixel paths are several× slower.
→ `docs/ai/environment.md`
+7 -2
View File
@@ -19,12 +19,17 @@ public partial class App : Application
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
AppLog.Write(args.ExceptionObject is Exception ex
? "AppDomain unhandled exception: " + ex
: "AppDomain unhandled exception object: " + args.ExceptionObject);
: "AppDomain unhandled exception object: " + args.ExceptionObject,
AppLogMessageType.Error, AppLogErrorClass.Class1);
DispatcherUnhandledException += (_, args) =>
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
AppLog.Write("App OnStartup begin");
// TASK 55·2: a crash leaves the same temp clip a clean exit would have deleted.
var sweptClips = ytLive.Services.AlertClipCleanup.Sweep(System.IO.Path.GetTempPath());
if (sweptClips > 0) AppLog.Write($"Temp alert clips removed at startup: {sweptClips}");
// 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.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

+25
View File
@@ -213,6 +213,31 @@
<Button Content="+100" Width="34" Height="16" FontSize="9" Padding="0"
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="100"/>
</StackPanel>
<!-- Microphone-only delay: the voice waits for the camera's picture. The
whole-mix slider above would drag desktop and alert audio with it. -->
<Grid Margin="0,5,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="MIC" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="10" VerticalAlignment="Center"
FontFamily="{StaticResource FontFamily.Mono}" TextAlignment="Right"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding MicSyncOffsetLabel}"/>
</Grid>
<Slider Minimum="0" Maximum="500" SmallChange="5" LargeChange="25"
TickFrequency="5" IsSnapToTickEnabled="True"
Value="{Binding MicSyncOffsetMs, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0" HorizontalAlignment="Right">
<Button Content="off" Width="26" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="0"/>
<Button Content="175" Width="30" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="175"/>
<Button Content="350" Width="30" Height="16" FontSize="9" Padding="0"
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="350"/>
</StackPanel>
</StackPanel>
</Grid>
</Border>
+7
View File
@@ -56,6 +56,13 @@ public partial class AudioDock : UserControl
vm.SetSyncOffset(ms);
}
private void MicSyncPreset_Click(object sender, RoutedEventArgs e)
{
if (DataContext is not ViewModels.MainViewModel vm) return;
if (sender is Button { Tag: string tag } && int.TryParse(tag, out var ms))
vm.MicSyncOffsetMs = ms;
}
private void TraxPlayPause_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
+5 -5
View File
@@ -7,15 +7,15 @@
to a window or reference it from code that compiles in Release; the only host is
OverlayHost.xaml.cs inside #if DEBUG. -->
<StackPanel>
<TextBlock Text="Developer" Foreground="{StaticResource Brush.Attention}" FontSize="11"
FontWeight="SemiBold" Margin="0,10,0,2"/>
<TextBlock Text="Developer" Style="{StaticResource PropertyHeader}"
Foreground="{StaticResource Brush.Attention}"/>
<CheckBox Margin="10,2,0,0" IsChecked="{Binding DeveloperMode, Mode=TwoWay}"
Foreground="{StaticResource Brush.OnSurface}"
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,2,0,0"
IsChecked="{Binding DeveloperMode, Mode=TwoWay}"
Content="Developer mode — all resolutions, output budget ignored"/>
<TextBlock Text="Debug builds only. Overrides the free output profile (921,600 px / 30 fps). Never present in a release build."
Foreground="{StaticResource Brush.Attention}" FontSize="10" TextWrapping="Wrap"
Style="{StaticResource PropertyHint}" Foreground="{StaticResource Brush.Attention}"
Margin="10,4,0,8"/>
</StackPanel>
</UserControl>
+576 -229
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -3,6 +3,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
@@ -178,8 +179,9 @@ public partial class LeftPanel : UserControl
var position = e.GetPosition(listBox);
if (!_isDragging)
{
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
if (!DragReorderMath.ExceededThreshold(
position.X - _dragStartPoint.X, position.Y - _dragStartPoint.Y,
SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance))
return;
_isDragging = true;
listBox.CaptureMouse();
@@ -193,12 +195,10 @@ public partial class LeftPanel : UserControl
return;
var targetIndex = listBox.Items.IndexOf(targetItem);
if (targetIndex < 0) return;
var landed = DragReorderMath.TryMove(items, _dragIndex, targetIndex);
if (landed < 0) return;
var dragged = items[_dragIndex];
items.RemoveAt(_dragIndex);
items.Insert(targetIndex, dragged);
_dragIndex = targetIndex;
_dragIndex = landed;
_lastHoveredItem = targetItem;
_dragReordered = true;
}
+85 -57
View File
@@ -15,7 +15,7 @@
</Button.Template>
</Button>
<Border Background="{StaticResource Brush.Background}" CornerRadius="8" Width="650" MaxHeight="700"
<Border Background="{StaticResource Brush.Background}" CornerRadius="8" Width="820" MaxHeight="860"
VerticalAlignment="Center" HorizontalAlignment="Center"
Padding="28" BorderBrush="#333" BorderThickness="1">
<Grid>
@@ -36,7 +36,7 @@
</Grid>
<!-- Report Bug -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
<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"
@@ -76,44 +76,91 @@
Background="{StaticResource Brush.SurfaceContainerLow}"
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
<!-- Hub view -->
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<Image Source="/LlamaCasty;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="{Binding AppVersionLabel}" Foreground="{StaticResource Brush.OnSurfaceVariant}" 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="{StaticResource Brush.OnSurfaceVariant}" 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="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
HorizontalAlignment="Center" Margin="0,16,0,8"/>
<!-- Hub view — a conversion surface, not a credits page (creator, 2026-11-05):
large logo, one tagline, the offer in three short blocks, one primary CTA,
everything else demoted. A paying customer never sees the price. -->
<ScrollViewer VerticalScrollBarVisibility="Auto"
Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<StackPanel>
<Image Source="/LlamaCasty;component/Assets/llamacasty-logo.jpg"
Width="520" Stretch="Uniform" HorizontalAlignment="Center" Margin="0,0,0,10"/>
<!-- 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 x:Name="ReleaseBadgeButton" Content="{Binding AppReleaseLabel}"
Style="{StaticResource PillBadgeButton}"
Command="{Binding OpenPremiumCommand}"
HorizontalAlignment="Center" Margin="0,0,0,10"
ToolTip="Pre-release — opens llamacasty.com when clicked"/>
<Button Content="Licenses &amp; legal" Command="{Binding OpenLicensesCommand}"
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
Margin="0,16,0,0"/>
</StackPanel>
<TextBlock x:Name="AboutTagline"
Text="1-2-3, and go live to the world on YouTube!"
Foreground="{StaticResource Brush.OnSurface}" FontSize="17" FontWeight="SemiBold"
TextWrapping="Wrap" MaxWidth="640" TextAlignment="Center"
HorizontalAlignment="Center" Margin="0,4,0,18"/>
<Grid MaxWidth="640" HorizontalAlignment="Center" Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Free" Margin="0,0,14,4"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding FreeOfferText}" Margin="0,0,0,4"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Paid" Margin="0,0,14,4"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding PaidOfferText}" Margin="0,0,0,4"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap"/>
<TextBlock Grid.Row="2" Grid.ColumnSpan="2" Text="{Binding TrustLine}"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
TextWrapping="Wrap" Margin="0,6,0,0"/>
</Grid>
<!-- Free: the offer. Owned: thanks, no price. -->
<StackPanel x:Name="UpgradeBlock"
Visibility="{Binding IsAppOwned, Converter={StaticResource InverseBoolToVis}}"
HorizontalAlignment="Center" Margin="0,18,0,0">
<TextBlock x:Name="AboutPriceLine" Text="{Binding PriceLine}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="16" FontWeight="Bold"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding PriceNote}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
TextAlignment="Center" TextWrapping="Wrap" MaxWidth="520" Margin="0,2,0,10"/>
<Button x:Name="UpgradeCtaButton" Content="{Binding UpgradeCtaLabel}"
Command="{Binding OpenPremiumCommand}"
Style="{StaticResource YtButton}"
HorizontalAlignment="Center" Padding="22,10" FontSize="14"
ToolTip="Opens llamacasty.com in your browser when clicked"/>
</StackPanel>
<TextBlock x:Name="OwnedStateText" Text="{Binding LicenceStateLabel}"
Foreground="{StaticResource Brush.Positive}" FontSize="13" FontWeight="SemiBold"
HorizontalAlignment="Center" Margin="0,18,0,0"
Visibility="{Binding IsAppOwned, Converter={StaticResource BoolToVis}}"/>
<!-- Secondary destinations, one row, deliberately quieter than the CTA. -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,22,0,0">
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
<Button Content="Licenses &amp; legal" Command="{Binding OpenLicensesCommand}"
Style="{StaticResource YtButtonSecondary}" FontSize="11" Padding="10,5"/>
</StackPanel>
<TextBlock x:Name="AboutCopyright" Text="{Binding CopyrightLabel}"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
HorizontalAlignment="Center" Margin="0,18,0,4"/>
</StackPanel>
</ScrollViewer>
<!-- Licensing/legal panel -->
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
@@ -131,25 +178,6 @@
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="{StaticResource Brush.OnSurface}" Margin="0,0,0,8"/>
<TextBlock Text="Enter your license key to remove the branding watermark."
Foreground="{StaticResource Brush.OnSurfaceVariant}" 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="{StaticResource Brush.OnSurfaceVariant}" 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>
+90 -15
View File
@@ -90,6 +90,15 @@
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
<!-- ⛔ The brand logo renders ONCE, in BrandLogoOverlay above
the blur host, so it can never be blurred. Drawing it here
as well stacked two copies on top of each other
(creator report, 2026-11-01). -->
<Style.Triggers>
<DataTrigger Binding="{Binding IsBrandLogo}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
@@ -143,15 +152,32 @@
<DataTrigger Binding="{Binding IsAlertBox}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsTicker}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<!-- The scoreboard strip is a frame-source too: without this trigger its
Image stays collapsed and every property change looks inert. -->
<DataTrigger Binding="{Binding IsScoreboard}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsTextOverlay}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsWebSource}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
<Setter Property="Stretch" Value="Fill"/>
</DataTrigger>
<!-- A video file layer is a frame source too. Without this trigger its Image stayed
Collapsed and the clip was invisible in the preview while the decode ran — the
same defect the alert box hit on 2026-09-26. -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsMediaSource}" Value="True"/>
<Condition Binding="{Binding IsRectShape}" Value="True"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsWebcam}" Value="True"/>
@@ -183,6 +209,15 @@
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
<!-- A round-clipped video layer uses the same unit-circle path, or it would
render in the output and be invisible in the preview (2026-11-05). -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsMediaSource}" Value="True"/>
<Condition Binding="{Binding IsRoundShape}" Value="True"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
@@ -476,6 +511,7 @@
<Image Canvas.Left="{Binding StagedBrandLogo.X}" Canvas.Top="{Binding StagedBrandLogo.Y}"
Width="{Binding StagedBrandLogo.Width}" Height="{Binding StagedBrandLogo.Height}"
Source="{Binding StagedBrandLogo.DisplaySource}" Stretch="Fill"
Opacity="{Binding StagedBrandLogo.Opacity}"
IsHitTestVisible="False"
Visibility="{Binding StagedBrandLogo, Converter={StaticResource NotNullToVis}}"/>
</Canvas>
@@ -732,27 +768,66 @@
<StackPanel Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="{Binding Ypp.DisplayName}" Foreground="{StaticResource Brush.OnSurface}" 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="{StaticResource Brush.OnSurface}" FontSize="13"/>
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,4,0,10">
<!-- TASK 39·2 — the three metrics that count, with the threshold
that applies TODAY (the long-form bar doubles 2027-02-01; that is
data, not a code change). YouTube decides eligibility; these are
estimates and the drawer says so. No red/green scoring. -->
<TextBlock Text="SUBSCRIBERS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,2">
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.SubscribersText}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
FontFamily="{StaticResource FontFamily.Mono}"/>
<TextBlock Text="Ad revenue tier" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
</DockPanel>
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,10">
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.Tier1SubsBarWidth}"/>
Width="{Binding Ypp.SubscribersBarWidth}"/>
</Border>
<TextBlock Text="SUBSCRIBERS — TIER 2 · AD REVENUE" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.Tier2SubsText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,4,0,10">
<TextBlock Text="LONG-FORM WATCH HOURS · 12 MONTHS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,2">
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.WatchHoursText}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
FontFamily="{StaticResource FontFamily.Mono}"/>
<TextBlock Text="Public videos" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
</DockPanel>
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,4">
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.Tier2SubsBarWidth}"/>
Width="{Binding Ypp.WatchHoursBarWidth}"/>
</Border>
<TextBlock Text="PUBLIC UPLOADS (90 DAYS)" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.UploadsText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,4,0,6">
<TextBlock Text="Bar rises to 8,000 h on 1 Feb 2027."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,10"/>
<TextBlock Text="SHORTS VIEWS · 90 DAYS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,2">
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.ShortsViewsText}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
FontFamily="{StaticResource FontFamily.Mono}"/>
<TextBlock Text="Shorts feed" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
</DockPanel>
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,8">
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.UploadsBarWidth}"/>
Width="{Binding Ypp.ShortsViewsBarWidth}"/>
</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="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Ypp.CriteriaMetText}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12" FontWeight="SemiBold"
TextWrapping="Wrap" Margin="0,0,0,2"/>
<TextBlock Text="Either view route qualifies — subscribers plus watch hours, or subscribers plus Shorts views."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,4"/>
<TextBlock Text="{Binding Ypp.ThresholdsAsOfText}"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<TextBlock Text="{Binding Ypp.WatchMetricsMissingText}"
Foreground="{StaticResource Brush.Attention}" FontSize="11" TextWrapping="Wrap"
Margin="0,0,0,8"
Visibility="{Binding Ypp.HasWatchMetrics, Converter={StaticResource InverseBoolToVis}}"/>
<TextBlock Text="FAN FUNDING TIER · 500 SUBSCRIBERS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
FontFamily="{StaticResource FontFamily.Mono}" Margin="0,0,0,10"/>
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/>
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
+7
View File
@@ -677,6 +677,13 @@ public partial class PreviewPane : UserControl
if (vm.SelectedElement is { } selected && IsHitTestable(selected) && Contains(selected, p))
return selected;
// The brand logo is drawn ABOVE everything (the compositor blits it after the blur group,
// and the preview renders it in BrandLogoOverlay), so a click inside it must select it —
// otherwise you see the logo on top and grab the webcam underneath it.
var brand = vm.StagedBrandLogo;
if (brand is { IsVisible: true } && Contains(brand, p))
return brand;
for (var i = scene.Elements.Count - 1; i >= 0; i--)
{
var element = scene.Elements[i];
+65 -1
View File
@@ -2,7 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:ytLive.Models"
xmlns:services="clr-namespace:ytLive.Services">
xmlns:services="clr-namespace:ytLive.Services"
xmlns:notification="clr-namespace:Notification.Core;assembly=Notification.Core">
<!-- App Settings, as a RIGHT-EDGE PULL-OUT (creator ruling 2026-11-01): the gear tab opens
this drawer immediately, in the same animated-width pattern as Stream Settings / YPP /
Socials / TEST. It used to be a centred modal overlay, which then needed its own scroll
@@ -25,6 +26,24 @@
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
</DockPanel>
<TextBlock Text="Slide-out alerts" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<TextBlock Text="Position" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
<ComboBox Style="{StaticResource YtComboBox}" Margin="10,0,0,8"
SelectedValue="{Binding ToastPosition, Mode=TwoWay}" SelectedValuePath="Tag"
ToolTip="Choose where notifications appear inside the app window.">
<ComboBoxItem Content="Top left" Tag="{x:Static notification:NotificationPosition.TopLeft}"/>
<ComboBoxItem Content="Top right" Tag="{x:Static notification:NotificationPosition.TopRight}"/>
<ComboBoxItem Content="Center left" Tag="{x:Static notification:NotificationPosition.CenterLeft}"/>
<ComboBoxItem Content="Center right" Tag="{x:Static notification:NotificationPosition.CenterRight}"/>
<ComboBoxItem Content="Bottom left" Tag="{x:Static notification:NotificationPosition.BottomLeft}"/>
<ComboBoxItem Content="Bottom right" Tag="{x:Static notification:NotificationPosition.BottomRight}"/>
</ComboBox>
<CheckBox Content="Keep alerts until dismissed" Style="{StaticResource PropertyPillCheck}"
Margin="12,2,0,2" IsChecked="{Binding StickyAlertsUntilDismissed, Mode=TwoWay}"
ToolTip="When on, every slide-out alert stays until you click it or close it."/>
<TextBlock Text="Off: informational alerts use their normal timeouts; errors still stay until closed."
Style="{StaticResource PropertyHint}" Margin="10,0,0,8"/>
<!-- ═══ BRAND ═══ -->
<TextBlock Text="Brand logo" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<TextBlock Text="Image file (shown on every scene)" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
@@ -63,6 +82,21 @@
</Popup>
<!-- ═══ ZOOM ═══ -->
<TextBlock Text="Clips" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
<CheckBox Content="Save replay clips" Style="{StaticResource PropertyPillCheck}" Margin="12,2,0,4"
IsChecked="{Binding ClipsEnabled, Mode=TwoWay}"
ToolTip="F10 saves the last few seconds of the show. Off by default; nothing runs while it is off."/>
<StackPanel Margin="12,0,0,4" IsEnabled="{Binding ClipsEnabled}">
<TextBlock Text="Clip length" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
<ComboBox ItemsSource="{Binding ClipLengthOptions}" SelectedItem="{Binding ClipLengthText, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="28" Margin="0,0,0,6"/>
<TextBlock Text="Keep" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
<ComboBox ItemsSource="{Binding ClipKeepOptions}" SelectedItem="{Binding ClipKeepText, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="28"/>
<TextBlock Text="A capture, not an editor: for trimming, captions or a library, use a video editor."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="10" TextWrapping="Wrap" Margin="0,6,0,0"/>
</StackPanel>
<TextBlock Text="Zoom" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<TextBlock Text="Zoom view shape" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
@@ -79,6 +113,18 @@
<TextBlock Text="Zoom box follows" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" VerticalAlignment="Center"/>
</DockPanel>
<!-- ═══ VIDEO CLIPS (TASK 64) ═══ -->
<TextBlock Text="Video clips" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<DockPanel Margin="10,0,0,10">
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource PillToggle}"
IsChecked="{Binding AutoChromaScreen, Mode=TwoWay}" VerticalAlignment="Center"
ToolTip="On: a clip shot against a clear green or blue screen is keyed as soon as it is added. Off: the app asks first."/>
<StackPanel>
<TextBlock Text="Auto Green/Blue Screen" Foreground="{StaticResource Brush.OnSurface}" FontSize="11"/>
<TextBlock Text="Removes a detected screen without asking." Style="{StaticResource PropertyHint}"/>
</StackPanel>
</DockPanel>
<!-- ═══ SCENE TRANSITIONS ═══ -->
<TextBlock Text="Scene transition" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<TextBlock Text="Type" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
@@ -96,6 +142,24 @@
Value="{Binding TransitionDurationMs, Mode=TwoWay}" VerticalAlignment="Center"/>
</DockPanel>
<!-- ═══ LAYOUT DEFAULTS (TASK 37) ═══ -->
<TextBlock Text="Editing" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
<CheckBox IsChecked="{Binding LockEditWhileLive, Mode=TwoWay}" Margin="10,0,0,2"
Style="{StaticResource PropertyPillCheck}"
Content="Lock editing while recording or live"/>
<TextBlock Text="Off: layers can be moved or resized during a show. On: going live turns EDIT off and stopping turns it back on."
Style="{StaticResource PropertyHint}" Margin="28,0,0,10"/>
<TextBlock Text="Layout defaults" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
<TextBlock Text="Replaces every scene and layer with this build’s factory layout. Your selected webcam stays the same."
Style="{StaticResource PropertyHint}" Margin="10,0,0,6"/>
<Button Content="Restore staged scene" Command="{Binding RestoreStagedSceneDefaultsCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="10,0,0,4" HorizontalAlignment="Left"/>
<TextBlock Text="Resets only the scene currently staged in the preview."
Style="{StaticResource PropertyHint}" Margin="10,0,0,8"/>
<Button Content="Restore all scenes" Command="{Binding RestoreAllLayoutDefaultsCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="10,0,0,10" HorizontalAlignment="Left"/>
<!-- Development panel host. Empty in a Release build: the panel itself is excluded
from the Release compile, and the code-behind only fills this inside #if DEBUG. -->
<StackPanel x:Name="DeveloperPanelHost"/>
+174 -49
View File
@@ -53,26 +53,6 @@
</TextBlock>
</Button>
<!-- EDIT: explicit toggle. Neutral = off (chat), blue = on (layers/properties). The
trigger used to set the SAME colour as the resting state, so the button never
looked pressed. Right edge aligns with the layers panel below. -->
<ToggleButton Grid.Column="1" IsChecked="{Binding EditingActive, Mode=TwoWay}" Content="EDIT"
Cursor="Hand" Height="32" Padding="14,0" Margin="18,0,0,2"
HorizontalAlignment="Right" VerticalAlignment="Center"
FontWeight="Bold" FontSize="12" Foreground="{StaticResource Brush.OnAccent}" BorderThickness="0">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="EditBody" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="EditBody" Property="Background" Value="{StaticResource Brush.Tertiary}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
</Grid>
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
@@ -81,35 +61,174 @@
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="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<ToggleButton Style="{StaticResource RecordPillToggle}"
IsChecked="{Binding RecordPillOn}">
<!-- the label follows the pill's own foreground: muted when armed-off,
dark on red when armed (SegmentLabel pins its own colour) -->
<TextBlock Text="REC" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
</ToggleButton>
<ToggleButton Style="{StaticResource RecordPillToggle}"
IsChecked="{Binding OnAirPillOn}">
<TextBlock Text="ON-AIR" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
</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 PrimaryStartCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
<!-- No separate TEST button (creator ruling 2026-10-30): EDIT decides whether
the ON-AIR start is the real livestream or the test one. -->
</StackPanel>
<!-- THE decision (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ].
Build Scenes is the new edit mode — there is no separate EDIT button, so the
question "which world am I in?" and "can I edit?" cannot disagree. Clicking the
highlighted half re-enters it, which is how a creator gets back to the layers
during a session. -->
<!-- THREE FIXED SLOTS (creator, 2026-11-13): [Build Scenes] [YouTube] [Record]. The bar's
shape NEVER changes — every transition happens inside one slot, so the neighbouring
buttons cannot move. Each slot is its own little flow. -->
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
VerticalAlignment="Center" Margin="0,0,12,0"
KeyboardNavigation.TabNavigation="Cycle" KeyboardNavigation.DirectionalNavigation="Cycle">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- ══ SLOT 1: BUILD SCENES ══ -->
<Button x:Name="BuildSegmentButton" Grid.Column="0" Style="{StaticResource SegmentHalf}"
Tag="True" Command="{Binding SelectBuildCommand}"
IsEnabled="{Binding IsBuildScenesEnabled}"
ToolTip="{Binding BuildScenesTooltip}" FocusVisualStyle="{x:Null}">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
<TextBlock Text="Build Scenes" FontSize="12" FontWeight="Bold" Margin="6,0,0,0"
VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- ══ SLOT 2: YOUTUBE ══ -->
<Grid Grid.Column="1" Margin="4,0">
<!-- State 0: idle -->
<Button Style="{StaticResource SegmentHalf}" Tag="False"
Command="{Binding OpenYouTubeCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsYouTubeIdle, Converter={StaticResource BoolToVis}}"
ToolTip="Stream to YouTube">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
<TextBlock Text="YouTube" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- State 1: choose privacy — grouped inside a bordered sub-pill -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
Visibility="{Binding IsYouTubeChoosingPrivacy, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource OutputChip}" Command="{Binding ChooseLiveCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Public livestream">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M8,5v14l11,-7z"/>
<TextBlock Text="Live" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding ChoosePrivateCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Private stream — nothing public is published">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M7,10V7a5,5 0 0,1 10,0v3h1a1,1 0 0,1 1,1v9a1,1 0 0,1 -1,1H6a1,1 0 0,1 -1,-1v-9a1,1 0 0,1 1,-1h1zM9,10h6V7a3,3 0 0,0 -6,0v3z"/>
<TextBlock Text="Private" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 2: armed — amber border, because the next click goes on air -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
Visibility="{Binding IsYouTubeArmed, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource OutputChip}" Command="{Binding StartYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Start broadcasting now">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.Attention}"/>
<TextBlock Text="{Binding YouTubeArmLabel}" FontSize="12" FontWeight="Bold"
Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 3: streaming -->
<Button Style="{StaticResource SegmentHalf}" Tag="True"
Command="{Binding StopYouTubeCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsYouTubeStreaming, Converter={StaticResource BoolToVis}}"
ToolTip="End the broadcast">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M6,6h12v12H6z"/>
<TextBlock Text="{Binding YouTubeStopLabel}" FontSize="12" FontWeight="Bold"
Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
</Grid>
<!-- ══ SLOT 3: RECORD ══ -->
<Grid Grid.Column="2" Margin="4,0,0,0">
<!-- State 0: idle -->
<Button Style="{StaticResource SegmentHalf}" Tag="False"
Command="{Binding OpenRecordCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsRecordIdle, Converter={StaticResource BoolToVis}}"
ToolTip="Record to a local MP4">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20zM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16zM12,7.5a4.5,4.5 0 1,1 0,9a4.5,4.5 0 1,1 0,-9z"/>
<TextBlock Text="Record" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- State 1: armed -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
Visibility="{Binding IsRecordArmed, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource OutputChip}" Command="{Binding StartRecordingCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Start recording now">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.Attention}"/>
<TextBlock Text="Start Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelRecordCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 2: recording — green pulse, clock, stop -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
Visibility="{Binding IsRecordRecording, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.RecordLive}"
Opacity="{Binding RecDotOpacity, Mode=OneWay}"/>
<TextBlock Text="{Binding RecordElapsedLabel}" FontSize="11" FontWeight="Bold"
FontFamily="Consolas" Margin="5,0,8,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
<Button Style="{StaticResource OutputChip}" Command="{Binding StopRecordingCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Stop recording and save">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
<TextBlock Text="Stop" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
<StackPanel Orientation="Horizontal"
@@ -120,6 +239,12 @@
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"/>
<!-- TASK 32·2: measured facts only — elapsed seconds and which attempt. No countdown:
YouTube's cutoff is not a constant we can promise. -->
<TextBlock x:Name="ReconnectDetail" Text="{Binding ReconnectDetailText}"
Foreground="{StaticResource Brush.Attention}" FontSize="13"
FontFamily="Consolas" VerticalAlignment="Center" Margin="10,0,0,0"
Visibility="{Binding IsReconnecting, Converter={StaticResource BoolToVis}}"/>
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="{StaticResource Brush.Primary}" Margin="14,0,0,0"
Command="{Binding EndStreamCommand}"/>
+4
View File
@@ -19,6 +19,10 @@ public partial class TopBar : UserControl
InitializeComponent();
AvatarImage.Source = null;
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
// Keyboard enters the bar on Build scenes, and Tab cycles between the two halves only
// (creator, build 729) — Tab must not walk off into the rest of the window from here.
Loaded += (_, _) => BuildSegmentButton.Focus();
}
/// <summary>Right-click on the LlamaCasty wordmark opens the app menu (Report Bug / Feature
+10 -3
View File
@@ -10,13 +10,20 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 session surface: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; right-click opens App Settings directly. `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; App Settings now groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font type/family, size, color via the shared palette, emphasis, and horizontal/vertical alignment with a static-text notice; sub-options are indented under their headings. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, slide-out alert placement/stickiness, brand-logo, preview, transition, and staged/all-scene layout-default controls. `BackToAbout_Click` lives here |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
The Add Layer menu exposes one standalone recent-alert Ticker per scene. The
properties panel edits its independent `Ticker` settings; Stream Alerts retains
its separate current-event ticker controls. Outside edit mode, the left panel
offers Live Chat first and Logs second; Logs wraps and colors typed entries,
pins explicit Class 1/2 errors above the timeline until acknowledged, then
returns them to the timeline; it excludes earlier file contents.
## Window ↔ control contract
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
+43 -33
View File
@@ -53,8 +53,8 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S
| SDK zip (sign in to partner first) | `partner.steamgames.com/downloads/steamworks_sdk.zip` |
| Docs home | `partner.steamgames.com/doc/home` |
| SteamPipe upload (steamcmd + VDF fields) | `partner.steamgames.com/doc/sdk/uploading` |
| DLC configuration | `partner.steamgames.com/doc/store/application/dlc` |
| `BIsDlcInstalled` | `partner.steamgames.com/doc/api/ISteamApps` |
| Demo configuration | `partner.steamgames.com/doc/store/application/demos` |
| Paid app ownership (`BIsSubscribedApp`) | `partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp` |
| Store artwork sizes | `partner.steamgames.com/doc/store/assets/standard` |
| Coming Soon / 2-week public window | `partner.steamgames.com/doc/store/coming_soon` |
| Wallet rule (in-app purchases) | `partner.steamgames.com/doc/features/microtransactions` |
@@ -62,19 +62,32 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S
| Partner support | `help.steampowered.com/en/wizard/HelpWithPublishing` |
| Steamworks.NET (managed wrapper) | `nuget.org/packages/Steamworks.NET` |
1. **Steamworks account** → onboarding (NDA, identity, company details, tax interview, payout bank,
then the **$100 Steam Direct fee** per app). The store page cannot be configured before the fee.
2. **App IDs.** One **base app** (free) and one **DLC app** (the lifetime Pro). Note both numbers:
the DLC app ID is what the app passes to `BIsDlcInstalled`.
3. **Depot.** One depot holding the publish output; the base app and the DLC share the install
directory, and the DLC package grants no files of its own — it is entitlement only.
4. **Steamworks SDK.** `./scripts/steam-sdk.sh ~/Downloads/steamworks_sdk_165.zip` extracts
`steam_api64.dll` from `sdk/redistributable_bin/win64/` into `steam/` (gitignored) and stages
`steam_appid.txt` = 480 — Valve's Spacewar app, so `SteamAPI.Init()` works locally before
LlamaCasty has an App ID. Add `--contentbuilder` on the machine that uploads a depot. The csproj
copies the DLL **beside the exe** (`Link`) with `ExcludeFromSingleFile`, and copies
`steam_appid.txt` in Debug only — never into a depot. The managed side is the `Steamworks.NET`
package (`SteamApps.BIsDlcInstalled`).
### Status (updated 2026-11-05 — this is a WAIT on Valve, not a task of ours)
| Step | State |
|---|---|
| Partner account created | ✅ done by the creator |
| Identity verification | ⏳ **pending** — Valve reviewing |
| Tax interview | ⏳ submitted; Valve quotes **up to 30 business days** to review |
| $100 Steam Direct fee | ✅ **paid** (creator, 2026-11-05) — a store page can be configured once identity clears |
| App IDs (paid app + free Demo) | **not yet assigned** |
| Store page | not created |
| Build review | not started |
**Critical path from 2026-11-05**, using Valve's own published windows:
1. identity + tax review — up to **30 business days** (~6 calendar weeks) → mid-December
2. fee paid, app IDs assigned, store page built and submitted
3. store page review — **up to 12 business days** (Valve's current backlog; docs still say 3–5)
4. page public **≥2 weeks before release** (Valve's rule)
5. build review — **up to 12 business days**
**Earliest realistic launch: early-to-mid February 2027.** The launch-price deadline is no longer a
date: it now ends **30 days after the store page goes live** (2026-11-05), so Valve's queue cannot make
the public claim wrong.
Nothing below can start until step 1 clears; none of it blocks the app work.
5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is
the window where wishlists accumulate.
@@ -89,31 +102,28 @@ publish → steam-stage → steamcmd + app_build.vdf → build upload → set li
2. **Set the build live** in Steamworks → *Builds* → the new build → **Set Build Live** on the
default branch. That *is* the release; there is no artifact to hash and no manifest to submit.
3. **Announce** with Steam Events (release notes appear in the library and on the store page).
4. **Verify**: install from a clean Steam account, confirm `BIsDlcInstalled` returns false, buy the
DLC (or use a test licence), confirm the high tiers unlock, then refund the test purchase.
4. **Verify**: install the Demo from a clean Steam account, confirm paid-app ownership is false,
then test with an account that owns the paid app and confirm the higher tiers unlock.
**Updates** are the same upload with a higher build ID. The app keeps **no self-updater and no
version banner** — Steam is the update path, and that policy has not changed.
## 4. Entitlement wiring
- `IEntitlement` (`Services/Entitlement/`) has one method, `IsPro`. **Steam DLC** answers it today
(`SteamEntitlement`, `Steamworks.NET` + `steam_api64.dll`); the **Polar** monthly rail plugs in
beside it. Resolution is `Pro = steamDlc || polarLease`, then `MainViewModel.HasProOutput` adds
the Debug-only `DeveloperMode`; anything unresolved is the free profile.
- **`SteamEntitlement.NotConfigured()` is the shipping default** until the DLC app id exists — it
answers false without touching the native library. Set the id when Steamworks creates the DLC.
- `IEntitlement` reports `IsAppOwned`. Debug uses a fake owned result; Release checks the paid app
ID through `BIsSubscribedApp`. `MainViewModel.IsAppOwned` is the app-wide binary ownership flag.
- The paid app ID is not yet configured. Until it is assigned, the Release path fails closed without
touching the native library.
- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned.
- **Never sell the monthly inside the Steam build.** Steamworks requires in-app purchases to use
the Steam Wallet; the app may *state* that monthly plans exist on the site, with no checkout,
no price and no payment field. Confirm the wording with Steamworks support before shipping it.
- The EULA should expressly allow commercial/monetized streaming use; confirm with counsel or Valve
that this satisfies the Steam Subscriber Agreement's software terms.
- **Never gate a feature.** The only delta is the output profile.
## 5. Release checklist
- Build clean, 0 warnings (`./scripts/verify.sh`), tests green.
- `steam_api64.dll` present; app starts **without** Steam and falls back to the free profile.
- DLC ownership false → free profile; true → every tier offered.
- Paid-app ownership false → free profile; true → higher output tiers offered.
- `LlamaCasty.exe` rename landed (never rename a live exe).
- Clean Steam install on a second machine, then an update pushed and observed.
- Store page public for the full two weeks; support contact and announcements configured.
@@ -123,15 +133,15 @@ version banner** — Steam is the update path, and that policy has not changed.
- **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and
disclaims warranty.
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube today, **Polar** the
day licence validation ships. Steam itself is named by Valve's own policy, not ours.
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube. Steam itself is
named by Valve's own policy, not ours.
- **`THIRD-PARTY-NOTICES.txt`** stays shipped and shown in About; the ffmpeg pin stays
`lgpl-shared`; no GPL component may be linked (see `product.md` → "Licensing").
- **DMCA**: a takedown needs only the notice — host, link, good-faith statement, signature.
Statutory damages need a registration ($65). Worth doing once there is something to pirate.
- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a free tier
good enough that a crack buys nothing, Steam's own delivery, short Polar leases, and a licence ID
in About. **Not obfuscation, not a native DLL, not hardware locking.**
- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a useful
free tier, Steam's own delivery, and Steam ownership of the paid app. **Not obfuscation, not a
native DLL, not hardware locking.**
## 7. Effort, once
@@ -141,4 +151,4 @@ version banner** — Steam is the update path, and that policy has not changed.
| Publish profile + `steam_api64.dll` + `IEntitlement` Steam path | 1 day |
| Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release |
| Store page + assets (screenshots, capsule art) | 1–2 days |
| Clean-account install + DLC purchase/refund test | 0.5 day |
| Clean-account Demo install + paid-app ownership test | 0.5 day |
+31 -31
View File
@@ -1,40 +1,40 @@
# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced)
# HANDOFF — 2026-11-12 (bitrate + audio-clock fixes; sync number pending)
## State
- Branch `main`, level with `origin` through `8f79b20`; this unit is local.
- **The shipped exe is `LlamaCasty.exe`** (`AssemblyName` renamed; four pack URIs fixed — the docs
said three, `KeepBroadcastDialog.xaml:5` was the fourth). Guarded by `AssemblyIdentityTests`.
- **`scripts/publish.sh`** stages `artifacts/steam-stage` and refuses to finish if `ffmpeg\`, the
licence, privacy or notices file is missing, or test assemblies leaked in. Output 257 MB.
- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps,
fail closed) resolved once in `BuildEncoderOptions` — one ffmpeg process, two outputs.
`MainViewModel.DeveloperMode` bypasses it in Debug only.
- **Distribution is Steam-exclusive** (2026-10-31): free app + paid edition as a Steam **DLC**; the
**$10/month** plan sold off-Steam by Polar, *stated, never sold*, in-app. Entitlement is
`steamDlcOwned || polarKeyValid`; the app must start without Steam, falling back to free.
- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex.
- `main`; `b265d42` is the latest pushed commit. Everything after is local.
- **Bitrate saga closed.** An @8 stream averaged 5.1 Mbps because the args were capped VBR, not CBR
(measured: 9 kbps vs 1951 kbps at a 2000k target on a static frame). `700c7a8` adds `-rc cbr` for
NVENC/AMF, nothing for QSV (infers CBR from bitrate == maxrate) or OpenH264 (no measurable gain).
- Ladder `1708667` (12/10/6); floor `93719a2` (tier YouTube minimum); rate box `f2cab5c`; rate history +
consent proposal `b0f8263`; pre-release 60 fps switch `1e3a4f7`.
- **Audio drift closed** (`47f409f`, `102cb96`, `751d9b1`): the mix is driven by the monotonic clock,
counts only audio whose write reached the pipe, and starts its clock at session open. Measured
across three recordings: +2.1% long → 226 ms short → 137 ms short → **lengths now match (34 ms in
13 s)**.
- **Sync answer (measured, settled).** Mic delay ships at **0** and should stay off; with it off the audio
path runs ~100 ms behind, so the **global** LIP-SYNC offset is **−130 ms**. Both offsets default to 0 —
a correction belongs to one camera, never to the product (`AudioSyncSettingsTests` pins it).
- **675 tests green, 0 warnings.** `scripts/avsync.py` measures any recording in seconds.
## Next action
**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is partly done ahead of time:
`IEntitlement` + `SteamEntitlement` (Steamworks.NET, fail-closed on every path) + `FakeEntitlement`,
with `HasProOutput` reading the rail. Undo/redo, the rail gear and the EDIT alignment also landed
early. Remaining on day 1: greyed tiers, licence UI's four states, system messages, licence ID in
About. Days 2–6 in `TASKS.md` → "The surge".
- Creator: set the mic delay to 350, record ~15 s, run `./scripts/avsync.py "<clip>" --crop
764x764+1032+159`. Target: `offset` inside ±30 ms and `lengths match`.
- Then resume the queue: **40·A/C/D → Move → 9·5 → 14·16/17/18 → 66 → 67 → 68 → 69 → 70 → 36**.
- Do not push app commits absent a checkpoint request.
## Landmines
- **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content** — without it the publisher bundles
libav\*.dll into the exe and the app ships unable to find ffmpeg. Recipe in `MyMistakes.md`.
- **Dev affordances are compiled out, not hidden** — `DeveloperPanel` is removed from the Release
build; `Helpers/DevBuild.cs` is the single source for the flag (never `const`).
- **Nothing may gate a feature.** The only paid delta is the output profile; `ShowBrandFlash` stays
`false` and entitlement must never enable it.
- **No checkout inside a Steam build** — the Wallet rule. The monthly is named, never sold, in-app.
- **The org access token never ships.** `organization_id` is public; the token is not.
- **The site publishes the model, never the numbers**, and never takes payment or links a checkout.
- **No self-updater and no version banner**: Steam updates the app.
- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs; `ytLive.Tests`
carries 3 xUnit warnings the app-only gate does not see.
- Not every hex is a theme colour — `BorderColorPalette`/chat defaults are USER DATA, not tokens.
- `Assets/starting-background.jpg` is modified in the working tree, **awaiting the creator's visual
check** — do not commit it with another unit.
- **Never read a field the constructor assigns later** (`RefreshRateProposal()` at ctor 354 reads
`_framePump`, built at 468 — 8 failing tests, `e654a16`).
- **Never pipe a long job through `tail`** — it buffers, so a live gate looks dead, and verify's phase-3
build writes to `/dev/null`. Use `vstest --logger:"console;verbosity=normal"`; it names the failure.
- A killed test run leaves `testhost.exe` holding `LlamaCasty.dll`; every later build then fails on the
copy. `taskkill.exe /F /IM testhost.exe` before rebuilding.
- `ChromaKeyRealClipTests`, `AudioPipelineTests.Mix_*`, `AppLogRotationTests` and
`NotificationAreaIntegrationTests` flake under load; each passes alone.
- Every rate change is a full encoder relaunch; rates go through `MainViewModel.EffectiveQuality`.
- `MainWindow` tests need a temporary `LayoutPathOverride` cleared in `finally`; read render numbers in Release.
+70 -2
View File
@@ -2,6 +2,24 @@ using System.IO;
namespace ytLive.Helpers;
/// <summary>Presentation category; independent from the entry's attention class.</summary>
public enum AppLogMessageType
{
Information,
Success,
Warning,
Error,
}
/// <summary>Explicit attention priority, assigned by the writer rather than inferred from text or source.</summary>
public enum AppLogErrorClass
{
/// <summary>Fatal or unhandled failure; highest priority.</summary>
Class1 = 1,
/// <summary>Recoverable failure that still requires attention.</summary>
Class2 = 2,
}
/// <summary>
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
/// where the WPF app has no visible console. Writes to
@@ -12,6 +30,7 @@ public static class AppLog
{
/// <summary>The cap for the live log file. Past this, it rolls to <c>startup.1.log</c>.</summary>
private const long DefaultMaxBytes = 5 * 1024 * 1024;
private const int CurrentLaunchEntryLimit = 500;
/// <summary>How many rolled copies to keep (startup.1.log, startup.2.log, …).</summary>
private const int RetainedFiles = 1;
@@ -20,6 +39,8 @@ public static class AppLog
InstanceProfile.DataRoot, "startup.log");
private static readonly object Gate = new();
private static readonly List<AppLogEntry> CurrentLaunchEntries = new();
private static long _nextSequence;
/// <summary>Test seam: point the log somewhere else. Null = the real profile's log.</summary>
internal static string? PathOverride { get; set; }
@@ -29,16 +50,40 @@ public static class AppLog
private static string LogPath => PathOverride ?? DefaultLogPath;
internal static IReadOnlyList<AppLogEntry> SnapshotCurrentLaunch()
{
lock (Gate)
return CurrentLaunchEntries.ToArray();
}
public static void Write(string message)
=> Write(message, AppLogMessageType.Information);
public static void WriteWarning(string message)
=> Write(message, AppLogMessageType.Warning);
public static void WriteSuccess(string message)
=> Write(message, AppLogMessageType.Success);
public static void Write(string message, AppLogMessageType type, AppLogErrorClass? errorClass = null)
{
try
{
lock (Gate)
{
var entry = new AppLogEntry(
++_nextSequence,
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}",
type,
errorClass);
CurrentLaunchEntries.Add(entry);
if (CurrentLaunchEntries.Count > CurrentLaunchEntryLimit)
CurrentLaunchEntries.RemoveAt(0);
var path = LogPath;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles);
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
File.AppendAllText(path, $"{entry.Text}{Environment.NewLine}");
}
}
catch
@@ -49,6 +94,29 @@ public static class AppLog
public static void Write(Exception exception, string message)
{
Write($"{message}: {exception}");
Write($"{message}: {exception}", AppLogMessageType.Error);
}
public static void Write(Exception exception, string message, AppLogErrorClass errorClass)
=> Write($"{message}: {exception}", AppLogMessageType.Error, errorClass);
}
public sealed record AppLogEntry(long Sequence, string Text, AppLogMessageType Type,
AppLogErrorClass? ErrorClass)
{
public string? ErrorClassLabel => ErrorClass switch
{
AppLogErrorClass.Class1 => "CLASS 1",
AppLogErrorClass.Class2 => "CLASS 2",
_ => null,
};
public string TypeLabel => ErrorClassLabel ?? Type switch
{
AppLogMessageType.Information => "INFO",
AppLogMessageType.Success => "SUCCESS",
AppLogMessageType.Warning => "WARNING",
AppLogMessageType.Error => "ERROR",
_ => Type.ToString().ToUpperInvariant(),
};
}
+36
View File
@@ -0,0 +1,36 @@
using System;
using System.Collections;
namespace ytLive.Helpers;
/// <summary>
/// The arithmetic behind dragging a row in the layer list, kept out of the window's code-behind so it
/// can be tested without a mouse. It used to live inline in the preview-mouse handlers, and the only
/// test that covered it injected REAL cursor movement (<c>SetCursorPos</c> + <c>mouse_event</c>) — which
/// took over whatever machine ran the tests and failed whenever the desktop was busy. 2026-11-05: the
/// arithmetic moved here (pure, tested), the injected-input test was deleted.
/// </summary>
public static class DragReorderMath
{
/// <summary>True once the pointer has travelled far enough to mean "drag", not "click". WPF's
/// own thresholds are the caller's (<c>SystemParameters.MinimumHorizontal/VerticalDragDistance</c>).</summary>
public static bool ExceededThreshold(double dx, double dy, double horizontal, double vertical)
=> Math.Abs(dx) >= horizontal || Math.Abs(dy) >= vertical;
/// <summary>Moves one item to the position currently occupied by another. Returns the index the
/// item actually landed on (the caller keeps dragging from there), or -1 when the move is a no-op
/// or the indices are out of range.
/// <para>Order matters: the item is removed first, so a downward move inserts at the target's old
/// index and lands exactly under the pointer — this is the behaviour the mouse test used to pin.</para>
/// </summary>
public static int TryMove(IList items, int from, int to)
{
if (items == null || from < 0 || to < 0 || from >= items.Count || to >= items.Count) return -1;
if (from == to) return -1;
var moved = items[from]!;
items.RemoveAt(from);
items.Insert(to, moved);
return to;
}
}
+47
View File
@@ -0,0 +1,47 @@
using System;
namespace ytLive.Helpers;
/// <summary>
/// Paces a frame stream to wall-clock deadlines instead of sleeping a fixed interval after each
/// frame. The difference matters: a fixed sleep ADDS the frame's own decode/copy cost, so a 30 fps
/// clip with 20 ms of work per frame plays at 1/(33+20) ≈ 19 fps — which is what a 1080p video layer
/// did (measured 20.9 fps on a 30 fps clip, 2026-11-05). Here the target is <c>start + n × frame</c>,
/// so work is absorbed by the interval when it fits, and a stream that has fallen behind resyncs
/// instead of bursting to catch up.
/// </summary>
public sealed class FramePacer
{
private long _nextTicks = long.MinValue;
/// <summary>How long to wait after emitting the frame at <paramref name="now"/>. Returns zero
/// when the frame is already due (or late).</summary>
public TimeSpan DelayFor(TimeSpan frameDuration, TimeSpan now)
{
if (frameDuration <= TimeSpan.Zero) return TimeSpan.Zero;
if (_nextTicks == long.MinValue)
{
_nextTicks = now.Ticks + frameDuration.Ticks;
return frameDuration;
}
var remaining = TimeSpan.FromTicks(_nextTicks - now.Ticks);
_nextTicks += frameDuration.Ticks;
if (remaining < TimeSpan.Zero)
{
// Late (the frame's work exceeded its interval): resync to now rather than emitting a
// burst of frames back-to-back, which the viewer would see as a speed-up.
_nextTicks = now.Ticks + frameDuration.Ticks;
return TimeSpan.Zero;
}
// Never wait longer than one interval, even if the caller's clock stood still (a fake delay
// in a test, or a suspended machine): a longer sleep would be a pause, not pacing.
return remaining > frameDuration ? frameDuration : remaining;
}
/// <summary>Restarts the schedule (a new playback pass begins at the top of the file).</summary>
public void Reset() => _nextTicks = long.MinValue;
}
+12
View File
@@ -69,6 +69,18 @@ public static class InstanceProfile
public static string? WebViewDataFolder => null;
#endif
/// <summary>The audio pipe name for this process. The pipe server allows exactly ONE instance, so a
/// shared name means the second app on the machine can never go live — it throws "All pipe instances
/// are busy" (2026-11-06: Release recording, Debug trying to go live). Release keeps the plain name;
/// a DEBUG build ALWAYS appends a suffix — its claimed id, or the process id when it has none — so a
/// debug run launched straight from the IDE can never collide with the Release build beside it.</summary>
public static string AudioPipeName(string baseName)
#if DEBUG
=> Id is { } id ? $"{baseName}_{id}" : $"{baseName}_pid{Environment.ProcessId}";
#else
=> baseName;
#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(
+25
View File
@@ -0,0 +1,25 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace ytLive.Helpers;
/// <summary>
/// True when a log entry's sequence falls inside the range a clicked alert points at. Two numbers
/// rather than a membership collection: the log list is virtualised, and rows that are already
/// realised do not re-evaluate when a collection changes — but they do when a bound number changes.
/// </summary>
public sealed class LogInRangeConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture)
{
if (values.Length < 3) return false;
if (values[0] is not long sequence) return false;
if (values[1] is not long from || values[2] is not long to) return false;
if (from == 0 && to == 0) return false;
return sequence >= from && sequence <= to;
}
public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
+1 -1
View File
@@ -6,7 +6,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|------|---------|
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty<T>()` — base for all ViewModels |
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) |
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab, with independent message type and explicit Class 1/2 severity metadata; it never loads prior-launch file contents |
| `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) |
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
+2
View File
@@ -11,6 +11,8 @@ public partial class KeepBroadcastDialog : Window
public KeepBroadcastDialog(string title)
{
InitializeComponent();
if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, this))
Owner = owner;
MessageText.Text = string.IsNullOrWhiteSpace(title)
? "Your private broadcast is on YouTube as a private video. Keep it, or delete it?"
: $"\u201c{title}\u201d is on YouTube as a private video. Keep it, or delete it?";
+52 -10
View File
@@ -137,12 +137,54 @@
<TextBlock Grid.Row="4" Grid.ColumnSpan="2" Text="{Binding CurrentHealth.HealthMessage}"
Foreground="{StaticResource Brush.Attention}" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,6"/>
<ComboBox Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom" Style="{StaticResource YtComboBox}"
ItemsSource="{Binding QualityOptions}"
SelectedItem="{Binding SelectedQuality}"
DisplayMemberPath="Label"
ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding IsOffline}"/>
<StackPanel Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom">
<ComboBox x:Name="QualityPicker" Style="{StaticResource YtComboBox}"
ItemsSource="{Binding QualityOptions}"
SelectedItem="{Binding SelectedQuality}"
ItemTemplate="{StaticResource YtQualityComboBoxItemTemplate}"
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding CanChangeQuality}"/>
<!-- The creator's own rate: the tier sets the geometry, this sets the bits.
Guidance lives in the tooltip (YouTube's numbers, from RateLadder). -->
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="RatedMbpsBox" Grid.Column="0" Style="{StaticResource YtTextBoxCompact}"
Text="{Binding RatedMbps, UpdateSourceTrigger=LostFocus, StringFormat={}{0:0.#}}"
ToolTip="{Binding RateHint}"
IsEnabled="{Binding CanChangeQuality}"/>
<TextBlock Grid.Column="1" Text="Mbps" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"/>
</Grid>
<!-- Between sessions only, never mid-show, never applied without a click: what
the last few sessions actually held (Models/RateHistory). -->
<Border Margin="0,6,0,0" Padding="8,6" CornerRadius="4"
Background="{StaticResource Brush.SurfaceContainerHigh}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasRateProposal}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<TextBlock Text="{Binding RateProposalText}" TextWrapping="Wrap" FontSize="11"
Foreground="{StaticResource Brush.OnSurface}"/>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<Button Content="Use it" Command="{Binding AcceptRateProposalCommand}"
Style="{StaticResource YtButton}"/>
<Button Content="Keep mine" Command="{Binding DismissRateProposalCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="6,0,0,0"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Border>
@@ -165,12 +207,12 @@
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
<controls:OverlayHost Grid.RowSpan="3"/>
<controls:OverlayHost x:Name="AboutOverlay" Grid.RowSpan="3"/>
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
<!-- ═══ TOASTS: non-blocking notifications (user-positioned, bottom-right by default) ═══ -->
<notifications:NotificationArea x:Name="ToastArea"
Grid.RowSpan="4"
Position="BottomRight" MaxItems="4"
Margin="0,0,12,96"/>
Position="{Binding ToastPosition}" MaxItems="4"
Margin="{Binding ToastAreaMargin}"/>
</Grid>
</Window>
+43 -1
View File
@@ -147,8 +147,28 @@ public partial class MainWindow : Window
_topBar.RefreshAvatar();
}
private void MainWindow_Closing(object? sender, CancelEventArgs e)
private bool _shutdownComplete;
private async void MainWindow_Closing(object? sender, CancelEventArgs e)
{
if (_shutdownComplete)
{
SaveWindowGeometry();
_viewModel.Shutdown();
return;
}
// A session must END before anything is disposed (2026-11-12: closing mid-recording used to
// pull the pump out from under itself). Hold the window open, stop properly, then close again.
if (_viewModel.IsLive || _viewModel.IsRecording)
{
e.Cancel = true;
await _viewModel.StopBeforeShutdownAsync();
_shutdownComplete = true;
Close();
return;
}
SaveWindowGeometry();
_viewModel.Shutdown();
}
@@ -181,6 +201,24 @@ public partial class MainWindow : Window
{
_viewModel.DismissZoomIfActive();
}
// Esc leaves a sub-menu (creator ruling 2026-11-12): the Output world is a sub-menu, and its
// back chevron is gone in favour of this. The bar says so under the switches.
// Esc backs out of whichever slot is mid-flow (creator, 2026-11-13): the slots cancel in place
// rather than moving the bar around.
if (e.Key == System.Windows.Input.Key.Escape)
{
if (_viewModel.IsYouTubeChoosingPrivacy || _viewModel.IsYouTubeArmed)
{
_viewModel.CancelYouTubeCommand.Execute(null);
e.Handled = true;
}
else if (_viewModel.IsRecordArmed)
{
_viewModel.CancelRecordCommand.Execute(null);
e.Handled = true;
}
}
}
private void UpdateTaskbarOverlay()
@@ -201,6 +239,10 @@ public partial class MainWindow : Window
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
// The Settings (gear) and Socials drawers live in the same host but were left out of this
// guard, so clicking anywhere else left them open (reported 2026-11-05).
_viewModel.CloseSettingsDrawerIfOpen();
_viewModel.CloseSocialsDrawer();
}
if (_previewPane.IsClickInsidePreview(original)) return;
if (_leftPanel.IsClickInside(original)) return;
+36
View File
@@ -0,0 +1,36 @@
namespace ytLive.Models;
/// <summary>
/// One row in the left panel's alert stack (creator design, 2026-11-05). The stack sits above the
/// panel's content and pushes it down, so an alert is seen without hunting for a tab.
/// <para><b>Pinned</b> rows are standing state, not errors to clear: they show a pin instead of a
/// close control and are only removed when the state itself changes (the Steam licence indicator
/// becomes dismissible — and its dismissal permanent — once ownership is verified).</para>
/// <para>Clicking a row runs its action: the licence indicator opens About; an event alert opens the
/// Logs tab with that event's lines highlighted.</para>
/// <para>Rows are text only — a heading line above the message was redundant and pushed the stack past
/// its two-line budget (creator, 2026-11-05).</para>
/// </summary>
public sealed record AttentionAlert(
string Id,
string Text,
bool IsPinned,
AttentionAlertAction Action,
long LogFrom = 0,
long LogTo = 0)
{
/// <summary>True while a close control should be shown (a dismissible row).</summary>
public bool CanDismiss => !IsPinned;
/// <summary>True while the row should show the pin glyph instead of a close control.</summary>
public bool ShowsPin => IsPinned;
}
public enum AttentionAlertAction
{
/// <summary>Open the About panel (the licence indicator).</summary>
About,
/// <summary>Open the Logs tab and highlight this event's lines.</summary>
Logs,
}
+19 -12
View File
@@ -3,11 +3,8 @@ namespace ytLive.Models;
/// <summary>
/// The one entitlement rule, as pure policy: what a build is allowed to emit.
///
/// <para><b>Why pixels and a frame rate rather than "720p".</b> The paid delta is output
/// quality, and a resolution name is a marketing word, not a measurable bound — 720p60 is
/// 921,600 pixels at twice the frame rate, and vertical 720×1280 is the same pixel count
/// with a different shape. A pixel budget plus a frame-rate ceiling expresses the same
/// promise without a lookup table that a new tier can quietly escape.</para>
/// <para><b>Frame rate is the paid boundary.</b> Every supported resolution at 30 fps is free;
/// 60 fps requires paid ownership, regardless of frame dimensions.</para>
///
/// <para><b>Fail closed.</b> Callers pass <c>pro: false</c> when entitlement is unknown,
/// unreadable or unresolved; the free profile is the default state, never the fallback
@@ -19,19 +16,29 @@ namespace ytLive.Models;
/// </summary>
public static class OutputProfile
{
/// <summary>Free ceiling: 921,600 px = 1280×720, i.e. one 720p frame.</summary>
public const int FreeMaxPixels = 921_600;
/// <summary>Free ceiling: 30 frames per second.</summary>
public const int FreeMaxFps = 30;
/// <summary>The named tier a capped build falls back to when nothing else qualifies.</summary>
public const string FreeFallbackName = "720p30";
/// <summary>
/// Pre-release switch. <c>false</c> means the frame-rate boundary is not enforced yet, so every
/// shipped tier is selectable by any build — the app is still being tested at 60 fps in
/// <b>Release</b>, where <c>DevBuild.IsDevelopment</c> is false and the developer override cannot
/// help. Flip to <c>true</c> when the paid edition ships; the rule itself already exists and its
/// tests do not change.
///
/// <para><b>Why <c>static readonly</c>:</b> a <c>const</c> folds the consuming <c>||</c> at compile
/// time and leaves an unreachable operand, which is a warning. The JIT folds a readonly field
/// just as well, and the build stays at zero warnings.</para>
/// </summary>
public static readonly bool EntitlementEnforced = false;
/// <summary>The default free tier and fallback when no shipped tier is available.</summary>
public const string FreeFallbackName = "1080p30";
/// <summary>Is this tier within the free budget? Pro may use anything shipped.</summary>
public static bool IsAllowed(QualityOption tier, bool pro)
=> pro
|| (tier.Width * tier.Height <= FreeMaxPixels && tier.Fps <= FreeMaxFps);
|| tier.Fps <= FreeMaxFps;
/// <summary>
/// The tier a session will actually encode. A permitted selection passes through
@@ -57,6 +64,6 @@ public static class OutputProfile
// No shipped tier fits the budget: build the floor explicitly rather than trusting
// the table. A build whose tiers were all redefined must still emit a legal frame.
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 6.0, 1280, 720);
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 10.0, 1920, 1080);
}
}
+2
View File
@@ -9,4 +9,6 @@ public sealed record QualityOption(string Display, int Fps, double Bitrate, int
{
/// <summary>Self-documenting label, e.g. "1080p60 (8Mbps)".</summary>
public string Label => $"{Display} ({Bitrate:0.#}Mbps)";
public bool RequiresPro => !OutputProfile.IsAllowed(this, pro: false);
}
+92
View File
@@ -0,0 +1,92 @@
namespace ytLive.Models;
/// <summary>One finished broadcast, as evidence about a tier's rate.</summary>
/// <param name="Tier">The quality tier's display name, e.g. "1080p60".</param>
/// <param name="Encoder">The probed encoder, e.g. "h264_nvenc" — history against one encoder is not
/// evidence about another, so a GPU or driver change cannot import stale conclusions.</param>
/// <param name="RatedMbps">The rate the session started at (the creator's or the tier default).</param>
/// <param name="SettledMbps">The rate the session ended at after any step-down.</param>
/// <param name="Steps">How many quality steps the session spent. Zero means the rate held.</param>
public readonly record struct SessionRateResult(
string Tier, string Encoder, double RatedMbps, double SettledMbps, int Steps)
{
public bool Stepped => Steps > 0;
}
/// <summary>
/// What the app has learned about a connection, as pure policy — the only thing allowed to suggest a
/// different starting rate, and it never applies one itself.
///
/// <para><b>High-water mark</b> is the highest rate that <i>finished</i> a session with zero steps, not
/// the highest number ever seen: one easy session (a menu screen, a quiet game) must not set a target
/// the machine cannot hold, which would make every later session look like a regression.</para>
///
/// <para><b>Asymmetric on purpose.</b> Raising needs a 10% improvement across five clean sessions, so
/// fixing nothing is slow; lowering needs only repeated step-downs, because fixing damage should not
/// wait. A creator may always override either way — see <c>MainViewModel.RatedMbps</c>.</para>
/// </summary>
public static class RateHistory
{
/// <summary>Sessions considered. Below this there is no evidence, only noise.</summary>
public const int Window = 5;
/// <summary>How far above the current rate an upward proposal must sit to be worth asking.</summary>
public const double RaiseThreshold = 0.10;
/// <summary>Step-downs in the window that make a downward proposal worth asking.</summary>
public const int LowerAfterSteps = 3;
/// <summary>A suggested starting rate, and which way it moves.</summary>
public readonly record struct Proposal(double Mbps, bool Raise);
/// <summary>
/// The suggestion for one tier, or null when the evidence does not warrant one. <paramref name="encoder"/>
/// null considers every encoder; callers pass the current one so a hardware change starts clean.
/// </summary>
public static Proposal? Suggest(
IEnumerable<SessionRateResult> history, string tier, string? encoder, double currentMbps)
{
var window = history
.Where(entry => entry.Tier == tier && (encoder is null || entry.Encoder == encoder))
.TakeLast(Window)
.ToList();
if (window.Count < Window) return null;
if (window.Count(entry => entry.Stepped) >= LowerAfterSteps)
return new Proposal(Math.Round(Median(window.Select(entry => entry.SettledMbps)), 1), Raise: false);
var highWater = window.Max(entry => entry.Stepped ? 0 : entry.RatedMbps);
return highWater >= currentMbps * (1 + RaiseThreshold)
? new Proposal(Math.Round(highWater, 0), Raise: true)
: null;
}
private static double Median(IEnumerable<double> values)
{
var sorted = values.OrderBy(value => value).ToArray();
if (sorted.Length == 0) return 0;
var middle = sorted.Length / 2;
return sorted.Length % 2 == 1
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
}
// ─── Storage form: one line per session, unit-separated fields ───
private const char Field = '\u001F';
public static string Format(SessionRateResult entry)
=> string.Join(Field, entry.Tier, entry.Encoder,
entry.RatedMbps.ToString("0.#"), entry.SettledMbps.ToString("0.#"), entry.Steps);
public static SessionRateResult? Parse(string? line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
var parts = line.Split(Field);
if (parts.Length != 5 || !double.TryParse(parts[2], out var rated)
|| !double.TryParse(parts[3], out var settled) || !int.TryParse(parts[4], out var steps))
return null;
return new SessionRateResult(parts[0], parts[1], rated, settled, steps);
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace ytLive.Models;
/// <summary>
/// YouTube's published H.264 ingestion numbers for a tier, in Mbps — the floor a step-down may walk
/// to and the recommended rate a creator aims at.
///
/// <para><b>Source:</b> support.google.com/youtube/answer/2853702 (checked 2026-11-12): 1080p@60
/// 6 min / 17 recommended, 1080p@30 5/14, 720p@30 and @60 3/8. The same page specifies CBR, which is
/// why these numbers are a target the encoder actually holds (<c>FfmpegArgs.AddRateControl</c>).</para>
///
/// <para><b>Vertical is not a separate row.</b> 1080×1920 has the same pixel count as 1920×1080, so a
/// portrait tier takes the band of its landscape pair — the frame rate alone decides which row.</para>
/// </summary>
public static class RateLadder
{
/// <summary>Minimum and recommended rates for a tier.</summary>
public readonly record struct Band(double Minimum, double Recommended);
public static Band For(int width, int height, int fps)
{
var is1080 = Math.Max(width, height) >= 1920;
return (is1080, fps > 30) switch
{
(true, true) => new Band(6.0, 17.0),
(true, false) => new Band(5.0, 14.0),
(_, _) => new Band(3.0, 8.0),
};
}
public static Band For(QualityOption tier) => For(tier.Width, tier.Height, tier.Fps);
}
+22
View File
@@ -72,6 +72,28 @@ public class Scene : INotifyPropertyChanged
public bool IsChatScene { get; init; }
private bool? _sceneMutesMic;
/// <summary>TASK 35: what this scene does to the mix when it takes the program output.
/// <c>null</c> = leave it alone (the default; scenes are passive until told); true mutes the mic on
/// entry. Applied once at entry, never while staging offline — auditioning a scene must not edit
/// the stream's state.</summary>
public bool? SceneMutesMic
{
get => _sceneMutesMic;
set => Set(ref _sceneMutesMic, value);
}
private bool? _sceneMutesDesktop;
/// <summary>TASK 35: as <see cref="SceneMutesMic"/>, for desktop/game audio.</summary>
public bool? SceneMutesDesktop
{
get => _sceneMutesDesktop;
set => Set(ref _sceneMutesDesktop, value);
}
/// <summary>
/// Asset-table id of this scene's top-strip tile artwork. The tile is a
/// separate image from the scene's background: the preview pane keeps
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace ytLive.Models;
/// <summary>One Super Chat or Super Sticker event, as YouTube reports it.</summary>
public sealed record SuperChatEvent(
string Id,
ChatEventKind Kind,
long AmountMicros,
string Currency,
string? DisplayAmount,
DateTime PublishedAt);
/// <summary>
/// TASK 3·20: what this session's rewards added up to — <b>in memory only</b>.
/// <para>Derived on demand from <c>superChatEvents.list</c> rather than persisted: YouTube is the system
/// of record for money, and keeping a local ledger would contradict the published promise that no
/// viewer, member or donor information is retained after the session ends.</para>
/// <para>Totals are kept <b>per currency</b> and never summed across currencies — a $5 and a €5 are not
/// $10, and a single blended number would be a lie.</para>
/// </summary>
public sealed record SessionRewardSummary(
int SuperChatCount,
int SuperStickerCount,
IReadOnlyDictionary<string, long> MicrosByCurrency,
DateTime? FirstEventAt,
DateTime? LastEventAt)
{
public static readonly SessionRewardSummary Empty = new(
0, 0, new Dictionary<string, long>(), null, null);
public int TotalEvents => SuperChatCount + SuperStickerCount;
public bool HasEvents => TotalEvents > 0;
/// <summary>Filters to a session window and rolls the events up. Events outside
/// [<paramref name="sinceUtc"/>, <paramref name="untilUtc"/>] are ignored, which is what makes a
/// report "this session" rather than "this channel".</summary>
public static SessionRewardSummary From(
IEnumerable<SuperChatEvent> events, DateTime sinceUtc, DateTime? untilUtc = null)
{
var until = untilUtc ?? DateTime.MaxValue;
var inWindow = events
.Where(e => e.PublishedAt >= sinceUtc && e.PublishedAt <= until)
.OrderBy(e => e.PublishedAt)
.ToList();
if (inWindow.Count == 0) return Empty;
var byCurrency = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
foreach (var e in inWindow)
{
var currency = string.IsNullOrWhiteSpace(e.Currency) ? "?" : e.Currency.ToUpperInvariant();
byCurrency.TryGetValue(currency, out var total);
byCurrency[currency] = total + Math.Max(0, e.AmountMicros);
}
return new SessionRewardSummary(
inWindow.Count(e => e.Kind == ChatEventKind.SuperChat),
inWindow.Count(e => e.Kind == ChatEventKind.SuperSticker),
byCurrency,
inWindow[0].PublishedAt,
inWindow[^1].PublishedAt);
}
/// <summary>Money lines for display, e.g. "USD $12.34 · EUR €4.00". Empty when there is nothing to
/// show — a creator who never monetized must never be shown a zero.</summary>
public IReadOnlyList<string> MoneyLines()
=> MicrosByCurrency
.Where(pair => pair.Value > 0)
.OrderByDescending(pair => pair.Value)
.Select(pair => $"{pair.Key} {Format(pair.Key, pair.Value)}")
.ToList();
private static string Format(string currency, long micros)
{
var amount = micros / 1_000_000m;
var symbol = currency switch
{
"USD" => "$",
"EUR" => "€",
"GBP" => "£",
"CAD" => "CA$",
"AUD" => "A$",
_ => string.Empty,
};
return symbol.Length > 0
? symbol + amount.ToString("0.00", CultureInfo.InvariantCulture)
: amount.ToString("0.00", CultureInfo.InvariantCulture) + " " + currency;
}
}
+8 -3
View File
@@ -21,12 +21,17 @@ public enum SessionStartKind
/// <para>Pure so the whole matrix is testable without a window.</para></summary>
public static class SessionStartDecision
{
public static SessionStartKind For(bool recordArmed, bool onAirArmed, bool editing)
/// <summary>
/// Which kind of session the armed output starts. Since 2026-11-12 there is no inference from
/// editing: the output stage offers "Private Test Stream" and "Review & Go Live" as separate
/// buttons, so ON-AIR always means the real livestream and Test is something the creator asks for.
/// </summary>
public static SessionStartKind For(bool recordArmed, bool onAirArmed)
{
// Neither pill armed still starts something: local recording needs no account, and the
// Neither output armed still starts something: local recording needs no account, and the
// no-dead-ends rule forbids an inert primary button.
if (recordArmed || !onAirArmed) return SessionStartKind.Record;
return editing ? SessionStartKind.Test : SessionStartKind.Live;
return SessionStartKind.Live;
}
/// <summary>What pressing the button actually does, given whether YouTube is connected.
+293 -13
View File
@@ -1,5 +1,6 @@
using System.Windows.Media;
using ytLive.Helpers;
using ytLive.Services;
namespace ytLive.Models;
@@ -21,7 +22,9 @@ public enum SourceType
AlertBox,
/// <summary>A privacy blur: blurs the frame beneath it inside a rect or ellipse. Live scene
/// only, always below the brand logo, several allowed (strongest wins on overlap).</summary>
Blur
Blur,
/// <summary>A scene-local feed of recent alert events.</summary>
Ticker
}
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
@@ -39,6 +42,20 @@ public enum MediaPlaybackState
Paused
}
public enum TextLayerAlignment
{
Left,
Center,
Right
}
public enum TextLayerVerticalAlignment
{
Top,
Center,
Bottom
}
public enum ClipShape
{
Traditional,
@@ -66,6 +83,19 @@ public enum AlertDisplayMethod
/// </summary>
public class Source : SceneElement
{
private readonly Ticker _ticker = new();
public Source()
{
_ticker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Ticker.IsEnabled)) Raise(nameof(AlertShowTicker));
if (e.PropertyName == nameof(Ticker.DisplayMethod)) Raise(nameof(AlertDisplayMethod));
};
}
public Ticker Ticker => _ticker;
private SourceType _type;
public SourceType Type
{
@@ -80,6 +110,8 @@ public class Source : SceneElement
Raise(nameof(IsWebSource));
Raise(nameof(IsTextOverlay));
Raise(nameof(IsScoreboard));
Raise(nameof(IsTicker));
Raise(nameof(ShowsChromaKey));
}
}
}
@@ -88,18 +120,20 @@ public class Source : SceneElement
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle
/// (creator ruling 2026-11-01).</summary>
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
or SourceType.AlertBox or SourceType.Blur;
or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker
or SourceType.MediaSource;
/// <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
or SourceType.Blur
or SourceType.Blur or SourceType.Ticker
? ElementKind.Dynamic : ElementKind.Static;
public override bool IsWebSource => Type == SourceType.WebSource;
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
public override bool IsScoreboard => Type == SourceType.Scoreboard;
public bool IsTicker => Type == SourceType.Ticker;
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
@@ -155,8 +189,8 @@ public class Source : SceneElement
/// scoreboard joined this list 2026-10-30 — without it the strip was rendered, published to
/// VideoImageSource, and never displayed (the same shape as the invisible-alert defect).</summary>
private bool IsFrameSource =>
Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
or SourceType.AlertBox or SourceType.Scoreboard;
Type is SourceType.TextOverlay or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
or SourceType.AlertBox or SourceType.Scoreboard or SourceType.Ticker;
public override ImageSource? DisplaySource => IsLiveCapture
? (VideoImageSource ?? _imageSource)
@@ -200,6 +234,16 @@ public class Source : SceneElement
set => Set(ref _mediaType, value);
}
private bool _isReplayLayer;
/// <summary>TASK 65: this media layer shows saved replay clips rather than a chosen file. The VM
/// owns its path and playlist; the compositor needs to know nothing.</summary>
public bool IsReplayLayer
{
get => _isReplayLayer;
set => Set(ref _isReplayLayer, value);
}
private string? _mediaPath;
public string? MediaPath
{
@@ -207,10 +251,17 @@ public class Source : SceneElement
set
{
if (Set(ref _mediaPath, value))
{
Raise(nameof(IsMediaSource));
Raise(nameof(MediaFileDisplay));
}
}
}
/// <summary>The chosen file's name for the properties panel (path stays in the model).</summary>
public string MediaFileDisplay =>
string.IsNullOrWhiteSpace(_mediaPath) ? "No video chosen" : System.IO.Path.GetFileName(_mediaPath);
private bool _mediaIsLooping;
public bool MediaIsLooping
{
@@ -225,6 +276,31 @@ public class Source : SceneElement
set => Set(ref _mediaVolume, Math.Clamp(value, 0.0, 1.0));
}
private bool _mediaMuted;
/// <summary>Silences this layer's soundtrack (the picture keeps playing). One decoder is
/// shared per file, so the level/mute are the file's — the layer is the control.</summary>
public bool MediaMuted
{
get => _mediaMuted;
set => Set(ref _mediaMuted, value);
}
private bool _isMediaRunning;
/// <summary>True while the clip is decoding. False means it has played out and is holding its
/// last frame — the panel then offers "Play again" instead of leaving a frozen picture with no
/// explanation (the 2026-11-05 "clicking Loop did nothing" report).</summary>
public bool IsMediaRunning
{
get => _isMediaRunning;
set
{
if (Set(ref _isMediaRunning, value)) Raise(nameof(IsMediaFinished));
}
}
public bool IsMediaFinished => !IsMediaRunning;
private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped;
public MediaPlaybackState MediaPlaybackState
{
@@ -234,6 +310,133 @@ public class Source : SceneElement
public override bool IsMediaSource => Type == SourceType.MediaSource;
// ── Green/Blue Screen keying (TASK 64) ────────────────────────────────────────────
// Settings live on the LAYER. The decoder is keyed by (path + these settings), so two
// layers with the same look share one decode and two different looks get two decoders.
private bool _chromaKeyEnabled;
public bool ChromaKeyEnabled
{
get => _chromaKeyEnabled;
set
{
if (Set(ref _chromaKeyEnabled, value))
{
Raise(nameof(ChromaKey));
Raise(nameof(HasChromaSuggestion));
}
}
}
private string _chromaKeyColor = "#00B140";
/// <summary>The screen colour as it was shot (the detector's median), not a canonical green.</summary>
public string ChromaKeyColor
{
get => _chromaKeyColor;
set
{
if (Set(ref _chromaKeyColor, value)) Raise(nameof(ChromaKey));
}
}
private double _chromaKeySimilarity = 0.12;
/// <summary>Creator-facing "Amount": how far from the key colour still counts as screen.</summary>
public double ChromaKeySimilarity
{
get => _chromaKeySimilarity;
set
{
if (Set(ref _chromaKeySimilarity, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
}
}
private double _chromaKeySoftness = 0.08;
/// <summary>Creator-facing "Softness": the width of the fade at the keyed edge.</summary>
public double ChromaKeySoftness
{
get => _chromaKeySoftness;
set
{
if (Set(ref _chromaKeySoftness, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
}
}
private bool _chromaKeySpill = true;
/// <summary>Creator-facing "Fringe": clean the coloured rim the screen leaves on hair and edges.</summary>
public bool ChromaKeySpill
{
get => _chromaKeySpill;
set
{
if (Set(ref _chromaKeySpill, value)) Raise(nameof(ChromaKey));
}
}
private ChromaScreen _chromaScreen = ChromaScreen.Green;
public ChromaScreen ChromaScreen
{
get => _chromaScreen;
set
{
if (Set(ref _chromaScreen, value)) Raise(nameof(ChromaKey));
}
}
/// <summary>The look this layer asks its decoder for — the one place the settings become an
/// ffmpeg chain and a session signature.</summary>
public ChromaKeyOptions ChromaKey
=> new(ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness, ChromaKeySpill, ChromaScreen);
private ChromaKeySuggestion _chromaSuggestion;
/// <summary>The detector's offer for this clip. Transient by design — never persisted, because
/// it is a hint about a file, not a setting the creator made.</summary>
public ChromaKeySuggestion ChromaSuggestion
{
get => _chromaSuggestion;
set
{
_chromaSuggestion = value;
Raise(nameof(ChromaSuggestion));
Raise(nameof(HasChromaSuggestion));
Raise(nameof(ChromaSuggestionText));
Raise(nameof(HasChromaSuggestionColor));
}
}
/// <summary>True while there is an offer the creator has not accepted.</summary>
public bool HasChromaSuggestion => !ChromaKeyEnabled && ChromaSuggestion.Screen != ChromaScreen.None;
private bool _chromaKeyAutoApplied;
/// <summary>True when the app applied the key on its own (the "Auto Green/Blue Screen"
/// setting). The panel says so, so an automatic change is never silent.</summary>
public bool ChromaKeyAutoApplied
{
get => _chromaKeyAutoApplied;
set
{
if (Set(ref _chromaKeyAutoApplied, value)) Raise(nameof(HasAutoChromaKey));
}
}
public bool HasAutoChromaKey => ChromaKeyAutoApplied && ChromaKeyEnabled;
/// <summary>Which layers get the Green/Blue Screen block: a video layer, or the alert box's
/// own clip (TASK 64 — a clip is a clip).</summary>
public bool ShowsChromaKey => IsMediaSource
|| (Type == SourceType.AlertBox && !AlertUseDefaultVideo && !string.IsNullOrWhiteSpace(AlertVideoPath));
public string ChromaSuggestionText => ChromaSuggestion.Screen switch
{
ChromaScreen.Green => "Looks like a green screen.",
ChromaScreen.Blue => "Looks like a blue screen.",
_ => string.Empty,
};
/// <summary>The detected colour, for the swatch next to the offer.</summary>
public string ChromaSuggestionColor => ChromaSuggestion.ColorHex ?? "#00B140";
public bool HasChromaSuggestionColor => ChromaSuggestion.ColorHex != null;
// 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
@@ -249,21 +452,26 @@ public class Source : SceneElement
public string? AlertVideoPath
{
get => _alertVideoPath;
set => Set(ref _alertVideoPath, value);
set
{
if (Set(ref _alertVideoPath, value)) Raise(nameof(ShowsChromaKey));
}
}
private bool _alertUseDefaultVideo = true;
public bool AlertUseDefaultVideo
{
get => _alertUseDefaultVideo;
set => Set(ref _alertUseDefaultVideo, value);
set
{
if (Set(ref _alertUseDefaultVideo, value)) Raise(nameof(ShowsChromaKey));
}
}
private bool _alertShowTicker = true;
public bool AlertShowTicker
{
get => _alertShowTicker;
set => Set(ref _alertShowTicker, value);
get => _ticker.IsEnabled;
set => _ticker.IsEnabled = value;
}
private double _alertVideoVolume = 1.0;
@@ -295,11 +503,10 @@ public class Source : SceneElement
/// <summary>True once a file is chosen — gates the loop switch and the clear button.</summary>
public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
public AlertDisplayMethod AlertDisplayMethod
{
get => _alertDisplayMethod;
set => Set(ref _alertDisplayMethod, value);
get => _ticker.DisplayMethod;
set => _ticker.DisplayMethod = value;
}
public override bool IsAlertBox => Type == SourceType.AlertBox;
@@ -376,6 +583,79 @@ public class Source : SceneElement
public override bool IsChatBox => Type == SourceType.ChatBox;
private string _textContent = "Text";
public string TextContent
{
get => _textContent;
set => Set(ref _textContent, value ?? string.Empty);
}
private string _textFontFamily = "Segoe UI";
public string TextFontFamily
{
get => _textFontFamily;
set => Set(ref _textFontFamily, string.IsNullOrWhiteSpace(value) ? "Segoe UI" : value.Trim());
}
private double _textFontSize = 48;
public double TextFontSize
{
get => _textFontSize;
set => Set(ref _textFontSize, double.IsFinite(value) ? Math.Clamp(value, 8, 300) : 48);
}
private string _textColor = "#FFFFFF";
public string TextColor
{
get => _textColor;
set
{
if (!Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF")) return;
Raise(nameof(TextColorEntry));
Raise(nameof(TextColorOptions));
}
}
public NamedColor TextColorEntry
{
get => BorderColorPalette.EntryFor(TextColor);
set => TextColor = value.Hex;
}
public IReadOnlyList<NamedColor> TextColorOptions => BorderColorPalette.OptionsFor(TextColor);
private bool _textBold;
public bool TextBold { get => _textBold; set => Set(ref _textBold, value); }
private bool _textItalic;
public bool TextItalic { get => _textItalic; set => Set(ref _textItalic, value); }
private bool _textUnderline;
public bool TextUnderline { get => _textUnderline; set => Set(ref _textUnderline, value); }
private TextLayerAlignment _textAlignment;
public TextLayerAlignment TextAlignment
{
get => _textAlignment;
set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left);
}
private TextLayerVerticalAlignment _textVerticalAlignment;
public TextLayerVerticalAlignment TextVerticalAlignment
{
get => _textVerticalAlignment;
set => Set(ref _textVerticalAlignment,
Enum.IsDefined(value) ? value : TextLayerVerticalAlignment.Top);
}
private static bool IsValidTextColor(string? value)
{
if (value is not { Length: 7 or 9 } || value[0] != '#') return false;
for (var i = 1; i < value.Length; i++)
if (!Uri.IsHexDigit(value[i])) return false;
return true;
}
public override bool IsCountdown => Type == SourceType.Countdown;
private bool _chatPreviewEnabled = true;
+286
View File
@@ -0,0 +1,286 @@
using System;
namespace ytLive.Models;
/// <summary>Why the policy acted. Reported in the toast and the session report, so it is never
/// guessed at again after the fact.</summary>
public enum StepCause
{
None,
/// <summary>The machine could not produce the frame: stalls, late renders, pump drops.</summary>
Machine,
/// <summary>The frames were produced but the sink could not take them: encoder drops.</summary>
Upload,
/// <summary>Both kinds of evidence in the same window — doing nothing is the only safe move.</summary>
Ambiguous,
}
public enum StepAction
{
None,
/// <summary>Cheapest first: halve LlamaCasty's own capture cost, change nothing the viewer sees.</summary>
ShedCaptureCadence,
LowerFrameRate,
LowerResolution,
LowerBitrate,
RaiseFrameRate,
RaiseResolution,
RaiseBitrate,
}
/// <summary>One statistics window as the pump and the encoder report it. Nothing here is derived
/// from the network directly: congestion shows up as frames the encoder failed to produce in
/// wall-clock time, and backpressure shows up as submit wait.</summary>
public readonly record struct StepDownSample(
double WindowSeconds,
int PumpStalls,
int PumpDrops,
int EncoderDrops,
double AvgRenderMs,
double AvgSubmitWaitMs);
/// <summary>The decision and the numbers behind it. <paramref name="Why"/> is for the log; the
/// creator-facing sentence is built in the VM from <see cref="Cause"/>.</summary>
public readonly record struct StepDownDecision(StepCause Cause, StepAction Action, string? Why)
{
public bool Acted => Action != StepAction.None;
}
/// <summary>
/// Decides when a session must give up quality, and why (TASK 33).
///
/// <para><b>Two levers, never mixed.</b> Machine starvation is answered with less work (60 → 30 fps,
/// then 1080 → 720); upload congestion is answered with fewer bits, sized by the observed loss
/// fraction. Resolution is never used as a bandwidth lever, because 1080p30 and 720p60 cost the same
/// 6–8 Mbps: dropping pixels at an unchanged bitrate relieves nothing.</para>
///
/// <para><b>One-way by default.</b> A step-down that climbs back is an encoder restart, which is a
/// visible hitch; the creator ruled that a show does not oscillate. The state machine still carries
/// the ceiling, so enabling recovery later is a constructor flag rather than a rewrite.
/// </para>
/// </summary>
public sealed class StepDownPolicy
{
/// <summary>Minimum evidence before acting: ~15 s of qualifying windows.</summary>
internal const double QualifyingSeconds = 15.0;
/// <summary>A sample window shorter than this is ignored — the pump reports every 5 s.</summary>
internal const double MinWindowSeconds = 4.0;
internal const double CooldownSeconds = 30.0;
/// <summary>Loss fraction at or above which the upload lever fires.</summary>
internal const double UploadLossThreshold = 0.05;
/// <summary>Render time relative to the frame budget that counts as machine starvation.</summary>
internal const double RenderBudgetFraction = 0.9;
internal const int StallThreshold = 5;
/// <summary>Clean seconds required before climbing when recovery is enabled.</summary>
internal const double ClimbAfterCleanSeconds = 180.0;
/// <summary>A climb followed by a fall inside this window freezes the session at the low value.</summary>
internal const double FlapWindowSeconds = 120.0;
private readonly bool _vertical;
private readonly bool _allowClimb;
private readonly int _ceilingFps;
private readonly int _ceilingWidth;
private readonly int _ceilingHeight;
private readonly double _ceilingBitrateMbps;
private readonly double _floorBitrateMbps;
private double _qualifyingSeconds;
private double _cleanSeconds;
private double _lastStepAt = double.NegativeInfinity;
private double _lastRaiseAt = double.NegativeInfinity;
private bool _frozen;
private bool _shedCapture;
public StepDownPolicy(bool vertical, int startFps, double startBitrateMbps, bool allowClimb = false)
: this(vertical ? 1080 : 1920, vertical ? 1920 : 1080, startFps, startBitrateMbps, allowClimb)
{
}
/// <summary>Explicit geometry: the tier the creator actually chose, which is what decides the
/// step budget (a 1080 start gets three, a 720 start gets one).</summary>
/// <param name="floorBitrateMbps">The lowest rate a step-down may reach — the tier's YouTube
/// minimum (<see cref="RateLadder"/>). Never below the starting rate, so an unusual start cannot
/// begin already under its floor. Omitted, the historical 3/4 Mbps floor applies.</param>
public StepDownPolicy(int startWidth, int startHeight, int startFps, double startBitrateMbps,
bool allowClimb = false, double? floorBitrateMbps = null)
{
_vertical = startHeight > startWidth;
_allowClimb = allowClimb;
_ceilingFps = startFps;
_ceilingWidth = startWidth;
_ceilingHeight = startHeight;
_ceilingBitrateMbps = startBitrateMbps;
_floorBitrateMbps = Math.Min(
floorBitrateMbps ?? (_vertical ? 4.0 : 3.0),
startBitrateMbps);
Fps = startFps;
Width = startWidth;
Height = startHeight;
BitrateMbps = startBitrateMbps;
// Creator ruling 2026-11-09: a 1080p start may spend three steps; 720 and vertical get one.
StepsAllowed = startWidth >= 1920 ? 3 : 1;
}
public int Fps { get; private set; }
public int Width { get; private set; }
public int Height { get; private set; }
public double BitrateMbps { get; private set; }
public int StepsUsed { get; private set; }
public int StepsAllowed { get; }
public bool Frozen => _frozen;
/// <summary>True when no lever has anything left: 30 fps, the floor geometry, the floor bitrate.</summary>
public bool AtFloor => Fps <= 30
&& (_vertical || Width <= 1280)
&& BitrateMbps <= _floorBitrateMbps + 0.001;
public double FrameBudgetMs => 1000.0 / Math.Max(1, Fps);
/// <summary>Classifies one window and, when the evidence is strong enough and the budget allows,
/// applies exactly one change. <paramref name="elapsed"/> is time since the session started.</summary>
public StepDownDecision Observe(in StepDownSample sample, TimeSpan elapsed)
{
if (sample.WindowSeconds < MinWindowSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
var now = elapsed.TotalSeconds;
var budget = FrameBudgetMs;
var machine = sample.PumpStalls >= StallThreshold
|| sample.PumpDrops > 0
|| sample.AvgRenderMs > budget * RenderBudgetFraction;
var loss = sample.EncoderDrops / Math.Max(1.0, Fps * sample.WindowSeconds);
var upload = loss >= UploadLossThreshold;
if (machine && upload)
{
_qualifyingSeconds = 0;
_cleanSeconds = 0;
return new StepDownDecision(StepCause.Ambiguous, StepAction.None,
$"render {sample.AvgRenderMs:0.0}ms, stalls {sample.PumpStalls}, loss {loss:P0} — not acting");
}
if (!machine && !upload)
{
_qualifyingSeconds = 0;
_cleanSeconds += sample.WindowSeconds;
return TryClimb(sample, now);
}
_cleanSeconds = 0;
_qualifyingSeconds += sample.WindowSeconds;
if (_qualifyingSeconds < QualifyingSeconds)
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, null);
if (now - _lastStepAt < CooldownSeconds)
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, "cooldown");
// Machine starvation: shed OUR cost before the viewer's tier is touched. Costs no step and is
// not a quality change — if it does not fix the starvation, the fps ladder runs next window.
if (machine && !_shedCapture)
{
_shedCapture = true;
_qualifyingSeconds = 0;
_lastStepAt = now;
return new StepDownDecision(StepCause.Machine, StepAction.ShedCaptureCadence,
"halving capture cost before lowering quality");
}
if (StepsUsed >= StepsAllowed)
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None,
"step budget spent — holding");
var decision = machine ? StepMachine() : StepUpload(loss);
if (!decision.Acted) return decision;
if (now - _lastRaiseAt < FlapWindowSeconds) _frozen = true;
StepsUsed++;
_lastStepAt = now;
_qualifyingSeconds = 0;
return decision;
}
private StepDownDecision StepMachine()
{
if (Fps > 30)
{
Fps = 30;
return new StepDownDecision(StepCause.Machine, StepAction.LowerFrameRate,
$"frame rate 60 → 30 to give the machine its time back");
}
// The vertical tier's floor IS its only geometry (1080×1920), so it has no resolution step.
if (!_vertical && (Width > 1280 || Height > 720))
{
Width = 1280;
Height = 720;
return new StepDownDecision(StepCause.Machine, StepAction.LowerResolution,
$"resolution {Width}×{Height} — fewer pixels to composite");
}
return new StepDownDecision(StepCause.Machine, StepAction.None, "at the machine floor — holding");
}
private StepDownDecision StepUpload(double loss)
{
var reduction = Math.Max(0.5, BitrateMbps * loss);
var next = Math.Max(_floorBitrateMbps, BitrateMbps - reduction);
if (next >= BitrateMbps - 0.05)
return new StepDownDecision(StepCause.Upload, StepAction.None, "at the bitrate floor — holding");
var from = BitrateMbps;
BitrateMbps = Math.Round(next, 1);
return new StepDownDecision(StepCause.Upload, StepAction.LowerBitrate,
$"{from:0.0} → {BitrateMbps:0.0} Mbps on {loss:P0} loss");
}
private StepDownDecision TryClimb(in StepDownSample sample, double now)
{
if (!_allowClimb || _frozen) return new StepDownDecision(StepCause.None, StepAction.None, null);
if (StepsUsed == 0) return new StepDownDecision(StepCause.None, StepAction.None, null);
if (_cleanSeconds < ClimbAfterCleanSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
if (now - _lastStepAt < CooldownSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
var (action, caused) = RaiseOneStep();
if (!caused) return new StepDownDecision(StepCause.None, StepAction.None, null);
StepsUsed = Math.Max(0, StepsUsed - 1);
_lastRaiseAt = now;
_lastStepAt = now;
_cleanSeconds = 0;
return new StepDownDecision(StepCause.None, action, "conditions clean — one step back up");
}
private (StepAction Action, bool Changed) RaiseOneStep()
{
if (Fps < _ceilingFps)
{
Fps = _ceilingFps;
return (StepAction.RaiseFrameRate, true);
}
if (Width < _ceilingWidth || Height < _ceilingHeight)
{
Width = _ceilingWidth;
Height = _ceilingHeight;
return (StepAction.RaiseResolution, true);
}
if (BitrateMbps < _ceilingBitrateMbps - 0.05)
{
BitrateMbps = Math.Round(Math.Min(_ceilingBitrateMbps, BitrateMbps + 1.0), 1);
return (StepAction.RaiseBitrate, true);
}
return (StepAction.None, false);
}
}
+31
View File
@@ -0,0 +1,31 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ytLive.Models;
public sealed class Ticker : INotifyPropertyChanged
{
private bool _isEnabled = true;
private AlertDisplayMethod _displayMethod = AlertDisplayMethod.TickerScroll;
public bool IsEnabled
{
get => _isEnabled;
set => Set(ref _isEnabled, value);
}
public AlertDisplayMethod DisplayMethod
{
get => _displayMethod;
set => Set(ref _displayMethod, value);
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Globalization;
namespace ytLive.Models;
/// <summary>
/// TASK 39·2: the three metrics that count towards YouTube Partner Program eligibility, as progress
/// towards the ad-revenue thresholds.
/// <para><b>YouTube decides.</b> This only reports what the published numbers say; it never claims
/// eligibility, and it cannot see strikes, AdSense state, 2FA or the review decision.</para>
/// <para><b>The view criteria are either/or</b> — 4,000 public watch hours <i>or</i> 10M Shorts views —
/// so "2 of 3" is reported with that qualifier rather than implying all three must pass.</para>
/// </summary>
public sealed record YppProgress(
long Subscribers,
long SubscriberTarget,
long WatchHours,
long WatchHoursTarget,
long ShortsViews,
long ShortsViewsTarget)
{
public bool SubscribersMet => Subscribers >= SubscriberTarget;
public bool WatchHoursMet => WatchHours >= WatchHoursTarget;
public bool ShortsViewsMet => ShortsViews >= ShortsViewsTarget;
/// <summary>Criteria currently met (0–3). Two of three can mean eligible: subs plus EITHER view route.</summary>
public int CriteriaMet => (SubscribersMet ? 1 : 0) + (WatchHoursMet ? 1 : 0) + (ShortsViewsMet ? 1 : 0);
/// <summary>True when the subscriber bar AND at least one view route are satisfied — the shape of the
/// published rule. Still not a qualification claim: YouTube reviews it.</summary>
public bool MeetsPublishedRule => SubscribersMet && (WatchHoursMet || ShortsViewsMet);
public double SubscribersFraction => Fraction(Subscribers, SubscriberTarget);
public double WatchHoursFraction => Fraction(WatchHours, WatchHoursTarget);
public double ShortsViewsFraction => Fraction(ShortsViews, ShortsViewsTarget);
public string SubscribersText => $"{Compact(Subscribers)} / {Compact(SubscriberTarget)}";
public string WatchHoursText => $"{Compact(WatchHours)} / {Compact(WatchHoursTarget)} h";
public string ShortsViewsText => $"{Compact(ShortsViews)} / {Compact(ShortsViewsTarget)}";
public string CriteriaMetText => CriteriaMet == 1
? "1 of 3 criteria met — YouTube reviews eligibility."
: $"{CriteriaMet} of 3 criteria met — YouTube reviews eligibility.";
private static double Fraction(long value, long target)
=> target <= 0 ? 0 : Math.Clamp((double)value / target, 0, 1);
/// <summary>1,234 → "1,234"; 12,345 → "12.3k"; 1,234,567 → "1.23M". Mono-face friendly and stable
/// in width, so the three bars do not jitter as values move.</summary>
internal static string Compact(long value) => value switch
{
>= 1_000_000 => (value / 1_000_000d).ToString("0.##", CultureInfo.InvariantCulture) + "M",
>= 10_000 => (value / 1_000d).ToString("0.#", CultureInfo.InvariantCulture) + "k",
_ => value.ToString("N0", CultureInfo.InvariantCulture),
};
}
+4
View File
@@ -13,6 +13,10 @@ public static class YppThresholds
/// <summary>The date the Tier-2 ad-revenue bars double for new applicants.</summary>
public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
/// <summary>When these thresholds were last read from YouTube's published requirements. Shown in the
/// drawer and asserted by a test, because YouTube moves the bar and a stale bar must not look current.</summary>
public static readonly DateTime AsOfUtc = new(2026, 11, 5);
/// <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);
+4 -3
View File
@@ -7,7 +7,8 @@ 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/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`, 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 (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **TextOverlay content + font/size/color/emphasis/horizontal-alignment/vertical-alignment settings** (independent per source; static text), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row |
| `Ticker.cs` | Shared per-source ticker settings: `IsEnabled` and `DisplayMethod`; one independent instance for each scene ticker and the AlertBox ticker |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
@@ -15,8 +16,8 @@ Plain data types. No logic beyond what a property can carry. See
| `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` |
| `BorderColorPalette.cs` | **TASK 60:** the colours the webcam's border-colour drop-down offers — `NamedColor(Hex, Name)` + `BorderColorPalette.All` (the single source of truth; the drop-down is bound to it) and `NameOf(hex)` (case-insensitive; **empty** for a colour outside the palette — "no name" is the honest answer, TASK 62). The creator asked for a colour NAME next to each hex: `#e94560 Crimson`. **TASK 63:** `EntryFor(hex)` (the palette entry for a hex, case-insensitively; a nameless entry for a colour outside the palette) and `OptionsFor(hex)` (the palette + that entry) — the two the drop-down binds to; `NamedColor.ToString()` is `"Crimson (#e94560)"` |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band; `VerticalShapeLocked` (the Chat scene's vertical layout is rect-only) **overrides** `ClipShape` + `ToggleClipShape()` so no writer can slip a circle in |
| `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 |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)`, `RequiresPro` derives from `OutputProfile` (current code uses pixel/FPS; the FPS-only policy correction is queued in `commercial.md`) — drives the quality dropdown and output-rect math |
| `StreamConfig.cs` | Legacy `StreamConfig` (no production call sites; defaults do not drive output), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum; encoder output comes from the selected quality tier |
| `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) — 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) |
+92 -12
View File
@@ -35,6 +35,61 @@ max 200.
## 🔬 Recipes registry
### A SETTING THAT "DIDN'T STICK" — CHECK THE DEBOUNCE FIRST (RECIPE, 2026-11-12)
**Symptom:** a value is set in the UI, behaves correctly for one session, and is gone after a
restart. It looks like a wiring bug in the control; it is almost always `ScheduleSave()`.
**Cause:** `MainViewModel.ScheduleSave()` restarts a debounce timer that later calls `Save()`, which
writes the whole layout. Anything changed in the last few seconds before the app closes is lost —
and none of it is logged, so the next session reads a value the creator never chose.
**Quick fix:** for anything small and decisive (a sync offset, a toggle the creator just clicked),
call the `_layoutStore.Save…()` method **in the setter itself**, before `ScheduleSave()`. Two rows
cost nothing; a lost setting costs a whole reproduce-and-test cycle. Live example:
`MainViewModel.Audio.cs` → `AudioSyncOffsetMs` / `MicSyncOffsetMs`.
**Check list when a remembered setting misbehaves:** grep the property for `ScheduleSave`, confirm a
direct save exists, then reproduce with a *restart* (not just a new session) before debugging deeper.
**Bit us:** the lip-sync offset. `Audio.SyncOffsetMs` was set to −130, lost on close, and two clips
were measured against a setting that was not in force — the numbers disagreed by ~65 ms and the
disagreement was blamed on the audio path.
### A SYNC OFFSET IS NEVER A PRODUCT DEFAULT (2026-11-12)
**Mistake:** `MicSyncOffsetMs` shipped at **175 ms** because that suited one C920 on one desk. It
shifted every creator's voice to correct one machine and hid whether a desync was the app's fault.
**Rule:** offsets ship at 0 and are opt-in. `AudioSyncSettingsTests` pins it. And the tool that
measures them, `scripts/avsync.py`, varies **±35 ms between clips** on identical settings — it can
answer "is this 100 ms out?" but never "is this 20 ms out?". Below ~50 ms the creator's judgement is
the better instrument, so stop tuning rather than chasing the tool's noise.
### A LOOSE FILE IN `Assets/` IS NOT AN ASSET — THE CSProj LIST IS (RECIPE, 2026-11-04)
**Problem:** four untracked `Assets/llamacasty-*` exports sat in the tree for days, reported in
`HANDOFF.md` as "untouched", and every session spent tokens re-litigating whether to commit them.
**What it actually is:** shipped art is embedded in the assembly via **`<Resource Include="Assets\…"/>`**
in `ytLive.csproj` (icons, logo, backgrounds, fonts) or `<EmbeddedResource>` (the ONNX model), and the
Store tiles in `Packaging/Assets/` are generated from `Assets/llamacasty-logo.jpg` by
`scripts/make-store-logos.py`. The app reads art through `pack://`/`/LlamaCasty;component/Assets/…`
URIs — it never loads a file from disk at runtime. So a loose `.jpg`/`.png` in `Assets/` that appears in
**neither** the csproj nor any `Source=`/`Icon=`/script is a **source export**, not a dependency.
**The recipe (30 seconds, do it before mentioning the file):**
1. `git status --short` → the untracked list.
2. For each file: `grep -rIn -F "<name>" --exclude-dir=.git --exclude-dir=bin --exclude-dir=obj .`
plus `grep -n 'Assets' ytLive.csproj`. **No hit in either = dead.** (Check `scripts/` too — a
generator can be the only consumer.)
3. Referenced → add it to `<Resource>` **and** commit it in the same unit. Unreferenced → `rm` it and
commit the deletion; do not park it in `HANDOFF.md`.
**Deleted 2026-11-04 by this rule:** `llamacasty-icon.jpg`, `llamacasty-logo-white.jpg`,
`llamacasty-logo-white-nobg.png`, `llamacasty-logo-white-nobg-dim.png` — the shipped brand art is
`llamacasty-icon.ico`/`.png` and `llamacasty-logo.jpg`, all three already tracked and embedded.
### DEPLOYING llamacasty.com TO THE DROPLET (RECIPE, 2026-10-30)
**Problem:** "upload the site" burned a session: `ssh -p 2214 gramps@llamachile.tube` hung on a
@@ -209,11 +264,11 @@ After: 127 MB exe, `ffmpeg\` present, depot ≈ 257 MB total.
`scripts/publish.sh` fails loudly when `ffmpeg\`, `LICENSE`, `PRIVACY.md` or
`THIRD-PARTY-NOTICES.txt` are missing, and when test assemblies leak in.
### STEAM IS THE DISTRIBUTOR — AND WHAT IT DOES NOT ALLOW (2026-10-31)
### STEAM DISTRIBUTION, COMMERCIAL USE, AND THE DEMO OWNERSHIP CHECK (2026-11-02)
**Why recorded:** the distribution answer changed three times in one day (winget → zip+GitHub →
Steam), and each pivot invalidated a doc written hours earlier. These are the facts that won't
change.
**Why recorded:** the Steam Subscriber Agreement's private-use wording was misread as a blocker
for monetized streaming. Check the exception and the established Steam software precedent before
raising it again.
- **Steam installs skip SmartScreen entirely** — Valve's client delivers the files, so there is no
browser download, no Mark-of-the-Web and no publisher warning. An unsigned exe is *fine* in this
@@ -221,16 +276,25 @@ change.
- **Steam updates the app** — no self-updater, no version banner, no update endpoint.
- **⛔ In-app purchases must use the Steam Wallet.** Steamworks, *Microtransactions (In-Game
Purchases)*: *"For any in-game purchases, you'll need to use the microtransaction API so Steam
customers can only make purchases from the Steam Wallet."* So a subscription sold through Polar
inside a Steam build is a policy violation. **Lifetime = plain DLC bought on the store page**
(the app only calls `BIsDlcInstalled`); the monthly is *stated*, never *sold*, in-app.
- **Steam can technically do recurring billing** (`ISteamMicroTxn` + `ProcessAgreement`) but its own
doc opens with *"Recurring Subscriptions is not a fully supported feature"* and requires a server
of yours to poll renewals — more infrastructure than Polar, for a 30% cut.
customers can only make purchases from the Steam Wallet."* LlamaCasty has no in-app checkout.
- **The private-use wording is not, by itself, a commercial-use ban for LlamaCasty.** The Steam
Subscriber Agreement §2.A permits commercial use when the applicable product terms expressly
allow it; §5 has general private/personal-use wording for third-party software. OBS Studio is
distributed on Steam as video-production software under GPL, and OBS confirms the Steam build is
the same as its website build. GPL permits commercial use. LlamaCasty's EULA should expressly
allow commercial and monetized streaming; confirm the wording with Valve or counsel. Do not call
this a Steam blocker based only on §5.
Sources: https://store.steampowered.com/subscriber_agreement/ ·
https://obsproject.com/kb/steam-faq · https://www.gnu.org/licenses/gpl-faq.html
- **Demo ownership check:** the free Demo uses its own App ID; `ISteamApps::BIsSubscribedApp`
checks ownership of the paid app. The paid App ID must be configured; zero/missing ID fails closed.
Valve reference: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
- **Steam sells one paid app and distributes its associated free Demo.** Same code; no GitHub
binary. The demo links to the paid store page and can send a one-time wishlist notification.
- **Renaming a live exe is painful** (Steamworks → *Tip – Renaming a live exe*). Do the
`LlamaCasty.exe` rename **before** the first depot upload, not after.
- **The Steam store page must be public two weeks before release**, and the $100 Steam Direct fee
must be paid before the page can be configured at all.
- **The paid app's Steam store page must be public two weeks before release.** The Steamworks
application and $100 fee are already submitted; Valve's decision is pending.
### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51)
@@ -556,6 +620,17 @@ packages, high-quality downscale via `TransformedBitmap`. Screenshots compress
**Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`.
**Faster route (2026-11-09): the bundled ffmpeg does the same job in one command** — no throwaway
project, and it is already the tool used for every other asset:
```bash
./ffmpeg/ffmpeg.exe -hide_banner -loglevel error -y -i "C:\path\shot.png" \
-vf "scale=1400:-2:flags=lanczos" -q:v 4 "C:\...\docs\ytLlive-preview.jpg"
```
`-q:v 4` is the sweet spot for UI screenshots (text stays crisp); q3 runs ~340 KB, q6 ~230 KB.
The WPF recipe above stays as the fallback if ffmpeg is unavailable.
---
### Measuring audio-video A/V sync from a recording (no ears needed) — RECIPE
@@ -1698,6 +1773,11 @@ afterward** — and I got it wrong in the very commit written to apply that idea
## Asked twice about a deliberate, already-documented decision (2026-10-04)
**Superseded 2026-11-04:** the creator clarified that forcing Private is a development/test safeguard,
not a permanent product lock. Release go-live must let the creator choose Private/Unlisted/Public,
default to Private, and allow live changes. Do not repeat the former “always Private until v1” rule
as current policy; keep development and test runs Private.
**What happened:** the creator asked whether the test streams still upload private, and added "I
don't think you're able to delete them". I answered, researched `videos.delete`, and then — in the
very next exchange — *offered to build a cleanup tool*, which re-litigated a decision that has been
+2 -1
View File
@@ -13,7 +13,8 @@ kept locally, and what is never collected.
| Your layout: scenes, sources, assets, settings | `%APPDATA%\ytLlive\ytLlive.db` | So the app looks the same next time |
| YouTube sign-in token | `%APPDATA%\ytLlive\ytLlive.auth`, encrypted with Windows DPAPI (CurrentUser) | So you stay signed in; only your Windows account can decrypt it |
| Diagnostic log | `%APPDATA%\ytLlive\startup.log`, capped at 5 MB | To diagnose crashes you report |
| Recordings | the folder you choose | Your files; the app never uploads them unless you press Publish |
| Recordings | the folder you choose | Your files; the app never uploads them |
| Replay clips (if you use them) | `clips\` beside your recordings | Only the three most recent are kept; older ones are deleted automatically. Local only, never uploaded |
Nothing else is written to disk. There is no analytics service, no crash
reporter, no advertising SDK and no telemetry of any kind.
+60
View File
@@ -0,0 +1,60 @@
using System;
using System.IO;
namespace ytLive.Services;
/// <summary>
/// TASK 55·2: the default alert clip is materialised into <c>%TEMP%</c> so ffmpeg can read it as a file.
/// Nothing used to delete it, so every profile that ever played an alert left an orphan behind.
///
/// <para>Two touches, deliberately simple: delete this session's file on close, and sweep anything the
/// prefix matches at startup (a crash leaves the same debris a clean exit does not). Only files this app
/// created are touched — the prefix is the contract, and the sweep never deletes a directory.</para>
/// </summary>
public static class AlertClipCleanup
{
public const string Prefix = "ytLive-alert-";
/// <summary>Deletes stale alert clips left in <paramref name="directory"/>. Returns how many went.
/// Missing directory is not an error.</summary>
public static int Sweep(string directory)
{
try
{
if (!Directory.Exists(directory)) return 0;
var removed = 0;
foreach (var path in Directory.EnumerateFiles(directory, Prefix + "*.mp4"))
{
if (TryDelete(path)) removed++;
}
return removed;
}
catch (Exception ex)
{
Helpers.AppLog.Write(ex, "Alert clip sweep failed");
return 0;
}
}
/// <summary>Deletes one clip if it exists. Safe on null/blank and on a locked file.</summary>
public static bool Delete(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
return TryDelete(path);
}
private static bool TryDelete(string path)
{
try
{
if (!File.Exists(path)) return false;
File.Delete(path);
return true;
}
catch (Exception ex)
{
Helpers.AppLog.Write(ex, $"Alert clip delete failed: {path}");
return false;
}
}
}
+20 -178
View File
@@ -1,6 +1,5 @@
using System.Diagnostics;
using System.IO;
using ytLive.Helpers;
using System;
using System.Threading;
using ytLive.Services.Audio;
using ytLive.Services.Encoder;
@@ -11,10 +10,10 @@ namespace ytLive.Services;
/// 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.
/// play is required. The fade is applied by the alert layer, not here.
/// <para>Since TASK 21's audio slice this is a thin wrapper over the shared
/// <see cref="VideoClipPipe"/> (the same pipe a media layer uses); the alert case
/// simply never sets <c>Looping</c>.</para>
/// </summary>
public interface IAlertClipDecoder : IDisposable
{
@@ -35,30 +34,14 @@ public interface IAlertClipDecoder : IDisposable
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>
/// <summary>Default <see cref="IAlertClipDecoder"/>: the shared clip pipe with looping off.</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;
private readonly VideoClipPipe _clip;
public AlertClipDecoder(
string path,
@@ -67,161 +50,20 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
IFfmpegLocator locator,
Func<IDecodeProcess> processFactory,
IFrameRateProbe? frameRateProbe = null,
Func<TimeSpan, CancellationToken, Task>? delay = null)
Func<TimeSpan, CancellationToken, Task>? delay = null,
ChromaKeyOptions? chromaKey = null)
{
_path = path;
_width = width;
_height = height;
_locator = locator;
_processFactory = processFactory;
_probe = frameRateProbe;
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
_clip = new VideoClipPipe(
path, width, height, locator, processFactory, frameRateProbe, delay,
audio: true, chromaKey: chromaKey);
_clip.FrameAvailable += frame => FrameAvailable?.Invoke(frame);
_clip.AudioAvailable += sample => AudioReady?.Invoke(sample);
_clip.Completed += () => Completed?.Invoke();
}
public void Start()
{
if (_run != null) return;
_run = Task.Run(RunAsync, CancellationToken.None);
}
public void Start() => _ = _clip.StartAsync();
public void Stop() => _cts.Cancel();
public void Stop() => _ = _clip.StopAsync();
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;
}
}
public void Dispose() => _clip.Dispose();
}
+81 -30
View File
@@ -58,6 +58,8 @@ public sealed class AlertOverlayLayer : IDisposable
private ChatMessage? _current;
private Source? _currentBox;
private sealed record TickerFeed(string Text, double Duration);
private volatile TickerFeed? _tickerFeed;
private IAlertClipDecoder? _clip;
private VideoFrame? _latestClipFrame;
private bool _clipEof;
@@ -66,8 +68,6 @@ public sealed class AlertOverlayLayer : IDisposable
private int _clipHeight;
private bool _clipSawFrame;
private bool _clipAudioHeard;
private string? _tickerText;
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
private double _elapsed;
private IEnumerable<Scene>? _scenes;
@@ -139,6 +139,7 @@ public sealed class AlertOverlayLayer : IDisposable
BeginClip();
}
_contentVersion++;
RefreshTickerFeed();
}
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
@@ -172,6 +173,7 @@ public sealed class AlertOverlayLayer : IDisposable
_current = null;
_elapsed = 0;
AdvanceToNext();
RefreshTickerFeed();
}
_contentVersion++;
return;
@@ -184,6 +186,7 @@ public sealed class AlertOverlayLayer : IDisposable
_elapsed = 0;
BeginClip();
_contentVersion++;
RefreshTickerFeed();
return;
}
@@ -193,6 +196,7 @@ public sealed class AlertOverlayLayer : IDisposable
_current = null;
_elapsed = 0;
AdvanceToNext();
RefreshTickerFeed();
}
_contentVersion++;
}
@@ -249,27 +253,55 @@ public sealed class AlertOverlayLayer : IDisposable
/// <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;
public VideoFrame? AlertTickerFrame => AlertBoxTickerFrame(_currentBox);
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)),
};
public VideoFrame? AlertBoxTickerFrame(Source? alertBox)
{
if (_current == null || alertBox is not { Type: SourceType.AlertBox }) return null;
var w = (int)Math.Max(1, Math.Round(alertBox.Width));
var h = (int)Math.Max(1, Math.Min(
Math.Round(alertBox.Height), AlertTickerRenderer.Height));
var frame = AlertTickerRenderer.Render(
alertBox.Ticker, ComposeTicker(_current), _elapsed, AlertSeconds(),
TickerReadsPerAlert, w, h);
return frame == null
? null
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
{
Placement = ((int)Math.Round(alertBox.X), (int)Math.Round(alertBox.Y)),
};
}
public VideoFrame? RenderSceneTicker(Source tickerSource)
{
if (tickerSource.Type != SourceType.Ticker || !tickerSource.Ticker.IsEnabled)
return null;
var feed = _tickerFeed;
if (feed == null) return null;
return AlertTickerRenderer.Render(
tickerSource.Ticker,
feed.Text,
_elapsed,
feed.Duration,
TickerReadsPerAlert,
(int)Math.Max(1, Math.Round(tickerSource.Width)),
(int)Math.Max(1, Math.Round(tickerSource.Height)));
}
private void RefreshTickerFeed()
{
if (_current == null)
{
_tickerFeed = null;
return;
}
var messages = new[] { _current }.Concat(_queue).ToArray();
_tickerFeed = new TickerFeed(
string.Join(" • ", messages.Select(ComposeTicker)),
messages.Sum(DurationOf));
}
/// <summary>How many times the marquee must cross the frame per alert, so the
@@ -338,7 +370,7 @@ public sealed class AlertOverlayLayer : IDisposable
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert sound byte failed");
AppLog.Write(ex, "Alert sound byte failed", AppLogErrorClass.Class2);
StopSoundByte();
}
}
@@ -354,17 +386,11 @@ public sealed class AlertOverlayLayer : IDisposable
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) return;
StartSoundByte(box);
@@ -387,7 +413,7 @@ public sealed class AlertOverlayLayer : IDisposable
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert clip setup failed");
AppLog.Write(ex, "Alert clip setup failed", AppLogErrorClass.Class2);
StopClip(); // decode setup failed (bad path) — fall back to animations
}
}
@@ -552,6 +578,9 @@ public sealed class AlertOverlayLayer : IDisposable
};
UpdatePreview(alertBox);
}
foreach (var ticker in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.Ticker))
UpdateTickerPreview(ticker);
}
private void RefreshAlertPreviews()
@@ -566,6 +595,27 @@ public sealed class AlertOverlayLayer : IDisposable
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.AlertBox))
UpdatePreview(alertBox);
foreach (var ticker in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.Ticker))
UpdateTickerPreview(ticker);
}
private void UpdateTickerPreview(Source ticker)
{
var frame = RenderSceneTicker(ticker);
if (frame == null)
{
ticker.VideoImageSource = null;
return;
}
var bitmap = ticker.VideoImageSource;
if (bitmap == null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height)
{
bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
ticker.VideoImageSource = bitmap;
}
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
}
public void Dispose()
@@ -574,6 +624,7 @@ public sealed class AlertOverlayLayer : IDisposable
_ticker?.Stop();
_queue.Clear();
_current = null;
_tickerFeed = null;
StopClip();
}
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ public sealed class AlertAudioOutput : IDisposable
{
// No output device (a headless stream box, a locked-down session): the alert still
// renders, it is simply silent. Never let audio take the alert down.
AppLog.Write(ex, "Alert audio output failed");
AppLog.Write(ex, "Alert audio output failed", AppLogErrorClass.Class2);
Stop();
}
}
+43
View File
@@ -0,0 +1,43 @@
using System;
namespace ytLive.Services.Audio;
/// <summary>
/// How many frames the mix must produce <i>now</i>, derived from the monotonic clock rather than from a
/// tick count.
///
/// <para><b>Why this exists (2026-11-12):</b> the live loop used to write a fixed 480 frames per
/// iteration and trust that every iteration was 10 ms. It is not: `Task.Delay` plus processing lands
/// near 9.8 ms under a raised timer resolution, and 102 ticks per second where 100 are expected is
/// 102% of an audio second per wall second. FFmpeg stamps audio by sample count, so the track comes out
/// longer than the show and the sound slides later against a correct picture — measured on a 9.9 s
/// recording: video 9.888 s (right), audio 10.112 s (+2.1%, 224 ms).</para>
///
/// <para>The video side never had this bug because the pump paces off a stopwatch. This is the audio
/// equivalent: ask the clock what should exist, produce the difference, pad on underrun. Pure, so the
/// drift case is a test rather than a listening test.</para>
/// </summary>
public static class AudioClock
{
/// <summary>
/// Frames still owed at <paramref name="elapsedSeconds"/>: expected-minus-produced, clamped to
/// 0..<paramref name="maxFrames"/>. Zero means "not due yet"; the cap keeps a stalled loop from
/// dumping a huge burst in one write (the deficit stays owed and is paid off over the next ticks).
/// </summary>
public static int FramesDue(double elapsedSeconds, long framesProduced, int sampleRate, int maxFrames)
{
if (elapsedSeconds <= 0 || sampleRate <= 0 || maxFrames <= 0) return 0;
var expected = (long)Math.Round(elapsedSeconds * sampleRate);
var debt = expected - framesProduced;
return (int)Math.Clamp(debt, 0L, maxFrames);
}
/// <summary>
/// Advance the produced count for frames that actually reached the encoder. A write that went
/// nowhere (ffmpeg had not connected yet) must stay owed: counting it would shorten the track by
/// that gap — measured 2026-11-12 on the first clock-driven recording, where 27 start-up drops made
/// the audio 226 ms <i>short</i> after the same bug had made it 224 ms long.
/// </summary>
public static long Advance(long framesProduced, int frames, bool wrote)
=> framesProduced + (wrote ? frames : 0);
}
+118 -35
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using ytLive.Helpers;
using ytLive.Services;
@@ -34,6 +35,9 @@ public sealed class AudioMixer : IDisposable
private readonly TimeSpan _mixInterval;
private readonly Func<int>? _syncOffsetMs;
private readonly AudioSyncDelay _syncDelay;
private readonly AudioSyncDelay _micSyncDelay;
private readonly Func<int>? _micSyncOffsetMs;
private float[]? _delayedMic;
private int _advanceSamplesRemaining;
private bool _started;
@@ -97,7 +101,8 @@ public sealed class AudioMixer : IDisposable
Func<double>? micGain = null,
Func<double>? loopbackGain = null,
Func<int>? syncOffsetMs = null,
TimeSpan? mixInterval = null)
TimeSpan? mixInterval = null,
Func<int>? micSyncOffsetMs = null)
{
_mic = mic;
_loopback = loopback;
@@ -108,20 +113,28 @@ public sealed class AudioMixer : IDisposable
_loopbackGain = loopbackGain;
_syncOffsetMs = syncOffsetMs;
_syncDelay = new AudioSyncDelay(OutputSampleRate);
// The microphone alone, mono: a camera's frames reach the compositor later than the mic's
// samples reach the pipe, so the voice has to wait for the picture. Loopback and alert audio
// are not tied to a camera frame and must not move with it.
_micSyncOffsetMs = micSyncOffsetMs;
_micSyncDelay = new AudioSyncDelay(OutputSampleRate, channels: 1);
_mixInterval = mixInterval ?? DefaultMixInterval;
var seconds = _mixInterval.TotalSeconds;
var framesPerTick = Math.Max(1, (int)Math.Round(OutputSampleRate * seconds));
// A tick normally owes ~10 ms; after a stall it may owe more, and the clock-based loop pays it
// off in one write. Buffers therefore size to the largest chunk the planner can return.
var maxChunkFrames = Math.Max(framesPerTick, OutputSampleRate / 5);
_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];
_micChunk = new float[maxChunkFrames];
_loopbackChunk = new float[maxChunkFrames * 2];
_alertChunk = new float[maxChunkFrames * 2];
_mixBuffer = new float[maxChunkFrames * 2];
_delayedMix = new float[maxChunkFrames * 2];
_mic.Started += OnMicStarted;
_mic.SampleReady += OnMicSample;
@@ -222,7 +235,9 @@ public sealed class AudioMixer : IDisposable
_liveCts = cts;
_pipe = new NamedPipeAudioWriter();
_pipe.Start(pipeName);
_log?.Invoke($"Audio live: started, pipe '{pipeName}'");
// Prove in the log which mic delay is actually in force: the value lives in the layout DB and a
// preset click is invisible in a recording.
_log?.Invoke($"Audio live: started, pipe '{pipeName}', mic delay {_micSyncOffsetMs?.Invoke() ?? 0} ms");
_ = Task.Run(() => LiveLoopAsync(_pipe, cts.Token));
}
@@ -283,6 +298,25 @@ public sealed class AudioMixer : IDisposable
LoopbackLevelChanged?.Invoke(level);
}
/// <summary>Drops a MEDIA clip's audio into the desktop/game ring (TASK 21's audio slice).
/// It joins that group on purpose: the desktop fader and the mic auto-ducker then apply
/// exactly as they do to game audio and TRAX, so a BRB bed ducks under the creator's
/// voice without a second audio knob. The layer's own level/mute are applied upstream
/// by <see cref="MediaVideoSourceManager"/>.</summary>
public void EnqueueDesktopMediaAudio(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);
_loopbackBuffer.Write(data);
var level = _loopbackMeter.Push(new AudioSample(data, OutputSampleRate, sample.Channels));
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
@@ -365,12 +399,44 @@ public sealed class AudioMixer : IDisposable
var mixBuffer = _mixBuffer!;
var micChunk = _micChunk!;
var loopbackChunk = _loopbackChunk!;
// The audio clock is the monotonic clock, not the tick count. A fixed chunk per iteration turns
// any scheduler error into a permanent A/V rate error (measured 2026-11-12: 2.1% fast, 224 ms
// of drift in ten seconds); asking the clock how much is owed and producing exactly that keeps
// the track the same length as the show.
// The clock runs from the moment the session opens, NOT from the first successful write. Starting
// it at connect made the track begin that much later than the picture: measured 2026-11-12, the
// writes began 160 ms after ffmpeg started reading and the audio stream came out 137 ms shorter
// than the video, so every word sat 160 ms late. Owed time is paid out of the capture ring when
// the pipe connects, which is the audio that was missed — real audio, not padding.
var clock = Stopwatch.StartNew();
long framesProduced = 0;
var maxChunkFrames = micChunk.Length;
while (!cancellationToken.IsCancellationRequested)
{
var nextTick = DateTime.UtcNow + _mixInterval;
try
{
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer);
// Nowhere for audio to go until ffmpeg is reading: wait without producing, so the debt
// stays owed and is paid from the ring the moment the pipe connects.
if (!pipe.IsConnected)
{
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
continue;
}
var frames = AudioClock.FramesDue(
clock.Elapsed.TotalSeconds, framesProduced, OutputSampleRate, maxChunkFrames);
if (frames <= 0)
{
// Nothing owed yet — sooner than the tick interval, so a short yield, not a spin.
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
continue;
}
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer, frames);
var samples = frames * 2;
// Negative offset: skip the armed |N| ms of stream head. The pipe
// writer tolerates a partial chunk (any float length is valid);
@@ -379,17 +445,19 @@ public sealed class AudioMixer : IDisposable
var writeFrom = 0;
if (_advanceSamplesRemaining > 0)
{
var drop = Math.Min(_advanceSamplesRemaining, mixBuffer.Length);
var drop = Math.Min(_advanceSamplesRemaining, samples);
_advanceSamplesRemaining -= drop;
writeFrom = drop;
}
if (writeFrom < mixBuffer.Length)
await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom), cancellationToken).ConfigureAwait(false);
var wrote = writeFrom >= samples
|| await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom, samples - writeFrom),
cancellationToken).ConfigureAwait(false);
framesProduced = AudioClock.Advance(framesProduced, frames, wrote);
_micDrainedTotal += micDrained;
_loopDrainedTotal += loopDrained;
for (var i = writeFrom; i < mixBuffer.Length; i++)
for (var i = writeFrom; i < samples; i++)
{
var abs = Math.Abs(mixBuffer[i]);
if (abs > _peakMix) _peakMix = abs;
@@ -422,7 +490,7 @@ public sealed class AudioMixer : IDisposable
if (now - _lastLoopErrorLogged >= TimeSpan.FromSeconds(5))
{
_lastLoopErrorLogged = now;
AppLog.Write(ex, "Audio live loop error");
AppLog.Write(ex, "Audio live loop error", AppLogErrorClass.Class2);
_log?.Invoke($"Audio live loop error: {ex.Message}");
}
}
@@ -447,17 +515,18 @@ public sealed class AudioMixer : IDisposable
/// 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)
private (float MicRms, int MicDrained, int LoopDrained) FillAndMix(
float[] micChunk, float[] loopbackChunk, float[] mix, int frames)
{
var micCount = _micBuffer.Read(micChunk, micChunk.Length);
var loopCount = _loopbackBuffer.Read(loopbackChunk, loopbackChunk.Length);
var micCount = _micBuffer.Read(micChunk, frames);
var loopCount = _loopbackBuffer.Read(loopbackChunk, frames * 2);
var alertChunk = _alertChunk!;
var alertCount = _alertBuffer.Read(alertChunk, alertChunk.Length);
var alertCount = _alertBuffer.Read(alertChunk, frames * 2);
float micRms = 0;
if (micCount == 0)
{
Array.Clear(micChunk, 0, micChunk.Length);
Array.Clear(micChunk, 0, frames);
}
else
{
@@ -465,56 +534,70 @@ public sealed class AudioMixer : IDisposable
for (var i = 0; i < micCount; i++)
sumSquares += micChunk[i] * micChunk[i];
micRms = (float)Math.Sqrt(sumSquares / micCount);
if (micCount < micChunk.Length)
Array.Clear(micChunk, micCount, micChunk.Length - micCount);
if (micCount < frames)
Array.Clear(micChunk, micCount, frames - micCount);
}
if (loopCount == 0)
{
Array.Clear(loopbackChunk, 0, loopbackChunk.Length);
Array.Clear(loopbackChunk, 0, frames * 2);
}
else if (loopCount < loopbackChunk.Length)
else if (loopCount < frames * 2)
{
Array.Clear(loopbackChunk, loopCount, loopbackChunk.Length - loopCount);
Array.Clear(loopbackChunk, loopCount, frames * 2 - loopCount);
}
if (alertCount == 0)
{
Array.Clear(alertChunk, 0, alertChunk.Length);
Array.Clear(alertChunk, 0, frames * 2);
}
else if (alertCount < alertChunk.Length)
else if (alertCount < frames * 2)
{
Array.Clear(alertChunk, alertCount, alertChunk.Length - alertCount);
Array.Clear(alertChunk, alertCount, frames * 2 - alertCount);
}
// Mic-only delay: applied to the source, before the mix, so the desktop and alert audio keep
// their timing. Reconfigured every tick like the mix line above (same 2026-09-12 trap: a
// reallocating Configure would discard the buffered voice every 10 ms).
var micDelayMs = _micSyncOffsetMs?.Invoke() ?? 0;
if (micDelayMs > 0)
{
_micSyncDelay.Configure(micDelayMs);
var delayedMic = _delayedMic;
if (delayedMic is null || delayedMic.Length < frames)
delayedMic = _delayedMic = new float[frames];
_micSyncDelay.Process(micChunk.AsSpan(0, frames), delayedMic.AsSpan(0, frames));
Array.Copy(delayedMic, micChunk, frames);
}
var duck = _ducker.Update(micRms);
var micGain = (float)(_micGain?.Invoke() ?? 1.0);
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
var frames = micChunk.Length;
for (var i = 0; i < frames; i++)
{
var m = micChunk[i] * micGain;
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);
var samples = frames * 2;
_masterLimiter.Process(mix.AsSpan(0, samples));
_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);
if (delayed is null || delayed.Length < samples)
delayed = _delayedMix = new float[samples];
_syncDelay.Process(mix.AsSpan(0, samples), delayed.AsSpan(0, samples));
Array.Copy(delayed, mix, samples);
if (MonoOutput) Helpers.AudioFold.ToMono(mix.AsSpan(0, mix.Length), 2);
MeasureMaster(mix);
if (MonoOutput) Helpers.AudioFold.ToMono(mix.AsSpan(0, samples), 2);
MeasureMaster(mix.AsSpan(0, samples));
return (micRms, micCount, loopCount);
}
/// <summary>One pass over the finished mix: 1024 samples per channel, so the cost is noise
/// beside the mixer it sits behind.</summary>
private void MeasureMaster(float[] mix)
private void MeasureMaster(ReadOnlySpan<float> mix)
{
double left = 0, right = 0;
var frames = mix.Length / 2;
+15 -10
View File
@@ -3,8 +3,8 @@ 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
/// TASK 22: a fixed end-to-end audio delay applied either to the interleaved
/// stereo mix or to one mono source 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
@@ -20,14 +20,19 @@ namespace ytLive.Services.Audio;
public sealed class AudioSyncDelay
{
private readonly int _sampleRate;
private readonly int _channels;
private float[] _buffer = Array.Empty<float>();
private int _delaySamples;
private int _writePos;
public AudioSyncDelay(int sampleRate)
/// <param name="channels">Samples per frame in the chunks passed to <see cref="Process"/>: 2 for
/// the interleaved stereo mix, 1 for a mono source such as the microphone. The offset is always
/// specified in milliseconds, so the frame count — not the sample count — is what stays constant.</param>
public AudioSyncDelay(int sampleRate, int channels = 2)
{
_sampleRate = Math.Max(1, sampleRate);
_channels = Math.Clamp(channels, 1, 2);
Configure(0);
}
@@ -45,13 +50,13 @@ public sealed class AudioSyncDelay
var delaySamples = Frames(delayMs);
if (delaySamples == _delaySamples && _buffer.Length > 0) return;
_delaySamples = delaySamples;
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
_buffer = new float[(_delaySamples + _sampleRate) * _channels + 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>
/// <summary>Delays one interleaved chunk (stereo or mono, per the constructor) 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)
@@ -87,8 +92,8 @@ public sealed class AudioSyncDelay
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;
// A frame is `_channels` samples, so the delay in buffer positions is
// the frame count times the channel count.
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * _channels;
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ public sealed class MasterLimiter
/// <summary>Scales the frame by the current gain. Gain drops instantly to
/// <c>ceiling/peak</c> when the frame exceeds the ceiling and recovers
/// toward unity slowly.</summary>
public void Process(float[] samples)
public void Process(Span<float> samples)
{
var peak = 0f;
foreach (var s in samples)
+7 -4
View File
@@ -23,8 +23,10 @@ public interface IAudioPipeWriter : IDisposable
/// 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);
/// <summary>Writes interleaved float samples. Returns false when nothing was written because the
/// client has not connected or the pipe is closed — the caller must not count that audio as
/// produced, or a start-up gap silently shortens the track.</summary>
Task<bool> WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
/// <summary>Closes the server end — ffmpeg sees EOF and ends the audio input.</summary>
void Stop();
@@ -65,7 +67,7 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
}
}
public async Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default)
public async Task<bool> WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default)
{
NamedPipeServerStream? pipe;
lock (_sync)
@@ -76,11 +78,12 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
{
if (pipe?.IsConnected == false || pipe == null)
DroppedWrites++;
return;
return false;
}
var bytes = MemoryMarshal.AsBytes(samples.Span).ToArray();
await pipe.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
return true;
}
public void Stop()
+33
View File
@@ -0,0 +1,33 @@
using System;
using System.Text.RegularExpressions;
namespace ytLive.Services;
/// <summary>
/// TASK 32·3 — naming the broadcast that resumes a dropped show. A resumed stream is a NEW broadcast
/// with a new VOD (YouTube cannot resume a completed one), so the title has to say so; otherwise the
/// channel grows two identically named videos and the creator has to explain it.
/// <para>Pure, because the interesting cases are the boring ones: a title already marked, a second drop
/// in the same session, and YouTube's 100-character limit.</para>
/// </summary>
public static class BroadcastTitle
{
public const int MaxLength = 100;
private static readonly Regex ResumeMarker =
new(@"\s*\(resumed(?: \d+)?\)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>The title for resume number <paramref name="resumeCount"/> (1 = first re-air).
/// Any existing marker is replaced rather than stacked, and the base is truncated so the whole
/// title fits YouTube's limit.</summary>
public static string ForResume(string? title, int resumeCount)
{
var baseTitle = ResumeMarker.Replace(title ?? string.Empty, string.Empty).Trim();
if (baseTitle.Length == 0) baseTitle = "LlamaCasty";
var marker = resumeCount <= 1 ? " (resumed)" : $" (resumed {Math.Max(2, resumeCount)})";
var room = MaxLength - marker.Length;
if (baseTitle.Length > room) baseTitle = baseTitle[..room].TrimEnd();
return baseTitle + marker;
}
}
+6 -3
View File
@@ -317,7 +317,8 @@ public sealed class CameraManager : IDisposable
if (suspects.Count > 0)
enriched += $" Other camera apps running: {string.Join(", ", suspects)}.";
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}");
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
CameraFailed?.Invoke(session.DeviceId, enriched);
_ = SafeStopAsync(session.Source);
}
@@ -335,7 +336,8 @@ public sealed class CameraManager : IDisposable
}
catch (Exception ex)
{
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}");
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
@@ -460,7 +462,8 @@ public sealed class CameraManager : IDisposable
catch (Exception ex)
{
lock (_gate) session.EffectWorkerRunning = false;
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}");
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
+7 -3
View File
@@ -1,6 +1,8 @@
using System.Net.Http;
using System.Text.Json;
using ytLive.Models;
using AppLogErrorClass = ytLive.Helpers.AppLogErrorClass;
using AppLogMessageType = ytLive.Helpers.AppLogMessageType;
using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
@@ -54,7 +56,8 @@ public class ChannelStatsService
// (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}");
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return null;
}
@@ -107,7 +110,8 @@ public class ChannelStatsService
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})",
AppLogMessageType.Error, AppLogErrorClass.Class2);
return 0;
}
@@ -129,4 +133,4 @@ public class ChannelStatsService
}
return count;
}
}
}
+159
View File
@@ -0,0 +1,159 @@
namespace ytLive.Services;
/// <summary>What a clip's background looks like, as far as keying goes.</summary>
public enum ChromaScreen
{
/// <summary>No usable backdrop — say nothing to the creator.</summary>
None,
Green,
Blue,
}
/// <summary>
/// TASK 64: "this clip was shot against a green screen". <c>Coverage</c> × <c>Tightness</c>:
/// how much of the frame is the colour, and how consistent that colour is. A painted screen
/// scores near 1; a lawn or a football pitch scores low because the greens disagree.
/// </summary>
public readonly record struct ChromaKeySuggestion(
ChromaScreen Screen,
string? ColorHex,
double Confidence)
{
/// <summary>The screen is uniform enough to key without the creator touching a slider.</summary>
public bool IsHighConfidence => Screen != ChromaScreen.None && Confidence >= HighConfidenceFloor;
public const double HighConfidenceFloor = 0.85;
public const double SuggestFloor = 0.65;
}
/// <summary>
/// TASK 64: finds a green or blue screen in a decoded frame — the "clueless creator" half of
/// the feature. Pure and allocation-light (it only samples an outer ring and a coarse grid), so
/// it runs on the probe frame without touching ffmpeg twice.
/// <para><b>It never decides anything.</b> A suggestion is offered to the creator; auto-apply is
/// an explicit setting and only fires at <see cref="ChromaKeySuggestion.IsHighConfidence"/>.
/// Wrongly keying a lawn is destructive, so "None" is a normal, expected answer.</para>
/// </summary>
public static class ChromaKeyDetector
{
// Hue windows in degrees. Green sits 75–165 (a real screen reads ~120–145), blue 185–265.
private const double GreenHueFrom = 75, GreenHueTo = 165;
private const double BlueHueFrom = 185, BlueHueTo = 265;
// A screen is saturated and not dark. These also exclude letterbox bars (near-black,
// no saturation) and blown-out highlights from the denominator, so black bars do not
// count against an otherwise perfect screen.
private const double MinSaturation = 0.35;
private const double MinValue = 0.15;
// No upper brightness bound ON PURPOSE (found on a real clip 2026-11-05): a clean painted
// screen is fully saturated, so its value sits at ~1.0 and a 0.98 ceiling rejected the
// greenest possible screen. White highlights are excluded by the SATURATION floor instead,
// which is the property that actually separates them from a screen.
// The screen's own pixels must agree with each other: a painted surface is flat, a lawn
// is not. This is what keeps "green field" from being keyed.
private const double HueTightness = 12.0;
/// <summary>Samples an outer ring (the subject sits in the middle, the screen fills the
/// edges) and reports what it saw.</summary>
public static ChromaKeySuggestion Detect(VideoFrame frame)
{
if (frame.Width < 8 || frame.Height < 8) return Empty;
var ring = Math.Max(2, (int)Math.Round(Math.Min(frame.Width, frame.Height) * 0.15));
var step = Math.Max(1, Math.Min(frame.Width, frame.Height) / 96);
var hues = new List<double>();
var sameHue = 0;
double hueSum = 0;
long rSum = 0, gSum = 0, bSum = 0;
var considered = 0;
var green = 0;
var blue = 0;
for (var y = 0; y < frame.Height; y += step)
{
var inVerticalRing = y < ring || y >= frame.Height - ring;
for (var x = 0; x < frame.Width; x += step)
{
if (!inVerticalRing && x >= ring && x < frame.Width - ring) continue;
var i = y * frame.Stride + x * 4;
var b = frame.BgraPixels[i];
var g = frame.BgraPixels[i + 1];
var r = frame.BgraPixels[i + 2];
if (frame.BgraPixels[i + 3] < 8) continue; // transparent: not the backdrop
var (hue, saturation, value) = ToHsv(r, g, b);
if (saturation < MinSaturation || value < MinValue) continue;
considered++;
if (hue is >= GreenHueFrom and <= GreenHueTo) green++;
else if (hue is >= BlueHueFrom and <= BlueHueTo) blue++;
else continue;
hues.Add(hue);
hueSum += hue;
rSum += r;
gSum += g;
bSum += b;
}
}
if (considered < 64) return Empty;
var screen = green >= blue
? (green == 0 ? ChromaScreen.None : ChromaScreen.Green)
: ChromaScreen.Blue;
if (screen == ChromaScreen.None) return Empty;
var accepted = hues.Count;
var coverage = (double)accepted / considered;
// Tint stability: how many of the accepted pixels sit within ±HueTightness of the
// median hue. A painted screen ≈ 1.0; grass, foliage and sunlight ≈ far less.
hues.Sort();
var median = hues[hues.Count / 2];
foreach (var hue in hues)
{
var delta = Math.Abs(hue - median);
if (delta > 180) delta = 360 - delta;
if (delta <= HueTightness) sameHue++;
}
var tightness = (double)sameHue / accepted;
var confidence = coverage * tightness;
if (confidence < ChromaKeySuggestion.SuggestFloor) return Empty;
// The keyed colour is the screen as it was actually shot, not a canonical green:
// similarity is a distance, so the median of the sampled pixels keys far better.
var keyed = Math.Max(1, accepted);
var color = $"#{rSum / keyed:X2}{gSum / keyed:X2}{bSum / keyed:X2}";
return new ChromaKeySuggestion(screen, color, confidence);
}
private static readonly ChromaKeySuggestion Empty = new(ChromaScreen.None, null, 0);
private static (double Hue, double Saturation, double Value) ToHsv(byte r, byte g, byte b)
{
var rf = r / 255.0;
var gf = g / 255.0;
var bf = b / 255.0;
var max = Math.Max(rf, Math.Max(gf, bf));
var min = Math.Min(rf, Math.Min(gf, bf));
var delta = max - min;
double hue = 0;
if (delta > 1e-6)
{
if (max == rf) hue = 60 * (((gf - bf) / delta) % 6);
else if (max == gf) hue = 60 * (((bf - rf) / delta) + 2);
else hue = 60 * (((rf - gf) / delta) + 4);
}
if (hue < 0) hue += 360;
var saturation = max <= 0 ? 0 : delta / max;
return (hue, saturation, max);
}
}
+74
View File
@@ -0,0 +1,74 @@
using System.Diagnostics;
using System.IO;
using ytLive.Services.Encoder;
namespace ytLive.Services;
/// <summary>
/// TASK 64: reads ONE frame out of a video file so the app can look at its background before
/// the creator says anything. Kept behind an interface so tests never spawn ffmpeg, and separate
/// from the decode pipe because this is a read, not a playback session.
/// </summary>
public interface IChromaProbe
{
/// <summary>The file's first usable frame at a small size, or null when it cannot be read
/// (bad path, unsupported file, ffmpeg gave nothing). Null is a normal answer: the layer
/// simply gets no suggestion.</summary>
Task<VideoFrame?> ProbeAsync(string path, CancellationToken cancellationToken = default);
}
/// <summary>Default <see cref="IChromaProbe"/>: one ffmpeg pass, scaled to 160×90 so the look is
/// cheap and the detector sees plenty of the backdrop.</summary>
public sealed class FfmpegChromaProbe : IChromaProbe
{
private const int ProbeWidth = 160;
private const int ProbeHeight = 90;
private readonly IFfmpegLocator _locator;
private readonly Func<IDecodeProcess> _processFactory;
public FfmpegChromaProbe(IFfmpegLocator locator, Func<IDecodeProcess> processFactory)
{
_locator = locator;
_processFactory = processFactory;
}
public async Task<VideoFrame?> ProbeAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path)) return null;
try
{
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
var args =
$"-hide_banner -loglevel error -i \"{path}\" -frames:v 1 -an " +
$"-f rawvideo -pix_fmt bgra -vf scale={ProbeWidth}:{ProbeHeight} -";
using var process = _processFactory();
process.Start(new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
});
var frames = await Task.Run(() =>
{
using var buffer = new MemoryStream();
process.StandardOutput.CopyTo(buffer);
var reader = new RawVideoFrameReader(ProbeWidth, ProbeHeight);
var fed = reader.Feed(buffer.ToArray());
return fed;
}, cancellationToken).ConfigureAwait(false);
return frames.Count > 0 ? frames[0] : null;
}
catch (Exception ex)
{
Helpers.AppLog.Write($"ChromaProbe: '{Path.GetFileName(path)}' unreadable: {ex.Message}");
return null;
}
}
}
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace ytLive.Services;
/// <summary>
/// TASK 65: where clips live and how many survive.
///
/// <para><b>Not clip management.</b> There is no gallery, no naming, no per-clip decision. A clip is
/// written to <c>clips\</c>, the oldest is deleted when the next one lands, and the only knobs are the
/// length (10 or 15 s) and how many to keep (3, 4 or 5). Anything richer belongs to a video editor.</para>
/// </summary>
public sealed class ClipLibrary
{
public const string Extension = ".mp4";
public const string FilePrefix = "clip-";
private readonly Func<string> _folder;
public ClipLibrary(Func<string> folder) => _folder = folder;
/// <summary>Bytes the ring should hold for a length and bitrate, with 25% headroom for bitrate
/// movement. This is a memory cap, not a duration — the ring trims whole fragments.</summary>
public static long RingBytesFor(int seconds, int bitrateKbps)
=> Math.Max(1, seconds) * (long)bitrateKbps * 1000 / 8 * 5 / 4;
/// <summary>Writes a clip and prunes to <paramref name="keep"/>; returns the file path, or null when
/// there was nothing to write (an empty snapshot must never become a zero-byte file).</summary>
public string? Save(byte[] mp4Bytes, DateTime nowLocal, int keep)
{
if (mp4Bytes.Length == 0) return null;
try
{
var folder = EnsureFolder();
var path = Path.Combine(folder, $"{FilePrefix}{nowLocal:yyyyMMdd-HHmmss}{Extension}");
var unique = path;
for (var n = 2; File.Exists(unique); n++)
unique = Path.Combine(folder, $"{FilePrefix}{nowLocal:yyyyMMdd-HHmmss}-{n}{Extension}");
File.WriteAllBytes(unique, mp4Bytes);
Prune(keep);
return unique;
}
catch (Exception ex)
{
Helpers.AppLog.Write(ex, "Clip save failed");
return null;
}
}
/// <summary>Newest first. The replay layer's playlist is this list, so its order matters.</summary>
public IReadOnlyList<string> Recent()
{
try
{
var folder = EnsureFolder();
return Directory.EnumerateFiles(folder, FilePrefix + "*" + Extension)
.OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch (Exception ex)
{
Helpers.AppLog.Write(ex, "Clip list failed");
return Array.Empty<string>();
}
}
/// <summary>Deletes everything past the retention count. Missing folders are not an error.</summary>
public int Prune(int keep)
{
if (keep < 1) keep = 1;
var removed = 0;
foreach (var stale in Recent().Skip(keep))
{
try
{
File.Delete(stale);
removed++;
}
catch (Exception ex)
{
Helpers.AppLog.Write(ex, $"Clip prune failed: {stale}");
}
}
return removed;
}
/// <summary>The folder beside the recordings, created on demand.</summary>
public string EnsureFolder()
{
var folder = Path.Combine(_folder(), "clips");
Directory.CreateDirectory(folder);
return folder;
}
}
+91
View File
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
namespace ytLive.Services;
/// <summary>
/// TASK 65: the replay buffer's memory. ffmpeg writes fragmented MP4 to a pipe; the app keeps the last
/// N seconds of those fragments here and hands them back whole.
///
/// <para><b>Why encoded bytes and not frames:</b> a 1080p BGRA composite frame is 8.3 MB, so fifteen
/// seconds at 60 fps is ~7.4 GB of raw frames. The 15 MB figure is the compressed stream, and only that
/// fits in RAM. <b>Why whole fragments:</b> a fragment is the smallest thing that can be replayed, so the
/// cap is a byte cap and the clip may be slightly shorter than the requested seconds — never corrupt.</para>
/// </summary>
public sealed class ClipRing
{
private readonly long _capBytes;
private readonly Queue<byte[]> _fragments = new();
private byte[]? _init;
private long _bytes;
public ClipRing(long capBytes)
{
if (capBytes <= 0) throw new ArgumentOutOfRangeException(nameof(capBytes));
_capBytes = capBytes;
}
/// <summary>The stream's initialisation segment (ftyp + moov). Every replayable file needs it, and
/// it does not rotate — it is written once per encoder start.</summary>
public void SetInit(byte[] init)
{
_init = init ?? throw new ArgumentNullException(nameof(init));
RecomputeBytes();
Trim();
}
public void Append(byte[] fragment)
{
if (fragment is null || fragment.Length == 0) return;
_fragments.Enqueue(fragment);
_bytes += fragment.Length;
Trim();
}
public long Bytes => _bytes;
public int FragmentCount => _fragments.Count;
public bool IsEmpty => _init is null || _fragments.Count == 0;
/// <summary>The bytes of a playable clip, init first. Empty array when nothing has been buffered —
/// the caller must not write a zero-byte file (a failed save is better than a file that won't open).</summary>
public byte[] Snapshot()
{
if (IsEmpty) return Array.Empty<byte>();
var total = _init!.Length;
foreach (var fragment in _fragments) total += fragment.Length;
var output = new byte[total];
var offset = 0;
Buffer.BlockCopy(_init, 0, output, offset, _init.Length);
offset += _init.Length;
foreach (var fragment in _fragments)
{
Buffer.BlockCopy(fragment, 0, output, offset, fragment.Length);
offset += fragment.Length;
}
return output;
}
public void Clear()
{
_fragments.Clear();
_init = null;
_bytes = 0;
}
private void RecomputeBytes()
{
_bytes = _init?.Length ?? 0;
foreach (var fragment in _fragments) _bytes += fragment.Length;
}
/// <summary>Drop oldest fragments until the ring fits. Newest data always wins: a clip that ends at
/// the moment the creator pressed the key is the whole point.</summary>
private void Trim()
{
while (_fragments.Count > 0 && _bytes > _capBytes)
{
_bytes -= _fragments.Dequeue().Length;
}
}
}
+10 -1
View File
@@ -34,6 +34,15 @@ public static class AlertTickerRenderer
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
public const double FlashHalfSeconds = 0.5;
public static VideoFrame? Render(Ticker ticker, string? text, double elapsedSeconds,
double alertSeconds, int readsPerAlert, int width, int height)
{
if (ticker == null) throw new ArgumentNullException(nameof(ticker));
return ticker.IsEnabled
? Render(text, elapsedSeconds, ticker.DisplayMethod, alertSeconds, readsPerAlert, width, height)
: null;
}
private static readonly object Gate = new();
private static readonly Dictionary<string, VideoFrame> StripCache = new();
@@ -179,4 +188,4 @@ public static class AlertTickerRenderer
}
return new VideoFrame(pillW, Height, pixels);
}
}
}
+5 -5
View File
@@ -1,11 +1,11 @@
namespace ytLive.Services.Compositor;
/// <summary>
/// Where the compositor draws from (the active quality tier's output rect over the
/// 1920×1080 master) and at what size. 16:9 tiers = the full master 1:1; the vertical
/// 9:16 tier = the centered 607×1080 crop scaled up to 1080×1920. The source rect is
/// integer-aligned — MainViewModel's <c>OutputRectX</c> can be 656.5, so the caller
/// rounds before building these options.
/// Where the compositor draws from (the active quality tier's crop over the
/// 1920×1080 master) and the size of the BGRA frame it sends to the encoder. The
/// compositor frame is the crop itself; FFmpeg scales it to the selected output
/// dimensions. The source rect is integer-aligned — MainViewModel's
/// <c>OutputRectX</c> can be 656.5, so the caller rounds before building these options.
/// </summary>
public sealed class CompositorOptions
{
+4 -7
View File
@@ -6,10 +6,11 @@ using ytLive.Models;
namespace ytLive.Services.Compositor;
/// <summary>
/// The output compositor: renders a scene into the encoder's master frame (tightly-packed
/// The output compositor: renders a scene into the encoder's raw input frame (tightly-packed
/// BGRA8 <see cref="VideoFrame"/>) exactly as the XAML preview renders it, minus the
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). The preview stays the
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). FFmpeg scales that frame
/// to the selected output dimensions. The preview stays the editing view; this is the output
/// view — see TASKS.md "Ship step 1 — Scene compositor".
///
/// Frame sources are supplied by a <see cref="Func{SceneElement, VideoFrame}"/> resolver
/// (the caller maps webcam → DeviceId, images → AssetId, background → CaptureKey), keeping
@@ -163,7 +164,6 @@ public sealed class SceneCompositor
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement { IsZoomActive: true }:
continue;
}
@@ -186,7 +186,6 @@ public sealed class SceneCompositor
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
// ⛔ The brand logo is drawn AFTER the blur group (creator ruling 2026-11-01):
// the blur is universal and must never hide the brand.
case Source { IsBrandLogo: true }:
@@ -310,7 +309,6 @@ public sealed class SceneCompositor
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement:
continue;
}
@@ -369,7 +367,6 @@ public sealed class SceneCompositor
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
// ⛔ The brand logo is drawn AFTER the blur group (creator ruling 2026-11-01):
// the blur is universal and must never hide the brand.
case Source { IsBrandLogo: true }:
+51
View File
@@ -0,0 +1,51 @@
using System;
namespace ytLive.Services;
/// <summary>
/// TASK 55·3: a recording that dies of a full disk is a trust breach, not a support ticket. The guard is
/// a pure threshold policy so the numbers are visible in one place and testable without a drive.
///
/// <para>Thresholds chosen 2026-11-10 for a 1080p stream at 8 Mbps (~3.6 GB/hour): refusing to start
/// under <b>1 GB</b> keeps the creator from beginning something that cannot finish; a warning under
/// <b>5 GB</b> says "you have about eighty minutes" while there is still time to act; and one mid-show
/// warning at <b>2 GB</b> is the last useful moment before the encoder fails for a reason it will not
/// explain.</para>
/// </summary>
public static class DiskSpaceGuard
{
public const long BlockStartBytes = 1L * 1024 * 1024 * 1024;
public const long WarnStartBytes = 5L * 1024 * 1024 * 1024;
public const long WarnDuringBytes = 2L * 1024 * 1024 * 1024;
public enum StartDecision
{
Ok,
Warn,
Block,
}
public static StartDecision ForStart(long freeBytes)
=> freeBytes < BlockStartBytes ? StartDecision.Block
: freeBytes < WarnStartBytes ? StartDecision.Warn
: StartDecision.Ok;
/// <summary>One warning per session, once the drive falls below the mid-recording threshold.</summary>
public static bool ShouldWarnDuring(long freeBytes) => freeBytes < WarnDuringBytes;
/// <summary>Free bytes on the drive that holds <paramref name="folder"/>, or null when it cannot be
/// read (a removable drive that just went away must not block a recording that is already running).</summary>
public static long? FreeBytes(string folder)
{
try
{
var root = System.IO.Path.GetPathRoot(System.IO.Path.GetFullPath(folder));
if (string.IsNullOrEmpty(root)) return null;
return new System.IO.DriveInfo(root).AvailableFreeSpace;
}
catch
{
return null;
}
}
}
+13
View File
@@ -14,8 +14,16 @@ public sealed class EncoderOptions
public const string DefaultAudioPipeName = "ytllive_audio";
public string RtmpUrl { get; init; } = string.Empty;
/// <summary>Final encoded output width and height.</summary>
public int Width { get; init; } = 1920;
public int Height { get; init; } = 1080;
/// <summary>Raw BGRA frame size from the compositor. Zero uses the final output size.</summary>
public int InputWidth { get; init; }
public int InputHeight { get; init; }
public int RawWidth => InputWidth > 0 ? InputWidth : Width;
public int RawHeight => InputHeight > 0 ? InputHeight : Height;
public int Fps { get; init; } = 60;
public int BitrateKbps { get; init; } = 8000;
@@ -42,6 +50,11 @@ public sealed class EncoderOptions
/// <summary>Name of the mixer's audio pipe (default <see cref="DefaultAudioPipeName"/>).</summary>
public string AudioPipeName { get; init; } = DefaultAudioPipeName;
/// <summary>TASK 65: when set, the encoder muxes a SECOND video output into this ring (fragmented
/// MP4 on stdout) for the replay buffer. Null = clips off, and the ffmpeg command line is exactly
/// what it was before the feature existed.</summary>
public Services.ClipRing? ClipBuffer { get; init; }
/// <summary>GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound.</summary>
public int GopSize => Fps * 4;
}
+44 -1
View File
@@ -19,7 +19,7 @@ public static class FfmpegArgs
"-hide_banner", "-loglevel", "info",
"-stats", "-stats_period", "0.5",
"-f", "rawvideo", "-pix_fmt", "bgra",
"-video_size", $"{options.Width}x{options.Height}",
"-video_size", $"{options.RawWidth}x{options.RawHeight}",
"-framerate", options.Fps.ToString(),
"-i", "pipe:0",
"-f", "f32le", "-ar", options.AudioSampleRate.ToString(),
@@ -43,9 +43,28 @@ public static class FfmpegArgs
AddVideoTags(args, options, videoEncoder);
args.AddRange(["-c:a", "aac",
"-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]);
// TASK 32·4: FRAGMENTED mp4. A plain MP4 writes its index (moov) at the end, so a
// power cut or a killed process leaves the video bytes on disk in a file nothing can
// open — the whole recording, not the tail. Fragmented writes the index first and
// flushes a playable fragment at every keyframe, so an interrupted file still plays up
// to the last fragment. Record block only: the FLV stream output has no index.
args.AddRange(["-movflags", "+frag_keyframe+empty_moov+default_base_moof"]);
args.AddRange(["-f", "mp4", options.RecordPath!]);
}
// TASK 65: the replay buffer is a third mux of the same encoded frames. Fragmented MP4 on
// stdout (stats already own stderr) — one fragment per second, so a clip can start anywhere
// inside the chosen window instead of at the last 4-second keyframe.
if (options.ClipBuffer != null)
{
AddVideoTags(args, options, videoEncoder);
args.AddRange(["-c:a", "aac",
"-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]);
args.AddRange(["-movflags", "+frag_keyframe+empty_moov+default_base_moof"]);
args.AddRange(["-frag_duration", "1000000"]);
args.AddRange(["-f", "mp4", "pipe:1"]);
}
return args;
}
@@ -53,10 +72,34 @@ public static class FfmpegArgs
{
var gop = options.GopSize;
args.AddRange(["-map", "0:v", "-map", "1:a"]);
if (options.RawWidth != options.Width || options.RawHeight != options.Height)
{
args.AddRange(["-vf", $"scale={options.Width}:{options.Height}:flags=bilinear,setsar=1"]);
}
args.AddRange(["-c:v", videoEncoder]);
AddRateControl(args, videoEncoder);
args.AddRange(["-b:v", $"{options.BitrateKbps}k", "-maxrate", $"{options.BitrateKbps}k",
"-bufsize", $"{options.BitrateKbps * 2}k"]);
args.AddRange(["-g", gop.ToString(), "-keyint_min", gop.ToString(),
"-sc_threshold", "0", "-bf", "0", "-pix_fmt", "yuv420p"]);
}
/// <summary>
/// Constant bitrate, spelled the one way each encoder understands.
///
/// <para><b>Why this is not a shared flag:</b> the <c>-b:v/-maxrate/-bufsize</c> triad is capped VBR,
/// not CBR — measured 2026-11-12 on the bundled BtbN build, a static frame at <c>-b:v 2000k</c>
/// produced <b>9 kbps</b> with NVENC's default rate control and <b>1951 kbps</b> with <c>-rc cbr</c>.
/// That is the gap behind an @8 stream that averaged 5.1 Mbps.</para>
///
/// <para>Per encoder: NVENC and AMF take an explicit mode. QSV has no <c>-rc</c> and already selects
/// CBR when <c>bitrate == maxrate</c> (<c>libavcodec/qsvenc.c</c>, <c>select_rc_mode</c>). OpenH264's
/// <c>-rc_mode bitrate</c> made no measurable difference on this build, so it is deliberately not
/// passed. YouTube's encoder spec requires CBR (support.google.com/youtube/answer/2853702).</para>
/// </summary>
private static void AddRateControl(List<string> args, string videoEncoder)
{
if (videoEncoder is "h264_nvenc" or "h264_amf")
args.AddRange(["-rc", "cbr"]);
}
}
+40 -10
View File
@@ -29,6 +29,9 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
private IEncoderProcess? _process;
private EncoderOptions? _options;
private StreamHealth _health = new() { Status = StreamStatus.Offline };
/// <inheritdoc/>
public string? VideoEncoderName { get; private set; }
private Task? _stderrLoop;
private bool _stopRequested;
@@ -82,6 +85,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
VideoEncoderName = encoder;
var args = FfmpegArgs.Build(options, encoder);
var startInfo = new ProcessStartInfo
@@ -103,7 +107,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess", AppLogErrorClass.Class2);
lock (_gate) _options = null;
throw;
}
@@ -118,6 +122,11 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
_stopRequested = false;
_stderrLoop = RunStderrLoopAsync(process);
StartDrainLoop(process);
// TASK 65: the clip mux is a side channel. It reads its own thread, never blocks the frame
// path, and a fault disables clips instead of touching the stream.
if (options.ClipBuffer is { } clipRing)
_ = Task.Run(() => ReadClipOutputAsync(process, clipRing));
}
/// <summary>
@@ -175,7 +184,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop");
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop", AppLogErrorClass.Class2);
}
try
@@ -186,12 +195,13 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (OperationCanceledException)
{
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing",
AppLogMessageType.Error, AppLogErrorClass.Class2);
process!.Kill();
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed", AppLogErrorClass.Class2);
process!.Kill();
}
@@ -201,7 +211,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop", AppLogErrorClass.Class2);
}
process.Dispose();
@@ -238,6 +248,24 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
/// 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 static async Task ReadClipOutputAsync(IEncoderProcess process, Services.ClipRing ring)
{
var splitter = new Fmp4FragmentSplitter(ring);
var buffer = new byte[64 * 1024];
try
{
var stdout = process.StandardOutputBytes;
int read;
while ((read = await stdout.ReadAsync(buffer).ConfigureAwait(false)) > 0)
splitter.Write(buffer.AsSpan(0, read));
}
catch (Exception ex)
{
// Clips stop; the broadcast does not.
AppLog.Write(ex, "Clip buffer reader failed — replay clips disabled for this session");
}
}
private void StartDrainLoop(IEncoderProcess process)
{
_drainTask = Task.Run(async () =>
@@ -263,7 +291,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted");
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted", AppLogErrorClass.Class2);
}
finally
{
@@ -274,7 +302,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted");
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted", AppLogErrorClass.Class2);
}
});
}
@@ -303,7 +331,8 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
AppLog.Write($"FFmpeg encoder: encoder probe failed — falling back to software: {ex}",
AppLogMessageType.Warning);
return FfmpegEncoderPicker.Preference[^1];
}
}
@@ -347,14 +376,15 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
// 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})");
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_health.LastError = $"FFmpeg exited with code {exitCode}";
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {exitCode}");
}
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted", AppLogErrorClass.Class2);
}
});
}
+2
View File
@@ -21,6 +21,8 @@ public sealed class FfmpegEncoderProcess : IEncoderProcess
}
public Stream StandardInput => _process.StandardInput.BaseStream;
public Stream StandardOutputBytes => _process.StandardOutput.BaseStream;
public TextReader StandardOutput => _process.StandardOutput;
public TextReader StandardError => _process.StandardError;
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace ytLive.Services.Encoder;
/// <summary>
/// TASK 65: turns the fragmented-MP4 byte stream ffmpeg writes to stdout into a playable ring.
///
/// <para>MP4 boxes are length-prefixed, so this needs no muxer cooperation: <c>ftyp</c>+<c>moov</c> are
/// the initialisation segment, and each <c>moof</c>+<c>mdat</c> pair is one fragment. The ring only ever
/// sees whole fragments, which is what makes a snapshot playable and a trim safe.</para>
///
/// <para>Deliberately forgiving: an unparseable header clears the buffer and waits for the next one. A
/// clip that fails must never take the encoder down with it — the stream is running through the same
/// process.</para>
/// </summary>
public sealed class Fmp4FragmentSplitter
{
private readonly ClipRing _ring;
private readonly List<byte> _pending = new(capacity: 64 * 1024);
private readonly MemoryStream _init = new();
private readonly MemoryStream _fragment = new();
private bool _inFragment;
public Fmp4FragmentSplitter(ClipRing ring) => _ring = ring;
public void Write(ReadOnlySpan<byte> data)
{
foreach (var b in data) _pending.Add(b);
Drain();
}
private void Drain()
{
while (true)
{
if (_pending.Count < 8) return;
long size = ((long)_pending[0] << 24) | ((long)_pending[1] << 16) | ((long)_pending[2] << 8) | _pending[3];
var type = Encoding.ASCII.GetString(_pending.GetRange(4, 4).ToArray());
var header = 8;
if (size == 1)
{
if (_pending.Count < 16) return;
size = 0;
for (var i = 8; i < 16; i++) size = (size << 8) | _pending[i];
header = 16;
}
if (size < header || size > int.MaxValue)
{
// Not a box boundary any more; drop what we have and resync on the next write.
_pending.Clear();
return;
}
if (_pending.Count < size) return;
var box = _pending.GetRange(0, (int)size).ToArray();
_pending.RemoveRange(0, (int)size);
Route(type, box);
}
}
private void Route(string type, byte[] box)
{
switch (type)
{
case "ftyp":
_init.Write(box);
break;
case "moov":
_init.Write(box);
_ring.SetInit(_init.ToArray()); // init is complete once moov lands
break;
case "moof":
FlushFragment();
_fragment.Write(box);
_inFragment = true;
break;
case "mdat":
if (!_inFragment) return; // mdat without a moof is not replayable
_fragment.Write(box);
FlushFragment();
break;
default:
if (_inFragment) _fragment.Write(box);
break;
}
}
private void FlushFragment()
{
if (_inFragment && _fragment.Length > 0)
_ring.Append(_fragment.ToArray());
_fragment.SetLength(0);
_inFragment = false;
}
}
+61 -11
View File
@@ -10,7 +10,7 @@ namespace ytLive.Services.Encoder;
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
/// managers + compositor and the encoder. While live it snapshots the active
/// scene each tick, resolves every element to its latest frame, composites it
/// into the tier's output frame, and paces frames into the encoder at the tier's
/// into the tier's cropped input frame, and paces frames into the encoder at the tier's
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
/// the capture managers and is fully hermetic in tests.
@@ -29,7 +29,7 @@ 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<Scene, VideoFrame?>? _alertTicker;
private readonly Func<VideoFrame?>? _brandFlash;
private readonly TransitionService? _transition;
private VideoFrame? _lastOutput;
@@ -91,6 +91,10 @@ public sealed class FramePump : IDisposable
private readonly object _gate = new();
private IFfmpegEncoder? _encoder;
/// <summary>The encoder this session is running, or null before the first start. The view model
/// files each broadcast's rate history under it.</summary>
public string? EncoderName => _encoder?.VideoEncoderName;
private CancellationTokenSource? _cts;
private Task? _pumpTask;
private bool _started;
@@ -129,6 +133,14 @@ public sealed class FramePump : IDisposable
/// <summary>Raised when the encoder cannot start or dies mid-stream. The pump stops itself.</summary>
public event EventHandler<string>? Failed;
/// <summary>TASK 32·1: the ENCODER died (the push dropped) — recoverable, unlike a compositor fault.
/// Raised before the pump stops, so the session can relaunch the push instead of ending.</summary>
public event EventHandler<string>? EncoderFailed;
/// <summary>TASK 33: the same 5-second window the stats line reports, structured — so a policy can
/// tell machine starvation from encoder backpressure without parsing prose.</summary>
public event EventHandler<FramePumpStats>? StatsReported;
public FramePump(
Func<Scene?> sceneProvider,
Func<SceneElement, VideoFrame?> frameResolver,
@@ -138,7 +150,7 @@ 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<Scene, VideoFrame?>? alertTicker = null,
Func<VideoFrame?>? brandFlash = null,
TransitionService? transition = null,
SceneGraph? sceneGraph = null)
@@ -240,6 +252,28 @@ public sealed class FramePump : IDisposable
}
}
/// <summary>TASK 33: relaunch the pump and encoder at a new tier, over the same URL and named audio pipe.
///
/// <para>Full stop + start rather than an encoder swap, deliberately: <see cref="StopAsync"/> awaits
/// the loop and flushes the encoder, so no in-flight submit can land on a disposed process. A bare
/// swap races exactly that and would fault the loop — which ends the broadcast, the one thing a
/// quality step-down must never do. The audio pipe must be closed before the pump stops and freshly
/// opened before it starts again; otherwise ffmpeg waits on the still-open audio input, then the new
/// process tries to attach to a broken server. The cost is a short freeze, already accepted by the ladder.</para>
/// </summary>
public async Task<bool> ChangeEncoderAsync(Action stopAudio, Action startAudio,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stopAudio);
ArgumentNullException.ThrowIfNull(startAudio);
stopAudio();
await StopAsync(cancellationToken);
if (cancellationToken.IsCancellationRequested) return false;
startAudio();
await StartAsync(cancellationToken);
return IsRunning;
}
public async Task StopAsync(CancellationToken cancellationToken = default)
{
IFfmpegEncoder? encoder;
@@ -422,6 +456,21 @@ public sealed class FramePump : IDisposable
_log?.Invoke($"FramePump parts ({statRenders} renders/5s): {parts}");
_timings.Reset();
// Structured twin of the line above, raised before the counters reset. `dropped` is the
// ENCODER's count (ffmpeg blocked); a renderer that duplicates frames is already visible
// as render time + stalls, and FramesRendered is ambiguous by design (paste cache).
StatsReported?.Invoke(this, new FramePumpStats(
WindowSeconds: 5,
FramesSubmitted: statFrames,
FramesRendered: statRenders,
AvgRenderMs: statRenders > 0 ? renderTicks / freq * 1000 / statRenders : 0,
AvgSubmitMs: statFrames > 0 ? submitTicks / freq * 1000 / statFrames : 0,
AvgWaitMs: statRenders > 0 ? waitTicks / freq * 1000 / statRenders : 0,
WorstRenderMs: worstRender / freq * 1000,
WorstSubmitMs: worstSubmit / freq * 1000,
Dropped: dropped,
Stalls: stalls));
worstRender = 0;
worstSubmit = 0;
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
@@ -486,7 +535,7 @@ public sealed class FramePump : IDisposable
}
var sw = System.Diagnostics.Stopwatch.StartNew();
VideoFrame? tickerFrame = _alertTicker?.Invoke();
VideoFrame? tickerFrame = _alertTicker?.Invoke(scene);
tickerTicks += sw.ElapsedTicks;
sw.Restart();
VideoFrame? flashFrame = _brandFlash?.Invoke();
@@ -722,11 +771,9 @@ public sealed class FramePump : IDisposable
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver,
CompositeTimings? timings = null)
{
// 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.
// The cache stores the compositor frame at crop size; FFmpeg scales that frame
// to the final output dimensions. With product options, scratch and rendered
// frames are therefore the same size, including for vertical output.
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
&& options.SourceRectHeight == options.OutputHeight;
var signature = useCache
@@ -858,7 +905,8 @@ public sealed class FramePump : IDisposable
}
else if (element is Source { Type: SourceType.TextOverlay })
{
h = Mix(h, 2UL); // skipped kind — never resolved by the compositor
h = Mix(h, 2UL); // static text is resolved from its raster-on-change frame cache
if (element.IsVisible) frame = resolver(element);
}
else if (element is Source { Type: SourceType.Background })
{
@@ -970,7 +1018,9 @@ public sealed class FramePump : IDisposable
private void OnProcessFailed(object? sender, string message)
{
_log?.Invoke($"FramePump: encoder process failed: {message}");
Failed?.Invoke(this, message);
// A dead push is not a dead session: the broadcast may still be live, so this is the
// recoverable signal (TASK 32·1). Fatal pump faults still go through Failed.
EncoderFailed?.Invoke(this, message);
_ = StopAsync();
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace ytLive.Services.Encoder;
/// <summary>One 5-second window of pump and encoder evidence, structured.
///
/// <para>The stats line in the log is for humans; this is for the policy that has to decide whether
/// the machine or the connection is the problem. Note which counter is which:</para>
/// <list type="bullet">
/// <item><see cref="Stalls"/> and <see cref="AvgRenderMs"/> are the MACHINE's evidence — the pump
/// could not produce a frame in time.</item>
/// <item><see cref="Dropped"/> is the ENCODER's — frames submitted but not accepted downstream. It
/// rises when ffmpeg is blocked, which under CBR means the socket, not the renderer.</item>
/// <item><see cref="FramesRendered"/> is deliberately NOT used for classification: a static scene
/// legitimately reuses the paste cache and renders few frames while completely healthy.</item>
/// </list>
/// </summary>
public readonly record struct FramePumpStats(
double WindowSeconds,
int FramesSubmitted,
int FramesRendered,
double AvgRenderMs,
double AvgSubmitMs,
double AvgWaitMs,
double WorstRenderMs,
double WorstSubmitMs,
int Dropped,
int Stalls);
+4
View File
@@ -17,6 +17,10 @@ public interface IEncoderProcess : IDisposable
Stream StandardInput { get; }
TextReader StandardOutput { get; }
/// <summary>TASK 65: the same stdout as raw bytes — the replay-buffer mux writes fragmented MP4
/// here while <c>-stats</c> keeps stderr for itself.</summary>
Stream StandardOutputBytes { get; }
TextReader StandardError { get; }
bool HasExited { get; }
+5
View File
@@ -19,6 +19,11 @@ public interface IFfmpegEncoder : IDisposable
/// <summary>Fires when the subprocess dies unexpectedly (non-zero exit while live).</summary>
event EventHandler<string>? ProcessFailed;
/// <summary>Which encoder the probe chose for this session ("h264_nvenc", "libopenh264", …). Null
/// before the first start. The rate history is keyed by it: evidence about one encoder says nothing
/// about another.</summary>
string? VideoEncoderName { get; }
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
Task StopAsync(CancellationToken cancellationToken = default);
+8 -5
View File
@@ -1,13 +1,16 @@
namespace ytLive.Services.Entitlement;
/// <summary>
/// Test double for the entitlement rail. Production never uses it — a fake that could be reached
/// in a shipped build is a licence bypass, so it lives here only because tests need to drive both
/// answers without a Steam client or a network.
/// Development/test ownership result. Release builds exclude this type.
/// </summary>
public sealed class FakeEntitlement : IEntitlement
{
public FakeEntitlement(bool isPro = false) => IsPro = isPro;
public FakeEntitlement(bool isAppOwned = false, string? errorMessage = null)
{
IsAppOwned = isAppOwned;
ErrorMessage = errorMessage;
}
public bool IsPro { get; set; }
public bool IsAppOwned { get; set; }
public string? ErrorMessage { get; set; }
}
+6 -11
View File
@@ -1,19 +1,14 @@
namespace ytLive.Services.Entitlement;
/// <summary>
/// Where "is this copy licensed for Pro output?" comes from.
/// Whether the current Steam account owns the paid app.
///
/// <para><b>One question, two sources.</b> The lifetime SKU is a Steam DLC; the monthly is a Polar
/// lease. Both answer this interface, and everything downstream — the output profile, the greyed
/// tier list, the notices — reads only this. Nothing outside <c>Services/Entitlement</c> may know
/// which rail proved it.</para>
///
/// <para><b>Fail closed.</b> An implementation that cannot reach its source, cannot load its
/// native library, or has no product id configured returns <c>false</c>. Pro is never a default,
/// never a cached "probably", and never the result of an exception being swallowed as true.</para>
/// <para>Steam is the source of truth. Failure to reach Steam, load its native library, or
/// resolve the paid app ID returns <c>false</c>.</para>
/// </summary>
public interface IEntitlement
{
/// <summary>True when this session may emit above the free output budget.</summary>
bool IsPro { get; }
bool IsAppOwned { get; }
string? ErrorMessage { get; }
}
+54 -31
View File
@@ -3,41 +3,58 @@ using Steamworks;
namespace ytLive.Services.Entitlement;
/// <summary>
/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client.
///
/// <para><b>This is the whole purchase story.</b> There is no key, no server and no lease — the
/// customer buys the DLC on the store page and Steam already knows. The app asks once and caches
/// the answer; a refunded DLC stops reporting ownership the next time the app starts.</para>
///
/// <para><b>⛔ Never sell the monthly plan through this.</b> Steamworks requires in-app purchases
/// to use the Steam Wallet, so anything sold from inside the app would be a policy violation. The
/// monthly lives on the website and is billed by Polar (<see cref="PolarEntitlement"/> when it
/// lands); this class only ever reads what Valve already sold.</para>
///
/// <para><b>Steam absent is normal, not exceptional.</b> A copied folder, a shortcut run without
/// the client, or a CI machine has no Steam — <see cref="SteamAPI.Init"/> returns false and the
/// app runs as the free tier. It must never refuse to start over an entitlement lookup.</para>
/// Reads ownership of the paid app from Steam. In the free Demo, this checks the associated paid
/// app ID; no key, server, or LlamaCasty account is involved. A missing ID, Steam client, or native
/// library fails closed so the app can continue with the free output profile.
/// </summary>
public sealed class SteamEntitlement : IEntitlement
{
/// <summary>The DLC's own app id, not the base app's. Zero = not configured yet, which reads
/// as "no entitlement" rather than "try anyway".</summary>
private readonly uint _dlcAppId;
private readonly uint _paidAppId;
private readonly Func<bool> _initializeSteam;
private readonly Func<uint, bool> _isAppSubscribed;
private readonly Action _shutdownSteam;
private bool _initAttempted;
private bool _available;
private bool? _cached;
private string? _errorMessage;
public SteamEntitlement(uint dlcAppId) => _dlcAppId = dlcAppId;
public SteamEntitlement(uint paidAppId)
: this(
paidAppId,
SteamAPI.Init,
appId => SteamApps.BIsSubscribedApp(new AppId_t(appId)),
SteamAPI.Shutdown)
{
}
/// <summary>Configured with no product id — the state before the Steamworks app exists.</summary>
internal SteamEntitlement(
uint paidAppId,
Func<bool> initializeSteam,
Func<uint, bool> isAppSubscribed,
Action shutdownSteam)
{
_paidAppId = paidAppId;
_initializeSteam = initializeSteam;
_isAppSubscribed = isAppSubscribed;
_shutdownSteam = shutdownSteam;
}
/// <summary>Configured with no paid app ID; ownership fails closed without touching Steam.</summary>
public static SteamEntitlement NotConfigured() => new(0);
public bool IsPro
public string? ErrorMessage => _errorMessage;
public bool IsAppOwned
{
get
{
if (_dlcAppId == 0) return false;
if (_paidAppId == 0)
{
_cached = false;
_errorMessage = "Steam licensing is not configured in this build.";
return false;
}
if (_cached is { } known) return known;
if (!_initAttempted)
@@ -45,38 +62,44 @@ public sealed class SteamEntitlement : IEntitlement
_initAttempted = true;
try
{
// No steam_appid.txt and no client → false. That is the documented free path.
_available = SteamAPI.Init();
_available = _initializeSteam();
}
catch (Exception)
catch (Exception ex)
{
// A missing steam_api64.dll throws on first touch. Free tier, never a crash.
_available = false;
_errorMessage = $"Steam could not start ({ex.Message}). Open Steam, sign in, then restart LlamaCasty.";
}
}
if (!_available) { _cached = false; return false; }
if (!_available)
{
_cached = false;
_errorMessage ??= "Steam could not verify ownership. Open Steam, sign in, then restart LlamaCasty.";
return false;
}
try
{
var owned = SteamApps.BIsDlcInstalled(new AppId_t(_dlcAppId));
var owned = _isAppSubscribed(_paidAppId);
_cached = owned;
_errorMessage = null;
return owned;
}
catch (Exception)
catch (Exception ex)
{
_cached = false;
_errorMessage = $"Steam could not check ownership ({ex.Message}). Open Steam, sign in, then restart LlamaCasty.";
return false;
}
}
}
/// <summary>Matches Steam's own lifecycle callbacks; called from the frame pump's pump thread
/// would be wrong, so nothing here schedules it automatically.</summary>
public void Shutdown()
{
if (_available) SteamAPI.Shutdown();
if (_available) _shutdownSteam();
_available = false;
_cached = null;
_initAttempted = false;
_errorMessage = null;
}
}
+3
View File
@@ -16,6 +16,8 @@ public enum HotkeyId
ToggleMicMute = 7,
ToggleGameMute = 8,
TraxPlayPause = 9,
/// <summary>TASK 65: snapshot the replay ring and save it as a clip.</summary>
SaveClip = 10,
}
/// <summary>Registers/revokes the OS-level hotkeys. Seam so tests never touch
@@ -65,6 +67,7 @@ public sealed class GlobalHotkeyManager
[HotkeyId.ToggleMicMute] = new HotkeyChord(0, 0x76), // F7
[HotkeyId.ToggleGameMute] = new HotkeyChord(0, 0x77), // F8
[HotkeyId.TraxPlayPause] = new HotkeyChord(0, 0x78), // F9
[HotkeyId.SaveClip] = new HotkeyChord(0, 0x79), // F10
};
private readonly IHotkeyRegistrar _registrar;
+7
View File
@@ -1,3 +1,5 @@
using ytLive.Services.Audio;
namespace ytLive.Services;
/// <summary>
@@ -17,6 +19,11 @@ public interface IMediaFrameSource : IDisposable
/// <summary>Raised (on a worker thread) for each decoded frame.</summary>
event Action<VideoFrame>? FrameAvailable;
/// <summary>Raised (on a worker thread) per chunk of the file's own soundtrack
/// (48 kHz stereo float). The manager applies the layer's level/mute and hands it
/// to the mixer; a source with no audio simply never raises it.</summary>
event Action<AudioSample>? AudioAvailable;
/// <summary>Raised when the file finishes decoding (natural EOF) — not raised
/// while <see cref="Looping"/> is true.</summary>
event Action? Completed;
+76
View File
@@ -0,0 +1,76 @@
using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ytLive.Helpers;
namespace ytLive.Services;
/// <summary>
/// TASK 32·5: can we actually reach YouTube's ingest right now? One TCP connect, nothing else.
/// <para>This is prevention, not recovery: "I forgot the VPN" used to produce a broadcast that YouTube
/// created and nobody ever saw — the app looked live while the push had nowhere to go. Checking the
/// socket before the API call means the failure is a message, not a ghost broadcast.</para>
/// </summary>
public interface IIngestProbe
{
/// <summary>True when the ingest host accepted a TCP connection. False on any failure — the
/// point is a fast, unambiguous answer, not a diagnosis.</summary>
Task<bool> CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default);
}
/// <summary>Default probe: parse the RTMP URL, connect to the host on its port (1935 for rtmp) with a
/// short timeout. Failures are logged and reported as "not reachable".</summary>
public sealed class TcpIngestProbe : IIngestProbe
{
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2);
private readonly TimeSpan _timeout;
public TcpIngestProbe(TimeSpan? timeout = null) => _timeout = timeout ?? DefaultTimeout;
public async Task<bool> CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default)
{
if (!TryParse(rtmpUrl, out var host, out var port))
{
AppLog.Write($"Ingest probe: '{rtmpUrl}' is not a usable RTMP URL");
return false;
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_timeout);
try
{
using var client = new TcpClient();
await client.ConnectAsync(host, port, timeoutCts.Token).ConfigureAwait(false);
return client.Connected;
}
catch (OperationCanceledException)
{
AppLog.Write($"Ingest probe: {host}:{port} did not answer within {_timeout.TotalSeconds:0.#} s");
return false;
}
catch (Exception ex)
{
AppLog.Write($"Ingest probe: {host}:{port} unreachable: {ex.Message}");
return false;
}
}
/// <summary>rtmp://a.rtmp.youtube.com/live2/key → (a.rtmp.youtube.com, 1935).
/// rtmps uses 443, which is what YouTube's secure ingest listens on.</summary>
internal static bool TryParse(string? rtmpUrl, out string host, out int port)
{
host = string.Empty;
port = 1935;
if (string.IsNullOrWhiteSpace(rtmpUrl)) return false;
if (!Uri.TryCreate(rtmpUrl, UriKind.Absolute, out var uri)) return false;
if (string.IsNullOrWhiteSpace(uri.Host)) return false;
host = uri.Host;
port = uri.IsDefaultPort
? (uri.Scheme.Equals("rtmps", StringComparison.OrdinalIgnoreCase) ? 443 : 1935)
: uri.Port;
return true;
}
}
+14
View File
@@ -35,6 +35,20 @@ public partial class LayoutStore : IDisposable
return (string)cmd.ExecuteScalar()!;
}
/// <summary>True when the asset already holds exactly these bytes. Used to decide whether a
/// bundled image needs re-seeding: comparing bytes cannot go stale the way a version constant
/// can (the constant and the embedded resource were built at different moments twice).</summary>
public bool AssetMatches(string assetId, byte[] data)
{
if (string.IsNullOrWhiteSpace(assetId)) return false;
var hash = Convert.ToHexString(SHA256.HashData(data));
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT 1 FROM Asset WHERE Id = $id AND Hash = $hash;";
cmd.Parameters.AddWithValue("$id", assetId);
cmd.Parameters.AddWithValue("$hash", hash);
return cmd.ExecuteScalar() != null;
}
// ─── License (TASK 10 — Polar billing) ───
/// <summary>The saved license key, or null if never entered.</summary>
+35 -2
View File
@@ -16,7 +16,7 @@ public partial class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId FROM Scene ORDER BY SortOrder";
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SceneMutesMic, SceneMutesDesktop FROM Scene ORDER BY SortOrder";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
@@ -29,6 +29,8 @@ public partial class LayoutStore : IDisposable
HasBackground = reader.GetInt32(4) != 0,
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
ThumbnailAssetId = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetString(6) : null,
SceneMutesMic = reader.FieldCount > 7 && !reader.IsDBNull(7) ? reader.GetInt32(7) != 0 : null,
SceneMutesDesktop = reader.FieldCount > 8 && !reader.IsDBNull(8) ? reader.GetInt32(8) != 0 : null,
});
}
}
@@ -111,7 +113,11 @@ public partial class LayoutStore : IDisposable
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted,
ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness,
ChromaKeySpill, ChromaScreen, IsReplayLayer
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -167,6 +173,33 @@ public partial class LayoutStore : IDisposable
BlurAmount = reader.IsDBNull(reader.GetOrdinal("BlurAmount")) ? 0.5 : reader.GetDouble(reader.GetOrdinal("BlurAmount")),
AlertAudioPath = reader.IsDBNull(reader.GetOrdinal("AlertAudioPath")) ? null : reader.GetString(reader.GetOrdinal("AlertAudioPath")),
AlertAudioLoop = reader.GetInt32(reader.GetOrdinal("AlertAudioLoop")) != 0,
TextContent = reader.IsDBNull(41) ? "Text" : reader.GetString(41),
TextFontFamily = reader.IsDBNull(42) ? "Segoe UI" : reader.GetString(42),
TextFontSize = reader.IsDBNull(43) ? 48 : reader.GetDouble(43),
TextColor = reader.IsDBNull(44) ? "#FFFFFF" : reader.GetString(44),
TextBold = !reader.IsDBNull(45) && reader.GetInt32(45) != 0,
TextItalic = !reader.IsDBNull(46) && reader.GetInt32(46) != 0,
TextUnderline = !reader.IsDBNull(47) && reader.GetInt32(47) != 0,
TextAlignment = reader.IsDBNull(48) || !Enum.IsDefined((TextLayerAlignment)reader.GetInt32(48))
? TextLayerAlignment.Left
: (TextLayerAlignment)reader.GetInt32(48),
TextVerticalAlignment = reader.IsDBNull(49)
|| !Enum.IsDefined((TextLayerVerticalAlignment)reader.GetInt32(49))
? TextLayerVerticalAlignment.Top
: (TextLayerVerticalAlignment)reader.GetInt32(49),
MediaMuted = reader.GetInt32(reader.GetOrdinal("MediaMuted")) != 0,
ChromaKeyEnabled = reader.GetInt32(reader.GetOrdinal("ChromaKeyEnabled")) != 0,
ChromaKeyColor = reader.IsDBNull(reader.GetOrdinal("ChromaKeyColor"))
? "#00B140"
: reader.GetString(reader.GetOrdinal("ChromaKeyColor")),
ChromaKeySimilarity = reader.GetDouble(reader.GetOrdinal("ChromaKeySimilarity")),
ChromaKeySoftness = reader.GetDouble(reader.GetOrdinal("ChromaKeySoftness")),
ChromaKeySpill = reader.GetInt32(reader.GetOrdinal("ChromaKeySpill")) != 0,
ChromaScreen = reader.IsDBNull(reader.GetOrdinal("ChromaScreen"))
? ChromaScreen.Green
: (ChromaScreen)reader.GetInt32(reader.GetOrdinal("ChromaScreen")),
IsReplayLayer = !reader.IsDBNull(reader.GetOrdinal("IsReplayLayer"))
&& reader.GetInt32(reader.GetOrdinal("IsReplayLayer")) != 0,
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>();
+147
View File
@@ -16,6 +16,8 @@ public partial class LayoutStore : IDisposable
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
HasBackground INTEGER NOT NULL DEFAULT 1,
SceneMutesMic INTEGER NULL,
SceneMutesDesktop INTEGER NULL,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
@@ -46,6 +48,7 @@ public partial class LayoutStore : IDisposable
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
IsBackground INTEGER NOT NULL DEFAULT 0,
IsReplayLayer INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0
);
@@ -139,10 +142,12 @@ public partial class LayoutStore : IDisposable
MigrateSceneTable();
MigrateSceneSocialBarColumn();
MigrateSceneThumbnailColumn();
MigrateSceneAudioColumns();
MigrateSocialsTable();
MigrateSocialEntryTable();
if (GetUserVersion() < 3)
MigrateToV3();
EnsureLayoutSnapshotSchema();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 10;";
@@ -154,6 +159,40 @@ public partial class LayoutStore : IDisposable
// 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.
/// <summary>TASK 35: scene-linked audio. NULL means "leave the mix alone", so a scene is passive
/// until the creator says otherwise — including every layout written before this column existed.</summary>
private void MigrateSceneAudioColumns()
{
var sceneColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Scene);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
sceneColumns.Add(reader.GetString(1));
}
if (!sceneColumns.Contains("SceneMutesMic"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN SceneMutesMic INTEGER NULL;";
cmd.ExecuteNonQuery();
}
if (!sceneColumns.Contains("SceneMutesDesktop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN SceneMutesDesktop INTEGER NULL;";
cmd.ExecuteNonQuery();
}
// BRB ships muted (creator spec), applied only where the creator has never expressed an opinion.
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "UPDATE Scene SET SceneMutesMic = 1 WHERE SceneMutesMic IS NULL AND LOWER(TRIM(Name)) = 'brb';";
cmd.ExecuteNonQuery();
}
}
private void MigrateSourceTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -283,6 +322,14 @@ public partial class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
cmd.ExecuteNonQuery();
}
// TASK 65: a media layer that plays the replay clips instead of a chosen file.
if (!columns.Contains("IsReplayLayer"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsReplayLayer INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ShowDesktop"))
{
using var cmd = _connection.CreateCommand();
@@ -290,6 +337,62 @@ public partial class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
// TASK 3.17: independent static text-layer content and formatting.
if (!columns.Contains("TextContent"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextContent TEXT NOT NULL DEFAULT 'Text';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("TextFontFamily"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontFamily TEXT NOT NULL DEFAULT 'Segoe UI';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("TextFontSize"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontSize REAL NOT NULL DEFAULT 48;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("TextColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextColor TEXT NOT NULL DEFAULT '#FFFFFF';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("TextBold"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextBold INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("TextItalic"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextItalic INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("TextUnderline"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextUnderline INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("TextAlignment"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextAlignment INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("TextVerticalAlignment"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextVerticalAlignment INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("WebUri"))
{
using var cmd = _connection.CreateCommand();
@@ -322,6 +425,50 @@ public partial class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaMuted"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaMuted INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
// TASK 64: green/blue screen settings per layer (applied in the decode pipe).
if (!columns.Contains("ChromaKeyEnabled"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeyEnabled INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChromaKeyColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeyColor TEXT NOT NULL DEFAULT '#00B140';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChromaKeySimilarity"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySimilarity REAL NOT NULL DEFAULT 0.12;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChromaKeySoftness"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySoftness REAL NOT NULL DEFAULT 0.08;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChromaKeySpill"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySpill INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChromaScreen"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaScreen INTEGER NOT NULL DEFAULT 0;";
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,
+62 -4
View File
@@ -54,8 +54,10 @@ public partial class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail, $sort)
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId,
SceneMutesMic, SceneMutesDesktop, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail,
$mutesMic, $mutesDesktop, $sort)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -65,6 +67,8 @@ public partial class LayoutStore : IDisposable
var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
var thumbnailP = cmd.Parameters.Add("$thumbnail", SqliteType.Text);
var mutesMicP = cmd.Parameters.Add("$mutesMic", SqliteType.Integer);
var mutesDesktopP = cmd.Parameters.Add("$mutesDesktop", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var sort = 0;
@@ -77,6 +81,12 @@ public partial class LayoutStore : IDisposable
backgroundP.Value = scene.HasBackground ? 1 : 0;
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
thumbnailP.Value = (object?)scene.ThumbnailAssetId ?? DBNull.Value;
mutesMicP.Value = scene.SceneMutesMic.HasValue
? (object)(scene.SceneMutesMic.Value ? 1 : 0)
: DBNull.Value;
mutesDesktopP.Value = scene.SceneMutesDesktop.HasValue
? (object)(scene.SceneMutesDesktop.Value ? 1 : 0)
: DBNull.Value;
sortP.Value = sort++;
cmd.ExecuteNonQuery();
}
@@ -92,7 +102,11 @@ public partial class LayoutStore : IDisposable
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop)
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted,
ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity,
ChromaKeySoftness, ChromaKeySpill, ChromaScreen, IsReplayLayer)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
@@ -100,7 +114,11 @@ public partial class LayoutStore : IDisposable
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop)
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
$textUnderline, $textAlignment, $textVerticalAlignment, $mediaMuted,
$chromaEnabled, $chromaColor, $chromaSimilarity, $chromaSoftness,
$chromaSpill, $chromaScreen, $isReplayLayer)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -133,6 +151,14 @@ public partial class LayoutStore : IDisposable
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 mediaMutedP = cmd.Parameters.Add("$mediaMuted", SqliteType.Integer);
var chromaEnabledP = cmd.Parameters.Add("$chromaEnabled", SqliteType.Integer);
var chromaColorP = cmd.Parameters.Add("$chromaColor", SqliteType.Text);
var chromaSimilarityP = cmd.Parameters.Add("$chromaSimilarity", SqliteType.Real);
var chromaSoftnessP = cmd.Parameters.Add("$chromaSoftness", SqliteType.Real);
var chromaSpillP = cmd.Parameters.Add("$chromaSpill", SqliteType.Integer);
var chromaScreenP = cmd.Parameters.Add("$chromaScreen", SqliteType.Integer);
var replayLayerP = cmd.Parameters.Add("$isReplayLayer", SqliteType.Integer);
var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
var alertVideoAssetIdP = cmd.Parameters.Add("$alertVideoAssetId", SqliteType.Text);
var alertVideoPathP = cmd.Parameters.Add("$alertVideoPath", SqliteType.Text);
@@ -144,6 +170,15 @@ public partial class LayoutStore : IDisposable
cmd.Parameters.Add("$blurAmount", SqliteType.Real);
var alertAudioPathP = cmd.Parameters.Add("$alertAudioPath", SqliteType.Text);
var alertAudioLoopP = cmd.Parameters.Add("$alertAudioLoop", SqliteType.Integer);
var textContentP = cmd.Parameters.Add("$textContent", SqliteType.Text);
var textFontFamilyP = cmd.Parameters.Add("$textFontFamily", SqliteType.Text);
var textFontSizeP = cmd.Parameters.Add("$textFontSize", SqliteType.Real);
var textColorP = cmd.Parameters.Add("$textColor", SqliteType.Text);
var textBoldP = cmd.Parameters.Add("$textBold", SqliteType.Integer);
var textItalicP = cmd.Parameters.Add("$textItalic", SqliteType.Integer);
var textUnderlineP = cmd.Parameters.Add("$textUnderline", SqliteType.Integer);
var textAlignmentP = cmd.Parameters.Add("$textAlignment", SqliteType.Integer);
var textVerticalAlignmentP = cmd.Parameters.Add("$textVerticalAlignment", SqliteType.Integer);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
@@ -185,6 +220,14 @@ public partial class LayoutStore : IDisposable
mediaPathP.Value = (object?)source.MediaPath ?? DBNull.Value;
mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
mediaVolumeP.Value = source.MediaVolume;
mediaMutedP.Value = source.MediaMuted ? 1 : 0;
chromaEnabledP.Value = source.ChromaKeyEnabled ? 1 : 0;
chromaColorP.Value = source.ChromaKeyColor;
chromaSimilarityP.Value = source.ChromaKeySimilarity;
chromaSoftnessP.Value = source.ChromaKeySoftness;
chromaSpillP.Value = source.ChromaKeySpill ? 1 : 0;
chromaScreenP.Value = (int)source.ChromaScreen;
replayLayerP.Value = source.IsReplayLayer ? 1 : 0;
mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString();
alertVideoAssetIdP.Value = (object?)source.AlertVideoAssetId ?? DBNull.Value;
alertVideoPathP.Value = (object?)source.AlertVideoPath ?? DBNull.Value;
@@ -196,6 +239,15 @@ public partial class LayoutStore : IDisposable
cmd.Parameters["$blurAmount"].Value = source.BlurAmount;
alertAudioPathP.Value = (object?)source.AlertAudioPath ?? DBNull.Value;
alertAudioLoopP.Value = source.AlertAudioLoop ? 1 : 0;
textContentP.Value = source.TextContent;
textFontFamilyP.Value = source.TextFontFamily;
textFontSizeP.Value = source.TextFontSize;
textColorP.Value = source.TextColor;
textBoldP.Value = source.TextBold ? 1 : 0;
textItalicP.Value = source.TextItalic ? 1 : 0;
textUnderlineP.Value = source.TextUnderline ? 1 : 0;
textAlignmentP.Value = (int)source.TextAlignment;
textVerticalAlignmentP.Value = (int)source.TextVerticalAlignment;
cmd.ExecuteNonQuery();
}
}
@@ -382,10 +434,16 @@ public partial class LayoutStore : IDisposable
DELETE FROM Asset WHERE Id NOT IN (
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
UNION
SELECT AssetId FROM DefaultSource WHERE AssetId IS NOT NULL
UNION
SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL
UNION
SELECT ThumbnailAssetId FROM DefaultScene WHERE ThumbnailAssetId IS NOT NULL
UNION
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
UNION
SELECT AlertVideoAssetId FROM DefaultSource WHERE AlertVideoAssetId IS NOT NULL
UNION
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
UNION
SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId'
+142 -1
View File
@@ -1,11 +1,96 @@
using Microsoft.Data.Sqlite;
using Notification.Core;
using ytLive.Services.Compositor;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public NotificationPosition LoadNotificationPosition()
{
var value = GetSetting("Notifications.Position");
return Enum.TryParse(value, true, out NotificationPosition position) && IsSupportedNotificationPosition(position)
? position
: NotificationPosition.BottomRight;
}
public void SaveNotificationPosition(NotificationPosition position)
{
if (!IsSupportedNotificationPosition(position)) position = NotificationPosition.BottomRight;
UpsertSetting("Notifications.Position", position.ToString());
}
/// <summary>May editing be used while a session runs? Off by default: the creator's ruling
/// (2026-11-12) is that moving a layer or resizing a webcam mid-show is theirs to do. Turning it on
/// restores the old gun-safety — go-live flips EDIT off and stop flips it back.</summary>
public bool LoadLockEditWhileLive() => GetSetting("App.LockEditWhileLive") == "1";
public void SaveLockEditWhileLive(bool locked)
=> UpsertSetting("App.LockEditWhileLive", locked ? "1" : "0");
public bool LoadStickyAlertsUntilDismissed()
=> GetSetting("Notifications.StickyUntilDismissed") == "1";
public void SaveStickyAlertsUntilDismissed(bool enabled)
=> UpsertSetting("Notifications.StickyUntilDismissed", enabled ? "1" : "0");
/// <summary>The creator's own video rate for one tier, in Mbps, or null to use the shipped default.
/// Per tier because each has its own ceiling; the rate is a target the encoder is asked to hold,
/// so a stored number means the same thing on every run (CBR).</summary>
public double? LoadRatedMbps(string tierDisplay)
=> double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null;
/// <summary>Finished sessions, oldest first — the evidence behind any rate suggestion. Capped so the
/// row never grows without bound: 40 sessions is several weeks of streaming for anyone.</summary>
public List<SessionRateResult> LoadRateHistory()
{
var raw = GetSetting("Video.RateHistory");
if (string.IsNullOrWhiteSpace(raw)) return new List<SessionRateResult>();
return raw.Split('\n')
.Select(RateHistory.Parse)
.Where(entry => entry is not null)
.Select(entry => entry!.Value)
.ToList();
}
public void AppendRateHistory(SessionRateResult entry)
{
var history = LoadRateHistory();
history.Add(entry);
if (history.Count > RateHistoryCap)
history.RemoveRange(0, history.Count - RateHistoryCap);
UpsertSetting("Video.RateHistory", string.Join('\n', history.Select(RateHistory.Format)));
}
private const int RateHistoryCap = 40;
/// <summary>The proposed rate the creator already refused for this tier, or zero for none. Stored as the
/// number rather than a flag, so a materially different suggestion may still be asked.</summary>
public double LoadDismissedRateProposal(string tierDisplay)
=> double.TryParse(GetSetting($"Video.RateProposalDismissed.{tierDisplay}"), out var mbps) ? mbps : 0;
public void SaveDismissedRateProposal(string tierDisplay, double mbps)
=> UpsertSetting($"Video.RateProposalDismissed.{tierDisplay}", mbps.ToString("0.#"));
public void SaveRatedMbps(string tierDisplay, double? mbps)
{
if (mbps is not > 0)
{
using var delete = _connection.CreateCommand();
delete.CommandText = "DELETE FROM Settings WHERE Key = $key;";
delete.Parameters.AddWithValue("$key", $"Video.Rate.{tierDisplay}");
delete.ExecuteNonQuery();
return;
}
UpsertSetting($"Video.Rate.{tierDisplay}", mbps.Value.ToString("0.#"));
}
private static bool IsSupportedNotificationPosition(NotificationPosition position)
=> position is NotificationPosition.TopLeft or NotificationPosition.TopRight
or NotificationPosition.CenterLeft or NotificationPosition.CenterRight
or NotificationPosition.BottomLeft or NotificationPosition.BottomRight;
/// <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()
@@ -40,6 +125,26 @@ public partial class LayoutStore : IDisposable
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
}
/// <summary>
/// The shipped default for the microphone-only delay: **zero**. A sync offset is a correction for one
/// camera on one desk, never a product default — shipping a number would shift every creator's audio
/// to fix one machine's camera, and would hide whether the fault is theirs or ours.
/// </summary>
public const int DefaultMicSyncOffsetMs = 0;
/// <summary>Microphone-only sync delay (0..500 ms), so the voice can wait for the camera's picture
/// without moving desktop or alert audio. Absent means the shipped default.</summary>
public int LoadMicSyncOffsetMs()
{
var value = GetSetting("Audio.MicSyncOffsetMs");
return int.TryParse(value, out var ms)
? Math.Clamp(ms, 0, 500)
: DefaultMicSyncOffsetMs;
}
public void SaveMicSyncOffsetMs(int ms)
=> UpsertSetting("Audio.MicSyncOffsetMs", Math.Clamp(ms, 0, 500).ToString());
public void SaveAudioSyncOffsetMs(int ms)
{
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
@@ -48,6 +153,42 @@ public partial class LayoutStore : IDisposable
/// <summary>Zoom box follow mode (creator ruling 2026-11-01): false = ANCHOR (the capture
/// stays on the point that was clicked, the default), true = FOLLOW (the capture re-anchors
/// to the box centre while it is dragged).</summary>
/// <summary>TASK 64: key a detected green/blue screen without asking. Off by default — the
/// app offers, the creator accepts; this switch is for the creator who is tired of clicking.</summary>
/// <summary>Creator closed the Steam licence indicator while ownership was verified. Honoured only
/// while Steam keeps confirming ownership — losing it brings the indicator back (2026-11-05).</summary>
public bool LoadSteamLicenceIndicatorDismissed() => GetSetting("Steam.LicenceIndicatorDismissed") == "1";
public void SaveSteamLicenceIndicatorDismissed(bool dismissed)
=> UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0");
/// <summary>TASK 3·20: show money in the app's own analytics surfaces. OFF by default — most
/// creators never monetize, and a $0.00 report is a spit in the face (creator ruling 2026-11-05).</summary>
public bool LoadShowMonetaryMetrics() => GetSetting("Metrics.ShowMonetary") == "1";
public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0");
/// <summary>TASK 65: replay clips. Off by default — the ring costs memory and the feature is opt-in.</summary>
public bool LoadClipsEnabled() => GetSetting("Clips.Enabled") == "1";
public void SaveClipsEnabled(bool enabled) => UpsertSetting("Clips.Enabled", enabled ? "1" : "0");
/// <summary>Clip length in seconds: 10 or 15 (default 10).</summary>
public int LoadClipSeconds()
=> int.TryParse(GetSetting("Clips.Seconds"), out var s) && (s == 10 || s == 15) ? s : 10;
public void SaveClipSeconds(int seconds) => UpsertSetting("Clips.Seconds", seconds.ToString());
/// <summary>How many clips survive on disk: 3, 4 or 5 (default 3).</summary>
public int LoadClipKeep()
=> int.TryParse(GetSetting("Clips.Keep"), out var k) && k >= 3 && k <= 5 ? k : 3;
public void SaveClipKeep(int keep) => UpsertSetting("Clips.Keep", keep.ToString());
public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1";
public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0");
public bool LoadZoomFollowBox() => GetSetting("Zoom.FollowBox") == "1";
public void SaveZoomFollowBox(bool follow) => UpsertSetting("Zoom.FollowBox", follow ? "1" : "0");
+348
View File
@@ -0,0 +1,348 @@
using Microsoft.Data.Sqlite;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
private static readonly string[] SnapshotTables =
{
"Scene", "Source", "Webcam", "WebcamSceneConfig", "Socials", "SocialEntry", "Music",
};
private const string DefaultsInitializedKey = "DefaultsInitialized";
private const string CurrentBuildIdKey = "CurrentBuildId";
private void EnsureLayoutSnapshotSchema()
{
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS LayoutSnapshotState (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS DefaultLayoutSetting (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL
);
""";
cmd.ExecuteNonQuery();
}
foreach (var table in SnapshotTables)
{
var snapshotTable = "Default" + table;
using (var create = _connection.CreateCommand())
{
create.CommandText = $"CREATE TABLE IF NOT EXISTS {snapshotTable} AS SELECT * FROM {table} WHERE 0;";
create.ExecuteNonQuery();
}
var snapshotColumns = ReadColumnNames(snapshotTable);
foreach (var (name, type, defaultValue) in ReadColumnDefinitions(table))
{
if (snapshotColumns.Contains(name, StringComparer.OrdinalIgnoreCase)) continue;
var definition = string.IsNullOrWhiteSpace(type) ? "BLOB" : type;
if (defaultValue != null) definition += " DEFAULT " + defaultValue;
using var add = _connection.CreateCommand();
add.CommandText = $"ALTER TABLE {snapshotTable} ADD COLUMN {QuoteIdentifier(name)} {definition};";
add.ExecuteNonQuery();
}
}
}
/// <summary>Captures the installed layout once as the immutable branded default. Existing databases
/// are snapshotted before MainViewModel loads them; a fresh database calls
/// <see cref="CaptureDefaultsIfMissing"/> after its factory scenes have been seeded.</summary>
public void PrepareLayoutSnapshots(string buildId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(buildId);
var defaultsInitialized = GetSnapshotState(DefaultsInitializedKey) == "1";
if (!defaultsInitialized && HasStoredScenes())
{
CaptureDefaults();
defaultsInitialized = true;
}
var currentBuildId = GetSnapshotState(CurrentBuildIdKey);
if (defaultsInitialized && !string.Equals(currentBuildId, buildId, StringComparison.Ordinal))
RestoreDefaults(buildId);
else if (!defaultsInitialized && string.IsNullOrWhiteSpace(currentBuildId))
SetSnapshotState(CurrentBuildIdKey, buildId);
}
/// <summary>Seeds the default snapshot after the first-run factory layout has been persisted.</summary>
public void CaptureDefaultsIfMissing()
{
if (GetSnapshotState(DefaultsInitializedKey) != "1")
CaptureDefaults();
}
/// <summary>Restores one existing scene by its current ID. Unknown scenes have no branded default
/// and are left untouched.</summary>
public bool RestoreDefaultScene(string sceneId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sceneId);
string? defaultSceneId;
using (var find = _connection.CreateCommand())
{
find.CommandText = """
SELECT Id FROM DefaultScene
WHERE Name = (SELECT Name FROM Scene WHERE Id = $sceneId) COLLATE NOCASE
LIMIT 1;
""";
find.Parameters.AddWithValue("$sceneId", sceneId);
defaultSceneId = find.ExecuteScalar() as string;
}
if (defaultSceneId == null) return false;
var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames);
var webcamId = GetCurrentWebcamId();
using var tx = _connection.BeginTransaction();
using (var clearBackgroundSettings = _connection.CreateCommand())
{
clearBackgroundSettings.Transaction = tx;
clearBackgroundSettings.CommandText = """
DELETE FROM Settings
WHERE Key IN (
SELECT 'BackgroundPath_' || Id FROM Source WHERE SceneId = $sceneId
UNION SELECT 'BackgroundUseDefault_' || Id FROM Source WHERE SceneId = $sceneId
UNION SELECT 'BackgroundPath_' || $sceneId
UNION SELECT 'BackgroundUseDefault_' || $sceneId
);
""";
clearBackgroundSettings.Parameters.AddWithValue("$sceneId", sceneId);
clearBackgroundSettings.ExecuteNonQuery();
}
foreach (var table in new[] { "WebcamSceneConfig", "Source" })
{
using var delete = _connection.CreateCommand();
delete.Transaction = tx;
delete.CommandText = $"DELETE FROM {table} WHERE SceneId = $sceneId;";
delete.Parameters.AddWithValue("$sceneId", sceneId);
delete.ExecuteNonQuery();
}
var sceneColumns = columnsByTable["Scene"]
.Where(column => column != "Id" && column != "SortOrder").ToArray();
var assignments = string.Join(", ", sceneColumns.Select(column =>
$"{QuoteIdentifier(column)} = (SELECT {QuoteIdentifier(column)} FROM DefaultScene WHERE Id = $defaultSceneId)"));
using (var updateScene = _connection.CreateCommand())
{
updateScene.Transaction = tx;
updateScene.CommandText = $"UPDATE Scene SET {assignments} WHERE Id = $sceneId;";
updateScene.Parameters.AddWithValue("$sceneId", sceneId);
updateScene.Parameters.AddWithValue("$defaultSceneId", defaultSceneId);
updateScene.ExecuteNonQuery();
}
CopyDefaultSceneRows("Source", sceneId, defaultSceneId, null, tx, columnsByTable["Source"]);
if (webcamId != null)
CopyDefaultSceneRows("WebcamSceneConfig", sceneId, defaultSceneId, webcamId, tx,
columnsByTable["WebcamSceneConfig"]);
using (var copyBackgroundSettings = _connection.CreateCommand())
{
copyBackgroundSettings.Transaction = tx;
copyBackgroundSettings.CommandText = """
INSERT INTO Settings (Key, Value)
SELECT Key, Value FROM DefaultLayoutSetting
WHERE Key IN (
SELECT 'BackgroundPath_' || Id FROM DefaultSource WHERE SceneId = $defaultSceneId
UNION SELECT 'BackgroundUseDefault_' || Id FROM DefaultSource WHERE SceneId = $defaultSceneId
UNION SELECT 'BackgroundPath_' || $defaultSceneId
UNION SELECT 'BackgroundUseDefault_' || $defaultSceneId
);
""";
copyBackgroundSettings.Parameters.AddWithValue("$defaultSceneId", defaultSceneId);
copyBackgroundSettings.ExecuteNonQuery();
}
tx.Commit();
return true;
}
/// <summary>Restores the full factory snapshot without changing the active build stamp.</summary>
public bool RestoreAllDefaults()
{
if (GetSnapshotState(DefaultsInitializedKey) != "1") return false;
var buildId = GetSnapshotState(CurrentBuildIdKey);
if (string.IsNullOrWhiteSpace(buildId)) return false;
RestoreDefaults(buildId);
return true;
}
private string? GetCurrentWebcamId()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Id FROM Webcam LIMIT 1;";
return cmd.ExecuteScalar() as string;
}
private void CopyDefaultSceneRows(string table, string sceneId, string defaultSceneId,
string? webcamId, SqliteTransaction tx, IReadOnlyList<string> columns)
{
var columnList = string.Join(", ", columns.Select(QuoteIdentifier));
var values = string.Join(", ", columns.Select(column => column switch
{
"SceneId" => "$sceneId",
"WebcamId" when webcamId != null => "$webcamId",
_ => QuoteIdentifier(column),
}));
using var copy = _connection.CreateCommand();
copy.Transaction = tx;
copy.CommandText = $"INSERT INTO {table} ({columnList}) SELECT {values} FROM Default{table} WHERE SceneId = $defaultSceneId;";
copy.Parameters.AddWithValue("$sceneId", sceneId);
copy.Parameters.AddWithValue("$defaultSceneId", defaultSceneId);
if (webcamId != null) copy.Parameters.AddWithValue("$webcamId", webcamId);
copy.ExecuteNonQuery();
}
private void CaptureDefaults()
{
var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames);
using var tx = _connection.BeginTransaction();
foreach (var table in SnapshotTables.Reverse())
{
using var delete = _connection.CreateCommand();
delete.Transaction = tx;
delete.CommandText = $"DELETE FROM Default{table};";
delete.ExecuteNonQuery();
}
foreach (var table in SnapshotTables)
{
var columns = string.Join(", ", columnsByTable[table].Select(QuoteIdentifier));
using var copy = _connection.CreateCommand();
copy.Transaction = tx;
copy.CommandText = $"INSERT INTO Default{table} ({columns}) SELECT {columns} FROM {table};";
copy.ExecuteNonQuery();
}
using (var clearSettings = _connection.CreateCommand())
{
clearSettings.Transaction = tx;
clearSettings.CommandText = "DELETE FROM DefaultLayoutSetting;";
clearSettings.ExecuteNonQuery();
}
using (var copySettings = _connection.CreateCommand())
{
copySettings.Transaction = tx;
copySettings.CommandText = """
INSERT INTO DefaultLayoutSetting (Key, Value)
SELECT Key, Value FROM Settings
WHERE Key LIKE 'BackgroundPath_%' OR Key LIKE 'BackgroundUseDefault_%';
""";
copySettings.ExecuteNonQuery();
}
using (var state = _connection.CreateCommand())
{
state.Transaction = tx;
state.CommandText = """
INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, '1')
ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;
""";
state.Parameters.AddWithValue("$key", DefaultsInitializedKey);
state.ExecuteNonQuery();
}
tx.Commit();
}
private void RestoreDefaults(string buildId)
{
var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames);
var currentWebcamId = GetCurrentWebcamId();
using var tx = _connection.BeginTransaction();
foreach (var table in SnapshotTables.Reverse())
{
if (table == "Webcam") continue;
using var delete = _connection.CreateCommand();
delete.Transaction = tx;
delete.CommandText = $"DELETE FROM {table};";
delete.ExecuteNonQuery();
}
foreach (var table in SnapshotTables)
{
if (table == "Webcam" || (table == "WebcamSceneConfig" && currentWebcamId == null)) continue;
var columns = string.Join(", ", columnsByTable[table].Select(QuoteIdentifier));
var values = string.Join(", ", columnsByTable[table].Select(column =>
column == "WebcamId" && table == "WebcamSceneConfig"
? "$webcamId"
: QuoteIdentifier(column)));
using var copy = _connection.CreateCommand();
copy.Transaction = tx;
copy.CommandText = $"INSERT INTO {table} ({columns}) SELECT {values} FROM Default{table};";
if (table == "WebcamSceneConfig") copy.Parameters.AddWithValue("$webcamId", currentWebcamId!);
copy.ExecuteNonQuery();
}
using (var clearSettings = _connection.CreateCommand())
{
clearSettings.Transaction = tx;
clearSettings.CommandText = "DELETE FROM Settings WHERE Key LIKE 'BackgroundPath_%' OR Key LIKE 'BackgroundUseDefault_%';";
clearSettings.ExecuteNonQuery();
}
using (var copySettings = _connection.CreateCommand())
{
copySettings.Transaction = tx;
copySettings.CommandText = "INSERT INTO Settings (Key, Value) SELECT Key, Value FROM DefaultLayoutSetting;";
copySettings.ExecuteNonQuery();
}
SetSnapshotState(CurrentBuildIdKey, buildId, tx);
tx.Commit();
}
private bool HasStoredScenes()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT EXISTS(SELECT 1 FROM Scene LIMIT 1);";
return Convert.ToInt32(cmd.ExecuteScalar()) != 0;
}
private string? GetSnapshotState(string key)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM LayoutSnapshotState WHERE Key = $key;";
cmd.Parameters.AddWithValue("$key", key);
return cmd.ExecuteScalar() as string;
}
private void SetSnapshotState(string key, string value, SqliteTransaction? transaction = null)
{
using var cmd = _connection.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = """
INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, $value)
ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;
""";
cmd.Parameters.AddWithValue("$key", key);
cmd.Parameters.AddWithValue("$value", value);
cmd.ExecuteNonQuery();
}
private List<string> ReadColumnNames(string table)
=> ReadColumnDefinitions(table).Select(column => column.Name).ToList();
private List<(string Name, string Type, string? DefaultValue)> ReadColumnDefinitions(string table)
{
var columns = new List<(string, string, string?)>();
using var cmd = _connection.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({table});";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add((reader.GetString(1), reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
reader.IsDBNull(4) ? null : reader.GetString(4)));
return columns;
}
private static string QuoteIdentifier(string identifier)
=> "\"" + identifier.Replace("\"", "\"\"") + "\"";
}
+27 -116
View File
@@ -1,38 +1,35 @@
using System.Diagnostics;
using System.IO;
using ytLive.Services.Audio;
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.
/// <see cref="VideoFrame"/>s for a MediaSource. This is the <see cref="IMediaFrameSource"/>
/// shape over the shared <see cref="VideoClipPipe"/> — the same pipe an alert clip uses
/// (TASK 47), so the two features cannot drift apart again in how they decode.
/// <para>Video AND the file's own soundtrack are decoded. The manager scales the audio by
/// the layer's level/mute and hands it to the mixer's desktop group.</para>
/// <para><b>Keying (TASK 64) is a decode option</b>: pass <see cref="ChromaKeyOptions"/> and the
/// green/blue screen is removed inside ffmpeg's pipe, so the preview bitmap and the composited
/// frame both carry the keyed alpha. Two looks = two sources; identical looks share one.</para>
/// </summary>
public sealed class MediaVideoSource : IMediaFrameSource
{
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioAvailable;
public event Action? Completed;
public string Key => _path;
public bool Looping { get; set; }
public bool Looping
{
get => _clip.Looping;
set => _clip.Looping = value;
}
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;
private readonly VideoClipPipe _clip;
public MediaVideoSource(
string path,
@@ -41,107 +38,21 @@ public sealed class MediaVideoSource : IMediaFrameSource
IFfmpegLocator locator,
Func<IDecodeProcess> processFactory,
IFrameRateProbe? frameRateProbe = null,
Func<TimeSpan, CancellationToken, Task>? delay = null)
Func<TimeSpan, CancellationToken, Task>? delay = null,
ChromaKeyOptions? chromaKey = null)
{
_path = path;
_width = width;
_height = height;
_locator = locator;
_processFactory = processFactory;
_probe = frameRateProbe;
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
_clip = new VideoClipPipe(
path, width, height, locator, processFactory, frameRateProbe, delay,
audio: true, chromaKey: chromaKey);
_clip.FrameAvailable += frame => FrameAvailable?.Invoke(frame);
_clip.AudioAvailable += sample => AudioAvailable?.Invoke(sample);
_clip.Completed += () => Completed?.Invoke();
}
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 StartAsync(CancellationToken cancellationToken = default) => _clip.StartAsync(cancellationToken);
public Task StopAsync()
{
_cts.Cancel();
_run = null;
return Task.CompletedTask;
}
public Task StopAsync() => _clip.StopAsync();
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 */ }
}
public void Dispose() => _clip.Dispose();
}
+157 -5
View File
@@ -6,6 +6,7 @@ using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Services.Audio;
namespace ytLive.Services;
@@ -23,8 +24,13 @@ public sealed class MediaVideoSourceManager : IDisposable
public string Key { get; }
public IMediaFrameSource Source { get; }
public Action<VideoFrame>? FrameHandler;
public Action<AudioSample>? AudioHandler;
public Action? CompletedHandler;
public bool Finished;
public int RefCount;
public bool Started;
public bool AudioMuted;
public double AudioVolume = 1.0;
public WriteableBitmap? PreviewBitmap;
public VideoFrame? LatestFrame;
public bool FramePending;
@@ -37,8 +43,10 @@ public sealed class MediaVideoSourceManager : IDisposable
}
}
private readonly Func<string, IMediaFrameSource?> _sourceFactory;
// (file path, the layer's look) → decoder. The SESSION key is separate: path + look.
private readonly Func<string, ChromaKeyOptions?, IMediaFrameSource?> _sourceFactory;
private readonly Dispatcher? _uiDispatcher;
private readonly Func<string, bool>? _loopProvider;
private readonly Dictionary<string, MediaSession> _sessions = new();
private readonly object _gate = new();
@@ -48,14 +56,28 @@ public sealed class MediaVideoSourceManager : IDisposable
/// <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)
/// <summary>Raised (worker thread) per audio chunk of a file's own soundtrack, already
/// scaled by the session's level and dropped when muted.</summary>
public event Action<string, AudioSample>? AudioAvailable;
public MediaVideoSourceManager(
Func<string, ChromaKeyOptions?, IMediaFrameSource?> sourceFactory,
Dispatcher? uiDispatcher = null,
Func<string, bool>? loopProvider = null)
{
_sourceFactory = sourceFactory;
_uiDispatcher = uiDispatcher;
_loopProvider = loopProvider;
}
/// <summary>Increments the refcount for a file, starting the decoder the first time.</summary>
public async Task<bool> AcquireAsync(string key)
/// <summary>Increments the refcount for a file, starting the decoder the first time.
/// <paramref name="keyOptions"/> is the layer's look (TASK 64): the caller keys sessions by
/// path + look, so two looks on one file are two decoders and identical looks share one.</summary>
public async Task<bool> AcquireAsync(string key, ChromaKeyOptions? keyOptions = null)
=> await AcquireAsync(key, key, keyOptions).ConfigureAwait(false);
/// <summary>Acquires by session key, decoding <paramref name="path"/> with the given look.</summary>
public async Task<bool> AcquireAsync(string key, string path, ChromaKeyOptions? keyOptions = null)
{
if (string.IsNullOrWhiteSpace(key)) return false;
@@ -74,7 +96,7 @@ public sealed class MediaVideoSourceManager : IDisposable
IMediaFrameSource? source;
try
{
source = _sourceFactory(key);
source = _sourceFactory(path, keyOptions);
}
catch (Exception ex)
{
@@ -90,6 +112,14 @@ public sealed class MediaVideoSourceManager : IDisposable
session = new MediaSession(key, source);
session.FrameHandler = frame => OnFrameAvailable(session, frame);
session.Source.FrameAvailable += session.FrameHandler;
session.AudioHandler = sample => OnAudioAvailable(session, sample);
session.Source.AudioAvailable += session.AudioHandler;
session.CompletedHandler = () => OnSessionCompleted(session);
session.Source.Completed += session.CompletedHandler;
// Loop intent lives on the Source (per scene), so the caller supplies the
// per-path answer ("any live reference loops"). Applied before Start so a
// zero-length or instantly-EOF file cannot slip past a later assignment.
session.Source.Looping = _loopProvider?.Invoke(key) ?? false;
_sessions[key] = session;
shouldStart = true;
}
@@ -108,6 +138,8 @@ public sealed class MediaVideoSourceManager : IDisposable
lock (_gate)
_sessions.Remove(key);
session.Source.FrameAvailable -= session.FrameHandler;
if (session.AudioHandler != null) session.Source.AudioAvailable -= session.AudioHandler;
if (session.CompletedHandler != null) session.Source.Completed -= session.CompletedHandler;
AppLog.Write($"MediaSource: failed to start decoder '{key}': {ex.Message}");
MediaFailed?.Invoke(key, ex.Message);
await SafeStopAsync(session.Source);
@@ -128,6 +160,8 @@ public sealed class MediaVideoSourceManager : IDisposable
if (toStop == null) return;
toStop.Source.FrameAvailable -= toStop.FrameHandler;
if (toStop.AudioHandler != null) toStop.Source.AudioAvailable -= toStop.AudioHandler;
if (toStop.CompletedHandler != null) toStop.Source.Completed -= toStop.CompletedHandler;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
@@ -146,6 +180,8 @@ public sealed class MediaVideoSourceManager : IDisposable
if (toStop == null) return;
toStop.Source.FrameAvailable -= toStop.FrameHandler;
if (toStop.AudioHandler != null) toStop.Source.AudioAvailable -= toStop.AudioHandler;
if (toStop.CompletedHandler != null) toStop.Source.Completed -= toStop.CompletedHandler;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
@@ -158,6 +194,120 @@ public sealed class MediaVideoSourceManager : IDisposable
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
}
/// <summary>Raised when a file plays out (natural end, no loop) — the layer is then holding its
/// last frame and the creator can start it again.</summary>
public event Action<string>? SessionCompleted;
/// <summary>True while a decode session exists for the file — the caller uses this to
/// keep one acquire per path instead of stacking refcounts on every layout pass.</summary>
public bool HasSession(string key)
{
lock (_gate)
return _sessions.ContainsKey(key);
}
/// <summary>The files with a live session, for reconciling a layout load.</summary>
public IReadOnlyList<string> GetActiveKeys()
{
lock (_gate)
return new List<string>(_sessions.Keys);
}
/// <summary>Applies a loop change to a running session (the creator flipped the
/// toggle). No-op when the file has no session.</summary>
/// <summary>False once the file has played to the end. A finished session keeps its last frame
/// (and its refcount), so "play again" is a restart, not a re-acquire.</summary>
public bool IsRunning(string key)
{
lock (_gate)
return _sessions.TryGetValue(key, out var session) && session.Started && !session.Finished;
}
/// <summary>Restarts a finished file from the top (the Loop switch or "Play again"). The session is
/// kept, so the shared preview bitmap and the frame consumers stay valid.</summary>
public async Task<bool> RestartAsync(string key)
{
MediaSession? session;
lock (_gate)
{
if (!_sessions.TryGetValue(key, out session)) return false;
session.Finished = false;
session.Started = false;
}
try
{
await session.Source.StartAsync().ConfigureAwait(false);
session.Started = true;
return true;
}
catch (Exception ex)
{
AppLog.Write($"MediaSource: restarting '{key}' failed: {ex.Message}");
return false;
}
}
public void SetLooping(string key, bool looping)
{
lock (_gate)
{
if (_sessions.TryGetValue(key, out var session))
session.Source.Looping = looping;
}
}
/// <summary>The layer's sound controls for this file. One decoder is shared per path,
/// so the level/mute are per SESSION: whichever layer the creator last touched wins —
/// the alternative (one decoder per layer) would play the same file twice.</summary>
public void SetAudioSettings(string key, double volume, bool muted)
{
lock (_gate)
{
if (!_sessions.TryGetValue(key, out var session)) return;
session.AudioVolume = Math.Clamp(volume, 0, 1);
session.AudioMuted = muted;
}
}
private void OnSessionCompleted(MediaSession session)
{
if (!TryGetActiveSession(session)) return;
lock (_gate)
{
if (session.Source.Looping) return; // it will restart itself
session.Finished = true;
}
SessionCompleted?.Invoke(session.Key);
}
private void OnAudioAvailable(MediaSession session, AudioSample sample)
{
if (!TryGetActiveSession(session)) return;
double volume;
bool muted;
lock (_gate)
{
volume = session.AudioVolume;
muted = session.AudioMuted;
}
if (muted || volume <= 0) return;
if (volume < 0.999)
{
// Copy before scaling: the source owns its buffer and may recycle it.
var scaled = new float[sample.Samples.Length];
var gain = (float)volume;
for (var i = 0; i < scaled.Length; i++)
scaled[i] = sample.Samples[i] * gain;
AudioAvailable?.Invoke(session.Key, new AudioSample(scaled, sample.SampleRate, sample.Channels));
return;
}
AudioAvailable?.Invoke(session.Key, sample);
}
/// <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)
@@ -178,6 +328,8 @@ public sealed class MediaVideoSourceManager : IDisposable
foreach (var session in sessions)
{
session.Source.FrameAvailable -= session.FrameHandler;
if (session.AudioHandler != null) session.Source.AudioAvailable -= session.AudioHandler;
if (session.CompletedHandler != null) session.Source.Completed -= session.CompletedHandler;
_ = SafeStopAsync(session.Source);
}
}
+24 -9
View File
@@ -8,9 +8,10 @@ using ytLive.Helpers;
namespace ytLive.Services;
/// <summary>
/// Toast notifications rendered inside the main window's <c>ToastArea</c>
/// (bottom-right, above the footer). Info/success auto-dismiss after ~4s,
/// warnings ~8s, errors stay until dismissed. Calls from any thread are
/// Toast notifications rendered inside the main window's <c>ToastArea</c>, whose
/// position is configurable in App Settings (bottom-right by default). Info/success auto-dismiss after ~4s,
/// warnings ~8s, errors stay until dismissed. App Settings can make all alerts sticky and
/// dismissible by clicking the card. Calls from any thread are
/// marshalled to the captured dispatcher.
/// </summary>
public sealed class NotificationService : INotificationService
@@ -28,35 +29,49 @@ public sealed class NotificationService : INotificationService
private readonly NotificationManager _manager = new();
private readonly Dispatcher _dispatcher;
private readonly Func<bool> _stickyAlertsUntilDismissed;
public NotificationService()
: this(Application.Current?.Dispatcher)
: this(Application.Current?.Dispatcher, static () => false)
{
}
internal NotificationService(Dispatcher? dispatcher)
: this(dispatcher, static () => false)
{
}
internal NotificationService(Dispatcher? dispatcher, Func<bool> stickyAlertsUntilDismissed)
{
_dispatcher = dispatcher ?? Dispatcher.CurrentDispatcher;
_stickyAlertsUntilDismissed = stickyAlertsUntilDismissed;
NotificationConstants.NotificationCornerRadius = new CornerRadius(8);
NotificationConstants.KeepNotificationVisibleOnMouseOver = true;
}
public void Show(string title, string message, AppNotificationSeverity severity)
{
var request = BuildRequest(title, message, severity);
_dispatcher.Invoke(() => _manager.Show(request));
_dispatcher.Invoke(() => _manager.Show(BuildRequestForCurrentSettings(title, message, severity)));
}
internal static NotificationRequest BuildRequest(string title, string message, AppNotificationSeverity severity)
internal NotificationRequest BuildRequestForCurrentSettings(string title, string message,
AppNotificationSeverity severity)
=> BuildRequest(title, message, severity, _stickyAlertsUntilDismissed());
internal static NotificationRequest BuildRequest(string title, string message,
AppNotificationSeverity severity, bool stickyUntilDismissed = false)
{
var (backgroundHex, expirationSeconds) = Cards[severity];
var builder = NotificationBuilder
.Create(title, message)
.InArea(AreaName)
.WithBackground(NotificationColor.FromHex(backgroundHex))
.WithForeground(NotificationColor.FromHex(ThemeBrush.Hex("Brush.OnSurface")));
.WithForeground(NotificationColor.FromHex(ThemeBrush.Hex("Brush.OnSurface")))
.CloseOnClick(stickyUntilDismissed);
if (severity == AppNotificationSeverity.Error)
if (stickyUntilDismissed)
builder.NeverExpires();
else if (severity == AppNotificationSeverity.Error)
builder.NeverExpires();
else
builder.ExpiresInSeconds(expirationSeconds);
+27 -25
View File
@@ -7,19 +7,30 @@ namespace ytLive.Services;
/// 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.
/// <para><b>Fill a preallocated frame, do not re-concatenate a pending buffer</b> (2026-11-05).
/// The previous version copied the whole accumulated partial frame on every <c>Feed</c>, so a
/// 1080p frame arriving in 64 KB reads cost ~64 copies of a growing 8 MB buffer — about 0.5 GB of
/// copying per frame, which capped playback at ~4 fps no matter what the pacing said (reported as
/// "playing at a hideously slow rate"; measured 6 frames in 3 s on a 30 fps clip). Now the cost is
/// one copy per frame plus one copy of the tail that spans reads.</para>
/// </summary>
public sealed class RawVideoFrameReader
{
private readonly int _frameBytes;
private readonly int _width;
private readonly int _height;
private byte[]? _pending;
// Staging buffer for the frame currently being assembled. It BECOMES the emitted frame's
// pixel array, then a fresh one is allocated — so a frame is copied exactly once.
private byte[] _frame;
private int _count;
public RawVideoFrameReader(int width, int height)
{
_width = width;
_height = height;
_frameBytes = width * height * 4;
_frame = new byte[_frameBytes];
}
/// <summary>Feeds <paramref name="bytes"/> read from the pipe and returns any
@@ -27,32 +38,23 @@ public sealed class RawVideoFrameReader
/// 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 (bytes.Length == 0) return frames;
if (offset < combined.Length)
_pending = combined[offset..];
var offset = 0;
while (offset < bytes.Length)
{
var take = Math.Min(_frameBytes - _count, bytes.Length - offset);
Buffer.BlockCopy(bytes, offset, _frame, _count, take);
_count += take;
offset += take;
if (_count < _frameBytes) continue;
frames.Add(new VideoFrame(_width, _height, _frame));
_frame = new byte[_frameBytes];
_count = 0;
}
return frames;
}
+9 -1
View File
@@ -64,10 +64,14 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
// 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);
/// <summary>TASK 33: the "shed our own load first" cadence — 30 conversions/s instead of ~100.
/// The viewer's tier does not change; only how often we pay for a readback.</summary>
private static readonly TimeSpan ShedConvertInterval = TimeSpan.FromMilliseconds(33);
private static readonly TimeSpan TelemetryInterval = TimeSpan.FromSeconds(2);
private const int MaxConcurrentConversions = 3;
private const int PoolBufferCount = 5;
private DateTime _lastConvertAt = DateTime.MinValue;
private TimeSpan _convertInterval = MinConvertInterval;
private DateTime _telemetryFrom = DateTime.UtcNow;
private DateTime _lastTelemetry = DateTime.UtcNow;
private long _skippedCadence;
@@ -79,6 +83,10 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
public string Key { get; }
public event Action<VideoFrame>? FrameAvailable;
/// <summary>TASK 33: halve our own capture cost before asking the creator to accept a lower tier.
/// Idempotent; the pump keeps its declared fps either way (it pads), so no output contract changes.</summary>
public void ShedLoad(bool shed) => _convertInterval = shed ? ShedConvertInterval : MinConvertInterval;
internal ScreenCaptureFrameSource(string key, GraphicsCaptureItem item)
{
Key = key;
@@ -171,7 +179,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
return;
}
var now = DateTime.UtcNow;
if (now - _lastConvertAt < MinConvertInterval)
if (now - _lastConvertAt < _convertInterval)
{
_skippedCadence++;
frame.Dispose();
+15 -3
View File
@@ -55,6 +55,15 @@ public sealed class ScreenCaptureManager : IDisposable
_uiDispatcher = uiDispatcher;
}
/// <summary>TASK 33: applies the capture-load shed to every live capture. Before this, the only
/// lever for machine starvation was the viewer's tier — the app charged them for its own cost.</summary>
public void ShedLoad(bool shed)
{
lock (_gate)
foreach (var session in _sessions.Values)
if (session.Source is ScreenCaptureFrameSource source) source.ShedLoad(shed);
}
/// <summary>Increments the refcount for a target key, starting capture the first time.</summary>
public async Task<bool> AcquireAsync(string key)
{
@@ -79,7 +88,8 @@ public sealed class ScreenCaptureManager : IDisposable
}
catch (Exception ex)
{
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}");
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
CaptureFailed?.Invoke(key, ex.Message);
return false;
}
@@ -109,7 +119,8 @@ public sealed class ScreenCaptureManager : IDisposable
lock (_gate)
_sessions.Remove(key);
session.Source.FrameAvailable -= session.FrameHandler;
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}");
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
CaptureFailed?.Invoke(key, ex.Message);
await SafeStopAsync(session.Source);
return false;
@@ -189,7 +200,8 @@ public sealed class ScreenCaptureManager : IDisposable
}
catch (Exception ex)
{
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}");
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
+77
View File
@@ -0,0 +1,77 @@
using System;
using ytLive.Models;
using ytLive.Services.Encoder;
namespace ytLive.Services;
/// <summary>
/// TASK 33 slice 2: the session's adaptor between the pump's evidence and the pure policy. One planner
/// per broadcast, because the policy is stateful (budget, cooldown, current tier) and a step must
/// survive across windows.
///
/// <para>It deliberately does not act. It answers "should this session change something, and why"; the
/// view model owns the restart, the toast and the session-report line. Keeping the effects out is what
/// makes the whole ladder testable without an encoder running.</para>
/// </summary>
public sealed class StepDownPlanner
{
private readonly StepDownPolicy _policy;
private readonly DateTime _startedUtc;
public StepDownPlanner(StepDownPolicy policy, DateTime startedUtc)
{
_policy = policy;
_startedUtc = startedUtc;
}
public StepDownPolicy Policy => _policy;
/// <summary>Current tier after any applied steps, in the shape the encoder restart needs.</summary>
public (int Width, int Height, int Fps, double BitrateMbps) Current =>
(_policy.Width, _policy.Height, _policy.Fps, _policy.BitrateMbps);
public StepDownDecision Observe(in FramePumpStats stats, DateTime nowUtc)
{
var sample = new StepDownSample(
WindowSeconds: stats.WindowSeconds,
PumpStalls: stats.Stalls,
PumpDrops: 0, // the pump pads with the last frame; FramesRendered is ambiguous
EncoderDrops: stats.Dropped,
AvgRenderMs: stats.AvgRenderMs,
AvgSubmitWaitMs: stats.AvgSubmitMs);
return _policy.Observe(sample, nowUtc - _startedUtc);
}
/// <summary>The plain sentence the creator sees, chosen by cause. Never a mechanism string: "ffmpeg
/// exited", "dropped 8", an HTTP code are log findings, not an explanation of what happened to them.
/// </summary>
public static string Describe(in StepDownDecision decision, (int Width, int Height, int Fps, double BitrateMbps) landed)
=> decision.Action switch
{
StepAction.LowerFrameRate =>
"LlamaCasty couldn't keep up with your scene, so it lowered the frame rate to " +
$"{landed.Fps}. Your connection wasn't the problem.",
StepAction.LowerResolution =>
"LlamaCasty couldn't keep up with your scene, so it lowered the picture to " +
$"{landed.Width}×{landed.Height}. Your connection wasn't the problem.",
StepAction.LowerBitrate =>
$"Your upload couldn't hold the bitrate, so LlamaCasty lowered it to {landed.BitrateMbps:0.0} Mbps " +
"to stop the freezing. It will stay there for this stream.",
StepAction.RaiseFrameRate or StepAction.RaiseResolution or StepAction.RaiseBitrate =>
"Conditions were clean for a while, so LlamaCasty restored one step of quality.",
_ => string.Empty,
};
/// <summary>One line for the session report, so a later question is answered from data.</summary>
public static string ReportLine(DateTime whenLocal, in StepDownDecision decision) =>
decision.Action switch
{
StepAction.None => string.Empty,
StepAction.LowerFrameRate or StepAction.LowerResolution =>
$"{whenLocal:HH:mm} quality stepped down — encoder ({decision.Why})",
StepAction.LowerBitrate =>
$"{whenLocal:HH:mm} quality stepped down — upload ({decision.Why})",
_ => $"{whenLocal:HH:mm} quality restored ({decision.Why})",
};
}
+150
View File
@@ -0,0 +1,150 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ytLive.Services;
/// <summary>What YouTube says about the broadcast once a push is back.</summary>
public enum BroadcastLiveness
{
/// <summary>Could not tell (no token, network, API hiccup) — treat as recovered.</summary>
Unknown,
/// <summary>The broadcast is still live/ready: the push landed and the show continues.</summary>
Live,
/// <summary>The broadcast is complete. It cannot be resumed; the push is talking to a dead ingest.</summary>
Complete,
}
/// <summary>Everything the recovery loop needs from the app, as delegates so it can be tested
/// without ffmpeg, without a network, and without a window.</summary>
public sealed class StreamRecoverySteps
{
/// <summary>Recreates the audio pipe server. The old one is unusable after its client died
/// (single-instance server), and the encoder cannot start without it.</summary>
public required Func<CancellationToken, Task> RestartAudio { get; init; }
/// <summary>Starts the push. True when the encoder came up.</summary>
public required Func<CancellationToken, Task<bool>> StartPush { get; init; }
/// <summary>Asks YouTube whether the broadcast survived. Only called after a push starts.</summary>
public required Func<CancellationToken, Task<BroadcastLiveness>> CheckBroadcast { get; init; }
/// <summary>State for the alert stack: attempt numbers and the outcome.</summary>
public required Action<string> Report { get; init; }
/// <summary>The record: one line per attempt, into the app log.</summary>
public required Action<string> Log { get; init; }
public Func<TimeSpan, CancellationToken, Task> Delay { get; init; }
= (delay, ct) => delay <= TimeSpan.Zero ? Task.CompletedTask : Task.Delay(delay, ct);
}
public enum RecoveryOutcome
{
Recovered,
GaveUp,
Cancelled,
BroadcastComplete,
}
public sealed record RecoveryResult(RecoveryOutcome Outcome, int Attempts, TimeSpan Elapsed);
/// <summary>
/// TASK 32·1 — the blip retry. When the encoder dies while the session is live, relaunch the same push
/// against the same ingest, restarting the audio pipe each time (its server is single-instance and the
/// dead client leaves it unusable).
/// <para><b>The check that stops us lying:</b> a reconnect can succeed at the socket level after
/// YouTube has already ended the broadcast. So each successful start asks the API whether the broadcast
/// is still there; "complete" ends recovery instead of showing a live sign over a dead stream.</para>
/// </summary>
public sealed class StreamRecovery
{
private readonly StreamRetryPolicy _policy;
private readonly StreamRecoverySteps _steps;
public StreamRecovery(StreamRetryPolicy policy, StreamRecoverySteps steps)
{
_policy = policy;
_steps = steps;
}
public async Task<RecoveryResult> RunAsync(CancellationToken cancellationToken = default)
{
var clock = System.Diagnostics.Stopwatch.StartNew();
var attempts = 0;
for (var attempt = 0; attempt < _policy.Attempts; attempt++)
{
if (cancellationToken.IsCancellationRequested) return Result(RecoveryOutcome.Cancelled);
attempts = attempt + 1;
var wait = _policy.WaitBefore(attempt);
if (wait > TimeSpan.Zero)
{
try { await _steps.Delay(wait, cancellationToken).ConfigureAwait(false); }
catch (OperationCanceledException) { return Result(RecoveryOutcome.Cancelled); }
}
if (cancellationToken.IsCancellationRequested) return Result(RecoveryOutcome.Cancelled);
_steps.Report($"Stream dropped — reconnecting (attempt {attempts} of {_policy.Attempts})");
_steps.Log($"RECONNECT: attempt {attempts} of {_policy.Attempts} — restarting the audio pipe and the push");
try
{
await _steps.RestartAudio(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_steps.Log($"RECONNECT: audio pipe restart failed on attempt {attempts}: {ex.Message}");
continue;
}
bool started;
try
{
started = await _steps.StartPush(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_steps.Log($"RECONNECT: push restart failed on attempt {attempts}: {ex.Message}");
continue;
}
if (!started)
{
_steps.Log($"RECONNECT: attempt {attempts} of {_policy.Attempts} failed — the encoder did not come up");
continue;
}
var liveness = BroadcastLiveness.Unknown;
try
{
liveness = await _steps.CheckBroadcast(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_steps.Log($"RECONNECT: could not check the broadcast on attempt {attempts}: {ex.Message}");
}
if (liveness == BroadcastLiveness.Complete)
{
_steps.Report("Stream ended — YouTube reports this broadcast complete. It cannot be resumed.");
_steps.Log("RECONNECT: push came back, but the broadcast is complete — cannot resume");
return Result(RecoveryOutcome.BroadcastComplete);
}
_steps.Report($"Stream recovered after {clock.Elapsed.TotalSeconds:0.0} s");
_steps.Log($"RECONNECT: push re-established after {clock.Elapsed.TotalSeconds:0.0} s — still live");
return Result(RecoveryOutcome.Recovered);
}
_steps.Report($"Stream dropped — {_policy.Attempts} attempts failed. The stream is off air.");
_steps.Log($"RECONNECT: gave up after {_policy.Attempts} attempts — stream is off air");
return Result(RecoveryOutcome.GaveUp);
RecoveryResult Result(RecoveryOutcome outcome) => new(outcome, attempts, clock.Elapsed);
}
}
+38
View File
@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
namespace ytLive.Services;
/// <summary>
/// TASK 32·1: how many times to relaunch the push, and how long to wait between tries.
/// <para><b>Minimum waits, deliberately</b> (creator ruling 2026-11-05): YouTube is not generous about
/// holding a broadcast whose ingest has gone quiet, so every second spent not pushing is grace spent.
/// The first attempt is immediate — there is nothing to wait for when the socket has just died.</para>
/// <para>Pure so the schedule is asserted without waiting on it.</para>
/// </summary>
public sealed class StreamRetryPolicy
{
/// <summary>Immediate, then +1 s, +2 s, +3 s — four attempts inside about six seconds.</summary>
public static readonly StreamRetryPolicy Default = new(new[]
{
TimeSpan.Zero,
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(3),
});
public IReadOnlyList<TimeSpan> Waits { get; }
public StreamRetryPolicy(IReadOnlyList<TimeSpan> waits)
{
if (waits == null || waits.Count == 0)
throw new ArgumentException("A retry policy needs at least one attempt.", nameof(waits));
Waits = waits;
}
public int Attempts => Waits.Count;
/// <summary>The wait BEFORE <paramref name="attemptIndex"/> (0-based).</summary>
public TimeSpan WaitBefore(int attemptIndex)
=> attemptIndex < 0 || attemptIndex >= Waits.Count ? TimeSpan.Zero : Waits[attemptIndex];
}
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// TASK 3·20: reads the channel's recent Super Chat / Super Sticker events so a session report can be
/// shown without storing anything.
/// <para><b>YouTube is the authority.</b> This service only asks; it keeps no ledger, and the events it
/// returns are the ones YouTube still considers valid (removed or refunded events do not come back).</para>
/// <para>Scope: the endpoint is authorised by the app's existing <c>auth/youtube</c> scope, so no
/// re-consent is needed — unlike the Analytics API, which does.</para>
/// </summary>
public interface ISuperChatEventSource
{
/// <summary>The most recent events for a channel (YouTube caps the window at roughly 30 days).</summary>
Task<IReadOnlyList<SuperChatEvent>> GetRecentAsync(
string channelId, int maxResults = 200, CancellationToken cancellationToken = default);
}
public sealed class SuperChatEventService : ISuperChatEventSource
{
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
private readonly YouTubeAuthService _auth;
private readonly HttpClient _http;
public SuperChatEventService(YouTubeAuthService auth, HttpClient? http = null)
{
_auth = auth;
_http = http ?? new HttpClient();
}
public async Task<IReadOnlyList<SuperChatEvent>> GetRecentAsync(
string channelId, int maxResults = 200, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(channelId)) return Array.Empty<SuperChatEvent>();
if (_auth.CurrentChannel?.AccessToken is not { } token) return Array.Empty<SuperChatEvent>();
_http.DefaultRequestHeaders.Authorization = new("Bearer", token);
var url = $"{ApiBase}/superChatEvents?part=snippet&maxResults={Math.Clamp(maxResults, 1, 200)}" +
$"&channelId={Uri.EscapeDataString(channelId)}";
try
{
var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"superChatEvents.list failed: {(int)response.StatusCode}");
return Array.Empty<SuperChatEvent>();
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return Parse(json);
}
catch (Exception ex)
{
AppLog.Write($"superChatEvents.list failed: {ex.Message}");
return Array.Empty<SuperChatEvent>();
}
}
/// <summary>Pure parse of a <c>superChatEvents.list</c> response. Both event types carry their money
/// in a different object, so a sticker must not be silently dropped.</summary>
internal static IReadOnlyList<SuperChatEvent> Parse(string json)
{
var events = new List<SuperChatEvent>();
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("items", out var items)) return events;
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("snippet", out var snippet)) continue;
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty;
var publishedAt = snippet.TryGetProperty("publishedAt", out var published)
&& DateTime.TryParse(published.GetString(), out var parsed)
? parsed.ToUniversalTime()
: DateTime.UtcNow;
var kind = ChatEventKind.SuperChat;
JsonElement details;
if (snippet.TryGetProperty("superChatDetails", out var chat))
details = chat;
else if (snippet.TryGetProperty("superStickerDetails", out var sticker))
{
details = sticker;
kind = ChatEventKind.SuperSticker;
}
else
{
continue;
}
var micros = details.TryGetProperty("amountMicros", out var amount)
&& amount.ValueKind == JsonValueKind.Number
&& amount.TryGetInt64(out var value)
? value
: 0;
var currency = details.TryGetProperty("currency", out var cur) && cur.ValueKind == JsonValueKind.String
? cur.GetString() ?? string.Empty
: string.Empty;
var display = details.TryGetProperty("amountDisplayString", out var shown)
&& shown.ValueKind == JsonValueKind.String
? shown.GetString()
: null;
events.Add(new SuperChatEvent(id, kind, micros, currency, display, publishedAt));
}
return events;
}
}
+158
View File
@@ -0,0 +1,158 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Models;
namespace ytLive.Services;
public sealed class TextOverlayRenderer
{
private const int MaxRasterDimension = 4096;
private readonly ConcurrentDictionary<Source, CacheEntry> _cache = new();
public VideoFrame? RenderFrame(Source source)
=> _cache.TryGetValue(source, out var entry) ? entry.Frame : null;
public void Remove(Source source) => _cache.TryRemove(source, out _);
public void Retain(IEnumerable<Source> sources)
{
var active = new HashSet<Source>(sources);
foreach (var source in _cache.Keys)
if (!active.Contains(source))
_cache.TryRemove(source, out _);
}
public void UpdatePreview(Source source)
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
{
dispatcher.Invoke(() => UpdatePreview(source));
return;
}
var config = Capture(source);
if (_cache.TryGetValue(source, out var cached) && cached.Config == config)
{
if (!ReferenceEquals(source.VideoImageSource, cached.Preview))
source.VideoImageSource = cached.Preview;
return;
}
var frame = Rasterize(config);
var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
_cache[source] = new CacheEntry(config, frame, bitmap);
source.VideoImageSource = bitmap;
}
private static RenderConfig Capture(Source source) => new(
source.TextContent,
source.TextFontFamily,
source.TextFontSize,
source.TextColor,
source.TextBold,
source.TextItalic,
source.TextUnderline,
source.TextAlignment,
source.TextVerticalAlignment,
RasterDimension(source.Width),
RasterDimension(source.Height));
private static int RasterDimension(double value) =>
double.IsFinite(value) ? (int)Math.Clamp(Math.Ceiling(value), 1, MaxRasterDimension) : 1;
private static VideoFrame Rasterize(RenderConfig config)
{
var visual = new DrawingVisual();
if (!string.IsNullOrEmpty(config.Content))
{
using var drawing = visual.RenderOpen();
var family = new FontFamily(config.FontFamily);
var typeface = new Typeface(
family,
config.Italic ? FontStyles.Italic : FontStyles.Normal,
config.Bold ? FontWeights.Bold : FontWeights.Normal,
FontStretches.Normal);
var brush = new SolidColorBrush(ParseColor(config.Color));
brush.Freeze();
var formatted = new FormattedText(
config.Content,
CultureInfo.CurrentUICulture,
FlowDirection.LeftToRight,
typeface,
config.FontSize,
brush,
1.0)
{
MaxTextWidth = config.Width,
MaxTextHeight = config.Height,
TextAlignment = config.Alignment switch
{
TextLayerAlignment.Center => System.Windows.TextAlignment.Center,
TextLayerAlignment.Right => System.Windows.TextAlignment.Right,
_ => System.Windows.TextAlignment.Left,
},
Trimming = TextTrimming.CharacterEllipsis,
};
if (config.Underline)
formatted.SetTextDecorations(TextDecorations.Underline, 0, config.Content.Length);
var top = config.VerticalAlignment switch
{
TextLayerVerticalAlignment.Center => Math.Max(0, (config.Height - formatted.Height) / 2),
TextLayerVerticalAlignment.Bottom => Math.Max(0, config.Height - formatted.Height),
_ => 0,
};
drawing.DrawText(formatted, new Point(0, top));
}
var bitmap = new RenderTargetBitmap(config.Width, config.Height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var pixels = new byte[config.Width * config.Height * 4];
bitmap.CopyPixels(pixels, config.Width * 4, 0);
UnpremultiplyAlpha(pixels);
return new VideoFrame(config.Width, config.Height, pixels);
}
private static Color ParseColor(string hex) =>
(Color)ColorConverter.ConvertFromString(hex)!;
private static void UnpremultiplyAlpha(byte[] pixels)
{
for (var i = 0; i < pixels.Length; i += 4)
{
var alpha = pixels[i + 3];
if (alpha == 0)
{
pixels[i] = pixels[i + 1] = pixels[i + 2] = 0;
continue;
}
if (alpha == 255) continue;
pixels[i] = Unpremultiply(pixels[i], alpha);
pixels[i + 1] = Unpremultiply(pixels[i + 1], alpha);
pixels[i + 2] = Unpremultiply(pixels[i + 2], alpha);
}
}
private static byte Unpremultiply(byte value, byte alpha) =>
(byte)Math.Min(255, (value * 255 + alpha / 2) / alpha);
private sealed record CacheEntry(RenderConfig Config, VideoFrame Frame, WriteableBitmap Preview);
private readonly record struct RenderConfig(
string Content,
string FontFamily,
double FontSize,
string Color,
bool Bold,
bool Italic,
bool Underline,
TextLayerAlignment Alignment,
TextLayerVerticalAlignment VerticalAlignment,
int Width,
int Height);
}
+279
View File
@@ -0,0 +1,279 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ytLive.Helpers;
using ytLive.Services.Audio;
using ytLive.Services.Encoder;
namespace ytLive.Services;
/// <summary>
/// One video FILE played through ffmpeg — the shared pipe behind every clip, so a media
/// layer (TASK 21) and an alert clip (TASK 47) decode identically. Owns two subprocesses
/// on the same file: video (<c>rawvideo bgra</c>, scaled to the target rect) and audio
/// (<c>f32le -ac 2 -ar 48000</c>, matching the mixer's native format so no resampler is
/// needed). Both are paced to real time, so the picture and the sound stay together even
/// though they arrive on separate pipes.
/// <para><b>Looping is the media case</b>: on natural EOF the whole pass restarts — a fresh
/// pair of processes, because a <see cref="Process"/> cannot be re-started. An alert clip
/// never loops, so its wrapper simply never sets <see cref="Looping"/>.</para>
/// <para>The seams (locator, process factory, frame-rate probe, delay) exist so tests drive
/// a fake decoder with no ffmpeg, no codec, and no audio device.</para>
/// </summary>
public sealed class VideoClipPipe : IDisposable
{
/// <summary>Pipe read size. A 1080p BGRA frame is ~8 MB, so 64 KB reads meant 127 awaits and
/// 127 reader calls per frame; 256 KB keeps the syscall count sane without a large allocation.</summary>
private const int ReadBufferBytes = 256 * 1024;
public const int AudioSampleRate = 48000;
public const int AudioChannels = 2;
private const int AudioChunkFloats = 4800; // 50 ms of stereo
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioAvailable;
public event Action? Completed;
/// <summary>Restart the pass on natural EOF (media/BRB loops).</summary>
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 bool _audio;
private readonly ChromaKeyOptions? _key;
private readonly FramePacer _pacer = new();
private readonly CancellationTokenSource _cts = new();
private bool _running;
private IDecodeProcess? _activeVideo;
private IDecodeProcess? _activeAudio;
public VideoClipPipe(
string path,
int width,
int height,
IFfmpegLocator locator,
Func<IDecodeProcess> processFactory,
IFrameRateProbe? frameRateProbe = null,
Func<TimeSpan, CancellationToken, Task>? delay = null,
bool audio = true,
ChromaKeyOptions? chromaKey = null)
{
_path = path;
_width = width;
_height = height;
_locator = locator;
_processFactory = processFactory;
_probe = frameRateProbe;
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
_audio = audio;
_key = chromaKey;
}
/// <summary>Runs one pass (or a loop of passes). A pass that ended naturally leaves the pipe
/// restartable — the creator can ask a finished clip to play again, which is what the Loop
/// switch and the "Play again" button need.</summary>
public Task StartAsync(CancellationToken cancellationToken = default)
{
if (_running) return Task.CompletedTask;
_running = true;
_ = Task.Run(RunAsync, CancellationToken.None);
return Task.CompletedTask;
}
public Task StopAsync()
{
_cts.Cancel();
Kill(_activeVideo);
Kill(_activeAudio);
_running = false;
return Task.CompletedTask;
}
public void Dispose()
{
_cts.Cancel();
_running = false;
_cts.Dispose();
_activeVideo?.Dispose();
_activeAudio?.Dispose();
}
private void StopInternal()
{
_running = false;
Kill(_activeVideo);
Kill(_activeAudio);
}
private async Task RunAsync()
{
var ct = _cts.Token;
var frames = 0;
var chunks = 0;
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);
do
{
// Both pipes per pass, awaited together: a restart must begin a new
// video AND a new audio stream, or the picture drifts from the sound
// after the first loop.
var video = RunVideoAsync(ffmpegPath, frameDuration, ct);
var audio = _audio ? RunAudioAsync(ffmpegPath, ct) : Task.FromResult(0);
frames += await video.ConfigureAwait(false);
chunks += await audio.ConfigureAwait(false);
}
while (Looping && !ct.IsCancellationRequested);
}
catch (OperationCanceledException)
{
_running = false;
return;
}
catch (Exception ex)
{
// A failed decode is not fatal: the alert layer falls back to its animations,
// and a media layer just holds its last frame.
AppLog.Write($"Video clip decode failed ({Path.GetFileName(_path)}): {ex.Message}");
_running = false;
return;
}
_running = false;
if (!ct.IsCancellationRequested)
{
AppLog.Write($"Video clip end: {Path.GetFileName(_path)} frames={frames} audioChunks={chunks}");
Completed?.Invoke();
}
}
private async Task<int> RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
{
var passStart = Stopwatch.GetTimestamp();
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f rawvideo -pix_fmt bgra -vf {VideoFilterChain.BuildVideo(_width, _height, _key)} -an -";
using var process = _processFactory();
_activeVideo = process;
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[ReadBufferBytes];
_pacer.Reset();
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)
{
// Deadline pacing: sleep what is LEFT of this frame's interval, so the frame's
// own work is not added on top (see FramePacer).
var wait = _pacer.DelayFor(frameDuration, Stopwatch.GetElapsedTime(passStart));
if (wait > TimeSpan.Zero)
await _delay(wait, ct).ConfigureAwait(false);
}
}
}
_activeVideo = null;
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();
_activeAudio = process;
process.Start(new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
});
var stream = process.StandardOutput;
var passStart = Stopwatch.GetTimestamp();
var pacer = new FramePacer();
var raw = new byte[65536];
var carry = Array.Empty<byte>();
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] : Array.Empty<byte>();
while (floatIndex == floatScratch.Length)
{
chunks++;
AudioAvailable?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels));
// Deadline pacing, exactly like the video pipe: sleep only what is LEFT of this
// chunk's 50 ms. A fixed sleep added the read cost every chunk, so audio fell
// progressively behind the picture on a long clip (2026-11-05).
var wait = pacer.DelayFor(
TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)),
Stopwatch.GetElapsedTime(passStart));
if (wait > TimeSpan.Zero)
await _delay(wait, ct).ConfigureAwait(false);
floatScratch = new float[AudioChunkFloats];
floatIndex = 0;
}
}
_activeAudio = null;
return chunks;
}
private static void Kill(IDecodeProcess? process)
{
try { process?.Kill(); } catch { /* already exited */ }
}
}
+75
View File
@@ -0,0 +1,75 @@
using System.Globalization;
namespace ytLive.Services;
/// <summary>
/// TASK 64: the key's settings for one layer, in the app's own terms. <see cref="Color"/> is the
/// screen as the creator's camera saw it (whatever the detector measured), not a canonical green.
/// </summary>
public sealed record ChromaKeyOptions(
bool Enabled,
string Color,
double Similarity,
double Softness,
bool Spill,
ChromaScreen Screen = ChromaScreen.Green)
{
public static readonly ChromaKeyOptions Off = new(false, "#00B140", 0.12, 0.08, true);
/// <summary>Creator-facing defaults when a detector suggestion is accepted: a little
/// tolerance around the measured colour, a soft edge, and spill cleanup on.</summary>
public static ChromaKeyOptions For(ChromaKeySuggestion suggestion)
=> new(true, suggestion.ColorHex ?? "#00B140", 0.12, 0.08, true, suggestion.Screen);
/// <summary>Identifies this look, so decode sessions can be shared per look rather than
/// duplicated per layer (two layers keyed identically reuse one decoder).</summary>
public string Signature => Enabled
? FormattableString.Invariant(
$"{Color.ToUpperInvariant()}|{Similarity:0.###}|{Softness:0.###}|{Spill}|{Screen}")
: "off";
}
/// <summary>
/// TASK 64: builds the ffmpeg filter chain for a clip. Keying happens IN THE DECODE PIPE, not in
/// this process: the bundled ffmpeg keys with slices of its own CPU cost, emits straight-alpha
/// BGRA (verified against the shipped binary: keyed pixels come out with alpha 0), and — the
/// point — the pipe is upstream of BOTH consumers (the shared preview bitmap and the compositor's
/// frame), so the creator's preview and the stream cannot disagree.
/// <para>Order matters: scale first (cheaper, and the edge is computed at output size), then key,
/// then despill, then force BGRA so the alpha survives the pipe.</para>
/// </summary>
public static class VideoFilterChain
{
/// <summary>The video half of a clip's `-vf`. Unkeyed clips keep the old single-scale chain.</summary>
public static string BuildVideo(int width, int height, ChromaKeyOptions? key = null)
{
var scale = $"scale={width}:{height}";
if (key is not { Enabled: true }) return scale;
var parts = new List<string>(4)
{
scale,
$"colorkey={NormalizeColor(key.Color)}:{Number(key.Similarity)}:{Number(key.Softness)}",
};
if (key.Spill)
parts.Add($"despill=type={(key.Screen == ChromaScreen.Blue ? "blue" : "green")}");
parts.Add("format=bgra");
return string.Join(",", parts);
}
/// <summary>ffmpeg takes `0xRRGGBB`. Anything unparseable falls back to the app default
/// rather than producing a filter string ffmpeg would reject at spawn time.</summary>
private static string NormalizeColor(string? hex)
{
var value = (hex ?? string.Empty).Trim();
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) value = value[2..];
if (value.StartsWith('#')) value = value[1..];
if (value.Length != 6) return "0x00B140";
foreach (var c in value)
if (!Uri.IsHexDigit(c)) return "0x00B140";
return "0x" + value.ToUpperInvariant();
}
private static string Number(double value)
=> Math.Clamp(value, 0, 1).ToString("0.###", CultureInfo.InvariantCulture);
}

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