The creator reported that clicking Cancel in the post-recording save prompt still
saved the file under the default name: "cancel should cancel the save option,
discarding the recording" — Enter is what accepts the default.
The modal was always correct (Enter -> DialogResult=true, Cancel/Escape/X -> false).
The bug was in the caller: FinalizeRecordingAsync only overwrote `stem` when the
dialog returned true and then ran File.Move UNCONDITIONALLY, so a falsy result fell
straight through into the save. The nullable stem made "I don't want this" look
like "I accept the default".
Extracted the decision into internal MainViewModel.CompleteRecordingSave so it is
testable against real files without a frame pump:
creatorSaved == false (Cancel/Escape/X) -> delete the temp file, leave the
videos folder empty; a failed delete returns DiscardFailed and the toast
names the file + folder (a "discarded" recording still on disk is worse
than no report).
creatorSaved == true (Save, or Enter on the pre-filled default) -> File.Move.
A blank box still means "keep the auto name", but only on an explicit Save.
Test: ytLive.Tests/RecordingSaveDialogTests.cs — 5 facts over real temp files. The
headline asserts Cancel leaves the directory EMPTY, not merely "the stem is
unchanged"; a modal's own test cannot cover the decision it feeds.
Full suite 362/362 (the RealMouseDrag flake passed this run).
7.2 KiB
HANDOFF — current state
Branch: main (pre-1.0, no feature branches). Last pushed: 7f14ffb.
This session's work is COMMITTED LOCALLY, NOT PUSHED — four commits ahead of origin/main
(fae8ec5, 5aedca7, f5a9d46, de81fa3) plus this unit. Push only when the creator says so.
The 2026-09-26 proof-of-concept round (creator reporting)
Context: no YouTube private test recordings are being saved, so the creator is judging
compositing from local recordings. They assumed the live path needed a separate "redirect" —
it does not, and that is verified, not assumed: one _framePump is constructed in the
MainViewModel ctor with a single brandFlash: callback, and both Streaming.Operations.cs:68
(go-live) and :199 (record) call that same StartAsync. Record+simulcast is one ffmpeg with two
outputs. Five items, tracked in TASKS.md → "Creator-reported batch".
Done this unit: recording save dialog — Cancel now discards
RenameRecordingDialog was always correct (Enter → DialogResult=true, Cancel/X/Escape → false).
The caller was not: FinalizeRecordingAsync only overwrote stem when the dialog returned true
and then ran File.Move unconditionally, so a cancelled dialog silently saved the recording
under the default name. Extracted the decision into internal
MainViewModel.CompleteRecordingSave(startPath, dir, autoStem, chosenStem, creatorSaved):
creatorSaved == false(Cancel/Escape/X) → delete the temp file; the videos folder is left empty. A failed delete returnsDiscardFailedand the toast names the file + folder.creatorSaved == true(Save, or Enter on the pre-filled default) →File.Moveto the final name. Blank box still means "keep the auto name" — but only on an explicit Save.
Test: ytLive.Tests/RecordingSaveDialogTests.cs (5 facts, real temp files, no WPF needed) — the
headline asserts Cancel leaves the directory empty, not merely "the stem is unchanged".
Lesson recorded in MyMistakes.md (falsy ShowDialog() falling through into a side effect).
Two earlier work units (committed)
1. Branding flash is now composited into the OUTPUT (TASK 36 shipped)
It used to be a WPF BrandFlashLayer TextBlock in the preview at 25% opacity on a 300s timer — a
credit the creator could see and no viewer ever could. Now one rendered VideoFrame goes to
both the frame pump and the preview, so they cannot drift:
Services/Compositor/BrandFlashPresenter.cs(new) — neon raster (white core + feathered red/blue halo, channels split opposite), 2s envelope, random on-canvas placement per presentation, licence gate re-checked on every read.ViewModels/MainViewModel.BrandFlash.cs(new) —BrandFlashFrame()for the pump +PublishBrandFlashPreview()for the pane.FramePumpgainedbrandFlash:→ the existing per-frameflashFrameslot, and mixes it into the C4 cache signature.SceneCompositor.Overlayis nowinternalfor the shortcut path.PreviewPane.xaml:BrandFlashLayer→BrandFlashElement(bound to the published frame).BrandFlashEnabledis derived!IsPremiumand no longer assignable.
Spec met: 500ms in / 1000ms hold / 500ms out, first credit ~5s after go-live then every
rand(30s)+30s, single unwrapped line, random spot every time, fully on canvas.
2. Dev-only: two instances side by side
Helpers/InstanceProfile.cs (new). Set YTLIVE_INSTANCE=<id> and that process gets a private
%APPDATA%\ytLlive\instances/<id>/ root for the layout DB, auth file, startup.log and the
WebView2 user data folder. Chromium locks that folder exclusively — without this the second
instance does not start at all. Recording folder and the ffmpeg tools cache stay shared on
purpose; global hotkeys stay un-namespaced.
Usage: $env:YTLIVE_INSTANCE=2; dotnet run
The entire implementation is inside #if DEBUG. Release compiles to DataRoot => DefaultRoot +
WebViewDataFolder => null; the call sites are unconditional so Release cannot drift.
Bugs found and fixed along the way
- Pre-existing, not mine:
FramePump's fully-static shortcut stretched the cached bake and returned it, silently discarding every per-frame overlay — the social bar and the alert ticker were already lost there, not just the flash. Now composites overlays onto a copy before stretching. - Cadence bug caught by my own test: the interval was an absolute deadline, not a period, so gaps collapsed (6.7s / 3.5s / 2.8s instead of 30–60s). Fixed.
BrandFlashPresenterrecycles one 8MB master buffer and must stampVideoFrame.Epochper read, or the paste cache freezes the credit. It does.
Test state — VERIFIED
- 357 total, 356 pass. 18 new facts (10 brand flash, 8 instance isolation).
- The one failure is
LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder, and it is flaky by environment, not a regression — 2 fail / 1 pass in isolation. Its own doc comment: "Requires an interactive desktop session: if the window is covered or the session is locked, the pointer no-ops and the drag never lands." The symptom matches exactly (order unchanged). Run the suite with ytLive CLOSED (seeMyMistakes.md2026-08 era note). RealAppHost.RunAsyncwas added for this: a frame-pump test MUSTawaitinside it. A blocking wait inRealAppHost.Runoccupies the one shared STA thread and hangs the whole suite with no output. Always background avstestrun and poll the log — foreground pipedvstestreturns nothing in this shell even on success.
Landmines
- A stale
ytLive.exe(PID 2544) lockedbin/.../ytLive.exeand brokedotnet runwith MSB3027. Killed. If the build fails to copyytLive.exe, check for a running instance first. GlobalHotkeys: two instances registering the SAME global hotkey — Windows refuses the second. Left alone deliberately.OverlayHost.xamlsays Polar unlocks alerts too; alerts are not gated. Unresolved, queued.
Next
- Ungate the brand flash from
IsLive(TASK batch #2) — it must run in every scene and appear in recordings; today it only starts inUpdateLiveVisuals()'s live branch, so a recording made without ever going live carries no credit. - Confine the ticker to the alert box (#3) — it is still a global 1920×48 top overlay
(
BlitOverlay(…, 0, 0)); needs a rect + scale/clip decision in both preview and output. - Multi-instance (#4) — route
FfmpegLocator._toolsDirthroughInstanceProfile, and confirm the launch method:dotnet runwhile the first app holdsbin/…/ytLive.exefails with MSB3027 before any instance starts (a build-output lock, not a log conflict).$env:YTLIVE_INSTANCE=2dotnet run --no-buildin a second shell is the known-good path.
- Post-session efficacy report (#5) — roll up
CurrentHealth(dropped frames, duration, health message) when a stream or recording ends. - Push the commits when the creator asks.
- Test-console chat UX (queued): don't clear the chat window, 20px right padding on the pull-out input, Enter inserts a newline.
- Decide the alert-gating copy question above.
- Ship-checklist items live in
TASKS.md→ "1.0 gates".