Compare commits
10
Commits
931ab46783
...
6be062c45f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be062c45f | ||
|
|
58b9a7d413 | ||
|
|
b1d77ee510 | ||
|
|
fb851f089d | ||
|
|
d1e73a3aad | ||
|
|
2a15be21bd | ||
|
|
cdf82f688d | ||
|
|
84cbec2d19 | ||
|
|
a3a4681aac | ||
|
|
3cb29ea45c |
@@ -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"/>
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>#<index> <name></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>#<i> <name> 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user