Compare commits

...
10 Commits
Author SHA1 Message Date
gramps 6be062c45f vv webcam Unit 1: the vertical (9:16) tier keeps its OWN camera placement per scene
The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Full suite 428 tests, 0 warnings; the only failures are the known audio env flakes
(both pass in isolation). Scope check passed.
2026-10-16 18:23:20 -07:00
gramps 3cb29ea45c HANDOFF refresh: tree clean, all units committed+pushed through 931ab46.
The prior handoff claimed the strip-tile/on-air unit was uncommitted and the
pinned-chat unit was 'not mine - do NOT commit'. Reality: both are committed
(8e1d6f4, 931ab46) and origin/main is level with HEAD (0 ahead). Corrected to
match the code and recorded the true next step (webcam per-element composite
timing measurement). Removed the stray Assets/llamacasty-text-nobg-2.png~ backup.
(Docs: handoff)
2026-10-14 09:41:24 -07:00
27 changed files with 1945 additions and 134 deletions
+30
View File
@@ -252,6 +252,36 @@
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
</StackPanel>
<!-- Webcam geometry (TASK 57): under the vertical tier the camera
keeps its OWN placement, so these fields edit the LIVE one —
the placement the current tier shows. Gated on the VM's
IsWebcamSelected (the same condition as the mirror/shape row
above) so the webcam's border/background section stays the
one panel bound to SelectedElement.IsWebcam. -->
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
</StackPanel>
<!-- Webcam border config -->
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
+20 -6
View File
@@ -442,12 +442,22 @@ public partial class PreviewPane : UserControl
if (_isDraggingOverlay)
{
var minX = -(selected.Width - 20);
var maxX = 1920 - 20;
var minY = -(selected.Height - 20);
var maxY = 1080 - 20;
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
// TASK 57: under the vertical tier the camera snaps into the
// cropped band (one of six spots) instead of free-floating over
// the master — outside the band it is not in the stream at all.
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
{
vm.MoveWebcamInVerticalTier(verticalCam, p.X - _grabOffset.X, p.Y - _grabOffset.Y);
}
else
{
var minX = -(selected.Width - 20);
var maxX = 1920 - 20;
var minY = -(selected.Height - 20);
var maxY = 1080 - 20;
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
}
}
else if (_isResizing)
{
@@ -465,6 +475,10 @@ public partial class PreviewPane : UserControl
}
selected.Width = newW;
selected.Height = newH;
// The spot's anchor follows the new size (TASK 57).
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
vm.SnapWebcamInVerticalTier(verticalCam);
}
}
+2 -2
View File
@@ -10,9 +10,9 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials) |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on) |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice) |
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
+186 -64
View File
@@ -1,78 +1,200 @@
# HANDOFF — 2026-10-10 (strip tile artwork + on-air tile unit; uncommitted)
# HANDOFF — 2026-10-21 (vv webcam Unit 1 shipped; the previous HA-lights session's state below)
## At a glance
- **Branch** main (no feature branch). Pushed through `5685c5d`; 19 commits local, **not pushed**
(push at the creator's call).
- Scene-order unit committed `06f88f4`; desktop-lock committed `63bf844`. The **strip tile
artwork unit below is uncommitted**.
- **Branch** main, tracking `origin/main`. **origin/main = `931ab46`; HEAD = `58b9a7d` + this session's
commit** — **9 ahead, 0 behind** (nothing pushed; push only at sub-milestones/milestones, at the
creator's word). This file cannot record its own commit's hash, so the tip is named by subject.
- Local-only commits: `3cb29ea`, `a3a4681`, `84cbec2`, `cdf82f6`, `2a15be2`, `d1e73a3`, `fb851f0`,
`b1d77ee`, `58b9a7d`, plus this session's. Always check `git log origin/main..HEAD` — never trust a
handoff's own "0 ahead" claim.
- **This session shipped vv webcam Unit 1** (the vertical 9:16 tier keeps its OWN camera placement per
scene; 16:9 restores the desktop layout exactly). Code + memory in ONE commit; the ONE Good Dog test
is `ytLive.Tests/VerticalWebcamLayoutTests.cs`. **Unit 2 (vv · Chat) is next** — see the vv section.
- **The previous session (HA lights) produced NO ytLive code** — it was spent on the creator's Home
Assistant light alert system (off-repo, `192.168.50.145`). See `MyMistakes.md` → 2026-10-21: the plan
was one tweak to a verified-working script; the script was rewritten twice, the alert path broke, and
the day was burned. **The HA work landed OK in the end** (recipes + measurements now in
`MyMistakes.md`). Records below so the next session does not re-hunt.
## Branch
- main (no feature branch). Pushed through `5685c5d` (TRAX toggle); everything after is local,
**not pushed** (push at the creator's call).
## HA light alert system — current, verified state (2026-10-21)
Five Tapo bulbs: `light.br1`, `light.br2`, `light.officeone`, `light.officetwo`,
`light.death_star_light`. Purpose: the creator is **deaf** — lights are the alert channel; the phone
(`notify.mobile_app_mis_lx9`) is secondary. **The iPhone is gone — never notify it.**
## New work unit (this handoff) — VERIFIED, ready to commit
Strip tile artwork + on-air indicator (creator ruling 2026-10-10). Three sub-parts:
| State | br1 / officeone | br2 / officetwo | Death Star |
|---|---|---|---|
| Idle | off | off | off |
| Pump running | off | **off** | **blue only** (creator directive) |
| Leak | warm-white 100% | purple | purple |
| Alarm | warm-white 100% | red | red |
| Doorbell | green, 8s | green, 8s | green, 8s |
| Doorbell during an alert | — | suppressed — | — |
| Clear | off | off | off |
1. **Per-scene CRT tile art** — each canonical scene's top-bar tile is its OWN image stored in the
internal DB (`Scene.ThumbnailAssetId`, additive migration, seeded at heal from the creator's five
Downloads JPGs via `SceneThumbnailSeedPaths`). The preview pane keeps rendering the scene's original
background — never the tile. `Save()` Asset prune now keeps `Scene.ThumbnailAssetId` rows
(this was a real bug: the first save would have deleted the seeded tiles).
2. **On-air image** — one shared image (`Gemini_Generated_Image_4zajyl4zajyl4zaj.jpg`) that REPLACES the
staged scene's tile ("the on-air screen jumps around the top bar as the creator swaps scenes").
Stored as an Asset BLOB tracked by the `OnAirImageAssetId` Setting (`EnsureOnAirImage`, same contract
as the alert clip; prune keep-set extended). Seed path override: `MainViewModel.OnAirSeedPath`.
`RefreshSnapshotsAsync`: staged scene → on-air art; unstaged → own thumbnail or background art;
staged Live without on-air art → green placeholder (unchanged invariant: minis never render captures).
3. **Preview + labels polish** — removed the 6px `#22c55e` rectangle in `BackdropLayer`
(`Controls/PreviewPane.xaml`) — the ONLY live signal is the outer `PreviewGlow` red border
(`MainViewModel.Streaming.cs`, transparent otherwise); the redundant rect was only visible when a
scene had no background ("green outline around the preview screen" the creator saw). Scene-tile
labels doubled: `Controls/SceneThumbnailStrip.xaml` FontSize 10 → 20.
**Architecture:** `script.alerts_arm` / `alerts_clear` write only `input_text.light_alert`
(`none|leak|alarm|motion`, restart-surviving). **`script.alerts_render_lights` is the single writer of
the bulbs** — flat top-level `if`/`else` blocks (nested `if` and `choose:` both silently lit nothing;
do not "improve" the shape). Priority: an alert always beats pump status.
- Files: `Models/Scene.cs`, `Services/LayoutStore.{Migrations,Load,Save,Settings}.cs`,
`ViewModels/MainViewModel.Background.cs`, `Controls/PreviewPane.xaml` (rect removed),
`Controls/SceneThumbnailStrip.xaml` (label), `ytLive.Tests/SceneThumbnailTests.cs` (4 tests).
- Full suite **427 passed / 0 failed / 0 warnings** (one run flaked on the known
`RealMouseDrag_…PersistsTheReorder` env test — needs an idle desktop — and passed on re-run;
nothing in this unit touches layer reorder). Existing 3 tile tests stub seed paths + `OnAirSeedPath`
so the suite is hermetic; the new 4th test covers the on-air swap + persistence.
- **To commit later (this unit only):** the files above. Then the working tree is back to just the
creator's pinned-chat unit (NOT mine to commit).
**⛔ THE ENTITY-ID TRAP (cost this session hours):** the YAML key is `alerts_render` but the entity id is
**`script.alerts_render_lights`**. `script.turn_on` on a non-existent entity returns **HTTP 200 and does
nothing** — no error, no log. All callers now use the `_lights` id. Check this first on any "script does
nothing" symptom.
## Desktop-lock unit (COMMITTED 63bf844)
- `ShowActivated = !FullscreenGameRunningNow` + `FullScreenDetectorOverride` seam + `DesktopLockTests`.
Creator still to verify in-game (launch while an exclusive-fullscreen game owns the foreground).
**Pump status** is a *status*, not an alert (auto-clearing), and is keyed off
`sensor.water_pump_current_consumption` — **never** `switch.water_pump_2`, which was observed latched
`on` for hours at 0.0A draw and painted the bedroom blue at 5am.
## Scene-order unit (COMMITTED 06f88f4)
- `ReorderScenesToCanonical()` + `SceneOrderTests`. The creator's DB (Chat SortOrder 2, BRB 3) heals to
canonical F1-F5 order at load; next save re-stamps SortOrder.
**HA files touched** (`/config`, root-owned → writes need `sudo`): `scripts.yaml`, `automations.yaml`,
`configuration.yaml` (added `input_text.light_alert` / `_prev`). Backups: `scripts.yaml.bak-alertbuild`,
`automations.yaml.bak-alertbuild`, `automations.yaml.bak-notify-20261020`, `configuration.yaml.bak-tplinkfix`.
Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbell_flash` (old one),
`automation.leak_strobe_notify_clear_restore`, `script.alerts_render_lights_2`.
**Leak sensor is healthy** (`binary_sensor.pump_leak_moisture` = off, battery 100, signal 3) — an earlier
"unavailable" claim was a bad read during a reload; `alert_leak_on` now also triggers on the latched
`sensor.pump_leak_last_water_leak_alert`.
## Dirty files — current reality (2026-10-10)
- **NOT mine — do NOT commit:** the creator's in-flight "pinned chat" unit (`Models/YouTube.cs`,
`ViewModels/MainViewModel.Chat.cs`, untracked `Helpers/RelayCommandGeneric.cs`, and **its hunks in
`Controls/LeftPanel.xaml`**), plus the creator's untracked `Assets/llamacasty*` art.
- ⚠️ Pre-commit: `scripts/verify.sh` runs `scripts/scope-check.sh` against ALL working-tree changes —
the not-mine pinned-chat + art files WILL flag. Pass them as declared-known.
## vv webcam feature — locked rules (creator, 2026-10-17) — **Unit 1 shipped 2026-10-21**
| mode | shape | size | position |
|---|---|---|---|
| 16:9 | as today | as today | as today |
| **vv · Live** | **circle OR rect, creator's choice** | small tile | one of six spots: top/bottom × L/C/R |
| **vv · Chat** | **rect only — no circle option** | normal, or enlarged to fill one area (607×540) | enlarged → top or bottom |
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
- **Placement is set by tap/click-drag with snap, exactly like the socials today**, plus manual entry in
properties. **vv keeps its own camera position, size and shape per scene** — switching back to 16:9
restores the desktop layout exactly.
- **No explicit borderless/no-background mode is needed** (round + border/bg options already allow it).
- Creator's "square" means the rectangular view (the camera's own aspect), not a literal 1:1 box.
### What the code already gives us (do not re-hunt)
- **vv = the active tier's centred band crop.** 1080p60-vertical: master band **x 656.25–1263.75
(607.5 wide), full height**, upscaled to 1080×1920 (`MainViewModel.Streaming.cs` `ApplyStreamQuality`
→ `OutputRect*`). The band splits into **two 607×540 areas** (top/bottom).
- **The Live webcam sits at x≈1425 → entirely outside the band** — in vv it is invisible. That is the
whole motivation.
- **Geometry only — no render-path change.** Preview (`Controls/PreviewPane.xaml` 144–230) and compositor
(`Services/Compositor/SceneCompositor.cs` 389–397) both read the element's own `X/Y/W/H` + `ClipShape`.
- **Round / border / background are independent:** `ClipShape.Round`; `HasBorder` = width > 0 AND valid
6-hex colour; `BackgroundEffect` = None/Remove/Blur.
- **Per-scene camera size policy exists**: `MaxWebcamWidthFor/HeightFor` (Chat 1358×764, others 960×540)
+ `ClampWebcamToBounds`. **A 607×540 tile sits inside both → no clamp change.**
- **Storage:** `WebcamSceneConfig` table; new columns via `MigrateWebcamConfigTable()` as idempotent
`ALTER TABLE … ADD COLUMN` guarded on `PRAGMA table_info` + `LayoutStore.Save.cs` INSERT and
`LayoutStore.Load.cs` SELECT.
- **Precedent for remembered geometry:** `RectWidth/RectHeight` + `ToggleClipShape`
(`Models/SceneElement.cs` 124–152).
### What shipped in Unit 1 (2026-10-21) — do not re-hunt
- **Geometry only — NO render-path change.** The tier switch swaps the element's LIVE geometry; the
preview and the compositor read `X/Y/W/H` + `ClipShape` exactly as before.
- `Models/VerticalCameraLayout.cs` (new): the pure math — band x 656.25, w 607.5, h 1080; two
607×540 areas; six spots; default tile **270×152 master px** (= 480×270 in the 1080×1920 output),
**bottom-right** spot; inset 32 master px.
- `Models/WebcamSceneConfig.cs`: the vertical placement is persisted in **seven additive columns**
(`VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight`, 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. `BeginVerticalLayout` /
`EndVerticalLayout` / `CommitLiveLayoutToVertical` do the swap.
- `Models/SceneElement.cs`: `X/Y/Width/Height` are now **`virtual`** so the webcam clamps EVERY write
(drag, resize, typed fields) into the band — outside the band = invisible in vv.
- `ViewModels/MainViewModel.Webcam.cs`: `IsVerticalTier`/`VerticalBand` + `ResolveVerticalCameraLayouts`
(the tier swap; the **Live scene gets its tile automatically** on the first vertical switch) +
`MoveWebcamInVerticalTier`/`SnapWebcamInVerticalTier` (the six-spot snap, by the tile's CENTRE).
`MainViewModel.Streaming.cs` `ApplyStreamQuality` is the tier-change hook.
- `Controls/LeftPanel.xaml`: the webcam properties gained a **Geometry** section (X/Y/W/H) 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).
- `Controls/PreviewPane.xaml.cs`: dragging/resizing a webcam under the vertical tier routes through the
VM's snap instead of free-floating over the master.
- Test seam: the drag lives in a **VM method**, so the test drives it without synthesising mouse input.
### Units (each: ONE Good Dog integration test, own commit, memory in the same commit)
1. ✅ **vv camera layout (Unit 1)** — shipped 2026-10-21. Stored separately per scene, resolved when the
tier changes. Live: small tile (circle or rect) snapped to one of six spots; drag like the socials +
properties entry.
*Test:* `ytLive.Tests/VerticalWebcamLayoutTests.cs` — with the vertical tier active, dragging the Live
camera to top-right places it top-right inside the band; switching back to 16:9 restores the original
layout exactly; both placements survive a save + reload. **PASSES.**
2. ☐ **vv · Chat (Unit 2)** — rect enforced with the circle option unavailable, plus enlarge-to-one-area
(607×540) snapping top or bottom.
*Test:* in Chat under the vertical tier, enlarge sets the full top area and the circle option is not
offered. **Not started.**
## Next step
- Commit this unit (files listed above; gate already green at 427/427, 0 warnings). Then the creator
launches the app on the real DB: the five CRT tiles seed from `C:\Users\gramp\Downloads\Gemini_…`,
the staged scene's tile shows the on-air image, the preview shows the scene's real background with no
green frame, and the tile labels are legible (FontSize 20). Push is still at the creator's call.
- **vv Unit 2 (the Chat scene)** — the only queued piece of this feature. Requirements are locked in
`TASKS/task-57-vertical-camera-layout.md`: rect ONLY in the vertical tier (no circle option offered),
size either normal or enlarged to fill one 607×540 area, same six-spot snap; and the Chat scene's
16:9 exemption from the half-frame cap (`MaxWebcamWidthFor/HeightFor`) must not leak into the
vertical tier.
- **Creator check (needs the creator's hands):** switch the resolution dropdown to "Vertical 1080p60",
confirm the Live camera tile lands inside the band and the Shape button toggles circle/rect there,
then switch back and confirm the desktop layout is untouched. The unit test covers the geometry, not
the eyeball.
- Nothing else is queued for llamacasty — the creator queues work.
## Open (operational)
- **GOG Galaxy launcher shows "offline" (2026-10-21) — GOG's own overnight outage, NOT Windows.** GOG
put `www.gog.com` behind a maintenance page (503) and their Fastly services for `auth.gog.com` /
`api.gog.com` / `remote-config.gog.com` return `500 "Fastly error: unknown domain"`; the page itself
says *"Galaxy Launcher will also be down."* **Window: 00:00–08:11 local on 2026-10-21** — the Wayback
snapshot of `www.gog.com/en/` at 06:56 UTC is HTTP 200 with the full live storefront. `status.gog.com`
is NXDOMAIN globally. Local exoneration chain: genuine `GOG sp. z o.o → DigiCert` cert (no MITM),
local DNS == Google's authoritative answers, real Fastly POP headers, and GOG's own non-Fastly servers
still answering. **Nothing to fix locally** — wait for GOG. Galaxy's logs (incl. the rotated ones) show
no API-status failures between Oct 10 and Oct 20. Diagnosis recipe + the `webfetch`-is-not-an-
independent-egress trap: `MyMistakes.md` → 2026-10-21 GOG Galaxy "offline". Galaxy 2.0 logs live in
`C:\ProgramData\GOG.com\Galaxy\logs\` (NOT `%LOCALAPPDATA%`). ⚠️ that folder also holds a **2.7 GB
`cef.log`** (since Jul 13) — disk hygiene, see TASK 55.
- **verify.sh's known-failure whitelist is too loose** (confirmed twice 2026-10-16): require EVERY failure
line to be whitelisted, and add `MasterLimiter_CapsTheLiveMix_OnThePipe` +
`RealMouseDrag_OnTheLayerList_PersistsTheReorder` to the list.
- **A pre-existing stash is still sitting there:** `stash@{0}` = "WIP on main: 0464593 Compositor:
fast-path opaque 1:1 when source fills canvas" (NOT this session's; do not drop blindly).
- The judder reproduction take in the FAILING geometry (vertical 1080p60 first) is still outstanding — no
fix until a take names the stage.
- ⛔ Sudo password rotation pending (user; then update both `CREDENTIALS.md` password lines).
- TASK 56 secret scan before `gramps/LlamaCasty` goes public.
- TASK 55 disk hygiene (`startup.log` uncapped — 23MB and growing every 5s).
## Tests
- Full suite **427 passed / 0 failed / 0 skipped / 0 warnings** (2026-10-10). Known env flakes:
`Mix_HonorsProviderGains…` (fails while system audio plays), `RealMouseDrag…` (needs an idle desktop),
`Round_Clip_Corner…` (grab/geometry under active mouse). The verify gate whitelists only the latter two —
`RealMouseDrag…` failing mid-session needs one re-run when the desktop idles.
- Last full run: **429/429 passed, 0 warnings** (2026-10-21, this session — the vv Unit 1 gate run).
- Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`,
`MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`,
`Round_Clip_Corner…`.
- ⚠️ **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` is NOT
"passes in isolation" any more.** During this session it FAILED ALONE on a **clean tree** at
`58b9a7d` (the vv work stashed) with the same "Expected: ImgC / Actual: ImgA" — i.e. it is an
environment/real-mouse flake, **not** a regression from the vv work — and it PASSES in the
full-suite run (429/429). Treat it as environment-dependent, and don't chase it as a regression
without stashing first.
## Also still open
- **Vertical-format regression (STALE, not fixed):** 1080p60 vertical selection no longer shrinks the
capture border in the preview. Mechanism mapped (see prior summary): the crop Rectangle
`Controls/PreviewPane.xaml:319-322` bound to `OutputRectX/Y/W/H` + `IsOutputCropped`, driven by
`MainViewModel.Streaming.cs` `ApplyStreamQuality` from `SelectedQuality` (binding + ctor only,
`new("Vertical 1080p60", 60, 8.0, 1080, 1920)`), `DimRects` in `MainViewModel.cs:91-94`. Root cause
unknown — paused.
- ⛔ Sudo password rotation pending (user; then update both password lines in `CREDENTIALS.md`).
- TASK 56 secret scan before `gramps/LlamaCasty` goes public. TASK 55 disk hygiene queued.
## Secrets / paths (do not re-hunt)
- Layout DB: `%APPDATA%\ytLlive\ytLlive.db` (SQLite, full-rewrite per save). Auth:
`%APPDATA%\ytLlive\ytLlive.auth` (DPAPI). Startup log: `%APPDATA%\ytLlive\startup.log` (23MB).
- Recordings land in `C:\Users\gramp\Downloads\recordings\` (`ty-<date>-<time>-<ms>-<seq>.mp4`; a
duplicate name gets a `-2` suffix).
- **Conversation history:** `~/.local/share/opencode/opencode.db`, table **`session_message`** — creator
turns are `type='user'` with text in `data.text`; my turns are `type='assistant'` with reasoning AND the
rendered reply concatenated into `content[0].text`. The **reply marker's verbatim string is recorded in
`MyMistakes.md`**.
- LAN credentials: `CREDENTIALS.md` (gitignored) — **⚠️ stale for HA: it says `192.168.50.144`, the real
Home Assistant is `192.168.50.145`** (`homeassistant.local`, HAOS 18.2, Core 2026.10.1, SSH
`gramps`/`einstein`, `/config` root-owned). `gordito` is `.144`. Fix this file when next editing it.
- Google Cloud OAuth project number `198374416003`, owned by `llamachile.shop@gmail.com` — verify at
`https://console.cloud.google.com/apis/credentials?project=198374416003`.
## Open (operational) — officerFriendly / PS5 "DNS errors" (2026-10-17, off-repo)
- **Root cause: a ~43 h-wrong system clock on officerFriendly × `dnssec = true`.** Pi 400, **no RTC
hardware at all**, healthy oscillator (−7.39 ppm). Journal shows +54 min, +7 h, +42.9 h steps.
`pihole-FTL.db`: **1,688,396 `dnssec=3` rows on 10-11, 839,276 on 10-12 vs ≤23 other days**, hitting
PSN endpoints = the PS5's "DNS errors". KSK-rollover theory **retracted**.
- **Pending creator decision (do not apply unilaterally):** tighten
`/etc/systemd/timesyncd.conf.d/lan.conf` to `PollIntervalMinSec=16` / `PollIntervalMaxSec=64` /
`ConnectionRetrySec=8` / `SaveIntervalSec=30`, and/or add a DS3231 RTC (`i2c-tools` already installed).
- **LAN DNS routing vs creator intent:** DHCP still hands out `.26` + `.1` (clients query Pi-hole
directly) whereas the creator wants everything → `.1` with BBR forwarding. Also `dnssec = true` on
Pi-hole duplicates dnscrypt-proxy's `require_dnssec = true`. Both offered-for-veto, not done.
- Router is the DHCP server (ASUS ROG GT-BE98 Pro `.1`) — **Pi-hole `.26` is DNS only**, its
`dhcp.leases` is 0 bytes.
+8 -5
View File
@@ -184,15 +184,18 @@ public abstract class SceneElement : INotifyPropertyChanged
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
public abstract ImageSource? DisplaySource { get; }
// Position/transform (per-scene usage)
// Position/transform (per-scene usage). The geometry setters are virtual so
// a derived element can CONSTRAIN them (the webcam clamps itself into the
// vertical tier's band — TASK 57) instead of every writer re-implementing
// the rule.
private double _x;
public double X { get => _x; set => Set(ref _x, value); }
public virtual double X { get => _x; set => Set(ref _x, value); }
private double _y;
public double Y { get => _y; set => Set(ref _y, value); }
public virtual double Y { get => _y; set => Set(ref _y, value); }
private double _width;
public double Width
public virtual double Width
{
get => _width;
set
@@ -203,7 +206,7 @@ public abstract class SceneElement : INotifyPropertyChanged
}
private double _height;
public double Height
public virtual double Height
{
get => _height;
set
+69
View File
@@ -0,0 +1,69 @@
using System.Windows;
namespace ytLive.Models;
/// <summary>
/// The vertical (9:16) tier's camera rules — TASK 57, the "vv" feature.
///
/// The vertical tier is a centred band crop of the SAME 1920×1080 master the
/// 16:9 tiers use, so a camera placed for 16:9 lands outside the cropped
/// window and vanishes from the vertical output. The vertical tier therefore
/// keeps its OWN placement per scene: a small tile inside the band, snapped to
/// one of six spots — the band splits into a top and a bottom half, each with
/// a left, a centre and a right slot.
///
/// Geometry only: the preview and the compositor both read the element's own
/// X/Y/W/H + ClipShape, so the placement is resolved by swapping those values
/// when the tier changes. No render-path change.
/// </summary>
public static class VerticalCameraLayout
{
/// <summary>The vertical tier's camera tile over the master frame —
/// 270×152 master px = 480×270 in the 1080×1920 output.</summary>
public const double TileWidth = 270;
public const double TileHeight = 152;
/// <summary>How far the tile keeps from the band's outer edge — the app's
/// own placement margin (32 master px) used by the default webcam spot.</summary>
public const double Inset = 32;
/// <summary>True for the portrait tier (the vertical 1080×1920 one).</summary>
public static bool IsVerticalTier(QualityOption quality) => quality.Height > quality.Width;
/// <summary>The half of the band a tile lives in — top or bottom.</summary>
public static Rect AreaFor(Rect band, bool bottom)
=> new(band.Left, bottom ? band.Top + band.Height / 2 : band.Top, band.Width, band.Height / 2);
/// <summary>The largest a camera may be inside the band: one whole area.</summary>
public static (double Width, double Height) MaxSizeFor(Rect band)
=> (band.Width, band.Height / 2);
/// <summary>
/// The spot a tile snaps to: the area (top/bottom) and the column
/// (left/centre/right) its centre falls in, then the tile's top-left for
/// that spot — inset from the band's outer edge, and never outside the band
/// (a tile wider than the inset eats it).
/// </summary>
public static (double X, double Y) SpotFor(Rect band, double width, double height, double centreX, double centreY)
{
var bottom = centreY >= band.Top + band.Height / 2;
var area = AreaFor(band, bottom);
var third = area.Width / 3;
double x;
if (centreX < area.Left + third) x = area.Left + Inset;
else if (centreX < area.Left + 2 * third) x = area.Left + (area.Width - width) / 2;
else x = area.Right - Inset - width;
var y = bottom ? area.Bottom - Inset - height : area.Top + Inset;
x = Math.Clamp(x, band.Left, Math.Max(band.Left, band.Right - width));
y = Math.Clamp(y, band.Top, Math.Max(band.Top, band.Bottom - height));
return (x, y);
}
/// <summary>The default tile spot: the bottom-right corner of the band —
/// the same corner the 16:9 default webcam placement uses.</summary>
public static (double X, double Y) DefaultSpot(Rect band, double width, double height)
=> SpotFor(band, width, height, band.Right, band.Bottom);
}
+150
View File
@@ -1,3 +1,4 @@
using System.Windows;
using System.Windows.Media;
namespace ytLive.Models;
@@ -57,4 +58,153 @@ public class WebcamSceneConfig : SceneElement
set => BackgroundEffect = value ? BackgroundEffect.Blur
: BackgroundEffect == BackgroundEffect.Blur ? BackgroundEffect.None : BackgroundEffect;
}
// ─── Vertical (9:16) tier placement (TASK 57, the "vv" feature) ────────
// The vertical tier is a centred band crop of the same master frame, so the
// 16:9 placement lands outside the cropped window and the camera vanishes
// from the vertical output. The vertical tier therefore keeps its OWN
// position, size and shape per scene, stored here and resolved when the
// tier changes; switching back to 16:9 restores the desktop layout exactly.
/// <summary>The vertical placement's X (null until the scene has one).</summary>
public double? VerticalX { get; set; }
/// <summary>The vertical placement's Y (null until the scene has one).</summary>
public double? VerticalY { get; set; }
/// <summary>The vertical placement's width (null until the scene has one).</summary>
public double? VerticalWidth { get; set; }
/// <summary>The vertical placement's height (null until the scene has one).</summary>
public double? VerticalHeight { get; set; }
/// <summary>The vertical placement's clip shape (circle or rect) — the
/// creator's own choice per scene, independent of the 16:9 shape.</summary>
public ClipShape? VerticalClipShape { get; set; }
/// <summary>The vertical placement's pre-Round rect (so a Round tile
/// restores its aspect on toggle-back, like the 16:9 one does).</summary>
public double? VerticalRectWidth { get; set; }
/// <inheritdoc cref="VerticalRectWidth"/>
public double? VerticalRectHeight { get; set; }
/// <summary>True once this scene has a vertical placement of its own.</summary>
public bool HasVerticalLayout => VerticalX is not null;
/// <summary>True while the vertical placement is the LIVE geometry (the
/// element's own X/Y/W/H). The 16:9 layout is parked in
/// <see cref="LandscapeX"/> and friends until the tier switches back.</summary>
public bool VerticalLayoutLive { get; private set; }
/// <summary>The vertical tier's band over the master frame (transient — the
/// VM sets it when the tier changes). While the vertical placement is live
/// the camera's geometry is clamped to it: outside the band is invisible.</summary>
public Rect VerticalBand { get; set; } = Rect.Empty;
// The parked 16:9 layout — meaningful only while the vertical placement is
// live (it is the 16:9 geometry the tier switch restores).
public double LandscapeX { get; private set; }
public double LandscapeY { get; private set; }
public double LandscapeWidth { get; private set; }
public double LandscapeHeight { get; private set; }
public ClipShape LandscapeClipShape { get; private set; }
public double? LandscapeRectWidth { get; private set; }
public double? LandscapeRectHeight { get; private set; }
/// <summary>Parks the live (16:9) layout and brings a vertical placement
/// in. The vertical placement becomes the live geometry from here on.</summary>
public void BeginVerticalLayout(double x, double y, double width, double height, ClipShape clipShape)
{
LandscapeX = X;
LandscapeY = Y;
LandscapeWidth = Width;
LandscapeHeight = Height;
LandscapeClipShape = ClipShape;
LandscapeRectWidth = RectWidth;
LandscapeRectHeight = RectHeight;
VerticalLayoutLive = true;
Width = width;
Height = height;
X = x;
Y = y;
ClipShape = clipShape;
RectWidth = VerticalRectWidth;
RectHeight = VerticalRectHeight;
}
/// <summary>Restores the parked 16:9 layout — the tier switched back.</summary>
public void EndVerticalLayout()
{
if (!VerticalLayoutLive) return;
// The vertical placement is the creator's own — keep it, so switching
// back and forth does not lose where they put it.
CommitLiveLayoutToVertical();
VerticalLayoutLive = false;
Width = LandscapeWidth;
Height = LandscapeHeight;
X = LandscapeX;
Y = LandscapeY;
ClipShape = LandscapeClipShape;
RectWidth = LandscapeRectWidth;
RectHeight = LandscapeRectHeight;
}
/// <summary>Copies the live geometry into the stored vertical placement, so
/// the stored copy never lags the live one (the save path calls this).</summary>
public void CommitLiveLayoutToVertical()
{
VerticalX = X;
VerticalY = Y;
VerticalWidth = Width;
VerticalHeight = Height;
VerticalClipShape = ClipShape;
VerticalRectWidth = RectWidth;
VerticalRectHeight = RectHeight;
}
// The geometry is clamped into the band while the vertical placement is
// live — a camera outside the cropped window is a camera the viewers never
// see, and every writer (drag, resize handle, the properties fields) goes
// through these setters.
public override double X
{
get => base.X;
set => base.X = ClampToBand(value, base.Width, vertical: true);
}
public override double Y
{
get => base.Y;
set => base.Y = ClampToBand(value, base.Height, vertical: false);
}
public override double Width
{
get => base.Width;
set => base.Width = VerticalLayoutLive && !VerticalBand.IsEmpty
? Math.Min(value, VerticalBand.Width)
: value;
}
public override double Height
{
get => base.Height;
set => base.Height = VerticalLayoutLive && !VerticalBand.IsEmpty
? Math.Min(value, VerticalBand.Height / 2)
: value;
}
private double ClampToBand(double value, double size, bool vertical)
{
if (!VerticalLayoutLive || VerticalBand.IsEmpty) return value;
var band = VerticalBand;
var min = vertical ? band.Left : band.Top;
var max = vertical ? band.Right - size : band.Bottom - size;
if (max < min) return min;
return Math.Clamp(value, min, max);
}
}
+3 -2
View File
@@ -8,11 +8,12 @@ Plain data types. No logic beyond what a property can carry. See
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule |
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses) |
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
| `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider) |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
+397
View File
@@ -382,6 +382,52 @@ delay + ~100ms pipeline = exactly the measured 2.11–2.22s. The lag ALSO scales
"time since app launch" up to the ring depth — a take right after a restart reads as
~370ms while a fully-warmed app reads ~2.2s. Same code, wildly different numbers.
### Measuring webcam CAMERA cadence in a recording — the MAD method (RECIPE, 2026-10-16)
Derived while reading the first instrumented take. Answers "did the camera's cadence reach the
FILE?" — the ground truth beside the pump's own counters. Tool: **`scripts/webcam-cadence.py`**
(`python3 scripts/webcam-cadence.py TAKE.mp4 --x X --y Y --size 180 --start SECONDS`).
Three wrong readings were produced before it worked; each is a trap to skip:
1. **Exact pixel equality is useless.** H.264 re-encodes even a *fully static* region slightly
differently every frame, so `cur == prev` reported **57.9 distinct images/s** on a take whose
camera delivers 30/s — every frame "new". Use a THRESHOLD on the mean absolute difference
(MAD) between consecutive frames: the distribution is bimodal (codec noise < 0.1, real camera
motion > 1), so read the threshold off the histogram (0.5 for a moving subject) instead of
guessing.
2. **The crop must sit INSIDE a round clip.** The webcam is `clip=Round`, radius `r = min(w,h)/2`
= 145 for a 526×290 box. A square crop's corners lie at `s/√2` from the centre, so `s = 240`
→ 170 > 145 → the corners sampled the 60fps BACKGROUND behind the circle and the probe counted
60/s. Keep `s ≤ 1.41·r` (180 is safe for r=145). **The crop rect comes from the pump's
`FramePump scene dump:` line** (`rect=1425,748 526x290` → centre 1688,893).
3. **One scene at a time.** Element rects differ per scene, so a crop carried across a scene
switch sits on background and reports a multi-second "hold" that never happened (a 13.8s
"freeze" in the first pass was just the `Chat` scene). Pick a window from the dump timestamps
with `--start`.
4. **A still subject also produces long holds.** Threshold counting cannot distinguish "the
camera had nothing new to send" from "we repeated a frame" — read it BESIDE the pump's
`FramePump stats` lines (`N renders/5s` = composites/s, `webcam N/s` = arrivals), which do not
depend on subject motion.
**Healthy shape:** the hold histogram peaks at **2 frames** (30fps camera in a 60fps container:
every image twice → ~50% duplicates), distinct/s ≈ the camera's own rate, longest holds in the
low hundreds of ms. Oct 16 Release take: 29.6/s, **82% of images held exactly 2 frames**, longest
267ms. A peak at 4+ frames, or distinct/s approaching the composites/s figure, is the judder.
**Cross-check with ffmpeg's own filters (no numpy, uses this file's A/V-sync technique):** the metric
above is the mean absolute frame-to-frame difference, which is exactly what
`tblend=all_mode=difference,signalstats,metadata=print` reports as `YAVG`. Same crop, 40s window:
```bash
ffmpeg -v error -ss 45 -t 40 -i TAKE.mp4 \
-vf "crop=180:180:1598:803,scale=64:64,format=gray,tblend=all_mode=difference,signalstats,metadata=print:file=-" \
-f null - | grep YAVG | sed 's/.*YAVG=//' # count values <= threshold
```
It agreed with the script on the same take (52.2% duplicates / 28.6 distinct/s vs 50.7% / 29.6/s —
the windows differ slightly), so either path is trustworthy; use the script when the full take and
the hold histogram matter, this pipeline for a fast spot check.
### Feeding a rawvideo pipe at 60fps: deadline pacing + row-blit budget
Derived 2026-09-03 (take-3 diagnosis — the stats seam from `97ffc42` named the stage
@@ -746,6 +792,20 @@ Rules derived:
for the producer before blaming the test.
3. Catch-and-log-swallow of `ex.Message` hides root causes; log with stack (`AppLog.Write(ex,
...)`) and throttle (5s) instead of dropping or flooding.
**Second instance (2026-10-16) — a "timing flake" that was a test-side data race.** A full-suite run
failed `FramePumpTests.Stats_ReportRealRendersAndDistinctWebcamFrames_NotJustPaddedSubmits`; it passed
3/3 in isolation, which is exactly how a load-sensitive flake presents. It was not timing:
`System.InvalidOperationException: Collection was modified; enumeration operation may not execute` at
the test's own `log.Any(...)` line — 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). The app was innocent (production passes the
thread-safe `AppLog`). Fix: `ConcurrentBag<string>` (snapshot enumeration). **The lesson that produced
it:** the previous commit ADDED log lines from the pump thread, which raised the collision rate from
rare to routine — a latent race is promoted to a routine failure by any change that increases the
frequency of the shared access, and the failure then looks like a flake in the new code rather than a
race in the old test. When a test fails ONLY under load, check for a shared mutable collection on the
producer's thread before reaching for a retry.
Bonus: the "failing" test encoded the MAP's contract (`loopbackGain = GameAudioVolume`); the
code had drifted to unity on a disproven premise (loopback capture does NOT follow endpoint
volume — creator's 20%-volume/pegged-meter observation killed it). The test was right all
@@ -1585,3 +1645,340 @@ semantics* — anchored-capture vs follow-the-box — before building any "zoom"
captures ALL layers with an extra layer in the fixture and assert it appears; (3) "draw above
everything" is a compositor ordering property (skip in the layer loops + blit last), not a z-index
of the element among the others.
## 2026-10-17 — Your own conversation history is in `session_message`, NOT `part`
I was asked to repeat the creator's original query verbatim. After a compaction I had only the summary,
so I searched the session store and landed on the wrong table twice. In `part` my search for the
creator's phrases returned hits — but they were **my own reasoning text quoting the phrase back**, which
looks exactly like a real hit and sent me chasing roles (`message.data` role), then
`instruction_entry`/`instruction_blob` (which do **not** hold prompts at all).
The creator's turns live in **`session_message`**, one row per turn: `type='user'`, the text inside the
JSON `data.text`.
```bash
# find the session by any distinctive phrase the creator used
sqlite3 ~/.local/share/opencode/opencode.db \
"SELECT DISTINCT session_id FROM session_message WHERE data LIKE '%<phrase>%';"
# then dump that session's turns in order
sqlite3 ~/.local/share/opencode/opencode.db \
"SELECT seq, json_extract(data,'\$.text') FROM session_message \
WHERE session_id='ses_…' AND type='user' ORDER BY seq;"
```
Lessons: (1) when compacted and unsure *what was asked*, query the store — never reconstruct the
creator's words from a summary; (2) a phrase hit in `part` may be my own echo of it, so check the
table's `type` before trusting a match; (3) this is the fix for "I can't search my own history" — it is
one query away, and the creator found their own query manually while I guessed.
## 2026-10-17 — I asked the creator to verify a preview visual I could have looked at
Planning the vv webcam feature, I asked: "when you select the vertical tier, does the preview actually
dim outside the band?" The answer was, in effect, *of course — that's the standard*. Two failures
stacked:
1. **I propagated an unverified claim.** My only reason to doubt it was a `HANDOFF.md` landmine
("vertical-format regression … no longer shrinks the capture border", marked STALE/root-cause-
unknown). It had never been verified, and it was **wrong** as stated. A stale, unverified handoff
line is a question-generator, not a fact — check it against the code or the app before letting it
shape what I ask.
2. **It was checkable without the creator.** The crop rectangle and the dimming are wired in code
(`PreviewPane.xaml:319-322` + `DimRects`/`ApplyStreamQuality`), and the creator can see the result in
one glance. I spent the creator's scarcest resource on something one look would have settled.
Created 2026-10-17 and standing (the creator stated these explicitly, after being left unable to tell
my reasoning from my replies):
- **Every reply begins with the reply marker on its own line.** The creator will not read an unmarked
reply — reasoning and reply are indistinguishable in their UI. Do not drop it; do not rely on a
leading blank line instead (the renderer trims it).
**The marker, verbatim — the single line, first line of the reply, nothing before it:**
```
**✂ ━━━━━━━ ── REPLY ── ━━━━━━━ ✂**
```
**How to recover it if it is ever lost again (it cost a whole session once, 2026-10-17):** assistant
turns live in `session_message` (`type='assistant'`), and BOTH the reasoning and the rendered reply
are concatenated into that row's `content[0].text` — so the marker sits *after* the reasoning prose.
Search the store for the scissors character, not for the word "marker":
```bash
sqlite3 ~/.local/share/opencode/opencode.db \
"SELECT session_id, seq, data FROM session_message WHERE data LIKE '%✂%' \
ORDER BY time_created DESC LIMIT 5;"
```
(The `part` table has the same reasoning+text concatenation for v1 sessions; `event` holds no text.)
- **One explanation and one question per reply.** No question batteries, no "three things I need"
lists, no blue-sky option menus (A-vs-B, clamp behaviour) — those read as derailing.
- **State the decision I would make, in one line, and let the creator veto** — rather than presenting a
menu of choices for them to sort out.
- **Ask the creator only what only they can decide** (product intent). Anything the code, the app, or a
single look can answer, I answer myself.
## 2026-10-17 — officerFriendly's clock: no RTC exists, and "the cron I installed" did not exist
Chasing the PS5's "DNS errors" I took the creator's premise at face value — *"the pi's battery has
failed, which is why you installed a cron task to correct drift"* — and went looking for that cron.
There is none, and there is no battery:
- **Raspberry Pi 400 Rev 1.0** (`cat /proc/device-tree/model`) — a Pi 4-class board with **no RTC
hardware at all**: `ls /dev/rtc*` → nothing, `hwclock -r` → "Cannot access the Hardware Clock",
no `dns-rtc`/`i2c-rtc` line in `/boot/firmware/config.txt`, no `rtc_*` module in `lsmod`. So "the
battery failed" cannot be the mechanism — there is nothing to hold time across a power cut except
`fake-hwclock`'s **file** (`/etc/fake-hwclock.data`, saved hourly by the stock
`/etc/cron.hourly/fake-hwclock`, which only ever *saves* — it never corrects).
- **No drift-correction cron**: root crontab empty, `/var/spool/cron/crontabs/` empty, the only
time-related job in any cron dir is that stock `fake-hwclock` save.
- What *was* installed (this is the thing to point at next time): `/etc/systemd/timesyncd.conf.d/lan.conf`
(created in the work window) with IP-literal NTP servers —
`NTP=162.159.200.1 162.159.200.123 216.239.35.0 216.239.35.4` (Cloudflare + Google) so NTP needs no
DNS — but **poll settings left at Debian defaults** (`PollIntervalMinSec=32`, `PollIntervalMaxSec=34min8s`).
`timedatectl show-timesync` prints the live server/interval; `systemd-timesyncd` logs only the *step*
("Contacted time server…" + "Initial clock synchronization"), so a long silent gap means the first
successful contact was that late, nothing more.
- **The oscillator was healthy, so do not confuse "drift" with "wrong base".** `adjtimex` frequency
correction was **−484296 raw = −7.39 ppm = −0.64 s/day**: a fine crystal. A clock that is *two days*
off got there by a bad base + a slow first sync, not by drift.
- **The signature that this was a clock problem, not a KSK/trust-anchor problem**: with a validating
resolver a wrong clock makes *good* signatures BOGUS, so `pihole-FTL.db` fills with `dnssec=3` rows
while the root still validates fine (`dig @127.0.0.1 . DNSKEY` → `flags: qr rd ra **ad**`). Both root
anchors (`20326` KSK-2017 **and** `38696` KSK-2024) were already present in
`/etc/pihole/dnsmasq.conf` and `/usr/share/dns/root.key` months before — check the clock *before*
blaming a root KSK rollover.
- Remote-survey hygiene: pipe `pf.py --script` output to a file (`> /tmp/opencode/out.txt`) and read the
file — piping to `head` silently cut a survey in half mid-section and hid the very answer being sought.
## 2026-10-21 — I broke a working HA light system, then burned the session, and the one-line cause was an entity id
**What happened.** The Home Assistant light alert system (leak / alarm / pump / doorbell on five Tapo
bulbs, for a deaf user who needs lights, not sounds) was **verified working** — leak gave warm-white
wake bulbs + purple, alarm gave red, clear restored pump status. The creator then asked for **one**
change: the water pump should light the Death Star only. Instead of making that one change, I rewrote
the render script twice (nested `if/then/else`, then `choose:`), broke the alert path, and spent the
rest of the session on it. The day's planned work (llamacasty / the vv 9:16 webcam Unit 1) did not
happen. The creator's read — *"you took something that was working just fine and fucked it up"* — was
exactly correct.
**The actual root cause, found only after three failed remedies.** The script's YAML key is
`alerts_render`, but its **entity id was `script.alerts_render_lights`** (a leftover `unique_id`). Every
caller — `script.alerts_clear` and both render automations — called `script.alerts_render`, which **does
not exist**. `script.turn_on` on a non-existent entity returns **HTTP 200 and silently does nothing**.
So clear never re-rendered and alerts froze on the last colour painted. One `grep` of the real entity
list would have found it; I instead spent the session theorising about templates, races and `choose:`
semantics.
**Rules this produces.**
- **Do the one change asked for.** When a system is verified working, a requested tweak is a *tweak*.
Rewriting the surrounding structure is how working things become broken things.
- **NEVER trust `script.turn_on` returning 200.** It is a silent no-op on a bad entity id — no error, no
log line. Before blaming logic, confirm the entity exists: compare the YAML key against the real
entity id (`/api/states`, or the entity registry `unique_id`). A YAML key and the derived entity id
can differ whenever a `unique_id` predates a rename.
- **A second failed remedy for the same symptom = STOP and verify the plumbing, not the logic.** The
diff between "logic wrong" and "call going nowhere" is one status/entity check, and I skipped it
three times.
- **Test reads need the right window.** The doorbell's green lasted 8s; I polled at 9s, saw dark, and
called it broken. Read the state *inside* the window the code defines.
## HA TAPO TP-LINK: A HUB ENTRY POISONED BY DISCOVERY IS FIXED BY RE-ADDING WITH A PINNED HOST (RECIPE)
**Symptom:** a Tapo hub (H500) shows its entities as unavailable or `-100001`, and the config entry's
`host` has been silently rewritten to the IP of a **camera** on the same LAN. `.storage/core.config_entries`
shows the hub entry pointing at the camera's address while `alias`/`model` also got mangled.
**Cause:** HA's `tplink` discovery rewrites the entry host to whatever answers on the LAN, so a camera
that shares the hub's MAC (Tapo uses one MAC across its wifi/ethernet interfaces — `ip neigh` showing the
hub MAC against several IPs is a **red herring**) wins. Upstream: core#178994 (H200 host flips to camera
IPs), "closed" only by a python-kasa bump that does not fix it.
**Recipe (works — verified):**
1. Note the hub's real IP (the one with the hub's TLS cert, no RTSP open) and its MAC.
2. Delete the poisoned entry: `POST /api/config/config_entries/entry/{entry_id}/remove`.
3. Re-add through the config flow **specifying the host explicitly** so discovery never probes the camera:
- `POST /api/config/config_entries/flow {"handler":"tplink","show_advanced_options":true}`
- `user` step: `{"host":"<hub-ip>"}`
- `user_auth_confirm` step: `{"username":…,"password":…}` (Tapo account; `-40404` + `sec_left` =
account lockout, wait it out)
- returns `create_entry` with a **new** entry id and `host` pinned to the literal IP.
4. Verify the new entry is `loaded` and the alarm sensors (Tapo base bores into HA via the hub) come back.
**Two traps:** (a) re-POSTing a removed alarm sensor with `enabled:false` **re-creates** it — remove once,
don't touch; (b) if the host re-poisons after a restart, temporarily disable the offending camera entry to
starve discovery.
## HA: RECALL MEASUREMENTS THAT RULE OUT FLASHING TAPO BULBS (RECIPE)
Tapo bulbs cannot be flashed usefully. Measured on `light.officeone` (2026-10-20): API round-trip ~130ms,
**colour change ~500ms**, **brightness change ~5s** (255→25 consistently). So:
- **Only steady states.** A "flash" is a colour change with extra steps; stacking commands mid-ramp is
what leaves a bulb stuck on an old colour (the creator's green-at-100% bulbs came from exactly that).
- If a flash is ever truly needed, colour-only, ≥1000ms per state. Never pulse brightness.
- HA drops `rgb_color`/`brightness` from a light's state when it is off (core#2316, closed as
"not a bug"), so an event must end by **explicitly turning off** bulbs that were off — otherwise the
bulb keeps its internal colour memory and comes back the wrong colour.
## 2026-10-21 — GOG Galaxy says "offline": a Fastly "unknown domain" 500 is GOG's outage, not your LAN
**Symptom:** the GOG Galaxy launcher (2.0) shows the offline badge. The instinct is "my network" — on this
LAN (Pi-hole, dnscrypt, an ASUS router, a Pi with a 43 h-wrong clock two weeks ago) that instinct is
usually wrong, and chasing it burns a session. Read the launcher's own log FIRST.
**Where Galaxy 2.0 logs live (the hunt):** `C:\ProgramData\GOG.com\Galaxy\logs\` — **NOT**
`%LOCALAPPDATA%\GOG.com\Galaxy\logs` (that folder exists but has no `logs` in it; the layout/config
lives in `%LOCALAPPDATA%\GOG.com\Galaxy\Configuration\config.json`).
- `CommunicationService.log` — auth/API side.
- `GalaxyClient.log` — the Electron/Qt UI side, which is where the **offline decision** is made.
**The launcher decides "offline" here** (`GalaxyClient.log`):
`Failed to check API status. Assuming it's offline.` → `Authentication failed but API is offline. Assuming
persisted refresh token is valid.` → `nativeReceiver received an unknown command: ShowOfflinePage`.
So "offline" = *the API status check failed*, not "the socket is down".
**Recipe (works — verified 2026-10-21):**
1. `grep -iE "offline|Failed to sign in|responseCode=500" "C:\ProgramData\GOG.com\Galaxy\logs\GalaxyClient.log" CommunicationService.log`
2. Curl GOG's hosts from Windows and **read the body, not the status code**:
`curl.exe -sS -i https://www.gog.com/` and `https://auth.gog.com/auth`.
3. **Fastly answering `500` with `Fastly error: unknown domain: <host>` is GOG's outage, not your DNS.**
Their `gog.com` services are simply not configured on the edge right now. The cert still verifies
(`ssl_verify_result=0`), so it is NOT a MITM and NOT a hijack; the DNS (real Fastly 151.101.x.241) and
TLS are both fine.
4. Prove global-vs-LAN by hitting the same URL from a **second egress that is provably not yours** —
check its IP first (see the correction below: `webfetch` is NOT one). Same 503 ⇒ GOG-side, stop
looking at your network.
5. `status.gog.com` was **NXDOMAIN globally** (Google DoH, `Status:3`, authority = gog.com's Cloudflare
NS) during the outage — no status page to consult, so the maintenance page on `www.gog.com` is the
authoritative signal. It states it outright: *"GOG.COM is temporarily down for maintenance work…
Galaxy Launcher will also be down."*
**What it actually looked like:** `www.gog.com` → 503 (GOG's own maintenance page), `auth.gog.com` /
`api.gog.com` / `remote-config.gog.com` → 500 "unknown domain", `notifications-pusher.gog.com` → the
websocket refused to connect. Nothing to fix locally; installed GOG games still launch (DRM-free).
### ⛔ 2026-10-21 — CORRECTION: the "second egress" was NOT a second egress, and the timeline was wrong
The creator pushed back: *"I think windows networking fucked something up. No way GOG would be down for
days."* **The second half was right and my evidence was wrong.** Two corrections, both load-bearing:
- **`webfetch` is NOT an independent egress — it leaves through THIS machine's own connection.**
Verified: `webfetch https://ipinfo.io/json` and `curl.exe https://ipinfo.io/json` from Windows returned
the **same public IP (201.170.13.206, AS8151 Uninet/Telnor, Tijuana)**. So "the same 503 from another
network" was the same network twice — it proved nothing. **Before using any fetcher as an independent
vantage point, read its egress IP first** (`ipinfo.io/json` or `api.ipify.org`) and compare it with the
local machine's. Off-LAN fetchers that actually work: **`api.allorigins.win/raw?url=<encoded>`** (live
fetch, cloud egress) and the **Wayback Machine** (`web.archive.org/cdx/search/cdx?url=…` for snapshots,
`web.archive.org/web/<ts>id_/<url>` for the raw archived body). `r.jina.ai` answers **422** and
`api.codetabs.com/v1/proxy` answers **522** for these targets — they fail open and look like outages.
- **The timeline was wrong: it broke OVERNIGHT, not "for days".** The Wayback snapshot of
`https://www.gog.com/en/` at **2026-10-21 06:56 UTC** is **HTTP 200 with 295 KB of live store content**
(product listings, "Cyberpunk: Edgerunners 2.5 Update coming Oct 22"). 06:56 UTC = 23:56 local Oct 20.
By 08:11 local today Galaxy's first API check failed and the site served the maintenance page. So the
window is **00:00–08:11 local on 2026-10-21**, and the creator was right to refuse a "days" story.
**The proof stack that actually holds (all local checks, no assumptions):**
- **No TLS interception:** the cert presented for `www/api/auth.gog.com` is `CN=gog.com, O=GOG sp. z o.o,
L=WARSZAWA` issued by `DigiCert Global G2 TLS RSA SHA256 2020 CA1` — read identically from WSL
(`openssl s_client`) and from Windows (`SslStream`). A local middlebox would need GOG's private key.
- **No DNS poisoning:** local answers (Pi-hole `.26` + `1.1.1.1`) are byte-identical to Google's
authoritative answers for all three hosts. Pi-hole is clean; the router is clean.
- **Real Fastly edge:** `Server: Varnish`, `Via: 1.1 varnish`, `X-Served-By: cache-cyw-mmcy1560056-CYW`.
A local filter cannot forge Fastly's POP headers.
- **GOG's own (non-Fastly) servers still answer:** `notifications-pusher.gog.com` (91.222.185.x) completes
TCP+TLS and returns HTTP 400 on a plain GET — only the **Fastly-fronted** hostnames are gone. That is
GOG's edge config, not your network.
- **Off-LAN confirmation:** allorigins (cloud egress) returns the identical maintenance page.
- **Galaxy's own history:** across all rotated logs, `Failed to check API status` appears **only** on
2026-10-07 (a blip) and 2026-10-21; the `responseCode=500` / `Failed to retrieve remote configuration`
/ `Failed to sign in` lines exist **only in today's log**. The `OnConnectionFailed` noise from
`notifications-pusher` also fired on Oct 9/10/20 — that websocket has been failing for days and is
**not** the offline cause.
**Rule this produces:** a clean TLS handshake with a *real* vendor cert plus vendor edge headers plus an
authoritative-DNS match is a strong local-exoneration chain. Build it before arguing about whose fault
it is, and never let a same-egress fetch masquerade as a second opinion.
**Separate, unrelated noise in the same logs:** `Waiting for process initialization failed … Return code:
4294967295` for `GalaxyUpdater.exe`, repeating hourly — the **updater**, not the connection. Do not let it
steer the offline diagnosis.
**Also false-lead here:** `w32tm /query /status` said "The service has not been started. (0x80070426)" —
the Windows Time *service* is off, but the clock was right and TLS verified, so it was not the cause. The
clock is the first thing to suspect on *TLS* failures (the officerFriendly PS5 case), never on a clean
handshake with a valid cert.
## HA: THE ALERT SYSTEM'S ARCHITECTURE (RECIPE — the shape that works, do not "improve" it)
Producer/consumer split, and **one single writer** of the bulbs:
- `script.alerts_arm` / `alerts_clear` only write `input_text.light_alert` (`none|leak|alarm|motion`),
which survives a restart.
- `script.alerts_render_lights` is the **only** thing that touches the bulbs. Priority: an alert always
beats pump status. Flat top-level `if`/`else` blocks — a nested `if` or a `choose:` both silently lit
**nothing** in this configuration, so the flat shape is deliberate.
- Pump is **status**, not an alert: auto-clearing, Death Star only (creator directive), keyed off
`sensor.water_pump_current_consumption`, **never** off `switch.water_pump_2` — that switch was observed
latched `on` for hours at 0.0A draw, and using its state painted the bedroom blue at 5am.
- Alerts **hold** until a human clears them; a sensor returning to normal notifies but does not clear.
- Colours: leak purple, alarm red, motion orange (reserved), pump blue, doorbell green (doorbell is not
an alert — it guards on `light_alert != 'none'` and hands back to the render).
## 2026-10-21 — TWO LAYOUTS FOR ONE ELEMENT ACROSS OUTPUT TIERS, WITHOUT TOUCHING THE RENDER PATH (RECIPE — TASK 57)
The vertical (9:16) tier is a centred **band crop** of the same 1920×1080 master, so the 16:9
placement of an element lands outside the cropped window and the element vanishes from the
stream. The render path (preview + compositor) reads the element's own `X`/`Y`/`Width`/`Height` +
`ClipShape` and knows nothing about tiers — and that is the point: **do not teach the render
path about tiers.**
**The recipe (four moves):**
1. **Park the inactive layout in the model.** The element keeps a transient copy of the layout
that is not live (`LandscapeX/Y/Width/Height/ClipShape/RectWidth/RectHeight`) next to the
persisted copy of the other one (`VerticalX/…`, NULL = "not set for this scene").
2. **Swap on the tier change.** `BeginVerticalLayout(...)` parks the live layout and brings the
other in; `EndVerticalLayout()` commits the outgoing layout back into its stored slot and
restores the parked one. The live geometry IS the render input → the compositor and the
preview XAML stay untouched.
3. **Keep the persisted columns for ONE layout.** The old columns (X/Y/W/H/ClipShape) keep
meaning "the 16:9 layout"; the save writes the parked values there while the other tier is
live. The new columns carry the other layout. A save can then never lose either.
4. **Commit the live copy before you persist it.** The stored copy lags the live one until the
save — so the tier switch itself commits (otherwise switching away and back loses the
creator's placement), and the save commits again (the DB write must not race the copy).
**The clamp belongs in ONE place: the model's own setters.** Make the base geometry setters
`virtual` and override them in the derived element, so *every* writer — drag, resize handle,
typed properties fields, code — is constrained by the same rule. Three writers each re-implementing
"inside the band" is three chances to forget one. (This is why `SceneElement.X/Y/Width/Height`
became virtual.)
**Why not the alternative:** a computed "effective geometry" property (`RenderX`) would have
touched the compositor's element read *and* every preview binding — a render-path change for a
geometry-only problem. The swap keeps the render path a pure consumer of the live geometry.
**Test seam:** the drag logic must live in a method the VM exposes, not in the control's mouse
handler — the test then drives the placement without synthesising mouse input.
## 2026-10-21 — A XAML TEST THAT FINDS A PANEL BY BINDING TEXT BREAKS WHEN A SECOND PANEL SHARES THAT BINDING (TASK 57)
`WebcamBackgroundPillTests` locates the webcam's properties panel by **searching the XAML text
for `SelectedElement.IsWebcam`** and then asserts on it. Adding a second panel with the same
visibility binding made the selector match twice → `Sequence contains more than one matching
element` → a test failure that had nothing to do with the feature under construction.
**Rule:** a properties panel that a test finds by binding text must have a **unique** binding.
Use the VM's semantic flag (`IsWebcamSelected`) for a new panel instead of repeating
`SelectedElement.IsWebcam`, and when a test selects UI by binding text, expect to have to keep
that text unique. Cheap fix, but only if you know the test selects that way — read the test
before you add UI.
## 2026-10-21 — THE LAYOUT STORE WRITES THE WEBCAM ROWS ONLY WHEN A WEBCAM IDENTITY EXISTS (TASK 57)
`LayoutStore.Save(scenes, webcam, …)` writes the `Webcam` row **and** the per-scene
`WebcamSceneConfig` rows only when `webcam != null` — the config rows hang off the identity.
A test that adds a `WebcamSceneConfig` to a scene and saves **without** an identity persists
nothing, and the reload comes back with no camera (a confusing "my placement vanished"
failure). Supply a `Webcam` identity in the test (`new Webcam { Id = "webcam-1", DeviceId = … }`)
or the round-trip assertion is meaningless.
+76
View File
@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace ytLive.Services.Compositor;
/// <summary>
/// Per-stage composite timing (webcam-cadence unit, 2026-10-16): wall time per render
/// pass, split into named stages — the backdrop, every element (keyed by z-index +
/// name), the global overlays, and the final output scale. The pump owns one instance
/// for the whole session, passes it into <see cref="SceneCompositor.Render"/> /
/// <c>CompositeLayers</c> / <c>BakeStaticBase</c>, and logs an averaged breakdown every
/// stats window — the "which of the 6 elements eats ~140ms" reading the old stats line
/// could not give (it reported render/acquire/resolve, never the culprit layer).
/// <para>Stage names are stable and greppable: <c>backdrop</c>, <c>base</c>,
/// <c>flash</c>, <c>social</c>, <c>ticker</c>, <c>zoom</c>, <c>scale</c>,
/// <c>bake backdrop</c>, <c>bake overlays</c>, and <c>#&lt;index&gt; &lt;name&gt;</c> per
/// element (bake-pass elements are prefixed <c>bake</c> so a one-off rebake cannot
/// masquerade as a per-composite cost). A <c>#&lt;i&gt; &lt;name&gt; raster</c> sub-stage NESTS inside its parent's total
/// (it is the paste-cache rasterize miss — the prime suspect when one element costs a
/// full resample every tick). Stage time INCLUDES that stage's frame resolve; resolve
/// is separately totalled in the stats line.</para>
/// <para>Single writer in production (the pump's render thread — every blit joins its
/// <see cref="StripPool"/> workers before returning); the lock only makes a test able
/// to share one across threads.</para>
/// </summary>
public sealed class CompositeTimings
{
private readonly object _gate = new();
private readonly Dictionary<string, long> _stages = new(StringComparer.Ordinal);
/// <summary>Accumulate one stage sample. Non-positive ticks are dropped — a
/// skipped layer must not create a zero-cost entry that dilutes the reading.</summary>
public void Add(string stage, long ticks)
{
if (ticks <= 0 || stage.Length == 0) return;
lock (_gate)
{
_stages.TryGetValue(stage, out var cur);
_stages[stage] = cur + ticks;
}
}
/// <summary>Clear every stage — called once per stats window after the parts line
/// is logged, so each window reports its own breakdown.</summary>
public void Reset()
{
lock (_gate) _stages.Clear();
}
/// <summary>Average milliseconds per render for every stage at or above
/// <paramref name="minMs"/>, sorted slowest-first:
/// <c>"#2 Webcam 88.1ms, backdrop 61.2ms, #2 Webcam raster 84.0ms"</c>.
/// <paramref name="renders"/> is the divisor (the window's render count, so the
/// numbers compare directly with the stats line's <c>avg render</c>). The floor
/// keeps a healthy pipeline from burying the contender in zeroes; the empty string
/// means nothing was composited (the fully-static shortcut and C4 cache hits do no
/// per-element work — an empty parts line there is the correct reading).</summary>
public string Describe(int renders, double minMs = 0.1)
{
List<KeyValuePair<string, long>> snap;
lock (_gate)
{
if (_stages.Count == 0) return string.Empty;
snap = _stages.ToList();
}
var freq = (double)Stopwatch.Frequency;
var parts = snap
.Select(kv => (Stage: kv.Key, Ms: kv.Value / freq * 1000.0 / System.Math.Max(1, renders)))
.Where(p => p.Ms >= minMs)
.OrderByDescending(p => p.Ms)
.Select(p => $"{p.Stage} {p.Ms:F1}ms");
return string.Join(", ", parts);
}
}
+72 -17
View File
@@ -54,7 +54,8 @@ public sealed class SceneCompositor
VideoFrame? staticBase = null,
int split = -1,
byte[]? scratch = null,
VideoFrame? tickerFrame = null)
VideoFrame? tickerFrame = null,
CompositeTimings? timings = null)
{
if (scene == null) throw new ArgumentNullException(nameof(scene));
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
@@ -76,12 +77,15 @@ public sealed class SceneCompositor
{
if (staticBase.Width != cropW || staticBase.Height != cropH)
throw new ArgumentException("The static base must match the source rect.", nameof(staticBase));
var tBase = System.Diagnostics.Stopwatch.GetTimestamp();
Buffer.BlockCopy(staticBase.BgraPixels, 0, buffer, 0, buffer.Length);
timings?.Add("base", System.Diagnostics.Stopwatch.GetTimestamp() - tBase);
}
else
{
if (split < 0) split = scene.Elements.Count;
var tBackdrop = System.Diagnostics.Stopwatch.GetTimestamp();
var elements = scene.Elements;
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
@@ -149,8 +153,11 @@ public sealed class SceneCompositor
if (backgroundFrame != null)
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
foreach (var element in elements)
timings?.Add("backdrop", System.Diagnostics.Stopwatch.GetTimestamp() - tBackdrop);
for (var ei = 0; ei < elements.Count; ei++)
{
var element = elements[ei];
if (!element.IsVisible) continue;
switch (element)
{
@@ -162,7 +169,7 @@ public sealed class SceneCompositor
}
var fullFrame = frameFor(element);
if (fullFrame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, fullFrame);
BlitBakedElement(buffer, cropW, cropH, options, element, fullFrame, timings, $"#{ei} {element.Name}");
}
}
@@ -181,17 +188,29 @@ public sealed class SceneCompositor
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
BlitBakedElement(buffer, cropW, cropH, options, element, frame, timings, $"#{i} {element.Name}");
}
if (flashFrame != null)
{
var tFlash = System.Diagnostics.Stopwatch.GetTimestamp();
BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
timings?.Add("flash", System.Diagnostics.Stopwatch.GetTimestamp() - tFlash);
}
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
{
var tSocial = System.Diagnostics.Stopwatch.GetTimestamp();
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
timings?.Add("social", System.Diagnostics.Stopwatch.GetTimestamp() - tSocial);
}
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
{
var tTicker = System.Diagnostics.Stopwatch.GetTimestamp();
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
timings?.Add("ticker", System.Diagnostics.Stopwatch.GetTimestamp() - tTicker);
}
// The zoom box is layer n+1: a live 2x view of a FIXED capture window sampled
// from the frame just composited (all layers). Drawn LAST — above everything.
@@ -199,10 +218,17 @@ public sealed class SceneCompositor
// the box; the content it shows never changes (creator ruling 2026-10-08).
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
if (zoom != null)
{
var tZoom = System.Diagnostics.Stopwatch.GetTimestamp();
BlitZoom(buffer, cropW, cropH, options, zoom);
timings?.Add("zoom", System.Diagnostics.Stopwatch.GetTimestamp() - tZoom);
}
return StretchMath.BilinearScale(
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
var scaled = StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
return scaled;
}
/// <summary>
@@ -220,12 +246,14 @@ public sealed class SceneCompositor
Func<CompositorOptions> options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? flashFrame = null)
VideoFrame? flashFrame = null,
CompositeTimings? timings = null)
{
if (split <= 0) return null;
var opts = options();
if (opts.SourceRectWidth <= 0 || opts.SourceRectHeight <= 0) return null;
var tBake = System.Diagnostics.Stopwatch.GetTimestamp();
var cropW = opts.SourceRectWidth;
var cropH = opts.SourceRectHeight;
var buffer = new byte[cropW * cropH * 4];
@@ -250,6 +278,8 @@ public sealed class SceneCompositor
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
}
timings?.Add("bake backdrop", System.Diagnostics.Stopwatch.GetTimestamp() - tBake);
for (var i = 0; i < split; i++)
{
var element = scene.Elements[i];
@@ -264,14 +294,19 @@ public sealed class SceneCompositor
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, opts, element, frame);
BlitBakedElement(buffer, cropW, cropH, opts, element, frame, timings, $"bake #{i} {element.Name}");
}
var baked = new VideoFrame(cropW, cropH, buffer);
if (flashFrame != null)
baked = Overlay(baked, opts, flashFrame, 0, 0);
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
baked = Overlay(baked, opts, socialBarFrame, 0, socialBarTop);
if (flashFrame != null || (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0))
{
var tOverlays = System.Diagnostics.Stopwatch.GetTimestamp();
if (flashFrame != null)
baked = Overlay(baked, opts, flashFrame, 0, 0);
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
baked = Overlay(baked, opts, socialBarFrame, 0, socialBarTop);
timings?.Add("bake overlays", System.Diagnostics.Stopwatch.GetTimestamp() - tOverlays);
}
return baked;
}
@@ -291,7 +326,8 @@ public sealed class SceneCompositor
int socialBarTop = 0,
VideoFrame? flashFrame = null,
byte[]? scratch = null,
VideoFrame? tickerFrame = null)
VideoFrame? tickerFrame = null,
CompositeTimings? timings = null)
{
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
@@ -299,7 +335,9 @@ public sealed class SceneCompositor
var buffer = scratch != null && scratch.Length == baseFrame.BgraPixels.Length
? scratch
: new byte[baseFrame.BgraPixels.Length];
var tBase = System.Diagnostics.Stopwatch.GetTimestamp();
Buffer.BlockCopy(baseFrame.BgraPixels, 0, buffer, 0, baseFrame.BgraPixels.Length);
timings?.Add("base", System.Diagnostics.Stopwatch.GetTimestamp() - tBase);
for (var i = split; i < scene.Elements.Count; i++)
{
@@ -315,22 +353,35 @@ public sealed class SceneCompositor
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
BlitBakedElement(buffer, cropW, cropH, options, element, frame, timings, $"#{i} {element.Name}");
}
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
{
var tTicker = System.Diagnostics.Stopwatch.GetTimestamp();
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
timings?.Add("ticker", System.Diagnostics.Stopwatch.GetTimestamp() - tTicker);
}
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
if (zoom != null)
{
var tZoom = System.Diagnostics.Stopwatch.GetTimestamp();
BlitZoom(buffer, cropW, cropH, options, zoom);
timings?.Add("zoom", System.Diagnostics.Stopwatch.GetTimestamp() - tZoom);
}
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
var scaled = StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
return scaled;
}
private static void BlitBakedElement(
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame)
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame,
CompositeTimings? timings, string stage)
{
var tElement = System.Diagnostics.Stopwatch.GetTimestamp();
var ex = (float)(element.X - options.SourceRectX);
var ey = (float)(element.Y - options.SourceRectY);
var ew = (float)element.Width;
@@ -342,9 +393,10 @@ public sealed class SceneCompositor
// the device frame had not changed; an opaque source rasterizes to an opaque
// raster whose paste is a per-pixel copy branch, so hits beat the sampler and
// misses cost exactly what the bypass cost.
BlitCachedLayer(buffer, cropW, cropH, ex, ey, ew, eh, frame, element, isRound);
BlitCachedLayer(buffer, cropW, cropH, ex, ey, ew, eh, frame, element, isRound, timings, stage);
if (element.HasBorder)
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
timings?.Add(stage, System.Diagnostics.Stopwatch.GetTimestamp() - tElement);
}
/// <summary>Non-opaque layer blit through the paste cache: the element-space raster
@@ -356,7 +408,8 @@ public sealed class SceneCompositor
private static void BlitCachedLayer(
byte[] buffer, int cropW, int cropH,
float ex, float ey, float ew, float eh,
VideoFrame frame, SceneElement element, bool isRound)
VideoFrame frame, SceneElement element, bool isRound,
CompositeTimings? timings, string stage)
{
if (ew <= 0 || eh <= 0) return;
var dw = (int)System.Math.Ceiling(ew);
@@ -374,6 +427,7 @@ public sealed class SceneCompositor
if (raster == null)
{
var tRaster = System.Diagnostics.Stopwatch.GetTimestamp();
// Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255
// alpha only where it actually draws, so untouched margins (outside the
// round clip, past the UniformToFill edges) stay alpha 0 and the paste
@@ -398,6 +452,7 @@ public sealed class SceneCompositor
_pasteCache[key!] = raster;
_elementRasters[element] = raster;
}
timings?.Add(stage + " raster", System.Diagnostics.Stopwatch.GetTimestamp() - tRaster);
}
// Paste at integer position; opacity blends. The raster is never flagged
+75 -9
View File
@@ -35,6 +35,12 @@ public sealed class FramePump : IDisposable
private readonly SceneGraph? _sceneGraph;
private readonly SceneCompositor _compositor = new();
/// <summary>Per-stage composite timing for the CURRENT stats window (webcam-cadence
/// unit, 2026-10-16): the compositor records each stage into this collector and
/// <see cref="ReportStats"/> logs the averaged breakdown, then clears it — the
/// "which of the 6 elements eats ~140ms" reading (see the parts line).</summary>
private readonly CompositeTimings _timings = new();
// Master-buffer scratch pool (take-4 starvation fix, slice 2 of 2): a fresh
// 8.3MB byte[] every tick is ~500MB/s of LOH churn — GC stalls masquerading
// as render cost. The pump pools ONLY buffers it handed out (reference-equality
@@ -402,6 +408,19 @@ public sealed class FramePump : IDisposable
+ $"worst submit {worstSubmit / freq * 1000:F1}ms, "
+ $"dropped {dropped}, stalls {stalls}{cacheDetail}, {webcamCadence.Summarize().Describe("webcam")}"
+ (detail.Length > 0 ? " | " + detail : ""));
// Per-stage breakdown (webcam-cadence unit, 2026-10-16): the stats line above
// says WHAT a composite cost; this says WHERE it went — one entry per stage,
// slowest first, averaged per render so the numbers compare with `avg render`.
// No entries (fully-static shortcut, or every render a C4 cache hit) means no
// per-element work happened — that IS the reading. The sub-stage
// `#i <name> raster` is the paste-cache rasterize miss and nests in its
// element's total.
var parts = _timings.Describe(statRenders);
if (parts.Length > 0)
_log?.Invoke($"FramePump parts ({statRenders} renders/5s): {parts}");
_timings.Reset();
worstRender = 0;
worstSubmit = 0;
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
@@ -430,6 +449,8 @@ public sealed class FramePump : IDisposable
return frame;
}
Scene? lastDumpedScene = null;
try
{
while (!ct.IsCancellationRequested)
@@ -437,6 +458,18 @@ public sealed class FramePump : IDisposable
var scene = _sceneProvider();
if (scene != null)
{
// Scene dump (webcam-cadence unit, 2026-10-16): the 141ms regression
// could not be compared take-to-take because the log recorded no scene
// shape at all. Dump it when the pump starts on a scene and whenever
// the active scene changes — one header line plus one line per element
// (z-index, name, kind, source type, visibility, rect, opacity, clip).
if (!ReferenceEquals(scene, lastDumpedScene))
{
lastDumpedScene = scene;
foreach (var line in FormatSceneDump(scene, _sceneGraph?.GetSplitPoint(scene) ?? -1))
_log?.Invoke(line);
}
var iterStart = System.Diagnostics.Stopwatch.GetTimestamp();
var compositorOptions = _compositorOptions();
VideoFrame? socialBarFrame = null;
@@ -470,7 +503,7 @@ public sealed class FramePump : IDisposable
sw.Restart();
var scratch = AcquireScratch(scratchSize);
acquireTicks += sw.ElapsedTicks;
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver);
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver, _timings);
if (_transition is { Active: true } transition)
{
frame = transition.BlendFrame(frame);
@@ -614,13 +647,14 @@ public sealed class FramePump : IDisposable
VideoFrame? tickerFrame,
VideoFrame? flashFrame,
byte[]? scratch = null,
Func<SceneElement, VideoFrame?>? resolver = null)
Func<SceneElement, VideoFrame?>? resolver = null,
CompositeTimings? timings = null)
{
resolver ??= _frameResolver;
if (_sceneGraph == null)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver, timings);
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
return frame;
}
@@ -630,7 +664,7 @@ public sealed class FramePump : IDisposable
{
// Fully static scene: bake once, reuse.
var sw = System.Diagnostics.Stopwatch.StartNew();
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions, timings: timings);
if (baked != null)
{
// BUG (pre-existing, found while wiring the branding credit 2026-09-26):
@@ -639,31 +673,35 @@ public sealed class FramePump : IDisposable
// cached bake is not mutated and the flash still cannot get sticky in it.
if (socialBarFrame != null || tickerFrame != null || flashFrame != null)
{
var tOverlay = System.Diagnostics.Stopwatch.GetTimestamp();
var withOverlays = baked;
if (flashFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, flashFrame, 0, 0);
if (tickerFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
if (socialBarFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, socialBarFrame, socialBarTop, 0);
baked = withOverlays;
timings?.Add("overlay", System.Diagnostics.Stopwatch.GetTimestamp() - tOverlay);
}
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
var stretched = StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
ProbeRender("fully-static", scene, split, sw.ElapsedMilliseconds, (int)sw.ElapsedMilliseconds);
return stretched;
}
}
var swBase = System.Diagnostics.Stopwatch.StartNew();
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions, timings: timings);
if (baseFrame != null)
{
var frame = SceneCompositor.CompositeLayers(
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch);
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch, timings: timings);
ProbeRender("base+layers", scene, split, swBase.ElapsedMilliseconds, 0);
return frame;
}
// No static base (first layer is dynamic or empty scene) — full render.
var swFull = System.Diagnostics.Stopwatch.StartNew();
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver, timings);
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
return frame2;
}
@@ -676,7 +714,8 @@ public sealed class FramePump : IDisposable
private VideoFrame RenderFull(
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
VideoFrame? tickerFrame, VideoFrame? flashFrame,
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver)
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver,
CompositeTimings? timings = null)
{
// 1:1 guard: the cache stores the OUTPUT bytes and scratch is source-sized —
// at 1:1 they are the same buffer size. Off-size tiers (a vertical 1080×1920
@@ -697,7 +736,7 @@ public sealed class FramePump : IDisposable
return new VideoFrame(options.SourceRectWidth, options.SourceRectHeight, scratch);
}
var rendered = _compositor.Render(scene, resolver, flashFrame, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame);
var rendered = _compositor.Render(scene, resolver, flashFrame, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame, timings: timings);
CacheRenders++;
if (useCache)
{
@@ -718,6 +757,33 @@ public sealed class FramePump : IDisposable
return rendered;
}
/// <summary>The pump's scene dump (webcam-cadence unit, 2026-10-16): one header line
/// plus one line per element, in z-order, carrying the shape two takes must be
/// compared by — index, name, kind, source type, visibility, rect in master pixels,
/// opacity, clip. <paramref name="split"/> is the SceneGraph split point (-1 when no
/// graph is wired: the full-render path). The 10:03 vs 12:01 regression
/// (29–34ms → 143–155ms at "the same scene shape") could not be compared because the
/// log recorded no scene at all; this is that record.</summary>
internal static string[] FormatSceneDump(Scene scene, int split)
{
var lines = new List<string>
{
$"FramePump scene dump: '{scene.Name}' elements={scene.Elements.Count}"
+ (split >= 0 ? $" split={split}" : "")
};
for (var i = 0; i < scene.Elements.Count; i++)
{
var e = scene.Elements[i];
var source = e is Source s
? $" type={s.Type}{(s.IsBackground ? " background" : "")}"
: "";
lines.Add($" [{i}] {e.Name} kind={e.Kind}{source}"
+ $" vis={(e.IsVisible ? 1 : 0)} rect={e.X:F0},{e.Y:F0} {e.Width:F0}x{e.Height:F0}"
+ $" op={e.Opacity:F2} clip={e.ClipShape}");
}
return lines.ToArray();
}
/// <summary>Hash the complete set of inputs the compositor's FULL render consumes:
/// the crop/output dimensions, the social bar, and — in element order, mirroring
/// <c>SceneCompositor.Render</c>'s iteration — every element's layout/visual fields
+14 -1
View File
@@ -166,7 +166,9 @@ public partial class LayoutStore : IDisposable
cmd.CommandText = """
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength
RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength,
VerticalX, VerticalY, VerticalWidth, VerticalHeight, VerticalClipShape,
VerticalRectWidth, VerticalRectHeight
FROM WebcamSceneConfig ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -193,6 +195,17 @@ public partial class LayoutStore : IDisposable
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
BackgroundEffect = Enum.TryParse<BackgroundEffect>(reader.GetString(17), out var effect) ? effect : BackgroundEffect.None,
BackgroundStrength = reader.GetDouble(18),
// TASK 57: the vertical tier's own placement (null = this
// scene keeps its 16:9 placement under the vertical tier).
VerticalX = reader.IsDBNull(19) ? null : reader.GetDouble(19),
VerticalY = reader.IsDBNull(20) ? null : reader.GetDouble(20),
VerticalWidth = reader.IsDBNull(21) ? null : reader.GetDouble(21),
VerticalHeight = reader.IsDBNull(22) ? null : reader.GetDouble(22),
VerticalClipShape = reader.IsDBNull(23)
? null
: Enum.TryParse<ClipShape>(reader.GetString(23), out var vClip) ? vClip : null,
VerticalRectWidth = reader.IsDBNull(24) ? null : reader.GetDouble(24),
VerticalRectHeight = reader.IsDBNull(25) ? null : reader.GetDouble(25),
};
if (!configsByScene.TryGetValue(sceneId, out var list))
configsByScene[sceneId] = list = new List<(WebcamSceneConfig, int)>();
+22
View File
@@ -378,6 +378,28 @@ public partial class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN BackgroundStrength REAL NOT NULL DEFAULT 0.5;";
cmd.ExecuteNonQuery();
}
// TASK 57 (the "vv" feature): the vertical (9:16) tier keeps its OWN
// camera placement per scene — position, size, shape and the pre-Round
// rect. Additive, no version bump: a NULL column means "this scene has
// no vertical placement yet" (the scene keeps its 16:9 placement).
AddWebcamConfigColumnIfMissing(columns, "VerticalX", "REAL");
AddWebcamConfigColumnIfMissing(columns, "VerticalY", "REAL");
AddWebcamConfigColumnIfMissing(columns, "VerticalWidth", "REAL");
AddWebcamConfigColumnIfMissing(columns, "VerticalHeight", "REAL");
AddWebcamConfigColumnIfMissing(columns, "VerticalClipShape", "TEXT");
AddWebcamConfigColumnIfMissing(columns, "VerticalRectWidth", "REAL");
AddWebcamConfigColumnIfMissing(columns, "VerticalRectHeight", "REAL");
}
// One guarded ADD COLUMN for the webcam config's vertical-placement columns.
private void AddWebcamConfigColumnIfMissing(HashSet<string> columns, string name, string type)
{
if (columns.Contains(name)) return;
using var cmd = _connection.CreateCommand();
cmd.CommandText = $"ALTER TABLE WebcamSceneConfig ADD COLUMN {name} {type};";
cmd.ExecuteNonQuery();
columns.Add(name);
}
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
+48 -9
View File
@@ -211,10 +211,13 @@ public partial class LayoutStore : IDisposable
INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength)
RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength,
VerticalX, VerticalY, VerticalWidth, VerticalHeight, VerticalClipShape,
VerticalRectWidth, VerticalRectHeight)
VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
$clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
$rectWidth, $rectHeight, $sort, $bgEffect, $bgStrength)
$rectWidth, $rectHeight, $sort, $bgEffect, $bgStrength,
$vX, $vY, $vW, $vH, $vClip, $vRectWidth, $vRectHeight)
""";
cmd.Transaction = tx;
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
@@ -236,6 +239,13 @@ public partial class LayoutStore : IDisposable
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var bgEffectP = cmd.Parameters.Add("$bgEffect", SqliteType.Text);
var bgStrengthP = cmd.Parameters.Add("$bgStrength", SqliteType.Real);
var vXP = cmd.Parameters.Add("$vX", SqliteType.Real);
var vYP = cmd.Parameters.Add("$vY", SqliteType.Real);
var vWP = cmd.Parameters.Add("$vW", SqliteType.Real);
var vHP = cmd.Parameters.Add("$vH", SqliteType.Real);
var vClipP = cmd.Parameters.Add("$vClip", SqliteType.Text);
var vRectWidthP = cmd.Parameters.Add("$vRectWidth", SqliteType.Real);
var vRectHeightP = cmd.Parameters.Add("$vRectHeight", SqliteType.Real);
foreach (var scene in scenes)
{
@@ -248,20 +258,49 @@ public partial class LayoutStore : IDisposable
sceneIdP.Value = scene.Id;
webcamIdP.Value = config.WebcamId;
visibleP.Value = config.IsVisible ? 1 : 0;
xP.Value = config.X;
yP.Value = config.Y;
wP.Value = config.Width;
hP.Value = config.Height;
opacityP.Value = config.Opacity;
clipP.Value = config.ClipShape.ToString();
mirroredP.Value = config.IsMirrored ? 1 : 0;
colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
borderOpacityP.Value = config.BorderOpacity;
borderWidthP.Value = config.BorderWidth;
animationP.Value = config.BorderAnimation.ToString();
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
sortP.Value = z++;
// TASK 57: the X/Y/W/H/ClipShape columns stay the 16:9
// layout — while the vertical tier is live the LIVE
// geometry is the vertical placement and the 16:9 one is
// parked in the model — and the Vertical* columns carry
// the vertical placement itself.
if (config.VerticalLayoutLive)
{
config.CommitLiveLayoutToVertical();
xP.Value = config.LandscapeX;
yP.Value = config.LandscapeY;
wP.Value = config.LandscapeWidth;
hP.Value = config.LandscapeHeight;
clipP.Value = config.LandscapeClipShape.ToString();
rectWidthP.Value = (object?)config.LandscapeRectWidth ?? DBNull.Value;
rectHeightP.Value = (object?)config.LandscapeRectHeight ?? DBNull.Value;
}
else
{
xP.Value = config.X;
yP.Value = config.Y;
wP.Value = config.Width;
hP.Value = config.Height;
clipP.Value = config.ClipShape.ToString();
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
}
vXP.Value = (object?)config.VerticalX ?? DBNull.Value;
vYP.Value = (object?)config.VerticalY ?? DBNull.Value;
vWP.Value = (object?)config.VerticalWidth ?? DBNull.Value;
vHP.Value = (object?)config.VerticalHeight ?? DBNull.Value;
vClipP.Value = (object?)config.VerticalClipShape?.ToString() ?? DBNull.Value;
vRectWidthP.Value = (object?)config.VerticalRectWidth ?? DBNull.Value;
vRectHeightP.Value = (object?)config.VerticalRectHeight ?? DBNull.Value;
cmd.ExecuteNonQuery();
}
}
+3 -2
View File
@@ -111,7 +111,8 @@ public sealed class SceneGraph
Func<CompositorOptions> options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? flashFrame = null)
VideoFrame? flashFrame = null,
CompositeTimings? timings = null)
{
var split = GetSplitPoint(scene);
if (split == 0) return null;
@@ -124,7 +125,7 @@ public sealed class SceneGraph
}
var baked = SceneCompositor.BakeStaticBase(
scene, split, frameFor, options, socialBarFrame, socialBarTop, flashFrame);
scene, split, frameFor, options, socialBarFrame, socialBarTop, flashFrame, timings);
if (baked == null) return null;
lock (_gate)
+4 -3
View File
@@ -15,7 +15,7 @@ External-facing logic: YouTube API, persistence. See
| `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. **Still the fallback** when no alert clip is playable (TASK 47) |
| `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain, and `AlertTickerFrame` composes the scrolling message strip |
| `AlertClipDecoder.cs` | **Per-play alert video decoder (TASK 47):** `IAlertClipDecoder` (Start/Stop/Dispose + `FrameAvailable`/`AudioReady`/`Completed`) + ffmpeg child emitting `rawvideo bgra` (`-vf scale=W:H`, read via `RawVideoFrameReader`) and `f32le -ar 48000 -ac 2` on a **carry-buffer** audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) |
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `BackgroundEffects/IPersonSegmenter.cs` | **TASK 51 seam:** `Segment(VideoFrame) → SegmentationMask?` (`SegmentationMask(Width, Height, float[] Values)`, 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes |
| `BackgroundEffects/OnnxPersonSegmenter.cs` | **TASK 51 default segmenter:** MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) |
@@ -41,7 +41,8 @@ External-facing logic: YouTube API, persistence. See
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
| `WebView2Manager.cs` | **Web-source ownership (TASK 17, composition-capture since 2026-09-14):** per registered `Source { Type: WebSource }` it owns a `CoreWebView2CompositionController` + its visual tree (`RootVisualTarget` child under a 1920×1080 root `ContainerVisual` on one `Windows.UI.Composition.Compositor` created on the UI thread via the CoreMessaging `CreateDispatcherQueueController` P/Invoke), a wrapped `WebCaptureFrameSource`, and the shared crop-sized `WriteableBitmap` preview (dispatcher-coalesced copy at Render priority, `PreviewBitmapChanged` raised only on recreation). Nav + `TransparentBackgroundScript` injection unchanged. Public ctor `(Dispatcher)`; internal seam ctor `(Dispatcher, Func<string, IScreenCaptureSource>?)` for hermetic tests (no WinRT). Caller: `MainViewModel.InitWebView2()` (now invoked from `MainWindow_Loaded` — the parent HWND must exist). See `../ai.md` "Slice 14" |
| `WebCaptureFrameSource.cs` | The `IScreenCaptureSource` for a web session: owns the `GraphicsCaptureItem` (`CreateFromVisual`) + a free-threaded `Direct3D11CaptureFramePool` (2 buffers) + session; readback via `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, **`BitmapAlphaMode.Straight`** — the compositor blends straight alpha); per-frame `FindContentBounds` alpha-bbox (internal static, unit-tested) stamps `VideoFrame.CropBounds`; ring up to 2 fresh frames + Epoch reuse; `IsOpaque=false`. Mirror of `ScreenCaptureFrameSource` |
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). **TASK 47:** `Render` + `CompositeLayers` take a trailing `tickerFrame` blitted at (0,0) after the bar — the alert ticker is a message-scrolling dynamic overlay, never baked. Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). **TASK 47:** `Render` + `CompositeLayers` take a trailing `tickerFrame` blitted at (0,0) after the bar — the alert ticker is a message-scrolling dynamic overlay, never baked. Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor". **2026-10-16 (measurement only):** `Render`/`CompositeLayers`/`BakeStaticBase` take a trailing optional `CompositeTimings?` — every stage and every element timed (`#<i> <name>`, nested `raster`) and reported by the pump's parts line; zero cost for callers that pass nothing |
| `Compositor/CompositeTimings.cs` | **Per-element composite timing (2026-10-16, measurement only)**: the collector the pump owns for a stats window — `Add(stage, ticks)` accumulates, `Describe(renders, minMs = 0.1)` prints the averaged breakdown slowest-first. Stage names: `backdrop`/`base`/`flash`/`social`/`ticker`/`zoom`/`scale`/`overlay`, `bake backdrop`/`bake overlays`/`bake #i name`, and **one entry per element `#<z-index> <name>`** with the nested `raster` sub-stage (paste-cache rasterize miss) — the line that names WHICH of the six elements eats the render budget. See `ai.md` "Per-element composite timing + scene dump" |
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
@@ -57,7 +58,7 @@ External-facing logic: YouTube API, persistence. See
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47:** adds an `alertTicker: Func<VideoFrame?>?` ctor seam (parallel to `socialBar`), read per tick and threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`** so the ticker scroll changes the cache key. See `ai.md` "Live frame pipeline" |
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47:** adds an `alertTicker: Func<VideoFrame?>?` ctor seam (parallel to `socialBar`), read per tick and threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`** so the ticker scroll changes the cache key. See `ai.md` "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
| `INotificationService.cs` | **Toast seam (TASK 24)**: `Show(title, message, AppNotificationSeverity)` + `AppNotificationSeverity { Info, Success, Warning, Error }` + `Info/Success/Warning/Error` extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread |
| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (MainWindow bottom-right above the footer); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" |
+29 -3
View File
@@ -72,6 +72,7 @@
| 54 | **Marcom round: free-forever positioning + merch storefront** | 🔶 In progress — llamacasty.com copy live 2026-10-08 (pricing stripped); merch POD + app About links queued | [`TASKS/task-54-marcom-free-merch.md`](TASKS/task-54-marcom-free-merch.md) |
| 55 | **Disk hygiene: log rotation + low-disk guard** | ☐ Queued (2026-10-09) — `startup.log` appears uncapped; also temp alert-clip cleanup + free-space check before recording | [`TASKS/task-55-disk-hygiene.md`](TASKS/task-55-disk-hygiene.md) |
| 56 | **Pre-publication secret scan (full git history)** | ☐ Queued (2026-10-09) — gate before the Gitea repo goes public; report commit/path only, never values | [`TASKS/task-56-secret-scan.md`](TASKS/task-56-secret-scan.md) |
| 57 | **Vertical (9:16) tier camera placement — the "vv" feature** | 🔶 **Unit 1 shipped 2026-10-21** (Live scene: own placement per tier — small tile, circle-or-rect, one of six spots; drag-snap + manual entry; 16:9 restored exactly). **Unit 2 open** (Chat scene: rect only, normal or enlarged to one 607×540 area) | [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md) |
---
@@ -154,6 +155,15 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
- **TASK 14** — Branch 2 shipped; branch 3+ in progress
- **TASK 21** — UI picker slice remaining (Windows-only verification pending)
- **TASK 32–36** — all queued future work (2026-09-01)
- **🔶 TASK 57 — the "vv" vertical camera layout.** The vertical tier is a centred band crop of
the same master, so the 16:9 camera placement falls outside the cropped window and the creator
is not in the vertical stream at all. **Unit 1 shipped 2026-10-21:** each scene keeps its own
vertical placement (position, size, shape); the Live scene gets a small tile in one of six spots
(top/bottom × left/centre/right), circle or rect, placed by drag-snap or typed into the new
Geometry fields; switching back to 16:9 restores the desktop layout exactly. **Unit 2 is next:**
the Chat scene in the vertical tier — rect only, either normal or enlarged to one 607×540 area
(the creator's table, 2026-10-17). See [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md)
and `ai.md` → "Vertical (9:16) tier camera placement".
- **Webcam resource lifecycle (2026-09-17)** — startup slice shipped (OS poll at start, single-camera
auto-lock, persistent red alert in the Layers panel on lock failure, re-polls until resolved); gate
slice shipped same day — webcam = app-level default, one per stream per scene, Add places the
@@ -171,9 +181,25 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
so the file is **88.7% duplicate frames** and that is the judder. 720p triples the bytes stretched
per render on an already-over-budget path. Keep `ExclusiveControl` + 720p as a **sharpness** win
and schedule it AFTER the compositor work.
- **⏭ ACTUAL NEXT: per-element composite timing + a scene dump at pump start.** One run names which
of the 6 elements eats 140ms and explains the 10:03→12:01 regression (29–34ms → 143–155ms, same
scene shape). Measurement only — no fix until it names the element.
- **✅ SHIPPED 2026-10-16 (measurement only): per-element composite timing + a scene dump.**
`CompositeTimings` + the pump's `FramePump parts (N renders/5s): …` line and
`FramePump scene dump:` block (see `ai.md` → "Per-element composite timing + scene dump").
One integration test: `ytLive.Tests/CompositeTimingTests.cs`. **NOW WAITING ON A CREATOR TAKE
(Release):** the parts line must name which of the 6 elements eats the ~140ms and the dump must
show the shape of the slow take vs the fast one (10:03 → 12:01, 29–34ms → 143–155ms, same shape).
**No fix until a real reading names the element** — the next unit is the fix that reading dictates.
- **📖 FIRST INSTRUMENTED TAKE READ (2026-10-16, Release, recording, 1920×1080 1:1 master tier):
THE JUDDER DID NOT REPRODUCE — and no element is expensive.** 60 composites/s, avg render
1.0–1.1ms, biggest element in the scene = webcam **0.4ms** (raster 0.2ms), backdrop 0.2ms, the
other four under the 0.1ms floor; webcam 30.0/s at the resolver (longest gap 50ms); the FILE holds
29.6 distinct webcam images/s with 82% of images held exactly 2 frames. **Nothing to fix yet.**
The historic slow numbers are different configurations, not a slow scene — Oct 9 19:48 was the
**vertical** tier (`comp=608x1080`, 45ms) and Oct 5 12:01's geometry was never logged (the
`scene=/comp=` suffix didn't exist); every slow window shows `cache xR/0H` (the whole-frame cache
never reusing) while the healthy one shows `151R/150H`, and one Oct 5 take latches fast→slow
mid-take. **NEXT: a Release take in the failing geometry** — vertical tier first, then the 21:9
display config — and only that take's `parts` line names the stage. See `ai.md` → "✅ What the
first instrumented take said".
- **Unit 2 (later, unchanged):** blinking amber **"SD" badge**, preview only, never in the
recording/stream, when a better format was available and not obtained. Top-right corner, or the
1:30 rim point when the clip is round.
+69
View File
@@ -0,0 +1,69 @@
# TASK 57 — Vertical (9:16) tier camera placement — the "vv" feature
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Status:** 🔶 Unit 1 shipped 2026-10-21 (the Live scene). **Unit 2 open** (the Chat scene).
## Why
The vertical tier (1080×1920) is a centred **band crop** of the same 1920×1080 master every
16:9 tier uses: `OutputRect*` = x 656.25, y 0, w 607.5, h 1080. The default webcam sits at
x≈1408 — **outside the cropped window entirely**, so in a vertical stream the creator is simply
not in the picture. Switching tiers must not cost the creator their camera.
## The creator's locked table (2026-10-17)
| mode | shape | size | position |
|---|---|---|---|
| 16:9 | as today | as today | as today |
| **vv · Live** | circle **or** rect, the creator's choice | small tile | one of **six** spots: top/bottom × left/centre/right |
| vv · Chat | rect only | normal, or enlarged to one 607×540 area | the same six spots (Unit 2) |
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
Plus: each scene keeps its **own** vertical position, size and shape; switching back to 16:9
restores the desktop layout **exactly**; placement is set by drag with snap (like the socials)
**and** by manual entry in the properties.
## Design (Unit 1)
- **Geometry only — no render-path change.** The preview and the compositor read the element's
own `X`/`Y`/`Width`/`Height` + `ClipShape`, so the tier switch swaps the live geometry:
`WebcamSceneConfig.BeginVerticalLayout(...)` parks the 16:9 layout and brings the vertical one
in; `EndVerticalLayout()` commits the vertical placement back to its stored slot and restores
the parked 16:9 layout.
- **Storage.** Seven additive columns on `WebcamSceneConfig` (`VerticalX/Y/Width/Height/
VerticalClipShape/VerticalRectWidth/VerticalRectHeight`), NULL = "this scene has no vertical
placement yet". The X/Y/W/H/ClipShape columns stay the **16:9** layout, so a save never loses
the desktop placement.
- **The band and the six spots** (`Models/VerticalCameraLayout.cs`, pure math): the band splits
into two 607×540 areas (top/bottom), each with three columns (left/centre/right). A tile snaps
to the spot its **centre** falls in, inset 32 master px from the band's outer edge and never
outside the band. Default tile: 270×152 master px (480×270 in the 1080×1920 output), bottom-right
spot — the corner the 16:9 default webcam uses. The Live scene gets it automatically the first
time the vertical tier is picked; the other scenes keep their 16:9 placement until the creator
drags them into the band.
- **One clamp, one choke point.** `SceneElement.X/Y/Width/Height` became `virtual` and the webcam
constrains every write (drag, resize handle, typed Geometry fields) into the band — outside it
the camera is invisible in the vertical stream.
- **Manual entry.** The webcam's Layers properties gained a Geometry section (X/Y/W/H) bound to
the **live** placement — the one the current tier shows.
## Slices (one integration test each)
1. ✅ **Unit 1 — the Live scene** (shipped 2026-10-21): the vertical tier brings the Live camera
into the band as a small tile; dragging it to the top-right snaps it top-right inside the
band; switching back to 16:9 restores the desktop layout exactly; both placements survive a
save + reload. `ytLive.Tests/VerticalWebcamLayoutTests.cs` — the ONE test for the unit.
2. ☐ **Unit 2 — the Chat scene**: rect only (no circle) in the vertical tier, either the normal
size or enlarged to fill one 607×540 area; the same six-spot snap. The Chat scene's 16:9
exemption from the half-frame cap must not leak into the vertical tier.
3. ☐ **Creator check** (needs the creator's hands): switch the dropdown to "Vertical 1080p60",
confirm the camera tile lands inside the band and the Shape button toggles circle/rect in the
vertical tier, then switch back and confirm the desktop layout is untouched.
## Notes
- OBS cannot do this (no per-aspect layout storage — the maintainers' answer is a second scene
fed as a source); Streamyard documents the same gap. Our per-scene, per-tier stored placement
resolved on the tier change is the step past both.
- The vertical tier's output crop is TASK 53's business (safe zones, caption clearance); the
camera tile is inside the band, but the band is not yet proven against TikTok/Shorts chrome.
+8
View File
@@ -422,6 +422,14 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(OutputRectHeight));
OnPropertyChanged(nameof(IsOutputCropped));
OnPropertyChanged(nameof(ResolutionBadgeText));
// TASK 57 (the "vv" feature): the tier decides which camera placement
// is live — the vertical tier brings each scene's own vertical placement
// in, 16:9 puts the desktop layout back exactly where it was.
OnPropertyChanged(nameof(IsVerticalTier));
OnPropertyChanged(nameof(VerticalBand));
if (ResolveVerticalCameraLayouts(VerticalCameraLayout.IsVerticalTier(quality)))
ScheduleSave();
}
public ICommand StartStreamCommand { get; }
+130
View File
@@ -367,6 +367,12 @@ public partial class MainViewModel
_sceneGraph.AddElement(scene, config);
SelectedElement = config;
// A camera added while the vertical tier is live starts inside the
// band (TASK 57) — outside it, the camera would not be in the stream.
if (IsVerticalTier)
ResolveVerticalCameraLayout(scene, config, vertical: true);
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
@@ -448,4 +454,128 @@ public partial class MainViewModel
_ = AddWebcamToStagedSceneAsync();
}
// ─── Vertical (9:16) tier camera placement (TASK 57, the "vv" feature) ──
// The vertical tier is a centred band crop of the same 1920×1080 master, so
// the 16:9 camera placement falls outside the cropped window and the camera
// is simply not in the vertical stream. Each scene therefore keeps its OWN
// vertical placement, and the tier switch swaps the live geometry (the
// preview and the compositor both read the element's own X/Y/W/H — no
// render-path change).
/// <summary>True while the portrait tier (1080×1920) is the active tier.</summary>
public bool IsVerticalTier => VerticalCameraLayout.IsVerticalTier(SelectedQuality);
/// <summary>The vertical tier's cropped window over the master frame.</summary>
public Rect VerticalBand => new(OutputRectX, OutputRectY, OutputRectWidth, OutputRectHeight);
/// <summary>
/// Resolves every scene's camera placement for the tier that was just
/// picked: the vertical tier brings each scene's own vertical placement in
/// (the Live scene gets its small tile on first use), and switching back to
/// 16:9 puts the desktop layout back exactly where it was.
/// </summary>
private bool ResolveVerticalCameraLayouts(bool vertical)
{
var changed = false;
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
changed |= ResolveVerticalCameraLayout(scene, config, vertical);
return changed;
}
private bool ResolveVerticalCameraLayout(Scene scene, WebcamSceneConfig config, bool vertical)
{
if (!vertical)
{
config.VerticalBand = Rect.Empty;
if (!config.VerticalLayoutLive) return false;
config.EndVerticalLayout();
return true;
}
config.VerticalBand = VerticalBand;
if (config.VerticalLayoutLive) return false;
// A scene with a vertical placement of its own gets it back.
if (config.HasVerticalLayout)
{
config.BeginVerticalLayout(
config.VerticalX!.Value,
config.VerticalY!.Value,
config.VerticalWidth ?? VerticalCameraLayout.TileWidth,
config.VerticalHeight ?? VerticalCameraLayout.TileHeight,
config.VerticalClipShape ?? config.ClipShape);
return true;
}
// The Live scene's camera must be INSIDE the band or the vertical
// output has no camera at all — it starts as a small tile in the
// bottom-right spot (the corner the 16:9 default uses). The other
// scenes keep their 16:9 placement until the creator drags them into
// the band (creator ruling 2026-10-17: Starting/BRB/Ending unchanged).
if (!SceneCatalog.Is(scene.Name, SceneCatalog.Live)) return false;
var spot = VerticalCameraLayout.DefaultSpot(
VerticalBand, VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight);
config.BeginVerticalLayout(
spot.X, spot.Y,
VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight,
config.ClipShape);
return true;
}
/// <summary>
/// Moves a camera inside the vertical band and snaps it to one of the six
/// spots (top/bottom × left/centre/right). The drop point is where the
/// camera's top-left would land; the spot is the one its centre falls in.
/// </summary>
public void MoveWebcamInVerticalTier(WebcamSceneConfig config, double x, double y)
{
if (!IsVerticalTier) return;
EnsureVerticalCameraLayout(config);
var spot = VerticalCameraLayout.SpotFor(
VerticalBand, config.Width, config.Height,
x + config.Width / 2, y + config.Height / 2);
config.X = spot.X;
config.Y = spot.Y;
}
/// <summary>Snaps a camera back onto its spot — the resize handle grew or
/// shrank it and the spot's anchor has to follow.</summary>
public void SnapWebcamInVerticalTier(WebcamSceneConfig config)
{
if (!IsVerticalTier) return;
EnsureVerticalCameraLayout(config);
var spot = VerticalCameraLayout.SpotFor(
VerticalBand, config.Width, config.Height,
config.X + config.Width / 2, config.Y + config.Height / 2);
config.X = spot.X;
config.Y = spot.Y;
}
/// <summary>Makes the scene's vertical placement live — creating it (a small
/// tile in the default spot) when the scene has none yet.</summary>
private void EnsureVerticalCameraLayout(WebcamSceneConfig config)
{
if (config.VerticalLayoutLive) return;
if (config.HasVerticalLayout)
{
config.BeginVerticalLayout(
config.VerticalX!.Value,
config.VerticalY!.Value,
config.VerticalWidth ?? VerticalCameraLayout.TileWidth,
config.VerticalHeight ?? VerticalCameraLayout.TileHeight,
config.VerticalClipShape ?? config.ClipShape);
return;
}
var spot = VerticalCameraLayout.DefaultSpot(
VerticalBand, VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight);
config.BeginVerticalLayout(
spot.X, spot.Y,
VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight,
config.ClipShape);
}
}
+2 -2
View File
@@ -14,11 +14,11 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
| `MainViewModel.Zoom.cs` | **Zoom box (TASK 52, creator-corrected 2026-10-08)** `ActivateZoom`/`ToggleZoom`/`DismissZoom`/`StartZoomMove`/`UpdateZoomMove`/`EndZoomMove` + `RefreshZoomUi`/`RebuildDimRectsForZoom`. Right-clicking the live preview anchors a FIXED **capture window** (the box/2 region centred on the click) and parks a movable **2x magnifier**. The box shows a live always-on 2x view of the anchored capture; **dragging moves only the box — `CaptureX/Y` are deliberately untouched in `UpdateZoomMove`** (creator ruling). Each shape is centred on the click and clamped independently (box to the master, capture to box/2 windows): Circle = square, VerticalRectangle = 9:16, HorizontalRectangle = **4:3** (w=h·4/3 with h=0.7·1080; NOT 16:9 — first cut read as a square). The transient `ZoomSceneElement` mirrors box + capture; the output compositor draws it **LAST (layer n+1)** from the already-composed frame (`SceneCompositor.BlitZoom`→`ZoomMagnifier`) so it captures EVERY layer. No per-frame builder/cache — the resolver case that built `ZoomFrameBuilder` frames was removed. `BuildCompositorOptions` (internal = test seam) always returns the tier crop — the box is never the crop source. |
| `MainViewModel.Background.cs` | Screen backdrop (Live-only by policy): `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture); background model `EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`/`HealBackgrounds`/`StampDefaultBackgroundAsset`; screen capture `ReacquireScreenCaptures`/`ResolveAutoCaptureKey`/`RedesignateBackgroundAsync`/`ClearBackgroundCaptureAsync`/`SetBackgroundCapture`/`OnScreenPreviewBitmapChanged`/`OnFullscreenMonitorChanged`/`RefreshBackgroundAutoCapture`/`BackgroundShowDesktop` (keys on full-screen-game monitor or primary display via `Win32FullScreenDetector`); display picker `Displays`/`SetBackgroundDisplayCommand`; preview fns `ActiveBackgroundImage`/`BackgroundImage`/`UpdateActiveBackground`/`UpdateBackgroundImage`/`RefreshSnapshotsAsync`/`LoadBackgroundImage`/`CreatePlaceholderSnapshot`; `ChangeCaptureCommand`/`RefreshCaptureCommand`/`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`. `ShowPreviewPlaceholder` stays in core. |
| `MainViewModel.Webcam.cs` | Webcam identity: `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`; per-scene configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`/`ChangeWebcamAsync` (Windows picker always opens); capture `OnCameraPreviewBitmapChanged`/`OnCameraFailed`; gating `CanAddWebcam`/`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`; size safeguards `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`; `AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`. |
| `MainViewModel.Webcam.cs` | Webcam identity: `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`; per-scene configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`/`ChangeWebcamAsync` (Windows picker always opens); capture `OnCameraPreviewBitmapChanged`/`OnCameraFailed`; gating `CanAddWebcam`/`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`; size safeguards `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`; `AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`. **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera placement — `IsVerticalTier`/`VerticalBand` + `ResolveVerticalCameraLayouts`/`ResolveVerticalCameraLayout` (the tier swap: the vertical tier brings each scene's own vertical placement in, the Live scene gets its small tile on first use, 16:9 puts the desktop layout back exactly) + `MoveWebcamInVerticalTier`/`SnapWebcamInVerticalTier` (the six-spot snap) + `EnsureVerticalCameraLayout`. |
| `MainViewModel.Audio.cs` | **Mic bar:** read-only realtime meter `AudioLevel`/`MeterFillWidth`/`MeterBrush` (fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)`, green→yellow→red at 60%/80% zones), `MicVolume` (0.8 default; drives `MicMuted` ⇔ volume 0; unmute flash `BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`), `MicSourceName` (left-justified inside the bar, persists via `LayoutStore.Settings`), `ToggleMicMute`/`PickMicrophone` (`MicPickerDialog`; picked device swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running), status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip` (`Models.MicStatus`: green Connected / yellow Problem / red no-device), capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live. **Game audio bar:** overlaid bottom-center, always visible (show/hide gating removed 2026-08-15); `GameAudioLevel` (loopback via `OnLoopbackLevelChanged`), `GameAudioVolume`/`GameMuted`/`GameMuteText`/`ToggleGameMute`/`PushSystemVolume`, mirror volume flash `SetGameVolumeAdjusting`/`BeginGameVolumeFlash`/`EndGameVolumeFlash`/`CancelGameVolumeFlash`. Mixer handlers `OnMicLevelChanged`/`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`. |
| `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). |
| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). |
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` on first open, never the OS viewer); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public, integration-test live) + `PremiumUrl` (tabled billing seam — TASK 12 → greyed "Unlock Premium" lights up when filled) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. Ctor command assignments stay in core. |
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
+186 -6
View File
@@ -128,9 +128,15 @@ exact instruction if the folder is missing. `FFMPEG_ZIP=<path>` reuses a pre-dow
```bash
# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project:
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
"/mnt/c/Program Files/dotnet/dotnet.exe" run
"/mnt/c/Program Files/dotnet/dotnet.exe" run -c Release # the launch command (creator, 2026-10-16): Release, not Debug — perf takes must read Release numbers
```
**Run-mode convention (creator directive 2026-10-16):** the creator launches with `-c Release`
by DEFAULT. Debug runs happen ONLY when the AI explicitly names the Debug command for a
specific reason — never implicitly, and never for reading performance numbers (Debug pixel
paths are several × slower; every take in the 141ms saga read Debug numbers where Release is
the real reading).
**Pre-commit gate (write it down — I forgot once):** ALWAYS run
`./scripts/verify.sh "<file1>" "<file2>" ...` (declared scope) before committing. It forces a
**clean** build (0 warnings), the full test suite (passes only the 2 known pre-existing `[FAIL]`s), and
@@ -697,6 +703,72 @@ which also `ScheduleSave`s). Per raw frame a background worker (latest-frame-dro
(`BlurRadius(0.5)` = 2, `BlurRadius(1)` = 6) and the behaviour at a step edge: default smears
light ~9px into the dark half vs ~26px at full strength.
### Vertical (9:16) tier camera placement (TASK 57 — "vv" Unit 1, built 2026-10-21)
**The problem.** The vertical tier (1080×1920) is a centred **band crop** of the SAME 1920×1080
master: `OutputRect*` = x 656.25, y 0, w 607.5, h 1080. A camera placed for 16:9 (the default
webcam sits at x≈1408) lands **outside the cropped window entirely** — in the vertical stream the
creator is simply not in the picture.
**The rule (creator's locked table, 2026-10-17).** Each scene keeps its **own** vertical
placement — position, size and shape — and switching back to 16:9 restores the desktop layout
exactly:
| mode | shape | size | position |
|---|---|---|---|
| 16:9 | as today | as today | as today |
| **vv · Live** | circle **or** rect (the creator's choice, per scene) | small tile | one of **six** spots: top/bottom × left/centre/right |
| vv · Chat | *Unit 2* (rect only; normal, or enlarged to one 607×540 area) | — | — |
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
**Design — geometry only, no render-path change.** The preview and the compositor both read the
element's OWN `X`/`Y`/`Width`/`Height` + `ClipShape`, so the tier switch **swaps the live
geometry** instead of teaching the render path about tiers:
- `WebcamSceneConfig` carries the vertical placement (`VerticalX/Y/Width/Height/VerticalClipShape`
+ `VerticalRectWidth/Height` — seven additive DB columns, NULL = "this scene has no vertical
placement yet") and the **parked** 16:9 layout (`LandscapeX/…/LandscapeRectHeight`, transient).
- `BeginVerticalLayout(...)` parks the live 16:9 layout and brings the vertical one in;
`EndVerticalLayout()` commits the vertical placement back into the stored slot and restores the
parked 16:9 layout. The live geometry IS the render input → **the compositor and
`PreviewPane.xaml` are untouched**.
- The save keeps the X/Y/W/H/ClipShape columns as the **16:9** layout (while the vertical tier is
live, the parked values are written there and the live ones into the `Vertical*` columns).
- `SceneElement.X/Y/Width/Height` became **virtual** so the webcam can CONSTRAIN every write
(drag, resize handle, properties fields) into the band — one choke point instead of three
writers re-implementing the rule. Outside the band = invisible in the vertical stream.
**The band and the six spots** (`Models/VerticalCameraLayout.cs`, pure math): the band is
656.25…1263.75 × 0…1080; it splits into two 607×540 areas (top/bottom), each with three columns
(left/centre/right) → six spots. A tile snaps to the spot its **centre** falls in, inset 32 master
px from the band's outer edge (the app's own placement margin) and never outside the band. The
default tile is **270×152 master px** (480×270 in the 1080×1920 output) in the **bottom-right**
spot — the corner the 16:9 default webcam uses. The Live scene gets that tile automatically the
first time the vertical tier is picked (otherwise the vertical stream has no camera at all); the
other scenes keep their 16:9 placement until the creator drags them into the band.
**Manual entry** (creator rule): the webcam's Layers properties gained a **Geometry** section
(X/Y/W/H) — it edits the LIVE placement, i.e. the one the current tier shows. The webcam's
geometry setters clamp it into the band, so a typed value can never push the camera out of the
cropped window.
**Derivative work.** OBS has no built-in way to store a scene's layout per aspect ratio — the
maintainers' answer is "create a new empty 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" — streamyard.com/blog/dual-aspect-ratio-streaming-tool). Our per-scene, per-tier stored
placement, resolved on the tier change, is the step past that.
**Tests.** `VerticalWebcamLayoutTests` — the ONE integration test for the unit: the vertical tier
is picked → the Live camera comes INTO the band as a small tile → dragged to the top-right it
lands top-right inside the band → back to 16:9 the desktop layout is restored exactly → and both
placements survive a save + reload. (The store writes the webcam rows only when the app has a
webcam identity — the config rows hang off it — so the test supplies one.)
**Known trap (see `MyMistakes.md`):** a second properties panel bound to `SelectedElement.IsWebcam`
broke `WebcamBackgroundPillTests` — that test finds the webcam's panel by **binding text**, so a
new panel must use a different binding (`IsWebcamSelected`) or the selector matches twice.
### Frame cadence — how a dropped frame became measurable (UNIT 0, 2026-10-05)
**The problem this exists to solve.** The recorded file cannot distinguish a dropped camera frame
@@ -749,9 +821,13 @@ pure pixel work in the composite, not frame lookup. Two consequences, both load-
the judder measurably worse.
- **The 10:01 take hit 25.6 images/s and the 12:01 take 6.8, same scene shape
(`full-render split=0 elements=6 dynamic=5`), same 640×480 camera.** Composites went 29–34ms →
143–155ms between sessions with nothing logged explaining it. **There is no per-element composite
breakdown and no scene dump at pump start**, so the log cannot name the culprit — that gap is the
next thing to close, and guessing at it is explicitly out of bounds.
143–155ms between sessions with nothing logged explaining it. That comparison was made by EYE on
two takes because the log carried no per-element breakdown and no scene dump — both now exist
(see "Per-element composite timing + scene dump" below); naming the culprit is a reading, not a
guess, and guessing is explicitly out of bounds. **Superseded 2026-10-16:** "same scene shape" was
an eye comparison of takes whose geometry was never logged, and the next bad take still on disk is
the *vertical* tier (below). A Release 1920×1080 take now measures 1.0ms with no element over
0.4ms — see "Per-element composite timing + scene dump" → what the first instrumented take said.
**Counter subtlety, so it is not re-derived:** the pump's `webcam` line read 13/s while the file held
6.8/s. Not a bug — the counter stamps a frame when the resolver FIRST sees it, and one composite takes
@@ -763,6 +839,105 @@ design.
`SetMediaStreamPropertiesAsync` attempts are refused under `SharedReadOnly`, so 720p MJPG is available
— but see above, it is a quality win only.
### Per-element composite timing + scene dump (UNIT 0 follow-up, 2026-10-16 — MEASUREMENT ONLY)
The gap the cadence work left open is now closed by instrumentation, and closed by nothing else: the
141ms take was never compared to the 30ms take because **no line in the log said which element cost
what, and no line said what the scene WAS.** Both now exist. No fix has been attempted, and none is
allowed until a real reading names the culprit.
- **`Services/Compositor/CompositeTimings.cs`** — a collector owned by the pump, threaded through
`SceneCompositor.Render` / `CompositeLayers` / `BakeStaticBase` / `BlitBakedElement` /
`BlitCachedLayer` (and `SceneGraph.GetBakedBase` → bake path) as an optional last parameter, so
every other caller (thumbnails, tests, transition preview) is untouched and costs nothing.
`Add(stage, ticks)` accumulates; `Describe(renders, minMs = 0.1)` renders the averaged breakdown
slowest-first, dropping sub-0.1ms noise.
- **Stage names** (stable, greppable): `backdrop` (prefill + live capture + background art + their
resolves), `base` (the cached-bake `BlockCopy`), `flash`, `social`, `ticker`, `zoom`, `scale`,
`overlay` (the fully-static path's per-frame overlay copies), `bake backdrop`, `bake overlays`, and
**one entry per element as `#<z-index> <name>`** — the index IS the z-order, so the log names the
layer by both position and name. Bake-pass elements are prefixed `bake #…` so a one-off rebake
cannot masquerade as a per-composite cost.
- **`#<i> <name> raster` nests inside its element's total** and is the paste-cache rasterize miss
(`BlitCachedLayer`'s `raster == null` branch) — the direct answer to "is one element re-sampling
every tick, or is the paste itself slow". A stage's time includes that stage's frame resolve;
`resolve` is separately totalled in the existing stats line.
- **`FramePump parts (N renders/5s): …`** — logged once per 5s window (same window as
`FramePump stats`), averaged per render so the numbers compare directly with `avg render`, then the
collector is reset. **No parts line, or a short one, is a reading too:** the fully-static shortcut
and C4 cache hits composite no elements (`cache 92R/130H` beside a missing parts line is expected).
- **`FramePump scene dump: '<name>' elements=N split=S`** followed by one line per element
(`[i] name kind=… type=… vis=… rect=x,y WxH op=… clip=…`), logged when the pump first runs and
whenever the active scene changes. This is the record the 10:01/12:01 comparison never had.
- Gate test: `ytLive.Tests/CompositeTimingTests.cs` (one `[Fact]`) — a REAL composite of a full-canvas
element plus a 64×64 one 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.
- **`scripts/webcam-cadence.py`** — the other half of a reading: what the FILE holds (distinct webcam
images/s + hold histogram), beside what the pump says it did. Recipe and its three traps (H.264
re-encoding, the round-clip crop, scene switches): `MyMistakes.md` → "Measuring webcam CAMERA cadence
in a recording".
**Read takes in Release.** The creator launches `dotnet run -c Release`; Debug pixel paths are
several× slower, so a Debug number is not a reading of this pipeline (see Run).
**Derivative check (AGENTS.md):** this is not invented — OBS's own answer to "which source is slow" is
the **Source Profiler**, which reports the *average execution time of each source's tick and render
functions* ([docs.obsproject.com → Source Profiler](https://docs.obsproject.com/reference-libobs-util-source-profiler),
per-source timers via `util/profiler.h`). Same shape here: one timer per element per composite, reported
as an average, plus the stage split. The `#<i> <name> raster` sub-stage is the extra this case needs —
it separates "re-sampled every tick" from "slow paste" without another session.
### ✅ What the first instrumented take said (2026-10-16, Release) — the judder DID NOT reproduce
Take `ty-20261016-1841-559-0002.mp4` (174.5s, 1920×1080@60, Release build `e95154d9` — a **recording**,
not preview; scene `Live`, switched to `Chat` at 28s, back to `Live` at 44s) and its log window:
| Reading | This take |
|---|---|
| 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 are under the 0.1ms print floor |
| C4 full-composite cache | **151R/150H** per window — half the ticks were a whole-frame `BlockCopy` |
| webcam at the resolver | **30.0/s**, longest gap **50.0ms** (one extra 60Hz frame = sampling phase, not a drop) |
| the file itself | **29.6 distinct webcam images/s, 82% of images held exactly 2 frames**, longest hold 267ms (`scripts/webcam-cadence.py`) |
Nothing in this take is over budget and **no element is expensive — there is no culprit here to fix.**
The two stalls in the take are not per-composite cost: 124ms on the very first render (warm-up) and
53ms on the switch into `Chat` (a rebake).
**Scale of the disagreement:** the historic slow readings are 141ms (Oct 5 12:01) and 45ms (Oct 9
19:48) — 40–140× the ~1ms this take needed per composite. Three facts about those takes, all read off
the same log:
- **Oct 9 19:48 (`ty-20261009-1948-121-0000-2.mp4`) was the VERTICAL tier**, not a 16:9 one:
`FramePump started (1080×1920 @ 60 fps) scene='Live' comp=608x1080` — the 607×1080 centre crop
bilinear-upscaled to 1080×1920 (2M px), 92 renders/5s, avg render 45.0ms, worst 278ms, 92 stalls,
`cache 92R/0H`. The file on disk is 11.6s and every frame is identical (a static desktop), so it
cannot be read for cadence — only its cost.
- **Oct 5 12:01 (`…1201-556-0001.mp4`, no longer on disk) logged `FramePump started (1920×1080 @ 60
fps)` with NO `scene=/comp=` suffix** — that suffix did not exist yet, so its source rect was never
recorded and the map's "21:9 → 1920×803" explanation stays a hypothesis.
- **A take can latch mid-take.** Oct 5 12:29 goes 1.5ms (avg render) → 3.0ms → **45.2ms within 15
seconds and stays there** (`cache 10R/0H`, then `98R/0H`). So the slow state is not a fixed property
of a scene geometry. The consistent signature of every slow window is `cache xR/**0H**`: the C4
whole-frame cache never reused once, so every tick composited. The healthy take shows the other
stable state (composite 1ms → every second tick is byte-identical → whole-frame reuse). **The latch
is the thing to test on a reproducing take** — it is a hypothesis, not a reading; the `parts` line
is what will decide it.
**What this does NOT say.** This take is the 1:1 master tier and its scene logs
`full-render split=0 elements=6 dynamic=4` (the bad-period takes logged `dynamic=5`), so it never
exercises the failing geometry. "The healthy path is healthy" is not "the judder is gone". Reproducing
it needs a Release take in the failing configuration (vertical tier first, then the 21:9 display
config) — that take's `parts` line names the stage, and only then is a fix unit allowed.
**Reading the `parts` line's denominators:** like `avg render`, it is divided by `statRenders`, which
counts C4 REUSES as well as real composites (the 301 above = 151 real + 150 whole-frame copies). So in
a healthy 30fps-camera take both averages are ~2× optimistic; the `cache NR/NH` pair on the same line
says how much of the window was real composite work. `CompositeTimings.Describe` drops stages under
0.1ms by default, and **a missing or short parts line is itself a reading** — a fully-static window
(every tick a C4 hit) composites no elements at all.
### Render budget — getting a composite inside 16.67ms (UNITs 4-5, 2026-10-05)
The pump emits one encoder frame per 16.67ms slot and **pads with a duplicate when a composite
@@ -774,8 +949,13 @@ so a slow stage names itself, and logs the scene name + compositor source rect a
21:9 display shrinks the source rect to 1920×803 ("fit", not "fill"), so the opaque-backdrop 1:1 row
walk is skipped, the base is prefilled with opaque black (~2M byte writes), and the backdrop is
resampled per-pixel (~1.54M pixels with an alpha branch). Every recorded A/B lines up:
21:9 → 141ms, 16:9 → 30-33ms, 2560×1440 (fits to 1920×1080 exactly) → fast path. It was never
Debug-vs-Release; today's Debug run on 16:9 with the game up measured 33ms.
21:9 → 141ms, 16:9 → 30-33ms, 2560×1440 (fits to 1920×1080 exactly) → fast path.
**Corrected 2026-10-16 — the "never Debug-vs-Release" half of that is withdrawn.** A Release
1920×1080 take measures **1.0–1.1ms** where an Oct 9 take of the same tier measured 10.4ms, and
`Build <hash>` carries no build configuration, so the config of every pre-2026-10-16 take is
UNKNOWN: no historic render number may be compared against a Release take. The 21:9 explanation
itself stays a hypothesis — the 141ms take's start line predates the `scene=/comp=` suffix, so its
source rect was never recorded. A take can also latch mid-take (see the instrumentation section).
**Unit 4 — cheap wins, all pixel-identical:**
- Opaque 1:1 fast paths expanded to the whole-canvas opaque case (a `BlockCopy`, not a per-pixel
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""webcam-cadence.py — measure the CAMERA cadence that actually reached a recording.
This is the ground-truth probe for the webcam-judder work (`ai.md` → "Frame cadence"):
the pump's own counters say what the compositor DID, this says what the FILE holds.
python3 scripts/webcam-cadence.py <video.mp4> <x> <y> <size> [--start SECONDS]
Get <x> <y> <size> from the pump's scene dump in startup.log — the element's `rect=` is
in master pixels, e.g. `[5] HD Pro Webcam C920 … rect=1425,748 526x290` → centre
(1425+526/2, 748+290/2) = (1688, 893); pass the crop top-left as x,y and `size` as the
square side: --x 1598 --y 803 --size 180 (see below for why not 526x290).
Why it is done this way (each of these was a real wrong reading on 2026-10-16):
1. **Exact pixel equality is useless.** H.264 re-encodes even a completely static region
slightly differently on every frame, so `cur == prev` reported "57.9 distinct images/s"
— i.e. every frame new — for a scene whose camera delivers 30/s. Use a THRESHOLD on the
mean absolute difference (MAD): a new camera frame moves the picture far more than codec
noise. The MAD distribution is bimodal (codec noise well under 0.5, real motion over 1),
so the threshold is read off the data, not guessed.
2. **The crop must sit INSIDE a round clip.** A round-clipped element (the webcam) has
radius r = min(w,h)/2 = 145 for a 526x290 box. A square crop of side s has its corners
at s/√2 from the centre: s = 240 → 170 > 145, so the crop corners showed the 60fps
BACKGROUND behind the circle and the probe counted 60/s. Keep s ≤ 1.41·r (180 is safe
for r=145) — or crop a circle-excluded region.
3. **One scene at a time.** Element rects differ per scene, so a fixed crop carried across
a scene switch reads whatever is under it (background) and reports a multi-second "hold"
that never happened. Use the `FramePump scene dump:` timestamps to pick a window with
`--start`, or measure each scene's window separately.
4. **A still subject also produces long holds.** Threshold counting cannot tell "the camera
had nothing new to send" from "we repeated a frame". Read this beside the pump's own
`FramePump stats` (renders/5s = composites/s) and `webcam … N/s` lines, which do not
depend on subject motion.
Expected healthy shape: the hold histogram peaks at **2 frames** (a 30fps camera in a 60fps
container: every image twice → 50% duplicates) with distinct/s ≈ the camera's own rate and
the longest holds in the low hundreds of ms. A peak at 4+ frames, or distinct/s near the
composites/s figure, means the file is a slide show of held images — the judder.
"""
import argparse
import shutil
import subprocess
import sys
import numpy as np
FFMPEG = shutil.which("ffmpeg")
if FFMPEG is None:
sys.exit("ffmpeg not on PATH (bundled copy: ffmpeg/ffmpeg.exe — use the Windows one from Windows)")
BLOCK = 64
def main() -> int:
ap = argparse.ArgumentParser(description="Count distinct webcam images per second in a recording.")
ap.add_argument("video")
ap.add_argument("--x", type=int, required=True, help="crop left, master pixels")
ap.add_argument("--y", type=int, required=True, help="crop top, master pixels")
ap.add_argument("--size", type=int, default=180, help="square crop side, INSIDE the round clip (default 180)")
ap.add_argument("--start", type=float, default=0.0, help="seconds to skip (single-scene window)")
ap.add_argument("--fps", type=float, default=60.0, help="container fps (default 60)")
ap.add_argument("--frames", type=int, default=0, help="stop after N frames (0 = whole file)")
args = ap.parse_args()
vf = f"crop={args.size}:{args.size}:{args.x}:{args.y},scale={BLOCK}:{BLOCK},format=gray"
cmd = [FFMPEG, "-v", "error"]
if args.start:
cmd += ["-ss", str(args.start)]
cmd += ["-i", args.video, "-vf", vf, "-f", "rawvideo", "-"]
print(f"# crop {args.size}x{args.size}+{args.x}+{args.y} @ {args.start}s of {args.video}", file=sys.stderr)
frame_bytes = BLOCK * BLOCK
prev = None
mads = []
buf = b""
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
assert proc.stdout is not None
while True:
chunk = proc.stdout.read(frame_bytes * 256 - len(buf))
if not chunk:
break
buf += chunk
while len(buf) >= frame_bytes:
raw, buf = buf[:frame_bytes], buf[frame_bytes:]
cur = np.frombuffer(raw, dtype=np.uint8).astype(np.int16)
if prev is not None:
mads.append(float(np.abs(cur - prev).mean()))
prev = cur
if args.frames and len(mads) + 1 >= args.frames:
proc.terminate()
break
if args.frames and len(mads) + 1 >= args.frames:
break
proc.wait()
if not mads:
sys.exit("no frames decoded")
mads = np.array(mads)
frames = len(mads) + 1
dur = frames / args.fps
print(f"frames={frames} ({dur:.1f}s @ {args.fps:.0f}fps)")
print("MAD between consecutive frames (mean abs gray diff, 0-255):")
for lo, hi in [(0, .1), (.1, .5), (.5, 1), (1, 2), (2, 4), (4, 8), (8, 16), (16, 1e9)]:
n = int(((mads >= lo) & (mads < hi)).sum())
label = f"{hi:g}" if hi < 1e9 else "999"
print(f" [{lo:>4}-{label:>4}): {n:>7} ({n / len(mads) * 100:5.1f}%)")
print(f" median={np.median(mads):.2f} p90={np.percentile(mads, 90):.2f} max={mads.max():.1f}")
# Thresholds read off the bimodal split; 0.5 is the operating point for a moving subject.
for t in (0.5, 1.0):
new = np.where(mads > t)[0]
distinct = len(new) + 1
holds = np.diff(np.concatenate([[0], new + 1, [frames]]))
hist: dict[int, int] = {}
for h in holds:
hist[h] = hist.get(h, 0) + 1
top = ", ".join(f"{k}x{hist[k]}" for k in sorted(hist)[:8])
longest = sorted(hist.items(), reverse=True)[:4]
print(f"\nthreshold MAD > {t}: distinct images={distinct} ({distinct / dur:.1f}/s), "
f"duplicates={(frames - distinct) / frames * 100:.1f}%")
print(f" hold histogram (frames-held x images) {top}")
print(" longest holds: " + ", ".join(f"{k}f ({k / args.fps * 1000:.0f}ms) x{hist[k]}" for k, _ in longest))
print(" want: peak at 2 frames, ~the camera's own rate, longest holds < ~300ms")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+70
View File
@@ -0,0 +1,70 @@
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// Per-element composite timing + the pump's scene dump (webcam-cadence unit,
/// 2026-10-16). The judder diagnosis ended with a question the log could not answer:
/// the compositor was doing 6.8 composites/s at ~141ms each, and nothing in the stats
/// line said WHICH of the scene's six elements ate the budget — only that the render
/// cost 141ms. This pins the two readings that close that gap: a real composite
/// attributes wall time per element (slowest first) with the paste-cache rasterize
/// split, and the scene dump records the shape a take was made from.
/// </summary>
public class CompositeTimingTests
{
/// <summary>The ONE integration test for this change: a REAL SceneCompositor.Render
/// over a full-canvas element plus a small one, through the real timing collector —
/// the parts line must name both elements, sort the full-canvas one first, and carry
/// the raster sub-stage; the dump must name every element with its z-index, source
/// type and rect, and echo the split point. No 5s stats window is waited on: the
/// window logic only formats what the collector already accumulated.</summary>
[Fact]
public void Composite_Timings_Rank_The_Slowest_Element_And_The_Dump_Names_The_Scene_Shape()
{
const int w = 640, h = 480;
var scene = new Scene { Name = "Live" };
var big = new Source { Type = SourceType.Image, Name = "FullCanvas", X = 0, Y = 0, Width = w, Height = h };
var small = new Source { Type = SourceType.Image, Name = "TinyBadge", X = 16, Y = 16, Width = 64, Height = 64 };
scene.Elements.Add(big);
scene.Elements.Add(small);
var bigFrame = SceneCompositorTests.Solid(w, h, 200, 40, 40);
var smallFrame = SceneCompositorTests.Solid(64, 64, 40, 200, 40);
VideoFrame? FrameFor(SceneElement e) => ReferenceEquals(e, big) ? bigFrame : smallFrame;
var options = new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = w, SourceRectHeight = h,
OutputWidth = w, OutputHeight = h,
};
var timings = new CompositeTimings();
new SceneCompositor().Render(scene, FrameFor, null, options, timings: timings);
// minMs 0: a 640×480 test scene is sub-millisecond per stage, so the production
// 0.1ms floor would hide exactly the entries being asserted.
var parts = timings.Describe(1, minMs: 0);
Assert.Contains("#0 FullCanvas", parts);
Assert.Contains("#1 TinyBadge", parts);
Assert.Contains("#0 FullCanvas raster", parts); // the paste-cache rasterize miss
Assert.Contains("scale", parts);
Assert.Contains("backdrop", parts);
Assert.True(
parts.IndexOf("#0 FullCanvas", StringComparison.Ordinal) < parts.IndexOf("#1 TinyBadge", StringComparison.Ordinal),
$"the full-canvas element must rank above the 64×64 one: {parts}");
var dump = FramePump.FormatSceneDump(scene, split: 1);
Assert.Contains("FramePump scene dump: 'Live' elements=2 split=1", dump[0]);
Assert.Contains(dump, l => l.Contains("[0] FullCanvas") && l.Contains("type=Image")
&& l.Contains("vis=1") && l.Contains("rect=0,0 640x480"));
Assert.Contains(dump, l => l.Contains("[1] TinyBadge") && l.Contains("rect=16,16 64x64"));
// No SceneGraph wired (-1) — the dump says nothing about a split point.
Assert.DoesNotContain("split=", FramePump.FormatSceneDump(scene, split: -1)[0]);
}
}
+7 -3
View File
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
@@ -74,9 +75,12 @@ public class FramePumpTests
return scene;
}
// The log sink must be thread-safe: FramePump reports stats from its OWN loop thread while
// the test enumerates this collection (a plain List<string> threw "Collection was modified"
// under full-suite load — the pump's extra parts/scene-dump lines made it fire far more often).
private static FramePump NewPump(FakeEncoder encoder, Func<EncoderOptions?>? options = null,
Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
List<string>? log = null,
ConcurrentBag<string>? log = null,
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
{
return new FramePump(
@@ -197,7 +201,7 @@ public class FramePumpTests
public async Task Start_WithoutRtmpUrl_SkipsEncoder()
{
var encoder = new FakeEncoder();
var log = new List<string>();
var log = new ConcurrentBag<string>();
using var pump = NewPump(encoder, options: () => null, log: log);
await pump.StartAsync();
@@ -589,7 +593,7 @@ public class FramePumpTests
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new WebcamSceneConfig { Name = "Webcam", IsVisible = true });
var encoder = new FakeEncoder();
var log = new List<string>();
var log = new ConcurrentBag<string>();
var epoch = 0L;
using var pump = NewPump(encoder, scene: () => scene, log: log, resolve: e =>
{
+134
View File
@@ -0,0 +1,134 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 57, the "vv" feature, Unit 1 — the vertical (9:16) tier's camera
/// placement.
///
/// The vertical tier is a centred band crop of the SAME 1920×1080 master, so a
/// camera placed for 16:9 lands outside the cropped window and is simply not
/// in the vertical stream (the Live scene's camera sat at x≈1408 — off the
/// 607.5-wide band entirely). Each scene therefore keeps its OWN vertical
/// placement, and the tier switch swaps the live geometry.
///
/// One test, the whole story (the Good Dog rule): the vertical tier is picked →
/// the Live camera comes into the band as a small tile → dragging it to the
/// top-right snaps it top-right INSIDE the band → switching back to 16:9
/// restores the desktop layout exactly → and the two placements survive a save
/// and reload.
/// </summary>
[Collection("RealApp")]
public sealed class VerticalWebcamLayoutTests
{
private readonly RealAppHost _app;
public VerticalWebcamLayoutTests(RealAppHost app) => _app = app;
[Fact]
public void Vertical_Tier_Puts_The_Live_Camera_In_The_Band_And_16x9_Restores_The_Landscape_Layout()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-vv-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
var vm = new MainViewModel();
var live = vm.Scenes.First(s => s.Name == "Live");
var camera = new WebcamSceneConfig
{
WebcamId = "webcam-1",
Name = "Webcam",
X = 1408,
Y = 778,
Width = 480,
Height = 270,
};
live.Elements.Add(camera);
// The 16:9 placement: the camera sits where the desktop layout put it.
Assert.Equal(1408, camera.X);
Assert.Equal(778, camera.Y);
Assert.Equal(480, camera.Width);
// ── The vertical tier is picked ───────────────────────────────────
vm.SelectedQuality = vm.QualityOptions[^1];
Assert.True(vm.IsVerticalTier);
var band = vm.VerticalBand;
// The camera came INTO the band (it used to sit outside it entirely).
Assert.True(camera.X >= band.Left, $"camera X {camera.X} is left of the band {band.Left}");
Assert.True(camera.X + camera.Width <= band.Right, "the camera hangs over the band's right edge");
Assert.True(camera.Y >= band.Top, "the camera hangs over the band's top edge");
Assert.True(camera.Y + camera.Height <= band.Bottom, "the camera hangs over the band's bottom edge");
Assert.Equal(VerticalCameraLayout.TileWidth, camera.Width);
Assert.Equal(VerticalCameraLayout.TileHeight, camera.Height);
// ── Drag it to the top-right ─────────────────────────────────────
vm.MoveWebcamInVerticalTier(camera, band.Right - 40, band.Top + 20);
Assert.Equal(band.Right - VerticalCameraLayout.Inset - camera.Width, camera.X, 3);
Assert.Equal(band.Top + VerticalCameraLayout.Inset, camera.Y, 3);
Assert.True(camera.X >= band.Left && camera.X + camera.Width <= band.Right);
Assert.True(camera.Y >= band.Top && camera.Y + camera.Height <= band.Bottom);
var verticalX = camera.X;
var verticalY = camera.Y;
// ── Back to 16:9: the desktop layout is restored exactly ─────────
vm.SelectedQuality = vm.QualityOptions[0];
Assert.False(vm.IsVerticalTier);
Assert.Equal(1408, camera.X);
Assert.Equal(778, camera.Y);
Assert.Equal(480, camera.Width);
Assert.Equal(270, camera.Height);
// ── And the vertical tier comes back with the creator's placement ─
vm.SelectedQuality = vm.QualityOptions[^1];
Assert.Equal(verticalX, camera.X, 3);
Assert.Equal(verticalY, camera.Y, 3);
// ── It survives a save + reload: both placements, side by side ───
// The store writes the webcam rows only when the app has a webcam
// identity (the config rows hang off it) — this box has no camera
// plugged in, so the identity is supplied directly.
using (var store = new Services.LayoutStore(tempDb))
{
store.Save(vm.Scenes, new Webcam { Id = "webcam-1", DeviceId = "device-1", Name = "Webcam" }, null);
}
using var reloaded = new Services.LayoutStore(tempDb);
var reloadedCamera = reloaded.Load().First(s => s.Name == "Live").WebcamConfig;
Assert.NotNull(reloadedCamera);
// The 16:9 layout is the one in the live columns…
Assert.Equal(1408, reloadedCamera.X);
Assert.Equal(778, reloadedCamera.Y);
Assert.Equal(480, reloadedCamera.Width);
Assert.Equal(270, reloadedCamera.Height);
// …and the vertical placement is the creator's own, stored beside it.
Assert.Equal(verticalX, reloadedCamera.VerticalX!.Value, 3);
Assert.Equal(verticalY, reloadedCamera.VerticalY!.Value, 3);
Assert.Equal(VerticalCameraLayout.TileWidth, reloadedCamera.VerticalWidth!.Value, 3);
Assert.Equal(VerticalCameraLayout.TileHeight, reloadedCamera.VerticalHeight!.Value, 3);
}
finally
{
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}