From 5a1993b6dbf8670aafde1a0cdc002ec78a3528b9 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 20 Sep 2026 09:23:46 -0700 Subject: [PATCH] fix(persist): one shared z-order across Source + WebcamSceneConfig rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- HANDOFF.md | 225 +++++++------------ Services/LayoutStore.Load.cs | 35 ++- Services/LayoutStore.Save.cs | 17 +- TASKS.md | 5 + TASKS/task-37-defaults-current-split.md | 48 ++++ ai.md | 1 + ytLive.Tests/LayerReorderPersistenceTests.cs | 156 +++++++++++++ 7 files changed, 327 insertions(+), 160 deletions(-) create mode 100644 TASKS/task-37-defaults-current-split.md diff --git a/HANDOFF.md b/HANDOFF.md index 8ee4db8..18df973 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 ""`'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. \ No newline at end of file diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs index 907cc77..d9f2840 100644 --- a/Services/LayoutStore.Load.cs +++ b/Services/LayoutStore.Load.cs @@ -11,8 +11,8 @@ public partial class LayoutStore : IDisposable Socials = null; Music = null; var scenes = new List(); - var sourcesByScene = new Dictionary>(); - var configsByScene = new Dictionary>(); + var sourcesByScene = new Dictionary>(); + var configsByScene = new Dictionary>(); 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(); - 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(); - 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().FirstOrDefault(s => s.IsBackground); if (bg != null) diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs index 93c50b9..94ed2a1 100644 --- a/Services/LayoutStore.Save.cs +++ b/Services/LayoutStore.Save.cs @@ -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()) + 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(); } } diff --git a/TASKS.md b/TASKS.md index e94630c..e4df545 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. --- diff --git a/TASKS/task-37-defaults-current-split.md b/TASKS/task-37-defaults-current-split.md new file mode 100644 index 0000000..1e40674 --- /dev/null +++ b/TASKS/task-37-defaults-current-split.md @@ -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. \ No newline at end of file diff --git a/ai.md b/ai.md index f6f59ee..5786c68 100644 --- a/ai.md +++ b/ai.md @@ -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` 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 diff --git a/ytLive.Tests/LayerReorderPersistenceTests.cs b/ytLive.Tests/LayerReorderPersistenceTests.cs index 82c552b..2107e43 100644 --- a/ytLive.Tests/LayerReorderPersistenceTests.cs +++ b/ytLive.Tests/LayerReorderPersistenceTests.cs @@ -37,6 +37,23 @@ public sealed class LayerReorderPersistenceTests _app.Run(Run); } + /// + /// 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. + /// + [Fact] + public void CrossTypeReorder_BelowTheWebcam_SurvivesFreshReload() + { + _app.Run(RunCrossTypeReload); + } + /// /// 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().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().Single(); + var dragged = elements.OfType().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 */ } + } + } + + /// Reads one scene's element order from a FRESH store, both tables + /// merged on the shared z. False on lock/timeout (save mid-transaction). + 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; + } + } + + /// Seeds the user's real Live layout: Background, YouTube Chat, + /// Image, Web Resource-0 Source rows + a Webcam row and its config. + 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(); + } + } + /// Pumps the app dispatcher (so the VM's debounce timer fires) until /// or the deadline passes. True when the condition /// became true, false on deadline.