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:
+83
-142
@@ -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
|
## Branch / Commit State
|
||||||
|
|
||||||
`main` HEAD = **Rename Recording dialog 20% taller + teardown unhooks the full-screen hook** (local).
|
`main` HEAD = **the cross-type ordering fix** (local — no push). This is a CONTINUATION of the
|
||||||
Below it: the layer drag-reorder persistence fix (`LayerReorderPersistenceTests`), then webcam work.
|
2026-09-18 drag-reorder work: the creator kept insisting the reorder "doesn't save" even after
|
||||||
**Real-input drag verification test added on top** (unpushed, see below).
|
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
|
**The actual bug the creator reproduced** (`f91bf87` shipped the immediate-save at drop, but the
|
||||||
(`%APPDATA%\ytLlive\...`), most code names are legacy **ytLive/ytLlive**. User-facing text:
|
reorder still reverted on restart): `Source` and `WebcamSceneConfig` are separate tables with
|
||||||
"llamacasty" / UI labels use the catalog names ("Web Cam", "YouTube Chat", "Countdown Timer"…).
|
**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
|
**Good Dog test** `CrossTypeReorder_BelowTheWebcam_SurvivesFreshReload` (`LayerReorderPersistenceTests`):
|
||||||
file) put the single `WebcamSceneConfig` in **Chat**, so the old TASK 26 app-wide rule grayed
|
seeds the builder's real Live layout (4 sources + `HD Pro Webcam C920` config row), reorders
|
||||||
Live's "+" → Web Cam even though the camera was available and in use. The creator ruled (rigorous
|
"Web Resource-0" DOWN below the webcam, calls `OnSceneElementsReordered`, then opens a **brand-new
|
||||||
resource model): **the webcam is an app-level resource — ONE app-wide default selection, usable in
|
`LayoutStore` on the same temp DB — the restart** — and asserts the cross-type order holds. **309/309
|
||||||
every scene that can host it; it is NOT blocked by other scenes' placements.** Scope of this slice:
|
tests pass, build 0 warnings.**
|
||||||
the availability layer. TASK 26's app-wide gate is **superseded** by creator directive.
|
|
||||||
|
|
||||||
- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`.
|
**Also committed:** TASK 37 queued — **defaults vs current layout split** (creator directive
|
||||||
A scene already hosting the webcam stays gray (one webcam per stream); another scene holding it
|
2026-09-20): the DB should hold the static brandable `default` layout; the `current` layout carries
|
||||||
does NOT gray the row elsewhere.
|
the build-id (loaded only when it matches `BuildStamp.Id`), enabling one-click revert. Conceptual
|
||||||
- **Attainable = a live lock, not a saved identity** (`75723ac` refinement): `IsWebcamAttainable` =
|
shape `scene[0-4].layerList.[default|current].elementList`; config-file vs second-rows-in-DB not yet
|
||||||
`_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`. An identity whose camera is
|
decided (`TASKS/task-37-defaults-current-split.md` has both + facts). **Process rule established:**
|
||||||
unplugged / can't start leaves the row greyed (tooltip "No webcam is currently available…"), and
|
capture out-of-scope needed work in TASKS.md rather than bolting it onto the in-flight change.
|
||||||
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.**
|
|
||||||
|
|
||||||
**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
|
## ✅ Committed earlier — webcam app-default gate + attainability (`9d00955`, `75723ac`)
|
||||||
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 — 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
|
## ✅ Committed earlier — Rename Recording dialog taller + full-screen hook unhooked (2026-09-18)
|
||||||
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.
|
|
||||||
|
|
||||||
## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`)
|
`RenameRecordingDialog.xaml` Height 230 → 276 → **304** (the +20% revealed the Cancel/Save row also
|
||||||
|
obscured); Save button named `SaveRecordingButton`. Test `RenameRecordingDialogSizingTests` asserts
|
||||||
Startup poll + tri-state (`ValidateWebcamResourceStartupAsync` after `LoadLayout`): 0 → run on,
|
textbox bottom + Save button bottom inside the client area. **Teardown leak fix:** `Shutdown()` now
|
||||||
no alarm; **1 → `AcquireAsync` as app-wide lock** (now always runs — see base-lock note above);
|
calls `_fullScreenDetector.StopWatching()` — the global `EVENT_SYSTEM_FOREGROUND` hook was never
|
||||||
**≥2 → no auto-lock** (App Settings selector
|
unhooked, invoking a GC'd `WinEventProc` on the next foreground event → test-host abort.
|
||||||
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.
|
|
||||||
|
|
||||||
## ⚠️ Open items
|
## ⚠️ Open items
|
||||||
|
|
||||||
- **RenameRecordingDialog too short — DONE (see below).**
|
- **Build-id on command-line exit (creator demand, 2026-09-20):** when the app exits under
|
||||||
- **Webcam take fix 2 of 2 — the 09:16 crash is UNFIXED (hard, untested):** hypothesis — native WMF
|
`dotnet run`, output the build number (`BuildStamp.Id` — a fresh per-build GUID, logged in
|
||||||
death when `MediaCapture.Failed` fires mid-stream and `SafeStopAsync` (fire-and-forget) disposes
|
`App.xaml.cs:32` as `Build {Id} (compiled {BuiltLocal})`) as return data on the command line.
|
||||||
reader+capture while the frame thread sits in `TryAcquireLatestFrame`/`Marshal.Copy` (outside the
|
NOT yet implemented — queued, do not fold into another slice.
|
||||||
frame's try/catch). No repro, no integration test, native race not catchable — deferred per
|
- **TASK 37** — defaults/current layout split (see above + `TASKS/task-37-defaults-current-split.md`).
|
||||||
spin-guard. Record a second occurrence's pattern.
|
- **Webcam take fix 2 of 2:** native WMF death when `MediaCapture.Failed` fires mid-stream and
|
||||||
- **Multi-camera selector slice (user-queued):** when ≥2 webcams exist, add web-cam selection text +
|
`SafeStopAsync` disposes reader+capture while the frame thread sits in `TryAcquireLatestFrame`. No
|
||||||
control to the **App Settings** dialog (gear icon, `OverlayHost` — left-click opens settings,
|
repro/test — deferred per spin-guard.
|
||||||
right-click the context menu). Selecting + successfully allocating/locking activates the WebCam
|
- **Multi-camera selector slice (user-queued):** ≥2 webcams → web-cam selection in the App Settings
|
||||||
layer. **Currently ≥2 cameras → nothing shows in settings.** Also: the "Change Webcam" picker
|
dialog (gear). Currently ≥2 cameras → nothing shows in settings.
|
||||||
should carry the OBS one-liner (one webcam per stream — a second means OBS).
|
- **Static (+) catalog slice (user-queued):** all catalog rows always listed, greyed with named
|
||||||
- **Static (+) catalog slice (user-queued):** all catalog rows always listed (Background, "Web Cam",
|
reason tooltips.
|
||||||
"Countdown Timer", "Web Resource", "YouTube Chat", "YouTube Event"…), gray only for (1) system-level
|
- **Device re-verify (one take session):** slice-18 cache verdicts; webcam fresh-launch with the
|
||||||
unavailability (persistent alert), (2) max already allocated, (3) not available for the current
|
browser holding the camera → red alert that clears after Retry once the browser closes; Live must
|
||||||
scene; tooltips must name the reason. Static labels only, no new code-behind.
|
OFFER Web Cam while Chat holds it.
|
||||||
- **Device re-verify (one take session):** 1. slice-18 cache verdicts (NR/WH hits, stalls, band
|
- **No push yet** — after the take verdict, decide push with the user.
|
||||||
~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.
|
|
||||||
|
|
||||||
## Open threads (carried)
|
## Open threads (carried)
|
||||||
|
|
||||||
- Audio-silence verification — fixed (`724af14`); creator heard real audio.
|
- 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).
|
- Sync control user-doc tutorial — REQUIRED before 1.0 (creator directive; TASK 22).
|
||||||
- Signed A/V sync: verify the negative (advance) direction on device.
|
- 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
|
## 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 —
|
- 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
|
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:
|
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
|
`Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — stale loopback sample in the pipe when the
|
||||||
when the mute flips (phase 2's `Assert.Equal(0f, f, 6)` sees 0.4). **Confirmed pre-existing and
|
mute flips. Unrelated; do not "fix" inside another slice.
|
||||||
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.
|
|
||||||
- **Assert footguns in this repo's xUnit:** `Assert.Null(x, "msg")`/`Assert.Single(coll, "msg")` do
|
- **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.
|
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
|
- 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`.
|
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
|
- 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`.
|
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.
|
- 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
|
- `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.
|
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 …
|
- **Test-host crash (fixed):** GC'd `WinEventProc` — `Shutdown()` unhooks the foreground-event hook;
|
||||||
`Win32FullScreenDetector+WinEventProc`" — `Shutdown()` now unhooks the foreground-event hook; never
|
never re-introduce `StartWatching` without matching teardown; dialogs with root-relative `/Assets/…`
|
||||||
re-introduce `StartWatching` without a matching teardown, and dialogs whose `Icon` is the
|
Icon can't be instantiated by tests (use `/ytLive;component/…`).
|
||||||
root-relative `/Assets/…` form can't be instantiated by tests (use `/ytLive;component/…`).
|
|
||||||
- **Scene.Elements layer tests:** any seeded scene with `HasBackground=1` loads a healed
|
- **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) —
|
`Background` element pinned at `Elements[0]` (created by `EnsureBackground` on `StagedScene` set) —
|
||||||
compute expected layer orders from the live collection, never hardcode indices.
|
compute expected layer orders from the live collection, never hardcode indices.
|
||||||
- **Real-input injection test** (`RealMouseDrag_OnTheLayerList_PersistsTheReorder`) needs an
|
- **Cross-type element order tests** must assert through a FRESH `LayoutStore` (the restart), not the
|
||||||
interactive desktop session: the window is shown Topmost and clicks are injected at real screen
|
running app's store — that's the read-back path the builder's bug exercised.
|
||||||
coords — a covered/locked session makes the pointer no-op and the drag never lands. Fine locally.
|
|
||||||
- `C:\tmpout` is for ffmpeg evidence artifacts; keep them out of the repo.
|
- `C:\tmpout` is for ffmpeg evidence artifacts; keep them out of the repo.
|
||||||
|
|
||||||
## Next step
|
## Next step
|
||||||
|
|
||||||
The multi-camera App Settings selector slice (+ static catalog rows after), then the creator's take
|
The ordering fix is the builder-facing half of the "webcam between sources" bug — a manual take
|
||||||
on this build. The webcam gate slice answers the creator's "go" ask — switching to the Live view
|
(relaunch, live view, drag Web Resource-0 below the webcam, restart, expect it pinned there) closes
|
||||||
must now offer Web Cam in the layer stack (Chat holding the camera no longer blocks it). The red
|
it. Then the copy with the builder on any remaining repro, then decide push. TASK 37 (defaults vs
|
||||||
Layers alert remains the new expected behavior to eyeball on the take: browser holds the camera →
|
current) and the build-id-on-exit demand are queued for a later slice — do not fold them in here.
|
||||||
fresh launch → alert (unless Chat's config grabbed it) that clears after a Retry once the browser
|
|
||||||
closes.
|
|
||||||
@@ -11,8 +11,8 @@ public partial class LayoutStore : IDisposable
|
|||||||
Socials = null;
|
Socials = null;
|
||||||
Music = null;
|
Music = null;
|
||||||
var scenes = new List<Scene>();
|
var scenes = new List<Scene>();
|
||||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
var sourcesByScene = new Dictionary<string, List<(Source Source, int Sort)>>();
|
||||||
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
|
var configsByScene = new Dictionary<string, List<(WebcamSceneConfig Config, int Sort)>>();
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
@@ -99,7 +99,7 @@ public partial class LayoutStore : IDisposable
|
|||||||
IsBackground, CaptureKey, ShowDesktop,
|
IsBackground, CaptureKey, ShowDesktop,
|
||||||
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
||||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState
|
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState, SortOrder
|
||||||
FROM Source ORDER BY SortOrder
|
FROM Source ORDER BY SortOrder
|
||||||
""";
|
""";
|
||||||
using var reader = cmd.ExecuteReader();
|
using var reader = cmd.ExecuteReader();
|
||||||
@@ -147,8 +147,8 @@ public partial class LayoutStore : IDisposable
|
|||||||
: MediaPlaybackState.Stopped,
|
: MediaPlaybackState.Stopped,
|
||||||
};
|
};
|
||||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||||
sourcesByScene[sceneId] = list = new List<Source>();
|
sourcesByScene[sceneId] = list = new List<(Source, int)>();
|
||||||
list.Add(source);
|
list.Add((source, reader.GetInt32(30)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ public partial class LayoutStore : IDisposable
|
|||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||||
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||||
RectWidth, RectHeight
|
RectWidth, RectHeight, SortOrder
|
||||||
FROM WebcamSceneConfig ORDER BY SortOrder
|
FROM WebcamSceneConfig ORDER BY SortOrder
|
||||||
""";
|
""";
|
||||||
using var reader = cmd.ExecuteReader();
|
using var reader = cmd.ExecuteReader();
|
||||||
@@ -184,19 +184,30 @@ public partial class LayoutStore : IDisposable
|
|||||||
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
|
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
|
||||||
};
|
};
|
||||||
if (!configsByScene.TryGetValue(sceneId, out var list))
|
if (!configsByScene.TryGetValue(sceneId, out var list))
|
||||||
configsByScene[sceneId] = list = new List<WebcamSceneConfig>();
|
configsByScene[sceneId] = list = new List<(WebcamSceneConfig, int)>();
|
||||||
list.Add(config);
|
list.Add((config, reader.GetInt32(16)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var scene in scenes)
|
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))
|
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
|
||||||
foreach (var source in sources)
|
foreach (var (source, sort) in sources)
|
||||||
scene.Elements.Add(source);
|
ordered.Add((sort, source));
|
||||||
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
||||||
foreach (var config in configs)
|
foreach (var (config, sort) in configs)
|
||||||
scene.Elements.Add(config);
|
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);
|
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||||
if (bg != null)
|
if (bg != null)
|
||||||
|
|||||||
@@ -129,12 +129,16 @@ public partial class LayoutStore : IDisposable
|
|||||||
var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
|
var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
|
||||||
var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
|
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)
|
foreach (var scene in scenes)
|
||||||
{
|
{
|
||||||
var sort = 0;
|
var z = 0;
|
||||||
foreach (var element in scene.Elements)
|
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;
|
idP.Value = source.Id;
|
||||||
sceneIdP.Value = scene.Id;
|
sceneIdP.Value = scene.Id;
|
||||||
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
||||||
@@ -152,7 +156,7 @@ public partial class LayoutStore : IDisposable
|
|||||||
isBackgroundP.Value = source.IsBackground ? 1 : 0;
|
isBackgroundP.Value = source.IsBackground ? 1 : 0;
|
||||||
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
|
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
|
||||||
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
|
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
|
||||||
sortP.Value = sort++;
|
sortP.Value = z++;
|
||||||
chatFontSizeP.Value = source.ChatFontSize;
|
chatFontSizeP.Value = source.ChatFontSize;
|
||||||
chatFontColorP.Value = source.ChatFontColor;
|
chatFontColorP.Value = source.ChatFontColor;
|
||||||
chatBgColorP.Value = source.ChatBackgroundColor;
|
chatBgColorP.Value = source.ChatBackgroundColor;
|
||||||
@@ -215,9 +219,10 @@ public partial class LayoutStore : IDisposable
|
|||||||
|
|
||||||
foreach (var scene in scenes)
|
foreach (var scene in scenes)
|
||||||
{
|
{
|
||||||
var sort = 0;
|
var z = 0;
|
||||||
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
foreach (var element in scene.Elements)
|
||||||
{
|
{
|
||||||
|
if (element is not WebcamSceneConfig config) { z++; continue; }
|
||||||
sceneIdP.Value = scene.Id;
|
sceneIdP.Value = scene.Id;
|
||||||
webcamIdP.Value = config.WebcamId;
|
webcamIdP.Value = config.WebcamId;
|
||||||
visibleP.Value = config.IsVisible ? 1 : 0;
|
visibleP.Value = config.IsVisible ? 1 : 0;
|
||||||
@@ -234,7 +239,7 @@ public partial class LayoutStore : IDisposable
|
|||||||
animationP.Value = config.BorderAnimation.ToString();
|
animationP.Value = config.BorderAnimation.ToString();
|
||||||
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
|
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
|
||||||
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
|
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
|
||||||
sortP.Value = sort++;
|
sortP.Value = z++;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) |
|
| 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) |
|
| 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) |
|
| 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
|
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 (+)
|
slices (user-queued): multi-camera webcam selector in the App Settings dialog (gear), static (+)
|
||||||
catalog rows (Background, YouTubeEvent, quoted labels) with reason-greying.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
- **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
|
- 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
|
- 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.
|
- **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)
|
- 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
|
- 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);
|
_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>
|
/// <summary>
|
||||||
/// Same use case as above but driven with REAL injected mouse input
|
/// Same use case as above but driven with REAL injected mouse input
|
||||||
/// (SetCursorPos + mouse_event against the desktop) so the actual
|
/// (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
|
/// <summary>Pumps the app dispatcher (so the VM's debounce timer fires) until
|
||||||
/// <paramref name="isDone"/> or the deadline passes. True when the condition
|
/// <paramref name="isDone"/> or the deadline passes. True when the condition
|
||||||
/// became true, false on deadline.</summary>
|
/// became true, false on deadline.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user