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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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)
- 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
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).
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".
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.
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.
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.
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.