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:
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user