vv webcam Unit 1: the vertical (9:16) tier keeps its OWN camera placement per scene
The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).
Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this
scene has no vertical placement yet"). The 16:9 layout stays in the old columns
and is parked in the transient Landscape* fields while the vertical tier is
live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
X/Y/W/H overrides clamp every write into the band - outside it the camera is
invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
constrains every writer (drag, resize handle, typed fields) in ONE choke point
instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
Live scene gets its small tile automatically on the first vertical switch (the
vertical stream would otherwise have no camera at all); the other scenes keep
their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
the vertical tier instead of free-floating over the master.
Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
(X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE
placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam -
the TASK 51 test finds that panel by binding text and would match twice).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
live the save writes the parked values there and the live ones into Vertical*.
Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.
Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.
Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
This commit is contained in:
@@ -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 |
|
||||
|
||||
+66
-26
@@ -1,17 +1,20 @@
|
||||
# HANDOFF — 2026-10-21 (HA lights session; the day's planned llamacasty work did NOT happen)
|
||||
# HANDOFF — 2026-10-21 (vv webcam Unit 1 shipped; the previous HA-lights session's state below)
|
||||
|
||||
## At a glance
|
||||
- **Branch** main, tracking `origin/main`. **origin/main = `931ab46`; HEAD = `d1e73a3`** — **6 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`, plus this
|
||||
session's. Always check `git log origin/main..HEAD` — never trust a handoff's own "0 ahead" claim.
|
||||
- **This session produced NO ytLive code, NO build, NO test run.** It was spent — entirely and
|
||||
wastefully — 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; I rewrote the
|
||||
script twice, broke the alert path, and burned the day. The creator had to say so before I stopped.
|
||||
- **The HA work landed OK in the end** (recipes + measurements now in `MyMistakes.md`). Records below so
|
||||
the next session does not re-hunt. **llamacasty / the vv webcam Unit 1 is still NOT started.**
|
||||
- **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.
|
||||
|
||||
## HA light alert system — current, verified state (2026-10-21)
|
||||
Five Tapo bulbs: `light.br1`, `light.br2`, `light.officeone`, `light.officetwo`,
|
||||
@@ -51,7 +54,7 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
|
||||
"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`.
|
||||
|
||||
## vv webcam feature — locked rules (creator, 2026-10-17 — unchanged, still awaiting go-ahead)
|
||||
## 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 |
|
||||
@@ -83,21 +86,53 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
|
||||
- **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** — 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:* 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.
|
||||
2. **vv · Chat** — rect enforced with the circle option unavailable, plus enlarge-to-one-area (607×540)
|
||||
snapping top or bottom.
|
||||
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.
|
||||
offered. **Not started.**
|
||||
|
||||
## Next step
|
||||
- **Implement vv Unit 1 on the creator's go-ahead.** Nothing is queued in `TASKS.md` — the creator queues
|
||||
work; this plan lives here until then. Queue it in `TASKS.md` as part of the first Unit 1 commit.
|
||||
- **The creator's stated plan for "today" (2026-10-21) was llamacasty. It did not happen.** Confirm the
|
||||
intended target before starting — do not assume.
|
||||
- **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
|
||||
@@ -124,11 +159,16 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
|
||||
- TASK 55 disk hygiene (`startup.log` uncapped — 23MB and growing every 5s).
|
||||
|
||||
## Tests
|
||||
- Last full run: **428/428 passed, 0 warnings** (2026-10-16, the gate run for `84cbec2`). **No run this
|
||||
session (no ytLive code was touched).**
|
||||
- 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.
|
||||
|
||||
## Secrets / paths (do not re-hunt)
|
||||
- Layout DB: `%APPDATA%\ytLlive\ytLlive.db` (SQLite, full-rewrite per save). Auth:
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -1923,3 +1923,62 @@ Producer/consumer split, and **one single writer** of the bulbs:
|
||||
- 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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -703,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
|
||||
|
||||
@@ -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