Compare commits

..
Author SHA1 Message Date
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
gramps 82960d0ca3 docs(video): the training-video plan — formats, why Shorts are a funnel, and what to film
New `youTubeTrainingVideos.md`: the feature list worth filming, split into "ready now" and
"after the surge lands" so nothing gets filmed before it exists, plus the format decisions
settled on 2026-11-01.

Format: 16:9 long-form is the library (search intent, walkthroughs, conversion); vertical Shorts
are trailers cut from the same pass. A 16:9 video cannot be a Short, so it is a per-video choice.
TikTok is a mirror of the verticals, never a strategy.

The numbers behind it: Shorts need 1,000 subs + 10M views in 90 days for YPP versus 4,000 watch
hours in a year; Shorts RPM is $0.02-0.08 per 1,000 against $3-8 for tutorials; and one $79 sale
nets ~$55.30, which is what 700k-2M Shorts views pay. Shorts are an install engine, not revenue.

Film first: privacy blur, auto-ducking, webcam blur.
2026-11-01 15:43:48 -08:00
gramps 5d836aecbb docs(commercial): one price — $79 Steam DLC rising to $99, subscription retired
Creator ruling 2026-11-01. One SKU, one price, Steam does the selling.

- Pro is a one-time Steam DLC on the free app: $79 introductory, $99 on a stated date, announced
  where the price is shown and actually raised.
- The subscription is retired. The reasoning is in decisions.md: no service behind a monthly fee
  (no servers, no cloud, no content), ~1-3 subscribers per 1,000 installs against 5-8% churn, and
  it carried the most expensive code in the product.
- Polar goes dormant. Its customer-portal design is recorded, not built. The "freemium on Steam,
  convert on Polar, Valve gets nothing" proposal is rejected: in-app purchases must use the Steam
  Wallet, so it risks the listing to save ~$24 a sale.
- Entitlement collapses to `steamDlcOwned`. No lease, no key, no portal, no device limit; the
  greyed tier opens the Steam store overlay, so every purchase stays inside Valve's own surface.
- The site publishes the model and links the store page. It takes no payment and hosts no binaries.
- The $400 lifetime died with the subscription it anchored. Founding 200 died with the coupons and
  passes it needed.

Costs: $115 fixed year one; $79 nets $55.30 after Steam, so three sales cover the year.
2026-11-01 15:40:44 -08:00
gramps 1c2eedcaba ui(audio): the volume faders stop at 0 dB — no unreachable track
Mic and desktop gains run -60 to 0 dB and that is the ceiling: 0 dB is full input level, and
nothing past it is offered or drawn. The unity tick is gone with the zone it explained.
2026-11-01 15:16:03 -08:00
gramps 1074a46e9c ui(audio): a 0 dB unity tick on both volume faders
The faders run -60 to +6 dB but the ladder tops out at 0 dBFS, so the last 6 dB looked like
dead track. The tick marks unity at 60/66 of the track: what is left of it is boost, and now it
reads that way. Both mic and desktop faders; the lip-sync slider is milliseconds and gets none.
2026-11-01 15:13:21 -08:00
gramps b00bf48b9d feat(alerts): sound byte under Built-in video, and alert audio that reaches the device
Creator request: a sound-byte browse + label under Built-in video, with an option to loop the
sound while the clip plays. The video itself is always one pass to completion.

- Source gains AlertAudioPath and AlertAudioLoop, persisted (column + migration + INSERT +
  SELECT + reader, the whole chain — that chain is where the blur crashed once).
- The sound byte plays through MediaPlayer on the default output device; MediaEnded re-seeks to
  zero while looping, and StopClip kills it so a tail cannot bleed into the next alert.
- The clip's OWN soundtrack moved off the mixer and onto the device as well
  (Services/Audio/AlertAudioOutput: one WaveOutEvent + buffered float provider).

Why the move: the creator asked that alert audio — the clip's track or the added sound byte —
appear in the desktop sound bar. Only audio that reaches the default device is picked up by the
WASAPI loopback that feeds both the meter and the stream. The old EnqueueAlertAudio route put the
sound in the stream but left the meter flat and the room silent.

Tests: the sound byte and its loop flag round-trip through SQLite, and a fresh alert layer starts
with no sound byte and no loop. Release 0 warnings.
2026-11-01 14:13:57 -08:00
gramps 46d12e5873 fix(alerts): Test alert actually plays — it was queuing into nothing
TestAlert called Enqueue, which is the pure test seam: it fills the queue and returns. The
alert layer needs three more things to show anything — its scene list, the playback ticker
running, and the preview refreshed — and all three live in OnMessageReceived, the same entry
the chat feed uses (MainViewModel.Chat.cs).

Now it calls OnMessageReceived(message, Scenes), so the clip advances on the ticker and writes
frames to the box's VideoImageSource, which is what the preview pane displays.
2026-11-01 14:07:14 -08:00
gramps 005b459fe0 feat(alerts): a Test alert button in the alert properties
Sits to the right of the Built-in video pill, labelled "Test alert". It enqueues one synthetic
Super Chat through the real AlertOverlayLayer, so what plays is exactly what a viewer triggers —
the custom clip when one is set, the built-in clip otherwise — and it goes through the same
output path (preview and encoder) as a live alert. Local only: nothing reaches YouTube and no
chat row is invented.
2026-11-01 14:04:20 -08:00
gramps 754377c5db ui(audio): every header label in the sound area uses one colour
MIC's device name was the only header label at full-strength OnSurface (it was brightened
because it shows a choice), and the lip-sync reading was Tertiary — three different values
across four bays in the same row. All four header labels are now Brush.OnSurfaceVariant, the
standard secondary-text token. No other colours touched.
2026-11-01 13:59:57 -08:00
gramps b75c2b3001 ui(audio): one row grid across the dock — ladders on a line, volume bars on a line
Every bay is now header / ladder / controls. Lip-sync has no ladder, so it holds an empty 20px
row (10px meter + the two margins) that keeps its slider exactly level with the other three,
and MIC's ladder gained the missing 4px bottom margin that had its slider sitting 4px high.

Presets stay under the slider in the narrow lip-sync bay.
2026-11-01 13:57:36 -08:00
gramps 2fedc7cb64 ui(audio): the lip-sync slider moves up one line — onto the header
Not under it: the offset slider now rides the header line beside the LIP-SYNC badge and the
reading, and the preset row is what stays below. The box is narrow, so a slider on its own line
was the only thing making it two-and-a-bit rows tall.
2026-11-01 13:56:53 -08:00
gramps 789272142d fix(build): the wordmark number counts commits again, not compiles
GenerateBuildStamp read BuildCounter.txt, added 1 and wrote it back on every CoreCompile, so
the number tracked BUILDS: a day of iteration reached #1122 against 563 commits. It now uses
git rev-list count of HEAD, which is what the comment always claimed (and why the file existed
in .gitignore). No counter file, no drift; a missing git still falls back to the start.

Also moves the MIC and lip-sync volume bars up one line: both were pinned to the floor of their
bay by a star row plus VerticalAlignment=Bottom. They now sit directly under their headers,
with the auto rows sized to content.
2026-11-01 13:56:17 -08:00
gramps 35c7809c28 fix(audio): bay ladders fill their bay again — the 320px cap was a mistake
Capping the three bay meters at 320px and left-aligning them made each ladder look cut off
mid-bay, which is what the creator saw as deleted bars. The caps are gone; the master ladder
keeps its own 420-900px range so it stays the longest without shortening anything else.
2026-11-01 13:52:31 -08:00
gramps 4c4f22e6bb ui(audio): the master ladder is always the longest one
The master meter was a fixed 420px, but each sound bay's ladder fills its column — on a wide
window a bay could be longer than the master, which is the opposite of what was asked.

Master now stretches between 420 and 760 px, and the three bay ladders cap at 320 with the same
cap for each, so they stay the same relative size to one another and stay shorter than the
master at every window width.
2026-11-01 13:49:05 -08:00
gramps f8e4f2cf29 ui(audio): master meter centred and biggest; TRAX gets its own bay and meter
Creator rulings, 2026-11-01.

- Master strip is 44px (from 36) and its meter is now the largest on screen: 420x14 with 32
  segments, centred under the preview, with a 12px MASTER label — bigger than the 9px bay
  labels. L/R imbalance is a flag on the left, MONO and the session clock on the right.
- The kbps / fps / dropped counters are gone from that line: the live readouts already sit
  above the preview, and the duplicate was noise rather than telemetry.
- TRAX is its own bay (MIC | GAME | TRAX | LIP-SYNC) with its own meter bound to
  TraxMeterFraction. It lived inside the game bay with no ladder, which meant whether the bed
  was actually playing could not be answered from the dock — and the player already measures
  its own output.
- Lip-sync is narrow (0.45 star) since it is one slider and three presets; the three sound bays
  are equal width, so their meters keep the same relative size.

AudioDockLayout moves with it (156 = 112 + 44; collapsed 44) and the layout test is updated.
TraxBayTests now asserts the opposite of the old ruling: the bay HAS a meter, and the game bay
no longer carries it.
2026-11-01 13:48:28 -08:00
gramps 9b337ad91b feat(blur): the brand logo cannot be blurred
The blur is universal, so it would swallow the logo along with everything else. The compositor
now defers every visible brand logo: skipped in both element loops, blitted immediately AFTER
the blur group, before the flash/social/ticker overlays. One logo per scene, so it is cheap.

Preview mirrors it: the staged scene's brand logo is drawn above the blur host (StagedBrandLogo),
so what you see in the editor is what the encoder gets.

Test: a blur covering the logo's area leaves the logo's pixels exactly white while the coloured
background beneath it blends.
2026-11-01 13:32:23 -08:00
gramps bb51164f2f feat(blur): universal — the blur covers every layer, webcam included
The creator found the use case that reframes the feature: a moment of privacy while the webcam is
on. A blur that can only cover the desktop cannot do that.

- SceneCompositor applies the blur group AFTER every element is composited (still before the
  flash / social bar / ticker / zoom overlays), so it covers webcam, chat, alerts — anything. Its
  position in the layer list no longer affects the output, so it is added at the top and the
  reorder clamp is gone.
- Supersedes the earlier "blur sits below the brand" rule: a universal blur does obscure the logo
  when it covers it. Called out in frame-pipeline.md so it is not re-litigated.
- Preview mirrors it exactly: the blur host moved OUT of CanvasGrid to a sibling above it, so the
  VisualBrush samples every layer (and cannot sample itself). It used to sample only the backdrop,
  which is why a circle over the webcam would have shown sharp pixels in the editor and blurred
  ones in the stream.
2026-11-01 13:29:15 -08:00
gramps bb80ec8370 ui(properties): geometry for every placeable layer, and no fake opacity on a blur
Two holes in the blur's property panel, both of which also affected other layers:

- X/Y/W/H were gated on IsWebcamSelected, so a blur (or an image, chat box or alert zone)
  had no numeric position or size at all — only dragging. The block is generic: it always
  edited SelectedElement's own values, so the gate was the only thing excluding them.
- Opacity showed for a blur, but the compositor's blur reads BlurAmount — the slider would
  have moved a number that changes nothing in the output. Hidden for blur layers.

Blur panel is now: Box | Circle, strength, and the shared geometry fields.
2026-11-01 13:20:13 -08:00
gramps da508f0a14 fix(blur): the (+) menu entry re-evaluates — CanAddBlur was never re-notified
The Blur item's Visibility is bound to CanAddBlur, which depends on the staged scene. The scene
setter never raised it, so the binding evaluated once (collapsed) and stayed collapsed for the
session — build 1082: the option simply wasn't there.

StagedScene now notifies CanAddBlur, with a test that pins both halves: Live true, Chat false,
and a notification on every staged-scene change.
2026-11-01 13:17:40 -08:00
gramps 9b44a32054 fix(blur): the layer is draggable, so adding one drops a rectangle with a resize handle
Source.IsDraggable listed Image/Web/Chat/Alert only, so a blur had no selection box, no corner
handle, and no hit-test — the rectangle existed in the render but could not be grabbed. Adding
Blur to that list gives it the same selection chrome and resize path every placeable layer has
(drag to move, corner dot to resize, one undo snapshot per gesture).

It is selected on add (AddSource already does that), so the rectangle appears already outlined
and ready to size.
2026-11-01 13:14:51 -08:00
gramps 1416548f24 feat(blur): the editor shows the blur — whatever is beneath it is blurred, in the preview
The preview canvas is WPF visuals; a blur layer contributes no visual of its own, so until now
the creator saw nothing where the blur was and could not tell what it was hiding.

Each visible blur now draws a rectangle (or ellipse) filled with a VisualBrush of the whole
BackdropLayer — the desktop capture plus the background art — sampled at the blur's own rect and
carrying a BlurEffect at the same strength the compositor uses. The host canvas sits after the
backdrop and before the element layers, which is the model's own z-rule, so the preview shows
exactly what the output will: desktop blurred, brand and everything above it untouched.

Rebuilt on scene change, on add/remove, and on any blur property change (position, size, shape,
strength) — the brush samples the live backdrop, so the picture keeps moving underneath.
2026-11-01 13:12:27 -08:00
gramps 0ed341fd18 fix(blur): the crash on launch — BlurAmount was in the INSERT and the reader, not the SELECT
LayoutStore.Load threw ArgumentOutOfRangeException on GetOrdinal("BlurAmount") during the
MainViewModel constructor, so the app died at startup rather than when a blur was used. The
migration adds the column and Save writes it; the reading query still listed its columns
explicitly and BlurAmount was missing from that list.

Adds LayoutBlurPersistenceTests: a blur saved and reloaded, asserting the amount and geometry
survive. That is the test that should have existed with the feature — the startup path it
covers has no other guard.
2026-11-01 13:08:03 -08:00
gramps 71d0e87cac feat(blur): a privacy blur layer for the Live scene — boxes and circles over the desktop
Creator request 2026-11-01: a layer that hides part of the desktop from viewers. Box or circle,
drag/resize like any layer, one strength slider, several allowed.

**Z-order is policy, not preference.** `AddSource` inserts a blur directly above the background
and therefore below the brand logo, and `OnSceneElementsReordered` pushes any blur that ends up
above the brand back down. Nothing else in the app can move it into the brand's way.

**Overlap = the strongest single blur, never a stack.** `SceneCompositor.ApplyBlurLayers` collects
every visible blur the first time one is reached in the z-order, sorts them strongest-first, and
shares one coverage map: a pixel already covered by a stronger blur is skipped. Blurring
back-to-front would have produced something stronger than any slider setting allows, which is
exactly what was ruled out.

**One blur curve, shared with the webcam.** `RegionBlur` carries the constants the webcam blur
already used — downscale 4, radius 1–6 downscaled px, `RadiusFor(strength)` — and
`BackgroundEffectProcessor` now delegates to it instead of holding its own copy. The layer's
slider is the same 0–1 scale with the same 0.5 default, so "half strength" means the same blur in
both features. Cost is proportional to the blur's own rect, not the frame.

**Live scene only**, enforced in `AddSource` as well as the (+) menu (`CanAddBlur`), so a stale
menu item or a scripted call cannot plant one on BRB. `ElementKind.Dynamic`, so it is never baked
into the static base and every render path gets it — pump, preview, thumbnails, scene dump.

Persistence: `Source.BlurAmount` column + migration; shape rides the existing `ClipShape`.
Properties panel: Box | Circle plus the strength slider, gated on `IsBlurSelected`.

4 tests: outside the mask is byte-identical, inside loses its hard edges, a circle leaves its
box corners alone, and two identical stacked blurs equal one (the coverage map). Release builds
at 0 warnings.
2026-11-01 09:55:04 -08:00
gramps 2e12c5651e ui(drawers): 20px of content padding on the right of every pull-out
The five drawers each sized their content Grid to Width=320 inside a panel that itself
animates to 320, with 14px margins — so the content box was 348 wide in a 320 window and its
right edge was clipped. That is why every drawer's content jammed into the right margin
regardless of the margin value.

The Grid now fills the panel (no fixed width) with Margin 14,10,20,12: 14 on the left, 20
between the content and the right edge of the div, which is the requested gutter.
2026-11-01 09:35:37 -08:00
gramps a543d3629b ui(settings): 20px right gutter on the drawer, rail tabs centred on the preview
Two build-1047 notes. The settings content gets a 20px right margin (plus 4 left) inside the
scroll viewer, so text and controls stop running into the panel edge and the scrollbar has
somewhere to live. The rail tab stack is VerticalAlignment=Center instead of Stretch, so the
whole group of tabs sits centred against the preview rather than hanging from the top.
2026-11-01 09:22:02 -08:00
gramps a35ec0c5f8 refactor(settings): a pull-out drawer, with Zoom as a category — and the menu moves to the wordmark
Creator directive, 2026-11-01. Settings stopped being a centred modal.

- **Drawer** (Controls/SettingsDrawer): hosted in PreviewPane's pull-out stack, 320px, the same
  animated-width pattern as Stream Settings / YPP / Socials / TEST, opened by the gear rail tab
  on a single left click. SettingsDrawerOpen is exclusive with the other four, both directions.
  This also removes the modal's MaxHeight-700 scroll problem for good: the drawer is the height
  of the preview.
- **Categories**: headings with controls indented beneath them. Zoom now owns *Zoom view shape*
  (Circle / Phone / Tablet) and *Zoom box follows*; Recordings, Brand logo and Scene transition
  are their own categories.
- **The app menu moved to the LlamaCasty wordmark** (right-click → Report Bug / Feature Request /
  About). Left-click still opens About. The gear tab no longer carries a menu at all, so clicking
  it does exactly one thing.

OverlayHost keeps only the Bug / Feature / About panels; its brand-logo handlers and the dev-panel
injection moved with the settings block. BrandLogoPreviewTests follows the control (same assertions,
new path). Release 0 warnings; 15 tests pass across brand logo, zoom, entitlement and preview
history.
2026-11-01 09:16:27 -08:00
gramps c418288215 fix(settings): the App Settings panel scrolls — the lower options were clipped off-screen
The shell is MaxHeight 700 and the settings panel had grown past it with no ScrollViewer,
so everything below roughly the transition section was unreachable. That is why 'Tablet
(Horizontal Rectangle)' existed in the XAML and in the model but never appeared in the app:
the shape control itself was cut off, not the option.

Panel row is now * with an Auto ScrollViewer, so every section is reachable and the next
one added cannot silently fall off the bottom.
2026-11-01 09:08:08 -08:00
gramps 517e60d593 fix(zoom): the magnifier freezes the layers — no picking up objects while it is open
While zoom is active a left drag moves the box and never selects or moves a layer beneath
it (creator ruling 2026-11-01). MouseMove already owned the zoom drag; the missing guard was
in MouseLeftButtonDown, which happily hit-tested a layer first and left a stray selection
plus a half-started drag behind the moving box.
2026-11-01 09:06:05 -08:00
gramps c3bbf1fdda fix(zoom): ignore Ctrl auto-repeat — the box was closing itself as it was created
Creation holds Ctrl (Ctrl+right-click), and Windows repeats a held modifier. The repeat
tick landed after the box existed, so the dismiss handler saw an open box and closed it:
appear, then vanish ~500ms later. Guarded with e.IsRepeat, which only ever skips the
repeat events — a genuine second press still closes the box.
2026-11-01 09:01:02 -08:00
gramps 15734a5477 feat(zoom): Ctrl closes the box, and a Follow pill that re-anchors the capture
Two creator directives, 2026-11-01.

**Ctrl-down dismisses an open zoom box.** Window-level PreviewKeyDown, so the same hand that
opened it can put it away. It cannot cancel itself at creation: Ctrl is already held before
the right-click that makes the box, so the keydown fires while nothing is open.

**Follow mode, default Anchor.** A PillToggle in App Settings ("Follow the box"), persisted
as Zoom.FollowBox. Off — the default — keeps the 2026-10-08 model: the capture stays on the
clicked point and dragging only moves the box over it. On, UpdateZoomMove re-anchors the
capture to the box centre (half the box's size, clamped per axis), so the magnifier shows
whatever it hovers. Flipping to Follow while the box is open snaps it immediately.

Three tests: anchor drags leave CaptureX alone, follow moves it to the expected quarter-offset
centre, and the setting round-trips through LayoutStore (tested against the store, not via two
view models — two live VMs fight over the temp DB, which is a fixture problem, not a bug).

Both new test classes share a collection tag: MainViewModel.LayoutPathOverride is static, and
xUnit runs classes in parallel, so without it the two view models collided (one failure in a
combined run, clean when run apart).

Release build 0 warnings; 14 tests pass across zoom, entitlement and preview history. Debug
build is blocked by the running app (PID 47388) — close it and the exe updates.
2026-11-01 08:51:28 -08:00
gramps 1d2d58f1d7 fix(zoom): the gesture now creates the box instead of only closing it
Creator report: Ctrl+right in edit mode and left-click in preview did nothing when
neither streaming nor recording. The bindings were correct; the action was not.

Two causes, both in the model rather than the handler:
- ActivateZoom opened with `if (!(IsLive || IsRecording)) return;` — the box could not be
  created outside a session at all.
- ToggleZoom only ever called DismissZoom, so "toggle" could turn it off and never on.

Now ToggleZoom(x, y) activates at the cursor or dismisses if already open, and the live-only
guard is gone — the magnifier is an editing aid as much as a live one. Both call sites pass
the canvas point, so the box still anchors on the click rather than the frame centre.

Debug 0 warnings; ZoomBox + ZoomMagnifier tests pass (3).
2026-11-01 08:43:17 -08:00
gramps 4b91760f92 fix(preview): Ctrl+right no longer opens the menu, and the social bar keeps its click
Two event-pairing bugs found by validating every handler instead of trusting the table.

1. Ctrl+right-click opened the context menu in edit mode. Cause: WPF's PopupControlService
   opens menus on right-button-UP, and only the DOWN event was being handled — so the zoom
   toggled and the menu appeared under it. Both events are now handled when Ctrl is down.
   (The creator saw this in edit mode; preview mode had the same latent bug.)

2. The social bar's left-click was dead in preview mode and dragged the layer beneath it in
   edit mode. Cause: PreviewGrid handles the TUNNELLING PreviewMouseLeftButtonDown, which
   fires before the bar's bubbling handler — and since preview-mode left-click now toggles
   zoom, that handler swallowed the bar's click too. The preview handler now steps aside when
   the point is inside the bar's bounds (SocialBarElement.RenderSize, translated to canvas).

Audited and left alone because they are correct: left-down → resize handle / element drag /
deselect; move → zoom drag before edit drag (by design); left-up → EndPreviewDrag; right-up
→ zoom-drag release then the Ctrl swallow.

Build Debug 0 warnings, 22 tests pass across ZoomBox, PreviewHistory and SocialBar.
2026-11-01 08:19:17 -08:00
gramps 6d1c982e26 feat(entitlement): IEntitlement seam — Steam DLC answers, everything fails closed
The first half of surge day 1. One question, "may this session emit Pro output?", with
one method on the interface so nothing downstream knows which rail proved it.

SteamEntitlement reads the lifetime SKU the way Valve intends: SteamAPI.Init() then
SteamApps.BIsDlcInstalled(dlcAppId). Four different failure paths all resolve to the
free tier rather than to Pro or to a crash — no DLC id configured, no Steam client,
steam_api64.dll missing (a throw on first touch), and simply not owned. A copied folder
run without Steam is the common case, not an exceptional one.

NotConfigured() is the shipping default until Steamworks creates the DLC app; it answers
false without ever touching the native library, so today's builds behave exactly as they
did before this commit.

HasProOutput is now IsPremium || DeveloperMode || rail, with an internal EntitlementOverride
test seam (internal, because a public setter here is a licence bypass). Four tests: no id
reads false, no client reads false without throwing, the fake reports what a test sets, and
the view model takes its answer from the rail.

Steamworks.NET 2024.8.0 added; the native DLL still comes from scripts/steam-sdk.sh, so the
csproj change is three lines and no formatting churn.
2026-11-01 08:13:25 -08:00
gramps 279eb08dea feat(preview): right-click is the menu, zoom is the single-handed gesture
Creator ruling 2026-11-01, and the reason is live-stream ergonomics: no two-handed
chords while a broadcast or recording is running.

- Edit mode: left selects / drags / resizes, right opens the context menu, Ctrl+right
  toggles the zoom box. Chords are acceptable here because editing is a stopped activity.
- Preview mode: left toggles the zoom box, right opens the context menu, and Ctrl+right
  is swallowed outright — handled rather than left to fall through, so Ctrl+right cannot
  open a menu by accident while one hand is on the mouse.

This also removes the old live behaviour where right-click activated the magnifier at the
cursor point (and dismissed it). Zoom in preview is now a plain toggle; if point-zoom is
wanted back it needs a one-button gesture, not a chord.

Build: Release 0 warnings. Debug is currently blocked by the running app holding the exe
(PID 47252) — close it and the Debug build copies normally.
2026-11-01 08:10:04 -08:00
gramps fdb27f0758 feat(ui): gear to the rail, EDIT on the panel edge, preview undo/redo
Three creator directives (2026-11-01), done ahead of the surge.

**Gear moved to the preview rail** — first tab, orange (Brush.Attention), gear icon
only. Left-click opens the same app menu the top bar gear owned (Settings / Bug /
Feature / About), right-click goes straight to App Settings; both behaviours moved
with it, so nothing lost an entry point. TopBar keeps the wordmark and EDIT.

**EDIT right edge aligned to the layers panel.** The work area is margin 8 + a 360px
column, the top bar is padding 16, so the shared edge is 352 inside the bar. TopBar
column 0 is now a fixed 352 grid with EDIT right-aligned in it — alignment by
arithmetic instead of by eye.

**Preview undo/redo: Ctrl+Z / Ctrl+Shift+Z.** Models/PreviewHistory snapshots element
REFERENCES plus geometry, visibility, order and membership — a deep clone would mirror
every property of a dozen element subclasses and quietly miss the next one added.
One snapshot per gesture (pushed at drag/resize start, never in the move loop), bounded
at 50, redo survives multiple undos, cleared on layout load. Gated on EditingActive:
undo while the preview shows program output would rewrite a scene nobody is looking at.

Honest scope, in the type's doc comment: layout edits only. Colour/opacity/border/font
edits are not captured yet, so Ctrl+Z after one of those restores the last layout change.

6 tests in PreviewHistoryTests; 19 pass across it and the neighbouring UI classes; Debug
and Release both build at 0 warnings.
2026-11-01 07:46:10 -08:00
gramps bd669f2e73 docs(handoff): the six-day surge is the next action 2026-11-01 07:35:49 -08:00
gramps 600088c794 docs(tasks): six days, all remaining code — no week 2, no after-launch list
Correcting my own plan: the surge is six calendar days (Mon 2 - Sat 7), Saturday included,
and it finishes the coding side. Every open row is assigned to a day; the week-2 and
after-launch sections are gone because there are no such sections.

Days: licensing (entitlement, licence UI, greyed tiers, system messages), release blockers
(visibility picker, output contract, webcam identity, Move transitions), resilience (TASK 32,
bandwidth step-down), content and media (text layer, media wiring, profanity filter,
scheduled streams), data and settings (reward persistence, YPP slice 2, defaults split,
scene audio, settings round, disk hygiene), finish and prove (publish recording, undo, gold
pass, full sweep).

Target 6-8 units a day against a measured baseline of 3-5 — that gap is the surge. If a day
overruns, the smallest item moves; a launch blocker never does. Day 6 is the only polish day.
2026-11-01 07:35:38 -08:00
gramps 68fdacc12d docs(handoff): trim to the 40-line cap 2026-11-01 07:33:00 -08:00
gramps 724f940f5e docs(handoff): 40 lines 2026-11-01 07:32:44 -08:00
gramps 832955bbb8 docs(handoff): fit the 40-line cap 2026-11-01 07:32:30 -08:00
gramps 7dab7ded36 docs(handoff): HANDOFF under the 40-line cap for the surge plan 2026-11-01 07:32:13 -08:00
gramps b2e8b26b6e docs(tasks): the surge week, coding only — five days sized at 3-5 units each
Non-coding is parked on Valve (identity verification, store page, depot); the week is code,
tests, debugging, polish. Ordered by what actually blocks a public build: entitlement seam
and licence UI (days 1-2), greyed tiers (2), visibility picker (3), stream resilience (3-4),
output contract (4), gold pass (5).

Sized honestly against the measured rate: ~50 units remain, 15-25 fit in five days, so the
plan names week-2 candidates and the five items that should not block launch at all
(scheduled streams, scene audio, defaults split, YPP slice 2, feedback batch).

Every unit carries the same definition of done — clean build, per-class tests, verify.sh on
the declared scope, and the falsified doc corrected in the same commit.
2026-11-01 07:31:52 -08:00
gramps f189c5e328 build(steam): the SDK zip IS the only download — ContentBuilder included, links recorded
The partner dashboard offers exactly one download because there is only one: the SDK zip
carries the runtime (steam_api64.dll) and the release tooling (sdk/tools/ContentBuilder
with steamcmd.exe for Windows and steamcmd.sh for Linux/macOS, plus the app_build and
depot_build VDF templates). Verified against the zip rather than assumed.

scripts/steam-sdk.sh gains --contentbuilder, opt-in because it is 27 MB of binaries that
only the uploading machine needs.

Distribution.md §2 now opens with that fact — there is no separate ContentBuilder
download to hunt for — plus a reference table of the pages that actually get used:
SteamPipe upload, DLC setup, BIsDlcInstalled, store artwork sizes, Coming Soon window,
the Wallet rule, recurring subs, partner support and Steamworks.NET.
2026-11-01 07:26:51 -08:00
gramps 1fe84be64a build(steam): stage steam_api64.dll beside the exe — one file, not the whole SDK
The SDK zip is 24 MB of headers, examples and tools we never build. The runtime
dependency is one native library, so scripts/steam-sdk.sh extracts exactly
sdk/redistributable_bin/win64/steam_api64.dll into steam/ (gitignored, like ffmpeg/ —
the script is the audit trail) and writes steam_appid.txt = 480, Valve's Spacewar app,
so SteamAPI.Init() resolves an app id locally before LlamaCasty has one of its own.

Two csproj details that would have cost a debugging session:
- Link="steam_api64.dll" puts it BESIDE the exe; without Link, None items keep their
  relative path and it lands in steam/, where the loader does not look.
- ExcludeFromSingleFile, for the same reason ffmpeg needed it: a native DLL otherwise
  gets swallowed into the single-file bundle and cannot be loaded at runtime.

steam_appid.txt is Debug-only — Valve's guidance is not to ship it — and the publish
output confirms it: dll present at the root, no appid file, exe still 127 MB (proof the
DLL was not bundled).

Also queued, as asked: preview undo (Ctrl+Z / Ctrl+Shift+Z) in edit mode, noted as the
partner to the defaults-vs-current split — undo covers the slip, defaults cover the mess.
2026-11-01 07:23:29 -08:00
gramps 27fc6fb507 feat(profile): the free budget is pixels and fps, enforced once — with a dev-only release hatch
The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.

Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)

Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).

Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.

Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
2026-11-01 07:11:49 -08:00
gramps abfab25685 build: the exe is LlamaCasty.exe, and the publish script proves the artifact
Renames AssemblyName to LlamaCasty before the first Steam depot — Steamworks is explicit
that renaming a live exe is painful, so this had to land first, not later.

Four assembly-qualified pack URIs carried the old name, not three: MainWindow (window
icon), OverlayHost (logo), RenameRecordingDialog and KeepBroadcastDialog. Pack URIs
resolve by assembly name at runtime, so a stale one compiles, passes every test and then
silently renders nothing the first time that view opens. AssemblyIdentityTests guards
both halves — the assembly name, and no source XAML still naming the legacy assembly.
Packaging/Package.appxmanifest and package-msix.sh carry the exe name too.

scripts/publish.sh replaces the by-hand publish: single-file win-x64, ReadyToRun,
ffmpeg beside the exe, and it refuses to finish if ffmpeg, LICENSE, PRIVACY.md or
THIRD-PARTY-NOTICES.txt are missing, or if a test assembly leaked in.

That last guard exists because the first publish was wrong in a way no test could see:
PublishSingleFile + IncludeNativeLibrariesForSelfExtract bundled the libav*.dll family
into the exe — a 9-file output with no ffmpeg folder and a 182 MB binary, so the locator
would have found nothing at runtime. ExcludeFromSingleFile="true" on the content keeps
the folder a folder. Diffing against a control publish without the single-file flags
found it; guessing at item globs wasted two attempts first (recorded in MyMistakes.md).

Measured: 127 MB exe + 129 MB ffmpeg = 257 MB depot. Tests: AssemblyIdentity,
StorePackaging, BuildStamp, BundledFonts all pass; app builds with 0 warnings.
2026-11-01 07:01:50 -08:00
gramps 8f79b20a18 docs(distribution): Steam is the only channel — the zip, winget and the Store are dead
The site said "Coming soon to Steam" while the app docs still said direct download plus
winget primary with Steam parked. One of them had to move, and the creator's ruling is
Steam exclusive: a free app on Steam with the paid edition as a Steam DLC, and the
$10/month plan sold off-Steam by Polar.

Records the constraint that shapes the code, not just the prose:
- Steamworks requires in-app purchases to use the Steam Wallet (Microtransactions doc,
  quoted in commercial.md), so the monthly is *stated*, never *sold*, inside a Steam
  build; the lifetime is plain DLC bought on the store page and read with BIsDlcInstalled.
- Entitlement is `steamDlcOwned || polarKeyValid`, both resolving to one OutputProfile.
  steam_api64.dll ships beside the exe and is a functional dependency, not the rejected
  "IP protection" native DLL — without it DLC ownership cannot be read. Launched without
  Steam, the app falls back to the free profile rather than refusing to start.
- Signing is moot: Steam installs never meet SmartScreen. Artifact Signing stays on the
  shelf for the day a non-Steam download exists. Year-one fixed cost is ~$115.
- The exe rename must land before the first depot upload — Steamworks warns about
  renaming a live exe.

Distribution.md is rewritten to SteamPipe mechanics (publish output, depot, steamcmd,
DLC wiring, clean-account purchase/refund test). product.md, decisions.md (a new
"Steam exclusive" entry with every dead vector), TASKS.md (Steamworks entitlement row,
Steam packaging row), ai.md, index.md and the TASK 48 banner follow. MyMistakes.md gains
the Steam rules that cost real time today: installs skip SmartScreen, updates are free,
the Wallet rule, and the recurring-billing caveat.
2026-10-31 11:05:53 -07:00
gramps 57ef9e7a5e docs(commercial): the claim page was never needed, signing waits for revenue, and support is a filter
Four things derived this session superseded what the plan said twenty-four hours earlier,
and the docs are corrected where each one died:

**Licensing needs no server.** Polar's customer-portal endpoints authenticate on the key
itself plus the public organization_id; the org access token is only needed to create
products. Channel binding rides in `conditions: {channel_id}`, re-checked on every
validate — so the "claim page" (a hosted OAuth flow that wrote the UC id onto the key)
and the key database it implied are deleted. Device management, key rotation and
subscription auto-revocation are Polar's; four states, one lease, no callback endpoint.

**Signing is deferred until two subscribers.** It never removes the day-one SmartScreen
warning (EV's instant-bypass died in 2024), so buying it before revenue buys only the
publisher line. Renamed to Azure Artifact Signing, $9.99/mo Basic, and it requires a
paid Azure subscription. Year-one fixed cost drops from ~$135 to ~$15.

**The Store account is free.** Microsoft waived the $19 individual fee and removed the
$99 company fee in May 2026, which makes the Store the only zero-cost route to a
Microsoft-signed install — the free answer to SmartScreen, noted beside the paid one.

**Support is a filter, not a promise.** Email, best effort, no SLA, refunds on request
without argument; no training service, no helpdesk. A repeated question becomes a
training video on @llamacasty, which is content and monetization instead of a ticket.
GitHub issues on the releases repo are the deliberate friction.

Also settled: binaries live in a second Gitea repo (`llamacasty-releases`) push-mirrored
to a public GitHub repo — issues and assets public, code private, one publish direction
(a git mirror carries refs, not GitHub Release assets); `@llamacasty` is
standard-verified with intermediate features, so live streaming, >15 min uploads and
custom thumbnails work today and no SIM or ID is needed; and OAuth comes out of Testing
mode by publishing the app (100-user lifetime cap, no 7-day token death).

TASKS.md now opens with the four-day surge slice — artifact shape, output gate,
entitlement seam, real-machine day — because those are what the first public release
waits on. HANDOFF.md rewritten. MyMistakes.md gains the Polar recipe and the
"verify means three different things" entry. MARCOM.md gains the support→content loop
and the surface roles.
2026-10-31 10:08:05 -07:00
gramps 0b41b7db67 docs(mistakes): the www → apex recipe — CNAME, the silently self-signed cert, and the heredoc that ate $request_uri
www.llamacasty.com now resolves and 301s to the apex. Four traps worth keeping:
yunohost domain add --install-letsencrypt-cert exits SUCCESS with only a self-signed
cert (it skips LE when the parent has no wildcard), plain cert install then insists on
a diagnosis and needs --no-checks, a domain with no app 302s to the YunoHost SSO rather
than 404ing, and writing the nginx snippet over ssh with an unquoted heredoc silently
expands $request_uri to nothing — every path then redirects to the homepage. Also
recorded: both resolvers near this job lie about the name (workstation NXDOMAIN cache,
droplet dnsmasq answering ::1).
2026-10-31 09:05:16 -07:00
gramps 7827d239e2 docs(commercial): one source of truth for the money — prices, entitlement, channels
The commercial plan was spread over five documents written in five different weeks,
and they contradicted each other: product.md sold the Store, decisions.md called the
Store dead then re-opened, Distribution.md (736 lines) still described Polar as a
download host, MARCOM.md quoted "no subscription" next to a paid tier, and README.md
promised "free forever". An outsider could not tell what ships.

docs/ai/commercial.md is now the only place money is decided: free = whole app at a
921,600 px / 30 fps output profile covering the recording as well as the stream; Pro =
$10/mo or $400 lifetime through Polar, key bound to the buyer's YouTube channel ID via
a claim page, 2 activations, 7–14 day lease with offline grace, fail closed, never
downgrade a running stream; one portable zip + winget primary, Steam and the Store
parked; Azure Trusted Signing (~$9.99/mo), no EV; Founding 200 promo; ~$135/yr costs;
anti-piracy posture and what is permanently rejected.

Everything else now points at it instead of restating it:
- product.md — money sections replaced with pointers; packaging rewritten to
  winget-first, Store parked; signing updated to Trusted Signing.
- decisions.md — graveyard with cause of death for free-forever, the Store ordering,
  the updater, and the 2026-10-30 reversal of the quality-ceiling rejection.
- Distribution.md — rewritten to release mechanics only (artifact, GitHub Releases,
  winget manifest, signing, checklist, takedown): 736 → 120 lines.
- TASKS.md — TASK 54 rewritten, TASK 48's route settled, TASK 10 superseded, five new
  rows (entitlement seam, output-profile gate, licence UI, system messages, packaging).
- README.md — "free forever" replaced with the real split.
- capture.md — obs-backgroundremoval is GPL-3.0 and so licence-INCOMPATIBLE since the
  proprietary EULA; the old "compatible" line was falsified by that change.
- TASKS/task-10, task-48, task-51, task-54 — superseded-banner at the top of each, so
  none of them reads as current policy.
- HANDOFF.md — rewritten: plan settled, next code unit is the entitlement seam.

MARCOM.md and MONETIZATION.md (gitignored) were corrected in place: policy pointer at
the top of each, the flash removed from every live claim, the Polar fee break-even
corrected to ~$900/mo, and the projections flagged as written against a dead model.
2026-10-31 08:32:25 -07:00
gramps 90d5140906 docs(mistakes): the llamacasty.com deploy recipe, recorded
Deploying the site cost a session because the only documentation was a destination
('/var/www/linkstack/'), not a transport, and the failure modes looked like credential
problems rather than the two things they were: sshd offers publickey only on 2214, and
it appended a keyboard-interactive 2FA step that a non-interactive tool call cannot
answer (hang, then 'Permission denied (publickey)').

Records the whole shape: web root and owner, port 2214 key-only, password = sudo not
SSH, the 2026-10-30 removal of AuthenticationMethods/PAM 2FA, and the traps — the md
files must never be published (build-this.md now carries INTERNAL pricing), drvfs 777
needs the chmod pass, linkstack__2 is a different app, and 22/1319 are closed.
Recipe lives in llamacasty.web/deploy.sh.
2026-10-30 16:49:33 -07:00
gramps c72b61b61e docs(decisions): the site publishes the model, not the terms
Records the split so neither side drifts: the app repo holds the real numbers (720p30 free, $10/mo
or $400 lifetime, Store individual account) and the site holds only the promise (free forever, every
feature unlocked, a paid edition at launch).
2026-10-30 16:16:31 -07:00
gramps 54de9c3dfb docs(decisions): Store route re-opened via an Individual developer account
Creator ruling 2026-10-30: the Microsoft Store is the distribution target again, through an
Individual (not Company) developer account — which is what made the 2026-10-22 withdrawal necessary
in the first place, and why no legal entity is now required. The publisher line shows the verified
individual name; distribution work stays parked until the account exists, and the site's 'coming
soon to the Microsoft Store' copy is accurate again.
2026-10-30 16:15:28 -07:00
gramps 0498f7e130 legal: proprietary EULA + privacy policy; GPL terms retired
Licensing posture changes to closed-source commercial software (creator ruling 2026-10-30): free
tier caps output at 720p30, a paid licence unlocks 1080p ($10/month or $400 lifetime).

- LICENSE is now an End User License Agreement: grant, what you may do with your own content,
  no redistribution/reverse engineering, third-party components prevail, updates at publisher
  discretion, warranty and liability limits, termination, governing law.
- ADDITIONAL-TERMS.md deleted — those were GPL §7 terms and have no meaning under the EULA.
- THIRD-PARTY-NOTICES.txt keeps every third-party obligation and drops the GPL header and §6
  source offer; it states plainly that LlamaCasty ships no source.
- PRIVACY.md is new: exactly what is written locally (layout DB, DPAPI token, capped log,
  recordings you choose), what leaves the machine (Google's APIs only, with your account), and the
  standing promises (chat rendered never stored, no telemetry, no ad SDKs).
- README, docs/ai/product.md, docs/ai/decisions.md and TASKS.md record the decision and the
  obligations that still bind (ffmpeg LGPL as a separate process, OFL fonts, CC0 icons, model
  provenance, notices in the About screen).

Deliberately NOT decided here: key issuance/verification and the resolution gate itself. Earlier
GPL builds remain GPL for the versions that shipped under it.
2026-10-30 16:10:38 -07:00
gramps 7829610cad docs(mistakes): record the record-struct zero-init trap
Two controls looked dead because new ScoreboardStyle() zero-initialised instead of applying the
declaration's defaults. Recorded with the rule (explicit parameterless ctor) and the testing lesson
(a defaults test must construct with new(), not with explicit arguments).
2026-10-30 15:24:33 -07:00
gramps c03acd0aa0 fix(scoreboard): the defaults were never applied — a record struct zero-initialises
Creator's defaults: white text, Background ON (black, 20%), speed capped at 200 px/s, text size shown
in real pixels, and "plate" dropped from the label.

The important find is underneath those: `ScoreboardStyle` is a record STRUCT, and `new
ScoreboardStyle()` zero-initialises — it does NOT run the constructor's parameter defaults. So the
shipped strip had null text colour, NO background and zero opacity, whatever the source said. That is
why the Background checkbox and the opacity slider looked like they had no code behind them: the
renderer was faithfully drawing the zeros it was handed.

An explicit parameterless constructor now carries the real defaults, and the renderer test asserts
them (white / on / black / 0.20 / max speed 200) so this class of silent-zero cannot return.

Readouts fixed too: the opacity and text-size setters never raised their own property, so the numbers
never moved while the render did. Text size now reads "24 px" instead of a bare slider value, and
snaps to whole pixels.
2026-10-30 15:24:14 -07:00
gramps 28525c90aa feat(scoreboard): scroll speed is a property (20-400 px/s, persisted)
The marquee now has the dial it needs. `ScoreboardPresenter.PixelsPerSecond` is the single owner of
the range (20-400, default 120), the layer's properties expose it with a live px/s readout, and the
value is persisted as Scoreboard.ScrollSpeed because it is a READABILITY choice — re-dialling it
every launch would be a tax on the creator who needs the strip to be readable.

Advance() still takes an optional override so tests can drive exact distances.

Guard: the presenter test proves speed is real (same elapsed time, twice the speed, different
window) — not a value that is stored and ignored — and the panel test pins the control, its range
and the persistence key.
2026-10-30 15:19:28 -07:00
gramps 460b36204d feat(scoreboard): it ticks — the marquee is the design, not an option
Creator, plainly: "the entire point of a ticker is to tick… so, tick." The static strip was my
reading of an earlier exchange; it is gone.

How it ticks without becoming the compositor's most expensive layer:
- `ScoreboardRenderer.RenderRun` rasterises the run ONCE per value change at its natural width plus
  a gap.
- `ScoreboardPresenter` crops a 1920-wide window at a moving offset each tick — a row-wise byte
  copy, no text layout, no re-raster.
- `TickScoreboard` runs on the existing 30 Hz meter clock and only while the strip is shown, so a
  hidden or disabled scoreboard costs nothing.

The dev simulation uses the same path, so what is previewed is what ships.

Test updated to the real contract: no tick → identical pixels; a tick → different pixels; a value
change → re-rasterised run. (The old test asserted frame-instance identity, which a cropped window
can never satisfy.)
2026-10-30 15:17:55 -07:00
gramps 882b8551d9 fix(scoreboard): the strip and the social bar now really share the frame
Reported: the ticker was painting OVER the socials. The coupling rule existed and was tested
(ScoreboardLayout.ResolveEdges) but nothing ever CALLED it — the strip's Y still came from its own
preference, so both bars landed on the south edge.

Now the resolution runs at publish time: the social bar keeps its side, the scoreboard takes the
other one, and its Y follows from that edge. The pair re-sorts whenever the social bar moves (the
preview click-toggle) or shows/hides, because the rule is "resolve from what is visible", not a
stored pairing.

The layer also gains an Edge preference (Top/Bottom) in its properties, labelled "socials take the
other" — a preference, never a claim on an occupied edge.

Also stabilised the opacity assertion: it samples a plate-only pixel 5px from the left edge instead
of the first semi-transparent pixel it finds (which could be anti-aliased text).
2026-10-30 15:11:35 -07:00
gramps e27e79962c fix(scoreboard): box follows the rendered strip, opacity proven, plate always settable, dev override
Four reports, three causes:

1. "Background plate has no code behind it" — the plate COLOUR and OPACITY controls were disabled
   until the plate switch was on, which reads as dead controls. They are always settable now; the
   switch is what decides whether a plate is drawn.
2. "Opacity doesn't work" — proven in the renderer test: 20% and 80% produce alphas of ~51 and ~204,
   so the slider reaches the pixels. What was actually wrong was the box.
3. The strip's BOX was fixed at 56px while a wrapped strip renders taller — the frame was scaled
   into the box and looked squashed/clipped. The source's width/height now follow the rendered
   frame, and its Y follows from the edge.
4. "Simulate (dev) should override Show the scoreboard" — it does: the preview gate is
   (_scoreboardEnabled || _scoreboardSimulate), so the dev toggle works even with the product
   setting off.

Not changed: the strip is STATIC by design (all enabled facts always visible, wrap rather than
scroll). If scrolling is wanted after all, that is a presentation mode to add, not a fix.
2026-10-30 15:09:19 -07:00
gramps 8caee21b9a fix(scoreboard): the strip finally reaches the preview, and dev labels are orange
"Clicking on everything produces no visible effect" — the cause was upstream of the controls, and it
is the same defect as the invisible alert box (TASK 47):

- `Source.DisplaySource` returned `VideoImageSource` only for ChatBox / WebSource / MediaSource /
  AlertBox. The scoreboard's frame was rendered and published, then never displayed.
- the preview's Image has a per-type Visibility trigger for each of those and none for IsScoreboard,
  so even a correct DisplaySource would have stayed collapsed.

Both are fixed, and the layer test now pins the frame-source list AND the trigger so the pair cannot
drift apart again.

Dev-only affordances are ORANGE from now on (creator ruling): "Simulate (dev)" is a TextBlock in
Brush.Attention, so a development control can never be mistaken for product. The panel test pins the
colour.
2026-10-30 15:05:33 -07:00
gramps 9405c54750 feat(scoreboard): dev mode can configure the board without signing in
Creator (2026-10-30): in dev mode the "Show the scoreboard" option must always be available.

The block's gate becomes CanUseScoreboard = IsConnected || IsDevelopmentBuild. Signed-out Release
still shows the inert block with its "sign in" line; a DEBUG build is fully usable, so the strip can
be toggled and previewed before there is an account. IsConnected raises it, so signing in flips it
live.

Guard: ScoreboardPropertiesTests now asserts the CanUseScoreboard gate (enabled AND hint), and the
VM surface list includes it.
2026-10-30 15:01:32 -07:00
gramps e6abc13461 feat(scoreboard): a DEBUG-only "Simulate" switch, so the strip is provable in the preview
Creator (2026-10-30): pre-1.0, use DEBUG — add a toggle that simulates the ticker so the strip can
be SEEN and its compositing verified before a real stream.

The sample travels the REAL ScoreboardRecorder a stream uses (one new member, a gift of three, a
milestone, a $12.34 Super Chat), so what the preview shows is exactly what live events produce — a
fake renderer would prove nothing. Toggling off clears to an empty session, so the empty state is
visible too.

Debug-gated at COMPILE time: `DevelopmentBuild` is a const true in DEBUG / false in Release, and the
checkbox binds its Visibility to it. A shipped Release build therefore has no dev control at all,
rather than one hidden by a runtime flag.

Guard: ScoreboardSimulationTests — the sample lands all four tallies through the recorder, off clears,
and the panel + const pair exist in the Dual (DEBUG/Release) form.
2026-10-30 14:58:56 -07:00
gramps ea1017776d feat(scoreboard): the properties are inert until the creator is signed in
The scoreboard is fed by the signed-in channel (chat events + channel stats), so offering its
switches to a signed-out user is a dead control. The whole block now binds IsEnabled to IsConnected
and shows one line — "Sign in to YouTube to use the scoreboard." — instead of looking broken.

Nothing extra is needed to re-enable: IsConnected already raises on sign-in/out, so the block
comes alive the moment the session lands.

Guard: ScoreboardPropertiesTests asserts the IsEnabled binding, the signed-out hint and that the
hint is gated by the inverse converter.
2026-10-30 14:54:52 -07:00
gramps 38c2ad6555 feat(scoreboard): a master on/off switch that also stops the polling
Creator: "even if it's not visible, we want to avoid polling."

ScoreboardEnabled is the layer's master switch in the properties panel. Off means two things:
- the strip frame is null in every scene (per-scene eye toggles stay independent), and
- the cadence policy returns None at every phase — a scoreboard nobody can see must not spend quota.

The switch is persisted (Scoreboard.Enabled), because one that resets would quietly resume polling.

The polling gate is ScoreboardPollsEnabled: master switch AND at least one scene with a visible
scoreboard — so hiding it in all five scenes also stops the polls.

Guards: ScoreboardCadenceTests adds the switch off across live/test/recording; ScoreboardProperties
pins the checkbox and the VM surface.
2026-10-30 14:54:05 -07:00
gramps 85a4a8a7d3 feat(scoreboard): the layer is available to ALL scenes (YT_ACTIVITY_TICKER)
Creator ruling: the Scoreboard layer exists in every scene rather than being added five times.
ScoreboardHealer.Ensure keeps exactly one per scene — healed on load like the Background and Brand
logo — so a scene can never end up with two, and 'off' is the ordinary per-scene eye toggle rather
than a delete.

It is appended as the TOPMOST layer on purpose: a full-width strip draws over the scene, and
inserting it above the background would break the Brand logo's documented invariant (directly above
the background). That choice is also why the layer-reorder test still passes untouched.

Also: a layer-list icon for the type, so the row is not blank.

Guard: ScoreboardHealerTests — one per scene, topmost, exact strip geometry, idempotent, and a
duplicate healed back to one.
2026-10-30 14:50:20 -07:00
gramps 96d528b3ac fix(scoreboard): clear the two nullable warnings
Both were mine, from the scoreboard units:
- ScoreboardLayout.FitLine null-forgave tileWidths on one expression and then iterated the field
  anyway; it now binds a non-null local once and uses that throughout.
- the output resolver dereferenced _scoreboardPresenter, which is assigned in InitScoreboard — it
  is nullable by construction, so the resolver reads '?.' exactly like the other optional services.

Clean build, 0 warnings, scoreboard tests green.
2026-10-30 14:45:14 -07:00
gramps d4a4b1c80e docs(handoff): scoreboard units 1-10 landed; providers + goal inputs remain 2026-10-30 14:43:26 -07:00
gramps b205ce9ba9 feat(scoreboard): unit 10 — the polling policy (quiet by default)
ScoreboardCadence.Due(phase, lastAnalytics, lastSubscribers, now) is the creator's rule as a pure
function: watch hours ONLY during a live stream, every 30 minutes (Analytics answers in days and
test/record sessions have no public audience, so querying then is pure waste); subscribers whenever
the app is working, every 60 s; and nothing at all when idle.

Deliberately returns 'no poll' rather than 'analytics only' — a poll kind the poller cannot perform
is a bug waiting for a caller.

Remaining for the scoreboard: the HTTP providers behind this policy (watch hours via Analytics
reports.query, subscribers via channels.list) and the timer that calls Due(). Recorded in HANDOFF.
2026-10-30 14:43:08 -07:00
gramps 9a6099af7c feat(scoreboard): unit 9 — the Analytics scope, requested incrementally
Scope becomes youtube + yt-analytics.readonly, with include_granted_scopes=true so an existing
session is asked for the NEW permission only. GrantedScopes records what Google says on the token
response (both the code exchange and the refresh), and HasAnalyticsScope is the flag the scoreboard
uses to offer a one-click re-consent instead of failing anonymously mid-stream.

TokenStore is unchanged on purpose: a launch-time refresh carries the scope list, so old sessions
report 'not granted' until the creator re-consents — the honest state.

The old auth test asserted 'exactly one scope'; it now asserts the new DECISION (two scopes, no
force-ssl, analytics read-only) rather than loosening.
2026-10-30 14:42:40 -07:00
gramps 7048bd8bff fix(scoreboard): the chat feed actually reaches the tallies
Unit 6 created the recorder and the session hook but nothing fed it — the strip would have stayed
empty through a whole stream. The poll's message handler now records each decoded event after the
chat and alert layers have seen it.

Caught by tracing the data path while wiring analytics, not by a test: the guards cover the tally
rules, not the seam into the poller.
2026-10-30 14:41:47 -07:00
gramps 534f56a7c4 feat(scoreboard): units 7+8 — the layer's properties panel (facts, colours, plate, size)
Selecting a Scoreboard layer now shows its settings, using the app's own widgets only:
- eight fact checkboxes (subscribers, watch hours, Super Chats, new members, milestones, last member,
  last Super Chat, last gift)
- text colour and plate colour via YtComboBox over BorderColorPalette (the app's one palette —
  TASK 63's rule: a picker IS the app's drop-down, never a hand-built menu)
- plate on/off, plate opacity, text size 14-24

Every change pushes through the presenter (SetStyle/SetTiles) and repaints the strip, so the preview
and the output follow immediately. One look per layout, stored on the view-model: the scoreboard is a
single billboard, not eight independent widgets.

Guard: ScoreboardPropertiesTests asserts the panel is gated on SelectedElement.IsScoreboard, binds
all eight facts and the four look controls, uses YtComboBox/YtCheckBox only, and that the VM surface
exists for each name (a typo would otherwise bind to nothing in silence).
2026-10-30 14:41:31 -07:00
gramps deb74529a6 feat(scoreboard): unit 6 — it is a LAYER (source type, strip geometry, resolver, session hook)
Adds SourceType.Scoreboard with the same per-scene eye toggle, drag and presence as every other
element — 'prominent on BRB, dialled back on Live' is geometry, not a second mechanism. Default
geometry is the strip: 1920 wide, 56 tall, bottom edge.

The output resolver returns the presenter's cached frame exactly like the chat box and alert box do,
and every session start clears the tallies. Preview publication writes one shared WriteableBitmap on
the UI thread (the pump thread must never touch one), refreshed only when a displayed value moves.

Guard: ScoreboardLayerTests pins the enum member, the geometry constants, the resolver case and the
session hook, so the layer cannot quietly become a special widget.
2026-10-30 14:40:31 -07:00
gramps 41b0cd6e98 feat(scoreboard): unit 5 — tile selection and the cached strip presenter
ScoreboardTiles decides which of the eight facts appear and shapes their text (money uses the
session's dominant currency and flags a second one rather than converting; watch hours carry
'as of {date}' because Analytics is a daily aggregate). Tiles are OPT-IN, so 'None' really means the
layer draws nothing, and 'Default' is the six session facts.

ScoreboardPresenter owns the raster cache: ScoreboardRecorder.Changed fires only on a visible change,
so the strip rebuilds only then — a per-tick raster would be the compositor's most expensive layer
for identical pixels.

Guard: ScoreboardPresenterTests asserts the cache identity (same instance when nothing moved, a new
frame when a value moved, reuse when a recipient-side gift event arrives) and that an empty selection
returns no frame at all.
2026-10-30 14:38:59 -07:00
gramps fbcccea0f7 feat(scoreboard): unit 4 — the strip renderer (plate, text colour, fit, wrap)
Rasterises enabled tiles into one straight-alpha BGRA frame: measured widths -> FitLine (shrink,
then wrap, never scroll) -> centred lines with dim separators, over an optional rounded plate whose
colour and opacity are the creator's knobs. Uses OutputPalette.Sans (bundled Inter) because on-stream
text wears the app's typefaces.

Static by design: it takes plain strings, so the caller caches the frame and only re-rasterises when
a value changes — a strip that re-rendered every tick would be the compositor's most expensive layer
for no benefit.

Guard: ScoreboardRendererTests paints real frames and inspects pixels — plate colour AND opacity,
text colour reaching glyphs, plate-off leaves the area transparent, and an empty tile set returns no
frame at all.
2026-10-30 14:37:34 -07:00
gramps 359e58a4c2 feat(scoreboard): unit 3 — edge coupling and line fitting, both pure
ResolveEdges: the strip and the social bar are a coupled pair that own opposite edges whenever both
are visible; the social bar keeps its choice and the scoreboard is pushed to the other edge. Hide
either and the survivor keeps its own preference — so hiding the scoreboard frees the social bar,
which is what 'resolved at render time' has to mean.

FitLine: takes MEASURED tile widths (the renderer measures with FormattedText; a guessed character
width would make the fit lie for a long handle), keeps full size when it fits, shrinks above the
14px legibility floor, and only then wraps — never scrolls, because a reader cannot wait for the
fact to come round.
2026-10-30 14:36:56 -07:00
gramps 6b6ccd7b0a feat(scoreboard): unit 2 — session lifecycle (start clears, End freezes)
ScoreboardRecorder owns the one scope the numbers promise. StartSession resets and marks running;
Record ignores everything outside a session (a stale tally from the last stream is exactly the
'today' lie this design avoids); EndSession freezes the numbers but stops them moving, so an End
screen can still show them. Changed fires only when a displayed value actually moves, which is what
keeps the strip's raster cache honest.
2026-10-30 14:36:13 -07:00
gramps 3ed8386303 feat(scoreboard): unit 1 — session tallies, with the gift double-count guard
ScoreboardState folds the six live-chat event kinds into the numbers a creator puts on screen:
last member, last Super Chat, last gift, members gained, milestones, and session money per
currency. Scope is the running session (creator ruling): reset at start, dead at End, no
persistence.

Two rules are load-bearing:
- money is keyed by currency and never summed across them; the tile shows the dominant currency by
  VALUE, so a single EUR tip cannot flip the display away from the larger USD total
- a gifted membership is counted ONCE, from membershipGiftingEvent (which carries the count);
  giftMembershipReceivedEvent is the recipient's view of the same purchase and changes nothing.
  The test fires five recipient events after one purchase of five and the total must not move.
2026-10-30 14:35:42 -07:00
gramps e92ce4bb63 fix(dock): the master ladder moves when no session is running
Reported: no activity in the master meter.

Cause: `MasterLevel` measures the buffer the ENCODER receives, and that buffer only exists while the
mix is being pulled — streaming or recording. Idle, nothing fills it, so the master read zero while
the bays below it moved. A master that is dead between sessions is a gap in the design.

Now: while a session runs the master shows the measured mix (unchanged); idle it shows an estimate
computed from the same measured sources and the same gains the mix applies
(`MeterScale.MonitorFraction`, pure + tested). Two ladders can therefore never tell contradictory
stories about whether audio exists.

Guard: MeterScaleTests pins the estimate — silence reads zero, a quiet source at unity is visible,
two sources add, and gain is applied in the LINEAR domain like the mix (scaling the display fraction
would be a different, wrong curve).
2026-10-30 14:01:29 -07:00
gramps ec293e54b0 feat(dock): TRAX becomes a background level, not a channel strip
Creator ruling (2026-10-30): TRAX is background music and is controlled separately from desktop and
mic — so KISS it:

- TRAX loses its own ladder and slider. It is one number plus ±5 dB buttons, disabled at the ends
  (−60 dB floor, +6 dB ceiling).
- It STARTS at −50 dB, taken from the creator's own meter readings, and moves in 5 dB clicks. If that
  proves inaudible on their setup, six clicks lands at −20 — the buttons exist for exactly that, and
  `TraxDefaultDb` is one constant.
- Layout follows: Desktop's volume now sits directly beneath its ladder, and the TRAX row (name,
  play/pause, level) sits beneath that, inside the GAME bay. The bays are MIC | GAME+TRAX | LIP-SYNC.
- The metering tap on the player stays (it is how "is the music producing sound" is answered) but has
  no ladder of its own; the desktop bed already shows the music.

Guards updated: TraxBayTests now asserts the stepper wiring and that the row has NO SegmentMeter;
GameBayTests asserts the desktop fader sits below the desktop ladder.
2026-10-30 13:46:05 -07:00
gramps 93dc6592ac docs(dock): the desktop ladder says it includes TRAX
TRAX is not a separate bus: it plays through the default output device and WASAPI loopback taps
that device, so the music genuinely is in the desktop bed — the Desktop ladder moving with a track
is correct, not a leak. The bay now says so on the label and in a tooltip, because two moving
ladders for one sound invites exactly this question.

No behaviour change: routing stays right (the stream hears the music once, via loopback).
2026-10-30 13:34:27 -07:00
gramps 8d23502220 fix(trax): the fader now actually changes the level — and the meter follows it
Reported: pegging the TRAX fader changes nothing audible, the ladder never reaches red, and the
ladder does not scale with the fader.

Cause (two defects in the same setters):
- UserGain wrote the OUTPUT DEVICE's volume. NAudio's WaveOut ignores that, so the fader was inert.
- LocalGain (the desktop-mute follow) recomputed the sample-chain volume WITHOUT the user gain, so
  every duck/mute silently discarded whatever the creator had set.

Fix: one `EffectiveVolume(user, local)` = MusicVolume × user × local, applied to the sample chain by
both setters. The meter sits AFTER that stage, so the ladder now scales with the fader and can reach
the peak band on a loud track — exactly what pegging should look like. Max user gain is +6 dB, so
"pegged" means the file at unity, not a magic boost.

Guard: MusicLevelTests pins the arithmetic (default half, pegged double, mute multiplies to zero),
that the user gain survives the mute follow, and that nothing may drive the output device's volume
again — the no-op that made this invisible.
2026-10-30 13:32:24 -07:00
gramps 2607ecf210 fix(dock): TRAX ladder gets the mic's ballistics (and a 33 Hz beat)
Reported: the TRAX bar moves, but its sampling looks far slower than the mic meter.

Cause: the TRAX number was a raw instantaneous peak per metering block — a stutter sampled by the UI
timer, with no smoothing. The mic looks fluid because it is smoothed at audio-callback rate.

Fix: the player now smooths its measured level with the SAME curve the mic uses
(MeterScale.NextLevel: fast attack, gentle decay), and the dock's meter clock runs at ~30 Hz instead
of 20. Two ladders that move alike, because they are the same ballistics.

Guard: MeterScaleTests pins the attack/decay behaviour alongside peak-hold.
2026-10-30 13:28:32 -07:00
gramps 839bc991a2 fix(dock): meters beat on their own 20 Hz clock, and TRAX silence is diagnosed
Reported: TRAX plays audibly but no ladder registers anywhere.

Two distinct faults, one of them mine:
1. The meter readouts were refreshed only from the MIC's level callback. With a silent or absent
   mic there is no tick, so a playing TRAX never updated its ladder — the level was measured, the
   UI simply never asked for it. Meters now have a 20 Hz DispatcherTimer of their own.
2. If the player's own meter still reads 0 while playing, the samples are not reaching the device
   (or metering is not attached). That now writes ONE line to startup.log after 2s — so the next
   report says which side is broken instead of another round of guessing.

Note: TRAX rides the desktop loopback, so once the mix captures it the GAME ladder should move too;
if it does not, the loopback endpoint and the music's output device differ.
2026-10-30 13:24:29 -07:00
gramps a54149bd7c feat(dock): TRAX gets its own ladder — measured at the player's output
Creator: "why is there no meter for when trax is playing?"

Routing answer: TRAX rides the desktop loopback, so it already moved the GAME ladder — there was no
separate TRAX signal in the mix. But that ladder cannot answer the question a deaf creator actually
has: "is the music producing sound at all?" The play glyph cannot either — an open file with a dead
decoder still reports Playing.

So the player now measures its own output: \`MusicPlayer\` wraps the volume stage in an NAudio
\`MeteringSampleProvider\` and exposes \`Level\`; \`ITraxPlayer.Level\` keeps the fake honest. The VM maps
it through the same display scale (\`TraxMeterFraction\`), zeroed when not playing, and the TRAX bay
carries a 24-segment ladder above its fader.

This reverses my earlier "no ladder, nothing honest to show" — the honest thing arrived with a real
measurement. TraxBayTests flipped accordingly (it now asserts the ladder AND its source).
2026-10-30 13:22:13 -07:00
gramps 8aa483f933 docs(handoff): audio dock shipped, awaiting an eyes-and-hands pass 2026-10-30 13:20:19 -07:00
gramps 16689c0a80 feat(dock): the dock is now the only audio surface (unit 10, final)
Deletes the preview's live-controls strip — mic meter + speaker + volume, the game-audio chip,
the TRAX button and the lip-sync slider — and the three code-behind handlers that served it.

One sound surface instead of two: the same gains in two places was the original duplication, and
two places to set a gain is how a creator ends up with a meter that disagrees with what they hear.
~16.7 KB of XAML and 60 lines of event code gone.

Verified: the preview still loads and its interactions still work (selected-layer hit testing, the
socials preview, the brand-logo preview), plus the theme's resource guard.
2026-10-30 13:19:58 -07:00
gramps 5889d71db6 feat(dock): master strip + MONO output (the accessibility switch)
Unit 9, the one that matters most for this creator.

MASTER STRIP (top, nearest the preview):
- one ladder fed by the MEASURED mix — `AudioMixer` now measures the buffer the encoder receives
  (`MasterLevel`, `MasterImbalanceDb`), so the master cannot show a source instead of the output
- `-12.4 dB` readout, and ONE L/R flag that appears above a 6 dB gap instead of a second ladder
- bitrate / FPS / dropped / duration from the existing health poll

MONO OUTPUT:
- `AudioFold.ToMono` averages each stereo pair into both channels, applied after the limiter and
  the sync delay, so the stream AND the recording get what was folded
- `MonoOutput` is persisted (`Audio.MonoOutput`) and restored at startup: an accessibility switch
  that resets each session is an accessibility failure
- one click in the strip, remembered forever

Guard: AudioFoldTests (hard-left reaches the right at half amplitude, hard-right — the inaudible
case — reaches the left, centred material keeps its level, mono buffers untouched) and
MasterStripTests (the strip binds the measured mix + the switch, the store round-trips it, and the
fold is the tested helper rather than new arithmetic). 33 audio tests green.
2026-10-30 13:18:23 -07:00
gramps a6f63f56f3 feat(dock): the LIP-SYNC bay — offset, readout, zero detent
Unit 8. Re-houses the shipped TASK 22 offset in the dock:
- slider −200…+200 ms, snapping to 5 ms ticks (the existing property still clamps and persists)
- readable readout: "0 ms (aligned)" when right, signed otherwise ("+50 ms", "−120 ms")
- presets 0 / +50 / +100; 0 IS the detent — a button is easier to hit than a snap, and "the lip-sync
  is right" deserves one click.

Guard: SyncBayTests pins the binding, the readout, the presets and the snapping, and that the
offset stays the persisted property (no second source of truth).
2026-10-30 13:16:19 -07:00
gramps 7339df5fcd feat(dock): the TRAX bay — track, play/pause, level, duck badge
Unit 7. The bay shows the track name (left-click toggles playback, right-click opens the picker —
the label stays the control), a play/pause button, the duck badge, and a dB level.

No ladder here, deliberately: nothing measures the music bed, and a decorative ladder would break
the measured-not-painted rule. A test asserts the bay has no SegmentMeter.

The level is real: `ITraxPlayer.UserGain` is a new creator gain multiplied onto the fixed design
volume, separate from `LocalGain` (which follows the desktop mute). The VM's `TraxVolumeDb` is the
same dB scale as the other faders, so unity means the bed's design level.
2026-10-30 13:15:17 -07:00
gramps a1d8e761ea feat(dock): the GAME bay — dB fader, measured ladder, mute, real duck badge
Unit 6. Mirrors the mic bay for the desktop/game bed, plus the badge that matters to a creator who
cannot hear the duck: it shows while the ducker is actually attenuating, and it prints the gain the
mixer applied (−12.0 DUCK by design).

- `AudioMixer.DuckGain` exposes `AutoDucker.CurrentGain` instead of leaving it internal.
- VM: `GameVolumeDb` / `GameVolumeDbLabel` (fader face), `GameMeterFraction` (measured level),
  `IsGameDucking` + `GameDuckLabel` refreshed on the level tick — the same moment the ducker runs,
  so the badge cannot lag behind the mix.
- The bay: header chip + source, ladder, mute dot (uses the meter band, so mute reads at a glance),
  dB readout, fader.

Guard: GameBayTests — the dock binds all four, `AutoDucker.DuckGain` is −12.04 dB and the label is
produced through the same dB scale as the faders, and the mixer property exists so the badge cannot
become a hardcoded number.
2026-10-30 13:12:10 -07:00
gramps 3524ede480 feat(dock): the MIC bay — dB fader, measured ladder, mute, picker
Unit 5. The dock's first working bay:
- 24-segment ladder fed by `MicMeterFraction` — the MEASURED display level (already scaled by the
  volume and zeroed when muted), never the gain setting.
- Fader in dB via `MicVolumeDb` (−60 … +6, 0 = unity) with a mono-weight readout
  (`MicVolumeDbLabel`), so "−6.0 dB" is what the creator reads instead of "0.50".
- Mute button whose dot uses the existing MicStatusBrush; clicking the device name opens the mic
  picker, so the label stays the control (the KISS rule: one place to change the mic).

The preview's own mic row stays for now — it is removed in the final unit, after the dock bays are
complete, so nothing is lost if a bay misbehaves.

Guard: MicBayTests — the bay binds the dB face and the measured level (not the gain), the fader
range IS the scale's range, the VM surface exists, and mute still zeroes the ladder.
2026-10-30 12:46:57 -07:00
gramps 7d8021ca09 fix(dock): master strip on top, against the preview
During a live session attention sits on the preview, so the master sound bar belongs as close to it
as possible — master row first, the four bays below. Collapsed mode is unchanged: 36px is the
master strip on its own, so it stays exactly where the eye already is.
2026-10-30 12:45:12 -07:00
gramps 5552bf97a1 fix(dock): inside the work area, bottom edge aligned with the layers panel
The dock was a sibling row BELOW the whole work area, so it could not line up with the layers
panel and read as sitting under it. It now lives in the work-area grid (preview column, bottom
row), and the left panel spans content + dock rows — both columns end on the same line, which is
what the creator asked for.

No behaviour change; placement only.
2026-10-30 12:42:49 -07:00
gramps a7d1afc70b fix(dock): spans the preview column and loses 20px
- The dock now sits in the preview column (Grid.Column=1), so its left edge is the layers panel's
  right edge instead of starting under the brand column. It is a preview-column control, not a
  full-width footer.
- Expanded height 168 → 148 (112 bays + 36 master strip): the mockup's number read as wasted space
  beneath the 154px scene strip.

Guard: AudioDockLayoutTests pins 148 as the design number, so the constant cannot drift silently.
2026-10-30 12:39:36 -07:00
gramps 1a9e8b8d36 feat(audio dock): the shell — 168px, auto-collapsing, replacing the empty bottom bar
Unit 4 of the sound-bar refactor. `Controls/AudioDock` takes the bottom row that held the empty
`BottomBar` spacer (deleted): four bay containers (MIC / GAME / TRAX / LIP-SYNC) above a 36px
master strip, all token-styled.

Height comes from `Models/AudioDockLayout`, not from a setting: 168px normally, 36px below a 900px
window height. At the 1366×768 minimum a full dock leaves ~398px for the preview, so the collapse
is mandatory, not cosmetic. The dock listens to its window's SizeChanged and re-applies.

Nothing here pretends to be a meter yet — the bays and the master readouts fill in over the next
units, each with its own test.

Guard: AudioDockLayoutTests pins both sides of the threshold (768 collapsed, 1040 expanded), the
boundary itself, and the two heights as the design's numbers.
2026-10-30 12:37:17 -07:00
gramps 0f62e55474 feat(audio): the segment meter control (unit 3 of the dock)
`Controls/SegmentMeter` draws a 24-segment ladder in ONE OnRender pass — no per-segment elements
updating on the UI thread, which is the difference between a meter and a UI thread tax. Level and
peak are display fractions (what was MEASURED), never gain settings; vertical for the bay ladders,
horizontal for the master strip.

Bands come from `Helpers/MeterScale` (nominal/caution/peak, DESIGN.md thresholds) and live as
dependencies of the control, so the painting logic is pure and testable: the band a segment paints
is the level it measured. Brushes resolve from the theme tokens via SetResourceReference, so the
ladder cannot drift from DESIGN.md.

Peak-hold is the reason the header exists: a bar that follows the moment hides clipping, and
clipping is the one audio fault that cannot be fixed after the stream.

Guard: MeterScaleTests — band edges (inclusive at −12 and −3, as the standard states the ranges),
the −60…0 display scale, and peak-hold rising instantly then decaying at 0.35/s.
2026-10-30 12:36:05 -07:00
gramps 575caa7bc4 feat(audio): the meter knows which side a signal is on (unit 2 of the dock)
`AudioLevelMeter` measured one number for everything. It now also keeps smoothed per-channel
levels with the same attack/decay, plus `ImbalanceDb`. Mono samples report both sides equal (a mono
source is centred), so the two never disagree about whether audio exists.

The dock shows ONE ladder per source — two ladders per bay was visual noise for a rare failure —
but this creator is profoundly deaf on the right, so the one case that matters is "the sound is
panned away from me". That is now measurable and will drive a flag, not a second ladder.

Semantics that mattered: a silent side is compared at the −60 dBFS floor, so a fully one-sided
signal reads ≈54 dB rather than "nothing to compare". My first version returned 0 for exactly the
case the feature exists for; the test caught it.

Guard: StereoMeterTests — hard left, hard right (the inaudible case), balanced stereo, mono
centred, and silence-is-not-an-imbalance. 31 audio tests green.
2026-10-30 12:33:40 -07:00
gramps afe21682c2 feat(audio): the dock's dB scale, and the mono fold primitive
Unit 1 of the sound-bar refactor. The model stores linear gain; the dock shows decibels, because
"−6 dB" is readable and "0.50" is not. `Helpers/GainDb` owns the mapping:

- 0 dB = unity (the old 1.0), range −60 dB (true silence, exactly 0 gain) … +6 dB.
- Labels for the fader readout: "−∞", "-6.0", "+1.5".
- `MonoFold(L,R)` = (L+R)/2, the accessible downmix: hard-panned content becomes audible on both
  sides at half amplitude instead of absent from one. Creator context: profoundly deaf on the
  right, so single-sided audio is inaudible, not merely annoying.

Guard: GainDbTests pins unity, the −6 dB half, the exact-zero floor, the +6 dB ceiling, a
round-trip across the range, the labels, and the fold arithmetic (a right-only 0.5 arrives on the
left at 0.25).
2026-10-30 12:32:07 -07:00
gramps 17249229c8 feat(top bar): EDIT moves beside the gear; the gear wears the app's orange
- EDIT leaves the centre session cluster and sits immediately right of the gear, in the brand
  column — so mode lives with the controls that configure the app, not with the session buttons.
- The gear is a LlamaCasty control, so its icon is `attention` orange instead of muted grey.

Guard: UiShellTests asserts the gear fill and that EDIT appears before the centre (session)
cluster, so a later reformat cannot quietly put it back.
2026-10-30 11:18:36 -07:00
gramps 0f8647790d fix(top bar): a signed-out ON-AIR press starts sign-in instead of disabling the button
Reported: EDIT on + ON-AIR on, signed out → the button reads "Test Stream" and cannot be pressed.
Cause: the primary button bound straight to StartTestCommand, whose CanExecute needs a connection.
StartSession already routed signed-out ON-AIR to the sign-in flow; the test path bypassed it.

The route is now explicit and pure: RouteFor(kind, connected) → SignIn | Record | Live | Test. The
primary command calls SignInAsync for the SignIn route, BeginTestStream for Test, StartSession
otherwise — so the label, the action and the sign-in fallback cannot drift apart. Pinned in
SessionStartDecisionTests for both live and test, connected and not.
2026-10-30 11:17:11 -07:00
gramps fbeb04385b feat(top bar): EDIT decides live vs test on the primary button — the TEST button is gone
Creator ruling (2026-10-30):
- EDIT off + ON-AIR armed → the primary button reads "Go Live" and runs the livestream.
- EDIT on + ON-AIR armed → the same button reads "Test Stream" and runs the test broadcast.
- REC armed (or nothing armed) → "Start Recording", whatever EDIT says; there is no test
  recording, and an unarmed start records locally rather than doing nothing.

So the fifth button was redundant: `ShowTestButton` no longer feeds any control. The decision is a
pure function (`Models/SessionStartDecision`) so the whole matrix is testable without a window;
`PrimaryStartCommand` follows the same kind, so label and action cannot disagree.

Guard: SessionStartDecisionTests pins the four cases, including the no-dead-ends fallback that my
first version got wrong (unarmed returned Live instead of Record — the test caught it).

Note for the v1 gate: the "hide TEST at v1" item is now satisfied by construction — the test path
is a mode of the primary button, not a separate control.
2026-10-30 11:14:33 -07:00
gramps 9b08c3af4f fix(ui): no space inside the wordmark — adjacent Runs again
The comment I added between the two <Run> elements introduced whitespace, and whitespace between
inline Runs collapses to a rendered space: the wordmark read "LlamaCas ty". The comment moved
above the TextBlock, the Runs are contiguous, and UiShellTests asserts them adjacent so a future
edit cannot re-introduce the gap.
2026-10-30 11:09:16 -07:00
gramps 318871033f feat(ui): the capture context menu is always available (Ctrl+right-click); wordmark "ty" is YouTube red
1. The preview's right-click handler swallowed every right-click for the zoom box, so the
   element/backdrop context menu could not open at all. Sources and capture windows must be
   changeable AT ANY TIME — a creator swaps a window mid-stream without entering edit mode.
   Ctrl + right-click now bypasses the zoom path and lets WPF open the menu under the cursor;
   plain right-click keeps the zoom box. No menu markup changed.

2. The wordmark's "ty" is YouTube red (#ff0000), permanently. The token sweep had recoloured it
   to the interactive blue because it was a bare literal; DESIGN.md now records "never re-colour
   the wordmark" beside the token, and UiShellTests pins it.

Guard: UiShellTests gains the wordmark rule and the Ctrl+right-click chord.
2026-10-30 11:07:51 -07:00
gramps cb4f8ec758 fix(ui): REC / ON-AIR labels are white in every state
RecordPillToggle never set a base Foreground, and its label inherits the toggle's — so unchecked
inherited the Control default (black) on the dark bar, while checked was white on red. The style
now sets on-accent once; the checked trigger only changes the fill.

That is the consistency rule for session controls: white ink always, the fill carries the state
(neutral off, red live). EDIT follows the same rule. UiShellTests asserts the base foreground, so
the missing setter cannot come back silently.
2026-10-30 10:46:51 -07:00
gramps feef763035 fix(ui): EDIT label is white in both states
The armed state was fixed to on-accent last build, but the resting state kept on-surface-variant
(#94a3b8) — so with EDIT off, which is its normal state, the label was grey on the dark bar. The
label is white always now; only the fill changes (neutral → blue). The redundant foreground setter
is gone, and UiShellTests asserts the resting colour rather than the trigger.
2026-10-30 10:45:24 -07:00
gramps d25dbf58dc design: white ink on any accent fill (on-accent token)
The EDIT button's armed face used on-tertiary (#0b1326 navy) on blue — the creator read it as grey
on blue and could not read it. Same problem, latent, on the armed REC/ON-AIR pills, which used the
error ink.

One rule instead of two exceptions: a saturated accent fill carries WHITE ink. New token
on-accent #ffffff, used by the EDIT trigger and RecordPillToggle; on-tertiary stays for large blue
surfaces where dark ink is right. UiShellTests pins the EDIT foreground to it.
2026-10-30 10:34:19 -07:00
gramps 568586c541 fix(xaml): the app refused to launch — RecordPillToggle was defined before SegmentToggle
`BasedOn="{StaticResource SegmentToggle}"` resolves while the dictionary is being built, so a
forward reference fails at load with "Cannot find resource named 'SegmentToggle'". The style now
sits after SegmentToggle and SegmentLabel, where its base already exists.

Guard: ThemeResourceTests gains the ordering rule — within one dictionary, no StaticResource may
point at a key defined later in the same file. The existence check alone passed this, which is
exactly why the app died on launch while the tests were green.

Root cause of the unreliable test results this session: a stale `testhost` held ytLive.dll locked,
so builds silently kept the previous assembly (MSB3027 on copy). Killed it; the suite now rebuilds
and the two new facts actually run.
2026-10-30 10:29:22 -07:00
gramps 4841c27514 feat(ui): the creator's six corrections
1. Stream Info + YPP rails are YouTube surfaces → new `youtube` token #ff0000. It is documented
   as brand-only: live state stays `primary`, so the two reds never mean the same thing.
2. EDIT toggle: the checked trigger set the SAME colour as the resting face, so clicking it
   changed nothing visible. Resting is now the neutral surface with muted text; armed is blue with
   dark text, and the label follows the fill.
3. Active scene outline is orange again (`attention`) — it marks the scene you work on, not a
   selection; blue made it read as "selected". Live stays red.
4. Scene names wear the same pill as the hotkey chips (black, 10px radius, 8×2 padding), so both
   labels stay readable over any thumbnail.
5. REC / ON-AIR pills and the running stop button read red while armed/live — including test
   mode, where the top bar is gold. New `RecordPillToggle` style (its own template, because
   SegmentToggle hard-codes a transparent face); the stop button's grey `#333` literal is gone.
6. The properties list had `MaxHeight="420"` inside an Auto row, so it scrolled while the window
   had space left. The row is `*` now and the cap is gone.

Guard: UiShellTests pins all six to the markup and names the one that regresses.
2026-10-30 10:27:11 -07:00
gramps df0118ac52 docs(handoff): design conversion complete; spacing is a layout pass 2026-10-30 10:02:32 -07:00
gramps e89cc3682a test(socials): a real preview test — and the Cancel bug it immediately found
The two regressions that reached the creator both lived in preview XAML that no test loads: the
appearance controls painted nothing, then the bar vanished when the plate was off. Every existing
test was green through both.

This test drives the real drawer on a real MainWindow (hermetic temp DB, per the layout-pollution
rule) and reads the real preview elements:
- the bar is VISIBLE, whatever the plate switch says
- a drawer text colour reaches the glyph
- the plate carries its colour and ~50% alpha, and its 18,6 padding only when enabled
- the shadow is an actual DropShadowEffect on the glyph, and off by default
- Cancel restores the pre-edit look

It paid for itself on the first run: Cancel did NOT revert. The snapshot held the same config
object the live preview mutates, so "restore" restored the edited object. The snapshot is now the
six values, written back on cancel — entries untouched.

Also fixes the harness details that made the first attempts fail: everything runs on the app's STA
thread (window creation AND close), and the exception is rethrown with its stack so a failure is
diagnosable next time.
2026-10-30 10:02:06 -07:00
gramps 0822552830 design: the theme's 4px corners come from the radius token
Six control corners used a literal 4px while DESIGN.md defines radius-sm for exactly that role.
Component geometry stays literal on purpose (the pill toggle's 9px is half its 18px height — a
token there would be a lie about what the number means).
2026-10-30 09:59:12 -07:00
gramps ec01a7deb8 design: the last VM-side colours come from tokens (ThemeBrush)
The state colours the view-models hand to bindings — top bar, REC/ON-AIR dots, health banner,
meter bands, toast tints — were hex literals in C#. They now resolve from Themes/Tokens.xaml
through `Helpers/ThemeBrush`, so the values live in one place. Without an Application (unit tests)
the helper returns a neutral grey: visibly wrong rather than silently plausible.

Four tokens the code needed and the document lacked, added to DESIGN.md (so the doc-is-the-contract
guard demanded their brushes): info-container #3a3b52, success-container #1d5c38,
warning-container #b8860b, test-container #9c6f1c (the test-stream gold bar).

Also fixed by this: the top bar still painted the RETIRED brand red #e94560 when live — the
standard's #ff5451 now, and `OnAirSignTests` (which pinned the old value) says so. BarOn/BarOff
became properties rather than static fields, because a static field would resolve tokens before
the Application exists.

Verified: DesignTokensTests, GameMeterHonestyTests, NotificationAreaIntegrationTests,
OnAirSignTests, SessionTeardownTests — 38 tests green.
2026-10-30 09:58:52 -07:00
gramps 7adff685c0 design: the on-stream graphics join the standard (OutputPalette)
The broadcast picture is part of the app, so it stops carrying its own colours. Alert cards, the
ticker and the branding flash now paint from OutputPalette, which Mirrors DESIGN.md for the brand
tokens and owns the alert box's six-event accent language (gold/violet/pink — content, not
chrome; the standard has no purple and should not grow one, but they live in ONE file instead of
57 scattered literals).

It is a separate type because the renderers run on the frame-pump thread and must not touch
application resources. The duplication is made safe by OutputPaletteTests: it parses
Themes/Tokens.xaml and fails if any mirrored token drifts, and fails if a renderer reintroduces a
colour literal or "Segoe UI".

Ticker and flash glyphs now use the bundled Inter instead of Segoe UI. Neon red is the standard
live red — one red in the product, including the credit.

Fixed on the way: the palette's font fields were static pack-URI initialisers, which throw
TypeInitializationException in any host without a WPF Application (the test process caught it);
they are lazy now with a name fallback.
2026-10-30 09:30:56 -07:00
gramps c4642f1e99 design: every window inherits Inter (implicit Window style)
Dialogs are separate WPF windows, so the FontFamily set on MainWindow never reached them —
GoLive, Socials and the four pickers still rendered Segoe UI while the main window used Inter.

The theme now carries an implicit Window style (family + Display text mode + layout rounding).
Guard: DialogTypographyTests opens a real Window inside the real app and asserts it resolves the
bundled Inter family — which fails if the style or the font resource goes missing — and asserts
no dialog sets FontFamily itself, i.e. nobody opts out of the standard.
2026-10-30 09:18:50 -07:00
gramps 51d9dd09c7 fix(socials): the bar disappeared from the preview when the plate was off
My own regression from the previous commit: the plate Border WRAPPED the bar's content and its
Visibility was bound to BarBackgroundEnabled — so with the plate off (the default) the preview
collapsed the entire social bar. Nothing was wrong with the data; the element was there and
invisible.

Fix: the wrapper is always laid out. "Plate off" now means a TRANSPARENT background
(`SocialBarBackgroundBrush` returns Transparent when disabled) and zero padding, "on" means the
colour/opacity plate with 18,6 padding — which also matches what the renderer bakes for the
output, so preview and broadcast agree.

Guard (new fact in SocialBarAppearanceTests): the preview must never visibility-gate the bar on
BarBackgroundEnabled, the wrapper must bind SocialBarBackgroundBrush, and the plate's switching
must be Padding.
2026-10-30 09:06:53 -07:00
gramps c1212644b0 fix(socials): live preview of the bar appearance + the crash on dragging a slider
Two bugs from the creator's first look at the new controls.

1. CRASH — '{DependencyProperty.UnsetValue}' is not a valid value for property 'Fill'.
   The token conversion deleted `YtSliderKnobHoverBrush` while two slider triggers still
   referenced it. WPF does not fail that at build time: it substitutes UnsetValue and throws at
   the next layout, inside `Ellipse.OnRender` — so the first slider touched (the new opacity one)
   killed the app. The brush is back, as a glossier version of the rest state.
   Guard: `ThemeResourceTests` scans every app XAML and proves each {StaticResource} key is
   defined (theme/App resources or the same file). Proven by removing the key again — the test
   names it.

2. PREVIEW DID NOTHING — the preview bar is its own XAML rendering (not the rasterized strip the
   compositor overlays), and it was hard-wired white-on-transparent, so the drawer's controls had
   nothing to move. Now:
   - `SocialBarTextBrush` / `SocialBarBackgroundBrush` come from the same `SocialBarStyle` the
     renderer bakes, so preview and output cannot disagree.
   - the preview draws the plate (same colour + opacity) and the drop shadow.
   - the drawer applies changes to the live config as you edit (`ApplyAppearancePreview`), and
     Cancel restores the pre-edit snapshot — so the preview tracks the controls without
     persisting anything until Save.

46 tests green (theme, socials, design tokens, layout store, real-MainWindow meter test).
2026-10-30 08:57:09 -07:00
gramps c82636e1a9 feat(socials): bar appearance — text colour, background plate, opacity, text shadow
In the Socials drawer, under the show/hide switch: text colour, an optional background plate
with its own colour and opacity, and a text drop shadow. The bar had been hard-wired
white-on-transparent since it shipped.

- `SocialsConfig` gains five additive properties (BarTextColor, BarBackgroundEnabled,
  BarBackgroundColor, BarBackgroundOpacity, BarTextShadow).
- Persistence: five new Socials columns, added by the per-column idempotent migration pattern
  (a half-migrated DB heals instead of failing), written in Save and read back in Load with
  defaults for pre-existing rows.
- `SocialBarRenderer.Render(entries, SocialBarStyle?)` — the look is a separate value type so the
  renderer stays a pure "entries + look" function and the preview can never read a half-applied
  config mid-edit. A malformed hex falls back to white/black instead of throwing inside a render
  tick.
- The plate hugs the entries (rounded 20px, padding) so it reads as a caption bar, not a band
  across the frame. Off by default, because transparent is what creators have today.
- The colour pickers reuse `BorderColorPalette` — one catalogue of the app's colours, per the
  TASK 60/63 rule.

Guard: SocialBarAppearanceTests paints the real strip and inspects pixels — default is white with
no plate, a red text colour reaches the glyphs, a 55% blue plate yields half-transparent blue
pixels, and the shadow adds dark pixels that were not there before.
2026-10-30 08:47:50 -07:00
gramps 9528128ca7 feat(settings): brand-logo thumbnail with a hover enlargement
The creator (2026-10-30): the brand-logo field in App Settings showed only a file NAME, so a
myopic user could not confirm which picture was set. Now a 28x28 thumbnail sits left of the
field showing the actual image, and hovering it opens an enlarged copy (up to 420x320) beside
the panel with the file name underneath. A click collapses it; moving the pointer away closes
it, unless the pointer walked onto the popup itself, where it stays so the creator can look
closely.

Mechanics:
- `MainViewModel.BrandLogoPreviewImage` decodes the stored asset ONCE and freezes it, so the
  thumbnail and the enlargement share one bitmap and no dispatcher hop is needed. A corrupt
  image returns null instead of taking the dialog down.
- The thumbnail is hidden when no image is set (NotNullToVis), so there is nothing to hover and
  no empty frame can appear.
- The popup is a separate window: WPF refuses to open one until its placement target is in a
  live visual tree, which is why the test hosts the control in a real window rather than
  asserting on a detached control.

Guard: BrandLogoPreviewTests — the wiring (image binding, the no-image gate, the four gesture
handlers, the readable enlargement size) and the control's open/close behaviour.
2026-10-30 08:39:42 -07:00
gramps 7a49b38cae docs(handoff): every XAML surface tokenised; VM state colours are the last unit 2026-10-30 08:30:45 -07:00
gramps c885bbd782 design: the code-set state colours now agree with the standard
Last inconsistency from the sweep: several VM-side state colours are still literals, and after
the palette moved they disagreed with everything around them — the top bar's LIVE state would
have painted the retired pink-red while the tile beside it used the standard red.

Values aligned (tokenisation of these sites is its own unit — they are strings bound to
Background/Foreground, so the fix is a VM->Brush refactor, not a find/replace):
- live / on-air / recorder dot: #e94560 -> #ff5451 (the standard red)
- error banner: #8f1f1f -> #93000a (error-container)
- audio meter bands follow the doc's stated thresholds: nominal cyan #38bdf8, caution amber
  #ffb95f, peak #ff5451 — they were green/yellow/red, which contradicted DESIGN.md's own table

GameMeterHonestyTests pinned the old band colours; its intent (the meter scales by volume) is
unchanged, so the two colour expectations now name the new bands.
2026-10-30 08:30:33 -07:00
gramps 8502c1d194 design: app settings + dialogs to tokens (surface 5 of the sweep)
OverlayHost (the App Settings surface) and every dialog — Go Live, Socials, Mic picker, Camera
picker, Hotkey config, Reuse Image, Rename Recording, Keep Broadcast — now resolve from tokens.
That closes the last place the creator sees a dialog: the settings overlay was still wearing the
old palette while the main window had moved on.

Every #e94560 in these files was an ERROR or validation affordance (hotkey conflicts, socials
validation, the remove tooltip, the go-live error line) — six sites all borrowing the retired
brand red to mean "something is wrong". They are `error` now, which is what the standard says
red-adjacent means; live/record keeps `primary`.

Guard list extended to all nine files.
2026-10-30 08:29:28 -07:00
gramps 8415bb5f37 design: preview pane to tokens (surface 4), and one palette list instead of two
110 hardcoded colours gone from the preview — the biggest surface in the app. Nicknames:
- the webcam selection ring is the interactive accent (was the retired brand red)
- the mute chips are `attention` (a muted mic is a caution, not a live state) with a white play
  glyph; the live badge is `primary`
- YPP progress bars/track, TRAX level zones, the error line, the dim strips and the poster veil
  all resolve from tokens (`positive`, `secondary-container`, `error-container`, `scrim-dark`,
  `scrim-dark-strong`)

Plus the duplication this sweep exposed: the webcam's right-click "Border Color" submenu carried
**seven hand-written colours**, a second source of truth beside the properties drop-down that
TASK 63 had already unified onto `Models/BorderColorPalette.cs` — and the two lists disagreed
(7 vs 14). The submenu is now built from the palette when it opens, so every offered colour is
the same colour everywhere, with the palette's name beside it. The click handler is unchanged;
generated rows inherit the menu's SceneElement DataContext.

Guard list extended: Controls/PreviewPane.xaml.
2026-10-30 08:28:21 -07:00
gramps 66b85d3fcc docs(handoff): preview pane next, with the colour-picker trap recorded 2026-10-30 08:03:44 -07:00
gramps 3edb0d10be design: three tokens the sweep needed — positive, scrim-dark, scrim-dark-strong
The palette had no green for status and no black scrim, so two surfaces were about to either
invent literals or misuse an accent. Creator's call (2026-10-30): give green a token rather
than painting "connected" blue, which already means five other things.

- `positive` #22c55e — STATUS ONLY (connected, applied, healthy). Never an accent, never a
  button fill; the doc says so and the guard parses the doc.
- `scrim-dark` #26000000 (15% black) and `scrim-dark-strong` #00000080 (50%) — the preview's dim
  strips and heavy overlays. 18 sites justify a token, and they are structural, not decoration.
- LeftPanel's ✓ confirm now wears `positive` instead of the interactive accent, matching the
  status rule the new token states.

DesignTokensTests still green: the doc is the contract, so the three new rows demanded three
new brushes and got them.
2026-10-30 08:03:18 -07:00
gramps 2dbc35b285 design: left panel to tokens (surface 3 of the sweep) — 101 colours gone
The layers/properties panel is the surface the creator looks at most, and it carried the
largest hardcoded palette in the app: 50 label greys, 15 icon blues, and a scatter of
one-off hexes. All of it resolves from tokens now.

Three sites needed a decision rather than a mapping, because the same hex meant different things:
- the + add icon and the chat author name were the old brand red → interactive accent (blue)
- the webcam lock alert is an ERROR, not decoration → error surface + error border
- the ✕ revert affordance was red, which the standard reserves for live/record → muted. ✓ stays
  a positive confirm, now in the interactive accent rather than a green that exists nowhere else
  in the palette. If green checks are missed, a `positive` token is the way back, not a literal.
- Super Chat gold was #eab308; it is the standard's amber now (game audio + Super Chat only).

Guard list extended: Controls/LeftPanel.xaml can no longer regress to a raw colour.
2026-10-30 08:00:43 -07:00
gramps 8a2cb92d8c docs(handoff): shell converted, left panel is next 2026-10-30 07:56:13 -07:00
gramps 0275e30103 design: shell to tokens — top bar, window, scene strip (surface 2 of the sweep)
- Telemetry readouts (bitrate, FPS, dropped, live duration) now wear JetBrains Mono, so the
  tabular digits stop shifting width as values change — the first place the bundled mono face
  is actually visible.
- Scene tiles: staged = blue (selection), live/program = red. Both were orange, which meant the
  same colour marked two different states and neither was the live colour.
- EDIT toggle: "on" is the interactive accent, not orange. Orange now means warning only.
- Wordmark's two-tone accent, avatar chrome, readout panel, tile borders and the program badge
  all resolve from tokens.

Guard list extended: MainWindow.xaml, Controls/TopBar.xaml, Controls/SceneThumbnailStrip.xaml —
these three can no longer regress to a hardcoded colour.
2026-10-30 07:56:02 -07:00
gramps ffdf3b6fc9 design: wire the sans family into the window; fonts ship as explicit Resource items
The bundled faces were packaged but unused — no style referenced FontFamily.Sans, so the app
still rendered Segoe UI. MainWindow now sets FontFamily from the token, which cascades to every
child (dialogs follow in the sweep).

Font packaging: six explicit <Resource> items instead of the Assets\Fonts\*.ttf glob, so the
guard can name exactly what must ship.

Investigation worth recording: a runtime assertion that the families resolve from
pack://application:,,,/ would have needed an Application instance inside the test host, and
without one WPF resolves the pack authority against the test host — every probe failed,
including the app icon that demonstrably works. The resources ARE embedded (the lowercase
`assets/fonts/inter-regular.ttf` entry is present in ytLive.g.resources); the assertion was
removed rather than faked, and the real confirmation is a launch, which is a visual check.

Guard remains structural: files are real binaries, each is a Resource in the csproj, the theme
uses pack URIs, notices + both OFL texts are present, and no third family sneaks in.
2026-10-30 07:53:37 -07:00
gramps 1defb71568 docs(handoff): design standard landed, token sweep in flight 2026-10-30 07:47:14 -07:00
gramps 4ef84f2a97 design: convert Themes/Controls.xaml to tokens (surface 1 of the sweep)
The theme file that every control inherits is now token-driven: 73 hardcoded colours became
Brush.* references, and the two role changes the standard requires landed here first.

- Red leaves the chrome. The seven #e94560 sites (base button, focus borders, checked box and
  segment backgrounds, the selected-layer accent strip) were all interactive/selection roles,
  so they are blue now. Red stays for live/record only.
- The slider was green: its fill and gloss knob are blue now, with the track on
  SurfaceContainerLowest. The old green fill predated the standard.
- The pill toggle was ON = green, OFF = red — a settings control using the live colour. It is
  now OFF = neutral surface, ON = blue.
- Added scrim-soft/scrim-strong tokens (#22FFFFFF/#26FFFFFF) for the existing white washes, and
  Color.* resources for the two gradients (a GradientStop needs a Color, not a Brush).

Guard: DesignTokensTests gains Converted_surfaces_carry_no_raw_colour — a declared list of
converted files, so each sweep commit is guarded as it lands instead of waiting for one global
grep at the end. Pure white/black highlights are the only allowed literals.

No layout, spacing or behaviour change: this unit is colour only.
2026-10-30 07:47:00 -07:00
gramps 84c335a929 design: bundle Inter + JetBrains Mono (OFL-1.1) as the standard typefaces
The DESIGN.md type standard needs the fonts actually present — the machine's installed faces
are not a design system. Four Inter weights (400/500/600/700) and two JetBrains Mono weights
(500/600) ship as WPF resources from Assets/Fonts; the token families resolve them with the
`/Assets/Fonts/#Family` pack form, so nothing depends on an install or a network fetch.

Licensing (recorded in docs/ai/product.md): OFL-1.1 is a free licence but is NOT GPL-compatible
in the linking sense. These are separate .ttf files loaded through WPF's font stack — an
aggregate, not linked code — so they coexist with the GPL-3.0 app. Obligations carried:
copyright line in THIRD-PARTY-NOTICES.txt (new items 13-14), the OFL text beside the files
(Assets/Fonts/OFL-*.txt), no modification, no reserved-name reuse.

Guard: BundledFontsTests — files exist and are real binaries, the csproj ships them, the theme
uses pack URIs (a bare family name would silently fall back), the notice and both OFL texts are
present, and no third font family sneaks in.

Pending visual confirmation: the first launch should render panel text in Inter and telemetry
in JetBrains Mono.
2026-10-30 07:45:19 -07:00
gramps f2bf577f34 design: Themes/Tokens.xaml — one place a raw value may live, guarded by DESIGN.md
Tokens.xaml carries every colour, type size, spacing step, radius and motion duration from
DESIGN.md; App.xaml merges it BEFORE Controls.xaml so styles can resolve tokens.

Guard (DesignTokensTests, 4 facts): the test PARSES DESIGN.md — the colour tables under
"## Colour" are the contract — and fails if a documented token has no brush, if the hex
disagrees, if the meter bands drift, if Tokens ever merges after Controls, or if the retired
brand red #e94560 comes back as a token. Adding a colour to the standard now requires
implementing it, and changing the theme without the document is caught.

Inter/JetBrains Mono are declared as token families with system fallbacks; the bundled font
assets are the next unit.

No surface consumes tokens yet — this unit is the foundation only.
2026-10-30 07:43:17 -07:00
gramps 565b8dbd6f docs(design): DESIGN.md becomes the visual standard (tokens merged, controls rejected)
Merges the 2026-10-29 Stitch export into the existing 2026-10-26 token sheet and promotes the
file from "redesign exploration" to the normative standard:

- type scale (32→10px), spacing, radii and elevation levels — the three scales the old
  DESIGN.md listed as missing
- meter thresholds: cyan ≤ -12 dB, amber -12..-3, red > -3
- interaction rules: focus ring, hover step, 150ms/90ms easing, no springs

Four export tokens rejected as Material-3 warm-seed artifacts (recorded with reasons):
tertiary #4cd7f6 (kept #38bdf8), outline #ab8986, outline-variant #5b403e,
on-surface-variant #e4beba (kept slate #94a3b8).

Product rulings recorded: #e94560 retired as the app's general accent (blue takes every
interactive role, red is live/record only, wordmark artwork untouched); error gets its own
surface (#93000a) so it cannot be mistaken for on-air red.

The export's dock, four bays, page-nav sidebar, CPU/GPU/NVENC readouts and acrylic blur are
explicitly marked exploratory, not spec — the design principle is a simpler surface, not
hardware cosplay. On-stream output styling stays outside this theme.

Falsified facts fixed in the same commit: product.md brand section now points at DESIGN.md for UI
tokens (MARCOM.md stays the marketing palette), and ui-surface.md no longer calls DESIGN.md an
unreconciled exploration.

Docs-only unit: scope-check run, no build.
2026-10-30 07:41:43 -07:00
gramps 5a102a4a37 docs: decisions log, queue trim, HANDOFF rewrite — the map stops being a treadmill
TASKS.md 467 → 66 lines: one line per live item, detail stays in TASKS/
task-*.md. The old catalogue (63 rows of paragraph status prose plus the
historical narration) is preserved verbatim as TASKS/archive.md — a
2026-10-29 snapshot, not the live queue. The "out of product" list and every
retired decision moved to docs/ai/decisions.md with cause of death.

HANDOFF.md 440 → 40 lines, and it is now OVERWRITTEN, not appended: branch,
what's in flight, next action, live landmines, pending creator checks. History
belongs to git log; the previous handoff is reachable at ee3c592:HANDOFF.md.

docs/ai/decisions.md (new) collects what no longer governs: the paid tier and
its pricing, Polar, Store IAP, the Store route and why it died, the deleted
Velopack updater, signing economics, the retired monetization ideas, and the
permanent out-of-product list.

Facts that lived in the old ai.md "Current limitations" section were re-homed
rather than dropped with the router rewrite: OAuth/DPAPI/session behaviour →
docs/ai/youtube-api.md; paths, persistence and the Google project number →
docs/ai/environment.md.

verify.sh caps now cover all four always-read files (AGENTS 210, ai 100,
HANDOFF 40, TASKS 120).

Docs-only unit: scope-check run, no build.
2026-10-29 11:10:51 -07:00
gramps c9ac348976 docs(ai): ai.md becomes a router; invariants move to AGENTS.md; budgets gated
ai.md 228 → 64 lines. It now carries only: what the product is, the build
commands, the architecture table, a "where the truth lives" table, and a
pointer to HANDOFF. No narrative, no status, no duplicated invariants.

AGENTS.md (195 lines) is now the single always-read file:
- the 20 invariants that break output, wipe user data, or ship a legal/trust
  breach — one line each, pointing at the doc that explains it (they replace
  110 lines of invariant stubs that duplicated docs/ai/*.md verbatim)
- Response style + No-Fluff Mode moved in from ai.md (rules belong in the rules file)
- onboarding is now: HANDOFF → ai.md router → the ONE docs/ai file → dir index;
  TASKS.md is read when the question is "what's next", not to write code
- memory rides with the change but is no longer a unit of its own; HANDOFF is
  ≤40 lines and overwritten, never appended

Anti-rebloat gate: scripts/verify.sh gains step 1/4, a doc-budget check
(AGENTS ≤210, ai ≤100; HANDOFF/TASKS caps land with the queue trim). A commit
that pads a capped file instead of moving detail fails the gate.

schema.md and docs/index.md corrected for the new roles and reading order.
Docs-only unit: scope-check run, no build.
2026-10-29 11:08:07 -07:00
gramps a311c8feb1 docs(ai): partition ai.md into five subsystem files (verbatim moves)
ai.md 1,308 → 228 lines. No prose rewritten: every block was moved as-is so
nothing could be lost in translation, then replaced by a pointer.

New files:
- docs/ai/environment.md — LAN/SSH + Gitea push recipe, Windows dotnet
  build/run, test-suite map, hermetic real-MainWindow test rule
- docs/ai/product.md — brand/naming, AI transparency, GPL licensing
  prohibitions, free-forever monetization, auth/account assumptions,
  packaging & distribution
- docs/ai/youtube-api.md — go-live ordering, the broadcast-binding fix, the
  private-only lock, health parsing, end-stream pre-flight, Test mode,
  chat insert shape
- docs/ai/overlays.md — social bar, native alert box (animations, clip,
  ticker), chat box conventions
- docs/ai/ui-surface.md — control surface, scene strip, preview rail,
  hotkeys, broadcast drawer, toasts, transitions, brand logo, launch
  geometry, theme/properties conventions

ai.md keeps the response/derivation rules, the architecture table, the four
existing invariant stubs, and one pointer per moved subsystem.
docs/ai/index.md routes every file with a "read when" column.

References falsified by the move were fixed in the same commit: AGENTS.md
build note → environment.md → Run, and the "Related" footers in
frame-pipeline.md / output.md / capture.md now point at the owning files.

Docs-only unit: scope-check run, no build (no source touched).
2026-10-29 11:05:53 -07:00
gramps ee3c59210d Settings: group preferences and open from gear
Group App Settings by purpose and let right-click on the gear open settings directly while preserving the left-click menu.

WPF event reference: https://learn.microsoft.com/en-us/dotnet/api/system.windows.uielement.previewmouserightbuttondown?view=windowsdesktop-10.0
2026-10-28 17:57:02 -07:00
gramps 769c616706 Preview: promote selected layer for hit testing
Temporarily raise the selected preview item and prioritize it in the custom hit test without changing scene order. Refresh integration-test expectations for Brand logo layers and explicit thumbnail assets.

WPF references: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.panel.zindex?view=windowsdesktop-10.0 https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer
2026-10-28 13:09:15 -07:00
gramps 7da287244b Transitions in edit mode and on output: program transitions run while recording or streaming; the preview animates the outgoing picture (fade/move fade, wipe shrinks) in every mode
Test: PreviewTransitionTests (with the transition, wipe, and brand logo tests).
2026-10-27 10:20:53 -07:00
gramps 841c905c06 Brand logo: one locked image layer per scene above the background, image from App Settings
- Source.IsBrandLogo persisted (additive migration); layer healed on load, removal blocked
- Image stored as an asset; settings keep the asset id and file name
- App Settings: Brand Logo file field with Browse
- Default placement top-left; per-scene eye uses the existing element visibility
Test: BrandLogoTests; layout/background tests pass (34). Not pushed.
2026-10-27 09:25:49 -07:00
gramps b86b6bb601 Transitions: Wipe (left-to-right reveal), added to the App Settings picker
Test: WipeTransitionTests. Not pushed.
2026-10-27 09:12:59 -07:00
gramps f67a263ccd App Settings: transition type and length picker
Cut/Fade/Move and a 100-2000 ms length, bound to the view model; changes save with the layout.
Test: TransitionPickerTests. Not pushed.
2026-10-27 09:12:37 -07:00
gramps 90cd039d98 Transitions: fade starts from the outgoing picture (was an instant cut)
The transition started with no from-frame, so BlendFrame returned the new scene at once.
Start now requests a capture and the frame pump supplies its last output on the next tick.
Test: TransitionFadeTests. Docs: ai.md, TASKS.md.

Not pushed.
2026-10-27 08:39:49 -07:00
gramps 7ae30d4cfc Right rail, top bar, bottom bar and chat border: Socials slide-out drawer, tab icons and colors, EDIT toggle, stream readouts moved to the top-left cell, webcam border restored, chat border controls, 30fps vertical tier
- Socials: the dialog is a right-rail drawer (same pattern as Stream Info/YPP/TEST); the live-screen button is removed
- Right rail: Stream Info (was Stream Settings) and YPP show the YouTube logo; Socials and TEST get icons; tab colors and label colors per spec
- Top bar: EDIT toggle beside REC (orange when on, blue when off); going live on YouTube turns it off
- Stream readouts (bitrate, FPS, dropped n (x.y%), Live hh:mm:ss, health) and the resolution dropdown moved to the top-left cell; bottom bar emptied at the same height
- Webcam border drawing restored (it was removed with the CRT bezel change)
- YouTube chat layer: border color, thickness, and opacity controls
- Vertical 1080p30 tier added

Not related to the fps gate yet; no gating is enforced.
2026-10-26 18:24:09 -07:00
gramps a8814b42d0 Top bar and scene strip: EDIT toggle beside REC (orange on, blue off); fixed-size centered mini screens; fixed 360px left column so the empty cell matches the layers panel
- TopBar: EDIT ToggleButton, spaced from the REC/ON-AIR segments
- MainViewModel: explicit EditingActive drives IsEditMode; YouTube go-live turns edit off, stop turns it on
- SceneThumbnailStrip: tiles fixed 196x130 and centered; dark background fills the column so its edge does not move on resize; strip row fixed 154px
- MainWindow: left column fixed at 360px; WIDTHS diagnostic log on resize

Not pushed.
2026-10-26 14:26:23 -07:00
gramps 3239808a73 Layers panel and scene strip: scene layers header with icons, per-type layer icons, PROGRAM badge and selected/live orange borders, scene name and hotkey overlays, Starting as default scene
- LeftPanel: SCENE LAYERS header with layer count; per-type blue icons; properties header gear; layers list min/max height; properties placeholder; list and properties indent
- SceneThumbnailStrip: names/hotkeys over the tile; PROGRAM badge with blinking red dot on the selected tile; orange border on selected and live tiles; fixed the element border override that hid the style border; reflag tiles on load
- LiveScene is now set on go-live, record start, and live transitions (it was never assigned, so IsLive never fired)
- Startup stages the scene named Starting
- DESIGN.md: color roles (red = YouTube/on-air/record, orange #ff8c3a = attention, amber = game audio/Super Chat)
- Staged live tile uses background art instead of the green LIVE placeholder

Not pushed.
2026-10-26 14:00:23 -07:00
gramps 24aca7d04a Scene strip: move top row over the preview column and scale it; remove CRT bezel art and on-air seed
- PreviewPane.xaml: drop the OBS-style border Rectangle/Ellipse over webcam elements
- SceneThumbnailStrip: Viewbox scales the row; height follows width so it fills the preview column; staged+live tile has no border
- MainWindow.xaml: strip moved to column 1 only, leaving the layers cell empty; Margin 8,0 matches the preview
- MainViewModel.Background: stop seeding the CRT scene tiles and the on-air image
- ytLive.csproj: per-build counter (BuildCounter.txt, gitignored) replaces the commit-count stamp

Build stamp now increments on every build.
2026-10-26 13:09:13 -07:00
gramps b7ab169718 docs(design): capture color + type tokens from the Stitch redesign exploration
DESIGN.md, elements only (no layout): the dark navy surface scale, the
single-role accent rule (blue = interactive, red = on-air/recording only,
amber = game audio + Super Chat), and the Inter/JetBrains Mono type split.
Sourced from the Google Stitch HTML export of the proposed edit screen,
2026-10-26. Not yet reconciled against Themes/Controls.xaml by design -
that comparison is deferred until the redesign direction is locked.
2026-10-26 11:00:51 -07:00
gramps 818d316477 docs(handoff): record the ai.md split progress (3 units, not pushed)
Session summary at the top: 3 docs commits split frame-pipeline, capture, and
output out of ai.md into docs/ai/*.md (verbatim moves + invariant stubs);
ai.md 2,702 -> 1,289 lines. Prior 2026-10-22 state preserved below unchanged,
headed as 'Prior state'.
2026-10-26 10:32:21 -07:00
gramps a4dfca6b6c docs(memory): split the output path (ffmpeg, encoder, recording, audio) out of ai.md
Unit 3. Verbatim move of 'FFmpeg locator', 'Live encoder + RTMP push',
'Local recording' and 'Live audio capture' into docs/ai/output.md, grouped as
encoder / recording / audio with a 'Related' footer.

ai.md's stub keeps the decisions that must not be re-litigated or rediscovered:
ffmpeg is bundled (never download/fetch; no-network test), args pure and no -re,
probe order NVENC->QSV->AMF->OpenH264 and never libx264, REC/ON-AIR radio-
exclusive by creator ruling, a modal's falsy result must never fall through into
its side effect, full StopStream teardown on every failure, audio pipe closes
BEFORE the video pump, and honest-by-measurement audio (true dBFS meters,
SpeechLeveler + -1 dBFS limiter, live Func gains).

- ai.md Services row and one 'FFmpeg locator section above' pointer repointed;
  MyMistakes.md dead-Pin entry repointed.
- docs/ai/index.md gains the output.md row.

ai.md 1,558 -> 1,289 lines. No source touched; no push.
2026-10-26 10:28:01 -07:00
gramps 096bf6debe docs(memory): split capture (backdrop, webcam, effects, vertical tier) out of ai.md
Unit 2 of slimming the mandatory read. Verbatim move again - the four capture
blocks leave ai.md unchanged in content, and one stub carries the law.

- docs/ai/capture.md: 'Screen backdrop capture', 'Webcam capture', 'Webcam
  background effects' and 'Vertical (9:16) tier camera placement', byte-
  identical, grouped under backdrop / webcam / vertical headings, with a
  'Related in ai.md' footer.
- ai.md stub keeps: the locked-Background model and NormalizeBackgrounds;
  ActiveBackgroundImage is fed by static art only (2026-08-23 regression);
  window:/picker: keys are session-scoped and a live pin beats auto-detect;
  the settled WinRT interop paths (TryGetDataUnsafe, from-DXGIDevice); the
  VideoFrame seam, own-catch subtype ladder and no fallback camera; one app-
  level webcam identity with live-lock attainability; CameraManager as the one
  effects processing point with per-session mask smoothing; geometry-only
  vertical tier and the model-level rect lock.
- docs/ai/index.md gains the capture.md row; Services/index.md (2) and
  TASKS/task-38-window-backdrop.md repointed.

ai.md 1,975 -> 1,558 lines. No source touched; no push.
2026-10-26 10:26:53 -07:00
gramps 804b1d2f5f docs(memory): split the frame pipeline out of ai.md into docs/ai
ai.md is the mandatory read every session; at 2,702 lines / ~64k tokens it
costs the whole subsystem history even when the task never touches it. This
is a verbatim move, not a compression - nothing is deleted and each stub is
written from the moved text.

- docs/ai/frame-pipeline.md: 'Frame cadence', 'Per-element composite timing +
  scene dump', 'What the first instrumented take said', 'Render budget',
  'Scene compositor' and 'Live frame pipeline', byte-identical, plus a short
  'Related in ai.md' footer and two stale 'below' pointers corrected.
- ai.md keeps two stubs carrying the invariants that must not be missed
  (overlay on every render path, VideoFrame.Epoch, StripPool threads,
  Release-only render numbers; arrival-stamped rawvideo, non-blocking submit,
  stop order, burned _outputIndex judge) and links the new file.
- docs/ai/index.md + docs/index.md route the split; schema.md inventory lists
  the new tier.
- 4 index-row references and 2 TASKS.md anchors repointed; ai.md's Services
  row too.

Measured: ai.md 63.6k -> 47.1k tokens, 2,702 -> 1,975 lines. Progressive
disclosure per schema.md convention 5. No source touched; no push.
2026-10-26 10:25:21 -07:00
gramps ef60f1f3b6 TASK 48: distribution & packaging is deferred until we are near v1
Creator ruling 2026-10-26: the remaining programming work is far from
release and a complete UX redesign is about to start, so packaging,
signing, installer and updater decisions would be chasing scope that is
about to move. The task is deferred, not cancelled.

Recorded what is frozen: TASK 48 items 6-8, the signing decision, the
updater choice (Velopack's deletion left no update path, and
StorePackagingTests currently vetoes a new one), and the route question
itself - ai.md says the Store route is dead (direct + Steam) while
TASKS.md/task-48/HANDOFF still read Store MSIX + Store IAP. That
contradiction is to be settled in ONE sweep at the revisit, not patched
piecemeal now.
2026-10-26 10:20:04 -07:00
gramps c768a8ee49 fix(border-color): keep ComboBox popup clicks inside LeftPanel (TASK 63) 2026-10-22 12:02:05 -07:00
gramps daac7689fd TASK 48 slice 2: the app packs as a Microsoft Store MSIX
The manifest the Store route needs, and a real package built from it.

Packaging/Package.appxmanifest is a full-trust packagedClassicApp: EntryPoint
Windows.FullTrustApplication, uap10:TrustLevel=mediumIL,
uap10:RuntimeBehavior=packagedClassicApp, the rescap runFullTrust capability, and
webcam + microphone DeviceCapabilities. That combination is what lets the packaged
app launch the bundled ffmpeg as a child process and write to the user's profile;
an appContainer package would virtualise the recordings folder instead. x64 only,
en-US only (policy 10.7).

Packaging/Assets/ carries the four tiles the packer requires, generated from the
app's own Assets/llamacasty-logo.jpg by scripts/make-store-logos.py - listing art
and app art cannot drift.

scripts/package-msix.sh publishes Release win-x64 self-contained into
artifacts/msix-stage (a RID-less publish would copy every RID's ONNX native),
stages the manifest as AppxManifest.xml + Assets, and runs the Windows SDK's
makeappx. MSIX_PFX/MSIX_PFX_PASSWORD sign a package for a local test install; the
Store signs submissions. artifacts/ is gitignored.

First real package: artifacts/LlamaCasty.msix, 159 MB, 509 files, 'Package
creation succeeded'.

Schema traps found by packing (makeappx reports one error at a time):
Square310x310Logo and Wide310x150Logo are NOT uap:VisualElements attributes - they
live on uap:DefaultTile.

Guard: StorePackagingTests.The_package_manifest_declares_full_trust_camera_and_
microphone - identity/architecture, the full-trust application element, the three
capabilities, no appContainer VALUE (not the file text: the manifest's own comment
says the word), en-US only, and every referenced logo present.

Full suite 438/438, 0 warnings. Cold-start smoke test after the Velopack removal:
MainWindow loaded, no exceptions.
2026-10-22 09:05:50 -07:00
gramps 20cfadca0c TASK 48 slice 3: the self-updater is deleted - the Store updates the app
The creator's ruling (2026-09-27) is Store MSIX + Store IAP, so Microsoft delivers
the updates and the in-app updater is gone rather than parked. This is a
COMPLIANCE action, not cleanup: the Velopack bootstrap downloaded and executed a
payload, which Store policy 10.2.2 does not permit.

Three edit sites, as planned: the PackageReference in ytLive.csproj, 'using
Velopack;' in App.xaml.cs, and the bootstrap block. The self-hosted DigitalOcean
update-URL droplet dies with it.

Guard: ytLive.Tests/StorePackagingTests.cs - the ONE test asserts the app project
references no updater package (Velopack/Squirrel/NetSparkle/AutoUpdater/WinSparkle)
and that no app source file uses the updater API. The check matches CODE (a using
directive, a type usage), not a bare word, so a file's prose may still name what it
deliberately does not do.

Smoke-tested after the removal: MainWindow loaded, 0 exceptions in startup.log.
Full suite 437/437, 0 warnings.
2026-10-22 06:57:34 -07:00
gramps b1d6034662 TASK 55 slice 1: the startup log is size-capped
The creator (2026-10-09): 'we need some sort of log-rotation so that we don't
inadvertently fill up a user's drive.' startup.log was uncapped - 23MB and growing
every few seconds, because FfmpegEncoder logs every non-progress ffmpeg stderr line.

The policy lives in a new Helpers/LogRotation.cs as a pure function over a path
(RollIfNeeded(logPath, maxBytes, retainedFiles)), so AppLog's 156 call sites keep
their surface and the rule is testable on its own. At 5MB the live file rolls to
startup.1.log and one rolled copy is kept - the FILE SET is bounded, not just the
live file, so a long stream cannot grow the log without limit. A failed rotation
degrades to the old behaviour instead of taking the app down.

Pattern reference (cited): Serilog's file sink pairs fileSizeLimitBytes (the cap)
with retainedFileCountLimit (how many rolled files to keep) -
https://github.com/serilog/serilog-sinks-file

Guard: ytLive.Tests/AppLogRotationTests.cs - the ONE test drives the real
AppLog.Write against a temp dir (two internal seams: PathOverride, MaxBytes) and
asserts the roll, the fresh live file, and that after a SECOND roll the directory
still holds exactly startup.log + startup.1.log.

Also fixed a stale catalog row: TASK 12 (master limiter) shipped 2026-08-15 and was
still marked 'Queued'.
2026-10-22 06:51:04 -07:00
gramps 826cc17e47 TASK 56: the secret scan is done - and the history is clean
The release gate before the repo goes public. Scanned 432 reachable commits (all
branches, 35 tags, the reflog, refs/stash) + the 63 dangling commits git fsck still
holds + every tracked file in the worktree, for Google OAuth client secrets, API
keys/tokens (Polar OAT, ghp_, AKIA, Slack, Stripe, OpenAI), private-key headers,
assigned credentials, and any once-tracked CREDENTIALS.md / OAuthCredentials.cs /
MARCOM.md. Report format is commit + path + rule only - no matched value ever
reaches the screen.

Result: 4 hits, ALL false positives - README.md:79 is the doc snippet's
'ClientSecret = your-client-secret' placeholder, TokenStoreTests.cs:22 is a 9-char
test dummy. Nothing to rotate, no history rewrite. The three secret files were
never tracked.

Also recorded: the repo was ALREADY public when the scan ran (anonymous GET -> 200,
API private=false), so the gate came late - but nothing leaked. The creator still
owns the keep-it-public decision.

Method + verdict in TASKS/task-56-secret-scan.md; the reusable recipe in
MyMistakes.md -> 2026-10-22.
2026-10-22 06:43:55 -07:00
gramps 8be6a83dab Docs: the properties-panel batch (TASK 58-63) is pushed to origin/main 2026-10-22 06:39:25 -07:00
gramps 987fddac24 TASK 63: the border-colour picker IS the app's own drop-down
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

  "why does the drop down under the web cam properties not show the same style for
   the border color drop down list box?"

  "one of the first things we did was define branding. We established look-and-feel
   standards. And yet you keep ignoring these and I'm wondering why"

WHY IT WAS MISSED - the answer the creator is owed.
The TASK 58 sweep had already walked the whole app for un-themed controls and the
border picker STILL was not themed, because the sweep looked for control TYPES
(ComboBox / CheckBox / TextBox) carrying no theme style - and the border picker was
NONE of those. It was a "..." Button whose Click handler built a ContextMenu of
swatches in code. It was never in the sweep's scope, and PropertiesStyleTests.cs
(a type-based guard) could not see it either. A picker that LOOKS like a drop-down
is invisible to a type-based audit; the guard has to ask "is this the app's widget?".

WHAT CHANGED
- The picker is now the app's YtComboBox - the widget the Display and Font pickers
  wear - bound to the palette (SelectedElement.BorderColorOptions) with its selection
  bound by VALUE (SelectedItem -> BorderColorEntry), because WPF's SelectedValue
  matching is ordinal and the palette mixes #FFFFFF and #e94560.
- The closed box shows the swatch + the colour's NAME; the rows show swatch + hex +
  name (the rows the creator approved in TASK 60). A ComboBox cannot do that with one
  ItemTemplate - WPF copies it into the read-only SelectionBoxItemTemplate - so
  Helpers/ClosedBoxTemplateSelector.cs picks by the ContentPresenter's templated
  parent: the ComboBox for the closed box, the ComboBoxItem for a row. Verified
  against WPF with a throwaway probe, not assumed.
- The swatch is painted by the THEME (Helpers/HexToBrushConverter.cs, registered as
  HexToBrush) - the hand-painted SolidColorBrush(Color.FromRgb(...)) in the old
  click handler was the exact anti-pattern the theme forbids. The swatch also carries
  a 1px #2a3a5e border now, so black and dark swatches are visible on the dark menu
  (the follow-up TASK 60 left open).
- The hex field stays (60px, editable): a colour outside the palette can still be
  typed, and the drop-down then carries that hex as a NAMELESS entry ("no name" is the
  honest answer). The palette's name moved out of TASK 62's TextBlock into the
  drop-down's closed box.
- The hand-built menu, its ContextMenu/MenuItem/SolidColorBrush code and
  WebcamBorderColorPicker_Click are gone.

MODEL: SceneElement.BorderColorName -> BorderColorEntry (the palette entry for the
current hex, with its name; setting it writes the hex back) + BorderColorOptions (the
palette plus the custom entry), both raised when BorderColor changes.
BorderColorPalette gains EntryFor(hex) and OptionsFor(hex); NameOf delegates.

Guards: ytLive.Tests/BorderColorPickerTests.cs (the ONE test for this change - the
model, the panel, and the REAL widget: with the app's theme style and the panel's own
templates the closed box names the colour and the rows show the hex AND the name).
The TASK 60 guard (BorderColorPaletteTests) was updated because the mechanism it
asserted - a code-built menu - no longer exists; it now guards the new wiring (the
drop-down is bound to the palette and neither the panel nor the code-behind carries a
second list of colours).

Full suite: 435/435 passed, 0 warnings.
2026-10-21 23:09:11 -07:00
gramps ef52508b1e TASK 61 + 62: the geometry fields share one width, and the border field names its colour
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

  "the Y and H boxes under webcam properties should be the same width as the X and W
   boxes and those widths should be fixed to accomodate the max number of possible
   digits providing the geometry values"

  "also the border color drop drown box content should also include the name of the
   color next to the hex value as they in the drop-down list box"

TASK 61 - one fixed width for the four geometry fields.
The row is a DockPanel, and WPF sizes an element to its CONTENT unless it is told
otherwise, while the LAST child of a DockPanel fills the remaining space. So X/W (not
last) were as wide as their text and Y/H (last) stretched across the row - two widths
for the same kind of field. Now all four carry Width="44" and the rows set
LastChildFill="False" (without it the last field ignores its own Width and fills anyway).
44px is the measured width of "-1920" in the app's font at 11px (the largest geometry
value - the master frame is 1920x1080 - plus a sign) plus the field's padding and
border. Docs: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.dockpanel.lastchildfill
The guard MEASURES the widest value with WPF's FormattedText instead of asserting a
magic number, so a font change cannot silently make the field too narrow:
https://learn.microsoft.com/en-us/dotnet/api/system.windows.media.formattedtext

TASK 62 - the border field names its colour.
SceneElement.BorderColorName carries the palette's name for the current colour
("Crimson") and is raised when the colour changes; it is EMPTY for a custom hex the
creator typed, because "no name" is the honest answer. BorderColorPalette.NameOf
changed to match (it used to fall back to the hex itself; the TASK 60 test assertion
was updated with it). The hex field keeps a fixed width (60) so the name sits right
next to the hex instead of drifting to the row's far edge - the same trap as TASK 61.

Guards: ytLive.Tests/GeometryFieldWidthTests.cs, ytLive.Tests/BorderColorNameTests.cs
Full suite: 435/435 passed, 0 warnings.
2026-10-21 22:19:13 -07:00
gramps 60b0218fa3 Docs: the HANDOFF's push state (three properties-panel commits are waiting; origin/main is still 3a77662) 2026-10-21 20:04:01 -07:00
gramps 6271bf34c9 TASK 60: the border-colour drop-down names every colour
The creator: "under Live->Layers->webcam->Properties->border color: in the drop
down box, the #hex values are good, but add a corresponding color name/label next
to the value."

Each row now reads "#e94560  Crimson" - the hex stays exactly as it was, the
colour's name goes next to it (dimmed #a0a0b0 so the hex reads first), and the
tooltip becomes "Crimson (#e94560)".

The 14 colours were a hard-coded string[] inside the click handler - a list of
bare strings cannot carry a name, and a list hidden in UI code cannot be asserted
on. They now live in Models/BorderColorPalette.cs: NamedColor(Hex, Name) +
BorderColorPalette.All (the single source of truth) + NameOf(hex), which answers
case-insensitively and falls back to the hex for a colour outside the palette.
The picker builds its menu from the catalogue and no longer knows any colour by
heart.

The names are chosen not to collide - the palette has two greens, two blues and
two warm colours: #00FF00 is Lime and #22c55e Emerald, #0000FF is Blue and
#3b82f6 Azure, #FF8800 is Orange and #f59e0b Amber.

Guard: ytLive.Tests/BorderColorPaletteTests.cs - every entry valid + named, no
hex or name repeated, the 14 legacy colours all still offered, NameOf answers,
and the old list is gone from the handler. 433/433 green, 0 warnings.
2026-10-21 20:03:19 -07:00
gramps 89f6aa3a67 TASK 59: the properties panel is a hierarchy, and the shape is a choice
The creator's ruling on the webcam's properties: a "Shape" title above the
Mirror/Rect row; the drag hint indented under that row "to show parentage";
the same for Geometry, Border Color, Border Thickness; Remove and Blur indented
under Background; Strength indented under Blur.

One rule covers it: a title owns its content and the content is indented one
level (10px) under it - two levels where a title is itself nested (Strength
under Blur under Background). The titles are SemiBold 11px #a0a0b0 like the
ones already there.

The shape also stops being a toggle button that shows the NEXT action ("Round"
when the camera was a rectangle) and becomes a CHOICE: two segment options,
Rect | Circle, with Rect selected by default - which is what the creator asked
for ("Rect should be selected as a default if none exists"). The old
ShapeButtonText is gone.

The mutual exclusion lives in the MODEL: IsRectShape/IsRoundShape on
SceneElement, each setter flipping the shape through ToggleClipShape() (so the
pre-round rect is still stored/restored) and always re-raising, so a refused
write - the vertical Chat layout is rect-only (TASK 57) - makes the UI snap
back instead of sticking. Deliberately NOT a WPF RadioButton group: a group
unchecks its siblings by setting IsChecked in code, which makes WPF drop the
binding (stackoverflow 7376373; the same trap measured in 50980687/57275046).

The Circle option stays hidden where CanToggleClipShape is false, so the circle
is still not offered in the vertical Chat layout.

Guard: ytLive.Tests/PropertiesHierarchyTests.cs - the model half (Rect by
default, pick circle, pick rect, the locked Chat layout refuses the circle) and
the panel half (the Shape title owns both options, the circle is the gated one,
the hint is indented and follows the row, every titled block's content is at
10px, Strength at 20px). 432/432 green, 0 warnings.
2026-10-21 19:58:28 -07:00
gramps 2273d20160 TASK 58: the properties panel wears the theme (no more light-system chrome)
The creator asked why the Stream Alerts "Display" drop-down was not in the same
style as the other drop-downs. It was never given the theme's YtComboBox style -
only a hand-painted Background/Foreground. Setting colours changes the paint, not
the template, so WPF still drew the light system ComboBox (grey border, system
arrow, white popup list) on top of the dark panel. The chat Font drop-down was a
copy of the same mistake, and every check box was the light system one.

The sweep of the whole properties panel (then the app):
  - ComboBox x2 (chat Font, alert Display) -> YtComboBox, hand-painted colours gone
  - TextBox x16 in the properties panel -> YtTextBoxCompact (new, BasedOn YtTextBox:
    11px / padding 4,2 - the panel is dense, the full 13px box would double it)
  - CheckBox x2 (chat) + x3 (preview broadcast) + x1 (socials dialog) -> YtCheckBox
    (new: a 14x14 rounded box + tick path, accent fill when checked)
  - the layer-rename field -> YtTextBoxCompact via BasedOn, still borderless
  - buttons, pills, sliders: already themed, untouched

The sweep also killed a typo'd foreground (#e0d0e0 for #e0e0e0) and the only
remaining hand-painted colour is the deliberate dimming of the two read-only path
fields.

Guard: ytLive.Tests/PropertiesStyleTests.cs (the ONE Good Dog test) parses the app
XAML and fails if an unstyled drop-down, check box or properties field comes back.
431/431 green, 0 warnings.
2026-10-21 19:46:25 -07:00
gramps 17aa0273a9 Docs: the HANDOFF's push state (the vv feature is pushed; origin/main = 3a77662 = HEAD) 2026-10-21 19:23:48 -07:00
gramps 3a77662a95 vv webcam Unit 2: the Chat scene in the vertical tier - rect only, tile or one whole 607x540 area
The creator's locked table (2026-10-17) gives the Chat scene its own rules in
the 9:16 tier: RECT ONLY - the circle option is not offered - and a size that
is either the normal small tile or ENLARGED to fill one whole 607x540 area
(top or bottom). Unit 1 shipped the per-scene placement; this unit is the Chat
scene's own variant of it.

The rule lives on the MODEL, not on the button:
- Models/SceneElement.cs: ClipShape and ToggleClipShape() are now virtual.
- Models/WebcamSceneConfig.cs: VerticalShapeLocked (set per scene by the VM)
  OVERRIDES the ClipShape property, so no writer can slip a circle in - the
  toggle, the Shape button and the placement swap all go through it. The lock
  is cleared BEFORE the 16:9 layout comes back, so the desktop shape returns.
- ViewModels/MainViewModel.Webcam.cs: CanToggleClipShape (the circle is NOT
  offered in the vertical Chat layout - the Shape button disappears, it is not
  merely greyed out), IsChatSceneCamera, ResizeWebcamInVerticalTier.
- Models/VerticalCameraLayout.cs: FillsArea (the "enlarged" state, derived -
  no new column) and SizeFor - the Chat camera's TWO sizes, the tile or the
  whole area, whichever is nearer the requested width, so the resize handle
  has two detents instead of any size in between.
- An enlarged camera FILLS the area its centre is in (top or bottom), so
  dragging it up/down swaps the areas.
- The Chat camera is auto-tiled like the Live one when the vertical tier is
  picked - otherwise the vertical chat stream has no camera at all.
- Controls/PreviewPane.xaml.cs: the resize routes through the VM; the drag
  assigns, lets the model clamp into the band, then snaps (so the enlarged
  camera can be dragged from the top area to the bottom one).

Test: ytLive.Tests/VerticalChatCameraLayoutTests.cs - the ONE Good Dog test for
the unit: the vertical tier is picked -> the Chat camera comes into the band as
a small RECT tile -> the circle option is not offered and cannot be set even on
the model -> dragged up and enlarged it fills the TOP area exactly -> back to
16:9 the desktop layout is restored exactly and the circle option is offered
again. 430/430 tests pass, 0 warnings.

Research (the creator's table is the established practice, not our invention):
- Streamlabs' vertical guide: "splitting the screen in half horizontally,
  placing the camera feed in the top half and the main content on the bottom
  (or vice versa) may be the bet", and a just-chatting vertical wants the
  camera "to take up most of the screen"
  (streamlabs.com/content-hub/post/overlays-vertical-and-horizontal-streaming)
- Bloopbot's vertical overlay docs describe the same per-layer model we use at
  scene granularity: "move or resize a layer on Vertical and it stops being
  Auto-placed. It keeps the place you gave it, and the horizontal layout is not
  touched" (bloopbot.com/docs/vertical-overlays-twitch-dual-format) - our
  swap-at-tier-change, stored per scene.
- Landmine recorded: "about the bottom 560 px of the vertical video can be
  covered by Twitch's chat and player controls on phones" (Bloopbot) - the Chat
  camera's enlarged BOTTOM area may be half-hidden by the platform chrome;
  TASK 53's safe-zone work decides that.

Memory in the same commit: ai.md (the Chat rules, the shape lock, the tests),
TASKS.md + TASKS/task-57-vertical-camera-layout.md (TASK 57 complete), HANDOFF.md,
MyMistakes.md (3 recipes: lock the model property not the UI button; two detents
for a discrete size set; the edit tool cannot match an oldString with an em
dash), Models/ViewModels/Controls index rows.
2026-10-21 19:23:29 -07:00
gramps ccc1db52d3 Docs: the Gitea push path (HTTPS + credential helper), and why SSH was a dead end
The creator approved the push and it failed on the old SSH remote. The causes: the
repo was RENAMED gramps/ytLlive -> gramps/LlamaCasty (the old path 301-redirects),
and llgit:2214 refuses the publickey of this machine's key even though that key IS
registered on the account (id 6, "Corsair VSC"). The account password in CREDENTIALS.md
is also the Gitea account password, so the HTTPS push went through immediately:
931ab46..6be062c main -> main.

origin is now https://llgit.llamachile.tube/gramps/LlamaCasty.git with the credential
helper /home/gramps/bin/gitea-credential, which reads the password from the gitignored
CREDENTIALS.md on demand - plain `git push` works, no plaintext secret on disk, no
secret in any command line.

Records the recipe and the lesson in MyMistakes.md (ask the REST API before doing
transport archaeology: one authenticated curl answered everything that ten minutes
of ssh -v could not) and the push mechanics in ai.md + HANDOFF.md.

Docs only - no code touched, no build/test gate applies.
2026-10-21 09:50:44 -07:00
gramps 6be062c45f vv webcam Unit 1: the vertical (9:16) tier keeps its OWN camera placement per scene
The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).

Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
  areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
  270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
  the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
  columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this
  scene has no vertical placement yet"). The 16:9 layout stays in the old columns
  and is parked in the transient Landscape* fields while the vertical tier is
  live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
  X/Y/W/H overrides clamp every write into the band - outside it the camera is
  invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
  constrains every writer (drag, resize handle, typed fields) in ONE choke point
  instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
  ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
  SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
  EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
  Live scene gets its small tile automatically on the first vertical switch (the
  vertical stream would otherwise have no camera at all); the other scenes keep
  their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
  the vertical tier instead of free-floating over the master.
  Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
  (X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE
  placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam -
  the TASK 51 test finds that panel by binding text and would match twice).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
  X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
  live the save writes the parked values there and the live ones into Vertical*.

Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.

Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.

Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
2026-10-21 08:58:24 -07:00
gramps 58b9a7d413 GOG outage: correct the timeline and my bogus 'second egress'
The creator was right that it is not a days-long outage: the Wayback snapshot of
www.gog.com/en/ at 2026-10-21 06:56 UTC is HTTP 200 with the full live storefront,
so the break window is 00:00-08:11 local today.

My 'independent egress' was not one - webfetch leaves through this machine's own
connection (verified: ipinfo returned the same public IP 201.170.13.206 from both
paths). The proof that holds is local: genuine GOG->DigiCert cert from WSL and
Windows, local DNS identical to Google's authoritative answers, real Fastly POP
headers, GOG's own non-Fastly servers still answering, and an off-LAN fetch
(allorigins) returning the same maintenance page.

Also records that r.jina.ai (422) and codetabs (522) fail open on this target and
look like outages, and that Galaxy's cef.log is 2.7 GB (TASK 55).
2026-10-21 08:19:53 -07:00
gramps b1d77ee510 GOG Galaxy "offline": it is GOG's own maintenance outage, not the LAN
www.gog.com serves GOG's maintenance page (503, "Galaxy Launcher will also be down");
auth/api/remote-config.gog.com return 500 "Fastly error: unknown domain". Proven
global by hitting it from a second egress (same 503); local DNS, TLS and clock are
clean, so there is nothing to fix on this machine.

Records the diagnosis recipe (where Galaxy 2.0 logs actually live, the log line
that decides the offline badge, and how to separate a GOG outage from a LAN
outage) in MyMistakes.md, and the operational note in HANDOFF.md.

No code touched - no build/test gate applies to this docs-only change.
2026-10-21 08:14:29 -07:00
gramps fb851f089d HA lights: record the entity-id trap, the recipes, and the day I broke working scripts
A verified-working Home Assistant light alert system (leak/alarm/pump/doorbell on
five Tapo bulbs, for a deaf user) was reduced to a single requested tweak — pump
lights the Death Star only. Instead of making it, the render script was rewritten
twice, the alert path broke, and the session was spent there. The creator had to
stop it. llamacasty did not happen today.

Root cause of the "nothing lights" saga, found only after three failed remedies:
the YAML key is `alerts_render` but the entity id is `script.alerts_render_lights`.
Every caller invoked `script.alerts_render`, which does not exist — and
`script.turn_on` on a non-existent entity returns HTTP 200 and silently does
nothing. One entity-list check would have found it; three guesses did not.

MyMistakes.md gains, in the same commit:
- the failure log for the day and the rules it produces (do the one change asked
  for; never trust a 200 from script.turn_on; a second failed remedy means verify
  the plumbing, not the logic; read state inside the window the code defines);
- the H500 discovery-poisoning fix (delete the entry, re-add with a pinned host);
- the measured Tapo recall numbers that rule out flashing (500ms colour, 5s
  brightness) and the off-state attribute-drop trap;
- the alert system's architecture: one writer, flat if/else (nested if and choose
  both silently lit nothing), pump as status keyed off consumption not the
  latched switch.

HANDOFF.md rewritten: the HA system's verified state and its landmines, the still
unstarted vv webcam plan, and the correction that CREDENTIALS.md is stale for HA
(it says .144; the real box is .145).
2026-10-20 22:27:06 -07:00
gramps d1e73a3aad HANDOFF + MyMistakes: officerFriendly clock is the PS5 DNS root cause (no RTC exists); record the reply marker verbatim
Off-repo diagnosis (Pi 400, no RTC hardware, healthy crystal at -7.39 ppm):
the box's clock sat ~43 h behind and systemd-timesyncd's first contact after
the Oct 12 restart was 42.9 h later, so Pi-hole ran as a DNSSEC validator with
a wrong clock -> 1.69M+0.84M dnssec=3 (BOGUS) rows on 10-11/10-12 hitting the
PSN endpoints = the PS5's DNS errors. KSK-rollover theory retracted (both root
anchors present; root DNSKEY still returns ad). Pending creator decision:
tighten timesyncd polling and/or add a DS3231 RTC.

MyMistakes: the reply marker string (recovered verbatim, with the search
recipe), and the no-RTC/no-cron/timesyncd facts so the premise is not
re-derived a third time.
2026-10-17 16:19:13 -07:00
gramps 2a15be21bd HANDOFF: vv webcam plan locked; drop the unverified vertical-preview landmine
Planning-only session: no code, no build, no test run. The creator's vv
(9:16) webcam feature is now specified, and HANDOFF carries the locked
rules plus the code facts needed to build it without re-hunting:

- vv Live: small tile, circle OR rect (creator's choice), snapped to one
  of six spots (top/bottom x L/C/R). vv Chat: rect only, plus enlarge to
  fill one 607x540 area (exactly 50% of the frame), top or bottom.
  16:9 and Starting/BRB/Ending unchanged.
- Placement by tap/click-drag with snap, like the socials, plus manual
  entry in properties. vv keeps its own camera layout per scene, so the
  16:9 layout survives switching modes (creator-confirmed).
- Geometry only: preview and compositor both read the element's own
  X/Y/W/H + ClipShape, so the two-tile look falls out of the camera
  covering half the band - no capture, chat-box or blit-path change. The
  existing per-scene clamp already permits 607x540.

Withdraws the HANDOFF landmine "vertical-format regression ... no longer
shrinks the capture border in the preview": never verified, and wrong as
stated - the creator confirms vv dims the two bands astride the centre
band. It cost this session a pointless question.

MyMistakes.md records the session-history search recipe (the creator's
turns live in session_message, NOT part - a hit in part can be my own
text echoing the phrase) and the ask-don't-check lesson plus the
creator's standing reply-format directives.
2026-10-17 09:02:59 -07:00
gramps cdf82f688d Read the first instrumented take: the judder did not reproduce, no element is slow
The measurement unit existed to name which of the six scene elements eats ~140ms per
composite. The creator's Release recording (ty-20261016-1841-559-0002.mp4, 174.5s,
1920x1080@60) names none of them — the whole composite costs ~1ms:

  composites     301 per 5s = 60/s, every slot, 0 dropped, 0 stalls after warm-up
  avg render     1.0-1.1ms (worst 2.5ms) = ~7% of the 16.67ms budget
  parts          "#5 HD Pro Webcam C920 0.4ms, #5 ... raster 0.2ms, backdrop 0.2ms"
                 - the other four elements never reach the 0.1ms print floor
  C4 cache       151R/150H: half the ticks were a whole-frame BlockCopy
  webcam         30.0/s at the resolver, longest gap 50.0ms (one 60Hz frame of
                 sampling phase, not a drop)
  the FILE       29.6 distinct webcam images/s, 82% of images held exactly 2 frames,
                 longest hold 267ms (the healthy 30fps-in-60fps signature)

The two stalls are not composite cost: 124ms on the very first render (warm-up) and
53ms entering the "Chat" scene (a rebake).

So the historic slow numbers are not a slow scene but different configurations:
- Oct 9 19:48 (only bad take still on disk) was the VERTICAL tier:
  "FramePump started (1080x1920 @ 60 fps) scene='Live' comp=608x1080" - the 607x1080
  centre crop upscaled to 2M px, 92 renders/5s, avg render 45.0ms, worst 278ms.
- Oct 5 12:01 (141ms) logged no scene=/comp= at all - the suffix did not exist yet,
  so the map's "21:9 -> 1920x803" explanation stays a hypothesis, not a reading.
- Every slow window shows "cache xR/0H" (the whole-frame cache never reusing) while
  this take shows 151R/150H, and one Oct 5 take latches mid-take (1.5ms -> 3.0ms ->
  45.2ms in 15s, then stays). That latch is the lead for the next take; it is a
  hypothesis and the parts line is what will decide it.

Corrections to the map (same commit, stale facts replaced not appended):
- "It was never Debug-vs-Release" is WITHDRAWN: same 1920x1080 tier, Oct 9 = 10.4ms
  vs this Release take's 1.0ms, and Build <hash> carries no configuration - every
  pre-2026-10-16 render number is config-unknown.
- The "same scene shape" 10:01/12:01 comparison is superseded: it was an eye
  comparison of takes whose geometry was never logged.

New: scripts/webcam-cadence.py - the ground-truth probe for what the FILE holds,
beside what the pump says it did. It took three wrong readings to get right (H.264
re-encoding defeats exact-pixel comparison; a round-clip crop's corners leak 60fps
background; a fixed crop across a scene switch reads background), all recorded in
MyMistakes.md as a recipe, including an ffmpeg-native cross-check
(tblend=difference + signalstats.YAVG - the same metric, the technique already in
this repo's A/V-sync recipe) which agreed: 52.2% duplicates / 28.6 distinct/s vs
50.7% / 29.6/s on different windows of the same take.

Next: no fix is allowed yet - the creator makes a Release take in the failing
geometry (vertical 1080p60 tier first), and that take's parts line names the stage.

Docs in the same commit: ai.md (new "What the first instrumented take said" section,
the two corrections, the parts-line denominators, the tool in the inventory),
TASKS.md (reading outcome), HANDOFF.md (rewritten), MyMistakes.md (recipe).

Gates: clean build 0 warnings, full suite 428/428, scope check passed.
2026-10-16 19:05:01 -07:00
gramps 84cbec2d19 FramePumpTests: thread-safe log sink — the full-suite failure was a test race
The suite failed Stats_ReportRealRendersAndDistinctWebcamFrames_NotJustPaddedSubmits
with "Collection was modified; enumeration operation may not execute" at its OWN
log.Any(...) line (FramePumpTests.cs:606): the test handed FramePump a plain
List<string> and enumerated it from the test thread while the pump reported stats
from its loop thread — pacingDelay is Task.Yield(), so the loop really is concurrent.
Production passes the thread-safe AppLog, so the app was never involved.

It passed 3/3 in isolation, which is exactly how a data race presents as a
load-sensitive flake. The trigger: the previous commit ("Per-element composite timing
+ scene dump") added log lines emitted FROM the pump thread, raising the collision
rate from rare to routine — a latent race in the old test, promoted by the new
traffic.

Fix: ConcurrentBag<string> (enumeration is a moment-in-time snapshot) for the sink,
2 call sites + the NewPump parameter. No app code touched.

Proof: full suite 428/428 passed, 0 warnings (the run that surfaced the failure was
before the fix; the same failure has not recurred).

Docs in the same commit: MyMistakes.md — "A 'known failure' label without a recorded
cause = a bug on life support" gained this second instance, with the rule that a
test failing ONLY under load means a shared mutable collection on the producer's
thread, not a retry.
2026-10-16 19:02:14 -07:00
gramps a3a4681aac Per-element composite timing + scene dump (measurement only)
The cadence work ended with a question the log could not answer: the compositor
manages 6.8 composites/s at ~141ms while the 10:03 take did 29-34ms on the "same
scene shape" (full-render split=0 elements=6 dynamic=5). Nothing in the stats line
said WHICH of the six elements cost what, and nothing recorded the scene itself, so
the two takes could only be compared by eye. This closes that gap and nothing else:
no fix is attempted until a real take names the culprit.

- Services/Compositor/CompositeTimings.cs (new): per-window collector.
  Add(stage, ticks) accumulates; Describe(renders, minMs = 0.1) prints the averaged
  breakdown slowest-first, dropping sub-0.1ms noise.
- SceneCompositor.Render / CompositeLayers / BakeStaticBase / BlitBakedElement /
  BlitCachedLayer + SceneGraph.GetBakedBase: trailing optional CompositeTimings?
  Every stage is timed (backdrop, base, flash, social, ticker, zoom, scale, overlay,
  bake backdrop, bake overlays) and EVERY element gets its own entry keyed by
  z-index + name ("#2 Webcam"), with the nested "raster" sub-stage recording the
  paste-cache rasterize miss - the split between "re-samples every tick" and "slow
  paste" without a second session. Bake-pass elements are prefixed "bake #..." so a
  one-off rebake cannot masquerade as a per-composite cost. Callers that pass
  nothing (thumbnails, existing tests) are unaffected and pay nothing.
- FramePump: owns the collector, threads it through RenderScene/RenderFull/
  GetBakedBase/CompositeLayers, and logs "FramePump parts (N renders/5s): ..." once
  per 5s window beside the stats line (averaged per render, so it compares directly
  with "avg render"), then resets it. Also dumps the scene - "FramePump scene dump:"
  header plus one line per element (z-index, name, kind, source type, visibility,
  rect, opacity, clip) - at pump start and on every scene change.
- One Good Dog integration test (ytLive.Tests/CompositeTimingTests.cs): a real
  composite of a full-canvas element plus a 64x64 one through the real collector
  must rank the full-canvas element first, carry the raster sub-stage and the fixed
  stages, and the dump must name both elements with z-index/type/rect and echo the
  split point.

Derivative work (AGENTS.md): OBS's answer to "which source is slow" is the Source
Profiler - average execution time of each source's tick and render functions
(https://docs.obsproject.com/reference-libobs-util-source-profiler, util/profiler.h).
Same shape here: one timer per element per composite, reported as an average, plus
the stage split this case needs.

Docs in the same commit: ai.md (new subsection "Per-element composite timing + scene
dump", the closed gap corrected in "Frame cadence"), Services/index.md (new file row
+ FramePump/SceneCompositor rows), TASKS.md (unit marked shipped, next = creator
take), HANDOFF.md (rewritten: unpushed commit, next step, verify.sh whitelist hole).

Also recorded this session (from the creator): the launch command is
`dotnet run -c Release` - Release is the DEFAULT, Debug only when explicitly named,
and perf takes must read Release. ai.md -> Run updated.

Full suite 428 tests, 0 warnings; the only failures are the known audio env flakes
(both pass in isolation). Scope check passed.
2026-10-16 18:23:20 -07:00
gramps 3cb29ea45c HANDOFF refresh: tree clean, all units committed+pushed through 931ab46.
The prior handoff claimed the strip-tile/on-air unit was uncommitted and the
pinned-chat unit was 'not mine - do NOT commit'. Reality: both are committed
(8e1d6f4, 931ab46) and origin/main is level with HEAD (0 ahead). Corrected to
match the code and recorded the true next step (webcam per-element composite
timing measurement). Removed the stray Assets/llamacasty-text-nobg-2.png~ backup.
(Docs: handoff)
2026-10-14 09:41:24 -07:00
gramps 931ab46783 Pinned chat message + brand wordmark assets. Chat can pin one message to a banner above the feed: ChatMessage.IsPinned, MainViewModel.PinnedChatMessage + Pin/ClearPinnedChatMessageCommand (Helpers/RelayCommandGeneric.cs), LeftPanel pinned-message border bound via NotNullToVis with the feed nudged down. 2026-10-10 14:54:08 -07:00
gramps 8e1d6f44db Scene strip tile artwork + on-air indicator (creator ruling 2026-10-10). Top-bar tiles now carry their own art: each canonical scene's tile is a per-scene CRT-styled image stored in the internal DB (Scene.ThumbnailAssetId - additive migration, seeded at heal from the creator's five Downloads JPGs via SceneThumbnailSeedPaths), and the preview pane keeps rendering the scene's ORIGINAL background - never the tiles. One shared on-air image (Gemini 4zajyl...jpg) replaces the staged scene's tile and follows the scene swap ('the on-air screen jumps around the top bar as the user swaps scenes'): stored as an Asset BLOB tracked by the OnAirImageAssetId Setting (EnsureOnAirImage, same contract as the alert clip) with MainViewModel.OnAirSeedPath override; RefreshSnapshotsAsync shows it for the staged scene, otherwise thumbnail-or-background art, and a staged Live with no on-air art still falls back to the green placeholder (minis never render live captures). Removed the 6px #22c55e rectangle in PreviewPane's backdrop ('green outline around the preview screen' that surfaced when a scene had no background) - the outer PreviewGlow red border is the real live signal. Doubled the scene-tile labels (FontSize 10 -> 20). Fixed a real bug this surfaced: Save()'s Asset pruner only kept Source/alert-clip rows, so the first save would have DELETED the seeded tile + on-air assets - the keep-set now includes Scene.ThumbnailAssetId and the OnAirImageAssetId Setting. New Settings/Load/Save migrations + SceneThumbnailTests (4 tests: seeded tile in strip, preview keeps original bg, Live-staged tile, no-seed fallback, on-air swap + persistence). 427 pass, 0 build warnings. (Unit: strip tiles + on-air) 2026-10-10 13:27:26 -07:00
gramps 06f88f4142 Scene strip now renders in canonical F-key order regardless of persisted SortOrder. The strip loads Scenes ORDER BY SortOrder while each tab's F-key label is name-derived, so a session that persisted Chat before BRB (found on the creator's DB: SortOrder 2/3) showed tabs 'Chat F4' then 'BRB F3'. MainViewModel.LoadLayout now calls the new ReorderScenesToCanonical() - the five canonical scenes reorder to Starting, Live, BRB, Chat, Ending (F1-F5, stable among renamed non-canonical scenes), and the next auto-save re-stamps SortOrder so the heal persists. One integration test (SceneOrderTests) seeds the swapped-order DB and asserts loaded order, F1-F5 labels, and that a save persists the corrected ordering. 423 pass, 0 build warnings. (Unit: scene order) 2026-10-10 12:09:07 -07:00
gramps 63bf8444bd Desktop 'lock' fix: the window no longer steals focus when a full-screen game owns the foreground at launch. Opening after an exclusive-fullscreen game (creator's THE DIVISION 2 gameplay-stream start) the window ACTIVATED by default, Windows dropped the game to the taskbar and the 1920x1040 window covered the whole work area - 'the desktop is locked and I can't reach the game with the mouse'. MainWindow ctor now sets ShowActivated = !MainViewModel.FullscreenGameRunningNow (GetForegroundFullScreenMonitorIndex() != null), so an in-game launch keeps the game focused and live; a normal launch is unchanged. New FullScreenDetectorOverride test seam (mirrors WindowEnumeratorOverride - deterministic, no real Win32 foreground state) + DesktopLockTests: game at launch -> ShowActivated=false, normal launch -> true, FullscreenGameRunningNow reflects the detector. 422 pass / 1 known env flake, 0 build warnings. (Unit: launch focus) 2026-10-10 11:52:17 -07:00
gramps c2d64dcd6d TASK 51 blur 'heavy' fix: quadratic blur radius. BlurRadius(strength) = 1 + 5s^2, MaxBlurRadius 12 -> 6 downscaled px (~24 full px max). Default 0.5 is now radius 2 small px (was the old linear 6) - ~3x lighter perceived spread, matching the creator's real-camera finding. Range from the established tools: StreamFX defaults its Box blur Size to 5 and its author stops downscale-based blurs around 8px. One integration test pins the mapping (0 -> 1, 0.5 -> 2, 1 -> 6) and step-edge behaviour (light reaches 9px at default vs 26px at full); the existing slice-2 blur test passes unchanged. 419/419 pass. Refs: https://github.com/Vhonowslend/StreamFX-Public/wiki/Filter-Blur https://xaymar.com/articles/2020/04/26/optimizing-streamfx-in-your-obs-scenes/ (Unit: bg-blur heavy fix) 2026-10-10 07:08:01 -07:00
gramps 305706956e TASK 51 jitter fix: temporal mask smoothing. MaskSmoother blends each raw segmentation 0.75xnew + 0.25xprevious at the one CameraManager worker (state per CameraSession, not in the shared segmenter); null resets history, first frame passes through, input array never aliased, ping-pong buffers (no LOH alloc for a 147KB 256x144 mask). Creator's real-camera check: Remove was 'jittery' (per-frame independent segmentation). Weights from the OBS/Jitsi consensus. One integration test asserts the second frame keeps partial alpha (x=6 -> 58) where the raw mask cuts to 0. 416 pass / 2 known env flakes. Refs: https://deepwiki.com/royshil/obs-backgroundremoval/2-core-features https://github.com/jitsi/jitsi-meet/issues/17080 (Unit: bg-removal jitter fix) 2026-10-09 22:15:59 -07:00
gramps 5685c5dc3a TRAX: toggle refreshes the status light on play/pause, terse scrolling error on failure, fixed quiet volume (0.5), ITraxPlayer seam. Deadlocked test fixed via RunAsync (was GetAwaiter().GetResult on the UI thread). 416 pass / 1 known env flake. Refs: https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/async-programming-model-tap#dont-block-on-async-code (Unit: trax toggle) 2026-10-09 19:05:04 -07:00
gramps 87f05ba2ce Private broadcasts end like recordings: Enter keeps, Escape deletes (KeepBroadcastDialog). Privacy read from YouTube; delete = videos.delete (fallback liveBroadcasts.delete on 404). Refs: https://developers.google.com/youtube/v3/docs/videos/delete https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/delete (Unit: private broadcast close-out) 2026-10-09 13:32:38 -07:00
gramps b140024ebf Audio: speech leveler (auto gain to -20 dBFS speech, -6..+20 dB, gated) between gate and compressor; honest mic meter (drop the +10 dB display boost and x1.2). Creator upload measured -28.5 LUFS; same audio through the new chain: -17.9 LUFS / -0.7 dBFS peak (Unit: audio loudness) 2026-10-09 13:27:53 -07:00
gramps 8303b1cf7f Brand flash OFF by default: MainViewModel.ShowBrandFlash master switch (default false) gates BrandFlashEnabled; feature kept, tests opt in; new test guards the default (Unit: brand flash off) 2026-10-09 13:22:52 -07:00
gramps 352f117cb2 TASK 51 slice 5: webcam Background section in the Layers properties — Remove/Blur PillToggles (radio-exclusive) + Strength slider; strength changes never blank the webcam (new variant inherits the same-effect frame + bitmap). TASK 51 built (Unit: bg-effects UI) 2026-10-09 10:31:11 -07:00
gramps de22983b36 TASK 51 slice 4: camera-path hookup — CameraManager renders per-scene effect variants (one segmentation per raw frame on a latest-frame-drop worker, raw path untouched), VM declares/adopts variants per config; separable upsampling cuts Remove 46->3 ms, Blur 80->18 ms (Debug, 720p) (Unit: bg-effects hookup) 2026-10-09 10:27:38 -07:00
gramps 55210b9a61 TASK 51 slice 3: per-scene webcam BackgroundEffect + BackgroundStrength on WebcamSceneConfig, persisted via additive guarded columns; radio-pill bools RemoveBackground/BlurBackground (Unit: bg-effect config) 2026-10-09 10:19:48 -07:00
gramps 227ee8fca6 TASK 51 slice 2: Blur — background at 1/4 res, 3 separable box passes (~gaussian, radius by strength), mask-blended; person untouched; Remove gains strength = edge feather; mask computed once, rendered per effect (Unit: bg blur) 2026-10-09 10:18:00 -07:00
gramps fbeb63ba79 Docs: queue TASK 56 — pre-publication secret scan of the full git history (gate before the repo goes public) (Unit: task catalog) 2026-10-09 10:15:33 -07:00
gramps 4e42871fae Docs: README rewritten in the creator's voice (why it was built, generic comparison, free + open, real build-from-source steps incl. ffmpeg fetch + own OAuth client); source offer URL llgit.llamachile.tube/gramps/LlamaCasty in README + notices; TASK 51 keeps the strength slider (Unit: README + source offer) 2026-10-09 10:13:43 -07:00
gramps db2fdbf68d Docs: GPL-3.0-or-later (creator ruling); AI-generated scene backgrounds + alert video are GPL placeholders, not reserved; TASK 51 UI spec — Remove/Blur as radio-exclusive pill switches in the webcam's Layers properties (Unit: license follow-up + bg-effects UI spec) 2026-10-09 10:11:11 -07:00
gramps c6569a7ccb License: LlamaCasty is GPL-3.0-only with section 7 terms (attribution, rename modified versions, no trademark rights); icon + logo all rights reserved; LICENSE + ADDITIONAL-TERMS ship beside the exe; notices + ai.md rewritten off the paid/closed-source framing (Unit: GPL-3.0 licensing) 2026-10-09 10:05:12 -07:00
gramps f529d47f42 Build: require 64-bit Windows — PlatformTarget x64 in app + tests (bundled ffmpeg is win64, ONNX Runtime has no win-x86, 32-bit address space can't hold 1080p buffers) (Unit: 64-bit requirement) 2026-10-09 10:01:38 -07:00
gramps 9f3c154d84 TASK 51 slice 1: background-effects engine — bundled MediaPipe Selfie Segmenter (ONNX) + Remove (Unit: bg-effects engine)
- Model: Google's official MediaPipe Selfie Segmenter (landscape, Apache-2.0 per model card),
  converted to ONNX by tools/selfie-seg/convert_tflite_to_onnx.py (custom op
  Convolution2DTransposeBias -> ConvTranspose) and verified against MediaPipe's own runtime
  (verify_against_mediapipe.py: max |diff| 5e-5, 100% binary agreement). Embedded resource, 430 KB.
- Services/BackgroundEffects: IPersonSegmenter seam, OnnxPersonSegmenter (onnxruntime 1.31.0 CPU,
  never throws), BackgroundEffectProcessor (None passthrough, Remove = alpha mask). Not wired yet.
- Tests: Remove integration test (fake segmenter) + bundled-model contract test vs MediaPipe
  reference mask. Full suite 407/408 — the one failure is the documented interactive-desktop flake
  RealMouseDrag_OnTheLayerList_PersistsTheReorder (passed in the run before; code path untouched).
- Docs: ai.md reversal + section + licensing guard, THIRD-PARTY-NOTICES 11-12, TASK 51, indexes,
  MyMistakes recipe, HANDOFF.

References: obs-backgroundremoval (approach only — ONNX Runtime on CPU, 2 threads; GPL-3.0, no code
used) https://github.com/royshil/obs-backgroundremoval ; MediaPipe image segmenter
https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter
2026-10-09 09:24:13 -07:00
gramps f8dd1079f3 Docs: queue TASK 55 — disk hygiene (log rotation, temp alert-clip cleanup, low-disk recording guard) (Unit: task catalog) 2026-10-09 09:06:36 -07:00
gramps ed47480116 Docs: never print CREDENTIALS.md, not even filtered — sudo-password exposure incident + safe patterns (Unit: secrets hygiene) 2026-10-09 08:48:53 -07:00
gramps 058bef4312 Docs: resettle TASK 54 on Fourthwall as provider of choice — kills the storefront-layer problem (Unit: marcom provider research) 2026-10-08 12:22:09 -07:00
gramps 29ef5d5f85 Docs: pivot TASK 54 storefront — Big Cartel is API-only, choose Ecwid free tier (Unit: marcom correction #2) 2026-10-08 11:30:26 -07:00
gramps d8febbd0d8 Docs: correct TASK 54 merch plan — Printful Quick Stores is US-only; storefront layer = Big Cartel free tier (Unit: marcom correction) 2026-10-08 10:32:55 -07:00
gramps 9fb628b473 Docs: record Shorts/TikTok output-format verification (TASK 53) + free-forever/merch round (TASK 54); mark TASK 10 superseded (Unit: task catalog) 2026-10-08 10:20:01 -07:00
gramps fe80e8c994 Docs: record the zoom box as TASK 52 and fix the task-numbering drift (Unit: zoom docs)
The zoom feature shipped without a task record while its index rows carried the
"TASK 51" label — which belongs to the webcam background effects (Remove + Blur).
For an honest catalogue and handoff:

- TASKS/task-52-zoom-box.md: feature provenance (creator-requested), the three
  2026-10-08 corrections (loupe -> anchored capture over the full composite drawn
  last -> never show the dim shading), shapes, implementation map, tests, DONE.
- TASKS.md: row 52 added (shipped); row 51 reworded — unblocked now that the
  zoom build shipped.
- TASK 51 -> TASK 52 in Models/index.md, ViewModels/index.md, and the two code
  comments that pointed at the zoom as "TASK 51".
- HANDOFF.md: pre-push readiness notes (5 commits ahead, push-safe).
2026-10-08 08:52:36 -07:00
gramps 948dd08214 Zoom: dims never shade the box — move the dim overlay above CanvasGrid (Unit: zoom shading fix)
Creator's final zoom correction: as the box is dragged, its capture region slides
under the dim rectangles and the box picks up the 55% black "div shading" inside
itself, because the VisualBrush samples the whole CanvasGrid and DimRects lived
inside CanvasGrid. The captured area must stay clear regardless of box position.

- PreviewPane.xaml: the DimRects ItemsControl is now a SIBLING of CanvasGrid
  (above the composed content, below the ZoomBox), never a descendant — the zoom
  source is shading-free by construction.
- ZoomBoxInteractionTests: structural invariant — DimRectsLayer is a direct
  child of PreviewCanvas (not inside CanvasGrid) and renders below the ZoomBox.
- Side effect (correctness, not a bug): quality dims now also sedate the global
  overlays outside the crop, matching "chrome outside the crop is dimmed".
2026-10-08 08:40:23 -07:00
gramps 6bff21b234 Zoom: anchor the capture, magnify the full composite, draw on top (Unit: zoom rework per creator correction)
Creator corrected the "loupe" zoom on 2026-10-08: the click anchors a fixed
capture region (box/2 centred on the click) and the box is a live 2x view of
that region — dragging the box never changes its content; it magnifies the
fully-composited frame (every layer, incl. webcam) and draws LAST (n+1).
Rectangle shape is 4:3 ("more of a 640x480 aspect").

- MainViewModel.Zoom.cs rewritten: ActivateZoom anchors CaptureX/CaptureY
  (independently clamped); UpdateZoomMove relocates the box only; the per-frame
  BuildZoomFrame/ZoomFrameBuilder path is gone.
- SceneCompositor: skips the active ZoomSceneElement in every layer loop and
  BlitZooms it last via the new pure ZoomMagnifier (2x bilinear, temp snapshot
  via ArrayPool, round clip).
- Preview: ZoomBox hoisted above CanvasGrid as a sibling; its brush samples the
  whole composed preview at the fixed capture (2x).
- Tests: ZoomMagnifierTests (box-over-blue shows green capture at 2x incl. an
  extra red "layer" — the loupe/backdrop-only regressions both fail it) +
  round-clip; ZoomBoxInteractionTests rewritten (anchored capture, move-doesn't-
  change-capture, 4:3/9:16/square). 404 pass, 2 known environment flakes.
2026-10-08 08:26:29 -07:00
gramps 4b3d82ff0f Zoom: compose the box as a real 2x layer above the background (Unit: zoom layer + magnification proof)
Right-click now creates a transient ZoomSceneElement at z-index 1 whose pixels are a
genuine 2x magnified sample of the backdrop (live capture under art), composited into
stream/recording like any layer while the output crop stays the tier. All shapes are
centered on the click and clamped to frame; healed on load/scene-create, never persisted,
hidden from the layers list + element template (IsZoomLayer). BuildCompositorOptions is
now internal = always tier crop (test seam). ZoomFrameBuilder is a pure static sampler
(box/2 window centered on the box center, single-slot cache) proven by a banded-backdrop
test; LayerReorderPersistenceTests drag resolves rows by item since the collapsed zoom
row shifts container indices. Also books TASK 51 (webcam background Remove + Blur) and
syncs memory docs (HANDOFF, MyMistakes, indexes). Full suite: 405 passed, 0 warnings.
2026-10-07 23:39:54 -07:00
gramps 966e718f1e Zoom: fix preview zoom box + shape-aspect anchoring (Unit: zoom box visuals)
Right-click in the live preview now procs a box that is actually visible and
populated with the zoomed region, and each Zoom View Shape sculpts the box per
spec. OBS-style derivative: magnifier box shows the composited region and dims
the surroundings (same pattern as OBS "zoom" / preview region highlights).

Root causes of the invisible box (two WPF traps, recorded in MyMistakes.md):
- Empty <VisualBrush Stretch="None"/> used as an OpacityMask paints nothing,
  so the mask alpha is 0 and the WHOLE box (border included) went transparent.
- ZoomBox lived inside CanvasGrid while its VisualBrush sampled CanvasGrid — a
  circular reference WPF renders as nothing. Hoisted ZoomBox to a sibling of
  CanvasGrid inside a new PreviewCanvas wrapper (same 1920x1080 space).

Shape semantics (MainViewModel.ActivateZoom):
- Circle: square, click point = center.
- VerticalRectangle (phone): tall 9:16, click point = top-left corner.
- HorizontalRectangle (tablet): wide 16:9, click point = top-left corner.
- Boxes stay fully inside 1920x1080 via Math.Clamp.

UI: ZoomRectOutline/ZoomCircleOutline shapes replace the square Border;
circle clips the box to an EllipseGeometry; brush Visual released when inactive.
Zoom View Shape dropdown now uses the standard YtComboBox style.

Test (Good Dog rule, one integration test): ZoomBoxInteractionTests verifies
aspect/anchoring/clamp per shape and the matching outline+clip in the real
MainWindow. Full suite 404 passed, 0 warnings.

Memory: HANDOFF.md zoom state updated; WPF VisualBrush traps appended to
MyMistakes.md.
2026-10-07 20:46:45 -07:00
gramps 6b6284d05c Perf: dedicated strip-parallel composite (4 threads, BelowNormal) — UNIT 5
A composite splits into four horizontal row strips across four DEDICATED
background threads; the calling thread takes a strip too (so four workers
never mean five-way oversubscription), and anything under 64 rows runs
inline. BlitContentRaw, BlitBakedElement and BlitOverlay now fan out.
Output is byte-identical — strips already partitioned by row.

The demotion deliberately does NOT come from Parallel.For. The pool is
shared, so Thread.CurrentThread.Priority = BelowNormal inside it leaks
into every unrelated work item that thread later runs — a full test run
after trying it broke AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_
KillsTheLoopback. Established guidance agrees: use your own producer/consumer
queue with your own Threads and set their priority yourself
(https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority,
https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool).
Helpers/StripPool.cs is that queue. Four threads is the measured sweet
spot: fewer leaves render over budget, more steals cycles from the game
and the encoder and made render worse.

The scaled blit's column LUT moves from a shared static buffer to
ArrayPool<float>.Shared with a finally — a single static buffer is a data
race the moment strips run in parallel.

Gate: SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical —
8 renders of a scaled round webcam + mirrored image + master-sized flash over
a short backdrop must be byte-for-byte identical, and must differ from a
backdrop-only render so it cannot pass vacuously. It caught a loop-variable
capture (new Thread(() => Worker(i)) saw i == Workers) that crashed the test
host on the first run.
2026-10-05 18:54:47 -07:00
gramps 3901cb8298 Compositor: parallelize BlitBakedElement and BlitOverlay row loops; cap at 4 threads (Unit 5) 2026-10-05 17:44:03 -07:00
gramps 623d77e531 Compositor: parallelize BlitContentRaw row loop (4 threads, BelowNormal intent) to speed scaled blits (Unit 5) 2026-10-05 17:40:05 -07:00
gramps dc1343896d Compositor: reuse column LUT buffer to avoid per-call allocations (Unit 4) 2026-10-05 16:18:26 -07:00
gramps 77d5120c87 Compositor: add per-column LUT for scaled BlitContentRaw to reduce per-pixel math (Unit 4) 2026-10-05 16:16:17 -07:00
gramps 08290ece16 Compositor: prefill only uncovered rows when backdrop not fully covered (Unit 4) 2026-10-05 16:13:39 -07:00
gramps bdea18e12c Fix: add missing System.Collections.Generic import and remove unused var
Also tidies build warnings from Unit 3 changes.
2026-10-05 13:39:42 -07:00
gramps 0464593a02 Compositor: fast-path opaque 1:1 when source fills canvas
Handle the case where an opaque source exactly covers the destination
canvas with 1:1 dimensions (even if offsets differ in calculation). This
avoids per-pixel alpha branching and mirrors the existing memcpy fast
path. Byte-identical for opaque sources.
2026-10-05 13:34:20 -07:00
gramps cea641d429 Log: include scene name and compositor source rect on pump start
Adds scene name and comp={w}x{h} to the 'FramePump started' line to
help correlate logs with backdrop geometry (for the 141ms/33ms puzzle).
2026-10-05 13:33:38 -07:00
gramps 25d5416a93 Timing: add per-stage counters to FramePump stats
Add timing for social bar, ticker, flash delegates and scratch acquire,
and extend the 5s stats line to report them (ms per render). No behavior
change, only measurement.
2026-10-05 13:31:42 -07:00
gramps 81da58ab9c Log: alert resolver uses state-change logging instead of 1/s
Logs on transition between has-frame and null, and re-logs at most
every 10s to avoid spam while idle. No behavior change to the resolve path.
2026-10-05 13:27:07 -07:00
gramps a8e3954f01 Fix: add IsAlertBox virtual on SceneElement and override on Source
The Stream Alerts properties panel was incorrectly showing for the webcam
layer (a WebcamSceneConfig is not a Source) because IsAlertBox only existed
on Source, causing WPF binding to be treated as false-but-invisible context
or the trigger to bind against the wrong type. Adding a virtual IsAlertBox
to the base SceneElement (default false) and overriding on Source prevents
the UI block from rendering under webcam selection. Scope: Models/SceneElement.cs, Models/Source.cs, ytLive.Tests/AlertBoxPreviewVisibilityTests.cs. Build/test: app + 4 alert-box tests pass, other failures unrelated (float precision).
2026-10-05 13:26:12 -07:00
gramps 9d04aea55b UNIT 0 reading: the camera was never the problem, so Unit 1 is the wrong lever
Take ty-20261005-1201-556-0001.mp4 (75.3s) plus its log settle it. The
C920 delivers 30.0/s with ZERO gaps over 50ms and its own device stamps
agree - camera, USB and driver are clean. The pump managed 34 composites per
5s at ~141ms each, and the file holds 6.8 distinct webcam images per second
at 88.7% duplicates with no arrival hitch whatsoever. The judder is us
holding one face for 140ms and padding the rest of a 60fps container, which
is precisely what a lipreader experiences as "cutting out".

So 720p is a SHARPNESS win, not a smoothness win: it triples the bytes the
compositor stretches per render on a path already 4.4x over budget. The
exclusive camera lock stays agreed and stays queued, but it moves after the
compositor work or it makes the judder measurably worse.

The number still unexplained: the 10:01 take hit 25.6 images/s and this one
6.8, with the same scene shape (full-render split=0 elements=6 dynamic=5)
and the same 640x480 camera - composites went 29-34ms to 143-155ms between
sessions and nothing logged says why. There is no per-element composite
breakdown and no scene dump at pump start, so the next unit is measurement,
not a fix. Recorded the pump-counter overcount (13/s vs 6.8/s: one 141ms
composite lets ~4 camera frames arrive, and the counter stamps the first
epoch the resolver sees, not the frame actually drawn) so it is not
re-derived as a bug later.
2026-10-05 12:06:28 -07:00
gramps 55ca1029a3 UNIT 0: make a dropped webcam frame measurable, not just inferable
The creator is DHH and lipreads their own recordings, so a webcam that
judders is an accessibility defect, not a polish item. Take
ty-20261005-1001-821-0001.mp4 holds 25.6 distinct webcam images/s against
the camera's 30fps with 281 gaps of 50-100ms - roughly one hitch in five -
in a file that muxes flawlessly. Nothing in the app could see that: rawvideo
carries no per-frame timestamps (ffmpeg stamps by ARRIVAL at the declared
60fps), and the stats line divided every average by the number of frames
SUBMITTED, which deadline padding always fills to the full target. So the
pump reported "300/300 frames, avg render 12.4ms" while producing 60
distinct composites.

Helpers/FrameCadence.cs is one pure counter fed from three points:
MediaCaptureFrameSource logs the device's own SystemRelativeTime stamps
against our arrival times (TryAcquireLatestFrame keeps only the LATEST
frame, so falling behind is silent by design), and FramePump logs the
distinct webcam Epoch values that reached a composite. Read together they
name the culprit - device/USB, our reader, or our compositor - instead of
guessing. Render/resolve/wait now divide by the real render count, and
"frames per 5s from R renders" is the duplicate-on-lag ratio stated
honestly.

An out-of-order timestamp counts as a frame but produces no gap and does not
become the next interval's reference, so one bad stamp cannot invent a
multi-second hitch twice.

Also corrects a stale ai.md line (the "webcam renders at ~18fps" figure
came from a different session and counted every newly rendered layer) and
re-records the background-removal ruling as permanently out. Records the
agreed-but-unbuilt Units 1-2, the no-fallback-camera ruling, and the
SetFormatAsync dead end found by probing the 19041 projection.

Suite 402/402, 0 warnings. The one red, LayerReorderPersistenceTests'
RealMouseDrag, fails identically on a stashed clean tree - it needs an
interactive desktop session this shell lacks.
2026-10-05 11:10:22 -07:00
gramps b2f219739d HANDOFF: record the explicit-bind fix, the unpushed 5, and the 395 suite
Push state was stale (said cc2d1ef, actual origin is 048cab7 with 5 local commits),
the RTMP entry still framed the binding as an open question, and the test count was
381. Also records the frame-pump jitter as unquantified and the creator's future
long-run benchmarking to-do as record-only.
2026-10-04 15:30:22 -07:00
gramps 509ef36aa0 Launch upper-left with 75px padding; stop restoring window position
Creator ruling: open in the upper-left of the screen with ~75px padding. The
restore path was placing the window below the fold.

RestoreWindowGeometry now ignores the saved position and calls the pure
TopLeftPlacement(workArea, w, h, padding): work-area origin + LaunchPadding
(75, in DIPs so it scales with DPI), clamped so the window is never pushed
off-screen. A window that fits stays wholly visible; one too big to fit falls
back to the work-area origin.

Size and maximized state are still persisted -- only position was dropped. The
size clamp is unchanged (saved values clamp to [MinWidth, work.Width], which is
why a saved 800x500 returns as 1366x768 per the XAML minimums).

The old clamp tolerated Left as low as work.Left - width + 120 and Top up to
work.Bottom - 80, ranges wide enough to leave the window almost entirely
off-screen while still looking like a successful clamp. A clamp that permits
the window to be unreachable is worse than none: it reads as a guarantee it
does not keep.

BroadcastPullOutTests is renamed ..._Window_Launches_TopLeft_... and now asserts
the constructor applies the derived placement for both an on-screen and a -99999
saved position. Placement math is covered by WindowPlacementTests as a pure
function needing no HWND.

Suite 395/395, 0 warnings.
2026-10-04 15:28:05 -07:00
gramps 07a088a23f Bind broadcasts explicitly; the week-long RTMP dead end was never a bind
root cause (proven, not inferred): contentDetails.boundStreamId on
liveBroadcasts.insert does NOT bind the broadcast. Our diagnostic reads
lifeCycle=created privacy=private autoStart=true bound=NONE
boundMatches=NO-BINDING | streamStatus=active

created is Google's "incomplete settings" state, so enableAutoStart has no
bound stream to fire from; close-out then 403s invalidTransition because
created -> complete is not a legal edge. A reusable stream accepts bytes for
a broadcast that isn't watching it, so a perfectly healthy 99MB RTMP session
is exactly what an unbound broadcast looks like -- every observable pointed
away from the binding.

Google's migration guide states the rule outright: a liveBroadcast must be
bound to exactly one stream before it can start, and is not bound at creation.
That was in the docs read during the original design and was not applied.

Shipped:
- BindBroadcastAsync -> POST liveBroadcasts/bind?part=id&id&streamId (no body),
  called from PrepareAndStartLiveAsync right after CreateBroadcast, before the pump.
  Failure is fatal to the session: an unbound broadcast can never go live, so a
  live-looking UI over one is a lie.
- boundStreamId removed from the insert body, and the streamId parameter removed
  from CreateBroadcast. A parameter that looks meaningful but cannot affect the
  outcome is what made the misdiagnosis possible.
- Replaced CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which
  asserted the disproven behaviour and therefore laundered a guess into a
  guarantee, with tests for the omitted field and the real bind endpoint.

Error messages (creator ruling 2026-10-04): bind failures are not all "check the
sign-in". Six documented reasons exist and only one is auth; BindFailureMessage
gives each its own text, and the fallback passes the raw reason through verbatim
so an unmapped reason stays greppable. liveBroadcastBindingNotAllowed matters
most -- it means our ordering is wrong, and a generic re-auth message would hide
a bug in our own code behind a plausible excuse. See MyMistakes.md.

ai.md: also corrects the TASK 44 claim that liveChatId is only populated once the
broadcast is live. The diagnostic shows it present while lifecycle is created.
That claim was inferred from a sample's active-status guard, which is not a spec.

Suite 395/395, 0 warnings.
2026-10-04 15:27:01 -07:00
gramps 8fbc3701d2 fix: the broadcast diagnostic reported NO-BINDING from a parse that never ran
The run I asked for came back with three alarming lines:

  lifeCycle=? privacy=? recording=? bound=NONE boundMatches=NO-BINDING
  autoStart=unset autoStop=unset liveChatId=none | streamStatus=active

Every single broadcast field was empty, and the stream half was fine. That
combination is the tell: not a failed request, a wrong nesting level.

liveBroadcasts.list returns a LIST wrapper, so the broadcast is at items[0]. I
descended into items for the stream and read the response root for the
broadcast, so every lookup missed, and BoundMatches — handed a null it could
not distinguish from a real null — reported a confident NO-BINDING. The
diagnostic invented the bug it was written to find. Three lines of fabricated
alarm on the one run that was supposed to produce evidence.

Two fixes, and the second is the one that matters:

  FirstItem() descends into items[0] for both halves, and returns default when
  there is genuinely no item.

  The line now prints boundMatches=unread when the broadcast could not be
  read at all, so an absent parse degrades to "I don't know" instead of a
  verdict. A diagnostic that guesses is worse than no diagnostic: this one
  would have sent me off to rebuild a binding that may well be correct.

Test guards the regression: FirstItem_DescendsIntoTheListWrapper_AndReports-
AbsenceHonestly, including the empty-items and missing-items cases. Suite
390/390, 0 warnings.

Credit to the run for catching it. What it did establish, from the half that
worked: streamStatus goes ready -> active about 30s into the push. So the
ingest registers as active server-side, which combined with the 99MB muxed
yesterday means the stream half is verified healthy end to end. The broadcast
half needs one more run to say anything.
2026-10-04 15:05:28 -07:00
gramps 0fb2f9c6c3 docs: Go Live IS private (verified), and v1 is where that guarantee ends
The creator's standing doubt: "I wasn't sure that the go live video would be
private." Answered by tracing every path that can touch privacy, not by
reassuring:

  CreateBroadcast          -> privacyStatus = "private"   hardcoded
  GoLiveViewModel          -> Visibility => "Private"     hardcoded
  BeginTestStream          -> StreamVisibility = "Private" hardcoded
  UpdateBroadcast          -> the only code that can change it — NO CALLER

UpdateBroadcast's sole caller in the repo is BroadcastPullOutTests.cs:120. It
is dead code. So there is no path today by which a broadcast this app creates
becomes non-private, and the "do not populate my channel with videos showing
the app at its worst" concern is fully met. Verified, not assumed.

Which makes the safety accidental rather than structural, and that is the part
worth writing down. LiveBroadcastFormViewModel still offers
VisibilityOptions {private, unlisted, public}, and UpdateBroadcast would honour
it. So the v1 visibility option is precisely the change that first wires that
method up — v1 is the moment private stops being guaranteed and becomes a
user-selectable default. Intended at v1, ruinous before, and "it's dead code,
let me call it" is not a valid reason to do it early.

Also recorded, having been absent from the map entirely: the TEST button is
hidden at v1. Its real job is being the guaranteed-private path (hence the
hardcoded Private at BeginTestStream:105), which the existing "dry-run"
comment on ShowTestButton does not capture — that framing is now known to be
incomplete, and must not be used to re-derive why the button persists.

Corrects a fact I got wrong one commit ago: the method is UpdateBroadcast, not
"UpdateBroadcastMetadataAsync", and I described it as an available retrofit
path when it is in fact unreachable in production.

Open, deliberately, for v1 rather than now: whether a dry run is still wanted
on its own merits once Test is hidden.
2026-10-04 14:58:55 -07:00
gramps 74da99c988 docs: the always-Private lock is a decision, and it unlocks at v1
Asked twice about a decision that has been settled since 2026-09-01, so this
is a documentation failure rather than a question worth answering a third
time.

The note was already there and it was accurate. It still didn't work, which is
the whole lesson: every sentence in it was descriptive. It said why the lock
exists and where the unlock lives, and not one sentence told the reader what to
DO when it looks like a defect. I read it, recognised it, and asked anyway —
because recognition is not instruction. It also pointed at TASK 36 for the
unlock while the creator's actual answer is v1, so it was confidently wrong on
top of being non-directive, which is worse than saying nothing.

Rewritten as a prohibition carrying its own trigger: verdict in imperative
voice, then the observations that make it look broken (privacy=private in the
diag line, the PRIVATE badge, recordFromStart/DVR on, no delete call in the
codebase) so recognition fires the instruction instead of prompting a question,
then the retired delete question with its verified answer.

Also: it belongs to v1, not TASK 36, and UpdateBroadcastMetadataAsync:112 is a
retrofit path, not the product change.

MyMistakes.md gets the transferable part, because the fix is not "write it
down" — it is "write decisions as prohibitions, not rationale, and treat a
repeated correction as a broken note rather than an impatient user".
TASKS.md gets it under 1.0 gates so it is a tracked deliverable, not prose.
2026-10-04 14:52:33 -07:00
gramps 5ad0106502 diag: log what the broadcast is actually bound to while a stream runs
The logs answered the question and closed off a whole branch of investigation.

The long run: broadcast inserted 14:17:04.577, ffmpeg to
rtmp://a.rtmp.youtube.com/live2/5f6z-... at 14:17:05.686, and ffmpeg's own
muxing summary at 14:18:56.473 reads

  video:101229KiB audio:1419KiB  muxing overhead: 0.202949%

~99MB of real video plus audio over 112s, which is what 8000+128 kb/s is
supposed to produce. No socket error, no 4xx from ingest, no reconnect. And the
broadcast still never left `ready`; close-out 403'd invalidTransition again. The
8s run behaved the same way, so "YouTube needed longer" is disproven — 112s is
longer.

So the RTMP path is provably healthy. That is the finding worth keeping: it
kills the entire "the push is broken" theory, which is where this had been
heading for a week. The break is between ingest being accepted and the broadcast
being told to start.

Which puts the binding in frame, and the app was structurally blind to it.
Google documents contentDetails.boundStreamId and says enableAutoStart fires
"when you start streaming video on the bound live stream" — both valid on
insert, neither restricted against reusable streams. But the migration guide
also says a broadcast "is not bound to any streams at the time that it's
created", and the failure that produces is invisible from the RTMP side
precisely because a reusable stream will happily accept bytes for an unbound
broadcast. Ingest succeeding proves nothing.

Hence LogBroadcastDiagnosticsAsync, riding the existing health poll rather than
adding a timer. One greppable line: lifecycle status, privacy, recording,
boundStreamId *compared against the stream id we are actually pushing*
(BoundMatches: yes / MISMATCH / NO-BINDING / unknown), boundStreamLastUpdate-
TimeMs, autoStart/Stop, liveChatId presence, the stream's own streamStatus and
activeLiveChatId, and the CDN settings we declared.

Deliberately self-limiting: _broadcastSeenLive latches on live/liveStarting so
the diagnostic shouts only while the broadcast is stuck and goes quiet once it
is healthy. A permanent per-tick log line would be noise; this is the opposite.

One test, per the Good Dog rule: BroadcastBindingTests pins the matrix and
specifically pins `unknown` when there is no stream id to compare against, so a
missing expectation can never be misreported as a match — that is the failure
that would make this diagnostic lie.

Suite 387/387, 0 warnings.
2026-10-04 14:47:09 -07:00
gramps 048cab79f8 docs: handoff answers the RTMP diagnostic and records today's two units
The handoff asked for the RTMP diagnostic as item 1 and claimed the next code
unit was packaging. Both are now wrong, and per the map's own rule a stale
handoff is worse than none because the reader trusts it.

1. DIAGNOSTIC ANSWERED, and it narrowed the question rather than closing it.
   ffmpeg reaches YouTube — rtmp://a.rtmp.youtube.com/live2/..., 7.4 MB video
   plus 46 KB audio muxed, no socket error. The broadcast never leaves `ready`:
   enableAutoStart=true was sent, yet close-out refused `complete` with 403
   invalidTransition, which is only legal from `live`. Two things stay honestly
   unknown — the push was only 8s, and the app never polls broadcast status
   during a stream (only stream health), so the log cannot show whether or when
   it flipped. Next step is one log line plus one 60-second test.

2. Packaging is DEFERRED, not next. Creator ruling 2026-10-04: still in active
   development, so ship-time work waits. Item 1 of "Next" said the opposite.

3. Push state: origin/main = cc2d1ef as of 2026-10-04, tree clean. Stale push
   state has now bitten twice here, so the line now tells the next session to
   verify with rev-list instead of believing the file.

4. Test count 381, not 380.

5. New landmine: the frame pump logged 86 stalls per 5s (142 lines in an
   8-second window) while stats still read 301/300 frames and 0 dropped. Those
   two facts contradict each other; recorded as a measurement to take, not a
   bug to fix from the log.

Startup stall (TRAX / unreachable NAS) shipped in cc2d1ef and is documented in
ai.md; not duplicated here.
2026-10-04 09:37:32 -07:00
gramps cc2d1ef622 TRAX: a share that is gone must never freeze the startup window
Startup took 44s and 42 of them were one line. Measured from startup.log:
OnStartup 08:42:59 -> MainWindow loaded 08:43:43, with the whole gap inside
LoadLayout, ending at

  TRAX: failed to load '\\192.168.50.86\llamaVault\Assets\Music\...':
  The network path was not found. (0x80070035)

LoadTrax called MusicPlayer.Load synchronously on the UI thread, and
new MediaFoundationReader(path) does not fail fast on a dead SMB session — it
burns the full ARP/TCP/SMB timeout and only then throws. The NAS had moved to
.87 after the power outage, so an unreachable path became a 40-second frozen
window on every launch.

The rule, from the creator: a probe may time out; a bind should return
immediately. Only one side of that is achievable here, and the reason matters —
an unreachable UNC path has no fast-fail at all, so probing it on the calling
thread is the worst possible probe. (A device open is the opposite: it normally
fails fast on a sharing violation, which is why this bug is network-specific.)

So: IsTrackReachableAsync runs the probe on a thread-pool thread under a
Task.WhenAny ceiling (TraxProbeBudget, 2s) and treats offline/missing/slow as a
normal state rather than an error. Only a probe that passed gets the real bind,
and that bind deliberately stays on the UI thread alongside every other
MusicPlayer mutation — moving the whole load off-thread would have introduced a
race against PlayTrax/PauseTrax that does not exist today. The orphan probe
thread stays parked until SMB gives up; it is not the thread anyone waits on.

One test, per the Good Dog rule: TraxProbeTests.
AHangingProbeIsAbandonedAtTheBudget_AndReportsOffline stands in a probe that
never returns and asserts the caller is released and told "offline". Drop the
ceiling and it fails on elapsed time — which is the frozen window returning.

Suite 381/381, 0 warnings.
2026-10-04 09:24:12 -07:00
gramps ad10176e8b docs: llamavault is .87 now, and SMB must be reached by name
The 2026-10 power outage rotated the NAS off its DHCP lease: 192.168.50.86 is
dead, 192.168.50.87 answers. The app had the literal IP baked into
Music.TrackPath, so TRAX blocked ~40s on an unreachable network path during
LoadLayout before giving up (0x80070035) — an unbounded SMB open on the
startup path.

Two facts banked so this is not re-derived:
- Reach the share as \\llamavault\llamaVault\... The Windows hosts file pins
  that name to the current IP, so an IP change is now a hosts-file edit, not
  a database edit inside a running app.
- The IP is DHCP. Ping before trusting any hardcoded path.

DB repointed to the name (Music.TrackPath, one row); backup kept alongside it
as ytLlive.db.bak-20261004-090001. Startup log truncated 16.7MB -> 118KB
(AppLog.AppendAllText holds no long-lived handle, so truncating in place is
safe).
2026-10-04 09:00:56 -07:00
gramps f3fed0a2a8 docs: purge the dead $29/$49 + Polar model from every tracked file, add the Y1 forecast
The pricing ruling landed twice (2026-09-21 one-time, then 2026-09-27 Store IAP
$10/mo + $400 lifetime) and the map never caught up. Six tracked files still told
a future session that billing was Polar and the price was $29/$49. A stale memory
map is worse than a stale comment: it is the file every session trusts, and this
one was actively steering work toward a deleted provider.

MONETIZATION.md (gitignored, so not in this commit) was rebuilt first:
- "Revenue projections" now carries a modelled Year 1 forecast —
  pessimistic $4.0K / base $15.4K / optimistic $53.3K net, ~$22K weighted.
  Funnel is installs -> 35-45% activate -> 1.5-3% of active users pay.
- Three findings recorded: Year 1 is ~97% lifetime transactions (subs net $392
  of $15.4K, so MRR is not a Year-1 planning input); conversion dominates price
  (~1pp of conversion = ~$7K net, and ADA 6(h) locks $10 anyway); subscriber
  churn moves Year 1 by under $200 and is a Year-2 problem.
- Break-even restated for $400: $25K net = 74 lifetime buyers, not 862.
  The price cut the volume requirement ~10x and raised the skill requirement
  to match.
- Market sizing willingness-to-pay row rewritten. The 100K-400K TAM was
  price-bound to $29-49 and is wrong at $400; revised ~20K-60K, flagged as an
  estimate rather than a sourced number.
- Flagged a real contradiction: $500K ARR needs ~4,900 concurrent subscribers,
  so the Exit section's ARR multiples do not describe what Years 1-3 produce.
  The defensible asset there is lifetime cash flow, not ARR.

Tracked files corrected to match:
- ai.md: paid-delta, pricing and billing bullets rewritten; Polar product id,
  org id, LCYT- key prefix and LLAMAFOUNDER/LLAMA50 marked as deleted;
  15% share + net-after-tax recorded with the ADA 6(b)(i) citation.
- TASKS.md: catalog row retitled as a teardown; the "still to verify — the
  price is not decided" note closed (it contradicted the ruling 20 lines
  below it); the OverlayHost "Polar unlocks alerts" open question marked
  resolved; Polar file-hosting deliverable removed from the release build.
- task-10: reframed as a teardown. Steps 4-5 marked as surviving, 1-2-3-6-7 as
  deleting, 8 as a compliance action (Velopack downloads AND executes, which
  Store policy 10.2.2 does not permit).
- task-36 item 5 (license-expiry reminders) struck — no key, no expiry, no
  lapse under Store IAP, so there is nothing to remind anyone about.
- Distribution.md 4.2 / 4.4 / 4.5 marked obsolete; 4.5's "already implemented"
  corrected, since PolarLicenseService is being deleted rather than shipped.
- README: "one-time license" -> "paid tier".

Historical Polar sections are retained deliberately — the reasoning is reusable
(merchant-of-record, fee comparison), only the conclusions are dead. Each is now
labelled so it cannot be mistaken for the plan.

No code touched.
2026-10-02 20:56:37 -07:00
gramps d95a23c6fb docs: the handoff was lying about three things — push state, TASK 50, test count
Correcting the handoff against the repo, per the rule that a stale map is worse
than no map. Three facts were wrong or missing, and the first two would have
made the next session's first action wrong.

1. PUSH STATE. "Last pushed: e7cded6" with two commits listed as unpushed.
   main was actually 9 commits ahead of origin/main. The line under-reported
   unpushed work by 7 commits, which is the optimistic-by-construction failure
   MyMistakes.md already names for a handoff whose heading promises the work
   is live. Now recorded as caught up.

2. TASK 50 WAS ABSENT, AND IT CARRIES A LIVE BUG, NOT A QUEUE ITEM. The task
   was carved 2026-09-28 and its mechanism section was CORRECTED the same day
   against the creator's own channel: 20+ private videos, all content-less
   placeholders. That is not a deferred feature, it is the real explanation of
   the 2026-09-26 "I cannot get private test recordings saved" report the map
   had been carrying as a closed item. liveBroadcasts.insert succeeds and
   creates the placeholder; the RTMP push / LIVE transition / archive chain is
   what fails, so no video content is ever produced. Added as an OPEN entry
   with the single diagnostic question that localises it (does ffmpeg reach
   a.rtmp.youtube.com, and does the broadcast reach LIVE), plus the sequencing
   constraint: do NOT flip enableAutomaticArchiving before TASK 50 ships, since
   archiving is currently the only route a recording takes to YouTube.

   It also carries an open 11.12 re-check — a publish feature makes the app
   PUSH creator content to a third party, so the IARC answers must not describe
   an app that cannot publish. That is a certification-ordering constraint, not
   a code detail, and it belongs where the next session will read it.

3. TEST COUNT WAS STALE. Recorded 379; the suite is 380 (the force-ssl scope
   test added one). Re-verified on a clean test-project build.

Also recorded a new flake honestly rather than leaving it for the next session
to rediscover as a regression: MasterLimiter_CapsTheLiveMix_OnThePipe failed
once on a full-suite run with Expected 0.891 / Actual 0, then passed 27/27 as a
class run and 380/380 on an immediate re-run. It reads the live mix off the
named pipe, so a ZERO is the pipe not being fed yet, not the limiter
misbehaving — the same whole-suite scheduling family as the RealAppHost STA
landmine. Recorded with "re-run before believing it".

Next list: the RTMP diagnostic sorts first because it is an observation rather
than a build and it gates a queued task; the packaging unit remains the next
CODE unit, which is not a reordering of the creator's priorities.

verify.sh: clean build, 0 warnings, 380/380, scope check passed.
2026-09-29 08:28:33 -07:00
gramps 2569c09617 task 50: correct mechanism — creator's channel proves stream never lands 2026-09-28 12:11:10 -07:00
gramps 3637950df5 task 50: one-button publish the local recording (creator ruling 2026-09-28) 2026-09-28 12:04:55 -07:00
gramps 967df83925 tests: one source-assertion helper, so the prose trap can't recur
The trap I hit twice today — a file-wide DoesNotContain matching the doc
comment that explains the ban — was possible because three tests each
hand-rolled their own File.ReadAllText + path-walker, two of them mine. Fixed
at the root rather than per-test: SourceAssert now owns path walking and
literal extraction, with Literal(memberName, params parts) and Scopes() that
assert on the parsed VALUE. Tests that genuinely assert structure (the #if
DEBUG arm in InstanceIsolationTests, the absence of a whole API in the
locator) still read the file themselves, because those claims cannot be
reduced to a value — the helper is for literal facts only.

Shipping the fix introduced two defects, both caught by the build: a method
named Path shadowed System.IO.Path and broke the error message that names the
missing file, and Literal(fileName, memberName) invited a call that silently
resolved to a nonexistent path. Argument order is now required-context-first
so a wrong path is visible at the call site.

Suite 380/380, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag flaked
once mid-session with no ytLive instance running and passed in isolation and
on re-run — the environment flake already recorded in HANDOFF.md, not a
regression from this change.
2026-09-28 11:53:52 -07:00
gramps b3edc2e42e Drop the redundant force-ssl scope, and reject the playlistItems "fix"
force-ssl is gone from the requested scope. Its grant ("see, edit, and
permanently delete your videos, ratings, comments and captions") is a strict
subset of auth/youtube's ("Manage your YouTube account"), and Google's own
liveBroadcasts.insert reference lists auth/youtube as an accepted ALTERNATIVE
to force-ssl, so every endpoint this app calls — channels, liveBroadcasts,
liveStreams, liveChat, playlistItems — is covered by the one we keep. Two
consent lines became one, and one fewer scope to justify. Removing a scope
cannot narrow capability when the scope being kept is the broader of the two,
and there is no forced re-consent: a refresh token keeps the scopes it was
originally granted, so existing sessions are untouched.

The playlistItems change is NOT being made, and the map entry recommending it
was wrong. It came out of the verification hunt on the theory that public data
should not ride on user credentials. Walking the code kills it: ChannelStats-
Service is built with one HttpClient, and channels?mine=true on the same
service genuinely needs the session. Splitting it means a second unauthenticated
client PLUS a Google API key, since every YouTube Data API request needs a
credential and the call 403s without one. So the fix would ship a new
extractable, quota-abusable credential inside the binary to replace a token the
app already holds, buying zero verification-surface reduction (the data is
public, no scope involved) and zero correctness. Strictly worse. Recorded as a
do-not-do so it is not "helpfully" reintroduced later.

Test: ExactlyOneScopeIsRequested_AndItIsTheBroadOne asserts on the const's
parsed VALUE rather than scanning the file — a file-wide DoesNotContain
fails on the doc comment that names force-ssl to explain its removal, which is
the same trap I hit and documented earlier today in the locator test. Suite
380/380, 0 warnings.
2026-09-28 09:45:35 -07:00
gramps 4fcf76289c TASK 48 item 1: bundle ffmpeg — the app no longer downloads an executable
Store policy 10.2.2 forbids dynamic code inclusion, and this was the only
third-party binary the app ever downloaded and ran. It had already failed in
production: the pin aged out of BtbN's 14-day retention and the download 404'd
on the creator's first real recording attempt (2026-09-01). Bundling kills
that failure class permanently and deletes the startup network dependency.

- scripts/fetch-ffmpeg.sh populates a gitignored ./ffmpeg from a pinned
  month-end BtbN LGPL-shared artifact, SHA-256 verified before extraction,
  idempotent, FFMPEG_ZIP= override. 135 MB of DLLs in git would tax every
  commit on this 9p tree; the URL + hash are the audit trail LGPL §6 wants.
- ytLive.csproj copies ffmpeg\** to output and publish (Always), and a
  GuardBundledFfmpeg target fails the BUILD if the folder is missing, so a
  fresh clone finds out before a take rather than during one.
- FfmpegLocator loses the downloader, the zip extraction and the
  %APPDATA%\ytLlive\tools cache. Order is now bundle -> PATH, deliberately
  inverted: a PATH ffmpeg may be a different major version or a GPL build,
  and the bundle is what was pinned, license-vetted and tested. A missing
  bundle throws loudly; the old "found something on disk" path could satisfy
  a missing bundle with a stale copy, and silent drift is the worst failure
  mode for a dependency certification inspects.
- ffplay.exe (18 MB) is not shipped; nothing calls it.
- THIRD-PARTY-NOTICES.txt: "downloads" -> "bundled" throughout, and the stale
  build tag autobuild-2026-08-09-13-03 — the tag that 404'd — corrected to
  autobuild-2026-08-31-13-27 with the exact artifact name and archive hash.
  OpenH264 made precise: compiled INTO avcodec-63.dll, no separate binary.

Checked whether other third-party binaries needed bundling: no. e_sqlite3.dll
and WebView2Loader.dll already ship via NuGet into runtimes\win-x64\native\,
OpenH264 is inside avcodec-63.dll, the rest are managed. Two gaps remain that
bundling cannot fix, recorded in the map: the WebView2 Evergreen runtime is a
machine prerequisite (TASK 48 item 2), and Velopack's updater downloads and
executes, so its teardown is a compliance action.

Tests: 16 locator facts, suite 379/379, 0 warnings. The integration test runs
the bundled ffmpeg -version, which is the only assertion that proves the
sibling libav*.dll resolved — presence checks pass with the DLLs missing and a
shared build then fails to start. TheLocatorCarriesNoNetworkCapability keeps
10.2.2 from rotting back.

Analysis: TASKS/research-store-certification.md §4.
2026-09-28 09:38:21 -07:00
gramps eb0dd91126 docs: OAuth verification is PARKED, not the next step — the map was lying
The 2026-09-27 entry sat under a "NEXT STEP" heading and framed the Google
verification as the top priority. It was neither un-attempted nor un-blocked:
the attempt had already failed on a Google-side bug and the creator had
already ruled it unimportant for where we sit. A "next step" heading lends a
dead end borrowed authority, and the next session would have opened the
console instead of the editor.

Records the ruling in the terms that change behaviour: attempted, died on
Google's side, ruled unimportant, do not re-attempt. States the cost of
tabling it honestly — an accepted 7-day refresh-token expiry while the
consent screen sits in Testing, which is why the creator kept having to
re-consent. The sensitive-vs-restricted classification was never read and is
moot until a second real user or Store submission.

The mechanism of the Google-side bug was never recorded and is left
deliberately blank rather than reconstructed — a confident fiction in a
memory map is the one sentence the next session would trust. The project
number and console link stay in ai.md:233; they cost a hunt and are still true.

The two code scope cleanups survive the tabling (neither needs verification
to land): drop the redundant force-ssl scope, and move the uploads-playlist
fetch off the user's session token. The channels?mine=true call on the same
service legitimately needs the session — flagged so it is not "fixed" later.

No code touched. Test state unchanged.
2026-09-28 09:25:27 -07:00
gramps 4873ef85ea docs: record the Google Cloud project owner + the OAuth verification blocker
Creator-confirmed: the Cloud project (number 198374416003, from the ClientId) is
owned by llamachile.shop@gmail.com. Recorded because finding it cost a hunt, and
because the trap was assuming it was the same account as the YouTube channel used
in the app -- they are separate logins.

Also records what the repo had nothing on at all: the OAuth verification path, the
Testing-mode 100-user cap + 7-day refresh-token expiry that explains the repeated
re-consent, the sensitive-vs-restricted fork (a paid annual CASA assessment if
restricted), and two scope cleanups -- auth/youtube.force-ssl is redundant, and
ChannelStatsService's playlistItems.list needs no OAuth scope at all.

Docs only, no code touched.
2026-09-28 09:04:54 -07:00
gramps 44344c9327 fix: Change Account signed out BEFORE the browser opened
Reported 2026-09-27: click avatar -> Change Account -> cancel in the Google
chooser -> back to a signed-out app, then "cannot sign-in because the callback
url was not released".

Two independent defects:

1. ChangeAccountAsync ran `await SignOutYouTubeAsync(); await SignInAsync();`.
   SignOutYouTubeAsync -> TokenStore.Clear() -> deletes ytLlive.auth, so the
   working account's token was destroyed before the browser even opened. It is
   now just `await SignInAsync()`: success overwrites the token via the
   sessionChanged hook, cancel/failure keeps the current session. The explicit
   sign-out was never needed.

2. Abandoning the browser gives the app no signal, so the OAuth attempt held
   localhost:8765 for its full 5-minute timeout and a retry could not bind. A
   new AuthenticateAsync now cancels and awaits the abandoned attempt first.
   That takeover is serialised by a SemaphoreSlim: the first version cleared
   the in-flight marker before awaiting, so a caller arriving in that window saw
   a free port and raced to bind it -- reproducing the very failure it was
   written to prevent. My own test caught that; reasoning had "proved" it fine.

Also adds an `openBrowser` ctor seam so tests never shell out to a real
browser, and corrects the ai.md line that documented "Logout / Change Account ->
SignOutYouTubeAsync" as intended behaviour.

Test: YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort
Full suite 370/370, 0 warnings. Coverage gap noted in HANDOFF.md: the ordering
fix has no automated test because _youtubeAuth is hardcoded in MainViewModel
and ChangeAccountAsync is private.

The reported cross-instance effect is NOT this cause and stays open -- no code
path can sign out a second instance (per-instance TokenStore, local delete, no
server-side revoke).
2026-09-27 16:02:05 -07:00
gramps 536676f11a fix: millisecond segment in the recording filename so two instances can't clobber a take
The dev multi-instance use case (one instance screen-capturing the other for
marketing/training videos) exposed a real bug. BuildStartPath in
MainViewModel.Recording.cs builds the start name with no dedup:

    var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
    var path = Path.Combine(folder, name);

Only FinalizeRecordingAsync calls UniquePath. So two instances that start
recording in the same minute computed the identical path
(ty-20260927-1454-0000.mp4) and both ffmpeg processes wrote one file -- a
corrupt take, not an error message.

Creator's fix (2026-09-27): add a millisecond count to the filename timestamp.
New format is ty-<yyyyMMdd>-<HHmm>-<fff>-<hh2mm2>.mp4. Both the start name and
the final name derive from _recordStartTime, so the ffff segment survives the
rename-on-stop and the final name cannot collide with another same-minute take
either. No UniquePath call needed, which keeps the start path a pure function of
the timestamp and the existing collision suffix behaviour intact.

Tests: 2 new (the same-minute guard and the rename-survival guard), 5 existing
assertions updated for the format. RecordingSaveDialogTests' stem fixtures
updated to match. 369/369 pass, 0 warnings.

Note: the first test run reported 367/367 because only ytLive.csproj had been
built -- the stale test DLL never picked up the new tests. The real count is 369.
2026-09-27 15:37:46 -07:00
gramps e7cded6879 docs: pricing ruled — $10/mo + $400 lifetime, and the 15% fee is verified for subscriptions
Reconcile the pending doc edits with the creator's actual ruling, and close the
revenue-share question that was blocking the monthly price.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fix: an IsAlertBox DataTrigger beside the IsChatBox one.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Good Dog: ONE integration test (GetBroadcastLiveChatIdAsync_Polls_Snippet_...
) drives a broadcast that gains its id mid-retry and asserts every request used
part=snippet. Gate: clean build 0 warnings, vstest 318/318.
2026-09-25 08:06:02 -07:00
gramps d4aa588da0 feat(alerts): TASK 43 — native events & alerts (six unique animations) + chat parity
Replace the external StreamElements feed with NATIVE YouTube events per the creator's
2026-09-23 question.

Chat parity half:
- YouTubeChatService decodes ALL SIX liveChat/messages event types into
  ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/
  giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay
  rows are real now — and re-arms its one-shot poll on the server's
  pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms,
  maxResults=2000). ParsePage internal static seam + ChatPage record for
  deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage
  (TASK 41) preserved.

Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no
menus/polls, sub mention = chat row only, NO viewer count):
- New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate
  mirroring chat; idle = transparent).
- AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/
  count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/
  chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card
  drawing the 'made with LlamaCasty!' brand line.
- AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail
  never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview,
  Advance(double) as the deterministic test clock; ChatEventKind.None rows never
  enqueue.
- Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews,
  Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged
  (member->NewMember, superchat->SuperChat).

Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) —
ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing;
six events play pairwise-distinct moving frames then drain to null.

Gate: clean build 0 warnings; full suite 316/317, the one failure
(LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the
known environmental class (real-mouse-drag no-ops with a game/fullscreen window
focused).

References (OBS/overlay ecosystem):
- streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
2026-09-24 08:27:00 -07:00
gramps 3a4e17c735 Account zone is world-independent: avatar stays in Record world
Creator on the live build: 'why do you make the youtube user icon go away?'
The TASK 42 one-surface bar had scoped the account zone to Stream only
('Record world has no YouTube identity at all'), so arming REC dropped the
avatar and the bar height bounced with it ('really fucking annoying').

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Task 37 queued: defaults vs current layout split (creator directive) —
capture out-of-scope work in TASKS.md rather than folding it in.
2026-09-20 09:23:46 -07:00
gramps f91bf8715d fix(reorder): save layer list AND preview layout immediately at drop; recording save dialog +10% (276→304)
Reorder: OnSceneElementsReordered now calls SaveLayoutNow() (was debounced
ScheduleSave) so a drag-drop persists instantly — one write stores the new
layer list (Source.SortOrder) and each layer's preview geometry (X/Y/W/H) in
the same rows. Reference: WPF ListBox drag-reorder requires an explicit persist
at drop; a debounce window lets a crash lose the drop.

Recording save dialog (RenameRecordingDialog.xaml): height 276→304 so the
Cancel/Save button row is no longer obscured; Save button named
SaveRecordingButton so the sizing test can assert it sits inside the client area.

Tests: deterministic seam test now asserts the dragged layer's geometry (preview
layout) is persisted with the new order; sizing test asserts 304 + textbox and
button-row bottoms within client area. 308/308 green.
2026-09-18 10:20:25 -07:00
gramps 2ac00db0da test: prove layer drag-reorder persists via REAL injected mouse input
Creator reported the layer reorder still "doesn't save" after the 08:18
fix (c117885). The existing Good Dog test short-circuited EndListDrag — it
called OnSceneElementsReordered() directly, so the actual
List_PreviewMouseLeftButtonDown/Move/Up handlers were never exercised
(synthetic RaiseEvent can't: e.GetPosition reads the physical cursor).

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

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

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

Reference: WPF WinEvent hook lifecycle guidance — SetWinEventHook callbacks
must be unhooked before the owning object is collected (obs-projector
fullscreen-detection pattern).
2026-09-18 08:33:13 -07:00
gramps c11788574e fix(reorder): trap layer-list drag drop as a data change and persist it
List_PreviewMouseMove reorders StagedScene.Elements via RemoveAt/Insert,
bypassing SceneGraph's mutation surface, but never scheduled a save — the
new z-order was lost on restart. The drop now sets _dragReordered and
EndListDrag funnels it through MainViewModel.OnSceneElementsReordered()
(InvalidateBake + ScheduleSave, same background-save path as every other
mutation). Integration test (RealApp + temp DB) reproduces the exact
code-behind mutation and asserts the debounced save lands the new Source
SortOrder. Derivation reference: standard WPF ItemsControl drag-reorder
pattern (OBS layering semantics: bottom-most layer = index 0).
2026-09-18 08:18:51 -07:00
gramps fd5198df9f docs(handoff): session-end refresh — HEAD 75723ac, attainability refinement, tree clean 2026-09-17 20:16:49 -07:00
gramps 75723acc1b fix(webcam): offer the Web Cam row only when a camera is attainable (live lock), not merely selected
Creator refinement: 'offered iff there's not one already configured & attainable'.
CanAddWebcam now requires IsWebcamAttainable = identity present AND a RUNNING
session (CameraManager.IsRunning) — an identity whose camera was unplugged or
whose lock keeps failing leaves the row greyed with reason 'No webcam is
currently available…', and it un-greys the moment a session is live. Gate
re-raised at every attainability flip: staging, removal, startup lock success,
first frame, camera failure, identity swap.

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

Good Dog: WebcamMenuGateTests second fact — identity loaded, session can't start
→ row NOT offered + 'No webcam is currently available…' tooltip. Positive fact
waits for WebcamStartupValidationTask to make the IsRunning read deterministic.
Docs same commit (ai.md gate + base-lock, TASKS.md, HANDOFF.md incl. proven
pre-existing audio flake). 305 tests (304 pass + known flake), 0 warnings.
2026-09-17 09:16:56 -07:00
gramps 9d00955004 feat(webcam): app-default gate slice — per-scene offer, Add places default directly, identity survives removal
Chat's camera no longer greys Web Cam in Live (per-scene max, not app-wide);
TASK 26 superseded by creator directive (app-level resource model). Add Webcam
now places the existing app default without the picker; the picker runs only
for the initial selection. Removing the last placement keeps the identity
(_webcam never nulled) so Add stays offered. Startup pass adopts a solo camera
as the app default, so a clean layout offers the layer in Live/Chat at once.
Dynamic WebcamAddToolTip names the why (incl. the 'graduated to OBS' line).

Good Dog: WebcamMenuGateTests rewritten (real app + temp DB + camera seams) —
identity in Chat does not gray Live; Add in Live places same wc-1 no picker;
scene-with-placement stays gray; identity survives both removals (DB row 1).
WebcamStartupResourceTests single-lock fact asserts CanAddWebcam after adoption.
Docs same commit (ai.md supersession, TASKS.md, HANDOFF.md). 304/304, 0 warnings.
2026-09-17 08:56:04 -07:00
gramps 1e4017df03 feat(webcam): resource lifecycle startup slice — poll-on-start, single-cam lock, persistent Layers alert
The creator couldn't add a webcam to Live (grayed app-wide) and nothing in the
app explained why. Ground truth from the live DB: one Webcam identity AND one
WebcamSceneConfig in the Chat scene — so the gray was the single-identity rule
working, but the reason was unobservable. This slice makes the webcam a
resource the app validates and locks, mirroring how OBS reserves its devices.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reference (external scan): https://obs-versions.com/blog/how-to-fix-audio-delay-on-obs
(audio ahead => positive delay). Verified: build 0 warnings; 3/3 delay tests pass.
2026-08-31 18:30:03 -07:00
gramps 670fe3a10a TASK 31: SceneGraph component + baked-crust compositor optimization
- Add ElementKind (Static/Dynamic) to SceneElement base; Source/WebcamSceneConfig classify
- Services/SceneGraph.cs: owns the Scenes collection (ViewModel's Scenes delegates to it),
  the element mutation surface (Add/Insert/Remove/Move, each invalidating the bake), and the
  queries that were scattered LINQ (GetBackground/GetWebcam/GetChatBoxes/GetSplitPoint/IsStatic)
- SceneCompositor: split-aware BakeStaticBase + CompositeLayers + Render(.., staticBase, split);
  builds/caches the static base below the split point in source-rect space
- FramePump: optional SceneGraph -> optimized bake+composite path; falls back to full render
- MainViewModel: routes element mutations through the graph; invalidates the bake on static
  layout/opacity/visibility/useDefaultBackground changes and after background heal/Ensure
- Integration test SceneGraphTests.BakedStaticBase_WithDynamicLayer_CompositesCorrectly

Derivative survey (mandated): tried before writing — OBS does per-source opacity/visibility
caching and static-scene baking; this mirrors OBS's 'cached static source' optimization.
3 documented defensive deviations from the spec (ChatOverlayLayer stays decoupled;
background helpers stay VM-static for direct testability; full facade peel deferred post-1.0)
in TASKS.md + ai.md. Scope check passed; clean build 0 warnings.
2026-08-31 17:53:34 -07:00
gramps 3bf053a3b1 TASK 31: SceneGraph design + baked-crust compositor optimization
Design captured in TASKS.md, pattern note in ai.md, handoff updated.
2026-08-31 11:38:12 -07:00
gramps 84a038c498 docs: record the true-decomposition pattern (ChatOverlayLayer) + handoff refresh
ai.md: Key patterns gains the 'true decomposition beats partial-shuffling'
rule + the glue-vs-component diagnostic (count OnPropertyChanged/SetProperty;
extract only zero/low-glue cohesive state blobs like chat's; leave binding glue
over already-extracted services alone; line count is a guideline not a goal).
ViewModels table row notes Chat.cs is now a thin facade over ChatOverlayLayer.

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

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

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

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

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

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

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

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

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

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-30 22:32:45 -07:00
526 changed files with 60274 additions and 7357 deletions
+15
View File
@@ -1,12 +1,27 @@
bin/
obj/
Helpers/OAuthCredentials.cs
# Bundled FFmpeg (135 MB of DLLs). Fetched once by scripts/fetch-ffmpeg.sh, which pins
# the URL + SHA-256; the build then copies it to the output. Never committed — see the
# script header for why the hash, not the binaries, is the audit trail.
*.user
*.suo
.vs/
*.DotSettings.user
# Build artifacts — the MSIX + its staging tree are produced by scripts/package-msix.sh,
# never committed (the package embeds the published app and the bundled ffmpeg).
artifacts/
# Confidential business files (gitignored, not committed)
MONETIZATION.md
MARCOM.md
CREDENTIALS.md
.aider*
ffmpeg/
BuildCounter.txt # unused since 2026-11-01 (the build stamp is the git commit count)
# Steamworks runtime (steam_api64.dll, 312 KB) — extracted from the SDK zip, never committed;
# scripts/steam-sdk.sh documents which file and why. Same rule as ffmpeg/: the pin is the audit trail.
steam/
+106 -50
View File
@@ -1,7 +1,8 @@
# AGENTS — how to work in this repo
This file is auto-read at the start of every session. The project keeps its
memory as a linked map of markdown files — read it before doing anything.
Auto-read at the start of every session: the rules, the invariants that break the product, and
the map to everything else. `ai.md` is the router, `docs/ai/*.md` is the subsystem truth,
`HANDOFF.md` is current state.
## ⚠️ CRITICAL: The Good Dog Rule
@@ -16,9 +17,8 @@ When the user asks for changes that would involve multiple integration tests:
> *A good dog learns one trick perfectly before learning the next.*
**No feature branches pre-1.0 (decided 2026-08-24):** solo dev, pre-1.0 — all
work lands directly on `main`, committed and pushed per work unit. No
`taskNN-*` branches, no PRs. The scope discipline above is about test focus,
not git mechanics.
work lands directly on `main`, committed per work unit. No `taskNN-*` branches, no PRs. The scope
discipline above is about test focus, not git mechanics.
**Commit / push policy (2026-08-30, user directive):** commit every work unit —
committing is expected and never needs extra permission. Pushing is different:
@@ -27,48 +27,53 @@ that means commits land locally every partial; `git push` happens at the
session's agreed checkpoint(s), not per-commit. When a milestone warrants a
push the user will say so (or say "push it").
MUST read the architecture guide before planning or modifying the codebase:
[`ai.md`](ai.md) (+ [`schema.md`](schema.md) for the memory-map conventions).
## Onboarding (in order — and no further)
MUST read the coding guide before writing tests or code: the [Working
rules](#working-rules) in this file — this repo has no `CODING.md`;
conventions live here and in `ai.md`.
1. [`HANDOFF.md`](HANDOFF.md) — current state: branch, what's in flight, next action, landmines.
**Trust it**; no branch hunting, no file-scanning to re-derive what it already says.
2. [`ai.md`](ai.md) — the router: what the product is, the architecture table, and where each
subsystem's truth lives.
3. **The one** [`docs/ai/`](docs/ai/index.md) file that matches your task — not all of them.
The routing table at the top of `ai.md` picks it.
4. `<dir>/index.md` — the index for any directory you're about to touch.
## Onboarding (in order)
`TASKS.md` is the queue. Read it when the user asks what's next or status is part of the
question — it is not required reading to write code.
1. [`schema.md`](schema.md) — the memory-map conventions (what lives where, how to keep it true).
2. [`ai.md`](ai.md) — the AI guide: architecture, patterns, decisions, current state.
3. [`TASKS.md`](TASKS.md) — the task queue + authoritative YouTube API research.
4. [`HANDOFF.md`](HANDOFF.md) — current operational state: what's in flight, landmines, next step.
5. `<dir>/index.md` — the index for any directory you're about to touch.
**Why it is this short:** the mandatory read used to be ~56k tokens of map, so sessions paid it
and then skimmed — which is exactly how stale facts survive. Read the one subsystem you touch.
**If `HANDOFF.md` exists, trust it as current state** — no `fsck`, no branch
hunting, no file-scanning to re-derive what it already states, unless it points
at a problem.
## Reading budget
Enforced by `scripts/verify.sh`; a commit that pads instead of replacing fails the gate.
| File | Cap | Role |
|---|---|---|
| `AGENTS.md` | 210 lines | rules + invariants, always read |
| `ai.md` | 100 lines | router only — no narrative, no status |
| `HANDOFF.md` | 40 lines | current state, overwritten every time |
| `TASKS.md` | 120 lines | the active queue; done work moves to `TASKS/archive.md` |
If a fact does not fit, it belongs in `docs/ai/<subsystem>.md`, `MyMistakes.md`, or `git log`.
## Working rules
- **Never work without the map.** If the map contradicts the code, the code wins
and the map gets fixed in the same change (stale facts are corrected, not appended).
- **Every feature change ships with its memory update in the SAME commit:**
`ai.md` for architecture/patterns, `TASKS.md` for status, index files when
layout changes. No code commit without its docs — a follow-up "docs backfill"
commit is a broken rule, not a style.
- **Rewrite `HANDOFF.md` at session end, compaction, or any interruption.**
Never end a session with uncommitted work unrecorded — the handoff names the
branch, the dirty files, and why it stopped.
- **The first time a fact costs a hunt (secrets path, DB path, port, recovery
source), record it** in `ai.md`/indexes/`HANDOFF.md` so the next session never
re-hunts it.
- **Derived-solution rule (record once, then grep):** the moment you work out a
reusable solution — a recipe, workaround, or how-to (e.g. how to shrink and
re-encode an image for the README) — write it into `MyMistakes.md` **in the same
session**, before you finish the task. And always **grep `MyMistakes.md` first**
when you hit a "I've done this before but have to figure it out again" wall. A
solution recorded once ends the re-derivation loop; an un-recorded solution is a
guarantee you will re-derive it and make the user sit through it again. This is
the fix for the image-shrink incident (2026-08-29): the recipe was never written
down, so it had to be worked out from scratch a second time.
- **Never work without the map.** If the map contradicts the code, the code wins and the facts
your change **falsifies** are corrected in the same commit (stale facts are corrected, not
appended). Cosmetic drift — a stale task number, a heading — may wait for a sweep; it is not a
reason to stall a unit.
- **Memory rides with the change, not as a unit of its own.** Correct what the change
falsifies in the same commit; a broad restatement can be batched at a milestone.
- **`HANDOFF.md` is ≤40 lines and is overwritten, never appended:** branch, in flight, dirty
files, next action, new landmines. History belongs to `git log`.
- **The first time a fact costs a hunt, record it where it will be found:** paths / ports /
secrets / build commands → `docs/ai/environment.md`; subsystem traps → that subsystem's
`docs/ai` file; one-off recipes → `MyMistakes.md` → Recipes registry.
- **Derived-solution rule (record once, then grep):** when you work out a reusable solution — a
recipe, workaround, or how-to (e.g. how to shrink and re-encode an image for the README) —
write it into `MyMistakes.md` **in the same session**, and **grep `MyMistakes.md` first** on
any "I've done this before but have to figure it out again" wall. An un-recorded solution
guarantees the user hears it derived again (the image-shrink incident, 2026-08-29).
- **Follow existing conventions** — MVVM, `RelayCommand` for actions,
`ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml`
(merged once in `App.xaml`; never duplicate per-window).
@@ -83,13 +88,64 @@ at a problem.
- **Spin guard (objectively triggered):** a SECOND failed remedy for the same
symptom = STOP. No third guess over the same code. Research the established
answer externally, cite its URL in the commit message AND in `MyMistakes.md`,
then code it. You do not know everything — repeated failure is a research
trigger, not a persistence trigger.
then code it. Repeated failure is a research trigger, not a persistence trigger.
- **Response style: no default planning template.** Do the work, then report
what changed and what's next. No "Plans & Pitfalls", pros/cons tables, or
step-by-step plans unless the user asks for a plan first (see `ai.md`).
- **No-Fluff Mode is available on request** — unpadded, ruthless review that
argues rather than reassures (see `ai.md`). Optional, never the default.
step-by-step plans unless the user asks for a plan first. A short diff-style summary beats a
proposal document every time.
- **No-Fluff Mode is available on request.** Invoke with "no-fluff mode" (or similar) for
ruthless review instead of reassurance: strip pleasantries, emojis and padding; treat the
user's input as a draft to deconstruct; give unvarnished truth. Occasional and
explicitly-invoked — never the default.
## Invariants that break the product
Violating any of these breaks output, wipes a user's data, or ships a legal/trust breach. Each
line points at the file that explains it.
- **The Background is one locked layer per scene** — index 0, never addable/removable/reorderable;
Live is `DisplayCapture`, every other canonical scene is static art. Never feed a capture row to
the `ActiveBackgroundImage` overlay. → `docs/ai/capture.md`
- **Every global overlay composites on every render path** — pump, preview/thumbnails, scene dump.
A feature wired into one path is silently absent from the output. → `docs/ai/overlays.md`
- **`VideoFrame.Epoch` bumps on every frame from a recycling producer**, or the pump's paste cache
and full-render signature serve stale pixels. → `docs/ai/frame-pipeline.md`
- **Never demote threads inside `Parallel.For`** — `StripPool` owns four `BelowNormal` threads;
strips below 64 rows run inline. → `docs/ai/frame-pipeline.md`
- **Submit never blocks:** the encoder queue is bounded, the newest frame is dropped and counted,
and `StopAsync` flushes the encoder before awaiting the pump. → `docs/ai/frame-pipeline.md`
- **Audio closes before video:** `StopStream` runs `StopLive` (audio EOF) before stopping the pump;
the reverse order stalls on pipe backpressure. → `docs/ai/output.md`
- **rawvideo is paced by arrival, never by a timestamp** — no `-re`, and `nextTick` is never
rebased to wall-now (that turns a hitch into a silent time-lapse). → `docs/ai/output.md`
- **The encoder is probed, never libx264** — NVENC → QSV → AMF → OpenH264; no static or GPL codec
path. → `docs/ai/output.md`
- **REC and ON-AIR are radio-exclusive** — one ffmpeg, one output, in the UI and the VM.
→ `docs/ai/output.md`
- **ffmpeg is bundled and separate** — never downloaded at runtime, never linked into the app; the
pin stays `lgpl-shared`. → `docs/ai/product.md`
- **A modal's falsy result never falls through into its side effect** — Cancel on the recording
save dialog deletes the temp footage. → `docs/ai/output.md`
- **Any test that constructs `MainWindow` must point `LayoutPathOverride` at a temp DB** and clear
it in `finally`, or it overwrites the user's real layout. → `docs/ai/environment.md`
- **The vertical tier is geometry only**, and the Chat camera is rect-locked in the MODEL
(`VerticalShapeLocked`), not just in the UI. → `docs/ai/capture.md`
- **Never override the user's camera choice** — there is deliberately no fallback camera.
→ `docs/ai/capture.md`
- **Chat is rendered, never stored** — no history, no moderation log, no reward payloads; the
Store 11.12 certification answer depends on it. → `docs/ai/product.md`
- **Full trust only** — a packaged build flipped to `appContainer` silently loses recordings.
→ `docs/ai/product.md`
- **A `PopupRoot` has no visual parent** — ancestor walks must fall back to
`LogicalTreeHelper.GetParent`, or a popup row click reads as an outside click.
→ `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`
- **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`
## Scope Lock
@@ -115,12 +171,12 @@ don't make it. A stable file touched by an unrelated task is a bug, not a featur
### Pre-commit audit
Before every commit, run `scripts/scope-check.sh` with the declared file list.
If any file appears in `git diff` but not in the declared scope, either justify
it or revert the change. The script enforces what the rule demands.
One gate: `./scripts/verify.sh "<file1>" "<file2>" ...` with the declared scope. It runs the doc
budgets, a **clean** build (0 warnings), the test suite, and `scope-check.sh`. Docs-only units run
`scope-check.sh` alone.
```bash
./scripts/scope-check.sh "Models/Scene.cs" "ViewModels/MainViewModel.cs" "MainWindow.xaml"
./scripts/verify.sh "Models/Scene.cs" "ViewModels/MainViewModel.cs" "MainWindow.xaml"
```
## Build
@@ -128,7 +184,7 @@ it or revert the change. The script enforces what the rule demands.
From WSL, ALWAYS use the Windows dotnet host — Linux `dotnet` re-downloads the
`windowsdesktop.app.*` packs over the slow 9p bridge and re-restores twice (WPF
`_wpftmp`), and `--no-restore` right after an interrupted restore produces bogus
`NETSDK1064` errors. See `ai.md` → Run for the exact commands and why.
`NETSDK1064` errors. See `docs/ai/environment.md` → Run for the exact commands and why.
```bash
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
+16 -13
View File
@@ -1,13 +1,16 @@
<Application x:Class="ytLive.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ytLive"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
<Application x:Class="ytLive.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ytLive"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- Design tokens first: every style below resolves its colours, sizes and
radii from them (DESIGN.md is the source of truth). -->
<ResourceDictionary Source="Themes/Tokens.xaml"/>
<ResourceDictionary Source="Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+13 -16
View File
@@ -1,6 +1,5 @@
using System.Windows;
using System.Windows.Threading;
using Velopack;
using ytLive.Helpers;
namespace ytLive;
@@ -20,26 +19,24 @@ 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");
// Velopack auto-update bootstrap — checks for updates on startup and
// applies them (the app restarts automatically if an update is found).
// Update URL is configured per-deployment (self-hosted on DO droplet);
// when no URL is set, the check is a no-op.
try
{
VelopackApp.Build()
.Run();
}
catch (Exception ex)
{
AppLog.Write($"Velopack bootstrap failed (non-fatal): {ex.Message}");
}
// 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.
AppLog.Write($"Build {ytLive.Helpers.BuildStamp.Id} (compiled {ytLive.Helpers.BuildStamp.BuiltLocal})");
// There is deliberately NO self-update bootstrap here: the Microsoft Store updates the
// app (TASK 48 — Store MSIX route), and a second updater would fight it.
base.OnStartup(e);
AppLog.Write("App OnStartup end");
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
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.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

+1
View File
@@ -0,0 +1 @@
1122
+10 -10
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.CameraPickerDialog"
<Window x:Class="ytLive.CameraPickerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Add Webcam" Height="340" Width="460"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Window.Resources>
<ResourceDictionary>
@@ -21,10 +21,10 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -43,10 +43,10 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Add Webcam" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,14"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,14"/>
<TextBlock Grid.Row="1" Text="Pick the camera to add to your stream."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
<Grid Grid.Row="2">
<ListBox Background="Transparent" BorderThickness="0"
@@ -58,7 +58,7 @@
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="📷" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="{Binding Name}" Foreground="#e0e0e0" FontSize="13"
<TextBlock Text="{Binding Name}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
@@ -67,15 +67,15 @@
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Searching for cameras…" Foreground="#a0a0b0" FontSize="13"/>
<TextBlock Text="Searching for cameras…" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"/>
</StackPanel>
<StackPanel VerticalAlignment="Center"
Visibility="{Binding NoCameras, Converter={StaticResource BoolToVis}}">
<TextBlock Text="No cameras found" Foreground="#e0e0e0" FontSize="15"
<TextBlock Text="No cameras found" Foreground="{StaticResource Brush.OnSurface}" FontSize="15"
FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Check that a webcam is connected and that Windows camera access is enabled."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
</StackPanel>
</Grid>
+278
View File
@@ -0,0 +1,278 @@
<UserControl x:Class="ytLive.Controls.AudioDock"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:ytLive.Controls">
<!-- The sound bars. Replaces the empty bottom-bar spacer; collapses to the telemetry strip
on short windows (AudioDockLayout decides the height, not this file). -->
<Border x:Name="DockRoot" Background="{StaticResource Brush.SurfaceContainer}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="0,1,0,0">
<Grid>
<Grid.RowDefinitions>
<!-- Master FIRST: during a live session attention is on the preview, so the
master strip sits against it and the bays hang below (creator, 2026-10-30). -->
<RowDefinition Height="44"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Four bays (creator ruling 2026-11-01): MIC | GAME | TRAX | LIP-SYNC. The three
sound bays share equal width so their meters stay the same relative size; lip-sync
is narrow because its function is one slider and three presets. -->
<Grid x:Name="Bays" Grid.Row="1" Margin="8,4,8,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="0.45*"/>
</Grid.ColumnDefinitions>
<!-- MIC -->
<Border Grid.Column="0" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="0,0,4,0" Padding="10,6">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Cursor="Hand" ToolTip="Change microphone" MouseLeftButtonUp="MicLabel_MouseLeftButtonUp">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="MIC 1" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="11" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Text="{Binding MicSourceName, TargetNullValue='No microphone'}"
Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Grid>
<controls:SegmentMeter Grid.Row="1" Height="10" Margin="0,6,0,4" Orientation="Horizontal"
Level="{Binding MicMeterFraction}" Segments="24"/>
<DockPanel Grid.Row="2" Margin="0,6,0,0">
<Button DockPanel.Dock="Left" Width="24" Height="20" Margin="0,0,6,0"
Style="{StaticResource YtButtonSecondary}" Padding="0"
Command="{Binding ToggleMicMuteCommand}" ToolTip="{Binding MicMuteText}">
<Ellipse Width="9" Height="9" Fill="{Binding MicStatusBrush}"/>
</Button>
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="10"
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding MicVolumeDbLabel}"/>
<Grid VerticalAlignment="Center" ToolTip="0 dB is the ceiling — full input level, no boost beyond it">
<Slider Minimum="-60" Maximum="0" SmallChange="0.5" LargeChange="3"
Value="{Binding MicVolumeDb, Mode=TwoWay}" VerticalAlignment="Center"/>
</Grid>
</DockPanel>
</Grid>
</Border>
<!-- GAME / PC, then TRAX beneath it: meter -> desktop volume -> TRAX line -->
<Border Grid.Column="1" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0" Padding="10,6">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="GAME / PC" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="11" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="Desktop loopback · incl. TRAX"
ToolTip="Everything playing on the desktop — including TRAX, which rides the same device."/>
<Border Grid.Column="2" Background="{StaticResource Brush.SurfaceContainerHighest}"
CornerRadius="{StaticResource Radius.Sm}" Padding="5,1"
Visibility="{Binding IsGameDucking, Converter={StaticResource BoolToVis}}">
<TextBlock FontSize="9" FontWeight="Bold" FontFamily="{StaticResource FontFamily.Mono}"
Foreground="{StaticResource Brush.Secondary}" Text="{Binding GameDuckLabel}"/>
</Border>
</Grid>
<controls:SegmentMeter Grid.Row="1" Height="10" Margin="0,6,0,4" Orientation="Horizontal"
Level="{Binding GameMeterFraction}" Segments="24"/>
<!-- Desktop volume, directly beneath its meter. -->
<DockPanel Grid.Row="2" VerticalAlignment="Center">
<Button DockPanel.Dock="Left" Width="24" Height="20" Margin="0,0,6,0"
Style="{StaticResource YtButtonSecondary}" Padding="0"
Command="{Binding ToggleGameMuteCommand}" ToolTip="Mute desktop audio">
<Ellipse Width="9" Height="9" Fill="{Binding GameMeterBrush}"/>
</Button>
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="10"
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding GameVolumeDbLabel}"/>
<Grid VerticalAlignment="Center" ToolTip="0 dB is the ceiling — full input level, no boost beyond it">
<Slider Minimum="-60" Maximum="0" SmallChange="0.5" LargeChange="3"
Value="{Binding GameVolumeDb, Mode=TwoWay}" VerticalAlignment="Center"/>
</Grid>
</DockPanel>
</Grid>
</Border>
<!-- TRAX: its own bay and its own meter now (creator ruling 2026-11-01). It used to
be a strip inside the game bay because the desktop ladder "already carried it";
that hid whether the music was actually playing. -->
<Border Grid.Column="2" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0" Padding="10,6">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="TRAX" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="11" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="{Binding TraxTrackLabel}" Cursor="Hand"
MouseLeftButtonUp="TraxLabel_MouseLeftButtonUp"
MouseRightButtonUp="TraxLabel_MouseRightButtonUp"/>
</Grid>
<controls:SegmentMeter Grid.Row="1" Height="10" Margin="0,6,0,4" Orientation="Horizontal"
Level="{Binding TraxMeterFraction}" Segments="24"/>
<DockPanel Grid.Row="2" VerticalAlignment="Center">
<Button DockPanel.Dock="Left" Width="24" Height="20" Margin="0,0,6,0" Padding="0"
Style="{StaticResource YtButtonSecondary}" Click="TraxPlayPause_Click"
ToolTip="{Binding TraxToolTip}">
<TextBlock FontSize="9" Foreground="{StaticResource Brush.OnSurface}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="▶"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsMusicPlaying}" Value="True">
<Setter Property="Text" Value="❚❚"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="−" Width="18" Height="16" Padding="0" FontSize="11"
Style="{StaticResource YtButtonSecondary}" Click="TraxStepDown_Click"
IsEnabled="{Binding CanLowerTrax}" ToolTip="5 dB quieter"/>
<TextBlock Width="52" TextAlignment="Center" FontSize="10" VerticalAlignment="Center"
FontFamily="{StaticResource FontFamily.Mono}"
Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="{Binding TraxVolumeDbLabel}"/>
<Button Content="+" Width="18" Height="16" Padding="0" FontSize="11"
Style="{StaticResource YtButtonSecondary}" Click="TraxStepUp_Click"
IsEnabled="{Binding CanRaiseTrax}" ToolTip="5 dB louder"/>
</StackPanel>
</DockPanel>
</Grid>
</Border>
<!-- LIP-SYNC (narrow: one slider, three presets) -->
<Border Grid.Column="3" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0,0,0" Padding="10,6">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Row alignment across the dock (creator ruling 2026-11-01): every bay is
header / meter / controls, so the ladders sit on one horizontal line and the
volume bars on another. Lip-sync has no ladder, so it holds an empty row the
height of one (10px meter + 6/4 margins = 20) to keep its slider level with
the other three. -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="LIP-SYNC" 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 SyncOffsetLabel}"/>
</Grid>
<Border Grid.Row="1" Height="20"/>
<StackPanel Grid.Row="2">
<Slider Minimum="-200" Maximum="200" SmallChange="5" LargeChange="25"
TickFrequency="5" IsSnapToTickEnabled="True"
Value="{Binding AudioSyncOffsetMs, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0" HorizontalAlignment="Right">
<Button Content="0" Width="26" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="0"/>
<Button Content="+50" Width="30" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="50"/>
<Button Content="+100" Width="34" Height="16" FontSize="9" Padding="0"
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="100"/>
</StackPanel>
</StackPanel>
</Grid>
</Border>
</Grid>
<!-- Master strip: always visible, even collapsed. Rebuilt 2026-11-01 — the meter is
CENTRED under the preview and is the biggest one on screen; MASTER's own label is
larger than the bay labels. The kbps / fps / dropped counters that used to end this
line are gone: the live readouts already sit above the preview. -->
<Border x:Name="MasterStrip" Grid.Row="0" Background="{StaticResource Brush.SurfaceContainerLowest}" Padding="12,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Left: the imbalance flag, the one case that silently ruins a mix. -->
<Border Grid.Column="0" Margin="0,0,8,0" Padding="5,1" VerticalAlignment="Center"
CornerRadius="{StaticResource Radius.Sm}" Background="{StaticResource Brush.SurfaceContainerHigh}"
Visibility="{Binding MasterImbalance, Converter={StaticResource BoolToVis}}"
ToolTip="Left and right differ by more than 6 dB — one side may be inaudible.">
<TextBlock Text="L/R" FontSize="9" FontWeight="Bold" FontFamily="{StaticResource FontFamily.Mono}"
Foreground="{StaticResource Brush.Attention}"/>
</Border>
<!-- Centre: MASTER + the long meter + its reading. The ladder stretches with the
window between 420 and 760 px — always longer than the 320-capped bay meters. -->
<Grid Grid.Column="1" VerticalAlignment="Center" HorizontalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="MASTER" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurface}"/>
<controls:SegmentMeter Grid.Column="1" Height="14" Margin="12,0,0,0" MinWidth="420" MaxWidth="900"
Orientation="Horizontal" Segments="32"
Level="{Binding MasterMeterFraction}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Width="72" Margin="10,0,0,0" FontSize="12"
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurface}"
Text="{Binding MasterLevelLabel}"/>
</Grid>
<!-- Right: MONO (accessibility, remembered) + the session clock. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<ToggleButton Height="20" Padding="8,0"
Style="{StaticResource SegmentToggle}" IsChecked="{Binding MonoOutput, Mode=TwoWay}"
ToolTip="Fold the output to mono — both ears hear everything">
<TextBlock Text="MONO" FontSize="10" FontWeight="Bold"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
</ToggleButton>
<TextBlock FontSize="11" FontFamily="{StaticResource FontFamily.Mono}" Margin="12,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="{Binding CurrentHealth.StreamDuration, StringFormat={}{0:hh\:mm\:ss}}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>
+93
View File
@@ -0,0 +1,93 @@
using System.Windows;
using System.Windows.Controls;
using ytLive.Models;
namespace ytLive.Controls;
/// <summary>The sound bars (creator ruling 2026-10-30). This unit owns the SHELL: the piece of the
/// window it occupies and the collapse behaviour. The bays and the master readouts fill in over
/// the following units, so nothing here pretends to be a working meter.
///
/// <para>Collapse is driven by the window height, not a user setting: at 1366×768 a full dock
/// squeezes the preview, and the creator should not have to manage that.</para></summary>
public partial class AudioDock : UserControl
{
private Window? _window;
public AudioDock()
{
InitializeComponent();
Loaded += OnLoaded;
Unloaded += OnUnloaded;
}
/// <summary>Is the dock showing only the telemetry strip?</summary>
internal bool IsCollapsed { get; private set; }
/// <summary>Test seam: the same code the window's SizeChanged runs, without a window.</summary>
internal void ApplyWindowHeight(double windowHeight)
{
var collapsed = AudioDockLayout.IsCollapsed(windowHeight);
IsCollapsed = collapsed;
Height = AudioDockLayout.HeightFor(windowHeight);
Bays.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
}
private void MicLabel_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm && vm.OpenMicPickerCommand.CanExecute(null))
vm.OpenMicPickerCommand.Execute(null);
}
private void TraxStepDown_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.StepTrax(-1);
}
private void TraxStepUp_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.StepTrax(+1);
}
private void SyncPreset_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.SetSyncOffset(ms);
}
private void TraxPlayPause_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
}
private void TraxLabel_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
}
private void TraxLabel_MouseRightButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.ChooseTrack();
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
_window = Window.GetWindow(this);
if (_window == null)
{
ApplyWindowHeight(AudioDockLayout.CollapseBelowHeight); // no host: show the dock
return;
}
_window.SizeChanged += OnWindowSizeChanged;
ApplyWindowHeight(_window.ActualHeight > 0 ? _window.ActualHeight : _window.Height);
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
if (_window != null) _window.SizeChanged -= OnWindowSizeChanged;
_window = null;
}
private void OnWindowSizeChanged(object sender, SizeChangedEventArgs e) => ApplyWindowHeight(e.NewSize.Height);
}
-64
View File
@@ -1,64 +0,0 @@
<UserControl x:Class="ytLive.Controls.BottomBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Border Background="#0f3460" Padding="16,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Gear icon: far left -->
<Button Grid.Column="0" ToolTip="Menu" Style="{StaticResource IconButton}"
VerticalAlignment="Center" Click="GearButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
<Separator/>
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"
Fill="#d0d0d0" Width="16" Height="16" Stretch="Uniform"/>
</Button>
<!-- Stream stats: centered under the preview pane -->
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Style="{StaticResource YtLabel}"
Foreground="White" Margin="0,0,20,0">
<Run Text="{Binding CurrentHealth.CurrentBitrate, Mode=OneWay}"/>
<Run Text=" Mbps"/>
</TextBlock>
<TextBlock Text="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Style="{StaticResource YtLabel}"
Foreground="White" Margin="0,0,24,0">
<Run Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"/>
</TextBlock>
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
Foreground="White" Margin="0,0,20,0"/>
<TextBlock Text="Duration:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Text="{Binding CurrentHealth.StreamDuration}" Style="{StaticResource YtLabel}"
Foreground="White"/>
<TextBlock Text="{Binding CurrentHealth.HealthMessage}" Style="{StaticResource YtLabel}"
Foreground="#e94560" Margin="20,0,0,0"/>
</StackPanel>
<!-- Resolution dropdown: far right -->
<ComboBox Grid.Column="2" Style="{StaticResource YtComboBox}" MinWidth="150"
ItemsSource="{Binding QualityOptions}"
SelectedItem="{Binding SelectedQuality}"
DisplayMemberPath="Label"
ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding IsOffline}"/>
</Grid>
</Border>
</UserControl>
-22
View File
@@ -1,22 +0,0 @@
using System.Windows;
using System.Windows.Controls;
namespace ytLive.Controls;
public partial class BottomBar : UserControl
{
public BottomBar()
{
InitializeComponent();
}
private void GearButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<UserControl x:Class="ytLive.Controls.DeveloperPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ⛔ THIS FILE IS EXCLUDED FROM THE RELEASE COMPILE (see ytLive.csproj: Page Remove +
Compile Remove under '$(Configuration) != Debug'). It is not merely hidden in a
Release build — its BAML never exists, so there is nothing to reveal. Do not add it
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" Style="{StaticResource PropertyHeader}"
Foreground="{StaticResource Brush.Attention}"/>
<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."
Style="{StaticResource PropertyHint}" Foreground="{StaticResource Brush.Attention}"
Margin="10,4,0,8"/>
</StackPanel>
</UserControl>
+21
View File
@@ -0,0 +1,21 @@
using System.Windows.Controls;
namespace ytLive.Controls;
/// <summary>
/// The developer affordances that must never exist in a shipped build: today, the
/// all-resolutions override for the output profile.
///
/// <para><b>This file is excluded from the Release compile</b> (<c>ytLive.csproj</c> removes
/// the Page and this Compile item when <c>Configuration != Debug</c>), so a Release binary
/// contains no BAML for it and cannot reveal it. That is deliberate over the cheaper
/// "collapse the panel" approach: a collapsed panel still exists in the visual tree of every
/// shipped copy, and "not visible" is a much weaker claim than "not compiled".</para>
/// </summary>
public partial class DeveloperPanel : UserControl
{
public DeveloperPanel()
{
InitializeComponent();
}
}
+976 -167
View File
File diff suppressed because it is too large Load Diff
+24 -45
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;
@@ -20,6 +21,7 @@ public partial class LeftPanel : UserControl
private int _dragIndex = -1;
private bool _isDragging;
private object? _lastHoveredItem;
private bool _dragReordered;
public LeftPanel()
{
@@ -53,9 +55,12 @@ public partial class LeftPanel : UserControl
{
while (child != null && !ReferenceEquals(child, ancestor))
{
child = child is Visual or System.Windows.Media.Media3D.Visual3D
var next = child is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child)
: LogicalTreeHelper.GetParent(child);
: null;
child = next
?? LogicalTreeHelper.GetParent(child)
?? (child as FrameworkElement)?.TemplatedParent;
}
return child != null;
}
@@ -93,12 +98,6 @@ public partial class LeftPanel : UserControl
webcam.IsMirrored = !webcam.IsMirrored;
}
private void ShapeButton_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
webcam.ToggleClipShape();
}
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
@@ -115,6 +114,11 @@ public partial class LeftPanel : UserControl
(DataContext as MainViewModel)?.RefreshBackgroundAutoCapture();
}
private void BackgroundMenu_RefreshWindowList(object sender, RoutedEventArgs e)
{
(DataContext as MainViewModel)?.RefreshWindowsCommand.Execute(null);
}
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
@@ -159,6 +163,7 @@ public partial class LeftPanel : UserControl
_dragStartPoint = e.GetPosition(listBox);
_isDragging = false;
_lastHoveredItem = null;
_dragReordered = false;
}
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
@@ -174,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();
@@ -189,13 +195,12 @@ 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;
}
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
@@ -207,9 +212,13 @@ public partial class LeftPanel : UserControl
{
if (_isDragging)
listBox.ReleaseMouseCapture();
var reordered = _dragReordered;
_dragIndex = -1;
_isDragging = false;
_lastHoveredItem = null;
_dragReordered = false;
if (reordered && DataContext is MainViewModel vm)
vm.OnSceneElementsReordered();
}
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
@@ -235,36 +244,6 @@ public partial class LeftPanel : UserControl
}
}
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is not SceneElement src) return;
if (sender is FrameworkElement fe && fe.ContextMenu == null)
{
var menu = new System.Windows.Controls.ContextMenu();
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
foreach (var c in colors)
{
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
var brush = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
item.Icon = new System.Windows.Controls.Border
{
Width = 16, Height = 16,
Background = brush,
CornerRadius = new System.Windows.CornerRadius(2)
};
var color = c;
item.Click += (_, _) => src.BorderColor = color;
menu.Items.Add(item);
}
fe.ContextMenu = menu;
}
if (sender is FrameworkElement fe2)
fe2.ContextMenu.IsOpen = true;
}
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
+97 -84
View File
@@ -1,10 +1,12 @@
<UserControl x:Class="ytLive.Controls.OverlayHost"
<UserControl x:Class="ytLive.Controls.OverlayHost"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers">
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:Models="clr-namespace:ytLive.Models"
xmlns:services="clr-namespace:ytLive.Services">
<Grid Grid.RowSpan="3"
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
<Button Background="{StaticResource Brush.ScrimDarkStrong}" BorderThickness="0" Cursor="Hand"
Command="{Binding CloseOverlayCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
@@ -13,45 +15,28 @@
</Button.Template>
</Button>
<Border Background="#0A0C1A" 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>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0">
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" VerticalAlignment="Center"/>
Foreground="{StaticResource Brush.OnSurface}" VerticalAlignment="Center"/>
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
Command="{Binding CloseOverlayCommand}">
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
Fill="#d0d0d0" Width="14" Height="14" Stretch="Uniform"/>
Fill="{StaticResource Brush.OnSurface}" Width="14" Height="14" Stretch="Uniform"/>
</Button>
</Grid>
<!-- App Settings: broadcast fields live in the preview's
Text pull-out now — this panel keeps app-level prefs only.
(Currently empty; the overlay shell stays for future use.) -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Default Location for Recordings" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}"
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
<StackPanel Orientation="Horizontal">
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
</StackPanel>
<!-- Report Bug -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
<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"
@@ -88,56 +73,103 @@
<!-- About -->
<Grid Grid.Row="1" Margin="0,16,0,0"
Background="#171928"
Background="{StaticResource Brush.SurfaceContainerLow}"
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
<!-- Hub view -->
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,16,0,0"/>
<TextBlock Text="Free forever — live streams carry a brief &quot;made with LlamaCasty!&quot; flash. A subscription removes it and adds alerts."
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,12,0,0"/>
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,16,0,8"/>
<!-- 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}}">
<TextBlock Text="Third-party notices &amp; license texts" FontSize="15" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,8"/>
<Border Background="#10101e" CornerRadius="4" Padding="10" MaxHeight="360"
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,8"/>
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="10" MaxHeight="360"
BorderBrush="#333" BorderThickness="1">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBlock Text="{Binding LicensesText}" Foreground="#b8b8c8" FontSize="11"
<TextBlock Text="{Binding LicensesText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11"
FontFamily="Consolas" TextWrapping="Wrap"/>
</ScrollViewer>
</Border>
@@ -146,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="#e0e0e0" Margin="0,0,0,8"/>
<TextBlock Text="Enter your license key to remove the branding watermark."
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,12"/>
<TextBlock Text="License Key" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding LicenseKey, UpdateSourceTrigger=PropertyChanged}"
MaxLength="100"/>
<Button Content="Activate" Command="{Binding ActivateLicenseCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right"
Margin="0,0,0,8"/>
<TextBlock Text="{Binding LicenseMessage}" Foreground="#a0a0b0" FontSize="12"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Left" Margin="0,8,0,0"
Click="BackToAbout_Click"/>
</StackPanel>
</Grid>
</Grid>
</Border>
+748 -276
View File
File diff suppressed because it is too large Load Diff
+412 -37
View File
@@ -17,6 +17,7 @@ public partial class PreviewPane : UserControl
{
private bool _isDraggingOverlay;
private bool _isResizing;
private bool _isZoomDragging;
private Point _grabOffset;
private double _resizeAspect;
@@ -29,8 +30,94 @@ public partial class PreviewPane : UserControl
public PreviewPane()
{
InitializeComponent();
DataContextChanged += PreviewPane_DataContextChanged;
OverlayElements.ItemContainerGenerator.StatusChanged += (_, _) => UpdateSelectedLayerZOrder();
}
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is MainViewModel oldVm)
{
oldVm.PropertyChanged -= ViewModel_PropertyChanged;
oldVm.PreviewTransition -= PlayPreviewTransition;
}
if (e.NewValue is MainViewModel newVm)
{
newVm.PropertyChanged += ViewModel_PropertyChanged;
newVm.PreviewTransition += PlayPreviewTransition;
UpdateZoomBox();
UpdateSelectedLayerZOrder();
HookBlurPreview();
}
}
/// <summary>Animates the outgoing picture away over the new scene: a fade for Fade and Move,
/// a right-to-left shrink for Wipe. Cut plays nothing.</summary>
private void PlayPreviewTransition(ytLive.Services.TransitionType type, double milliseconds)
{
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, null);
TransitionOverlay.Clip = null;
if (type == ytLive.Services.TransitionType.Cut || milliseconds <= 0)
{
TransitionOverlay.Visibility = Visibility.Collapsed;
return;
}
var snapshot = new System.Windows.Media.Imaging.RenderTargetBitmap(1920, 1080, 96, 96,
System.Windows.Media.PixelFormats.Pbgra32);
snapshot.Render(PreviewCanvas);
TransitionOverlay.Source = snapshot;
TransitionOverlay.Opacity = 1;
TransitionOverlay.Visibility = Visibility.Visible;
var duration = TimeSpan.FromMilliseconds(milliseconds);
if (type == ytLive.Services.TransitionType.Wipe)
{
var clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 1920, 1080));
TransitionOverlay.Clip = clip;
var reveal = new System.Windows.Media.Animation.RectAnimation(new Rect(0, 0, 0, 1080), duration)
{
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
};
reveal.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
clip.BeginAnimation(System.Windows.Media.RectangleGeometry.RectProperty, reveal);
}
else
{
var fade = new System.Windows.Media.Animation.DoubleAnimation(1, 0, duration)
{
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
};
fade.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, fade);
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.SelectedElement))
UpdateSelectedLayerZOrder();
if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape))
{
UpdateZoomBox();
}
}
private void UpdateSelectedLayerZOrder()
{
var selected = (DataContext as MainViewModel)?.SelectedElement;
if (selected != null && !IsDraggableElement(selected))
selected = null;
for (var i = 0; i < OverlayElements.Items.Count; i++)
{
if (OverlayElements.ItemContainerGenerator.ContainerFromIndex(i) is UIElement container)
Panel.SetZIndex(container, ReferenceEquals(OverlayElements.Items[i], selected) ? 1 : 0);
}
}
// Static images and the webcam share the move/resize/selection behavior.
public static bool IsDraggableElement(SceneElement element)
=> element.IsDraggable;
@@ -122,13 +209,6 @@ public partial class PreviewPane : UserControl
return null;
}
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
{
vm.ToggleMicMuteCommand.Execute(null);
}
}
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
@@ -141,18 +221,7 @@ public partial class PreviewPane : UserControl
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
// loaded); right-click always opens the picker. The picker is the OS file
// dialog — the "never the OS viewer" rule covers the preview, not pickers.
private void TraxButton_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxLeftClick();
}
private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxRightClick();
e.Handled = true; // the button's right-click context menu must not fire
}
// ─── Webcam / background context menus (right-click on overlay elements) ───
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
@@ -279,6 +348,34 @@ public partial class PreviewPane : UserControl
sb.Children.Add(anim);
}
/// <summary>The border-colour submenu is BUILT from the palette on open: the list of
/// offered colours lives once, in <see cref="BorderColorPalette"/> (TASK 63's rule —
/// a picker never carries its own copy). The generated rows inherit this menu's
/// DataContext (the <see cref="SceneElement"/>), so the click handler is unchanged.</summary>
private void WebcamBorderColorMenu_SubmenuOpened(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem { DataContext: SceneElement element } menu) return;
menu.Items.Clear();
var none = new MenuItem { Header = "None", Tag = string.Empty, IsCheckable = true,
IsChecked = string.IsNullOrEmpty(element.BorderColor) };
none.Click += WebcamMenu_SetBorderColor;
menu.Items.Add(none);
foreach (var colour in BorderColorPalette.All)
{
var row = new MenuItem
{
Header = colour.ToString(),
Tag = colour.Hex,
IsCheckable = true,
IsChecked = string.Equals(element.BorderColor, colour.Hex, StringComparison.OrdinalIgnoreCase),
};
row.Click += WebcamMenu_SetBorderColor;
menu.Items.Add(row);
}
}
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
@@ -306,6 +403,18 @@ public partial class PreviewPane : UserControl
e.Handled = true;
}
/// <summary>Is this canvas point inside the social bar? It is a full-width strip at the top or
/// bottom of the frame and it consumes its own click, so the preview's tunnelling handler has
/// to step aside (see <c>Preview_MouseLeftButtonDown</c>).</summary>
private bool IsOverSocialBar(Point canvasPoint)
{
if (DataContext is not MainViewModel { SocialBarVisible: true }) return false;
if (SocialBarElement.ActualHeight <= 0) return false;
var topLeft = SocialBarElement.TranslatePoint(new Point(0, 0), CanvasGrid);
return new Rect(topLeft, SocialBarElement.RenderSize).Contains(canvasPoint);
}
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
{
if (SocialBarElement == null) return;
@@ -355,14 +464,35 @@ public partial class PreviewPane : UserControl
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var vm = DataContext as MainViewModel;
if (vm is null || !vm.IsEditMode) return;
if (vm is null) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
// The social bar owns its own left-click (toggle top ⇄ bottom). This handler is the
// TUNNELLING preview event, so it fires before the bar's bubbling handler: without this
// guard the bar would be unclickable in preview mode (zoom would win) and, in edit mode,
// it would drag whatever layer sat underneath it.
if (IsOverSocialBar(p)) return;
// Preview mode (EDIT off): the left button drives the zoom box — the common gesture in
// the mode where nothing is editable. Dragging it afterwards is handled by MouseMove.
if (!vm.IsEditMode)
{
vm.ToggleZoom(p.X, p.Y);
e.Handled = true;
return;
}
// Zoom freezes the layers (creator ruling 2026-11-01): while the magnifier is up, a left
// drag moves the box and NEVER picks up a layer underneath it. MouseMove owns the drag.
if (vm.IsZoomActive) return;
var selected = vm.SelectedElement;
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
{
vm.PushPreviewUndo();
_isResizing = true;
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
grid.CaptureMouse();
@@ -370,10 +500,13 @@ public partial class PreviewPane : UserControl
return;
}
var hit = HitElement(vm, p);
var hit = HitElementAt(vm, p);
if (hit != null)
{
vm.SelectedElement = hit;
// One snapshot per gesture: pushing inside the move loop would fill the stack with
// a hundred one-pixel steps.
vm.PushPreviewUndo();
_isDraggingOverlay = true;
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
grid.CaptureMouse();
@@ -387,12 +520,29 @@ public partial class PreviewPane : UserControl
private void Preview_MouseMove(object sender, MouseEventArgs e)
{
var vm = DataContext as MainViewModel;
if (vm is null || !vm.IsEditMode) return;
if (!_isDraggingOverlay && !_isResizing) return;
if (vm is null) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
if (e.LeftButton == MouseButtonState.Pressed && vm.IsZoomActive)
{
if (!_isZoomDragging)
{
vm.StartZoomMove(p.X, p.Y);
_isZoomDragging = true;
grid.CaptureMouse();
}
else
{
vm.UpdateZoomMove(p.X, p.Y);
}
return;
}
if (!vm.IsEditMode) return;
if (!_isDraggingOverlay && !_isResizing) return;
var selected = vm.SelectedElement;
if (selected == null)
{
@@ -402,12 +552,22 @@ public partial class PreviewPane : UserControl
if (_isDraggingOverlay)
{
var minX = -(selected.Width - 20);
var maxX = 1920 - 20;
var minY = -(selected.Height - 20);
var maxY = 1080 - 20;
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
// TASK 57: under the vertical tier the camera snaps into the
// cropped band (one of six spots) instead of free-floating over
// the master — outside the band it is not in the stream at all.
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
{
vm.MoveWebcamInVerticalTier(verticalCam, p.X - _grabOffset.X, p.Y - _grabOffset.Y);
}
else
{
var minX = -(selected.Width - 20);
var maxX = 1920 - 20;
var minY = -(selected.Height - 20);
var maxY = 1080 - 20;
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
}
}
else if (_isResizing)
{
@@ -425,18 +585,80 @@ public partial class PreviewPane : UserControl
}
selected.Width = newW;
selected.Height = newH;
// TASK 57: inside the vertical band the size snaps (the Chat
// camera has two sizes: the small tile or one whole area) and the
// camera ends up on its spot.
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
vm.ResizeWebcamInVerticalTier(verticalCam, newW, newH);
}
}
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> EndPreviewDrag((Grid)sender);
{
if (_isZoomDragging && DataContext is MainViewModel vm)
{
vm.EndZoomMove();
_isZoomDragging = false;
((Grid)sender).ReleaseMouseCapture();
e.Handled = true;
return;
}
EndPreviewDrag((Grid)sender);
}
private void Preview_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
// Right-click = the context menu, in both modes (creator ruling 2026-11-01).
// Ctrl+right-click = the zoom box, EDIT MODE ONLY: the creator asked for no two-handed
// chords while live or recording, so the preview swallows Ctrl+right-click instead of
// letting it fall through to the menu. Returning without Handled is what lets WPF's
// ContextMenuService open the menu under the cursor.
var vm = DataContext as MainViewModel;
if (vm is null) return;
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) return;
if (!vm.IsEditMode)
{
e.Handled = true;
return;
}
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
vm.ToggleZoom(p.X, p.Y);
e.Handled = true;
}
private void Preview_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (_isZoomDragging)
{
var grid = (Grid)sender;
grid.ReleaseMouseCapture();
_isZoomDragging = false;
if (DataContext is MainViewModel vm) vm.EndZoomMove();
e.Handled = true;
return;
}
// WPF's PopupControlService opens a context menu on right-button-UP, not down — so
// handling the Down event alone still let a menu appear under a Ctrl+right-click (found
// by the creator in edit mode, 2026-11-01). Swallow the Up event too, in both modes:
// Down decides the action, Up must not undo that decision.
if ((Keyboard.Modifiers & ModifierKeys.Control) != 0)
e.Handled = true;
}
private void EndPreviewDrag(Grid grid)
{
if (_isDraggingOverlay || _isResizing)
if (_isDraggingOverlay || _isResizing || _isZoomDragging)
grid.ReleaseMouseCapture();
_isDraggingOverlay = false;
_isResizing = false;
_isZoomDragging = false;
if (DataContext is MainViewModel vm) vm.EndZoomMove();
}
private bool HitHandle(Point mouseScreen, SceneElement element)
@@ -445,20 +667,173 @@ public partial class PreviewPane : UserControl
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
}
private SceneElement? HitElement(MainViewModel vm, Point p)
internal SceneElement? HitElementAt(MainViewModel vm, Point p)
{
var scene = vm.StagedScene;
if (scene == null) return null;
// A layer selected from the list temporarily wins both preview stacking and hit-testing;
// the scene's persistent element order remains unchanged.
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];
if (!IsDraggableElement(element)) continue;
if (element is Source { IsEnabled: false }) continue;
if (element is WebcamSceneConfig { IsVisible: false }) continue;
if (p.X >= element.X && p.X <= element.X + element.Width &&
p.Y >= element.Y && p.Y <= element.Y + element.Height)
if (IsHitTestable(element) && Contains(element, p))
return element;
}
return null;
}
private static bool IsHitTestable(SceneElement element)
=> IsDraggableElement(element)
&& element is not Source { IsEnabled: false }
&& element is not WebcamSceneConfig { IsVisible: false };
private static bool Contains(SceneElement element, Point point)
=> point.X >= element.X && point.X <= element.X + element.Width
&& point.Y >= element.Y && point.Y <= element.Y + element.Height;
private void UpdateZoomBox()
{
if (DataContext is not MainViewModel vm) return;
if (vm.IsZoomActive && ZoomVisualBrush != null)
{
// The preview mirrors the output zoom layer: sample the WHOLE composed
// preview (CanvasGrid = backdrop + every element layer — the same all-layer
// source the output compositor magnifies) at the FIXED capture window (the
// box/2 region centred on the click) → 2x via Stretch=Fill. Moving the box
// only moves the ZoomBox element; the Viewbox stays glued to the capture.
ZoomVisualBrush.Visual = CanvasGrid;
ZoomVisualBrush.Viewbox = new Rect(
vm.ZoomState.CaptureX,
vm.ZoomState.CaptureY,
vm.ZoomState.Width / 2,
vm.ZoomState.Height / 2);
ZoomVisualBrush.ViewboxUnits = BrushMappingMode.Absolute;
if (vm.ZoomShape == Models.ZoomShape.Circle)
{
ZoomCircleOutline.Visibility = Visibility.Visible;
ZoomRectOutline.Visibility = Visibility.Collapsed;
ZoomBox.Clip = new EllipseGeometry(new Rect(0, 0, vm.ZoomState.Width, vm.ZoomState.Height));
}
else
{
ZoomCircleOutline.Visibility = Visibility.Collapsed;
ZoomRectOutline.Visibility = Visibility.Visible;
ZoomBox.Clip = null;
}
}
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, CanvasGrid))
{
ZoomVisualBrush.Visual = null;
}
}
/// <summary>Rail settings tab (creator ruling 2026-11-01): a left click opens the Settings
/// DRAWER immediately, exactly like the other rail tabs — no menu, no modal.</summary>
private void SettingsTab_Click(object sender, MouseButtonEventArgs e)
{
if (DataContext is MainViewModel vm) vm.ToggleSettingsDrawer();
e.Handled = true;
}
// ─── Privacy blur preview (2026-11-01) ───
// The editor has to show the blur it is about to bake into the output, and the blur is
// UNIVERSAL: it covers every layer, webcam and chat included. Each blur becomes a
// rectangle/ellipse filled with a VisualBrush of the whole CanvasGrid sampled at that rect,
// with a BlurEffect at the same strength the compositor uses. The host canvas is a SIBLING
// above CanvasGrid, which is what stops the brush from sampling itself.
private MainViewModel? _blurVm;
private Scene? _blurScene;
private void HookBlurPreview()
{
if (_blurVm != null) _blurVm.PropertyChanged -= BlurViewModelChanged;
_blurVm = DataContext as MainViewModel;
if (_blurVm != null) _blurVm.PropertyChanged += BlurViewModelChanged;
HookBlurScene();
}
private void BlurViewModelChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(MainViewModel.StagedScene)) HookBlurScene();
else if (e.PropertyName is nameof(MainViewModel.ActiveBackgroundImage)
or nameof(MainViewModel.BackgroundImage)) RebuildBlurPreview();
}
private void HookBlurScene()
{
if (_blurScene != null)
{
_blurScene.Elements.CollectionChanged -= BlurElementsChanged;
foreach (var element in _blurScene.Elements) element.PropertyChanged -= BlurElementChanged;
}
_blurScene = _blurVm?.StagedScene;
if (_blurScene != null)
{
_blurScene.Elements.CollectionChanged += BlurElementsChanged;
foreach (var element in _blurScene.Elements) element.PropertyChanged += BlurElementChanged;
}
RebuildBlurPreview();
}
private void BlurElementsChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
foreach (var element in e.OldItems?.OfType<SceneElement>() ?? System.Linq.Enumerable.Empty<SceneElement>())
element.PropertyChanged -= BlurElementChanged;
foreach (var element in e.NewItems?.OfType<SceneElement>() ?? System.Linq.Enumerable.Empty<SceneElement>())
element.PropertyChanged += BlurElementChanged;
RebuildBlurPreview();
}
private void BlurElementChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (sender is Source { IsBlur: true }) RebuildBlurPreview();
}
/// <summary>Rebuild one overlay shape per visible blur. Cheap by design: a handful of shapes,
/// and the VisualBrush samples the live backdrop so the preview keeps moving underneath.</summary>
private void RebuildBlurPreview()
{
if (BlurPreviewHost == null) return;
BlurPreviewHost.Children.Clear();
if (_blurScene == null) return;
foreach (var blur in _blurScene.Elements.OfType<Source>()
.Where(s => s.IsBlur && s.IsVisible && s.Width > 0 && s.Height > 0))
{
var shape = new Rectangle
{
Width = blur.Width,
Height = blur.Height,
Fill = new VisualBrush(CanvasGrid)
{
Viewbox = new Rect(blur.X, blur.Y, blur.Width, blur.Height),
ViewboxUnits = BrushMappingMode.Absolute,
Stretch = Stretch.Fill,
},
// 4..24 px at master scale, the same shape of curve the compositor's radius uses.
Effect = new BlurEffect { Radius = 4 + 20 * Math.Clamp(blur.BlurAmount, 0, 1), KernelType = KernelType.Gaussian },
};
if (blur.ClipShape == ClipShape.Round)
shape.Clip = new EllipseGeometry(new Rect(0, 0, blur.Width, blur.Height));
Canvas.SetLeft(shape, blur.X);
Canvas.SetTop(shape, blur.Y);
BlurPreviewHost.Children.Add(shape);
}
}
}
+54 -21
View File
@@ -1,54 +1,87 @@
<UserControl x:Class="ytLive.Controls.SceneThumbnailStrip"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Border Background="#0d1117" CornerRadius="4" Margin="0,0,0,4" Padding="4">
<ItemsControl ItemsSource="{Binding Scenes}">
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="4">
<ItemsControl ItemsSource="{Binding Scenes}" HorizontalAlignment="Center" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" Columns="5"/>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border CornerRadius="4" Margin="2" Padding="2"
<Border Width="196" Height="130" CornerRadius="4" Margin="2" Padding="2"
BorderThickness="2"
BorderBrush="Transparent"
Cursor="Hand"
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderBrush" Value="#333344"/>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Outline}"/>
<Style.Triggers>
<!-- Active scene = orange (creator ruling 2026-10-30): it marks
the stage you are working on, not a selection — blue is
the interactive accent and read as "selected". Live
stays red. -->
<DataTrigger Binding="{Binding IsStaged}" Value="True">
<Setter Property="BorderBrush" Value="#e94560"/>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Attention}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="BorderBrush" Value="#22c55e"/>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Primary}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" CornerRadius="3"
Background="#0a0a1a" ClipToBounds="True">
<Border CornerRadius="3" Background="{StaticResource Brush.SurfaceContainerLowest}" ClipToBounds="True">
<Image Source="{Binding Snapshot}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="LowQuality"/>
</Border>
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="10"
HorizontalAlignment="Center"
Margin="0,2,0,0"
TextTrimming="CharacterEllipsis">
<Run Text="{Binding Name, Mode=OneWay}"/><Run Text=" "/><Run Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="#6a6a7a" FontWeight="SemiBold"/>
</TextBlock>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="6" Padding="8,3"
CornerRadius="3" Background="{StaticResource Brush.Primary}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsStaged}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{StaticResource Brush.Primary}" VerticalAlignment="Center" Margin="0,0,5,0">
<Ellipse.Triggers>
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever" AutoReverse="True">
<DoubleAnimation Storyboard.TargetProperty="Opacity" From="1" To="0.15" Duration="0:0:0.6"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Ellipse.Triggers>
</Ellipse>
<TextBlock Text="PROGRAM" Foreground="White" FontSize="11" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border HorizontalAlignment="Right" VerticalAlignment="Top" Margin="6" Padding="8,2"
CornerRadius="10" Background="#000000">
<TextBlock Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</Border>
<!-- The scene name wears the same pill as the hotkey chip, so both
labels stay readable over any thumbnail. -->
<Border HorizontalAlignment="Left" VerticalAlignment="Bottom" Margin="6"
Padding="8,2" CornerRadius="10" Background="#000000">
<TextBlock Text="{Binding Name, Mode=OneWay}" Foreground="White" FontSize="15" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</UserControl>
</UserControl>
+10
View File
@@ -11,6 +11,16 @@ public partial class SceneThumbnailStrip : UserControl
public SceneThumbnailStrip()
{
InitializeComponent();
Loaded += (_, _) =>
{
if (DataContext is MainViewModel vm)
foreach (var scene in vm.Scenes)
{
var staged = scene.IsStaged;
scene.IsStaged = !staged;
scene.IsStaged = staged;
}
};
}
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using ytLive.Helpers;
namespace ytLive.Controls;
/// <summary>A segmented level ladder for the audio dock.
///
/// <para>Drawn in ONE <see cref="OnRender"/> pass. Twenty-four <c>Rectangle</c>s per meter would
/// mean hundreds of elements updating on the UI thread; a single drawing call does not. The
/// consumer updates <see cref="Level"/> at ~20 Hz — music-level smoothness is not needed and the
/// paint cost should never compete with the compositor.</para>
///
/// <para>Level is a 0…1 display fraction (see <see cref="MeterScale"/>), never a gain setting: the
/// ladder shows what was measured.</para></summary>
public sealed class SegmentMeter : FrameworkElement
{
public static readonly DependencyProperty LevelProperty = DependencyProperty.Register(
nameof(Level), typeof(double), typeof(SegmentMeter),
new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender));
/// <summary>Peak-hold mark, 0…1. Falls back slowly so a clip stays visible long enough to see.</summary>
public static readonly DependencyProperty PeakProperty = DependencyProperty.Register(
nameof(Peak), typeof(double), typeof(SegmentMeter),
new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty SegmentsProperty = DependencyProperty.Register(
nameof(Segments), typeof(int), typeof(SegmentMeter),
new FrameworkPropertyMetadata(24, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(
nameof(Orientation), typeof(Orientation), typeof(SegmentMeter),
new FrameworkPropertyMetadata(Orientation.Vertical, FrameworkPropertyMetadataOptions.AffectsRender));
public double Level { get => (double)GetValue(LevelProperty); set => SetValue(LevelProperty, value); }
public double Peak { get => (double)GetValue(PeakProperty); set => SetValue(PeakProperty, value); }
public int Segments { get => (int)GetValue(SegmentsProperty); set => SetValue(SegmentsProperty, value); }
public Orientation Orientation { get => (Orientation)GetValue(OrientationProperty); set => SetValue(OrientationProperty, value); }
public SegmentMeter()
{
// Token-driven, not literal: the ladder cannot drift from DESIGN.md.
SetResourceReference(NominalBrushProperty, "Brush.Meter.Nominal");
SetResourceReference(CautionBrushProperty, "Brush.Meter.Caution");
SetResourceReference(PeakBrushProperty, "Brush.Meter.Peak");
SetResourceReference(DimBrushProperty, "Brush.SurfaceContainerHighest");
}
private static readonly DependencyProperty NominalBrushProperty = DependencyProperty.Register(
"NominalBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.Cyan, FrameworkPropertyMetadataOptions.AffectsRender));
private static readonly DependencyProperty CautionBrushProperty = DependencyProperty.Register(
"CautionBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.Orange, FrameworkPropertyMetadataOptions.AffectsRender));
private static readonly DependencyProperty PeakBrushProperty = DependencyProperty.Register(
"PeakBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.Red, FrameworkPropertyMetadataOptions.AffectsRender));
private static readonly DependencyProperty DimBrushProperty = DependencyProperty.Register(
"DimBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.DimGray, FrameworkPropertyMetadataOptions.AffectsRender));
private Brush NominalBrush => (Brush)GetValue(NominalBrushProperty);
private Brush CautionBrush => (Brush)GetValue(CautionBrushProperty);
private Brush PeakBrush => (Brush)GetValue(PeakBrushProperty);
private Brush DimBrush => (Brush)GetValue(DimBrushProperty);
private const double Gap = 2;
protected override void OnRender(DrawingContext dc)
{
var count = Math.Max(1, Segments);
var vertical = Orientation == Orientation.Vertical;
var extent = vertical ? ActualHeight : ActualWidth;
var thickness = vertical ? ActualWidth : ActualHeight;
if (extent <= 0 || thickness <= 0) return;
var segmentSize = (extent - Gap * (count - 1)) / count;
if (segmentSize <= 0) return;
var lit = Math.Clamp(Level, 0, 1) * count;
for (var i = 0; i < count; i++)
{
// Segment 0 sits at the bottom (vertical) or the left (horizontal).
var fraction = (i + 1) / (double)count;
var brush = fraction >= MeterScale.ToFraction(MeterScale.PeakDb) ? PeakBrush
: fraction >= MeterScale.ToFraction(MeterScale.CautionDb) ? CautionBrush
: NominalBrush;
if (i >= lit) brush = DimBrush;
var offset = i * (segmentSize + Gap);
var rect = vertical
? new Rect(0, extent - offset - segmentSize, thickness, segmentSize)
: new Rect(offset, 0, segmentSize, thickness);
dc.DrawRectangle(brush, null, rect);
}
// Peak-hold: a 2px marker at the held level, drawn above the ladder.
var peak = Math.Clamp(Peak, 0, 1);
if (peak <= 0) return;
var line = vertical
? new Rect(0, extent - peak * extent - 1, thickness, 2)
: new Rect(peak * extent - 1, 0, 2, thickness);
dc.DrawRectangle(PeakBrush, null, line);
}
}
+142
View File
@@ -0,0 +1,142 @@
<UserControl x:Class="ytLive.Controls.SettingsDrawer"
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">
<!-- 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
viewer once the section list outgrew the shell — a drawer has the height of the preview
and that problem disappears.
Categories are headings; their controls are indented under them (Zoom is one). -->
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="0,0,0,0">
<!-- ═══ RECORDINGS ═══ -->
<TextBlock Text="Recordings" Style="{StaticResource SectionHeader}" Margin="0,0,0,4"/>
<TextBlock Text="Default location" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
<DockPanel Margin="10,0,0,10">
<Button DockPanel.Dock="Right" Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="8,2" FontSize="11" Margin="6,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="8,2" FontSize="11" Margin="6,0,0,0"/>
<TextBox Style="{StaticResource YtTextBoxCompact}" Text="{Binding RecordFolderDisplay, Mode=OneWay}"
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
</DockPanel>
<!-- ═══ 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"/>
<DockPanel Margin="10,0,0,10">
<Button DockPanel.Dock="Right" Content="Browse…" Command="{Binding BrowseBrandLogoCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="8,2" FontSize="11" Margin="6,0,0,0"/>
<!-- A file NAME says nothing about the picture. The thumbnail shows what is set;
hovering enlarges it, a click collapses it again. -->
<Border x:Name="BrandLogoThumbFrame" DockPanel.Dock="Left" Width="28" Height="28"
Margin="0,0,6,0" CornerRadius="4" ClipToBounds="True"
Background="{StaticResource Brush.SurfaceContainerLowest}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"
Cursor="Hand" ToolTip="Hover to enlarge — click to close"
Visibility="{Binding BrandLogoPreviewImage, Converter={StaticResource NotNullToVis}}">
<Image x:Name="BrandLogoThumbImage" Source="{Binding BrandLogoPreviewImage}"
Stretch="Uniform" MouseEnter="BrandLogoThumb_MouseEnter"
MouseLeave="BrandLogoThumb_MouseLeave"
MouseLeftButtonUp="BrandLogoThumb_Click"/>
</Border>
<TextBox Style="{StaticResource YtTextBoxCompact}" Text="{Binding BrandLogoFileDisplay, Mode=OneWay}"
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
</DockPanel>
<Popup x:Name="BrandLogoPreviewPopup" PlacementTarget="{Binding ElementName=BrandLogoThumbFrame}"
Placement="Left" HorizontalOffset="-6" StaysOpen="True" AllowsTransparency="True"
IsHitTestVisible="True" MouseLeave="BrandLogoPreviewPopup_MouseLeave">
<Border Padding="6" CornerRadius="6"
Background="{StaticResource Brush.SurfaceContainerHighest}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1">
<StackPanel>
<Image Source="{Binding BrandLogoPreviewImage}" MaxWidth="420" MaxHeight="320"
Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="{Binding BrandLogoFileDisplay}" FontSize="11" Margin="0,6,0,0"
Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</StackPanel>
</Border>
</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"/>
<ComboBox Style="{StaticResource YtComboBox}" Margin="10,0,0,8" SelectedValue="{Binding ZoomShape}" SelectedValuePath="Tag">
<ComboBoxItem Content="Circle" Tag="{x:Static models:ZoomShape.Circle}"/>
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static models:ZoomShape.VerticalRectangle}"/>
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static models:ZoomShape.HorizontalRectangle}"/>
</ComboBox>
<DockPanel Margin="10,0,0,10">
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource PillToggle}"
IsChecked="{Binding ZoomFollowsBox, Mode=TwoWay}" VerticalAlignment="Center"
ToolTip="Off: the box magnifies the area you clicked. On: it magnifies whatever it hovers over."/>
<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"/>
<ComboBox Style="{StaticResource YtComboBox}" Margin="10,0,0,8" SelectedValue="{Binding SelectedTransitionType}" SelectedValuePath="Tag">
<ComboBoxItem Content="Cut" Tag="{x:Static services:TransitionType.Cut}"/>
<ComboBoxItem Content="Fade" Tag="{x:Static services:TransitionType.Fade}"/>
<ComboBoxItem Content="Move" Tag="{x:Static services:TransitionType.Move}"/>
<ComboBoxItem Content="Wipe" Tag="{x:Static services:TransitionType.Wipe}"/>
</ComboBox>
<DockPanel Margin="10,0,0,10">
<TextBlock DockPanel.Dock="Right" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Margin="8,0,0,0" Width="56">
<Run Text="{Binding TransitionDurationMs, StringFormat={}{0:0}}"/><Run Text=" ms"/>
</TextBlock>
<Slider Minimum="100" Maximum="2000" TickFrequency="50" IsSnapToTickEnabled="True"
Value="{Binding TransitionDurationMs, Mode=TwoWay}" VerticalAlignment="Center"/>
</DockPanel>
<!-- ═══ LAYOUT DEFAULTS (TASK 37) ═══ -->
<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"/>
</StackPanel>
</ScrollViewer>
</UserControl>
+58
View File
@@ -0,0 +1,58 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace ytLive.Controls;
/// <summary>
/// App Settings as a right-edge pull-out (creator ruling 2026-11-01). Hosted by
/// <see cref="PreviewPane"/>'s drawer stack and opened by the gear rail tab, exactly like Stream
/// Settings / YPP / Socials / TEST.
///
/// <para>Born from the modal overlay: that shell was MaxHeight 700, so once the section list grew
/// the bottom options were clipped and unreachable — including the zoom shape the creator was
/// looking for. A drawer is the height of the preview, so the list can grow without a second
/// scroll decision to get wrong.</para>
/// </summary>
public partial class SettingsDrawer : UserControl
{
public SettingsDrawer()
{
InitializeComponent();
#if DEBUG
// Development affordances are added at runtime, never declared in XAML: the panel is
// excluded from the Release compile (ytLive.csproj), so this line is the only reference.
DeveloperPanelHost.Children.Add(new DeveloperPanel());
#endif
}
/// <summary>Is the enlarged brand-logo preview showing? Test seam — a Popup is a separate
/// window, so nothing else can observe it.</summary>
internal bool IsBrandLogoPreviewOpen => BrandLogoPreviewPopup.IsOpen;
/// <summary>The thumbnail is hidden when no image is set (see the XAML), so there is nothing to
/// open in that case — no empty frame can appear.</summary>
internal void OpenBrandLogoPreview() => BrandLogoPreviewPopup.IsOpen = true;
internal void CloseBrandLogoPreview() => BrandLogoPreviewPopup.IsOpen = false;
/// <summary>Hover opens the enlargement. Nothing to show when no image is set, so the
/// thumbnail simply does nothing in that case.</summary>
private void BrandLogoThumb_MouseEnter(object sender, MouseEventArgs e)
=> BrandLogoPreviewPopup.IsOpen = true;
/// <summary>Leaving the thumbnail closes it — unless the pointer walked onto the popup
/// itself, where it stays put so the creator can look closely.</summary>
private void BrandLogoThumb_MouseLeave(object sender, MouseEventArgs e)
{
if (BrandLogoPreviewPopup.Child?.IsMouseOver == true) return;
BrandLogoPreviewPopup.IsOpen = false;
}
/// <summary>A click collapses the enlargement — the explicit "enough, thanks" gesture.</summary>
private void BrandLogoThumb_Click(object sender, MouseButtonEventArgs e)
=> BrandLogoPreviewPopup.IsOpen = false;
private void BrandLogoPreviewPopup_MouseLeave(object sender, MouseEventArgs e)
=> BrandLogoPreviewPopup.IsOpen = false;
}
+147 -90
View File
@@ -1,118 +1,175 @@
<UserControl x:Class="ytLive.Controls.TopBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:helpers="clr-namespace:ytLive.Helpers"
x:Name="TopBarRoot">
<!-- LOGO / BRAND — clicking opens the in-app About overlay -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<!-- 352 = the layers panel's right edge in the work area below: window margin 8 +
360px column = 368, minus this bar's own 16px padding. EDIT is right-aligned in
this column so the two edges line up exactly (creator ruling 2026-11-01). -->
<ColumnDefinition Width="352"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Logo / Brand — clicking opens the in-app About overlay -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About LlamaCasty"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock FontSize="22" FontWeight="Bold">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/>
</TextBlock>
</Button>
<!-- BRAND + EDIT. The gear moved to the preview's right rail, first tab (2026-11-01):
app-level actions live with the other pull-outs now. -->
<Grid Grid.Column="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- REC / ON-AIR pills (intent) + status signs (reality) -->
<!-- LOGO / BRAND — left-click opens the in-app About overlay; right-click opens the app
menu (Report Bug / Feature Request / About). App Settings is no longer in this
menu: the gear rail tab opens the Settings drawer directly (creator ruling
2026-11-01). -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
HorizontalAlignment="Left"
VerticalAlignment="Center" ToolTip="About LlamaCasty — right-click for the menu"
Command="{Binding OpenAboutCommand}"
PreviewMouseRightButtonDown="BrandWordmark_PreviewMouseRightButtonDown">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<!-- Wordmark: "LlamaCas" + "ty" in YouTube red (creator ruling 2026-10-30 — never
re-colour it). The Run elements MUST stay adjacent: whitespace between inline
Runs renders as a space and splits the name into "LlamaCas ty". -->
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
<Run Text="LlamaCas" Foreground="{StaticResource Brush.OnSurface}"/><Run Text="ty" Foreground="{StaticResource Brush.Youtube}"/><Run
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
BaselineAlignment="Superscript" Foreground="{StaticResource Brush.OnSurfaceVariant}"
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
</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
the FIRST decision — Record or Stream. Each world is the whole bar; the
other world never exists on screen. While running, the bar drops to a
reality line + End so nothing mid-session can be re-armed. -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- REC pill: local recording needs no sign-in -->
<TextBlock Text="REC" Foreground="#e94560" FontWeight="Bold" FontSize="12"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
IsChecked="{Binding RecordPillOn}"/>
<!-- ON-AIR pill: greyed out until signed in -->
<TextBlock Text="ON-AIR" Foreground="#e94560" FontWeight="Bold" FontSize="12"
VerticalAlignment="Center" Margin="16,0,6,0"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
IsChecked="{Binding OnAirPillOn}"
IsEnabled="{Binding CanToggleOnAir}"
ToolTip="{Binding AccountStatusLightToolTip}"/>
<!-- Status signs: REC dot + ON-AIR dot -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
<!-- ON-AIR sign: gray offline, red public, green private -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
Fill="{Binding OnAirBrush}"/>
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding IsLivePrivate, Converter={StaticResource BoolToVis}}">
<TextBlock Text="PRIVATE" FontSize="11" FontWeight="Bold" Foreground="White"/>
</Border>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
<!-- 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}}"/>
<!-- TASK 32·3: the drop ended this broadcast. YouTube cannot resume a completed one, so
the only honest action is a new broadcast — one click, saved details, and the title
says it is a resume so the channel shows what happened. -->
<Button x:Name="BackOnAirButton" Content="{Binding BackOnAirLabel}"
Style="{StaticResource YtButton}" Height="32" Margin="10,0,0,0"
Background="{StaticResource Brush.Primary}"
Command="{Binding BackOnAirCommand}"
ToolTip="Start a new broadcast from your saved details — this begins a new video"
Visibility="{Binding IsOffAirAfterDrop, 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>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding RunningVisible, Converter={StaticResource BoolToVis}}">
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding RunningDotBrush}"/>
<TextBlock Text="{Binding RunningText}" FontSize="16" FontWeight="Bold"
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="8,0,0,0"/>
<!-- 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}"/>
</StackPanel>
</StackPanel>
<!-- Three-state top bar: logged-out / idle / live -->
<!-- Account zone: lives only in the Stream world. Signed out → Sign In;
signed in → a big avatar of the streaming account (build 77 feedback:
"make the user icon much bigger"). Record world has no YouTube
identity at all. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Status light -->
<Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountStatusLightToolTip}">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="#ef4444"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="#22c55e"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="Fill" Value="#e94560"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<!-- Avatar (connected only); shows the channel initial when
the profile image is missing or fails to download -->
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
Margin="0,0,8,0" VerticalAlignment="Center"
<Button Content="Sign In" Style="{StaticResource YtButtonSecondary}"
Margin="0,0,8,0" Command="{Binding SignInCommand}"
ToolTip="Sign in to YouTube to go live."
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
<Button Style="{StaticResource IconButton}" Margin="0,0,0,0"
ToolTip="{Binding AccountDisplayName}"
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="13"
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
<!-- Sign In button (disconnected + offline) -->
<Button Content="Sign In" Style="{StaticResource YtButton}"
Margin="0,0,12,0"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowLogInButton, Converter={StaticResource BoolToVis}}"/>
<!-- Primary Start button (offline): dynamic label from the pills -->
<Button Content="Start" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
Visibility="{Binding ShowAvatarButton, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Choose Record Folder" Command="{Binding ChooseRecordFolderCommand}"/>
<Separator/>
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
<Separator/>
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Border Width="40" Height="40" CornerRadius="20" Background="{StaticResource Brush.SurfaceContainer}"
ClipToBounds="True" BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="{StaticResource Brush.OnSurface}" FontSize="20"
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
</Button>
<!-- End button (live OR recording) — the only button whose
label changes; "Stop Recording" while recording -->
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowEndStreamButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</Grid>
</UserControl>
+15
View File
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Net.Http;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using ytLive.Helpers;
@@ -20,6 +21,20 @@ public partial class TopBar : UserControl
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
}
/// <summary>Right-click on the LlamaCasty wordmark opens the app menu (Report Bug / Feature
/// Request / About) — the menu that used to hang off the gear, which now opens the Settings
/// drawer directly (creator ruling 2026-11-01).</summary>
private void BrandWordmark_PreviewMouseRightButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
e.Handled = true;
}
}
public void RefreshAvatar()
{
_ = LoadAvatarImageAsync();
+17 -7
View File
@@ -7,16 +7,23 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `BottomBar.xaml(.cs)` | Footer (Row 3): gear menu (stop/mic/game-mute/quality dropdown), stream stats (bitrate/fps/dropped/duration/health), resolution dropdown |
| `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 stream controls: REC/ON-AIR pills+signs, elapsed timer, start/stop, account avatar/name (`RefreshAvatar()`) |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept |
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
| `TopBar.xaml(.cs)` | Row 0 session surface: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; right-click opens App Settings directly. `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font 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, 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
@@ -47,7 +54,10 @@ cross-cutting calls through it:
`WebViewHostPanel` (ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
window root — so `OverlayHost` deliberately leaves them as thin window-root
children (Commit 18).
children (Commit 18). Since the 2026-09-14 composition-capture redesign only
`ToastArea` remains — `WebViewHostPanel` is deleted (web sources no longer
host a multiline XAML control; `InitWebView2()` runs from `MainWindow_Loaded`
with no panel argument).
- When cutting visual wrappers, don't drop the outer `Border`'s
background/glow/margin — Commit 15 initially lost the preview's outer border
and had to restore it same-commit.
+192
View File
@@ -0,0 +1,192 @@
# DESIGN.md — LlamaCasty visual design standard
> **Status: the standard** (decided 2026-10-30). Token values here are normative;
> `Themes/Tokens.xaml` is the implementation, and no other file may hardcode a hex once the sweep
> lands. Two Google Stitch exports feed it (2026-10-26 edit screen, 2026-10-29 audio dock).
>
> **The exports' layouts and control drawings are exploratory, not spec.** The dock, the four
> bays, the page-nav sidebar and the skeuomorphic faders are *not* adopted — the product's design
> principle is a surface so obvious that a right-brained person can use it without a manual, and
> hardware cosplay is the opposite of that. Only the colour, type, spacing, radius and elevation
> system is adopted.
## Colour
### Surfaces (dark navy scale, darkest to lightest)
| Token | Hex | Use |
|---|---|---|
| `background` | `#0b1326` | App window background |
| `surface-container-lowest` | `#060e20` | Deepest recess (scene strip background, canvas housing) |
| `surface-container-low` | `#131b2e` | Panel background (sidebars, headers) |
| `surface-container` | `#171f33` | Card / grouped-section background |
| `surface-container-high` | `#222a3d` | Hover state, input fields |
| `surface-container-highest` | `#2d3449` | Selected/active row background |
| `surface-bright` | `#31394d` | Lightest surface, rare use (strong hover) |
| `outline` | `#334155` | Borders, dividers |
| `outline-variant` | `#1e293b` | Subtle dividers, inactive meter segments |
| `scrim-soft` | `#22FFFFFF` | Hover/press wash over a surface (13% white) |
| `scrim-strong` | `#26FFFFFF` | Stronger wash — selected icon, dimmed image (15% white) |
| `scrim-dark` | `#26000000` | Dim strip / shading over the preview (15% black) |
| `scrim-dark-strong` | `#00000080` | Heavy overlay — modal veil, poster frame (50% black) |
### Text
| Token | Hex | Use |
|---|---|---|
| `on-surface` | `#dae2fd` | Primary text |
| `on-surface-variant` | `#94a3b8` | Secondary / label text |
| `on-surface-disabled` | `#5b6478` | Disabled text and icons |
### Accent — one role each, never mixed
| Token | Hex | Role |
|---|---|---|
| `tertiary` (electric blue) | `#38bdf8` | **The** interactive accent: selection, focus, sliders, primary buttons, transform handles |
| `on-tertiary` | `#0b1326` | Dark ink, for large blue **surfaces** only |
| `on-accent` | `#ffffff` | Text/icon on any saturated accent fill — the blue EDIT button, the armed REC/ON-AIR pills. One rule: a coloured fill carries white ink |
| `primary` (red) | `#ff5451` | **On-air / recording only** — LIVE badge, program border, End/Go Live |
| `primary-container` | `#b91a24` | Red pressed/hover state |
| `secondary` (amber) | `#ffb95f` | Game audio channel, Super Chat highlight |
| `secondary-container` | `#ee9800` | Amber pressed/hover state |
| `attention` (orange) | `#ff8c3a` | Dropped frames, the live-screen indicator, blinking warnings |
| `positive` | `#22c55e` | **Status only** — connected, applied, healthy. Never an accent, never a button fill |
| `error` | `#ef4444` | Error text and icons |
| `error-container` | `#93000a` | Error surface (health banner, toast) — keeps error visually distinct from on-air red |
| `on-error` | `#ffdad6` | Text on an error surface |
| `info-container` | `#3a3b52` | Neutral notice surface (info toast) |
| `success-container` | `#1d5c38` | Success notice surface (success toast) |
| `warning-container` | `#b8860b` | Warning surface — health banner, warning toast |
| `test-container` | `#9c6f1c` | Test-stream chrome: the gold top bar and glow |
| `youtube` | `#ff0000` | **YouTube-branded surfaces only** — Stream Info / YPP rails, the YouTube account row, and the wordmark's "ty" (creator ruling: never re-colour it) | — the Stream Info and YPP rails, the YouTube account row. Not for state: live stays `primary` |
**The rule:** red means "live or recording" and nothing else. Never a Send button, a delete action,
a brand flourish, or a general accent — that was the defect in the first Stitch draft.
**`#e94560` is retired** as the app's general accent (it was in 15 files). Blue takes over every
interactive role; red keeps only live/record. The brand/wordmark *artwork* is unchanged — those
assets are the creator's art, not a theme value.
### Meter thresholds (both meters, all bays)
| Band | Range | Brush |
|---|---|---|
| Nominal | −∞ … −12 dB | `#38bdf8` |
| Caution | −12 … −3 dB | `#ffb95f` |
| Peak / clip | −3 … 0 dB+ | `#ff5451` |
Meters are **measured, never decorative** (see `docs/ai/output.md`): the ladder shows the live
level scaled by the current gain, not the gain setting.
## Typography
| Token | Family | Use |
|---|---|---|
| `sans` | Inter (400/500/600/700) | All UI text — labels, buttons, panel titles |
| `mono` | JetBrains Mono (500/600) | Numeric/telemetry values — bitrate, FPS, dB, coordinates, timers. Tabular figures stop digits shifting width as values change |
Icons: Material Symbols Outlined (bundled, not fetched).
### Scale
| Token | Size / line | Weight | Tracking | Use |
|---|---|---|---|---|
| `headline-lg` | 32 / 40 | 700 | −0.02em | Landing/empty-state titles only |
| `headline-md` | 24 / 32 | 600 | −0.015em | Dialog titles |
| `headline-sm` | 18 / 24 | 600 | −0.01em | Panel and section headers |
| `body-lg` | 15 / 22 | 400 | — | Default body, chat |
| `body-md` | 13 / 18 | 400 | — | Panel rows, form text |
| `body-sm` | 12 / 16 | 400 | — | Secondary text, hints |
| `label-lg` | 13 / 16 | 600 | 0.02em | `mono` — primary telemetry values |
| `label-md` | 11 / 14 | 500 | 0.04em | `mono` — labels, hotkey chips |
| `label-sm` | 10 / 12 | 500 | 0.05em | `mono` — dense telemetry only |
**Floors and implementation notes**
- **11px is the default label size; 10px is the absolute floor** and only for dense readouts that
sit beside a larger value. Nothing readable at 100% zoom may be smaller than 10px.
- **WPF has no letter-spacing.** Tracking values above are design intent, not a property; implement
as-is (no tracking) rather than faking it with padded strings.
- Mono numerics use `Typography.NumeralAlignment="Tabular"` so widths stay stable.
## Spacing
| Token | px | Use |
|---|---|---|
| `space-xs` | 4 | Inside a chip, icon-to-text |
| `space-sm` | 8 | Between related controls |
| `space-md` | 12 | Default gap inside a panel |
| `space-lg` | 16 | Panel padding, section separation |
| `space-xl` | 24 | Dialog padding, major separation |
| `gutter` | 12 | Between page columns |
| `margin` | 16 | Window edge to content |
| `margin-docked` | 8 | Edge to content when a rail/drawer is docked |
## Shape
| Token | px | Use |
|---|---|---|
| `radius-sm` | 4 | Chips, badges, keycap tags |
| `radius-md` | 8 | Buttons, inputs, cards, scene tiles |
| `radius-lg` | 16 | Preview viewport, drawers, dialogs |
| `radius-full` | pill | Status chips (LIVE, EDIT), toggles |
Recessed controls (fader wells, inputs) use the same outer radius as their housing so the seams
line up — do not mix a 4px well inside an 8px card.
## Elevation
| Level | Use | Implementation |
|---|---|---|
| −1 recessed | Fader wells, input fields, table headers | inset top shadow + `surface-container-lowest` |
| 0 panel | The default chassis | `surface-container*` + **1px `outline` border**, no shadow |
| 1 raised | Scene tiles, floating chips, action buttons | subtle drop shadow, 0 1px 2px |
| 2 floating | Drawers, popups, dialogs | larger spread + 1px top highlight |
**Budget rule:** `DropShadowEffect` is software-rendered in WPF. Shadows on small controls only —
**never** on the preview viewport, the scene strip, or either wing. Depth on large surfaces comes
from the surface scale and the 1px border, not from effects.
## Interaction
- Focus ring: 1px `#38bdf8` outside a 1px gap — visible against every surface level.
- Hover: step one surface level up; active: step two. No colour change on hover except for
destructive actions.
- Motion: 150ms `CubicEase(EaseOut)` for drawers/popovers; 90ms for hover/press. No springs —
WPF has no spring easing and hand-rolled overshoot reads as jank at 60Hz.
- Every action produces a visible outcome. No dead ends, no silent failures.
## Not in this standard
- The audio dock, its four bays, per-bay faders, 24-segment ladders, transport controls, lip-sync
slider and master telemetry strip — **exploratory**. The controls they re-house already exist in
the app; whether any of them gets a dock is a separate product decision.
- The page-nav sidebar (Broadcast Studio / Audio Mixer / Scene Matrix / Telemetry / Hotkeys) — the
app is one surface with an EDIT toggle and drawers, not a page router.
- CPU / GPU / NVENC readouts — no telemetry exists, and none of it changes a live decision.
- Acrylic/backdrop blur — WPF has no sub-region backdrop blur; panels are solid.
- **On-stream output** (chat box, social bar, alerts, brand flash) is styled by its own renderers,
not by this theme. Do not apply UI tokens to the broadcast picture.
## Rejected from the 2026-10-29 export
The export is a Material-3 dump from a warm seed; four tokens came out tinted by that seed and are
rejected in favour of the values above.
| Export value | Token | Why rejected |
|---|---|---|
| `#4cd7f6` | `tertiary` | Neon cyan beside the amber reads "toy"; `#38bdf8` is calmer and already used in-app |
| `#ab8986` | `outline` | Warm pink borders from the seed — wrong for a navy chassis |
| `#5b403e` | `outline-variant` | Same seed artifact |
| `#e4beba` | `on-surface-variant` | Pink secondary text; slate `#94a3b8` is legible and neutral |
## Implementation status
- **`Themes/Tokens.xaml` exists and is merged first in `App.xaml`** (2026-10-30). Guard:
`ytLive.Tests/DesignTokensTests.cs` — it parses this document and fails if a documented token has
no brush, if a value disagrees, or if the retired `#e94560` returns as a token.
- **Still to sweep:** `Themes/Controls.xaml` and the dozens of hardcoded hexes in XAML/C# still use
the legacy palette. Surfaces convert one at a time; the end state is that every colour, radius and
spacing value resolves from `Tokens.xaml` and a guard fails on any raw `#hex` outside it.
- **Fonts ship with the app** (`Assets/Fonts/*.ttf`, OFL-1.1) — no installation, no network, no
dependence on what the machine happens to have. Notice and license text travel with them (`THIRD-PARTY-NOTICES.txt` items 13-14).
+131 -621
View File
@@ -1,644 +1,154 @@
# Distribution & IP Protection Plan
# Distribution & release mechanics
> 360-degree protection strategy for LlamaCasty (.NET 8 / WPF), built around Polar.sh.
How the build becomes an install on someone's PC. **Policy — prices, entitlement, why Steam is the
only channel, support — is [`docs/ai/commercial.md`](docs/ai/commercial.md).** This file is the how.
---
> Superseded twice on 2026-10-31. The 736-line Polar/MSIX/obfuscation plan is in
> `git log -- Distribution.md`; the zip-plus-winget plan written earlier the same day is in the
> same place. The reasoning that survives lives in `commercial.md` and `docs/ai/decisions.md`.
## Polar.sh as the Distribution Backbone
## 1. The artifact
Polar handles everything between "customer wants to pay" and "customer has a working license key." We don't build any of that. What Polar gives us:
A self-contained win-x64 publish plus the bundled `ffmpeg\` folder, uploaded to a Steam depot. No
installer, no admin rights, no zip for anyone to mirror.
| Capability | How We Use It |
|------------|---------------|
| **License key management** | Polar generates, validates, expires, rotates, and revokes keys. Activation limits (device caps) are built-in — no custom device registry. |
| **File hosting** (up to 10GB) | Upload `LlamaCasty.exe` (self-contained, ~80-120MB) as a "File Download" benefit. Customers get signed, personal download URLs. SHA-256 checksums included. |
| **Checkout** | Polar's hosted checkout page handles payment. No Stripe integration, no PCI scope, no custom checkout UI. |
| **Customer portal** | Customers self-serve: view/copy license keys, see expiry, deactivate devices, rotate compromised keys. We don't build a portal. |
| **Subscriptions** | If we go subscription model (deferred), Polar handles recurring billing, dunning, cancellation. |
| **Merchant of Record** | Polar collects and remits VAT/GST/sales tax globally. We never touch tax compliance. |
| **Webhooks** | Polar notifies our backend on purchase, cancellation, key rotation. Used for optional telemetry (section 6.3). |
**Polar pricing (2026):** 5% + $0.50 per transaction (Starter plan). No monthly fee.
**What Polar does NOT handle:** Obfuscation, code signing, anti-tamper, runtime protection, EULA, DMCA. That's all us — sections 1-3, 5-7 below.
---
## 1. Code Architecture for Protection
### 1.1 Assembly Splitting (est. 2–3 days)
Split the monolith into three assemblies with different protection tiers:
| Assembly | Contents | Protection Level |
|----------|----------|-----------------|
| `LlamaCasty.Core.dll` | Models, DTOs, interfaces, enums, constants | Light obfuscation (symbol renaming only) |
| `LlamaCasty.Services.dll` | YouTube auth, stream service, encoder, audio pipeline, layout store | Full obfuscation + control flow + string encryption |
| `LlamaCasty.exe` | WPF UI, ViewModels, XAML | No obfuscation (XAML constraints) |
**Why:** Obfuscators break WPF XAML/BAML bindings when they rename types referenced in `DataContext`, `Binding Path=`, or `x:Name`. Keeping the UI assembly separate lets you protect the business logic aggressively while leaving the presentation layer safe.
**How:**
- `LlamaCasty.Core` → Class library, `net8.0`, no WPF dependency. Pure C#.
- `LlamaCasty.Services` → Class library, `net8.0`, references Core. No XAML.
- `LlamaCasty` → WPF app, references both. All `MainWindow.xaml`, `Themes/`, ViewModels live here.
### 1.2 Obfuscation-Friendly Patterns (est. 1–2 days, ongoing)
**Do:**
- Use `public`/`internal` properties with `{ get; set; }` for data binding (XAML `Binding Path=StringLiteral` resolves at runtime by string match — obfuscators handle this if configured).
- Use `[Obfuscation(Exclude = true)]` on anything bound to XAML via `Binding`, `CommandParameter`, or `x:Name`.
- Prefer `nameof()` for property names passed to `INotifyPropertyChanged` so renaming works.
- Use `RelayCommand` (already in use) — it takes lambdas, not string names.
**Avoid:**
- `FindName("SomeControl")` in code-behind (breaks if XAML name is mangled).
- `x:Name` references accessed from code-behind (use `Binding` instead).
- `Activator.CreateInstance(Type.GetType("SomeString"))` — obfuscator renames the type string.
- `Assembly.Load("LlamaCasty.Core")` by string — use direct references.
- `[Serializable]` DTOs that get binary-serialized (renamed fields break deserialization).
### 1.3 Strategic Attribute Placement (est. 1 day)
```csharp
// Exclude from obfuscation — XAML binding relies on property name
[Obfuscation(Exclude = true)]
public string RecordFolderDisplay { get; }
// Exclude — DTO serialized to SQLite
[Obfuscation(Exclude = true)]
public class StreamHealth { ... }
// Exclude — ViewModels bound by name in XAML
[Obfuscation(Exclude = true)]
public partial class MainViewModel : ViewModelBase { ... }
// Safe to obfuscate — internal service, no XAML binding
public class FfmpegEncoder { ... }
```bash
dotnet publish ytLive.csproj -c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:PublishReadyToRun=true -o artifacts/steam-stage
```
**Rules:**
- All ViewModels: `[Obfuscation(Exclude = true)]` (XAML `Binding Path=` resolves by name at runtime).
- All Models/DTOs used in SQLite: `[Obfuscation(Exclude = true)]` (column names are strings).
- All public service interfaces: `[Obfuscation(Exclude = true)]` (DI resolution).
- Internal service implementations: safe to obfuscate.
- Helpers/Converters in XAML: `[Obfuscation(Exclude = true)]` on the class.
- **`PublishTrimmed` stays OFF.** Trimming breaks WPF's XAML reflection (`Binding`, `DataTemplate`,
`StaticResource`). Expect ~110–150 MB in the depot; that is the cost of "no .NET install".
- **ffmpeg stays outside the exe** — `ffmpeg\` beside it (the app's locator contract, see
[`output.md`](output.md) → "FFmpeg locator").
- **`steam_api64.dll` ships beside the exe** and is loaded at startup; without it the app cannot
read DLC ownership. Its absence must degrade to the free profile, never to a crash.
- **WebView2 is the one machine dependency.** Valve's Common Redistributables configure the
Evergreen bootstrapper as a prerequisite, so the depot declares it instead of us shipping it.
- **`AssemblyName` is `LlamaCasty`** (renamed 2026-11-01, before the first depot because
Steamworks warns about renaming a live exe) → the artifact is `LlamaCasty.exe` plus its
`ffmpeg\` folder. `scripts/publish.sh` stages both and refuses to finish if either is missing.
- **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content.** Without it, `PublishSingleFile` +
`IncludeNativeLibrariesForSelfExtract` swallow the libav\*.dll family **into the exe** — a
182 MB binary with no `ffmpeg\` folder beside it, so the locator finds nothing and every stream
fails at runtime. The publish output is ~257 MB: 127 MB exe + 129 MB ffmpeg.
- **Portable mode** (`portable.txt` → `.\data\`) survives as a feature for a copied install; it is
not a delivery vector.
---
## 2. Steamworks setup, once
## 2. Build Pipeline
**The SDK zip is the only download.** It is ~24 MB and contains the runtime
(`sdk/redistributable_bin/win64/steam_api64.dll`) *and* the release tooling
(`sdk/tools/ContentBuilder/` → `steamcmd.exe` for Windows, `steamcmd.sh` for Linux/macOS, plus the
`app_build_*.vdf` / `depot_build_*.vdf` templates). There is no separate "ContentBuilder download"
on the partner dashboard — the dashboard's Downloads page only re-offers the SDK.
### 2.1 Build Configuration (est. 0.5 day)
**Reference links, so nobody hunts again:**
In `ytLive.csproj`:
| What | Where |
|---|---|
| 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` |
| 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` |
| Recurring subscriptions | `partner.steamgames.com/doc/store/pricing/subscriptions` |
| Partner support | `help.steampowered.com/en/wizard/HelpWithPublishing` |
| Steamworks.NET (managed wrapper) | `nuget.org/packages/Steamworks.NET` |
```xml
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<OutputType>WinExe</OutputType>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode> <!-- 'full' breaks WPF reflection; 'partial' is safe -->
<AssemblyName>LlamaCasty</AssemblyName>
<Version>0.9.0</Version>
<Company>LlamaCasty</Company>
<Product>LlamaCasty</Product>
</PropertyGroup>
```
### Status (updated 2026-11-05 — this is a WAIT on Valve, not a task of ours)
**Key decisions:**
- `PublishTrimmed` + `TrimMode=partial`: Trims unused BCL code but preserves WPF infrastructure. `full` trimming breaks `Binding`, `DataTemplate`, and XAML reflection.
- `PublishSingleFile`: One `.exe` to distribute. Self-contained = no .NET runtime dependency on user machines.
- `win-x64`: Target the dominant platform. ARM64 is a separate RID if needed later.
| 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 |
### 2.2 Obfuscator Integration (est. 2–3 days)
**Critical path from 2026-11-05**, using Valve's own published windows:
**Recommended: Eazfuscator.NET** (free for projects under $1M revenue, excellent WPF support, .NET 8 compatible).
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**
Alternative commercial: **ArmDot** ($399/yr, better control flow, built-in anti-tamper).
**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.
**Integration as post-publish step:**
Nothing below can start until step 1 clears; none of it blocks the app work.
```powershell
# publish.ps1 — called after dotnet publish
param([string]$PublishDir = "publish")
5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is
the window where wishlists accumulate.
dotnet publish ytLive.csproj -c Release -r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:PublishTrimmed=true `
-p:TrimMode=partial `
-o $PublishDir
# Obfuscate the Services assembly (core business logic)
eazfuscator.exe "$PublishDir\LlamaCasty.Services.dll" `
--target-framework net8.0 `
--Renaming-mode unprintable `
--Control-flow-obfuscation true `
--String-encryption true `
--Resource-encryption true
# Obfuscate the Core assembly (lighter touch)
eazfuscator.exe "$PublishDir\LlamaCasty.Core.dll" `
--target-framework net8.0 `
--Renaming-mode unprintable
# Do NOT obfuscate LlamaCasty.exe (WPF entry point + XAML)
```
### 2.3 XAML/BAML Handling (est. 1 day research)
**Problem:** WPF compiles XAML into BAML (binary XAML) embedded as resources. Obfuscators that rename types break BAML because it references types by assembly-qualified name.
**Solution:**
- Do NOT obfuscate the main `.exe` assembly (contains all BAML).
- Only obfuscate `Core` and `Services` DLLs (no XAML).
- If an obfuscator must touch the exe: use Eazfuscator.NET's `[Obfuscation(Exclude = true, Feature = "XAML")]` attribute on types referenced in BAML, or configure the obfuscator to skip the `LlamaCasty.g.resources` manifest resource.
**Testing:** After obfuscation, launch the app and verify:
- All screens render correctly
- `Binding` expressions resolve (no blank/missing data)
- `DataContext` inheritance works
- Storyboards/animations play
- `ContextMenu` items bind correctly
- Dialogs open/close properly
### 2.4 CI/CD Pipeline (est. 1 day)
```yaml
# .github/workflows/release.yml
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Test
run: dotnet test --no-restore -c Release
- name: Publish
run: dotnet publish ytLive.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true -p:TrimMode=partial -o publish
- name: Obfuscate
run: |
eazfuscator.exe publish/LlamaCasty.Services.dll --target-framework net8.0 --Renaming-mode unprintable --Control-flow-obfuscation true --String-encryption true
eazfuscator.exe publish/LlamaCasty.Core.dll --target-framework net8.0 --Renaming-mode unprintable
- name: Sign
run: |
$cert = Get-Content -Raw -Path secrets/certificate.pfx | ConvertTo-SecureString -AsPlainText -Force
Set-AuthenticodeSignature -FilePath publish/LlamaCasty.exe -Certificate $cert -TimestampServer http://timestamp.digicert.com -HashAlgorithm SHA256
- name: Upload to Polar
run: |
# Upload the signed .exe to Polar as a file download benefit
# Update the product's file benefit with the new version
curl -X POST "https://api.polar.sh/v1/products/{product_id}/benefits" \
-H "Authorization: Bearer $POLAR_ACCESS_TOKEN" \
-F "type=file_download" \
-F "file=@publish/LlamaCasty.exe"
```
---
## 3. Obfuscation Strategy
### 3.1 Recommended Obfuscator
**Eazfuscator.NET** (primary):
- Free for projects under $1M revenue — fits solo dev.
- Excellent WPF support: knows to skip BAML resources, handles `INotifyPropertyChanged` patterns.
- .NET 8 support confirmed.
- Symbol renaming (unprintable Unicode), control flow obfuscation, string encryption, resource encryption.
- Simple CLI integration.
**ArmDot** (commercial, $399/yr):
- Better control flow (virtualization of critical methods).
- Anti-tamper and anti-debug built in.
- Better at handling complex WPF scenarios.
**What NOT to use:**
- ConfuserEx — abandoned, doesn't support .NET 8.
- Obfuscar — limited WPF support, BAML breakage issues.
- Dotfuscator Community — too basic, no string encryption.
### 3.2 What to Obfuscate
| Technique | Where | Effect |
|-----------|-------|--------|
| Symbol renaming (unprintable) | `Services.dll`, `Core.dll` | All type/method/field names → Unicode garbage. 10x decompilation difficulty. |
| Control flow obfuscation | `Services.dll` | Flattens `if/else/switch` into state machines. Decompiler produces spaghetti. |
| String encryption | `Services.dll` | All string literals encrypted; decrypted at runtime. Hides API URLs, error messages, config values. |
| Resource encryption | `Services.dll` | Embedded resources encrypted. Prevents extraction of embedded data. |
| Anti-tamper | `Services.dll` | CRC check on method bodies at runtime. Detects binary patching. |
### 3.3 What to Exclude
| Item | Reason |
|------|--------|
| `LlamaCasty.exe` (WPF entry point) | BAML resources break if types are renamed. |
| All ViewModels | `Binding Path=` resolves property names at runtime. |
| All Models/DTOs | SQLite column mapping uses property names as strings. |
| `LayoutStore` (public interface) | Called by ViewModels via interface — renaming breaks DI. |
| All `IValueConverter` implementations | XAML references converters by type name in `StaticResource`. |
| All `MarkupExtension` subclasses | XAML parser resolves by type name. |
| `App.xaml.cs` entry point | Must be discoverable. |
### 3.4 Testing Obfuscated Build (est. 1 day per release)
**Automated checks:**
1. App launches without crash (startup log at `%APPDATA%\ytLlive\startup.log`).
2. All 5 scenes load (Starting/Live/BRB/Chat/Ending).
3. Social bar renders with entries.
4. TRAX button click opens file picker.
5. Recording starts/stops correctly.
6. YouTube OAuth flow completes (if signed in).
7. Settings panel opens from Gear menu.
8. All 247 unit tests pass against the unobfuscated assemblies (tests never run against obfuscated code).
**Manual smoke test:**
- Start a recording, verify file appears in configured folder.
- Open social dialog, add/edit/delete a handle.
- Toggle mic mute, verify meter responds.
- Switch scenes via thumbnail strip.
---
## 4. Distribution Artifacts
### 4.1 Files to Ship
## 3. Release
```
LlamaCasty.exe (single-file, self-contained, ~80-120MB)
publish → steam-stage → steamcmd + app_build.vdf → build upload → set live → announcement
```
Single-file means everything is inside the `.exe`. No DLLs, no runtime dependency.
**Why not an installer?**
- Single `.exe` is simpler to distribute and update.
- No admin rights required (installs to `%LOCALAPPDATA%`).
- No installer framework to maintain (WiX, Inno Setup, etc.).
- User drops it anywhere and runs it.
**Caveat:** Self-contained single-file means ~80-120MB due to bundled .NET runtime. For smaller size, use framework-dependent publishing (requires .NET 8 runtime on user machines) — drops to ~5-10MB.
### 4.2 Distribution via Polar
**Product setup on Polar:**
1. Create a product: "LlamaCasty" — one-time purchase (or subscription if deferred).
2. Add two benefits:
- **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry.
- **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included.
**Customer flow:**
1. Customer visits `buy.polar.sh/polar_cl_...` (the Premium URL already in `MainViewModel.PremiumUrl`).
2. Customer pays via Polar checkout.
3. Polar generates a license key + provides a signed download URL.
4. Customer downloads `LlamaCasty.exe`, runs it, enters license key in Settings.
5. App calls `PolarLicenseService.ValidateAsync(key)` → Polar API validates → `IsValid = true`.
6. Premium features unlock locally (persisted in `LayoutStore.Settings`).
**Activation limits:**
- Polar supports `limit_activations` per license key (e.g., 3 devices).
- Client calls `/v1/customer-portal/license-keys/activate` before first validation.
- Polar tracks activations; customer can deactivate via Polar's customer portal.
- No custom device registry needed — Polar handles it.
**Key rotation:**
- If a key is compromised, customer or merchant can rotate it via Polar.
- Previous key stops validating immediately. Status, usage, limits, expiry preserved.
### 4.3 Code Signing
**Certificate:** Buy an EV (Extended Validation) code-signing certificate from DigiCert or Sectigo ($200-400/year). EV certificates get instant SmartScreen reputation; DV/OV certs trigger SmartScreen warnings for months.
**Signing process:**
```powershell
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
"path\to\certificate.pfx",
"password",
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable
)
Set-AuthenticodeSignature -FilePath "LlamaCasty.exe" `
-Certificate $cert `
-TimestampServer "http://timestamp.digicert.com" `
-HashAlgorithm SHA256
```
**Storage:** Certificate `.pfx` file stored in GitHub Secrets (or Azure DevOps secure files). Never committed to the repo.
### 4.4 Update Mechanism
**Polar-native approach (no custom infrastructure):**
```csharp
// In Services.dll (obfuscated)
public class UpdateCheckService
{
// Polar's customer portal API — we check if a newer file is available
// by validating the license key and checking the benefit's file checksum
private const string ValidateUrl = "https://api.polar.sh/v1/customer-portal/license-keys/validate";
public async Task<UpdateInfo?> CheckForUpdateAsync(string currentVersion, string licenseKey)
{
// Validate the key — Polar returns the current benefit state
// If the file benefit's checksum changed, a new version is available
// The download URL is Polar's signed URL — we never hardcode it
// ... implementation in PolarLicenseService ...
}
}
```
**Design:**
- App validates license key on startup (already implemented in `PolarLicenseService`).
- Polar's validation response includes benefit metadata — if the file benefit's SHA-256 changed, a new version exists.
- App prompts user to download from Polar's customer portal (or provides the signed URL).
- No custom update manifest, no self-hosted endpoints, no signing key infrastructure.
- Version obsolescence: Polar can disable old file benefits, making old versions unsupported.
**Alternative (if Polar's API doesn't expose file version metadata):**
- Host a lightweight `version.json` on GitHub Releases (public, no secrets).
- App checks GitHub Releases API for latest version.
- Download link points to Polar's customer portal (not direct download).
- This adds one external dependency (GitHub) but keeps Polar as the source of truth.
### 4.5 Licensing Logic (already implemented)
```csharp
// Services/PolarLicenseService.cs — already ships
// Calls Polar's customer-portal validation endpoint (no auth required)
// Survives obfuscation — property names excluded via [Obfuscation(Exclude = true)]
[Obfuscation(Exclude = true)]
public class LicenseValidationResult
{
public bool IsValid { get; set; }
public string? Status { get; set; } // "granted", "revoked", "disabled"
public DateTime? ExpiresAt { get; set; }
public string? Error { get; set; }
}
```
**What's already done:**
- `PolarLicenseService` validates keys against Polar API.
- `OrganizationId` is hardcoded (safe — it's public knowledge, tied to our Polar account).
- License state persists in `LayoutStore.Settings` (SQLite) with offline grace period.
- `PremiumUrl` points to Polar checkout.
**What survives obfuscation:** The validation logic in `Services.dll` is obfuscated. An attacker who decompiles the exe sees only the UI calling an interface — the actual Polar API call is in the obfuscated DLL.
---
## 5. Runtime Protection
### 5.1 Anti-Debugging (est. 1–2 days)
```csharp
// In Services.dll (obfuscated)
internal static class AntiTamper
{
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern bool IsDebuggerPresent();
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern bool CheckRemoteDebuggerPresent(IntPtr hProcess, ref bool isDebuggerPresent);
internal static void Validate()
{
if (IsDebuggerPresent())
Environment.FailFast("Tamper detected.");
bool debuggerPresent = false;
CheckRemoteDebuggerPresent(IntPtr.Zero, ref debuggerPresent);
if (debuggerPresent)
Environment.FailFast("Tamper detected.");
// Timing check: if a method takes >10x expected time, a debugger is stepping
var sw = System.Diagnostics.Stopwatch.StartNew();
SomeFrequentlyCalledMethod();
sw.Stop();
if (sw.ElapsedMilliseconds > 100)
Environment.FailFast("Tamper detected.");
}
}
```
**Trade-offs:**
- `IsDebuggerPresent()` is trivially patched (NOP the call). Useful against casual inspection, not determined attackers.
- Timing checks can false-positive on slow machines. Use conservative thresholds.
- `Environment.FailFast()` terminates the process immediately — no data corruption, no recovery.
### 5.2 Anti-Tampering (est. 1 day)
```csharp
// Method body CRC check — runs at startup
// Obfuscator can inject this automatically (ArmDot)
internal static void VerifyMethodBodies()
{
var assembly = Assembly.GetExecutingAssembly();
var location = assembly.Location;
var bytes = File.ReadAllBytes(location);
// Check known CRC offsets (computed at build time)
foreach (var (offset, expectedCrc) in KnownChecksums)
{
var actualCrc = Crc32.Compute(bytes, offset, MethodBodyLength);
if (actualCrc != expectedCrc)
Environment.FailFast("Binary integrity check failed.");
}
}
```
### 5.3 Environment Validation (est. 0.5 day)
```csharp
// Detect common analysis environments — NOT bulletproof, raises the bar
internal static bool IsAnalysisEnvironment()
{
var suspiciousProcesses = new[] { "dnSpy", "ILSpy", "x64dbg", "x32dbg", "ollydbg", "procmon", "wireshark" };
var running = System.Diagnostics.Process.GetProcesses()
.Select(p => p.ProcessName.ToLowerInvariant());
if (running.Any(p => suspiciousProcesses.Contains(p)))
return true;
var vmIndicators = new[] { "VMware", "VirtualBox", "Hyper-V", "Sandboxie" };
var wmi = new System.Management.ManagementObjectSearcher("SELECT * FROM Win32_ComputerSystem");
foreach (var obj in wmi.Get())
{
var manufacturer = obj["Manufacturer"]?.ToString() ?? "";
if (vmIndicators.Any(v => manufacturer.Contains(v, StringComparison.OrdinalIgnoreCase)))
return true;
}
return false;
}
```
**Privacy note:** Environment checks that query WMI or enumerate processes require the app to have no network exfiltration of this data. Log locally only. Do NOT send "user is in a VM" telemetry — that's a privacy violation and potential legal issue.
### 5.4 Preventing Memory Dumps (est. 0.5 day, limited effectiveness)
```csharp
internal static void ProtectSensitiveMemory()
{
// Encrypt license keys in memory when not in use
// Zero memory after use
// Use SecureString for transient secrets
}
```
**Reality check:** Against a determined attacker with admin rights, memory protection is bypassable. The value is in raising the bar, not creating an impenetrable wall. Focus effort on:
1. String encryption (hides API endpoints, keys, config).
2. Control flow obfuscation (makes logic hard to follow).
3. Polar server-side validation (license checks hit Polar's API, not our code).
### 5.5 Secrets Management
| Secret | Storage | Notes |
|--------|---------|-------|
| YouTube OAuth ClientSecret | Never in binary. Server-side only. | Client uses OAuth device flow; the secret lives on your auth server. |
| Code-signing certificate | CI/CD secrets only. | Never in source. |
| Polar access token (for uploads) | CI/CD secrets only. | Used in pipeline to upload new .exe to Polar. |
| User tokens (DPAPI) | `%APPDATA%\ytLlive\ytLlive.auth` | OS-level encryption. Never sent anywhere. |
**Principle:** The client binary contains zero secrets that matter. Polar handles license validation server-side. The binary is a consumer of Polar's API, not a holder of credentials. The `OrganizationId` in `PolarLicenseService` is not a secret — it's required by Polar's public validation endpoint.
---
## 6. Legal and Operational Measures
### 6.1 EULA (est. 1 day, lawyer review recommended)
Key clauses:
```
1. LICENSE GRANT: Limited, non-exclusive, non-transferable license to use the software.
2. RESTRICTIONS: No reverse engineering, decompilation, disassembly, or circumvention
of technical protection measures. No redistribution, sublicensing, or rental.
3. INTELLECTUAL PROPERTY: All rights reserved. The software is protected by copyright
law and international treaties.
4. TERMINATION: License terminates automatically upon breach. Upon termination,
you must destroy all copies.
5. DISCLAIMER: Software provided "AS IS" without warranty.
6. LIMITATION OF LIABILITY: Not liable for damages arising from use.
7. GOVERNING LAW: [Your jurisdiction].
```
**Enforceability:** EULAs that prohibit reverse engineering have been upheld in the US (DMCA §1201) and EU (Copyright Directive Art. 6). They're less enforceable in some jurisdictions (e.g., Germany has compulsory decompilation rights under §69e UrhG for interoperability). A lawyer should review for your target markets.
### 6.2 DMCA / Takedown
1. **Register copyright** with the US Copyright Office ($65 per work). Required for statutory damages in infringement suits.
2. **Monitor** GitHub, torrent sites, and software sharing sites for pirated copies. Set up Google Alerts for "LlamaCasty download", "LlamaCasty crack".
3. **DMCA takedown** to GitHub/hosting provider when found. Template:
```
Subject: DMCA Takedown Notice — Copyright Infringement
I am the copyright owner of the software "LlamaCasty".
The following repository contains unauthorized copies:
[URL]
I have a good faith belief that the use is not authorized.
I swear under penalty of perjury that this information is accurate.
```
4. **DMCA to Google** to delist pirate download pages.
### 6.3 Telemetry for Unauthorized Use (est. 0.5 day, optional)
```csharp
// Optional: anonymized heartbeat that detects unauthorized distribution
// Only send: installation GUID (random, stored locally), version, license tier
// DO NOT send: file paths, user names, screen content, keystrokes
internal static async Task SendHeartbeatAsync()
{
var id = GetOrCreateInstallationId(); // Random GUID, stored in Settings
var version = Assembly.GetExecutingAssembly().GetName().Version;
// POST to your API — response can include "deactivate" flag
}
```
**Privacy:** Heartbeat must be disclosed in the EULA and privacy policy. Must not collect PII. Must be opt-out-able in EU (GDPR). Consider whether this is worth the complexity for a solo dev — Polar already tracks validation counts per key, which gives you usage data without custom telemetry.
---
## 7. Fallback and Recovery
### 7.1 If Protection Is Bypassed
**Tier 1 — Casual piracy (someone shares the .exe):**
- Monitor download sites, issue DMCA takedowns.
- Polar can disable the file benefit — old download URLs stop working.
- Push a new version to Polar; old versions become obsolete.
- No kill-switch needed — version obsolescence is sufficient.
**Tier 2 — Cracked version distributed (license check bypassed):**
- Revoke the compromised license key via Polar dashboard (or API: `POST /v1/license-keys/{id}/revoke`).
- Rotate the key — previous key stops validating immediately.
- Push emergency update with new validation logic.
- The cracked version is now outdated; Polar's API rejects old keys.
**Tier 3 — Full decompilation and source reconstruction:**
- This means obfuscation failed. Audit your obfuscation settings.
- Switch from Eazfuscator to ArmDot (virtualization is harder to reverse).
- Add server-side validation for critical features (license, updates).
- Accept that a determined attacker with enough time will always win — focus on making it not worth their time.
### 7.2 Emergency Update Push
**Via Polar (no custom infrastructure):**
1. Upload new `.exe` to Polar dashboard as updated file benefit.
2. Disable old file benefit — existing customers lose access to old download URL.
3. Customers with valid license keys see the new version in their Polar purchase page.
4. Optional: Use Polar webhook to notify a lightweight endpoint → push in-app notification.
**Via GitHub Releases (fallback):**
1. Create a new release on GitHub with the updated `.exe`.
2. App checks GitHub Releases API for latest version (if Polar's file version metadata isn't exposed).
3. Download link points to Polar's customer portal (not direct GitHub download).
### 7.3 Kill-Switch Ethics
A kill-switch is legally and ethically complex:
- **Legal:** You can deactivate your own software per your EULA. Users agreed to it.
- **Ethical:** Don't deactivate without warning. Give 30 days notice. Provide data export.
- **Practical:** A kill-switch that bricks the app without notice will generate bad press and potential legal action.
- **Recommendation:** Use Polar's key revocation + version obsolescence instead of kill-switches. Let the version go stale rather than actively disabling it.
---
## Summary: Feasibility Assessment
| Step | Time | Difficulty | Solo Dev? | Polar Handles? |
|------|------|------------|-----------|----------------|
| Assembly split | 2–3 days | Medium | Yes | No |
| Obfuscation-friendly patterns | 1–2 days | Low | Yes | No |
| Attribute placement | 1 day | Low | Yes | No |
| Build pipeline | 1 day | Medium | Yes | No |
| Obfuscator integration | 2–3 days | Medium | Yes | No |
| XAML/BAML testing | 1 day | Medium | Yes | No |
| CI/CD pipeline | 1 day | Medium | Yes | No |
| Code signing setup | 0.5 day | Low | Yes | No |
| License key setup (Polar) | 0.5 day | Low | Yes | **Yes** |
| File hosting (Polar) | 0.5 day | Low | Yes | **Yes** |
| Checkout (Polar) | 0 | None | Yes | **Yes** |
| Customer portal (Polar) | 0 | None | Yes | **Yes** |
| Activation limits (Polar) | 0.5 day | Low | Yes | **Yes** |
| Key rotation/revocation (Polar) | 0 | None | Yes | **Yes** |
| Update mechanism | 1 day | Medium | Yes | Partial |
| Anti-debug/anti-tamper | 1–2 days | Medium | Yes | No |
| Environment validation | 0.5 day | Low | Yes | No |
| EULA (draft + review) | 1–2 days | Low | Lawyer recommended | No |
| DMCA preparation | 0.5 day | Low | Yes | No |
| **Total** | **~12–18 days** | | **Yes** | **~5 days saved** |
**Bottom line:** Polar eliminates ~5 days of custom infrastructure (licensing backend, checkout, customer portal, device management, tax compliance, file hosting). What remains is purely our protection layer (obfuscation, code signing, anti-tamper) and legal measures.
The most cost-effective approach for a solo dev:
1. **Do (core):** Assembly split + Eazfuscator (Services.dll only) + code signing + Polar license keys + Polar file hosting. Stops 95% of casual piracy. (~7 days)
2. **Do (hardening):** Anti-debug checks + Polar activation limits (device caps). Adds another layer. (~3 days)
3. **Skip (for now):** Environment VM detection, memory protection, complex kill-switches. The cost/benefit doesn't work at indie scale. These are for enterprise or high-value targets.
The determined reverse engineer with IDA Pro and unlimited time will always win. The goal is to make the cost of cracking exceed the value of the software — for a solo dev product, that bar is low. Focus on making a great product that people want to pay for.
1. **Upload the depot** with `steamcmd +login <user> +run_app_build ..\scripts\app_build_<appid>.vdf
+quit`. The VDF names the app, depot, content root and branch.
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 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` 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.
- 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.
- 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.
- Portable mode: `portable.txt` present → data lands in `.\data\`.
## 6. Legal and takedown
- **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and
disclaims warranty.
- **`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 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
| Step | Effort |
|---|---|
| Steamworks onboarding + $100 fee | 0.5–1 day, mostly waiting on verification |
| 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 Demo install + paid-app ownership test | 0.5 day |
+11 -11
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.GoLiveWindow"
<Window x:Class="ytLive.GoLiveWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Start Stream" Height="480" Width="520"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Window.Resources>
<ResourceDictionary>
@@ -22,12 +22,12 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,16"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,16"/>
<!-- ACCOUNT -->
<StackPanel Grid.Row="1" Margin="0,0,0,16">
<TextBlock Text="ACCOUNT" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="0,0,0,8"/>
<!-- signed in -->
<StackPanel Orientation="Horizontal">
@@ -41,11 +41,11 @@
</Style.Triggers>
</Style>
</StackPanel.Style>
<Border Width="34" Height="34" CornerRadius="17" Background="#16213e" ClipToBounds="True"
<Border Width="34" Height="34" CornerRadius="17" Background="{StaticResource Brush.SurfaceContainer}" ClipToBounds="True"
VerticalAlignment="Center" Margin="0,0,10,0">
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
</Border>
<TextBlock Text="{Binding AccountDisplayName}" Foreground="#e0e0e0" FontSize="14"
<TextBlock Text="{Binding AccountDisplayName}" Foreground="{StaticResource Brush.OnSurface}" FontSize="14"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Content="Change Account" Command="{Binding SignInCommand}"
Style="{StaticResource YtButtonSecondary}" VerticalAlignment="Center"/>
@@ -65,12 +65,12 @@
</StackPanel.Style>
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,10,0"/>
<TextBlock Text="Going live requires a YouTube account." Foreground="#a0a0b0" FontSize="12"
<TextBlock Text="Going live requires a YouTube account." Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="Signing in… open the browser to authorize, then come back here."
Foreground="#e94560" FontSize="12" Margin="0,8,0,0">
Foreground="{StaticResource Brush.Error}" FontSize="12" Margin="0,8,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
@@ -87,7 +87,7 @@
<!-- STREAM METADATA -->
<StackPanel Grid.Row="2">
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="0,0,0,8"/>
<TextBlock Text="Title" Style="{StaticResource YtLabel}"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
@@ -100,10 +100,10 @@
<TextBlock Text="Visibility" Style="{StaticResource YtLabel}"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<TextBlock Text="Private" Foreground="#e0e0e0" FontSize="14"
<TextBlock Text="Private" Foreground="{StaticResource Brush.OnSurface}" FontSize="14"
VerticalAlignment="Center"/>
<TextBlock Text="Streams always start Private — change it on YouTube after going live."
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" TextWrapping="Wrap"
Margin="10,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
+23 -92
View File
@@ -1,99 +1,30 @@
# HANDOFF — Session State
# HANDOFF — 2026-11-11 (TASK 37 complete; queue advances to TASK 40)
## Branch / Commit State
## State
**`main`**, working tree is **CLEAN**.
- `main`, clean and pushed to `origin/main` at `1d35a74`.
- **Done:** TASK 35 scene-linked audio, TASK 55 disk hygiene, TASK 65 replay clips, TASK 37 defaults/current split.
- TASK 37: SQLite keeps an immutable default snapshot beside build-stamped current rows; App Settings can
restore the staged scene or all scenes, offline only. Webcam identity survives both restore paths.
- TASK 14·15 mic input gain was closed by creator test (−2.6 dB, works great); slices 16–18 remain.
- Verification: **615 tests green, clean build with 0 warnings** (`FAST=1 ./scripts/verify.sh`).
- Steam remains blocked on Valve identity/tax review; no app IDs or store page.
Both split phases are now complete. Phase 1 (view-model partials) is pushed;
Phase 2 (MainWindow.xaml → UserControls) is complete but **local-only**.
## Next action
**Pushed** (reached `origin/main` == `14d3ed4`, incl. tags): Phase 1 commit `0`
and refactor commits `1..11` (`refactor-commit-0..11`), plus policy/HANDOFF.
**LOCAL ONLY (Phase 2 — 12, 13, 14, 15, 16, 17, 18)** — policy 2026-08-30: no
per-commit push; `git push` only at a user-agreed milestone / "push it".
**Push pending on explicit user approval:**
| Commit | Hash | Tag | Control | From Loc |
|--------|------|-----|---------|----------|
| 12 | `bc23b64` | `refactor-commit-12` | `Controls/BottomBar` | footer |
| 13 | `6914252` | `refactor-commit-13` | `Controls/HealthBanner` | row 1 banner |
| 14 | `90f5ad5` | `refactor-commit-14` | `Controls/TopBar` | row 0 top bar |
| 15 | `c5e3466` | `refactor-commit-15` | `Controls/PreviewPane` | center preview |
| 16 | `73397cf` | `refactor-commit-16` | `Controls/SceneThumbnailStrip` | row 0 strip |
| 17 | `c9fd1bd` | `refactor-commit-17` | `Controls/LeftPanel` | row 1 col 0 |
| 18 | `544208e` | `refactor-commit-18` | `Controls/OverlayHost` | overlay dialogs |
## What's In Flight
Nothing code-in-flight — working tree clean, **Phase 2 complete**.
**Phase 2 result:** `MainWindow.xaml` carved 1,681 → **123 lines** (a shell over
6 UserControls under `Controls/`, namespace `ytLive.Controls`). Every change:
0-warning build, `scripts/verify.sh` (246 pass + 2 known only), explicit-file
`scripts/scope-check.sh`, `ViewModels/index.md` in the SAME commit, tagged
`refactor-commit-N`, no per-commit push.
**Next:**
- If user says "push": `git push origin main && git push --tags` (7 commits 12-18
+ their tags).
- Then optional Phase 3 if queued. Nothing is queued beyond Phase 2.
**Pending bus:** `ViewModels/index.md` Phase 2 tracker has rows 12–18 (closed);
Phase 1 tracker closed. `Controls/` has `index.md`-style facts only if needed.
- Queue: **40·A/C/D → Move → 9·5 → 14·16/17/18 → 66 → 67 → 68 → 36**.
- Ask creator before TASK 68 about first-run ON-AIR default; before gold pass, settle the Release
single-instance mutex behavior.
- TASK 34 scheduling and TASK 50 recording publish are retired; YouTube owns those surfaces.
- Do not push app commits absent a checkpoint request.
## Landmines
- **Pre-existing test failures (do not chase):**
`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` +
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`.
- **Tests occasionally abort early (236/237)** — known flake, accepted by `verify.sh`.
- **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building.
- **WinForm crash from `Window_PreviewMouseLeftButtonDown`** — handled in-side the
preview/left panel; don't add handlers that deref `OriginalSource` unguarded.
- **Controls cannot see `Window.Resources`** (converter rule, Commit 13): any
StaticResource the window once supplied must live at app level in
`Themes/Controls.xaml`. The 7 converters were promoted there.
- **Some control XAML needs its own `xmlns:models` / `xmlns:Helpers`** — a moved
block lost its namespace when it left MainWindow's root (LeftPanel hit this,
MC2000 "key null"). Always add the model/helper xmlns on each control root.
- **Name-looked-up host stubs must stay in the window:** `WebViewHostPanel`
(ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
window — keep them as window-root children, don't bury them in a control.
OverlayHost deliberately left them in place (Commit 18) for this reason.
- **Pre-commit gate:** `scripts/verify.sh` (0-warning build + full tests) and
`scripts/scope-check.sh "file1" …` — scope-check REQUIRES explicit file args.
- Build from WSL with the Windows dotnet host only (see `AGENTS.md`).
## Todo state
- MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed.
- MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, local-only.
- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
- TASK 22 (audio sync offset): not started — required for v1.
## Session summary (2026-08-30)
- Ran the **MainWindow.xaml split to completion** under the standing "continue
without stopping" directive — no interruption prompts, no `question` tool.
- Commits 12–18 landed locally, each: cut → 0-warning build → verify (246 pass +
2 known) → scope-check → index.md in-commit → `git tag refactor-commit-N`. No push.
- **Commit 15 (PreviewPane** the hardest: 1920×1080 canvas + drag/select/resize/
hit-test, webcam menu handlers, social glow, audio meters. A dropped outer
`Border` (background/glow/margin) was caught and restored same-commit.
- **Commit 17 (LeftPanel)** the biggest: property editors + chat autoscroll
(moved to self-subscribe on `Loaded`), drag-to-reorder, snapshot revert/accept
family; window routes via `_leftPanel.IsClickInside` and
`_leftPanel.OnSelectionChanged(vm)`.
- **Commit 18 (OverlayHost)** the scope-decision: moved only the three dialogs;
kept `WebViewHostPanel` + `ToastArea` as window-root stubs because their
name-based lookups (WebView2 init, toast `InArea`) would break if buried in a
control — a spin-guard decision, not a guess.
- Two XAML extraction gotchas recorded: (a) a moved block losing its `xmlns`
→ MC2000 "key null" (Line n col m), fixed by adding `xmlns:models` on the
control root; (b) re-deriving line numbers after each edit before `sed`/awk
deletion ranges (the edit tool shifts numbers).
- Push checkpoint reached; awaiting explicit "push" for commits 12–18 + tags.
- Preview frame-source layers need a `PreviewPane` DataTrigger; pin each new type with a visibility test.
- Bundled-art healing compares bytes (`LayoutStore.AssetMatches`); a compiled version constant races its resource.
- Pack URIs for bundled assets must be assembly-qualified (`/LlamaCasty;component/Assets/…`).
- Media sessions are keyed by path + look; never acquire by raw path.
- Audio pipe allows one server per name; DEBUG suffixes it, Release does not.
- Any test constructing `MainWindow` must use a temporary `LayoutPathOverride` and clear it in `finally`.
- Build with the Windows `dotnet.exe` host from WSL; read render numbers in Release.
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
+91 -9
View File
@@ -2,29 +2,88 @@ 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
/// %APPDATA%\ytLlive\startup.log, appending synchronously so entries survive
/// a hard crash.
/// %APPDATA%\ytLlive\startup.log (or a per-instance subfolder in DEBUG — see
/// InstanceProfile), appending synchronously so entries survive a hard crash.
/// </summary>
public static class AppLog
{
private static readonly string LogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
"startup.log");
/// <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;
private static readonly string DefaultLogPath = Path.Combine(
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; }
/// <summary>Test seam: the cap that triggers a roll.</summary>
internal static long MaxBytes { get; set; } = DefaultMaxBytes;
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)
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
File.AppendAllText(LogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
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, $"{entry.Text}{Environment.NewLine}");
}
}
catch
@@ -35,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(),
};
}
+27
View File
@@ -0,0 +1,27 @@
using System;
namespace ytLive.Helpers;
/// <summary>The accessible mono fold: average each stereo pair into both channels.
///
/// <para>Creator context (2026-10-30): profoundly deaf on the right. Hard-panned content is not
/// quieter to them, it is ABSENT, so a single-sided game or clip is unusable. Folding to mono puts
/// everything on both sides — the loudness cost is honest (a one-sided signal arrives at half
/// amplitude) and speech is unaffected because the mic is already centred.</para></summary>
public static class AudioFold
{
/// <summary>Folds an interleaved buffer in place. Mono buffers are left untouched: there is
/// nothing to combine, and rewriting them would be a silent no-op that hides a caller's bug.</summary>
public static void ToMono(Span<float> interleaved, int channels)
{
if (channels < 2) return;
var frames = interleaved.Length / channels;
for (var frame = 0; frame < frames; frame++)
{
var offset = frame * channels;
var mono = (interleaved[offset] + interleaved[offset + 1]) * 0.5f;
interleaved[offset] = mono;
interleaved[offset + 1] = mono;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace ytLive.Helpers;
/// <summary>Gives a ComboBox's CLOSED box a different template from its drop-down
/// rows (TASK 63).
///
/// <para>WPF asks an <see cref="ItemTemplateSelector"/> with the ContentPresenter's
/// templated parent: the <see cref="ComboBox"/> itself when it renders the closed
/// selection box, the <see cref="ComboBoxItem"/> when it renders a row. That is the
/// only way to tell the two apart — <c>ComboBox.SelectionBoxItemTemplate</c> is
/// read-only, and WPF copies <c>ItemTemplate</c> into it, so a ComboBox otherwise
/// cannot show one thing when shut and another when open.</para>
///
/// <para>Verified against WPF on .NET 8 (2026-10-21): with the rows' presenter the
/// templated parent is the ComboBoxItem, with the closed box's it is the ComboBox.</para>
/// </summary>
public sealed class ClosedBoxTemplateSelector : DataTemplateSelector
{
/// <summary>The template for the closed box — what the creator reads while the
/// drop-down is shut.</summary>
public DataTemplate? ClosedTemplate { get; set; }
/// <summary>The template for the drop-down rows.</summary>
public DataTemplate? RowTemplate { get; set; }
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
=> container is FrameworkElement { TemplatedParent: ComboBox }
? ClosedTemplate
: RowTemplate;
}
+29
View File
@@ -0,0 +1,29 @@
namespace ytLive.Helpers;
/// <summary>
/// Whether this binary is a **development build**.
///
/// <para><b>Why <c>static readonly</c> and not <c>const</c>:</b> a const makes
/// <c>if (!DevBuild.IsDevelopment) return;</c> a compile-time branch, so whichever arm is dead
/// raises CS0162 — in Debug the guard is dead, in Release the body is. A readonly field is set
/// once at type init, so both arms compile clean and the JIT folds the branch away.</para>
///
/// <para><b>This is not the only defence.</b> The developer panel's XAML and code-behind are
/// removed from the Release compile entirely (<c>ytLive.csproj</c>, Page/Compile Remove), so a
/// shipped binary contains no BAML to reveal. This flag is the second lock: it refuses the
/// value even if something else calls the setter.</para>
///
/// <para>Single source for the question. It gates the multi-instance profile
/// (<see cref="InstanceProfile"/>), the scoreboard's debug seeding, and the all-resolutions
/// override (<c>MainViewModel.DeveloperMode</c>).</para>
/// </summary>
public static class DevBuild
{
/// <summary>True for a Debug compile; false for Release and anything else.</summary>
public static readonly bool IsDevelopment =
#if DEBUG
true;
#else
false;
#endif
}
+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;
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace ytLive.Helpers;
public class DroppedFramesToBrushConverter : IValueConverter
{
private static readonly SolidColorBrush Attention = new(Color.FromRgb(0xff, 0x8c, 0x3a));
private static readonly SolidColorBrush Clean = new(Colors.White);
static DroppedFramesToBrushConverter()
{
Attention.Freeze();
Clean.Freeze();
}
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is int dropped && dropped > 0 ? Attention : Clean;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
+20
View File
@@ -0,0 +1,20 @@
using System.Globalization;
using System.Windows.Data;
namespace ytLive.Helpers;
public class DroppedPercentConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
var dropped = values.Length > 0 && values[0] is int d ? d : 0;
var fps = values.Length > 1 && values[1] is double f ? f : 0;
var duration = values.Length > 2 && values[2] is TimeSpan t ? t : TimeSpan.Zero;
var total = fps * duration.TotalSeconds;
var percent = total > 0 ? dropped / total * 100 : 0;
return $"{dropped} ({percent.ToString("0.0", CultureInfo.InvariantCulture)}%)";
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
+123
View File
@@ -0,0 +1,123 @@
using System;
using System.Diagnostics;
namespace ytLive.Helpers;
/// <summary>
/// Arrival cadence for one window of a timestamped frame stream: how many frames
/// arrived, how far apart they were, and how many gaps were long enough to read
/// as a hitch. Fed any shared monotonic clock (<see cref="Stopwatch.GetTimestamp"/>
/// values, or a device's system-relative stamps on the same clock) and read through
/// <see cref="Summarize"/>.
///
/// Why it exists: a 60fps container makes a missing camera frame indistinguishable
/// from a padded repeat. rawvideo carries no per-frame timestamps — ffmpeg stamps
/// frames by ARRIVAL at the declared rate — so the OBS "duplicate on lag" loop
/// (FramePump) emits 300 authored frames per 5s whether the renderer managed 300
/// composites or 84. The recorded file therefore cannot name the stage that lost
/// the frames; only these counters can. The same counter run twice tells the two
/// apart: the camera's OWN timestamps vs our ARRIVAL timestamps. Steady camera
/// stamps with ragged arrivals means WE dropped it (compositor/reader); ragged in
/// both means the camera, USB link, or driver did.
/// </summary>
public sealed class FrameCadence
{
/// <summary>Default hitch threshold in ms. 50ms is ~1.5x a 30fps period — a
/// missed 30fps frame arrives as ~66ms — so this counts dropped frames without
/// flagging ordinary jitter (a 33ms frame can legitimately measure 35ms).</summary>
public const double DefaultHitchThresholdMs = 50;
private readonly double _hitchThresholdMs;
private long _firstTicks;
private long _previousTicks;
private double _gapTicksTotal;
private int _frames;
private int _gaps;
private int _hitches;
private double _longestGapMs;
public FrameCadence(double hitchThresholdMs = DefaultHitchThresholdMs)
{
if (hitchThresholdMs <= 0)
throw new ArgumentOutOfRangeException(nameof(hitchThresholdMs), hitchThresholdMs,
"Hitch threshold must be positive.");
_hitchThresholdMs = hitchThresholdMs;
}
public int Frames => _frames;
public int Gaps => _gaps;
public int Hitches => _hitches;
public double HitchThresholdMs => _hitchThresholdMs;
/// <summary>Records one frame at <paramref name="timestampTicks"/> on the shared
/// monotonic clock. Monotonicity is asserted, not trusted: an out-of-order stamp
/// (a device whose clock domain differs from ours, a wrapped counter, a dropped
/// timestamp) is counted as a frame but produces no gap AND does not become the
/// reference for the next one — otherwise a single bad stamp would poison both
/// its own interval and the one after it, inventing a multi-second "hitch".</summary>
public void Add(long timestampTicks)
{
if (_frames == 0)
{
_firstTicks = timestampTicks;
_previousTicks = timestampTicks;
_frames++;
return;
}
var delta = timestampTicks - _previousTicks;
_frames++;
if (delta <= 0) return;
var gapMs = delta * 1000d / Stopwatch.Frequency;
_gaps++;
_gapTicksTotal += gapMs;
if (gapMs > _longestGapMs) _longestGapMs = gapMs;
if (gapMs >= _hitchThresholdMs) _hitches++;
_previousTicks = timestampTicks;
}
public FrameCadenceSummary Summarize()
{
var spanMs = _frames >= 2
? (_previousTicks - _firstTicks) * 1000d / Stopwatch.Frequency
: 0d;
// Rate is gaps-per-span, not frames-per-span: N frames span N-1 periods, and
// counting the extra interval would report one extra frame per window.
var rate = _gaps > 0 && spanMs > 0 ? _gaps / (spanMs / 1000d) : 0d;
return new FrameCadenceSummary(
_frames, rate, spanMs, _gaps, _hitches, _longestGapMs,
_gaps > 0 ? _gapTicksTotal / _gaps : 0d, _hitchThresholdMs);
}
/// <summary>Starts a fresh window, keeping the configured threshold.</summary>
public void Reset()
{
_firstTicks = 0;
_previousTicks = 0;
_gapTicksTotal = 0;
_frames = 0;
_gaps = 0;
_hitches = 0;
_longestGapMs = 0;
}
}
/// <summary>One measured cadence window — see <see cref="FrameCadence"/>.</summary>
public readonly record struct FrameCadenceSummary(
int Frames,
double RatePerSecond,
double SpanMs,
int Gaps,
int Hitches,
double LongestGapMs,
double AverageGapMs,
double HitchThresholdMs)
{
/// <summary>One-line log form. Identical in shape from every producer (camera
/// source, frame pump) so two lines can be read against each other: same window
/// length, same fields, same order.</summary>
public string Describe(string label) => Frames == 0
? $"{label} no frames"
: $"{label} {Frames}f/{SpanMs / 1000d:F1}s {RatePerSecond:F1}/s, {Gaps} gaps, " +
$"{Hitches} >= {HitchThresholdMs:F0}ms, avg {AverageGapMs:F1}ms, longest {LongestGapMs:F1}ms";
}
+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;
}
+44
View File
@@ -0,0 +1,44 @@
using System;
namespace ytLive.Helpers;
/// <summary>The fader scale for the audio dock: the model stores LINEAR gain (0..1), the dock
/// shows and edits DECIBELS, because "−6 dB" means something to a person and "0.50" does not.
///
/// <para>Range: −∞ (silence) to +6 dB of headroom above unity. 0 dB is unity gain, which is what
/// the old 1.0 meant — so a persisted 0.8 comes back as ≈ −1.9 dB and nothing about the stored
/// data changes.</para>
///
/// <para>Creator context (2026-10-30): nearly deaf on the right, so the fader number is read, not
/// heard. The scale therefore has to be the true one, not a cosmetic curve.</para></summary>
public static class GainDb
{
/// <summary>Silence floor. Anything at or below this reads as −∞ and writes as 0 gain.</summary>
public const double FloorDb = -60;
public const double MaxDb = 6;
/// <summary>Linear gain → dB, clamped to the fader's range. Zero becomes <see cref="FloorDb"/>.</summary>
public static double ToDb(double gain)
{
if (gain <= 0) return FloorDb;
var db = 20 * Math.Log10(gain);
return Math.Clamp(db, FloorDb, MaxDb);
}
/// <summary>dB → linear gain. At the floor the result is exactly 0 (true silence), so the
/// bottom of the fader cannot leave a residue of noise.</summary>
public static double FromDb(double db)
{
if (db <= FloorDb) return 0;
return Math.Pow(10, Math.Clamp(db, FloorDb, MaxDb) / 20);
}
/// <summary>"−∞", "−6.0", "+1.5" — the label beside a fader.</summary>
public static string Label(double db) =>
db <= FloorDb ? "−∞" : (db > 0 ? "+" : string.Empty) + db.ToString("0.0");
/// <summary>Mono fold: the accessible downmix. Averages the pair so hard-panned content is
/// audible on both sides at half amplitude instead of absent from one.</summary>
public static float MonoFold(float left, float right) => (left + right) * 0.5f;
}
+32
View File
@@ -0,0 +1,32 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace ytLive.Helpers;
/// <summary>Turns a "#RRGGBB" hex string into a brush, so a template can paint a colour
/// swatch straight from the palette (TASK 63).
///
/// <para>Without it the UI would have to hand-build a brush in code — the hand-painted
/// approach the theme forbids. A colour is data; the theme paints it.</para></summary>
public class HexToBrushConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string hex || string.IsNullOrWhiteSpace(hex))
return Brushes.Transparent;
try
{
var colour = (Color)ColorConverter.ConvertFromString(hex.Trim());
return new SolidColorBrush(colour);
}
catch (FormatException)
{
return Brushes.Transparent;
}
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("A brush is not a hex value.");
}
+89
View File
@@ -0,0 +1,89 @@
using System;
using System.IO;
namespace ytLive.Helpers;
/// <summary>
/// Which run of LlamaCasty this process is, and where its private state lives.
/// <para><b>Dev-only affordance (creator directive, 2026-09-26).</b> Pre-1.0 the creator
/// needs two instances side by side: one streaming, one screen-capturing the other.
/// Two instances on one Windows profile collide on state, and two of those collisions
/// are hard failures rather than annoyances:
/// <list type="bullet">
/// <item><b>The SQLite layout DB.</b> The app's model is read-whole-scene / write-whole-scene,
/// so two instances saving different layouts clobber each other.</item>
/// <item><b>The WebView2 user data folder.</b> Chromium takes an exclusive lock on it; the
/// second instance of the same exe simply fails to start.</item>
/// <item><b>The auth token store.</b> A test instance would overwrite the real YouTube
/// sign-in.</item>
/// <item><b>startup.log.</b> Two appenders interleave into one file, so a crash in either
/// instance is ambiguous.</item>
/// </list>
/// So in <c>DEBUG</c> a process can claim an identity and gets its own private root
/// under <c>instances\&lt;id&gt;</c>: <c>$env:YTLIVE_INSTANCE=2; dotnet run</c>.
/// Recording, screen capture and the ffmpeg <c>tools\</c> cache are deliberately LEFT
/// shared — the cache should be shared, and the creator chooses the record folder.
/// Global hotkeys are also left alone: if both instances register the same one, Windows
/// refuses the second, which is the correct answer.</para>
/// <para><b>At 1.0 this disappears.</b> The whole implementation is inside
/// <c>#if DEBUG</c>; a Release build compiles to the two pass-throughs at the bottom —
/// no environment variable, no alternate folder, not even the variable's NAME in the
/// binary. Call sites are unconditional, so Release cannot accidentally diverge: it
/// always resolves the one real user profile.</para>
/// </summary>
public static class InstanceProfile
{
#if DEBUG
/// <summary>Environment variable naming this instance. Absent or blank = the primary
/// instance on the real user profile.</summary>
public const string InstanceVariable = "YTLIVE_INSTANCE";
/// <summary>This instance's id, or null for the primary instance.</summary>
public static string? Id => Sanitize(Environment.GetEnvironmentVariable(InstanceVariable));
/// <summary>Private state root: the real profile normally, a per-instance subfolder
/// when an id is claimed.</summary>
public static string DataRoot => Id is { } id
? Path.Combine(DefaultRoot, "instances", id)
: DefaultRoot;
/// <summary>Chromium's user data folder, or null to let WebView2 pick its default
/// (which is what Release always does). Must be per-instance or the second
/// instance dies on Chromium's exclusive lock.</summary>
public static string? WebViewDataFolder => Id is null ? null : Path.Combine(DataRoot, "webview2");
/// <summary>Reject anything that is not a plain path segment, so the variable can
/// never be used to walk out of the profile directory or name a UNC path.</summary>
private static string? Sanitize(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var trimmed = raw.Trim();
foreach (var c in trimmed)
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') return null;
return trimmed;
}
#else
// Release: one profile, no alternate paths, no environment variable. Multi-instance
// is a development affordance, not a shipping feature.
public static string DataRoot => DefaultRoot;
public static string? WebViewDataFolder => null;
#endif
/// <summary>The 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(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive");
}
+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();
}
+63
View File
@@ -0,0 +1,63 @@
using System.IO;
namespace ytLive.Helpers;
/// <summary>
/// Size-capped log rotation.
/// <para>When a log file grows past a cap, it is rolled to <c>&lt;name&gt;.1.log</c> and a
/// fresh one is started, keeping a bounded number of rolled files. The pattern is the
/// one every sized logger uses — Serilog's file sink pairs <c>fileSizeLimitBytes</c>
/// (the cap) with <c>retainedFileCountLimit</c> (how many rolled files to keep):
/// https://github.com/serilog/serilog-sinks-file</para>
/// <para><b>Why it lives here and not in the logger:</b> <see cref="AppLog"/>'s call surface
/// (156 call sites) must not change, and the rotation rule is worth testing on its own —
/// so the policy is a pure function over a path, and <see cref="AppLog"/> just calls it.</para>
/// </summary>
public static class LogRotation
{
/// <summary>
/// Rolls <paramref name="logPath"/> if it has reached <paramref name="maxBytes"/>,
/// keeping <paramref name="retainedFiles"/> rolled copies
/// (<c>name.1.log</c>, <c>name.2.log</c>, …). Does nothing when the file is missing
/// or still under the cap.
/// <para>Never throws: a failed rotation must not take the app down — the log just
/// keeps growing, which is the old behaviour.</para>
/// </summary>
public static void RollIfNeeded(string logPath, long maxBytes, int retainedFiles)
{
try
{
var info = new FileInfo(logPath);
if (!info.Exists || info.Length < maxBytes)
{
return;
}
// Oldest first: name.1 → name.2 → … → name.N, then the live file → name.1.
for (var i = retainedFiles; i >= 1; i--)
{
var source = i == 1 ? logPath : RolledPath(logPath, i - 1);
var target = RolledPath(logPath, i);
if (!File.Exists(source))
{
continue;
}
File.Move(source, target, overwrite: true);
}
}
catch
{
// Never let logging itself crash the app.
}
}
/// <summary>The rolled path for a given index: <c>startup.log</c> → <c>startup.1.log</c>.</summary>
public static string RolledPath(string logPath, int index)
{
var directory = Path.GetDirectoryName(logPath) ?? string.Empty;
var name = Path.GetFileNameWithoutExtension(logPath);
var extension = Path.GetExtension(logPath);
return Path.Combine(directory, $"{name}.{index}{extension}");
}
}
+68
View File
@@ -0,0 +1,68 @@
using System;
namespace ytLive.Helpers;
/// <summary>Which colour band a level falls in (DESIGN.md meter thresholds).</summary>
public enum MeterBand
{
Nominal,
Caution,
Peak,
}
/// <summary>The mathematics of the dock's segment meter, kept out of the control so it can be
/// tested: a segment ladder is only honest if the band it paints is the level it measured.
///
/// <para>Bands follow DESIGN.md: nominal to −12 dB, caution to −3 dB, peak above. The display
/// fraction spreads −60…0 dBFS across 0…1 — the same scale <c>AudioLevelMeter.ToDisplay</c> uses,
/// so the ladder and the old bars cannot disagree.</para></summary>
public static class MeterScale
{
public const float FloorDb = -60f;
public const float CautionDb = -12f;
public const float PeakDb = -3f;
/// <summary>dBFS → 0…1 across the whole ladder.</summary>
public static float ToFraction(float dbfs)
=> Math.Clamp(1f + Math.Clamp(dbfs, FloorDb, 0f) / 60f, 0f, 1f);
/// <summary>The band boundaries are INCLUSIVE at the bottom edge (a meter reading exactly
/// −12.0 dB is cautious, exactly −3.0 is peaking), matching the ranges as DESIGN.md states
/// them: "−12 … −3" and "−3 … 0+".</summary>
public static MeterBand BandFor(float dbfs)
=> dbfs >= PeakDb ? MeterBand.Peak
: dbfs >= CautionDb ? MeterBand.Caution
: MeterBand.Nominal;
/// <summary>The master ladder when no session is running.
///
/// <para>`AudioMixer.MasterLevel` measures the buffer the encoder receives, and that buffer only
/// exists while the mix is being pulled (streaming or recording). Idle, the master would read
/// zero while the source ladders move — so the monitor level is computed from the same measured
/// sources and the same gains the mix applies. It is an estimate of the mix, not a substitute
/// for it: once a session starts, the real measured value takes over.</para></summary>
public static float MonitorFraction(float micLevel, double micGain, float gameLevel, double gameGain)
{
var linear = Math.Max(0f, (float)(micLevel * micGain) + (float)(gameLevel * gameGain));
if (linear <= 0.001f) return 0f;
return ToFraction(20f * MathF.Log10(linear));
}
/// <summary>Ballistics for a level: reach a new peak quickly, fall back gently. Raw per-block
/// peaks sampled by a UI timer look like a stutter; this is what makes the mic feel fluid, and
/// TRAX gets the same curve so the two ladders move alike.</summary>
public static float NextLevel(float current, float incoming, float attack = 0.6f, float decay = 0.12f)
{
var alpha = incoming > current ? attack : decay;
return current + (incoming - current) * alpha;
}
/// <summary>Peak hold: jumps up instantly to a new maximum and falls back at
/// <paramref name="decayPerSecond"/> — a moment-to-moment bar hides clipping, which is the one
/// audio fault that cannot be fixed afterwards.</summary>
public static float NextPeak(float held, float incoming, float seconds, float decayPerSecond = 0.35f)
{
if (incoming >= held) return incoming;
return Math.Max(0f, held - decayPerSecond * Math.Max(0f, seconds));
}
}
+34
View File
@@ -0,0 +1,34 @@
using System;
using System.Windows.Input;
namespace ytLive.Helpers;
public class RelayCommand<T> : ICommand
{
private readonly Action<T?> _execute;
private readonly Predicate<T?>? _canExecute;
public RelayCommand(Action<T?> execute, Predicate<T?>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
public bool CanExecute(object? parameter)
{
if (parameter is T t) return _canExecute?.Invoke(t) ?? true;
return _canExecute?.Invoke(default) ?? true;
}
public void Execute(object? parameter)
{
if (parameter is T t) _execute(t);
else _execute(default);
}
}
+204
View File
@@ -0,0 +1,204 @@
using System;
using System.Threading;
namespace ytLive.Helpers;
/// <summary>
/// A fixed pool of four DEDICATED background threads at <see cref="ThreadPriority.BelowNormal"/>
/// that split one composite into horizontal row strips. The compositor needs the split to get
/// render inside a 16.67ms slot, but it must not compete with the encoder, the WGC capture
/// callbacks, the WebView2 chat or the game the user is playing — hence the hard cap of four
/// and the demotion.
///
/// Research (2026-10-05, Unit 5): the demotion must NOT be done by poking
/// <c>Thread.CurrentThread.Priority</c> inside <c>Parallel.For</c> — the pool is shared, so the
/// demotion leaks into every unrelated work item that thread later runs
/// (https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority:
/// "you have next to no control of which thread pool thread will actually run your task");
/// https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool:
/// "You shouldn't mess with the priority of the regular ThreadPool, since you aren't the only
/// consumer ... write your own producer/consumer queue, using your own Thread(s) as the
/// consumer(s) — setting the thread priority yourself"). A real failed attempt confirmed it: a
/// full test run after demoting the pool threads broke an unrelated audio-mix timing test.
/// This class IS that producer/consumer queue; the caller thread is the producer and also runs
/// one strip itself, so four workers never means five-way oversubscription.
///
/// Contract: <see cref="For"/> blocks until every strip has run, each strip must write only its
/// own rows (the compositor's blits already partition by row), and it must not be called from
/// inside a strip (the blits never nest).
/// </summary>
internal static class StripPool
{
/// <summary>Four workers: the measured sweet spot on this box. Fewer leaves render over
/// budget; more steals cycles from the game and the encoder and made render worse, not better.</summary>
public const int Workers = 4;
/// <summary>Below this many rows the fan-out costs more than it saves — run it inline.</summary>
private const int MinRowsForFanOut = 64;
private static readonly object[] _queueLocks = CreateLocks();
private static readonly Queue<Strip>[] _queues = CreateQueues();
private static readonly Queue<Strip>[] _free = CreateQueues();
private static readonly SemaphoreSlim[] _signals = CreateSignals();
private static Thread[]? _threads;
private static int _roundRobin = -1;
private sealed class Strip
{
public int From;
public int To; // exclusive
public Action<int>? Body;
public Batch? Owner;
}
private sealed class Batch
{
public int Remaining;
public Exception? Error;
}
/// <summary>Run <paramref name="body"/> for every row in [from, to), split across the pool.</summary>
public static void For(int from, int to, Action<int> body)
{
if (from >= to) return;
var rows = to - from;
if (rows < MinRowsForFanOut || !EnsureStarted())
{
for (var y = from; y < to; y++) body(y);
return;
}
// Strips are handed to whole workers (not per-row tasks) so the strip boundaries —
// and therefore the destination row each strip owns — are fixed before any of them run.
var strips = Math.Min(Workers, rows);
var chunk = (rows + strips - 1) / strips;
var batch = new Batch { Remaining = strips - 1 }; // the caller runs the last one
var first = from;
for (var k = 0; k < strips - 1; k++)
{
var end = Math.Min(first + chunk, to);
var w = NextWorker();
var strip = Rent(w);
strip.From = first;
strip.To = end;
strip.Body = body;
strip.Owner = batch;
lock (_queueLocks[w]) _queues[w].Enqueue(strip);
_signals[w].Release();
first = end;
}
try
{
for (var y = first; y < to; y++) body(y);
}
catch (Exception ex)
{
batch.Error ??= ex;
}
if (batch.Remaining > 0)
{
lock (batch)
{
while (batch.Remaining > 0) Monitor.Wait(batch);
}
}
if (batch.Error != null) throw new InvalidOperationException("A compositor strip failed.", batch.Error);
}
private static void Worker(int index)
{
var signal = _signals[index];
while (true)
{
signal.Wait();
Strip? strip = null;
lock (_queueLocks[index])
{
if (_queues[index].Count > 0) strip = _queues[index].Dequeue();
}
if (strip == null) continue; // released for an already-drained queue
var batch = strip.Owner!;
try
{
for (var y = strip.From; y < strip.To; y++) strip.Body!(y);
}
catch (Exception ex)
{
batch.Error ??= ex;
}
finally
{
strip.Body = null;
strip.Owner = null;
lock (_queueLocks[index]) _free[index].Enqueue(strip);
lock (batch)
{
if (--batch.Remaining == 0) Monitor.PulseAll(batch);
}
}
}
}
private static Strip Rent(int worker)
{
lock (_queueLocks[worker])
return _free[worker].Count > 0 ? _free[worker].Dequeue() : new Strip();
}
private static int NextWorker()
{
var w = Interlocked.Increment(ref _roundRobin) % Workers;
return w < 0 ? w + Workers : w;
}
/// <summary>Start the workers on first use, so a WPF cold start pays nothing for them.</summary>
private static bool EnsureStarted()
{
if (_threads != null) return true;
lock (_signals)
{
if (_threads != null) return true;
var threads = new Thread[Workers];
for (var i = 0; i < Workers; i++)
{
var index = i; // capture per-iteration: the loop variable would be Workers by the time the thread runs
var t = new Thread(() => Worker(index), 256 * 1024)
{
IsBackground = true,
Name = $"compositor-strip-{i}",
Priority = ThreadPriority.BelowNormal,
};
threads[i] = t;
t.Start();
}
_threads = threads;
return true;
}
}
private static object[] CreateLocks()
{
var l = new object[Workers];
for (var i = 0; i < Workers; i++) l[i] = new object();
return l;
}
private static Queue<Strip>[] CreateQueues()
{
var q = new Queue<Strip>[Workers];
for (var i = 0; i < Workers; i++) q[i] = new Queue<Strip>();
return q;
}
private static SemaphoreSlim[] CreateSignals()
{
var s = new SemaphoreSlim[Workers];
for (var i = 0; i < Workers; i++) s[i] = new SemaphoreSlim(0, int.MaxValue);
return s;
}
}
+40
View File
@@ -0,0 +1,40 @@
using System.Windows;
using System.Windows.Media;
namespace ytLive.Helpers;
/// <summary>Resolves a design token for code that cannot bind — view-model state colours (the
/// top bar, the health banner, the meters, toast tints). The values live once, in
/// <c>Themes/Tokens.xaml</c>; this reads them from the application resources.
///
/// <para>Why not a Brush field per colour: a second list in C# would drift from the theme. Why not
/// OutputPalette: that one exists for the frame-pump thread, which must not touch resources; these
/// properties are read by bindings on the UI thread.</para>
///
/// <para>Without an Application (unit tests, design-time) a neutral grey comes back: visibly wrong
/// rather than silently plausible, and never a crash.</para></summary>
internal static class ThemeBrush
{
private static readonly SolidColorBrush Missing = Frozen(Color.FromRgb(0x80, 0x80, 0x80));
/// <summary>The brush for a token key, e.g. <c>ThemeBrush.Get("Brush.Primary")</c>.</summary>
public static SolidColorBrush Get(string key)
=> Application.Current?.TryFindResource(key) as SolidColorBrush ?? Missing;
/// <summary>The token's colour as a browser-style hex string, for APIs that take one
/// (Notification.Wpf's <c>NotificationColor.FromHex</c>, for instance).</summary>
public static string Hex(string key)
{
var c = Get(key).Color;
return c.A == 255
? $"#{c.R:x2}{c.G:x2}{c.B:x2}"
: $"#{c.A:x2}{c.R:x2}{c.G:x2}{c.B:x2}";
}
private static SolidColorBrush Frozen(Color colour)
{
var brush = new SolidColorBrush(colour);
brush.Freeze();
return brush;
}
}
+1 -3
View File
@@ -14,9 +14,7 @@ namespace ytLive.Helpers;
public static class TokenStore
{
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
"ytLlive.auth");
InstanceProfile.DataRoot, "ytLlive.auth");
public static void Save(YouTubeChannel channel, string? path = null)
{
+6 -1
View File
@@ -6,7 +6,8 @@ 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) |
| `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) |
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
@@ -14,6 +15,10 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
| `AllTrueToVisibilityConverter.cs` | Multi-value AND-gate → Visible; gates the layer-row capture menu items on row-IsBackground + staged-scene-is-Live (TASK 25) |
| `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict |
| `StripPool.cs` | Four DEDICATED background threads at `BelowNormal` priority that split one composite into horizontal row strips (the caller runs a strip too); started lazily, runs inline below 64 rows. UNIT 5 — do NOT demote the ThreadPool from `Parallel.For`, it leaks into unrelated work (see [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Render budget") |
| `FrameCadence.cs` | Pure arrival-cadence counter (`FrameCadence` + `FrameCadenceSummary.Describe`): frames, gaps, hitches, longest/avg gap for a window of a timestamped stream. Fed by `MediaCaptureFrameSource` (camera stamps vs our arrivals) and `FramePump` (distinct webcam Epochs that reached the output) — UNIT 0, see [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Frame cadence" |
| `HexToBrushConverter.cs` | A "#RRGGBB" hex string → `SolidColorBrush` (transparent on bad input), so a template can paint a colour swatch straight from the palette. Registered in the theme as `HexToBrush` — the theme paints the colour, never the UI code (TASK 63) |
| `ClosedBoxTemplateSelector.cs` | An `ItemTemplateSelector` that gives a ComboBox's CLOSED box a different template from its drop-down rows: WPF calls it with the `ContentPresenter`'s templated parent — the `ComboBox` for the closed box, the `ComboBoxItem` for a row. The only way to tell them apart (`SelectionBoxItemTemplate` is read-only and mirrors `ItemTemplate`) — TASK 63 |
Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
class backs; [`Models/index.md`](../Models/index.md) and
+10 -10
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.HotkeyConfigDialog"
<Window x:Class="ytLive.HotkeyConfigDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Global Hotkey Bindings" Height="520" Width="480"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
@@ -20,7 +20,7 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Global Hotkey Bindings" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,12"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,12"/>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Rows}">
@@ -34,7 +34,7 @@
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding ActionName}"
Foreground="#e0e0e0" FontSize="13"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Grid.Column="1" Click="ChordButton_Click"
@@ -42,8 +42,8 @@
HorizontalAlignment="Left" MinWidth="140"
Padding="10,5" FontSize="13"
VerticalAlignment="Center">
<Border Background="#16213e" CornerRadius="4" Padding="10,5">
<TextBlock Text="{Binding DisplayText}" Foreground="#e0e0e0"
<Border Background="{StaticResource Brush.SurfaceContainer}" CornerRadius="4" Padding="10,5">
<TextBlock Text="{Binding DisplayText}" Foreground="{StaticResource Brush.OnSurface}"
FontSize="13"/>
</Border>
</Button>
@@ -51,7 +51,7 @@
<Button Grid.Column="2" Content="✕" Click="UnbindButton_Click"
Tag="{Binding}" Cursor="Hand"
ToolTip="Remove binding"
FontSize="12" Foreground="#8a93a8"
FontSize="12" Foreground="{StaticResource Brush.OnSurfaceVariant}"
Background="Transparent" BorderThickness="0"
Padding="6,4" VerticalAlignment="Center">
<Button.Style>
@@ -71,8 +71,8 @@
</ScrollViewer>
<!-- Conflict warning: hidden when no message -->
<Border Grid.Row="2" Background="#3d1a1a" CornerRadius="4" Padding="10,8"
Margin="0,8,0,0" BorderBrush="#e94560" BorderThickness="1">
<Border Grid.Row="2" Background="{StaticResource Brush.ErrorContainer}" CornerRadius="4" Padding="10,8"
Margin="0,8,0,0" BorderBrush="{StaticResource Brush.Error}" BorderThickness="1">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
@@ -82,7 +82,7 @@
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding ConflictMessage}" Foreground="#e94560"
<TextBlock Text="{Binding ConflictMessage}" Foreground="{StaticResource Brush.Error}"
FontSize="12" TextWrapping="Wrap"/>
</Border>
+31
View File
@@ -0,0 +1,31 @@
<Window x:Class="ytLive.KeepBroadcastDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Keep this broadcast?" Height="240" Width="460"
Icon="/LlamaCasty;component/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Keep this broadcast?" FontSize="18" FontWeight="Bold"
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,12"/>
<TextBlock Grid.Row="1" x:Name="MessageText" TextWrapping="Wrap" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"
Margin="0,0,0,16"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="DeleteBroadcastButton" Content="Delete" IsCancel="True" Click="Delete_Click"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"
ToolTip="Esc — delete the recording from YouTube"/>
<Button x:Name="KeepBroadcastButton" Content="Keep" IsDefault="True" Click="Keep_Click"
Style="{StaticResource YtButton}" MinWidth="90"
ToolTip="Enter — keep it on YouTube as a private video"/>
</StackPanel>
</Grid>
</Window>
+25
View File
@@ -0,0 +1,25 @@
using System.Windows;
namespace ytLive;
/// <summary>Asked when a PRIVATE broadcast ends — the same contract as the recording's save
/// dialog (creator ruling 2026-10-09): <b>Enter keeps</b> it on YouTube as a private video,
/// <b>Escape deletes</b> it. <c>DialogResult == true</c> = keep; false (Delete, Escape, the X)
/// = delete.</summary>
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?";
Loaded += (_, _) => KeepBroadcastButton.Focus();
}
private void Keep_Click(object sender, RoutedEventArgs e) => DialogResult = true;
private void Delete_Click(object sender, RoutedEventArgs e) => DialogResult = false;
}
+68
View File
@@ -0,0 +1,68 @@
LlamaCasty — Proprietary Software License (EULA)
Copyright (C) 2026 Gramps. All rights reserved.
This is a commercial licence for the LlamaCasty desktop application ("the
Software"). It replaces the GNU General Public License that earlier development
builds carried; those earlier builds remain under the licence they shipped
with, and nothing here revokes rights already granted for them.
1. LICENCE GRANT
Subject to payment of the applicable fee and to this agreement, you are
granted a personal, non-exclusive, non-transferable licence to install and
use the Software on machines you own or control, for your own streaming
and recording. A lifetime licence is granted for the life of the product;
a subscription licence lasts while the subscription is paid.
2. WHAT YOU MAY DO
- Use the Software to produce and monetise your own streamed or recorded
content, including commercially, without any further payment or royalty.
- Install it on more than one of your own machines.
- Make copies for backup.
3. WHAT YOU MAY NOT DO
- Redistribute, sublicense, sell, rent, lease or lend the Software, or
make it available to others as a download, on a shared machine, or
bundled with your own product, without written permission.
- Reverse engineer, decompile or disassemble the Software, or remove,
bypass or alter any licence check, except to the extent that applicable
law expressly permits notwithstanding this restriction.
- Remove or obscure copyright, trademark or attribution notices.
- Use the LlamaCasty name or logo to endorse or promote a derived product,
or in a way that suggests affiliation you do not have.
4. THIRD-PARTY COMPONENTS
The Software includes third-party components licensed to you under their
own terms, listed in THIRD-PARTY-NOTICES.txt, which ships with the
Software and is shown in the application's About screen. Those terms
prevail over this licence for those components.
5. UPDATES, SUPPORT AND CHANGES
Updates are provided at the publisher's discretion. Support is provided on
a best-effort basis by email and issue tracker; no service level is
promised. The publisher may change or discontinue features, and may revise
this licence for future versions; the version you installed remains
governed by the licence it shipped with.
6. NO WARRANTY
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT.
7. LIMITATION OF LIABILITY
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE PUBLISHER IS NOT LIABLE FOR ANY
INDIRECT, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES, OR FOR LOST CONTENT,
LOST REVENUE OR LOST PROFITS, ARISING FROM USE OF THE SOFTWARE. THE
PUBLISHER'S TOTAL LIABILITY UNDER THIS AGREEMENT IS LIMITED TO THE AMOUNT
YOU PAID FOR THE LICENCE.
8. TERMINATION
This licence terminates if you breach it. On termination you must stop
using the Software and delete your copies, without refund except where the
law requires one.
9. GOVERNING LAW
This agreement is governed by the laws of the publisher's place of
residence, without regard to conflict-of-law rules. Nothing here removes
rights you have under mandatory consumer law in your own country.
Contact: https://llamacasty.com · llamachile.shop@gmail.com
+87 -31
View File
@@ -11,16 +11,26 @@
mc:Ignorable="d"
Title="{Binding WindowTitle}" Height="1040" Width="1920"
MinWidth="1366" MinHeight="768"
Icon="/ytLive;component/Assets/llamacasty-icon.png"
Background="#1a1a2e"
Icon="/LlamaCasty;component/Assets/llamacasty-icon.png"
Background="{StaticResource Brush.Background}"
FontFamily="{StaticResource FontFamily.Sans}"
WindowStartupLocation="Manual"
Closing="MainWindow_Closing"
Loaded="MainWindow_Loaded"
Activated="MainWindow_Activated"
Deactivated="MainWindow_Deactivated"
PreviewKeyDown="MainWindow_PreviewKeyDown"
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"
ContextMenuOpening="Window_ContextMenuOpening">
<!-- Preview undo/redo (creator directive 2026-11-01). Window-level so the keys work wherever
focus sits; the commands themselves refuse unless EditingActive, because undo with the
preview showing program output would rewrite a scene nobody is looking at. -->
<Window.InputBindings>
<KeyBinding Gesture="Ctrl+Z" Command="{Binding UndoPreviewCommand}"/>
<KeyBinding Gesture="Ctrl+Shift+Z" Command="{Binding RedoPreviewCommand}"/>
</Window.InputBindings>
<Window.DataContext>
<vm:MainViewModel/>
</Window.DataContext>
@@ -31,12 +41,14 @@
<Window.Resources>
<ResourceDictionary>
<Helpers:DroppedFramesToBrushConverter x:Key="DroppedToBrush"/>
<Helpers:DroppedPercentConverter x:Key="DroppedPercent"/>
<!-- Red dot shown in the taskbar icon while live -->
<DrawingImage x:Key="LiveOverlay">
<DrawingImage.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="#e94560">
<GeometryDrawing Brush="{StaticResource Brush.Tertiary}">
<GeometryDrawing.Geometry>
<EllipseGeometry Center="8,8" RadiusX="8" RadiusY="8"/>
</GeometryDrawing.Geometry>
@@ -45,24 +57,10 @@
</DrawingImage.Drawing>
</DrawingImage>
<!-- Eye icon: open when visible, slashed when hidden (source rows) -->
<Style x:Key="EyeIconStyle" TargetType="Path">
<Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Data" Value="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
<Setter Property="ToolTip" Value="Hide Layer"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="ToolTip" Value="Show Layer"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- EyeButton/EyeIconStyle MOVED to Themes/Controls.xaml (2026-09-01): Controls/
LeftPanel.xaml consumes them via StaticResource, and window-scope resources
are invisible to a UserControl at parse time — the "known test failure" that
was actually an app-crash regression from the LeftPanel extraction. -->
</ResourceDictionary>
</Window.Resources>
@@ -86,33 +84,91 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<!-- the dock lives INSIDE the work area so its bottom edge lines up with the
layers panel (creator, 2026-10-30) -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" MinWidth="220" MaxWidth="360"/>
<ColumnDefinition Width="360"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<!-- ═══ THUMBNAIL STRIP ═══ -->
<controls:SceneThumbnailStrip Grid.Row="0" Grid.ColumnSpan="2"/>
<!-- STREAM READOUTS (top-left cell, above the layers) + resolution -->
<Border Grid.Row="0" Grid.Column="0" Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="10,8" Margin="0,0,8,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Bitrate" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,12,2"/>
<TextBlock Grid.Row="0" Grid.Column="1" Foreground="{StaticResource Brush.OnSurface}" FontSize="13" FontFamily="{StaticResource FontFamily.Mono}" HorizontalAlignment="Right" Margin="0,0,0,2">
<Run Text="{Binding CurrentHealth.CurrentBitrate, Mode=OneWay, StringFormat={}{0:0.0}}"/><Run Text=" Mbps"/>
</TextBlock>
<TextBlock Grid.Row="1" Grid.Column="0" Text="FPS" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,12,2"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" FontFamily="{StaticResource FontFamily.Mono}" HorizontalAlignment="Right" Margin="0,0,0,2"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Dropped" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,12,2"/>
<TextBlock Grid.Row="2" Grid.Column="1" FontSize="13" FontFamily="{StaticResource FontFamily.Mono}" HorizontalAlignment="Right" Margin="0,0,0,2"
Foreground="{Binding CurrentHealth.DroppedFrames, Converter={StaticResource DroppedToBrush}}">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource DroppedPercent}">
<Binding Path="CurrentHealth.DroppedFrames" Mode="OneWay"/>
<Binding Path="CurrentHealth.FPS" Mode="OneWay"/>
<Binding Path="CurrentHealth.StreamDuration" Mode="OneWay"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Live:" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,12,2"/>
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding CurrentHealth.StreamDuration, Mode=OneWay, StringFormat={}{0:hh\:mm\:ss}}"
Foreground="{StaticResource Brush.Attention}" FontSize="13" FontFamily="{StaticResource FontFamily.Mono}" FontWeight="SemiBold" HorizontalAlignment="Right" Margin="0,0,0,2"/>
<TextBlock Grid.Row="4" Grid.ColumnSpan="2" Text="{Binding CurrentHealth.HealthMessage}"
Foreground="{StaticResource Brush.Attention}" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,6"/>
<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}"/>
</StackPanel>
</Grid>
</Border>
<!-- ═══ THUMBNAIL STRIP (over the preview column; left column stays empty) ═══ -->
<controls:SceneThumbnailStrip x:Name="SceneStrip" Grid.Row="0" Grid.Column="1" Margin="8,0" Height="154"/>
<!-- LEFT PANEL: one panel, two states.
Offline (edit mode): LAYERS + PROPERTIES
Live/Recording: YouTube Live Chat -->
<controls:LeftPanel x:Name="LeftPanel" Grid.Row="1" Grid.Column="0"/>
<controls:LeftPanel x:Name="LeftPanel" Grid.Row="1" Grid.Column="0" Grid.RowSpan="2"/>
<!-- CENTER: Preview Area -->
<controls:PreviewPane x:Name="PreviewPane" Grid.Row="1" Grid.Column="1"/>
<!-- The dock starts where the layers panel ends and shares its bottom edge. -->
<controls:AudioDock x:Name="AudioDock" Grid.Row="2" Grid.Column="1"/>
</Grid>
<controls:BottomBar Grid.Row="3"/>
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
<controls:OverlayHost Grid.RowSpan="3"/>
<!-- ═══ WEB SOURCE HOST: hidden panel for WebView2 controls ═══ -->
<Canvas x:Name="WebViewHostPanel" Grid.RowSpan="4"
Opacity="0" IsHitTestVisible="False" Background="Transparent"/>
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
<controls:OverlayHost x:Name="AboutOverlay" Grid.RowSpan="3"/>
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
<notifications:NotificationArea x:Name="ToastArea"
+84 -19
View File
@@ -20,10 +20,15 @@ public partial class MainWindow : Window
{
AppLog.Write("MainWindow ctor: before InitializeComponent");
InitializeComponent();
SizeChanged += (_, e) => Helpers.AppLog.Write($"WIDTHS window={e.NewSize.Width:F0} "
+ $"layers={LeftPanel.ActualWidth:F0} strip={SceneStrip.ActualWidth:F0} preview={PreviewPane.ActualWidth:F0}");
AppLog.Write("MainWindow ctor: after InitializeComponent");
_viewModel = (MainViewModel)DataContext;
_viewModel.InitWebView2(WebViewHostPanel);
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
// Don't steal focus from an exclusive-fullscreen game the creator is already
// playing (e.g. while starting a gameplay stream): activating our window would
// drop that game to the taskbar and make the desktop look "locked".
ShowActivated = !_viewModel.FullscreenGameRunningNow;
_topBar = (Controls.TopBar)FindName("TopBar")!;
_previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
_leftPanel = (Controls.LeftPanel)FindName("LeftPanel")!;
@@ -37,33 +42,62 @@ public partial class MainWindow : Window
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
AppLog.Write("MainWindow loaded");
// Web sources need the window HWND as the composition controller's parent,
// which does not exist in the ctor — InitWebView2 runs here (Loaded), after
// SourceInitialized. Nothing renders from web frames before this.
_viewModel.InitWebView2();
}
// ─── Window geometry persistence (launch size/position) ───
// Saved on close (RestoreBounds so a maximized window remembers its
// un-maximize rect); restored in the ctor before the window is shown.
// Values clamp to the minimums and to the primary work area — a position
// saved on a since-disconnected secondary monitor falls back on-screen.
// ─── Window geometry (launch position/size) ───
// Launch POSITION is derived, never restored: upper-left of the work area plus
// padding (creator ruling 2026-10-04). The old restore clamped the saved position
// to [work.Left - width + 120, work.Right - 120] horizontally and
// [work.Top, work.Bottom - 80] vertically — ranges wide enough to leave the window
// almost entirely off-screen, which is what the creator was seeing ("below the
// fold"). Size and maximized state are still persisted. Placement math is pure and
// tested (WindowPlacementTests); changing it back to restore position is reverting
// this block only, since the saved values are still written.
/// <summary>Launch inset from the work-area corner, in device-independent units —
/// so it scales with DPI rather than being a fixed physical pixel count.</summary>
internal const double LaunchPadding = 75;
/// <summary>Upper-left of <paramref name="workArea"/> plus padding, clamped so the
/// window is never pushed off-screen. The clamp is the point: a window that fits
/// stays wholly visible, and one too big to fit falls back to the work-area origin
/// rather than hanging off an edge.</summary>
internal static Point TopLeftPlacement(Rect workArea, double width, double height, double padding)
{
var left = workArea.Left + padding;
var top = workArea.Top + padding;
if (width <= workArea.Width) left = Math.Min(left, workArea.Right - width);
else left = workArea.Left;
if (height <= workArea.Height) top = Math.Min(top, workArea.Bottom - height);
else top = workArea.Top;
return new Point(left, top);
}
private void RestoreWindowGeometry()
{
try
{
var saved = _viewModel.RestoreWindowState();
if (saved == null) return;
var (left, top, width, height, state) = saved.Value;
var work = SystemParameters.WorkArea;
width = Math.Clamp(width, MinWidth, work.Width);
height = Math.Clamp(height, MinHeight, work.Height);
left = Math.Clamp(left, work.Left - width + 120, work.Right - 120);
top = Math.Clamp(top, work.Top, work.Bottom - 80);
var saved = _viewModel.RestoreWindowState();
Left = left;
Top = top;
// No saved state: keep the XAML-declared size rather than collapsing to Min*.
var width = Math.Clamp(saved?.Width ?? Width, MinWidth, work.Width);
var height = Math.Clamp(saved?.Height ?? Height, MinHeight, work.Height);
Width = width;
Height = height;
if (state == "Maximized") WindowState = WindowState.Maximized;
var place = TopLeftPlacement(work, width, height, LaunchPadding);
Left = place.X;
Top = place.Y;
if (saved?.State == "Maximized") WindowState = WindowState.Maximized;
}
catch (Exception ex)
{
@@ -128,6 +162,27 @@ public partial class MainWindow : Window
{
}
/// <summary>
/// Ctrl released/late-pressed while the magnifier is open → the box closes (creator ruling
/// 2026-11-01). This is the "let go and it's gone" half of the zoom gesture; it also means one
/// hand can dismiss without reaching for the mouse. Creating the box still takes Ctrl+right
/// (edit) or a left click (preview), so holding Ctrl to *make* it never cancels it: the key is
/// already down before the box exists.
/// </summary>
private void MainWindow_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
// ⛔ e.IsRepeat matters: Windows auto-repeats a held modifier. Creation holds Ctrl (the
// chord is Ctrl+right-click), so without this guard the repeat tick ~500 ms later saw an
// open box and closed it — the box appeared and vanished as it was created.
if (e.IsRepeat) return;
if (e.Key is System.Windows.Input.Key.LeftCtrl or System.Windows.Input.Key.RightCtrl
&& _viewModel.IsZoomActive)
{
_viewModel.DismissZoomIfActive();
}
}
private void UpdateTaskbarOverlay()
{
TaskbarInfo.Overlay = _viewModel.IsLive
@@ -139,8 +194,18 @@ public partial class MainWindow : Window
{
if (e.OriginalSource is not DependencyObject original) return;
var form = _viewModel.BroadcastForm;
if (form.IsDrawerOpen && !_previewPane.IsClickInsideDrawer(original))
form.CloseDrawerCommand.Execute(null);
var ypp = _viewModel.Ypp;
var test = _viewModel.TestSession;
if (!_previewPane.IsClickInsideDrawer(original))
{
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
// 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;
_viewModel.SelectedElement = null;
+10 -10
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.MicPickerDialog"
<Window x:Class="ytLive.MicPickerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Select Microphone" Height="340" Width="460"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Window.Resources>
<ResourceDictionary>
@@ -21,10 +21,10 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -43,10 +43,10 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Select Microphone" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,14"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,14"/>
<TextBlock Grid.Row="1" Text="Pick the microphone to use as your voice source."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
<Grid Grid.Row="2">
<ListBox Background="Transparent" BorderThickness="0"
@@ -58,7 +58,7 @@
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="🎙️" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="{Binding Name}" Foreground="#e0e0e0" FontSize="13"
<TextBlock Text="{Binding Name}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
@@ -67,15 +67,15 @@
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Searching for microphones…" Foreground="#a0a0b0" FontSize="13"/>
<TextBlock Text="Searching for microphones…" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"/>
</StackPanel>
<StackPanel VerticalAlignment="Center"
Visibility="{Binding NoMicrophones, Converter={StaticResource BoolToVis}}">
<TextBlock Text="No microphones found" Foreground="#e0e0e0" FontSize="15"
<TextBlock Text="No microphones found" Foreground="{StaticResource Brush.OnSurface}" FontSize="15"
FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Check that a microphone is connected and that Windows microphone access is enabled."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
</StackPanel>
</Grid>
+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,
}
+28
View File
@@ -0,0 +1,28 @@
namespace ytLive.Models;
/// <summary>How tall the audio dock should be for a given window height.
///
/// <para>Creator ruling (2026-10-30): the dock is 148px on a normal window and collapses to a 36px
/// telemetry strip when the window is short, because at 1366×768 a full dock leaves ~398px for the
/// preview and the composition starts letterboxing into a postage stamp.</para>
///
/// <para>2026-11-01: the master strip grew from 36 to 44 so its meter could be the biggest one on
/// screen (centred under the preview), so both constants moved with it — 156 = 112 bays + 44.</para>
///
/// <para>Pure, so the switch is testable without a window.</para></summary>
public static class AudioDockLayout
{
/// <summary>Below this window height the dock collapses. The app's minimum is 768; the
/// default window is 1040, so a normal launch shows the full dock.</summary>
public const double CollapseBelowHeight = 900;
/// <summary>148 = 112 of bays + the 36px master strip. The mockup wanted 168; the creator
/// read that as wasted space under a 154px scene strip, so the bays lost 20px of air.</summary>
public const double ExpandedHeight = 156;
public const double CollapsedHeight = 44;
public static bool IsCollapsed(double windowHeight) => windowHeight < CollapseBelowHeight;
public static double HeightFor(double windowHeight)
=> IsCollapsed(windowHeight) ? CollapsedHeight : ExpandedHeight;
}
+11
View File
@@ -0,0 +1,11 @@
namespace ytLive.Models;
/// <summary>Per-webcam background effect (TASK 51). <see cref="Remove"/> makes everything
/// outside the person transparent so the layers below show through; <see cref="Blur"/>
/// blurs the background behind the person.</summary>
public enum BackgroundEffect
{
None,
Remove,
Blur,
}
+72
View File
@@ -0,0 +1,72 @@
namespace ytLive.Models;
/// <summary>A colour the border picker offers: its hex value and the plain-English
/// name shown next to it (TASK 60).</summary>
/// <param name="Hex">The hex value, e.g. <c>#e94560</c>.</param>
/// <param name="Name">The name the creator reads, e.g. <c>Crimson</c>.</param>
public readonly record struct NamedColor(string Hex, string Name)
{
/// <summary>"Crimson (#e94560)" — the tooltip the border drop-down shows.</summary>
public override string ToString()
=> string.IsNullOrEmpty(Name) ? Hex : $"{Name} ({Hex})";
}
/// <summary>The colours the webcam's border-colour drop-down offers — the SINGLE
/// source of truth for the list and for the names.
///
/// <para>TASK 60 (the creator, 2026-10-21): *"in the drop down box, the #hex values are
/// good, but add a corresponding color name/label next to the value."* The list used to
/// be a hard-coded array in the click handler, which is why a name could not be added
/// to it without touching the UI code.</para>
/// </summary>
public static class BorderColorPalette
{
/// <summary>The offered colours, in menu order.</summary>
public static IReadOnlyList<NamedColor> All { get; } = new NamedColor[]
{
new("#FFFFFF", "White"),
new("#000000", "Black"),
new("#FF0000", "Red"),
new("#00FF00", "Lime"),
new("#0000FF", "Blue"),
new("#FFFF00", "Yellow"),
new("#FF00FF", "Magenta"),
new("#00FFFF", "Cyan"),
new("#FF8800", "Orange"),
new("#8800FF", "Violet"),
new("#e94560", "Crimson"),
new("#22c55e", "Emerald"),
new("#3b82f6", "Azure"),
new("#f59e0b", "Amber"),
};
/// <summary>The colour's name, or an **empty string** when the palette has no name
/// for that hex (a custom hex the creator typed) — the caller decides what to show
/// then, and the border field shows the hex alone.</summary>
public static string NameOf(string hex) => EntryFor(hex).Name;
/// <summary>The palette entry for a hex value — the palette's OWN entry (so a
/// drop-down bound to it matches by value), or a nameless entry for a custom hex
/// the creator typed, so the drop-down always has a selection (TASK 63).</summary>
public static NamedColor EntryFor(string hex)
{
if (string.IsNullOrWhiteSpace(hex)) return new NamedColor(string.Empty, string.Empty);
var trimmed = hex.Trim();
foreach (var colour in All)
if (string.Equals(colour.Hex, trimmed, StringComparison.OrdinalIgnoreCase))
return colour;
return new NamedColor(trimmed, string.Empty);
}
/// <summary>The colours a border drop-down offers for a given hex — the palette, plus
/// the custom colour the creator typed when it is not one of the palette's, so the
/// drop-down always has a selection (TASK 63).</summary>
public static IReadOnlyList<NamedColor> OptionsFor(string hex)
{
var entry = EntryFor(hex);
var options = new List<NamedColor>(All);
if (!string.IsNullOrEmpty(entry.Hex) && !All.Contains(entry))
options.Add(entry);
return options;
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace ytLive.Models;
/// <summary>
/// The one entitlement rule, as pure policy: what a build is allowed to emit.
///
/// <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
/// after an error.</para>
///
/// <para><b>This is not a feature gate.</b> Every feature, scene, overlay and the recording
/// itself stay available; only the emitted frame gets smaller. Never call it for anything
/// else — a cracked build buys almost nothing, which is the entire anti-piracy posture.</para>
/// </summary>
public static class OutputProfile
{
/// <summary>Free ceiling: 30 frames per second.</summary>
public const int FreeMaxFps = 30;
/// <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.Fps <= FreeMaxFps;
/// <summary>
/// The tier a session will actually encode. A permitted selection passes through
/// untouched (so Pro and the free tier's own 720p30 keep their exact bitrate); a
/// disallowed one resolves to the best tier the budget allows — most pixels first, then
/// most frames, then the cheapest bitrate — because silently encoding something the user
/// did not pick is only acceptable if it is the closest legal thing to it.
/// </summary>
/// <param name="requested">What the dropdown says.</param>
/// <param name="shipped">Every tier the build offers.</param>
/// <param name="pro">Entitlement, already resolved. Unknown must be passed as false.</param>
public static QualityOption Resolve(
QualityOption requested, IEnumerable<QualityOption> shipped, bool pro)
{
if (IsAllowed(requested, pro)) return requested;
var candidate = shipped
.Where(tier => IsAllowed(tier, pro))
.OrderByDescending(tier => tier.Width * tier.Height)
.ThenByDescending(tier => tier.Fps)
.ThenBy(tier => tier.Bitrate)
.FirstOrDefault();
// 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, 8.0, 1920, 1080);
}
}
+115
View File
@@ -0,0 +1,115 @@
namespace ytLive.Models;
/// <summary>
/// Undo/redo for what the creator does in the preview: move, resize, reorder, add, remove, hide.
///
/// <para><b>Snapshots hold element REFERENCES, not copies.</b> The model has a dozen subclasses
/// with dozens of properties each; a deep clone would be a large, fragile mirror of every one of
/// them, and it would silently miss the next field someone adds. Holding the live instances and
/// recording the values the preview can change (geometry, visibility, order, and which elements a
/// scene contains) means a new model property cannot break undo — it simply is not part of this
/// stack's contract.</para>
///
/// <para><b>Scope, stated plainly:</b> this covers layout edits. Property edits (colour, opacity,
/// border, font) are not captured yet — pressing Ctrl+Z after one of those restores the last
/// layout change instead. That is a limitation, not a bug to discover later.</para>
///
/// <para><b>Bounded:</b> <see cref="MaxDepth"/> entries; the oldest falls off. Snapshots are
/// references plus a handful of doubles, so the stack costs kilobytes, not megabytes.</para>
/// </summary>
public sealed class PreviewHistory
{
public const int MaxDepth = 50;
private readonly List<LayoutState> _undo = new();
private readonly List<LayoutState> _redo = new();
public bool CanUndo => _undo.Count > 0;
public bool CanRedo => _redo.Count > 0;
public int UndoDepth => _undo.Count;
public int RedoDepth => _redo.Count;
/// <summary>Record the layout as it is now, before an edit. Clears the redo branch — a new
/// edit after an undo is a new future.</summary>
public void Capture(IEnumerable<Scene> scenes)
{
_undo.Add(LayoutState.From(scenes));
_redo.Clear();
if (_undo.Count > MaxDepth) _undo.RemoveAt(0);
}
/// <summary>Restore the previous layout. The current layout is pushed onto the redo stack, so
/// redo is exact even after several undos.</summary>
public bool Undo(IEnumerable<Scene> scenes)
{
if (_undo.Count == 0) return false;
var sceneList = scenes as IList<Scene> ?? scenes.ToList();
_redo.Add(LayoutState.From(sceneList));
var previous = _undo[^1];
_undo.RemoveAt(_undo.Count - 1);
previous.RestoreTo(sceneList);
return true;
}
public bool Redo(IEnumerable<Scene> scenes)
{
if (_redo.Count == 0) return false;
var sceneList = scenes as IList<Scene> ?? scenes.ToList();
_undo.Add(LayoutState.From(sceneList));
var next = _redo[^1];
_redo.RemoveAt(_redo.Count - 1);
next.RestoreTo(sceneList);
return true;
}
/// <summary>Dropped when the layout is reloaded from disk — undoing across a reload would
/// resurrect a layout the creator never had.</summary>
public void Clear()
{
_undo.Clear();
_redo.Clear();
}
/// <summary>One scene's contents and geometry at a point in time.</summary>
private sealed record SceneState(Scene Scene, SceneElement[] Order, ElementState[] Elements);
private sealed record ElementState(SceneElement Element, double X, double Y, double Width, double Height, bool IsVisible);
private sealed record LayoutState(SceneState[] Scenes)
{
public static LayoutState From(IEnumerable<Scene> scenes) => new(
scenes.Select(scene => new SceneState(
scene,
scene.Elements.ToArray(),
scene.Elements.Select(element => new ElementState(
element, element.X, element.Y, element.Width, element.Height, element.IsVisible)).ToArray()))
.ToArray());
public void RestoreTo(IList<Scene> scenes)
{
foreach (var state in Scenes)
{
// A scene removed from the layout since the snapshot is not restored — the
// layout, not the history, owns which scenes exist.
if (!scenes.Contains(state.Scene)) continue;
foreach (var element in state.Elements)
{
element.Element.X = element.X;
element.Element.Y = element.Y;
element.Element.Width = element.Width;
element.Element.Height = element.Height;
element.Element.IsVisible = element.IsVisible;
}
var elements = state.Scene.Elements;
// Membership AND order: this is what makes add / remove / reorder undo-able.
if (elements.SequenceEqual(state.Order)) continue;
elements.Clear();
foreach (var element in state.Order) elements.Add(element);
}
}
}
}
+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);
}
+30
View File
@@ -72,6 +72,36 @@ 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
/// rendering the scene's original background, the strip shows this instead.
/// Null = the strip falls back to the background art (creator ruling 2026-10-10).
/// </summary>
public string? ThumbnailAssetId { get; set; }
private BitmapSource? _snapshot;
/// <summary>
+88 -9
View File
@@ -19,6 +19,17 @@ public enum BorderAnimation
Sparkles
}
/// <summary>Whether an element's pixel content is fixed (bake once into the
/// compositor's cached base) or changes every frame (composite each tick).
/// See ai.md "SceneGraph component + baked-crust compositor" (TASK 31).</summary>
public enum ElementKind
{
/// <summary>Pixels never change at runtime (images, background art).</summary>
Static,
/// <summary>Pixels change every frame (webcam, live capture, chat, web source).</summary>
Dynamic
}
/// <summary>
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
/// or a singleton resource's per-scene config (webcam). Carries the shared
@@ -44,6 +55,15 @@ public abstract class SceneElement : INotifyPropertyChanged
private string _name = string.Empty;
public string Name { get => _name; set => Set(ref _name, value); }
/// <summary>Whether this element's pixels are fixed (Static) or change every
/// frame (Dynamic). The compositor bakes all Static layers below the first
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
public virtual ElementKind Kind => ElementKind.Static;
/// <summary>True only for the transient zoom layer. Gates zoom-only UI:
/// hidden from the layers list, never selectable/draggable in the preview.</summary>
public virtual bool IsZoomLayer => false;
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
public virtual bool IsWebcam => false;
@@ -61,6 +81,15 @@ public abstract class SceneElement : INotifyPropertyChanged
/// <summary>True only for Countdown-type sources. Gates timer properties panel.</summary>
public virtual bool IsCountdown => false;
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
public virtual bool IsAlertBox => false;
public virtual bool IsTextOverlay => false;
/// <summary>The scoreboard strip: a per-scene, full-width bar.</summary>
public virtual bool IsScoreboard => false;
public virtual bool IsMediaSource => false;
public virtual bool IsLiveCapture => false;
public virtual bool IsBackground { get; set; }
private bool _useDefaultBackground = true;
@@ -88,13 +117,45 @@ public abstract class SceneElement : INotifyPropertyChanged
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
private ClipShape _clipShape = ClipShape.Traditional;
public ClipShape ClipShape
// Virtual so a derived element can CONSTRAIN the shape (the webcam is
// rect-only in the vertical tier's Chat layout — TASK 57) instead of every
// writer re-implementing the rule.
public virtual ClipShape ClipShape
{
get => _clipShape;
set
{
if (Set(ref _clipShape, value))
Raise(nameof(ShapeButtonText));
{
Raise(nameof(IsRectShape));
Raise(nameof(IsRoundShape));
}
}
}
/// <summary>True when the element's clip is the plain rectangle — the shape a
/// camera has when none was ever picked (TASK 59: the properties panel shows
/// the shape as a CHOICE, and Rect is the selected default).</summary>
public bool IsRectShape
{
get => ClipShape != ClipShape.Round;
set
{
// Round -> rect (restores the pre-round rect, like the toggle does).
if (value && ClipShape == ClipShape.Round) ToggleClipShape();
Raise(nameof(IsRectShape));
}
}
/// <summary>True when the element's clip is a circle (TASK 59).</summary>
public bool IsRoundShape
{
get => ClipShape == ClipShape.Round;
set
{
// Rect -> round (stores the pre-round rect, like the toggle does).
if (value && ClipShape != ClipShape.Round) ToggleClipShape();
Raise(nameof(IsRoundShape));
}
}
@@ -107,7 +168,7 @@ public abstract class SceneElement : INotifyPropertyChanged
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
public void ToggleClipShape()
public virtual void ToggleClipShape()
{
if (ClipShape == ClipShape.Traditional)
{
@@ -144,7 +205,6 @@ public abstract class SceneElement : INotifyPropertyChanged
public double MirrorScale => IsMirrored ? -1 : 1;
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
private WriteableBitmap? _videoImageSource;
@@ -161,15 +221,18 @@ public abstract class SceneElement : INotifyPropertyChanged
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
public abstract ImageSource? DisplaySource { get; }
// Position/transform (per-scene usage)
// Position/transform (per-scene usage). The geometry setters are virtual so
// a derived element can CONSTRAIN them (the webcam clamps itself into the
// vertical tier's band — TASK 57) instead of every writer re-implementing
// the rule.
private double _x;
public double X { get => _x; set => Set(ref _x, value); }
public virtual double X { get => _x; set => Set(ref _x, value); }
private double _y;
public double Y { get => _y; set => Set(ref _y, value); }
public virtual double Y { get => _y; set => Set(ref _y, value); }
private double _width;
public double Width
public virtual double Width
{
get => _width;
set
@@ -180,7 +243,7 @@ public abstract class SceneElement : INotifyPropertyChanged
}
private double _height;
public double Height
public virtual double Height
{
get => _height;
set
@@ -207,10 +270,26 @@ public abstract class SceneElement : INotifyPropertyChanged
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
Raise(nameof(BorderColorEntry));
Raise(nameof(BorderColorOptions));
}
}
}
/// <summary>The border colour as the drop-down sees it: the palette's entry for the
/// current hex — with its NAME — or a nameless entry for a custom hex the creator
/// typed. Setting it writes the hex back (TASK 63).</summary>
public NamedColor BorderColorEntry
{
get => BorderColorPalette.EntryFor(BorderColor);
set => BorderColor = value.Hex;
}
/// <summary>The colours the border drop-down offers — the palette, plus the custom
/// colour the creator typed (so the drop-down always has a selection) (TASK 63).</summary>
public IReadOnlyList<NamedColor> BorderColorOptions
=> BorderColorPalette.OptionsFor(BorderColor);
private double _borderOpacity = 1.0;
public double BorderOpacity
{
+52
View File
@@ -0,0 +1,52 @@
using System;
namespace ytLive.Models;
/// <summary>What kind of session the app is in, for polling decisions.</summary>
public enum SessionPhase
{
Idle,
Live,
Test,
Recording,
}
/// <summary>Which channel figures a poll should fetch.</summary>
public enum ScoreboardPoll
{
None,
SubscribersOnly,
Both,
}
/// <summary>Polling policy for the scoreboard's two channel figures (creator ruling 2026-10-30).
///
/// <para>Analytics answers in DAYS, and test/record sessions have no public audience — so querying
/// watch hours then is pure waste. Subscriber count is cheap (one quota unit) and moves during any
/// session, so it polls whenever the app is working. Idle means silent.</para></summary>
public static class ScoreboardCadence
{
public static readonly TimeSpan AnalyticsInterval = TimeSpan.FromMinutes(30);
public static readonly TimeSpan SubscriberInterval = TimeSpan.FromSeconds(60);
/// <summary><paramref name="enabled"/> is the layer's master switch. Off means NOTHING is
/// polled, whatever the session is doing — a hidden scoreboard has no reason to spend quota
/// (creator ruling 2026-10-30).</summary>
public static ScoreboardPoll Due(SessionPhase phase, DateTime lastAnalyticsUtc, DateTime lastSubscriberUtc,
DateTime nowUtc, bool enabled = true)
{
if (!enabled) return ScoreboardPoll.None;
if (phase == SessionPhase.Idle) return ScoreboardPoll.None;
var analyticsDue = phase == SessionPhase.Live && nowUtc - lastAnalyticsUtc >= AnalyticsInterval;
var subscribersDue = nowUtc - lastSubscriberUtc >= SubscriberInterval;
return (analyticsDue, subscribersDue) switch
{
(true, true) => ScoreboardPoll.Both,
(true, false) => ScoreboardPoll.None, // analytics alone is not a poll kind
(false, true) => ScoreboardPoll.SubscribersOnly,
_ => ScoreboardPoll.None,
};
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
namespace ytLive.Models;
/// <summary>Keeps exactly one Scoreboard layer in EVERY scene.
///
/// <para>Creator ruling (2026-10-30): "YT_ACTIVITY_TICKER … is available to all scenes." The
/// scoreboard is not something to add five times — it exists once per scene like the Background and
/// the Brand logo, and a creator turns it off per scene with the ordinary eye toggle. Removing it is
/// therefore not a delete: it heals on the next load.</para>
///
/// <para>Pure over a scene collection, so the rule is testable without a view-model.</para></summary>
public static class ScoreboardHealer
{
public const string LayerName = "Scoreboard";
/// <summary>The strip's default box: full width, a strip's height, and its Y for the bottom
/// edge. Geometry only — the EDGE itself is resolved against the social bar at render time.</summary>
public const double StripHeight = 56;
public const double BottomY = 1080 - StripHeight;
/// <summary>Ensures one scoreboard per scene, appended as the TOPMOST layer: it is a full-width
/// strip drawn over the scene, and inserting it above the background would break the Brand
/// logo's documented invariant ("directly above the background"). Returns true when anything
/// changed, so the caller can save.</summary>
public static bool Ensure(IEnumerable<Scene> scenes)
{
var changed = false;
foreach (var scene in scenes)
{
var existing = scene.Elements.OfType<Source>().Where(s => s.IsScoreboard).ToList();
// More than one: keep the first, drop the rest.
foreach (var extra in existing.Skip(1))
{
scene.Elements.Remove(extra);
changed = true;
}
if (existing.Count > 0) continue;
scene.Elements.Add(new Source
{
Name = LayerName,
Type = SourceType.Scoreboard,
IsEnabled = true,
X = 0,
Y = BottomY,
Width = 1920,
Height = StripHeight,
});
changed = true;
}
return changed;
}
}
+90
View File
@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace ytLive.Models;
/// <summary>Which edge a full-width bar occupies.</summary>
public enum BarEdge
{
Top,
Bottom,
}
/// <summary>The scoreboard's placement and line-fitting arithmetic, kept pure so both are testable
/// without a renderer.
///
/// <para>Two rules from the creator (2026-10-30):
/// the strip and the social bar are a COUPLED pair that own opposite edges, resolved at render time
/// from what is actually visible; and a strip that does not fit SHRINKS to a floor and then wraps to
/// a second line — it never scrolls, because a creator reading the screen cannot wait for the fact
/// to come round.</para></summary>
public static class ScoreboardLayout
{
public const double FloorFontSize = 14;
public const double MaxFontSize = 24;
/// <summary>Result of fitting the enabled tiles into one strip.</summary>
public readonly record struct Fit(double FontSize, int LineCount, bool Shrunk)
{
public bool Wrapped => LineCount > 1;
}
/// <summary>Resolves the edge pair. When both bars are visible they take opposite edges; the
/// social bar keeps its own choice and the scoreboard is pushed to the other one. When either is
/// hidden the survivor keeps its preferred edge — so hiding the scoreboard frees the social bar
/// to sit anywhere, exactly as the creator asked.</summary>
public static (BarEdge Social, BarEdge Scoreboard) ResolveEdges(
bool socialVisible, BarEdge socialPreferred,
bool scoreboardVisible, BarEdge scoreboardPreferred)
{
if (socialVisible && scoreboardVisible)
return (socialPreferred, Opposite(socialPreferred));
return (socialPreferred, scoreboardPreferred);
}
public static BarEdge Opposite(BarEdge edge) => edge == BarEdge.Top ? BarEdge.Bottom : BarEdge.Top;
/// <summary>Fits measured tile widths (at <paramref name="requestedSize"/>) into the available
/// width: full size if they fit, otherwise shrink, and only if the shrink would drop below the
/// legibility floor does it wrap. Widths are passed in because the renderer MEASURES them —
/// guessing a character width would make the fit lie for a wide glyph or a long handle.</summary>
public static Fit FitLine(IReadOnlyList<double> tileWidths, double separatorWidth,
double availableWidth, double requestedSize = MaxFontSize, double floorSize = FloorFontSize)
{
var widths = tileWidths ?? System.Array.Empty<double>();
var count = widths.Count;
if (count == 0 || availableWidth <= 0)
return new Fit(requestedSize, 1, false);
var total = widths.Sum() + separatorWidth * (count - 1);
if (total <= availableWidth)
return new Fit(requestedSize, 1, false);
var scaled = requestedSize * (availableWidth / total);
if (scaled >= floorSize)
return new Fit(scaled, 1, true);
// Below the floor: keep the floor and wrap. Greedy packing, measured widths scaled to the
// floor size so the line breaks match what is actually drawn.
var factor = floorSize / requestedSize;
var lines = 1;
var lineWidth = 0.0;
foreach (var width in widths)
{
var w = width * factor;
var needed = lineWidth == 0 ? w : lineWidth + separatorWidth + w;
if (needed > availableWidth && lineWidth > 0)
{
lines++;
lineWidth = w;
}
else
{
lineWidth = needed;
}
}
return new Fit(floorSize, lines, true);
}
}
+51
View File
@@ -0,0 +1,51 @@
namespace ytLive.Models;
/// <summary>Owns the session's <see cref="ScoreboardState"/> and the one lifecycle rule that
/// matters: a new session starts from zero.
///
/// <para>Kept separate from the view-model so the rule is testable without a window, and so the
/// scoreboard layer has exactly one place that decides when counting begins.</para></summary>
public sealed class ScoreboardRecorder
{
public ScoreboardState State { get; private set; } = new();
/// <summary>Called when a session starts (go-live, test, or a local recording). Every start
/// clears the tallies — "session" is the only scope the numbers promise.</summary>
public void StartSession()
{
State = new ScoreboardState();
IsRunning = true;
RaiseChanged();
}
/// <summary>Folds a chat event in. Outside a session nothing is counted: a stale tally left over
/// from the last stream would be the "today" lie this design exists to avoid.</summary>
public void Record(ChatMessage message)
{
if (!IsRunning) return;
var before = Fingerprint(State);
State.Apply(message);
if (Fingerprint(State) != before) RaiseChanged();
}
/// <summary>True while the tallies are live.</summary>
public bool IsRunning { get; private set; }
/// <summary>Marks the session finished. The tallies stay visible (the End screen may show
/// them) but stop moving.</summary>
public void EndSession()
{
IsRunning = false;
RaiseChanged();
}
public event System.Action? Changed;
private void RaiseChanged() => Changed?.Invoke();
/// <summary>Cheap change detector: only re-render the strip when a displayed value moved.</summary>
private static string Fingerprint(ScoreboardState state) =>
$"{state.NewMembers}|{state.MilestoneCount}|{state.LastMemberName}|{state.LastGifterName}|" +
$"{state.LastGiftCount}|{state.LastSuperChatAuthor}|{state.LastSuperChatDisplay}|" +
$"{state.SessionTotal.Currency}|{state.SessionTotal.Total:0.######}";
}
+29
View File
@@ -0,0 +1,29 @@
namespace ytLive.Models;
/// <summary>Development-only sample data for the scoreboard.
///
/// <para>Creator (2026-10-30): the strip must be provable in the PREVIEW before a real stream —
/// "simulate the ticker so we can see it and ensure it's getting composited". Synthetic events go
/// through the same <see cref="ScoreboardRecorder"/> a live stream uses, so what the preview shows
/// is exactly what real events would produce; nothing here fakes the renderer.</para>
///
/// <para>Kept in the model (not the view-model) so the sample is testable without a window.</para></summary>
public static class ScoreboardSample
{
public static void Inject(ScoreboardRecorder recorder)
{
if (!recorder.IsRunning) recorder.StartSession();
recorder.Record(new ChatMessage { Kind = ChatEventKind.NewMember, AuthorName = "newviewer" });
recorder.Record(new ChatMessage
{
Kind = ChatEventKind.MemberGift, AuthorName = "giftgiver", GiftNewMembersCount = 3,
});
recorder.Record(new ChatMessage { Kind = ChatEventKind.MemberMilestone, AuthorName = "longtimer" });
recorder.Record(new ChatMessage
{
Kind = ChatEventKind.SuperChat, AuthorName = "tipper",
SuperChatAmount = 12.34, SuperChatCurrency = "USD", SuperChatDisplayString = "$12.34",
});
}
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace ytLive.Models;
/// <summary>The session's scoreboard tallies — the facts a creator puts on screen to prove the
/// channel is moving.
///
/// <para>Scope is the RUNNING SESSION (creator ruling 2026-10-30): the counts start at zero when a
/// session starts and die with it. No persistence, nothing to reconcile, and every number is true
/// for exactly what this session saw — a stored "today" that survives a Windows restart would be
/// right only for the hours the app happened to be running, which is a lie the creator cannot
/// check.</para>
///
/// <para>The two polled tiles (subscribers, watch hours) are NOT here: they are channel state, not
/// session events, and live on the polled side of the scoreboard.</para></summary>
public sealed class ScoreboardState
{
/// <summary>Super Chat + Super Sticker money, per currency. Adding across currencies would be
/// the one arithmetic nobody forgives, so totals are keyed and only the dominant one is shown.</summary>
private readonly Dictionary<string, double> _moneyByCurrency = new(StringComparer.OrdinalIgnoreCase);
public string? LastMemberName { get; private set; }
public string? LastSuperChatAuthor { get; private set; }
public string? LastSuperChatDisplay { get; private set; }
public double LastSuperChatAmount { get; private set; }
public string? LastSuperChatCurrency { get; private set; }
public string? LastGifterName { get; private set; }
public int LastGiftCount { get; private set; }
/// <summary>Members gained this session: paid memberships plus gifted ones (counted once, from
/// the GIFT PURCHASE — see <see cref="Apply"/>).</summary>
public int NewMembers { get; private set; }
public int MilestoneCount { get; private set; }
public IReadOnlyDictionary<string, double> MoneyByCurrency => _moneyByCurrency;
/// <summary>The session's dominant currency (most money) and its total, or ("", 0) when nothing
/// has come in. Chosen by VALUE so a single EUR tip beside ten USD cents cannot flip the display
/// to the smaller currency.</summary>
public (string Currency, double Total) SessionTotal =>
_moneyByCurrency.Count == 0
? (string.Empty, 0)
: _moneyByCurrency.OrderByDescending(pair => pair.Value).First() is var top
? (top.Key, top.Value)
: (string.Empty, 0);
public bool HasMixedCurrencies => _moneyByCurrency.Count > 1;
/// <summary>Folds one chat message into the tallies. Every event kind the poll decodes is
/// handled; plain chat is ignored.
///
/// <para><b>giftMembershipReceivedEvent deliberately does nothing.</b> It is the RECIPIENT's view
/// of a purchase already counted by membershipGiftingEvent, so counting it would double both the
/// members and the money (creator ruling 2026-10-30: "come on").</para></summary>
public void Apply(ChatMessage message)
{
switch (message.Kind)
{
case ChatEventKind.NewMember:
NewMembers++;
LastMemberName = message.AuthorName;
break;
case ChatEventKind.MemberGift:
// The purchase: one event carrying how many memberships were bought.
var count = Math.Max(1, message.GiftNewMembersCount);
NewMembers += count;
LastGiftCount = count;
LastGifterName = message.AuthorName;
break;
case ChatEventKind.GiftReceived:
break; // recipient side of the event above — never counted
case ChatEventKind.MemberMilestone:
MilestoneCount++;
break;
case ChatEventKind.SuperChat:
case ChatEventKind.SuperSticker:
LastSuperChatAuthor = message.AuthorName;
LastSuperChatAmount = message.SuperChatAmount;
LastSuperChatCurrency = string.IsNullOrWhiteSpace(message.SuperChatCurrency)
? "USD"
: message.SuperChatCurrency!;
LastSuperChatDisplay = message.SuperChatDisplayString
?? FormatMoney(LastSuperChatAmount, LastSuperChatCurrency);
if (message.SuperChatAmount > 0)
{
_moneyByCurrency.TryGetValue(LastSuperChatCurrency!, out var running);
_moneyByCurrency[LastSuperChatCurrency!] = running + message.SuperChatAmount;
}
break;
}
}
/// <summary>A new session starts from zero.</summary>
public void Reset() => CopyFrom(new ScoreboardState());
private void CopyFrom(ScoreboardState other)
{
_moneyByCurrency.Clear();
foreach (var pair in other._moneyByCurrency) _moneyByCurrency[pair.Key] = pair.Value;
LastMemberName = other.LastMemberName;
LastSuperChatAuthor = other.LastSuperChatAuthor;
LastSuperChatDisplay = other.LastSuperChatDisplay;
LastSuperChatAmount = other.LastSuperChatAmount;
LastSuperChatCurrency = other.LastSuperChatCurrency;
LastGifterName = other.LastGifterName;
LastGiftCount = other.LastGiftCount;
NewMembers = other.NewMembers;
MilestoneCount = other.MilestoneCount;
}
private static string FormatMoney(double amount, string? currency) =>
string.IsNullOrEmpty(currency) ? amount.ToString("0.00") : $"{currency} {amount:0.00}";
}
+87
View File
@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace ytLive.Models;
/// <summary>Which of the eight facts the creator put on screen, and how each renders.
///
/// <para>Every tile is optional because the strip is an ego wall — the creator chooses the
/// billboard. Goal tiles carry their targets here so the rendering stays a pure function of the
/// state plus this selection.</para></summary>
public sealed class ScoreboardTiles
{
/// <summary>Nothing enabled — the layer draws nothing.</summary>
public static ScoreboardTiles None => new();
/// <summary>The selection a new layer starts with: the six session facts, no channel polls
/// (those need the Analytics scope).</summary>
public static ScoreboardTiles Default => new()
{
SuperChats = true, NewMembers = true, Milestones = true,
LastMember = true, LastSuperChat = true, LastGift = true,
};
public bool Subscribers { get; set; }
public bool WatchHours { get; set; }
public bool SuperChats { get; set; }
public bool NewMembers { get; set; }
public bool Milestones { get; set; }
public bool LastMember { get; set; }
public bool LastSuperChat { get; set; }
public bool LastGift { get; set; }
/// <summary>Goal targets for the two polled tiles; 0 means "no goal, show the raw number".</summary>
public int SubscriberGoal { get; set; }
public int WatchHourGoal { get; set; }
/// <summary>Channel state fed by the polled side (subscribers, watch hours). Kept here so the
/// strip is built from ONE source of truth: tallies + channel values + this selection.</summary>
public int SubscriberCount { get; set; }
public int WatchHoursValue { get; set; }
/// <summary>Cache key: the selection alone. Values are folded in by the presenter's fingerprint,
/// so this only needs to change when the ENABLED SET changes.</summary>
public string CacheKey =>
$"{Subscribers}{WatchHours}{SuperChats}{NewMembers}{Milestones}{LastMember}{LastSuperChat}{LastGift}" +
$"{SubscriberGoal}{WatchHourGoal}";
/// <summary>The strip's text, in reading order. Empty strings are dropped by the renderer.</summary>
public IReadOnlyList<string> Build(ScoreboardState state)
{
var tiles = new List<string>(8);
if (Subscribers)
tiles.Add(SubscriberGoal > 0
? $"Subs {SubscriberCount:N0}/{SubscriberGoal:N0}"
: $"Subs {SubscriberCount:N0}");
if (WatchHours)
// Analytics is a DAILY aggregate, so the tile must not imply live data.
tiles.Add(WatchHourGoal > 0
? $"Hours {WatchHoursValue:N0}/{WatchHourGoal:N0} (as of {DateTime.Now:MMM d})"
: $"Hours {WatchHoursValue:N0} (as of {DateTime.Now:MMM d})");
if (SuperChats && state.SessionTotal.Total > 0)
tiles.Add(state.HasMixedCurrencies
? $"SC {state.SessionTotal.Currency} {state.SessionTotal.Total:0.00} +{state.MoneyByCurrency.Count - 1}"
: $"SC {state.SessionTotal.Currency} {state.SessionTotal.Total:0.00}");
if (NewMembers && state.NewMembers > 0)
tiles.Add($"Members +{state.NewMembers}");
if (Milestones && state.MilestoneCount > 0)
tiles.Add($"Milestones {state.MilestoneCount}");
if (LastMember && !string.IsNullOrWhiteSpace(state.LastMemberName))
tiles.Add($"Last member {state.LastMemberName}");
if (LastSuperChat && !string.IsNullOrWhiteSpace(state.LastSuperChatDisplay))
tiles.Add($"Last SC {state.LastSuperChatDisplay}");
if (LastGift && !string.IsNullOrWhiteSpace(state.LastGifterName))
tiles.Add($"Last gift {state.LastGifterName} ×{Math.Max(1, state.LastGiftCount)}");
return tiles;
}
}
+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;
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace ytLive.Models;
/// <summary>What the top bar's primary action button will do.</summary>
public enum SessionStartKind
{
Record,
Live,
Test,
}
/// <summary>Creator ruling (2026-10-30): the primary button is context-sensitive, so the separate
/// TEST button is gone.
///
/// <list type="bullet">
/// <item>EDIT off + ON-AIR armed → Go Live (the livestream message)</item>
/// <item>EDIT on + ON-AIR armed → Test Stream (the test-livestream message) — a dry run you
/// cannot leak, because every broadcast is private pre-v1 anyway</item>
/// <item>REC armed → Start Recording, whatever EDIT says (there is no test recording)</item>
/// </list>
///
/// <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)
{
// Neither pill 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;
}
/// <summary>What pressing the button actually does, given whether YouTube is connected.
/// A signed-out ON-AIR press must start the SIGN-IN flow, not sit there disabled — a dead
/// primary button is the dead end the design principle forbids (creator, 2026-10-30).</summary>
public enum Route
{
SignIn,
Record,
Live,
Test,
}
public static Route RouteFor(SessionStartKind kind, bool connected) => kind switch
{
SessionStartKind.Record => Route.Record,
SessionStartKind.Test => connected ? Route.Test : Route.SignIn,
_ => connected ? Route.Live : Route.SignIn,
};
public static string LabelFor(SessionStartKind kind) => kind switch
{
SessionStartKind.Record => "Start Recording",
SessionStartKind.Test => "Test Stream",
_ => "Go Live",
};
}
+44
View File
@@ -70,6 +70,50 @@ public sealed class SocialsConfig : INotifyPropertyChanged
set => Set(ref _barPosition, value);
}
// ─── Bar appearance (creator request 2026-10-30) ───────────────────────────
// The bar used to be hard-wired white-on-transparent. These five knobs are the whole
// appearance surface: text colour, an optional plate behind it, and a text shadow.
private string _barTextColor = "#FFFFFF";
/// <summary>Text and logo colour of the bar (hex, straight alpha).</summary>
public string BarTextColor
{
get => _barTextColor;
set => Set(ref _barTextColor, string.IsNullOrWhiteSpace(value) ? "#FFFFFF" : value);
}
private bool _barBackgroundEnabled;
/// <summary>Draw a rounded plate behind the entries. Off by default — the bar shipped
/// transparent and most creators keep it that way.</summary>
public bool BarBackgroundEnabled
{
get => _barBackgroundEnabled;
set => Set(ref _barBackgroundEnabled, value);
}
private string _barBackgroundColor = "#000000";
public string BarBackgroundColor
{
get => _barBackgroundColor;
set => Set(ref _barBackgroundColor, string.IsNullOrWhiteSpace(value) ? "#000000" : value);
}
private double _barBackgroundOpacity = 0.55;
/// <summary>0 = invisible plate, 1 = solid. Only meaningful when the plate is on.</summary>
public double BarBackgroundOpacity
{
get => _barBackgroundOpacity;
set => Set(ref _barBackgroundOpacity, Math.Clamp(value, 0, 1));
}
private bool _barTextShadow;
/// <summary>A soft black drop shadow behind the glyphs, for a bright or busy scene.</summary>
public bool BarTextShadow
{
get => _barTextShadow;
set => Set(ref _barTextShadow, value);
}
public event PropertyChangedEventHandler? PropertyChanged;
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
+418 -5
View File
@@ -1,5 +1,6 @@
using System.Windows.Media;
using ytLive.Helpers;
using ytLive.Services;
namespace ytLive.Models;
@@ -13,7 +14,17 @@ public enum SourceType
Countdown,
WebSource,
ChatBox,
MediaSource
/// <summary>The session scoreboard strip: full-width, state not events.</summary>
Scoreboard,
MediaSource,
/// <summary>The designated alert zone: the six monetization events play their
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
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,
/// <summary>A scene-local feed of recent alert events.</summary>
Ticker
}
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
@@ -31,12 +42,39 @@ public enum MediaPlaybackState
Paused
}
public enum TextLayerAlignment
{
Left,
Center,
Right
}
public enum TextLayerVerticalAlignment
{
Top,
Center,
Bottom
}
public enum ClipShape
{
Traditional,
Round
}
/// <summary>How an alert box presents its announcement strip (TASK 47). The strip is
/// the full-width pill naming the event ("Viewer — Super Chat · $12.34").</summary>
public enum AlertDisplayMethod
{
/// <summary>Marquee: the pill scrolls across the top edge, paced so the text is
/// read <see cref="AlertOverlayLayer.TickerReadsPerAlert"/> times per alert.</summary>
TickerScroll,
/// <summary>Pulse: the static pill blinks on and off for the whole alert.</summary>
Flash,
/// <summary>Static: the pill sits centred and still for the whole alert.</summary>
Solid
}
/// <summary>
/// A multi-instance scene object: image/background/text (screen/window later).
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
@@ -45,6 +83,19 @@ public enum ClipShape
/// </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
{
@@ -57,13 +108,36 @@ public class Source : SceneElement
Raise(nameof(IsLiveCapture));
Raise(nameof(DisplaySource));
Raise(nameof(IsWebSource));
Raise(nameof(IsTextOverlay));
Raise(nameof(IsScoreboard));
Raise(nameof(IsTicker));
Raise(nameof(ShowsChromaKey));
}
}
}
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox;
/// <summary>Blur is draggable like a layer: it has no pixels of its own, but the creator places
/// 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.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.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>
public bool IsBrandLogo { get; set; }
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
private bool _isBackground;
@@ -89,7 +163,7 @@ public class Source : SceneElement
private string? _captureKey;
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
public bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
public override bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
// Image (asset stored in the layout database)
private string? _assetId;
@@ -111,14 +185,37 @@ public class Source : SceneElement
public ImageSource? ImageSource => _imageSource;
/// <summary>Types whose frame is produced at runtime rather than stored as an asset. The
/// 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.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)
: (Type is SourceType.ChatBox or SourceType.WebSource ? VideoImageSource : _imageSource);
: (IsFrameSource ? VideoImageSource : _imageSource);
public override bool IsImageSource => Type == SourceType.Image;
// Countdown config
private int _timerMinutes = 15;
// Privacy blur (Live scene only): 0–1, the same scale and default as the webcam's background
// blur so "half strength" means the same blur in both places.
private double _blurAmount = 0.5;
public double BlurAmount
{
get => _blurAmount;
set => Set(ref _blurAmount, Math.Clamp(value, 0, 1));
}
public bool IsBlur => Type == SourceType.Blur;
/// <summary>True for a round blur (circle) — the shape rides the existing clip surface, so the
/// box/circle choice is the same switch every other layer uses rather than a second concept.</summary>
public bool IsRoundBlur => IsBlur && ClipShape == ClipShape.Round;
public int TimerMinutes
{
get => _timerMinutes;
@@ -137,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
{
@@ -144,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
{
@@ -162,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
{
@@ -169,7 +308,208 @@ public class Source : SceneElement
set => Set(ref _mediaPlaybackState, value);
}
public bool IsMediaSource => Type == SourceType.MediaSource;
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
// order: replacement path exists → DB default → six celebration animations.
private string? _alertVideoAssetId;
public string? AlertVideoAssetId
{
get => _alertVideoAssetId;
set => Set(ref _alertVideoAssetId, value);
}
private string? _alertVideoPath;
public string? AlertVideoPath
{
get => _alertVideoPath;
set
{
if (Set(ref _alertVideoPath, value)) Raise(nameof(ShowsChromaKey));
}
}
private bool _alertUseDefaultVideo = true;
public bool AlertUseDefaultVideo
{
get => _alertUseDefaultVideo;
set
{
if (Set(ref _alertUseDefaultVideo, value)) Raise(nameof(ShowsChromaKey));
}
}
public bool AlertShowTicker
{
get => _ticker.IsEnabled;
set => _ticker.IsEnabled = value;
}
private double _alertVideoVolume = 1.0;
public double AlertVideoVolume
{
get => _alertVideoVolume;
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
}
// Sound byte that plays with the alert clip (creator ruling 2026-11-01). Referenced from disk
// like the clip; the video always plays once to completion, the sound may loop under it.
private string? _alertAudioPath;
public string? AlertAudioPath
{
get => _alertAudioPath;
set => Set(ref _alertAudioPath, value);
}
private bool _alertAudioLoop;
public bool AlertAudioLoop
{
get => _alertAudioLoop;
set => Set(ref _alertAudioLoop, value);
}
public string AlertAudioFileDisplay =>
string.IsNullOrWhiteSpace(_alertAudioPath) ? "No sound byte" : System.IO.Path.GetFileName(_alertAudioPath);
/// <summary>True once a file is chosen — gates the loop switch and the clear button.</summary>
public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
public AlertDisplayMethod AlertDisplayMethod
{
get => _ticker.DisplayMethod;
set => _ticker.DisplayMethod = value;
}
public override bool IsAlertBox => Type == SourceType.AlertBox;
// Chat box config (TASK 3.18)
private double _chatFontSize = 16;
@@ -243,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;
+281
View File
@@ -0,0 +1,281 @@
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>
public StepDownPolicy(int startWidth, int startHeight, int startFps, double startBitrateMbps,
bool allowClimb = false)
{
_vertical = startHeight > startWidth;
_allowClimb = allowClimb;
_ceilingFps = startFps;
_ceilingWidth = startWidth;
_ceilingHeight = startHeight;
_ceilingBitrateMbps = startBitrateMbps;
_floorBitrateMbps = _vertical ? 4.0 : 3.0;
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));
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.Windows;
namespace ytLive.Models;
/// <summary>
/// The vertical (9:16) tier's camera rules — TASK 57, the "vv" feature.
///
/// The vertical tier is a centred band crop of the SAME 1920×1080 master the
/// 16:9 tiers use, so a camera placed for 16:9 lands outside the cropped
/// window and vanishes from the vertical output. The vertical tier therefore
/// keeps its OWN placement per scene: a small tile inside the band, snapped to
/// one of six spots — the band splits into a top and a bottom half, each with
/// a left, a centre and a right slot.
///
/// Geometry only: the preview and the compositor both read the element's own
/// X/Y/W/H + ClipShape, so the placement is resolved by swapping those values
/// when the tier changes. No render-path change.
/// </summary>
public static class VerticalCameraLayout
{
/// <summary>The vertical tier's camera tile over the master frame —
/// 270×152 master px = 480×270 in the 1080×1920 output.</summary>
public const double TileWidth = 270;
public const double TileHeight = 152;
/// <summary>How far the tile keeps from the band's outer edge — the app's
/// own placement margin (32 master px) used by the default webcam spot.</summary>
public const double Inset = 32;
/// <summary>True for the portrait tier (the vertical 1080×1920 one).</summary>
public static bool IsVerticalTier(QualityOption quality) => quality.Height > quality.Width;
/// <summary>The half of the band a tile lives in — top or bottom.</summary>
public static Rect AreaFor(Rect band, bool bottom)
=> new(band.Left, bottom ? band.Top + band.Height / 2 : band.Top, band.Width, band.Height / 2);
/// <summary>The largest a camera may be inside the band: one whole area.</summary>
public static (double Width, double Height) MaxSizeFor(Rect band)
=> (band.Width, band.Height / 2);
/// <summary>True when a camera is wide enough to fill one whole area of the
/// band — the "enlarged" Chat layout (one 607×540 area).</summary>
public static bool FillsArea(Rect band, double width) => width >= band.Width - 1;
/// <summary>The two sizes a camera may take in the vertical tier's Chat
/// layout: the small tile (normal) or one whole area (enlarged). Returns
/// the one nearer the requested width, so the resize handle snaps between
/// them instead of taking any size in between.</summary>
public static (double Width, double Height) SizeFor(Rect band, double requestedWidth)
{
var (areaWidth, areaHeight) = MaxSizeFor(band);
return Math.Abs(requestedWidth - TileWidth) <= Math.Abs(requestedWidth - areaWidth)
? (TileWidth, TileHeight)
: (areaWidth, areaHeight);
}
/// <summary>
/// The spot a tile snaps to: the area (top/bottom) and the column
/// (left/centre/right) its centre falls in, then the tile's top-left for
/// that spot — inset from the band's outer edge, and never outside the band
/// (a tile wider than the inset eats it).
/// </summary>
public static (double X, double Y) SpotFor(Rect band, double width, double height, double centreX, double centreY)
{
var bottom = centreY >= band.Top + band.Height / 2;
var area = AreaFor(band, bottom);
var third = area.Width / 3;
double x;
if (centreX < area.Left + third) x = area.Left + Inset;
else if (centreX < area.Left + 2 * third) x = area.Left + (area.Width - width) / 2;
else x = area.Right - Inset - width;
var y = bottom ? area.Bottom - Inset - height : area.Top + Inset;
x = Math.Clamp(x, band.Left, Math.Max(band.Left, band.Right - width));
y = Math.Clamp(y, band.Top, Math.Max(band.Top, band.Bottom - height));
return (x, y);
}
/// <summary>The default tile spot: the bottom-right corner of the band —
/// the same corner the 16:9 default webcam placement uses.</summary>
public static (double X, double Y) DefaultSpot(Rect band, double width, double height)
=> SpotFor(band, width, height, band.Right, band.Bottom);
}
+219
View File
@@ -1,3 +1,4 @@
using System.Windows;
using System.Windows.Media;
namespace ytLive.Models;
@@ -13,5 +14,223 @@ public class WebcamSceneConfig : SceneElement
public string WebcamId { get; init; } = string.Empty;
public override bool IsWebcam => true;
public override bool IsDraggable => true;
public override ElementKind Kind => ElementKind.Dynamic;
public override ImageSource? DisplaySource => VideoImageSource;
private BackgroundEffect _backgroundEffect = BackgroundEffect.None;
/// <summary>TASK 51: this scene's background effect for the webcam (persisted).</summary>
public BackgroundEffect BackgroundEffect
{
get => _backgroundEffect;
set
{
if (!Set(ref _backgroundEffect, value)) return;
Raise(nameof(RemoveBackground));
Raise(nameof(BlurBackground));
Raise(nameof(HasBackgroundEffect));
}
}
/// <summary>True while Remove or Blur is on — shows the strength slider.</summary>
public bool HasBackgroundEffect => BackgroundEffect != BackgroundEffect.None;
private double _backgroundStrength = 0.5;
/// <summary>TASK 51: 0..1 — Blur amount, or Remove's edge softness (persisted).</summary>
public double BackgroundStrength
{
get => _backgroundStrength;
set => Set(ref _backgroundStrength, Math.Clamp(value, 0, 1));
}
/// <summary>Pill-switch surface: Remove and Blur behave like radio buttons — turning one
/// on turns the other off; turning the active one off means <see cref="BackgroundEffect.None"/>.</summary>
public bool RemoveBackground
{
get => BackgroundEffect == BackgroundEffect.Remove;
set => BackgroundEffect = value ? BackgroundEffect.Remove
: BackgroundEffect == BackgroundEffect.Remove ? BackgroundEffect.None : BackgroundEffect;
}
/// <inheritdoc cref="RemoveBackground"/>
public bool BlurBackground
{
get => BackgroundEffect == BackgroundEffect.Blur;
set => BackgroundEffect = value ? BackgroundEffect.Blur
: BackgroundEffect == BackgroundEffect.Blur ? BackgroundEffect.None : BackgroundEffect;
}
// ─── Vertical (9:16) tier placement (TASK 57, the "vv" feature) ────────
// The vertical tier is a centred band crop of the same master frame, so the
// 16:9 placement lands outside the cropped window and the camera vanishes
// from the vertical output. The vertical tier therefore keeps its OWN
// position, size and shape per scene, stored here and resolved when the
// tier changes; switching back to 16:9 restores the desktop layout exactly.
/// <summary>The vertical placement's X (null until the scene has one).</summary>
public double? VerticalX { get; set; }
/// <summary>The vertical placement's Y (null until the scene has one).</summary>
public double? VerticalY { get; set; }
/// <summary>The vertical placement's width (null until the scene has one).</summary>
public double? VerticalWidth { get; set; }
/// <summary>The vertical placement's height (null until the scene has one).</summary>
public double? VerticalHeight { get; set; }
/// <summary>The vertical placement's clip shape (circle or rect) — the
/// creator's own choice per scene, independent of the 16:9 shape.</summary>
public ClipShape? VerticalClipShape { get; set; }
/// <summary>The vertical placement's pre-Round rect (so a Round tile
/// restores its aspect on toggle-back, like the 16:9 one does).</summary>
public double? VerticalRectWidth { get; set; }
/// <inheritdoc cref="VerticalRectWidth"/>
public double? VerticalRectHeight { get; set; }
/// <summary>True once this scene has a vertical placement of its own.</summary>
public bool HasVerticalLayout => VerticalX is not null;
/// <summary>True while the vertical placement is the LIVE geometry (the
/// element's own X/Y/W/H). The 16:9 layout is parked in
/// <see cref="LandscapeX"/> and friends until the tier switches back.</summary>
public bool VerticalLayoutLive { get; private set; }
/// <summary>True while the vertical placement must stay RECT — the Chat
/// scene's vertical layout (the creator's table: "vv · Chat = rect only, no
/// circle option"). The circle is not offered there, so the shape cannot
/// become Round by any writer.</summary>
public bool VerticalShapeLocked { get; internal set; }
/// <summary>The vertical tier's band over the master frame (transient — the
/// VM sets it when the tier changes). While the vertical placement is live
/// the camera's geometry is clamped to it: outside the band is invisible.</summary>
public Rect VerticalBand { get; set; } = Rect.Empty;
// The parked 16:9 layout — meaningful only while the vertical placement is
// live (it is the 16:9 geometry the tier switch restores).
public double LandscapeX { get; private set; }
public double LandscapeY { get; private set; }
public double LandscapeWidth { get; private set; }
public double LandscapeHeight { get; private set; }
public ClipShape LandscapeClipShape { get; private set; }
public double? LandscapeRectWidth { get; private set; }
public double? LandscapeRectHeight { get; private set; }
/// <summary>Parks the live (16:9) layout and brings a vertical placement
/// in. The vertical placement becomes the live geometry from here on.</summary>
public void BeginVerticalLayout(double x, double y, double width, double height, ClipShape clipShape)
{
LandscapeX = X;
LandscapeY = Y;
LandscapeWidth = Width;
LandscapeHeight = Height;
LandscapeClipShape = ClipShape;
LandscapeRectWidth = RectWidth;
LandscapeRectHeight = RectHeight;
VerticalLayoutLive = true;
Width = width;
Height = height;
X = x;
Y = y;
ClipShape = clipShape;
RectWidth = VerticalRectWidth;
RectHeight = VerticalRectHeight;
}
/// <summary>Restores the parked 16:9 layout — the tier switched back.</summary>
public void EndVerticalLayout()
{
if (!VerticalLayoutLive) return;
// The vertical placement is the creator's own — keep it, so switching
// back and forth does not lose where they put it.
CommitLiveLayoutToVertical();
VerticalLayoutLive = false;
Width = LandscapeWidth;
Height = LandscapeHeight;
X = LandscapeX;
Y = LandscapeY;
ClipShape = LandscapeClipShape;
RectWidth = LandscapeRectWidth;
RectHeight = LandscapeRectHeight;
}
/// <summary>Copies the live geometry into the stored vertical placement, so
/// the stored copy never lags the live one (the save path calls this).</summary>
public void CommitLiveLayoutToVertical()
{
VerticalX = X;
VerticalY = Y;
VerticalWidth = Width;
VerticalHeight = Height;
VerticalClipShape = ClipShape;
VerticalRectWidth = RectWidth;
VerticalRectHeight = RectHeight;
}
// The Chat scene's vertical layout is rect-only (the creator's table, the
// circle option is not offered there). Locking the SHAPE property — not
// just the toggle — means no writer can slip a Round camera in: the
// toggle, the shape button and the placement swap all go through here.
public override ClipShape ClipShape
{
get => base.ClipShape;
set => base.ClipShape = VerticalShapeLocked ? ClipShape.Traditional : value;
}
public override void ToggleClipShape()
{
if (VerticalShapeLocked)
{
ClipShape = ClipShape.Traditional;
return;
}
base.ToggleClipShape();
}
// The geometry is clamped into the band while the vertical placement is
// live — a camera outside the cropped window is a camera the viewers never
// see, and every writer (drag, resize handle, the properties fields) goes
// through these setters.
public override double X
{
get => base.X;
set => base.X = ClampToBand(value, base.Width, vertical: true);
}
public override double Y
{
get => base.Y;
set => base.Y = ClampToBand(value, base.Height, vertical: false);
}
public override double Width
{
get => base.Width;
set => base.Width = VerticalLayoutLive && !VerticalBand.IsEmpty
? Math.Min(value, VerticalBand.Width)
: value;
}
public override double Height
{
get => base.Height;
set => base.Height = VerticalLayoutLive && !VerticalBand.IsEmpty
? Math.Min(value, VerticalBand.Height / 2)
: value;
}
private double ClampToBand(double value, double size, bool vertical)
{
if (!VerticalLayoutLive || VerticalBand.IsEmpty) return value;
var band = VerticalBand;
var min = vertical ? band.Left : band.Top;
var max = vertical ? band.Right - size : band.Bottom - size;
if (max < min) return min;
return Math.Clamp(value, min, max);
}
}

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