Commit Graph
205 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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