fix(persist): one shared z-order across Source + WebcamSceneConfig rows

A webcam dragged between sources reverted to the bottom of the stack on
every relaunch: Source and WebcamSceneConfig each carried independent
per-type SortOrder counters, and Load appended all Sources before all
configs. Save now stamps both tables' SortOrder from the element's index
within scene.Elements; Load merges the two tables' rows by that shared z
(sources-first tie-break preserves legacy rows). Cross-type reorder now
survives a fresh LayoutStore reload.

Task 37 queued: defaults vs current layout split (creator directive) —
capture out-of-scope work in TASKS.md rather than folding it in.
This commit is contained in:
2026-09-20 09:23:46 -07:00
parent f91bf8715d
commit 5a1993b6db
7 changed files with 327 additions and 160 deletions
+83 -142
View File
@@ -1,148 +1,99 @@
# HANDOFF — 2026-09-18 (drag-reorder persistence proven via real mouse input + recording-dialog height, both local — no push)
# HANDOFF — 2026-09-20 (cross-table z-order bug fixed: webcam between sources survives reload; defaults/current split queued as TASK 37)
## Branch / Commit State
`main` HEAD = **Rename Recording dialog 20% taller + teardown unhooks the full-screen hook** (local).
Below it: the layer drag-reorder persistence fix (`LayerReorderPersistenceTests`), then webcam work.
**Real-input drag verification test added on top** (unpushed, see below).
`main` HEAD = **the cross-type ordering fix** (local — no push). This is a CONTINUATION of the
2026-09-18 drag-reorder work: the creator kept insisting the reorder "doesn't save" even after
commit `f91bf87`. Their repro was real and the per-table `SortOrder` theory was the actual mechanism.
**No push yet.** Working tree clean.
**No push yet.** Working tree clean after the ordering-fix commit.
## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive
## ✅ Committed — cross-type z-order: webcam between sources survives save→reload (2026-09-20)
Product is **llamacasty**; repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths
(`%APPDATA%\ytLlive\...`), most code names are legacy **ytLive/ytLlive**. User-facing text:
"llamacasty" / UI labels use the catalog names ("Web Cam", "YouTube Chat", "Countdown Timer"…).
**The actual bug the creator reproduced** (`f91bf87` shipped the immediate-save at drop, but the
reorder still reverted on restart): `Source` and `WebcamSceneConfig` are separate tables with
**independent per-type `SortOrder` counters**. Save stamped sources 0..n and configs 0..m;
`LayoutStore.Load` appended **all Sources first, then all WebcamSceneConfigs**. A webcam dragged
between two sources was structurally unrecoverable — the live scene (Background, YouTube Chat,
Image, Web Resource-0 sources + one HD Pro Webcam C920 config) reverted the Webcam to the bottom
of the stack on every relaunch.
## ✅ Committed — webcam app-default gate + attainability (`9d00955`, `75723ac`)
**Fix:** one shared z-space per scene. `Save` stamps both tables' `SortOrder` from the element's
index within `scene.Elements` (the non-source branches bump the counter too); `Load` merges the two
tables' rows **by that shared z** instead of appending tables. Legacy pre-unification rows can tie at
0 — ties break sources-first, so old data reads back exactly as it saved.
**Background:** the DB truth (`%APPDATA%\ytLlive\ytLlive.db`; NOT `layout.db`, a 0-byte legacy
file) put the single `WebcamSceneConfig` in **Chat**, so the old TASK 26 app-wide rule grayed
Live's "+" → Web Cam even though the camera was available and in use. The creator ruled (rigorous
resource model): **the webcam is an app-level resource — ONE app-wide default selection, usable in
every scene that can host it; it is NOT blocked by other scenes' placements.** Scope of this slice:
the availability layer. TASK 26's app-wide gate is **superseded** by creator directive.
**Good Dog test** `CrossTypeReorder_BelowTheWebcam_SurvivesFreshReload` (`LayerReorderPersistenceTests`):
seeds the builder's real Live layout (4 sources + `HD Pro Webcam C920` config row), reorders
"Web Resource-0" DOWN below the webcam, calls `OnSceneElementsReordered`, then opens a **brand-new
`LayoutStore` on the same temp DB — the restart** — and asserts the cross-type order holds. **309/309
tests pass, build 0 warnings.**
- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`.
A scene already hosting the webcam stays gray (one webcam per stream); another scene holding it
does NOT gray the row elsewhere.
- **Attainable = a live lock, not a saved identity** (`75723ac` refinement): `IsWebcamAttainable` =
`_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`. An identity whose camera is
unplugged / can't start leaves the row greyed (tooltip "No webcam is currently available…"), and
it un-greys the moment a session is running (startup lock, first frame, picker + acquire).
- **Add places the default directly:** when attainable, "Add Webcam" creates the placement with NO
picker; the Windows picker runs only for the initial selection (`_webcam == null`).
- **Identity survives last-placement removal:** `RemoveElement` no longer nulls `_webcam` (that was
the TASK 26 rule). `LayoutStore` writes the singleton whenever it's non-null, so it persists.
- **Startup lays an app-wide BASE lock:** the single-camera branch ALWAYS acquires (the old
`IsRunning` skip is gone) — on an already-running session it just bumps the refcount, and that ref
is the app's own hold. Result: removing every scene's placement leaves `RefCount = 1`, the session
stays alive, and the row stays offered (the app default outlives the scenes). It still **adopts a
solo camera as the app default** when no identity exists, so a clean layout offers Web Cam at once.
- **Dynamic why-gray tooltip:** `WebcamAddToolTip` (raised wherever the gate can flip: staging,
removal, startup lock success, first frame, camera failure, identity swap) — "Already in this
scene — one webcam per stream. A second camera means you've graduated to OBS." / "No webcam is
currently available — plug one in, or allow camera access in Windows." / "Adds the app default
webcam to this scene."
- **Good Dog integration tests** `WebcamMenuGateTests` (real app + temp DB + camera seams): (1) Chat's
identity does NOT gray Live; Add in Live places the same `wc-1` without a picker; scene-with-
placement stays gray; identity survives both removals (DB row count keeps 1, session kept by the
base lock). (2) **Negation:** identity loaded but its session can't start → row NOT offered.
`WebcamStartupResourceTests` test 1 strengthened: after startup the single camera is adopted →
`CanAddWebcam` true. **305 tests (304 pass + the known audio flake, see Landmines); build 0 warnings.**
**Also committed:** TASK 37 queued — **defaults vs current layout split** (creator directive
2026-09-20): the DB should hold the static brandable `default` layout; the `current` layout carries
the build-id (loaded only when it matches `BuildStamp.Id`), enabling one-click revert. Conceptual
shape `scene[0-4].layerList.[default|current].elementList`; config-file vs second-rows-in-DB not yet
decided (`TASKS/task-37-defaults-current-split.md` has both + facts). **Process rule established:**
capture out-of-scope needed work in TASKS.md rather than bolting it onto the in-flight change.
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated (supersession recorded).
## ✅ Committed earlier — layer-list drag-to-reorder persists (2026-09-18, `f91bf87` + below)
## ✅ Committed — layer-list drag-to-reorder persists (creator-reported, 2026-09-18)
`StagedScene.Elements` reorder (RemoveAt/Insert) bypasses SceneGraph's mutation surface, so the drop
must be trapped + persisted. Fix (2026-09-18): the drag sets `_dragReordered` and `EndListDrag`
funnels the drop through `MainViewModel.OnSceneElementsReordered()` (Sources.cs) =
`SceneGraph.InvalidateBake(staged) + SaveLayoutNow()` — an IMMEDIATE save at drop (no debounce
window). The single write stores BOTH the layer list (SortOrder) AND the preview layout (X/Y/W/H in
the same rows). Tests `LayerReorderPersistenceTests` (real App + temp DB): (1) exact code-behind
mutation + geometry assert; (2) **real-gesture** test `RealMouseDrag_OnTheLayerList_PersistsTheReorder`
(`SetCursorPos` + `mouse_event` on a shown Topmost window) — needs an interactive desktop session
(covered/locked session = pointer no-op = flake). Both green in the 309. **This fix was correct but
INSUFFICIENT — the cross-table bug above is what the builder actually hit.**
`StagedScene.Elements` order (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the
drop must be trapped and persisted. Fix (c117885): the drag sets `_dragReordered` and, on mouse
release, `EndListDrag` funnels the drop through `MainViewModel.OnSceneElementsReordered()`
(Sources.cs) = `SceneGraph.InvalidateBake(staged) + SaveLayoutNow()` — an IMMEDIATE save at drop
(no debounce window, added 2026-09-18 after the creator re-reported it "doesn't save"; the single
write stores BOTH the **layer list** = Source `SortOrder` AND the **preview layout** = each layer's
`X`/`Y`/`Width`/`Height` in the same Source rows; `LayoutStore.Save` deletes+reinserts Source rows
in `scene.Elements` order, `LayoutStore.Load` reads them back `ORDER BY SortOrder`).
**Good Dog tests** `LayerReorderPersistenceTests` (real App + temp DB): (1) reproduces the exact
code-behind mutation
calls the trap, pumps the dispatcher until the save lands, asserts the DB `Source`
SortOrder matches the in-memory `Elements` order.
**Creator later insisted the reorder "doesn't save" even after the build — settled 2026-09-18 with a
REAL-gesture test.** My first test short-circuited `EndListDrag` — it never exercised the mouse
handlers (impossible with synthetic events: `e.GetPosition` reads the physical cursor). Added
`RealMouseDrag_OnTheLayerList_PersistsTheReorder`: real `SetCursorPos` + `mouse_event`/`SendInput`
against the shown MainWindow physically drag row 3 (ImgC) onto row 1 (ImgA), pumping between steps,
then asserts the DB lands at `[Background, ImgC, ImgA, ImgB]` — AND that ImgC's geometry
(X/Y/W/H, the preview layout) is intact in the same rows. **Both pass.**
The mouse-down/move/up → `_dragReordered` → `EndListDrag` → `OnSceneElementsReordered` → DB seam is
now proven with real input; a real user drag working differently would mean a stale binary (the
Debug exe from 08:42 postdates fix commit 08:18) or a different deployment.
## ✅ Committed earlier — webcam app-default gate + attainability (`9d00955`, `75723ac`)
## ✅ Committed — Rename Recording dialog taller + full-screen hook unhooked on shutdown (2026-09-18)
Webcam is an **app-level resource** — ONE app-wide default, usable in every scene that can host it,
NOT blocked by other scenes' placements (creator ruling; supersedes TASK 26). Per-scene gate
`CanAddWebcam` = scene has no config && attainable (`CameraManager.IsRunning(_webcam.DeviceId)` —
a live lock, not a saved identity). Add places the default directly (no picker). Identity survives
last-placement removal; startup lays an app-wide BASE lock (refcount keeps the session alive);
adopts a solo camera as default. Dynamic why-gray tooltip. Tests: `WebcamMenuGateTests`,
`WebcamStartupResourceTests`. The DB truth lives at `%APPDATA%\ytLlive\ytLlive.db` (NOT `layout.db`).
- **WHERE/HOW:** the end-of-recording save dialog is `RenameRecordingDialog.xaml` (repo root — the
dialog that pops up with a default file name when a recording is ended so it can be saved/renamed;
title "Rename Recording"). Its fixed `Height` attribute controls everything: 230 → **276** (+20%,
textbox clipped at 230) → **304** (+10% more, creator: the +20% revealed the Cancel/Save button
row had ALSO been obscured). The Save button is named `SaveRecordingButton`.
**Good Dog test** `RenameRecordingDialogSizingTests`: shows the real dialog in the RealApp host and
asserts BOTH the file-name textbox bottom AND the Save button bottom sit inside the client area
(client area ≈ Height − ~37px of chrome; measure via `TransformToAncestor` against the dialog's
content root). The dialog's
`Icon` was changed from root-relative `/Assets/...` to the explicit `/ytLive;component/...` (the
form `MainWindow.xaml` already uses) — root-relative only resolves in production via
`Application.ResourceAssembly`, so dialogs couldn't be constructed by tests otherwise. The other
dialogs still use the fragile form; fix as they get tested.
- **Teardown leak fix (dependency, discovered by the above):** `MainViewModel.Shutdown()` now calls
`_fullScreenDetector.StopWatching()`. The global `EVENT_SYSTEM_FOREGROUND` hook was never unhooked,
so after the VM was collected the next foreground event invoked a **garbage-collected
`WinEventProc` delegate → process crash** ("Test Run Aborted" in the suite). Harmless in production
(exits the process) but fatal to the multi-window test host when a test shows/foregrounds a window.
## ✅ Committed earlier — Rename Recording dialog taller + full-screen hook unhooked (2026-09-18)
## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`)
Startup poll + tri-state (`ValidateWebcamResourceStartupAsync` after `LoadLayout`): 0 → run on,
no alarm; **1 → `AcquireAsync` as app-wide lock** (now always runs — see base-lock note above);
**≥2 → no auto-lock** (App Settings selector
next). Lock failure → **persistent red alert** (`WebcamLockAlert` in the Layers panel + Retry),
re-polls every 5 s (`_webcamLockPollTimer`), clears on lock success or any first real frame.
`CameraManager.IsRunning(deviceId)` = session exists (started OR starting). Camera test seams
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` (mirror `LayoutPathOverride`).
Attribution correction committed: "NVIDIA Broadcast opens the webcam exclusively" is a *suspect-list*
claim — `CameraConflictProbe` reads process names only, no device handles; do not restate as fact.
`RenameRecordingDialog.xaml` Height 230 → 276 → **304** (the +20% revealed the Cancel/Save row also
obscured); Save button named `SaveRecordingButton`. Test `RenameRecordingDialogSizingTests` asserts
textbox bottom + Save button bottom inside the client area. **Teardown leak fix:** `Shutdown()` now
calls `_fullScreenDetector.StopWatching()` — the global `EVENT_SYSTEM_FOREGROUND` hook was never
unhooked, invoking a GC'd `WinEventProc` on the next foreground event → test-host abort.
## ⚠️ Open items
- **RenameRecordingDialog too short — DONE (see below).**
- **Webcam take fix 2 of 2 — the 09:16 crash is UNFIXED (hard, untested):** hypothesis — native WMF
death when `MediaCapture.Failed` fires mid-stream and `SafeStopAsync` (fire-and-forget) disposes
reader+capture while the frame thread sits in `TryAcquireLatestFrame`/`Marshal.Copy` (outside the
frame's try/catch). No repro, no integration test, native race not catchable — deferred per
spin-guard. Record a second occurrence's pattern.
- **Multi-camera selector slice (user-queued):** when ≥2 webcams exist, add web-cam selection text +
control to the **App Settings** dialog (gear icon, `OverlayHost` — left-click opens settings,
right-click the context menu). Selecting + successfully allocating/locking activates the WebCam
layer. **Currently ≥2 cameras → nothing shows in settings.** Also: the "Change Webcam" picker
should carry the OBS one-liner (one webcam per stream — a second means OBS).
- **Static (+) catalog slice (user-queued):** all catalog rows always listed (Background, "Web Cam",
"Countdown Timer", "Web Resource", "YouTube Chat", "YouTube Event"…), gray only for (1) system-level
unavailability (persistent alert), (2) max already allocated, (3) not available for the current
scene; tooltips must name the reason. Static labels only, no new code-behind.
- **Device re-verify (one take session):** 1. slice-18 cache verdicts (NR/WH hits, stalls, band
~60); 2. webcam: fresh launch with the browser holding the camera must show the webcam or a NAMED
chip (now also the persistent red alert, and the alert must clear after the browser closes + retry);
3. verify Live now OFFERS the Web Cam layer even while Chat holds the camera (the exact creator
goal: "webcam offered in the layer stack when I switch over to the Live view").
- **No push yet** — after the take verdict, re-measure the clap offset (`/tmp/opencode/avsync.py`),
then decide push with the user.
- **Build-id on command-line exit (creator demand, 2026-09-20):** when the app exits under
`dotnet run`, output the build number (`BuildStamp.Id` — a fresh per-build GUID, logged in
`App.xaml.cs:32` as `Build {Id} (compiled {BuiltLocal})`) as return data on the command line.
NOT yet implemented — queued, do not fold into another slice.
- **TASK 37** — defaults/current layout split (see above + `TASKS/task-37-defaults-current-split.md`).
- **Webcam take fix 2 of 2:** native WMF death when `MediaCapture.Failed` fires mid-stream and
`SafeStopAsync` disposes reader+capture while the frame thread sits in `TryAcquireLatestFrame`. No
repro/test — deferred per spin-guard.
- **Multi-camera selector slice (user-queued):** ≥2 webcams → web-cam selection in the App Settings
dialog (gear). Currently ≥2 cameras → nothing shows in settings.
- **Static (+) catalog slice (user-queued):** all catalog rows always listed, greyed with named
reason tooltips.
- **Device re-verify (one take session):** slice-18 cache verdicts; webcam fresh-launch with the
browser holding the camera → red alert that clears after Retry once the browser closes; Live must
OFFER Web Cam while Chat holds it.
- **No push yet** — after the take verdict, decide push with the user.
## Open threads (carried)
- Audio-silence verification — fixed (`724af14`); creator heard real audio.
- Webcam MJPG missing / ~10–14Hz, layer SortOrder, truncation-with-dynamic-scenes — queued.
- Webcam MJPG missing / ~10–14Hz, truncation-with-dynamic-scenes — queued.
- Sync control user-doc tutorial — REQUIRED before 1.0 (creator directive; TASK 22).
- Signed A/V sync: verify the negative (advance) direction on device.
- Focus-loss capture lag — closed as NOT the cause (1824: delivery healthy ~60-100/s).
- The 2026-09-18 "reorder doesn't save" stale-binary theory — RETRACTED 2026-09-20. The builder was
right; the cross-table z-order bug above is the real mechanism. Never re-air the stale-binary claim.
## Landmines
@@ -153,40 +104,30 @@ claim — `CameraConflictProbe` reads process names only, no device handles; do
- Build/tests: **Windows dotnet host** (`/mnt/c/Program Files/dotnet/dotnet.exe`). 0 warnings —
only `./scripts/verify.sh "<files>"`'s clean build counts. Running `ytLive.csproj` alone does NOT
rebuild `ytLive.Tests.dll` — run the Tests csproj before `vstest`. Known audio flake:
`Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — a stale loopback sample still in the pipe
when the mute flips (phase 2's `Assert.Equal(0f, f, 6)` sees 0.4). **Confirmed pre-existing and
currently deterministic in this environment: it fails on clean `9d00955` (stash test) and on the
working tree, alone and in-suite.** Unrelated to camera work; do not "fix" it inside a webcam slice.
`Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — stale loopback sample in the pipe when the
mute flips. Unrelated; do not "fix" inside another slice.
- **Assert footguns in this repo's xUnit:** `Assert.Null(x, "msg")`/`Assert.Single(coll, "msg")` do
NOT take a message — the 2-arg overloads mean something else (predicate/item). Empty message = 1 arg.
- The webcam's real state lives in `%APPDATA%\ytLlive\ytLlive.db` (`layout.db` there is a 0-byte
legacy file). sqlite3 at `/home/gramps/android-sdk/platform-tools/sqlite3`.
- Contention failure modes (startup.log): "no frames within 4s" = init OK but starved;
"OutputFormatNotSupported" = reader subtype below the MJPG-active camera; "file being used by
another process / CaptureMode is SharedReadOnly" = re-negotiation refused while others hold the
device. `CameraConflictProbe` = suspect list, not handle evidence.
- FramePump tests asserting per-frame CONTENT need a STABLE scene; cache-sensitive assertions can't
use a call-count resolver flip (tick resolves twice) — key alternation on `OutputIndex`.
- ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths.
- `MyMistakes.md`: WINRT resource-allocation RECIPE, freeze-audit RECIPE, A/V sync recipe, deadline
pacing, CoreMessaging DQ, WGC-CLIP + slice blocks. Grep before re-deriving.
- **Test-host crash (fixed 2026-09-18):** "callback was made on a garbage collected delegate …
`Win32FullScreenDetector+WinEventProc`" — `Shutdown()` now unhooks the foreground-event hook; never
re-introduce `StartWatching` without a matching teardown, and dialogs whose `Icon` is the
root-relative `/Assets/…` form can't be instantiated by tests (use `/ytLive;component/…`).
- **Test-host crash (fixed):** GC'd `WinEventProc` — `Shutdown()` unhooks the foreground-event hook;
never re-introduce `StartWatching` without matching teardown; dialogs with root-relative `/Assets/…`
Icon can't be instantiated by tests (use `/ytLive;component/…`).
- **Scene.Elements layer tests:** any seeded scene with `HasBackground=1` loads a healed
`Background` element pinned at `Elements[0]` (created by `EnsureBackground` on `StagedScene` set) —
compute expected layer orders from the live collection, never hardcode indices.
- **Real-input injection test** (`RealMouseDrag_OnTheLayerList_PersistsTheReorder`) needs an
interactive desktop session: the window is shown Topmost and clicks are injected at real screen
coords — a covered/locked session makes the pointer no-op and the drag never lands. Fine locally.
- **Cross-type element order tests** must assert through a FRESH `LayoutStore` (the restart), not the
running app's store — that's the read-back path the builder's bug exercised.
- `C:\tmpout` is for ffmpeg evidence artifacts; keep them out of the repo.
## Next step
The multi-camera App Settings selector slice (+ static catalog rows after), then the creator's take
on this build. The webcam gate slice answers the creator's "go" ask — switching to the Live view
must now offer Web Cam in the layer stack (Chat holding the camera no longer blocks it). The red
Layers alert remains the new expected behavior to eyeball on the take: browser holds the camera →
fresh launch → alert (unless Chat's config grabbed it) that clears after a Retry once the browser
closes.
The ordering fix is the builder-facing half of the "webcam between sources" bug — a manual take
(relaunch, live view, drag Web Resource-0 below the webcam, restart, expect it pinned there) closes
it. Then the copy with the builder on any remaining repro, then decide push. TASK 37 (defaults vs
current) and the build-id-on-exit demand are queued for a later slice — do not fold them in here.
+23 -12
View File
@@ -11,8 +11,8 @@ public partial class LayoutStore : IDisposable
Socials = null;
Music = null;
var scenes = new List<Scene>();
var sourcesByScene = new Dictionary<string, List<Source>>();
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
var sourcesByScene = new Dictionary<string, List<(Source Source, int Sort)>>();
var configsByScene = new Dictionary<string, List<(WebcamSceneConfig Config, int Sort)>>();
using (var cmd = _connection.CreateCommand())
{
@@ -99,7 +99,7 @@ public partial class LayoutStore : IDisposable
IsBackground, CaptureKey, ShowDesktop,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState, SortOrder
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -147,8 +147,8 @@ public partial class LayoutStore : IDisposable
: MediaPlaybackState.Stopped,
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>();
list.Add(source);
sourcesByScene[sceneId] = list = new List<(Source, int)>();
list.Add((source, reader.GetInt32(30)));
}
}
@@ -157,7 +157,7 @@ public partial class LayoutStore : IDisposable
cmd.CommandText = """
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
RectWidth, RectHeight
RectWidth, RectHeight, SortOrder
FROM WebcamSceneConfig ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -184,19 +184,30 @@ public partial class LayoutStore : IDisposable
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
};
if (!configsByScene.TryGetValue(sceneId, out var list))
configsByScene[sceneId] = list = new List<WebcamSceneConfig>();
list.Add(config);
configsByScene[sceneId] = list = new List<(WebcamSceneConfig, int)>();
list.Add((config, reader.GetInt32(16)));
}
}
foreach (var scene in scenes)
{
// Source and WebcamSceneConfig rows share ONE z-order space per scene
// (both SortOrders = the element's index within scene.Elements). Merge
// the two tables' rows by that shared z rather than appending all
// sources before all configs — a webcam slotted between sources must
// reload in place. Legacy rows (written before the unified space) may
// tie at 0: break ties sources-first, so old data reads back stacked
// the same way it did pre-unification.
var ordered = new List<(int Sort, SceneElement Element)>();
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
foreach (var source in sources)
scene.Elements.Add(source);
foreach (var (source, sort) in sources)
ordered.Add((sort, source));
if (configsByScene.TryGetValue(scene.Id, out var configs))
foreach (var config in configs)
scene.Elements.Add(config);
foreach (var (config, sort) in configs)
ordered.Add((sort, config));
foreach (var (_, element) in ordered.OrderBy(e => e.Sort).ThenBy(e => e.Element is Source ? 0 : 1))
scene.Elements.Add(element);
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg != null)
+11 -6
View File
@@ -129,12 +129,16 @@ public partial class LayoutStore : IDisposable
var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
// table, so a webcam slotted between sources survives save→load.
// (Pre-unified rows tie at 0 — load breaks ties sources-first.)
foreach (var scene in scenes)
{
var sort = 0;
var z = 0;
foreach (var element in scene.Elements)
{
if (element is not Source source) continue;
if (element is not Source source) { z++; continue; }
idP.Value = source.Id;
sceneIdP.Value = scene.Id;
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
@@ -152,7 +156,7 @@ public partial class LayoutStore : IDisposable
isBackgroundP.Value = source.IsBackground ? 1 : 0;
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
sortP.Value = sort++;
sortP.Value = z++;
chatFontSizeP.Value = source.ChatFontSize;
chatFontColorP.Value = source.ChatFontColor;
chatBgColorP.Value = source.ChatBackgroundColor;
@@ -215,9 +219,10 @@ public partial class LayoutStore : IDisposable
foreach (var scene in scenes)
{
var sort = 0;
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
var z = 0;
foreach (var element in scene.Elements)
{
if (element is not WebcamSceneConfig config) { z++; continue; }
sceneIdP.Value = scene.Id;
webcamIdP.Value = config.WebcamId;
visibleP.Value = config.IsVisible ? 1 : 0;
@@ -234,7 +239,7 @@ public partial class LayoutStore : IDisposable
animationP.Value = config.BorderAnimation.ToString();
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
sortP.Value = sort++;
sortP.Value = z++;
cmd.ExecuteNonQuery();
}
}
+5
View File
@@ -52,6 +52,7 @@
| 34 | Scheduled streams (Text-drawer version) | ☐ Queued (2026-09-01) | [`TASKS/task-34-scheduled-streams.md`](TASKS/task-34-scheduled-streams.md) |
| 35 | Scene-linked audio | ☐ Queued (2026-09-01) | [`TASKS/task-35-scene-linked-audio.md`](TASKS/task-35-scene-linked-audio.md) |
| 36 | Gold pass | ☐ Queued (2026-09-01) | [`TASKS/task-36-gold-pass.md`](TASKS/task-36-gold-pass.md) |
| 37 | Defaults vs current layout split | ☐ Queued (2026-09-20) | [`TASKS/task-37-defaults-current-split.md`](TASKS/task-37-defaults-current-split.md) |
---
@@ -73,6 +74,10 @@
adopts a solo camera as default. TASK 26's app-wide gate superseded by creator directive. Next
slices (user-queued): multi-camera webcam selector in the App Settings dialog (gear), static (+)
catalog rows (Background, YouTubeEvent, quoted labels) with reason-greying.
- **TASK 37** — queued (2026-09-20): split the layout data into a `default` set (the established DB)
and a `current` set carrying the build-id; a saved `current` is honored only by the same build-id,
otherwise defaults load — enables one-click revert to defaults too. Capture needed work OUT of
current scope here; do not bolt it onto an in-flight change.
---
+48
View File
@@ -0,0 +1,48 @@
# TASK 37 — Defaults vs current layout split
**Status:** ☐ Queued (2026-09-20)
## Idea (creator directive 2026-09-20)
The app establishes a DB of static, branded layout objects as its defaults. Today the DB is BOTH the
defaults and the live workspace: `LayoutStore.Save` deletes and reinserts the whole layout on every
save, so "your last edit" and "the defaults" occupy the same rows. The recurring "reorder doesn't
persist" bug was a symptom of that conflation.
Store TWO data sets:
- **`default`** — the established DB (branded factory scenes/layers), never mutated by user edits.
- **`current`** — whatever the user currently has, stamped with the build-id.
Load rule: honor `current` **iff** its build-id equals the running build's id (`BuildStamp.Id`),
otherwise load `default`. Bonus: one-click "revert to defaults" = drop `current`.
Two implementations were floated (config file vs second rows in the same DB); nothing decided — both
are OPS acceptable to the creator. The proposed conceptual shape:
```
scene[0-4].layerList.[default | current].elementList
```
## Notes / facts established while investigating
- `SceneCatalog` only defines the five canonical scene **names**; the actual default element set
exists only as whatever the DB holds at first migration. There is no standalone in-code factory
element set — "defaults" at migration time = a snapshot of today's DB.
- SortOrder is a DB column only (not on models); `Source` and `WebcamSceneConfig` were stamped with
independent per-type sort counters and load appended all sources before all configs. That
cross-table order bug is ALREADY FIXED (2026-09-20, unified z-space, see commit) — TASK 37 is the
defaults/current data split on top of it.
- `MainViewModel.LoadLayout` (MainViewModel.cs:348) seeds `SceneCatalog.All` when `Scenes.Count == 0`
and SaveLayoutNow deletes/reinserts everything (LayoutStore.Save.cs). Startup path:
`%APPDATA%\ytLlive\ytLlive.db` (MainViewModel.cs:332), `LayoutPathOverride` test seam.
## Acceptance
- A saved `current` layout with a matching build-id restores on start.
- A `current` stamped by a different (older/newer) build-id falls back to `default`.
- Revert-to-default restores `default` for one scene or all.
## Cross-references
- `ai.md` — persistence architecture; `MyMistakes.md` — prior reorder persistence failures.
+1
View File
@@ -194,6 +194,7 @@ C# / WPF (.NET 8) following MVVM:
- **SceneGraph component + baked-crust compositor (TASK 31):** `Services/SceneGraph.cs` owns the scene collection (`Scenes` — the ViewModel's `Scenes` property delegates to it) and the element mutation surface (`AddElement`/`InsertElement`/`RemoveElement`/`MoveElement`, each invalidating the bake cache) plus queries that were scattered LINQ (`GetBackground`/`GetWebcam`/`GetChatBoxes`/`GetSplitPoint`/`IsStatic`). Elements expose `ElementKind Kind` (`Static` = images/background art, `Dynamic` = webcam/live capture/chat/web). The **split point** is the index of the first dynamic element; `SceneGraph.GetBakedBase` bakes/caches all static layers below it (keyed by scene id + static element identities), and `SceneCompositor.BakeStaticBase`/`CompositeLayers`/the `Render(.., staticBase, split)` overload composite the dynamic/above-split layers per frame. `FramePump.RenderScene` uses the optimized path when a `SceneGraph` is wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (via `InvalidateBake` from the VM's element-property and background-heal paths). **Defensive deviation from the TASK 31 spec:** `ChatOverlayLayer` keeps taking `IEnumerable<Scene>` instead of depending on `GetChatBoxes()` — it is deliberately decoupled from the graph (its doc comment says "without owning the scene graph"); and the static background helpers (`EnsureBackground`/`NormalizeBackgrounds`) stay on the ViewModel because `BackgroundTests.cs` unit-tests `MainViewModel.EnsureBackground` directly. Queries + invalidation moved; helpers stayed.
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load
- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug).
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary)
- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. Bottom bar: gear icon far left, stream stats (bitrate/FPS/dropped/duration) centered under preview, resolution dropdown far right. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
@@ -37,6 +37,23 @@ public sealed class LayerReorderPersistenceTests
_app.Run(Run);
}
/// <summary>
/// The user's reproduced bug: dragging a layer BELOW the webcam and relaunching
/// reverted the order. Root cause: Sources and WebcamSceneConfigs each carried
/// their OWN per-type SortOrder and load appended all Sources before all
/// configs — a webcam slotted between sources was structurally unrecoverable.
/// Save now stamps BOTH tables from one shared z-space (the element's index
/// within scene.Elements); load merges the two lists on that shared z. This
/// test seeds the real Live layout (sources + HD Pro Webcam C920), physically
/// reorders "Web Resource-0" down below the webcam, then fresh-loads a NEW
/// LayoutStore from the same DB — the restart — and asserts the order holds.
/// </summary>
[Fact]
public void CrossTypeReorder_BelowTheWebcam_SurvivesFreshReload()
{
_app.Run(RunCrossTypeReload);
}
/// <summary>
/// Same use case as above but driven with REAL injected mouse input
/// (SetCursorPos + mouse_event against the desktop) so the actual
@@ -199,6 +216,145 @@ public sealed class LayerReorderPersistenceTests
}
}
private void RunCrossTypeReload()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-crosstype-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = default(MainWindow);
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
SeedLiveSceneWithWebcam(tempDb);
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
// The saved architecture (live scene = Background + YouTube Chat +
// Image + Web Resource-0 sources, plus the HD Pro Webcam C920 config
// row) plus the healed background.
Assert.True(vm.StagedScene!.Elements.OfType<Source>().Count() >= 4,
"seeded sources + healed Background must load");
// Mirror the user's repro: drag "Web Resource-0" DOWN below the webcam.
var elements = vm.StagedScene.Elements;
var webcam = elements.OfType<WebcamSceneConfig>().Single();
var dragged = elements.OfType<Source>().Single(s => s.Name == "Web Resource-0");
var from = elements.IndexOf(dragged);
elements.RemoveAt(from);
elements.Insert(elements.IndexOf(webcam) + 1, dragged);
var expected = elements.Select(e => e.Name).ToArray();
vm.OnSceneElementsReordered();
// Restart: a brand-new LayoutStore reading the same DB must restore
// the cross-type order exactly — webcam between Image and Web
// Resource-0, not appended after all sources.
Assert.True(PumpUntil(() =>
TryReadSceneOrder(tempDb, out var names) && names.SequenceEqual(expected),
TimeSpan.FromSeconds(10)),
"the webcam-between-sources order must survive a fresh reload");
}
finally
{
if (window != null) window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
/// <summary>Reads one scene's element order from a FRESH store, both tables
/// merged on the shared z. False on lock/timeout (save mid-transaction).</summary>
private static bool TryReadSceneOrder(string path, out string[] names)
{
try
{
using var store = new LayoutStore(path);
var scene = store.Load().Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
names = scene.Elements.Select(e => e.Name).ToArray();
return true;
}
catch (SqliteException)
{
names = [];
return false;
}
}
/// <summary>Seeds the user's real Live layout: Background, YouTube Chat,
/// Image, Web Resource-0 Source rows + a Webcam row and its config.</summary>
private static void SeedLiveSceneWithWebcam(string path)
{
var scenes = new (string Id, string Name)[]
{
("scene-starting", "Starting"),
("scene-live", "Live"),
("scene-brb", "BRB"),
("scene-chat", "Chat"),
("scene-ending", "Ending"),
};
var sources = new (string Id, string Type, string Name, int Sort)[]
{
("bg-live", "DisplayCapture", "Background", 0),
("chat-live", "ChatBox", "YouTube Chat", 1),
("img-live", "Image", "Image", 2),
("web-live", "WebSource", "Web Resource-0", 3),
};
using var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
foreach (var (id, name) in scenes)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$name", name);
cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name)));
cmd.ExecuteNonQuery();
}
using (var source = connection.CreateCommand())
{
source.CommandText =
@"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, Width, Height, IsBackground, SortOrder)
VALUES ($id, 'scene-live', $type, $name, 1, 640, 480, $bg, $sort);";
var idP = source.Parameters.Add("$id", SqliteType.Text);
var typeP = source.Parameters.Add("$type", SqliteType.Text);
var nameP = source.Parameters.Add("$name", SqliteType.Text);
var bgP = source.Parameters.Add("$bg", SqliteType.Integer);
var sortP = source.Parameters.Add("$sort", SqliteType.Integer);
foreach (var (id, type, name, sort) in sources)
{
idP.Value = id;
typeP.Value = type;
nameP.Value = name;
bgP.Value = name == "Background" ? 1 : 0;
sortP.Value = sort;
source.ExecuteNonQuery();
}
}
using (var webcam = connection.CreateCommand())
{
webcam.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ('wid-live', 'dev-physical-9', 'HD Pro Webcam C920');";
webcam.ExecuteNonQuery();
}
using (var config = connection.CreateCommand())
{
config.CommandText =
@"INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity, SortOrder)
VALUES ('scene-live', 'wid-live', 1, 100, 100, 480, 270, 1, 0);";
config.ExecuteNonQuery();
}
}
/// <summary>Pumps the app dispatcher (so the VM's debounce timer fires) until
/// <paramref name="isDone"/> or the deadline passes. True when the condition
/// became true, false on deadline.</summary>